diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index 32d941c..fdf06dd 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -83,6 +83,7 @@ RUN ARCH="$(uname -m)"; \ /build/src/key_store.c \ /build/src/socket_name.c \ /build/src/auth_envelope.c \ + /build/src/miner.c \ /build/resources/tui_continuous/tui_continuous.c \ "$NOSTR_LIB" \ -o /build/nsigner_static \ diff --git a/Makefile b/Makefile index ee83814..e7864b3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ CC := gcc -CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous +CFLAGS := -Wall -Wextra -std=c99 -Os -ffunction-sections -fdata-sections -DNOSTR_ENABLE_NSIGNER_CLIENT=1 -D_GNU_SOURCE -Isrc -Iresources/nostr_core_lib -Iresources/nostr_core_lib/nostr_core -Iresources/nostr_core_lib/cjson -Iresources/tui_continuous LDFLAGS := -Wl,--gc-sections resources/nostr_core_lib/libnostr_core_x64.a -lz -ldl -lpthread -lm -lssl -lcrypto -lcurl -lsecp256k1 SRC_DIR := src @@ -24,6 +24,7 @@ SOURCES := \ $(SRC_DIR)/key_store.c \ $(SRC_DIR)/socket_name.c \ $(SRC_DIR)/auth_envelope.c \ + $(SRC_DIR)/miner.c \ resources/tui_continuous/tui_continuous.c HEADERS := @@ -40,18 +41,19 @@ TEST_SOCKET_NAME_TARGET := $(BUILD_DIR)/test_socket_name TEST_AUTH_ENVELOPE_TARGET := $(BUILD_DIR)/test_auth_envelope TEST_QREXEC_AUTH_TARGET := $(BUILD_DIR)/test_qrexec_auth TEST_MNEMONIC_INPUT_TARGET := $(BUILD_DIR)/test_mnemonic_input +TEST_MINE_EVENT_TARGET := $(BUILD_DIR)/test_mine_event EXAMPLE_GET_PUBLIC_KEY_TARGET := $(BUILD_DIR)/example_get_public_key_client EXAMPLE_SIGN_EVENT_TARGET := $(BUILD_DIR)/example_sign_event_client EXAMPLE_GET_PUBKEY_TCP_TARGET := $(BUILD_DIR)/example_get_pubkey_tcp EXAMPLE_GET_PUBKEY_QREXEC_TARGET := $(BUILD_DIR)/example_get_pubkey_qrexec DEMO_C99_TARGET := $(BUILD_DIR)/demo_c99 -.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth examples test-client clean +.PHONY: all lib dev static static-debug static-arm64 firmware-feather test test-integration test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event examples test-client clean all: dev lib: - cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,19,44 + cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,13,19,44 dev: lib $(TARGET_DEV) @@ -74,7 +76,7 @@ static-arm64: firmware-feather: cd firmware/feather_s3_tft && idf.py build -test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-client +test: lib test-mnemonic test-mnemonic-input test-role test-selector test-enforcement test-dispatcher test-policy test-socket-name test-auth-envelope test-qrexec-auth test-mine-event test-client test-integration: $(TEST_INTEGRATION_TARGET) $(TARGET_DEV) ./$(TEST_INTEGRATION_TARGET) @@ -109,6 +111,9 @@ test-auth-envelope: $(TEST_AUTH_ENVELOPE_TARGET) test-qrexec-auth: $(TEST_QREXEC_AUTH_TARGET) $(TARGET_DEV) ./$(TEST_QREXEC_AUTH_TARGET) +test-mine-event: $(TEST_MINE_EVENT_TARGET) $(TARGET_DEV) + ./$(TEST_MINE_EVENT_TARGET) + test-client: examples examples: $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(EXAMPLE_SIGN_EVENT_TARGET) $(EXAMPLE_GET_PUBKEY_TCP_TARGET) $(EXAMPLE_GET_PUBKEY_QREXEC_TARGET) $(DEMO_C99_TARGET) @@ -133,9 +138,9 @@ $(TEST_ENFORCEMENT_TARGET): $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcemen @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(TEST_DIR)/test_enforcement.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c -o $(TEST_ENFORCEMENT_TARGET) $(LDFLAGS) -$(TEST_DISPATCHER_TARGET): $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c +$(TEST_DISPATCHER_TARGET): $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_DISPATCHER_TARGET) $(LDFLAGS) + $(CC) $(CFLAGS) $(TEST_DIR)/test_dispatcher.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/key_store.c $(SRC_DIR)/miner.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_DISPATCHER_TARGET) $(LDFLAGS) $(TEST_POLICY_TARGET): $(TEST_DIR)/test_policy.c $(SRC_DIR)/policy.c @mkdir -p $(BUILD_DIR) @@ -157,6 +162,10 @@ $(TEST_QREXEC_AUTH_TARGET): $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envel @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(TEST_DIR)/test_qrexec_auth.c $(SRC_DIR)/auth_envelope.c -o $(TEST_QREXEC_AUTH_TARGET) $(LDFLAGS) +$(TEST_MINE_EVENT_TARGET): $(TEST_DIR)/test_mine_event.c $(SRC_DIR)/miner.c $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c + @mkdir -p $(BUILD_DIR) + $(CC) $(CFLAGS) $(TEST_DIR)/test_mine_event.c $(SRC_DIR)/miner.c $(SRC_DIR)/key_store.c $(SRC_DIR)/dispatcher.c $(SRC_DIR)/selector.c $(SRC_DIR)/enforcement.c $(SRC_DIR)/role_table.c $(SRC_DIR)/mnemonic.c $(SRC_DIR)/secure_mem.c -o $(TEST_MINE_EVENT_TARGET) $(LDFLAGS) + $(EXAMPLE_GET_PUBLIC_KEY_TARGET): $(EXAMPLES_DIR)/get_public_key_client.c @mkdir -p $(BUILD_DIR) $(CC) $(CFLAGS) $(EXAMPLES_DIR)/get_public_key_client.c -o $(EXAMPLE_GET_PUBLIC_KEY_TARGET) $(LDFLAGS) diff --git a/README.md b/README.md index e52f60c..a8744b3 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ Request shape is JSON-RPC with NIP-46-style methods and optional trailing select { "id": "2", "method": "sign_event", "params": ["", { "role": "main" }] } { "id": "3", "method": "sign_event", "params": ["", { "nostr_index": 7 }] } { "id": "4", "method": "sign_event", "params": ["", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] } +{ "id": "5", "method": "mine_event", "params": ["", { "difficulty": 20, "threads": 4, "timeout_sec": 30, "nostr_index": 0 }] } ``` Implemented signer verbs in this build: @@ -186,6 +187,42 @@ Implemented signer verbs in this build: - `sign_event` - `nip04_encrypt` / `nip04_decrypt` - `nip44_encrypt` / `nip44_decrypt` +- `mine_event` — add NIP-13 proof-of-work and sign (see below) + +### `mine_event` — NIP-13 Proof-of-Work + +Mines proof-of-work (adds a `nonce` tag per NIP-13) and signs the event in one step. The mining runs in a detached thread so the server stays responsive. + +**Parameters (in options object):** + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `difficulty` | One of difficulty/timeout | 0 (no target) | Target leading zero bits. Stops early if reached. | +| `timeout_sec` | One of difficulty/timeout | 600 (safety) | Time budget in seconds. Always returns best result found. | +| `threads` | No | 1 | Number of mining threads (max 32). | + +At least one of `difficulty` or `timeout_sec` must be specified. If both are given, mining stops when either condition is met. The response always includes the best event found — timeout is not an error. + +**Response format:** + +```json +{ + "id": "5", + "result": { + "event": "", + "achieved_difficulty": 18, + "target_difficulty": 20, + "target_reached": false, + "elapsed_sec": 30, + "attempts": 4523456 + } +} +``` + +**Error codes:** + +- `1007` — `no_termination_condition` (neither difficulty nor timeout_sec specified) +- `1008` — `mining_failed` (internal error) Selector resolution order: @@ -215,6 +252,7 @@ Selector resolution chooses *which* role. Enforcement decides *whether the reque Example: - `sign_event` requires `purpose="nostr"` and `curve="secp256k1"`. +- `mine_event` requires `purpose="nostr"` and `curve="secp256k1"` (same as `sign_event`). - If caller selects a Bitcoin-role key for `sign_event`, request fails with `purpose_mismatch`. This prevents cross-protocol misuse inside one mnemonic-rooted signer process. diff --git a/client/demo_c99.c b/client/demo_c99.c index e490d63..dfc96e6 100644 --- a/client/demo_c99.c +++ b/client/demo_c99.c @@ -6,6 +6,10 @@ * 2. sign_event — sign a Nostr event (kind 1 text note) * 3. nip44_encrypt — encrypt a message to a peer (and decrypt it back) * + * Note: mine_event (NIP-13 PoW) is also available via the JSON-RPC interface. + * See demo_javascript.js and demo_python.py for mine_event usage examples. + * The high-level nostr_signer API does not yet wrap mine_event. + * * This uses the high-level nostr_signer API from nostr_core_lib: * - nostr_signer_nsigner_qrexec() — qrexec transport (no network) * - nostr_signer_nsigner_set_nostr_index() — select key by NIP-06 index diff --git a/client/demo_javascript.js b/client/demo_javascript.js index 647f4e4..08f1799 100644 --- a/client/demo_javascript.js +++ b/client/demo_javascript.js @@ -198,6 +198,48 @@ async function demoNip44(targetQube, nostrIndex, pubkeyHex) { } } +async function demoMineEvent(targetQube, nostrIndex) { + console.log("\n--- Demo 4: mine_event (NIP-13 Proof-of-Work) ---"); + + const event = { + kind: 1, + content: "Hello Nostr with PoW!", + tags: [], + }; + + console.log(" Mining with difficulty=4, threads=4, timeout_sec=30..."); + const response = await callNsigner(targetQube, { + id: "5", + method: "mine_event", + params: [JSON.stringify(event), { + difficulty: 4, + threads: 4, + timeout_sec: 30, + nostr_index: nostrIndex, + }], + }); + + if (response.error) { + throw new Error(`mine_event failed: ${JSON.stringify(response.error)}`); + } + + const result = JSON.parse(response.result); + console.log(` achieved_difficulty: ${result.achieved_difficulty}`); + console.log(` target_reached: ${result.target_reached}`); + console.log(` elapsed_sec: ${result.elapsed_sec}`); + console.log(` attempts: ${result.attempts}`); + + const minedEvent = JSON.parse(result.event); + console.log(` event id: ${minedEvent.id}`); + console.log(` nonce tag: ${JSON.stringify(minedEvent.tags[0])}`); + + if (result.target_reached) { + console.log(" ✓ Target difficulty reached!"); + } else { + console.log(` (Target not reached, best effort: ${result.achieved_difficulty} bits)`); + } +} + async function main() { const targetQube = process.argv[2] || "nostr_signer"; const nostrIndex = parseInt(process.argv[3] || "0", 10); @@ -212,6 +254,7 @@ async function main() { const pubkeyHex = await demoGetPublicKey(targetQube, nostrIndex); await demoSignEvent(targetQube, nostrIndex, pubkeyHex); await demoNip44(targetQube, nostrIndex, pubkeyHex); + await demoMineEvent(targetQube, nostrIndex); console.log("\n=== Summary ==="); console.log("All demos completed successfully."); diff --git a/client/demo_python.py b/client/demo_python.py index 4a010d9..d7f638c 100644 --- a/client/demo_python.py +++ b/client/demo_python.py @@ -227,6 +227,45 @@ def demo_nip44(target_qube, nostr_index, pubkey_hex): raise RuntimeError("Round-trip FAILED: plaintext does not match decrypted") +def demo_mine_event(target_qube, nostr_index): + print("\n--- Demo 4: mine_event (NIP-13 Proof-of-Work) ---") + + event = {"kind": 1, "content": "Hello Nostr with PoW!", "tags": []} + + print(" Mining with difficulty=4, threads=4, timeout_sec=30...") + response = call_nsigner( + target_qube, + { + "id": "5", + "method": "mine_event", + "params": [json.dumps(event), { + "difficulty": 4, + "threads": 4, + "timeout_sec": 30, + "nostr_index": nostr_index, + }], + }, + ) + + if "error" in response: + raise RuntimeError(f"mine_event failed: {json.dumps(response['error'])}") + + result = json.loads(response["result"]) + print(f" achieved_difficulty: {result['achieved_difficulty']}") + print(f" target_reached: {result['target_reached']}") + print(f" elapsed_sec: {result['elapsed_sec']}") + print(f" attempts: {result['attempts']}") + + mined_event = json.loads(result["event"]) + print(f" event id: {mined_event['id']}") + print(f" nonce tag: {mined_event['tags'][0]}") + + if result["target_reached"]: + print(" ✓ Target difficulty reached!") + else: + print(f" (Target not reached, best effort: {result['achieved_difficulty']} bits)") + + # --- Main --- def main(): @@ -243,6 +282,7 @@ def main(): pubkey_hex = demo_get_public_key(target_qube, nostr_index) demo_sign_event(target_qube, nostr_index, pubkey_hex) demo_nip44(target_qube, nostr_index, pubkey_hex) + demo_mine_event(target_qube, nostr_index) print("\n=== Summary ===") print("All demos completed successfully.") diff --git a/plans/mine_event_pow.md b/plans/mine_event_pow.md new file mode 100644 index 0000000..5efcb86 --- /dev/null +++ b/plans/mine_event_pow.md @@ -0,0 +1,430 @@ +# Plan: Add `mine_event` Verb (NIP-13 Proof-of-Work) to n_signer + +## Executive Summary + +**Recommendation: Do NOT import event_miner as a subrepo or copy its code.** + +n_signer already has [`nip013.h`](resources/nostr_core_lib/nostr_core/nip013.h:1) and [`nip013.c`](resources/nostr_core_lib/nostr_core/nip013.c:1) in its `resources/nostr_core_lib/`. event_miner is a standalone CLI tool (~716 lines) whose useful logic is ~50 lines. The rest is CLI parsing, stdin/file I/O, signal handlers, and `exit()` calls that are inappropriate for a long-running server. + +Instead, we add a new `mine_event` verb to n_signer's existing dispatcher that: +1. Mines PoW for a time budget (e.g., "mine for 30 seconds with 4 threads") +2. Returns the **best result found** within that time — regardless of whether the target difficulty was reached +3. Signs the event with the role's derived private key +4. Returns the mined + signed event JSON along with metadata about the achieved difficulty + +## Why Not Subrepo or Copy? + +| Factor | Subrepo | Copy | New verb (recommended) | +|--------|---------|------|----------------------| +| Key management | event_miner takes raw nsec CLI arg | same | Uses n_signer's role table + key_store (secure, mlock'd) | +| Architecture | CLI tool, calls exit() | same | Fits JSON-RPC dispatcher/enforcement/policy model | +| Threading | Global vars, signal handlers, exit() | same | Clean detached thread per request, no globals | +| Best-effort model | No — stops at target or max_attempts | same | Yes — returns best result within time budget | +| Code reuse | ~50 lines useful | same | Rewrite ~150 lines for server-safe best-effort model | +| Maintenance | Extra submodule to track | Drift risk | Single codebase, single nostr_core_lib | + +## Design Decisions + +- **Verb name**: `mine_event` +- **Params**: `[event_json, {difficulty: N, threads: N, timeout_sec: N, ...role_selector}]` +- **Dual termination model**: Both `timeout_sec` and `difficulty` are independent options. Either, both, or at least one must be specified: + - **Both set**: Mine until target reached OR timeout — return best result (with `target_reached` flag) + - **Only `timeout_sec`**: Mine for the full duration, return best result found + - **Only `difficulty`**: Mine until target reached, with a safety max timeout (e.g., 10 minutes) to prevent infinite mining + - **Neither**: Error — must specify at least one termination condition +- **Best-effort return**: The response always includes the best event found, plus metadata (`achieved_difficulty`, `target_difficulty`, `target_reached`, `elapsed_sec`, `attempts`) +- **Threading**: Spawn a detached pthread for mining. Server stays responsive. Mining thread writes result to client socket when done. +- **Key source**: Uses the role's derived private key from `key_store` (same as `sign_event`) + +## Why Our Own Mining Loop (Not `nostr_add_proof_of_work()`) + +The existing [`nostr_add_proof_of_work()`](resources/nostr_core_lib/nostr_core/nip013.c:121) has two problems for the best-effort model: + +1. **Discards work on failure**: It returns `NOSTR_ERROR_CRYPTO_FAILED` if `max_attempts` is exceeded without reaching the target. The best nonce found is lost. +2. **No time-based termination**: It uses `max_attempts` (a count), not a time deadline. For a "mine for 30 seconds" model, we need time-based termination. + +Instead, `miner.c` implements its own mining loop that: +- Iterates nonces across multiple threads +- Uses [`nostr_create_and_sign_event()`](resources/nostr_core_lib/nostr_core/nip001.h:1) from nip001 to sign each attempt +- Uses [`nostr_calculate_pow_difficulty()`](resources/nostr_core_lib/nostr_core/nip013.h:42) from nip013 to check difficulty +- Tracks the best event (highest difficulty) across all threads +- Stops when target reached OR timeout +- Returns the best event with metadata + +This reuses the library's crypto/JSON primitives without modifying the shared library. + +## Request/Response Format + +### Request +```json +{ + "id": "req-1", + "method": "mine_event", + "params": [ + "{\"kind\":1,\"content\":\"Hello Nostr!\",\"tags\":[],\"created_at\":1723666800}", + { + "difficulty": 20, + "threads": 4, + "timeout_sec": 30, + "role": "main" + } + ] +} +``` + +- `difficulty` (optional): Target leading zero bits. If reached, mining stops early. If not specified, mining runs until timeout. +- `threads` (optional, default: 1): Number of mining threads. +- `timeout_sec` (optional): How long to mine in seconds. If not specified, mining runs until target difficulty is reached (with a safety max of 10 minutes). +- **At least one of `difficulty` or `timeout_sec` must be specified.** If neither is provided, returns error `1007` (`mining_failed` / `no_termination_condition`). +- **If only `difficulty` is specified**: A safety max timeout of 600 seconds (10 min) is applied to prevent infinite mining. +- **If only `timeout_sec` is specified**: Mining runs the full duration and returns the best result found. +- **If both are specified**: Mining stops when either condition is met (target reached OR timeout elapsed). + +### Success Response (best-effort, always returned if mining ran) +```json +{ + "id": "req-1", + "result": { + "event": "{\"kind\":1,\"content\":\"Hello Nostr!\",\"pubkey\":\"...\",\"id\":\"00000...\",\"sig\":\"...\",\"tags\":[[\"nonce\",\"12345\",\"20\"]],\"created_at\":1755197090}", + "achieved_difficulty": 18, + "target_difficulty": 20, + "target_reached": false, + "elapsed_sec": 30, + "attempts": 4523456 + } +} +``` + +The `result` is now a JSON **object** (not a string like other verbs) containing the signed event plus mining metadata. The `event` field within it is the signed event JSON string. This is a departure from the other verbs that return a plain string result, but the metadata is essential for the client to know whether the target was met. + +### Error Responses +- `1007` - `mining_failed` — internal error (invalid event, bad params, crypto failure) +- `1008` - `mining_busy` — another mining operation is already running (optional: reject or queue) +- Existing error codes (1001-1006, -326xx) apply as usual for policy/selector/enforcement errors + +Note: Timeout is NOT an error — it's the normal termination condition. The best result is always returned. + +## Architecture Diagram + +```mermaid +flowchart TD + Client -->|JSON-RPC mine_event| ServerHandleOne + ServerHandleOne -->|policy_check and enforce_verb_role| PolicyEnforcement + PolicyEnforcement -->|ALLOWED| SpawnDetachedThread + ServerHandleOne -->|returns immediately| ServerLoop + SpawnDetachedThread --> MinerCoordinator + MinerCoordinator -->|spawn N worker threads| Worker1 + MinerCoordinator -->|spawn N worker threads| Worker2 + MinerCoordinator -->|spawn N worker threads| WorkerN + Worker1 -->|nostr_create_and_sign_event| NIP001Lib + Worker2 -->|nostr_create_and_sign_event| NIP001Lib + WorkerN -->|nostr_create_and_sign_event| NIP001Lib + NIP001Lib -->|signed event with nonce| CalcDifficulty + CalcDifficulty -->|nostr_calculate_pow_difficulty| NIP013Lib + NIP013Lib -->|difficulty bits| TrackBest + TrackBest -->|mutex protected| BestEvent + MinerCoordinator -->|timeout or target reached| JoinThreads + JoinThreads -->|best event + metadata| BuildResponse + BuildResponse -->|transport_send_framed| Client +``` + +## Implementation Steps + +### Step 1: Add nip013 to nostr_core_lib build + +The Makefile's `lib` target runs: +``` +cd resources/nostr_core_lib && ./build.sh --nips=1,4,6,19,44 +``` +Add `13` to the `--nips` flag so nip013.c is compiled into the static library. + +**Files to modify:** +- [`Makefile`](Makefile:54) — change `--nips=1,4,6,19,44` to `--nips=1,4,6,13,19,44` + +### Step 2: Add VERB_MINE_EVENT to enforcement.c + +Add `mine_event` to the known nostr verbs so it passes enforcement. + +**Files to modify:** +- [`src/enforcement.c`](src/enforcement.c:200) — add `#define VERB_MINE_EVENT "mine_event"` and add it to [`is_nostr_verb()`](src/enforcement.c:453) +- [`src/dispatcher.c`](src/dispatcher.c:200) — add the same `#define VERB_MINE_EVENT "mine_event"` (the headerless decls pattern means defines are repeated per-file) + +### Step 3: Create src/miner.c — Multithreaded Best-Effort Mining Coordinator + +This is the core new file. It implements a server-safe, best-effort mining loop. + +**Key design:** +- No global variables (all state in context structs) +- No `exit()` calls (return result codes) +- No signal handlers (server handles signals) +- Time-based termination (not attempt-count-based) +- Tracks best event across all threads +- Returns best event + metadata regardless of whether target was reached + +**Key structures:** +```c +typedef struct { + cJSON *best_event; /* best event found so far (mutex-protected) */ + int best_difficulty; /* difficulty of best_event */ + uint64_t total_attempts; /* total attempts across all threads */ + int target_difficulty; /* 0 = no target, mine full timeout */ + int target_reached; /* 1 if target was reached */ + pthread_mutex_t mutex; /* protects best_event, best_difficulty, total_attempts */ + volatile int stop; /* set by coordinator when target reached or timeout */ +} mine_shared_state_t; + +typedef struct { + mine_shared_state_t *shared; + cJSON *event_template; /* this thread's copy of the event */ + unsigned char private_key[32]; + int thread_id; + uint64_t nonce_start; /* starting nonce for this thread (thread_id * stride) */ + uint64_t nonce_stride; /* increment per iteration to avoid overlap */ + time_t deadline; /* absolute time to stop */ + uint64_t attempts; /* this thread's attempt count */ +} miner_worker_ctx_t; + +typedef struct { + cJSON *best_event; /* caller frees */ + int achieved_difficulty; + int target_difficulty; + int target_reached; + int elapsed_sec; + uint64_t total_attempts; +} mine_result_t; +``` + +**Main function:** +```c +/* Returns 0 on success (result populated with best event found), -1 on error */ +int miner_run(cJSON *event, const unsigned char *private_key, + int target_difficulty, int thread_count, int timeout_sec, + mine_result_t *result); +``` + +**Mining loop (per thread):** +```c +while (!shared->stop && time(NULL) < deadline) { + /* Build event with current nonce */ + cJSON *working_tags = cJSON_Duplicate(original_tags, 1); + update_nonce_tag(working_tags, nonce, target_difficulty); + + cJSON *signed_event = nostr_create_and_sign_event(kind, content, working_tags, + private_key, timestamp); + cJSON_Delete(working_tags); + + /* Check difficulty */ + const char *id = cJSON_GetStringValue(cJSON_GetObjectItem(signed_event, "id")); + int difficulty = nostr_calculate_pow_difficulty(id); + + /* Track best under mutex */ + pthread_mutex_lock(&shared->mutex); + shared->total_attempts++; + if (difficulty > shared->best_difficulty) { + if (shared->best_event) cJSON_Delete(shared->best_event); + shared->best_event = cJSON_Duplicate(signed_event, 1); + shared->best_difficulty = difficulty; + } + if (target_difficulty > 0 && difficulty >= target_difficulty) { + shared->target_reached = 1; + shared->stop = 1; + } + pthread_mutex_unlock(&shared->mutex); + + cJSON_Delete(signed_event); + nonce += nonce_stride; + attempts++; +} +``` + +**Files to create:** +- `src/miner.c` — mining coordinator (~250 lines) + +### Step 4: Add crypto_mine_event() to key_store.c + +Add a function that: +1. Parses the event JSON +2. Gets the role's private key from key_store +3. Calls `miner_run()` from miner.c +4. Builds the response JSON object (event string + metadata) +5. Zeroizes the private key copy +6. Returns the response JSON string (caller frees) + +**Signature:** +```c +/* Returns newly-allocated JSON string with result object, or NULL on error. + * Caller frees. */ +char *crypto_mine_event(const key_store_t *store, int role_index, + const char *event_json, int difficulty, + int threads, int timeout_sec); +``` + +**Response building:** +```c +cJSON *result_obj = cJSON_CreateObject(); +cJSON *event_item = cJSON_CreateString(signed_event_json); +cJSON_AddItemToObject(result_obj, "event", event_item); +cJSON_AddNumberToObject(result_obj, "achieved_difficulty", result.achieved_difficulty); +cJSON_AddNumberToObject(result_obj, "target_difficulty", result.target_difficulty); +cJSON_AddBoolToObject(result_obj, "target_reached", result.target_reached); +cJSON_AddNumberToObject(result_obj, "elapsed_sec", result.elapsed_sec); +cJSON_AddNumberToObject(result_obj, "attempts", (double)result.total_attempts); +char *out = cJSON_PrintUnformatted(result_obj); +cJSON_Delete(result_obj); +return out; +``` + +**Files to modify:** +- [`src/key_store.c`](src/key_store.c:456) — add `#include `, add `crypto_mine_event()` function, add forward declaration in the headerless decls section + +### Step 5: Update Makefile + +Add `miner.c` to SOURCES. pthread is already linked (`-lpthread` in LDFLAGS). + +**Files to modify:** +- [`Makefile`](Makefile:13) — add `$(SRC_DIR)/miner.c \` to SOURCES list + +### Step 6: Add dispatcher handler for mine_event + +In [`dispatcher_handle_request()`](src/dispatcher.c:545), add a branch for `VERB_MINE_EVENT` that: +1. Extracts `event_json` from params[0] +2. Extracts `difficulty`, `threads`, `timeout_sec` from the options object (params[last]) +3. Validates: at least one of `difficulty` or `timeout_sec` must be specified (else error `1007`); `threads` defaults to 1 (max 32); if only `difficulty` is set, safety timeout of 600 sec is applied +4. Calls `crypto_mine_event()` +5. Returns the result + +**Files to modify:** +- [`src/dispatcher.c`](src/dispatcher.c:675) — add `else if (strcmp(method, VERB_MINE_EVENT) == 0)` branch after the `VERB_SIGN_EVENT` branch + +### Step 7: Modify server.c for async mining + +The current [`server_handle_one()`](src/server.c:1453) is synchronous. For `mine_event`, we need to: + +1. Detect `mine_event` method after policy check passes +2. Spawn a detached thread that: + - Calls `dispatcher_handle_request()` (which calls `crypto_mine_event()`) + - Sends the response via `transport_send_framed()` on the client_fd + - Closes the client_fd +3. Return immediately from `server_handle_one()` without closing the fd (the thread owns it now) + +**Thread function:** +```c +typedef struct { + int client_fd; + dispatcher_ctx_t *dispatcher; + char *request; +} mine_thread_arg_t; + +static void *mine_event_thread(void *arg) { + mine_thread_arg_t *a = (mine_thread_arg_t *)arg; + char *response = dispatcher_handle_request(a->dispatcher, a->request); + if (response != NULL) { + (void)transport_send_framed(a->client_fd, response); + free(response); + } + free(a->request); + close(a->client_fd); + free(a); + return NULL; +} +``` + +**In server_handle_one**, after `pchk == POLICY_ALLOW` and before calling `dispatcher_handle_request()`: +```c +if (pchk == POLICY_ALLOW && strcmp(method, "mine_event") == 0) { + pthread_t tid; + mine_thread_arg_t *arg = malloc(sizeof(*arg)); + if (arg == NULL) { + /* fall through to error path */ + } else { + arg->client_fd = client_fd; + arg->dispatcher = ctx->dispatcher; + arg->request = request; /* transfer ownership */ + request = NULL; /* prevent free in cleanup */ + if (pthread_create(&tid, NULL, mine_event_thread, arg) == 0) { + pthread_detach(tid); + /* Skip synchronous response path — thread owns fd and request */ + /* Log activity and return without closing fd */ + verdict = "ALLOWED"; + source_label = "async-mine"; + /* ... log activity ... */ + return 1; + } + /* pthread_create failed — fall through to error path */ + free(arg->request); + free(arg); + } +} +``` + +**Files to modify:** +- [`src/server.c`](src/server.c:1755) — add mine_event async path before the synchronous `dispatcher_handle_request()` call + +### Step 8: Add client demos + +Add `mine_event` examples to the existing demo files. + +**Files to modify:** +- [`client/demo_javascript.js`](client/demo_javascript.js:1) — add mine_event example +- [`client/demo_python.py`](client/demo_python.py:1) — add mine_event example +- [`client/demo_c99.c`](client/demo_c99.c:1) — add mine_event example + +### Step 9: Add tests + +Create a test that: +1. Starts nsigner with a test mnemonic +2. Sends a `mine_event` request with low difficulty (e.g., 2) and short timeout (e.g., 5 sec) +3. Verifies the response contains a signed event with a nonce tag +4. Verifies the `achieved_difficulty` >= 2 and `target_reached` is true +5. Sends a `mine_event` request with high difficulty (e.g., 30) and short timeout (e.g., 3 sec) +6. Verifies the response contains a signed event, `target_reached` is false, and `achieved_difficulty` < 30 + +**Files to create:** +- `tests/test_mine_event.c` + +**Files to modify:** +- [`Makefile`](Makefile:37) — add `TEST_MINE_EVENT_TARGET` and `test-mine-event` target + +### Step 10: Update documentation + +**Files to modify:** +- [`README.md`](README.md:1) — add `mine_event` to the verbs table with params description +- [`client/README.md`](client/README.md:1) — add mine_event usage example + +## Security Considerations + +1. **Private key never leaves key_store**: The mining thread receives a copy of the 32-byte private key, which is zeroized after use (following the pattern in [`crypto_sign_event()`](src/key_store.c:456)) +2. **Policy enforcement applies**: `mine_event` goes through the same `policy_check()` and `enforce_verb_role()` as all other verbs +3. **Thread safety**: + - Each mining thread works on its own cJSON copies + - The shared state (best_event, best_difficulty, total_attempts) is protected by a mutex + - secp256k1 context is thread-safe for signing +4. **Resource limits**: + - Max threads capped at 32 to prevent resource exhaustion + - `timeout_sec` is required (must be > 0) to prevent infinite mining + - Optional: limit to 1 concurrent mining operation (reject with `mining_busy` if already running) +5. **Client fd ownership**: The detached thread owns the client_fd and is responsible for closing it. The main server loop must not close it. + +## Concurrency Considerations + +- Each mining thread uses a unique nonce stride (thread_id) to avoid duplicate work +- cJSON operations are NOT thread-safe across different cJSON objects, but each thread works on its own copies +- The result mutex protects only the shared best-event tracking +- `nostr_create_and_sign_event()` creates a new secp256k1 context per call (or uses a thread-local one) — need to verify this is thread-safe. If not, each thread may need its own context. + +## File Summary + +| File | Action | Description | +|------|--------|-------------| +| `Makefile` | Modify | Add nip013 to --nips, add miner.c to SOURCES, add test target | +| `src/enforcement.c` | Modify | Add VERB_MINE_EVENT to is_nostr_verb() | +| `src/dispatcher.c` | Modify | Add VERB_MINE_EVENT define + handler branch | +| `src/key_store.c` | Modify | Add crypto_mine_event() function + nip013 include | +| `src/miner.c` | Create | Multithreaded best-effort mining coordinator | +| `src/server.c` | Modify | Add async mine_event path with detached thread | +| `tests/test_mine_event.c` | Create | Integration test for mine_event | +| `client/demo_javascript.js` | Modify | Add mine_event example | +| `client/demo_python.py` | Modify | Add mine_event example | +| `client/demo_c99.c` | Modify | Add mine_event example | +| `README.md` | Modify | Document mine_event verb | +| `client/README.md` | Modify | Add mine_event usage | diff --git a/src/dispatcher.c b/src/dispatcher.c index 037a7c4..3e3865a 100644 --- a/src/dispatcher.c +++ b/src/dispatcher.c @@ -203,6 +203,7 @@ const char *selector_strerror(int err); #define VERB_NIP44_DECRYPT "nip44_decrypt" #define VERB_NIP04_ENCRYPT "nip04_encrypt" #define VERB_NIP04_DECRYPT "nip04_decrypt" +#define VERB_MINE_EVENT "mine_event" /* * Check whether `verb` is allowed to execute against `role`. @@ -329,6 +330,13 @@ const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index); * Caller must free() the returned string. */ char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json); +/* Mine a Nostr event with NIP-13 proof-of-work, then sign it. + * Returns a newly-allocated JSON string with result object, or NULL on error. + * Caller must free() the returned string. */ +char *crypto_mine_event(const key_store_t *store, int role_index, + const char *event_json, int difficulty, + int threads, int timeout_sec); + /* NIP-44 encrypt/decrypt */ char *crypto_nip44_encrypt(const key_store_t *store, int role_index, const char *recipient_pubkey_hex, const char *plaintext); @@ -687,6 +695,60 @@ char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request) return make_error_response(id_str, -32602, "invalid_params"); } result = owned_result; + } else if (strcmp(method, VERB_MINE_EVENT) == 0) { + cJSON *event_item = cJSON_GetArrayItem(params_item, 0); + cJSON *diff_item = NULL; + cJSON *threads_item = NULL; + cJSON *timeout_item = NULL; + int difficulty = 0; + int threads = 1; + int timeout_sec = 0; + int has_difficulty = 0; + int has_timeout = 0; + + if (!cJSON_IsString(event_item) || event_item->valuestring == NULL) { + cJSON_Delete(root); + return make_error_response(id_str, -32602, "invalid_params"); + } + + /* Extract mining options from the options object (last param) */ + if (cJSON_IsObject(options_item)) { + diff_item = cJSON_GetObjectItemCaseSensitive(options_item, "difficulty"); + if (cJSON_IsNumber(diff_item)) { + difficulty = diff_item->valueint; + has_difficulty = 1; + } + + threads_item = cJSON_GetObjectItemCaseSensitive(options_item, "threads"); + if (cJSON_IsNumber(threads_item)) { + threads = threads_item->valueint; + } + + timeout_item = cJSON_GetObjectItemCaseSensitive(options_item, "timeout_sec"); + if (cJSON_IsNumber(timeout_item)) { + timeout_sec = timeout_item->valueint; + has_timeout = 1; + } + } + + /* At least one of difficulty or timeout_sec must be specified */ + if (!has_difficulty && !has_timeout) { + cJSON_Delete(root); + return make_error_response(id_str, 1007, "no_termination_condition"); + } + + /* Clamp threads to 1..32 */ + if (threads < 1) threads = 1; + if (threads > 32) threads = 32; + + owned_result = crypto_mine_event(ctx->key_store, role_index, + event_item->valuestring, + difficulty, threads, timeout_sec); + if (owned_result == NULL) { + cJSON_Delete(root); + return make_error_response(id_str, 1008, "mining_failed"); + } + result = owned_result; } else if (strcmp(method, VERB_NIP44_ENCRYPT) == 0) { cJSON *peer_item = cJSON_GetArrayItem(params_item, 0); cJSON *msg_item = cJSON_GetArrayItem(params_item, 1); diff --git a/src/enforcement.c b/src/enforcement.c index 92a83de..3301201 100644 --- a/src/enforcement.c +++ b/src/enforcement.c @@ -203,6 +203,7 @@ const char *selector_strerror(int err); #define VERB_NIP44_DECRYPT "nip44_decrypt" #define VERB_NIP04_ENCRYPT "nip04_encrypt" #define VERB_NIP04_DECRYPT "nip04_decrypt" +#define VERB_MINE_EVENT "mine_event" /* * Check whether `verb` is allowed to execute against `role`. @@ -460,7 +461,8 @@ static int is_nostr_verb(const char *verb) { (strcmp(verb, VERB_NIP44_ENCRYPT) == 0) || (strcmp(verb, VERB_NIP44_DECRYPT) == 0) || (strcmp(verb, VERB_NIP04_ENCRYPT) == 0) || - (strcmp(verb, VERB_NIP04_DECRYPT) == 0); + (strcmp(verb, VERB_NIP04_DECRYPT) == 0) || + (strcmp(verb, VERB_MINE_EVENT) == 0); } int enforce_verb_role(const char *verb, const role_entry_t *role) { diff --git a/src/key_store.c b/src/key_store.c index 08a2dfe..abaa604 100644 --- a/src/key_store.c +++ b/src/key_store.c @@ -334,6 +334,19 @@ const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index); * Caller must free() the returned string. */ char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json); +/* Mine a Nostr event with NIP-13 proof-of-work, then sign it. + * event_json is the unsigned event JSON string. + * difficulty: target leading zero bits (0 = no target, mine for full timeout) + * threads: number of mining threads (1..32) + * timeout_sec: time budget in seconds (0 = use 600s safety default) + * Returns a newly-allocated JSON string containing a result object: + * {"event":"","achieved_difficulty":N,"target_difficulty":N, + * "target_reached":bool,"elapsed_sec":N,"attempts":N} + * Returns NULL on error. Caller must free() the returned string. */ +char *crypto_mine_event(const key_store_t *store, int role_index, + const char *event_json, int difficulty, + int threads, int timeout_sec); + /* Zeroize all derived keys in the store. */ void crypto_wipe(key_store_t *store); @@ -457,6 +470,7 @@ int socket_name_random(char *out, size_t out_len); #include #include #include +#include #include #include #include @@ -690,6 +704,88 @@ char *crypto_sign_event(const key_store_t *store, int role_index, const char *ev return out; } +/* ---- mine_result_t and miner_run are defined in miner.c ---- */ +/* Forward declaration of the mining coordinator */ +typedef struct { + cJSON *best_event; + int achieved_difficulty; + int target_difficulty; + int target_reached; + int elapsed_sec; + uint64_t total_attempts; +} mine_result_t; + +int miner_run(cJSON *event, const unsigned char *private_key, + int target_difficulty, int thread_count, int timeout_sec, + mine_result_t *result); + +char *crypto_mine_event(const key_store_t *store, int role_index, + const char *event_json, int difficulty, + int threads, int timeout_sec) { + const unsigned char *private_key; + cJSON *parsed = NULL; + mine_result_t result; + char *event_str = NULL; + cJSON *result_obj = NULL; + char *out = NULL; + + if (event_json == NULL) { + return NULL; + } + + private_key = crypto_get_private_key(store, role_index); + if (private_key == NULL) { + return NULL; + } + + parsed = cJSON_Parse(event_json); + if (parsed == NULL || !cJSON_IsObject(parsed)) { + cJSON_Delete(parsed); + return NULL; + } + + /* Run the mining coordinator */ + memset(&result, 0, sizeof(result)); + if (miner_run(parsed, private_key, difficulty, threads, timeout_sec, &result) != 0) { + cJSON_Delete(parsed); + return NULL; + } + + if (result.best_event == NULL) { + /* No result found (shouldn't happen unless threads failed immediately) */ + cJSON_Delete(parsed); + return NULL; + } + + /* Build the result JSON object */ + event_str = cJSON_PrintUnformatted(result.best_event); + cJSON_Delete(result.best_event); + cJSON_Delete(parsed); + + if (event_str == NULL) { + return NULL; + } + + result_obj = cJSON_CreateObject(); + if (result_obj == NULL) { + free(event_str); + return NULL; + } + + cJSON_AddItemToObject(result_obj, "event", cJSON_CreateString(event_str)); + free(event_str); + + cJSON_AddNumberToObject(result_obj, "achieved_difficulty", result.achieved_difficulty); + cJSON_AddNumberToObject(result_obj, "target_difficulty", result.target_difficulty); + cJSON_AddBoolToObject(result_obj, "target_reached", result.target_reached); + cJSON_AddNumberToObject(result_obj, "elapsed_sec", result.elapsed_sec); + cJSON_AddNumberToObject(result_obj, "attempts", (double)result.total_attempts); + + out = cJSON_PrintUnformatted(result_obj); + cJSON_Delete(result_obj); + return out; +} + static int parse_hex_pubkey32(const char *hex, unsigned char out[32]) { if (hex == NULL || out == NULL || strlen(hex) != 64) { return -1; diff --git a/src/main.c b/src/main.c index 6ecdc0c..88be73c 100644 --- a/src/main.c +++ b/src/main.c @@ -503,8 +503,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 0 -#define NSIGNER_VERSION_PATCH 44 -#define NSIGNER_VERSION "v0.0.44" +#define NSIGNER_VERSION_PATCH 45 +#define NSIGNER_VERSION "v0.0.45" /* NSIGNER_HEADERLESS_DECLS_END */ @@ -536,6 +536,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size); #include #define NSIGNER_DEFAULT_SOCKET_NAME "nsigner" +#define NSIGNER_QREXEC_SERVICE_NAME "qubes.NsignerRpc" #define ACTIVITY_LOG_CAP 16 #define CONNECTION_INFO_CAP 8 @@ -1100,7 +1101,7 @@ static void activity_log_cb(const char *message, void *user_data) { int idx; time_t now; struct tm tm_now; - char ts[16]; + char ts[32]; (void)user_data; if (message == NULL) { @@ -1109,9 +1110,9 @@ static void activity_log_cb(const char *message, void *user_data) { now = time(NULL); if (localtime_r(&now, &tm_now) != NULL) { - (void)strftime(ts, sizeof(ts), "%m%d-%H%M%S", &tm_now); + (void)strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm_now); } else { - strncpy(ts, "0000-000000", sizeof(ts) - 1); + strncpy(ts, "0000-00-00 00:00:00", sizeof(ts) - 1); ts[sizeof(ts) - 1] = '\0'; } @@ -2190,6 +2191,11 @@ int main(int argc, char *argv[]) { connection_info_add("listen: unix @%s", socket_name); } connection_info_add("client: nsigner --socket-name %s client ''", socket_name); + if (transport_mask & TRANSPORT_QREXEC_BRIDGE) { + connection_info_add("qrexec service: %s", NSIGNER_QREXEC_SERVICE_NAME); + connection_info_add("qrexec caller: qrexec-client-vm %s", NSIGNER_QREXEC_SERVICE_NAME); + printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME); + } printf("System is ready and waiting for connections on @%s.\n", socket_name); } if (transport_mask & TRANSPORT_TCP) { @@ -2199,6 +2205,11 @@ int main(int argc, char *argv[]) { } else if (listen_mode == NSIGNER_LISTEN_UNIX) { connection_info_add("listen: unix @%s", socket_name); connection_info_add("client: nsigner --socket-name %s client ''", socket_name); + if (bridge_source_trusted) { + connection_info_add("qrexec service: %s", NSIGNER_QREXEC_SERVICE_NAME); + connection_info_add("qrexec caller: qrexec-client-vm %s", NSIGNER_QREXEC_SERVICE_NAME); + printf("qrexec service: %s\n", NSIGNER_QREXEC_SERVICE_NAME); + } printf("System is ready and waiting for connections on @%s.\n", socket_name); } else if (listen_mode == NSIGNER_LISTEN_TCP) { connection_info_add("listen: %s", listen_target); diff --git a/src/miner.c b/src/miner.c new file mode 100644 index 0000000..03d1a8e --- /dev/null +++ b/src/miner.c @@ -0,0 +1,368 @@ +/* + * miner.c - Multithreaded best-effort NIP-13 Proof-of-Work mining coordinator + * + * This module implements a server-safe mining loop that: + * - Runs N worker threads, each trying nonces with a unique stride + * - Tracks the best event (highest difficulty) across all threads + * - Stops when target difficulty is reached OR timeout expires + * - Always returns the best result found (best-effort, not failure on timeout) + * + * Unlike the standalone event_miner tool, this code: + * - Has no global variables (all state in context structs) + * - Never calls exit() (returns result codes) + * - Has no signal handlers (the server handles signals) + * - Uses time-based termination as the primary budget + */ + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/* ---- Constants ---- */ + +#define MINER_MAX_THREADS 32 +#define MINER_SAFETY_TIMEOUT 600 /* 10 minutes safety max when only difficulty is set */ +#define MINER_NONCE_STRIDE 0 /* 0 = use thread_count as stride */ + +/* ---- Result structure ---- */ + +typedef struct { + cJSON *best_event; /* best event found (caller frees) */ + int achieved_difficulty; + int target_difficulty; + int target_reached; + int elapsed_sec; + uint64_t total_attempts; +} mine_result_t; + +/* ---- Shared state across worker threads ---- */ + +typedef struct { + cJSON *best_event; /* best event found so far (mutex-protected) */ + int best_difficulty; /* difficulty of best_event */ + uint64_t total_attempts; /* total attempts across all threads */ + int target_difficulty;/* 0 = no target, mine full timeout */ + int target_reached; /* 1 if target was reached */ + pthread_mutex_t mutex; /* protects best_event, best_difficulty, total_attempts */ + volatile int stop; /* set when target reached or timeout */ +} mine_shared_state_t; + +/* ---- Per-worker context ---- */ + +typedef struct { + mine_shared_state_t *shared; + int kind; + char content[65536]; /* copy of event content */ + cJSON *tags_template; /* this thread's copy of original tags */ + unsigned char private_key[32]; + int thread_id; + uint64_t nonce_start; /* starting nonce for this thread */ + uint64_t nonce_stride; /* increment to avoid overlap with other threads */ + time_t deadline; /* absolute time to stop */ + uint64_t attempts; /* this thread's attempt count */ +} miner_worker_ctx_t; + +/* ---- Helper: update nonce tag in a tags array ---- */ + +static int miner_update_nonce_tag(cJSON *tags, uint64_t nonce, int target_difficulty) { + cJSON *tag; + int index = 0; + + if (!tags) return -1; + + /* Remove existing nonce tag if present */ + cJSON_ArrayForEach(tag, tags) { + if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) { + cJSON *tag_type = cJSON_GetArrayItem(tag, 0); + if (tag_type && cJSON_IsString(tag_type) && + strcmp(cJSON_GetStringValue(tag_type), "nonce") == 0) { + cJSON_DetachItemFromArray(tags, index); + cJSON_Delete(tag); + break; + } + } + index++; + } + + /* Add new nonce tag: ["nonce", "", ""] */ + { + cJSON *nonce_tag = cJSON_CreateArray(); + char nonce_str[32]; + char difficulty_str[16]; + + if (!nonce_tag) return -1; + + snprintf(nonce_str, sizeof(nonce_str), "%llu", (unsigned long long)nonce); + snprintf(difficulty_str, sizeof(difficulty_str), "%d", target_difficulty); + + cJSON_AddItemToArray(nonce_tag, cJSON_CreateString("nonce")); + cJSON_AddItemToArray(nonce_tag, cJSON_CreateString(nonce_str)); + cJSON_AddItemToArray(nonce_tag, cJSON_CreateString(difficulty_str)); + + cJSON_AddItemToArray(tags, nonce_tag); + } + return 0; +} + +/* ---- Worker thread function ---- */ + +static void *miner_worker(void *arg) { + miner_worker_ctx_t *ctx = (miner_worker_ctx_t *)arg; + uint64_t nonce = ctx->nonce_start; + time_t current_ts = time(NULL); + + while (!ctx->shared->stop && time(NULL) < ctx->deadline) { + cJSON *working_tags; + cJSON *signed_event; + const char *event_id; + int difficulty; + + /* Build tags with current nonce */ + working_tags = cJSON_Duplicate(ctx->tags_template, 1); + if (!working_tags) break; + + if (miner_update_nonce_tag(working_tags, nonce, ctx->shared->target_difficulty) != 0) { + cJSON_Delete(working_tags); + break; + } + + /* Update timestamp periodically (every 1000 attempts) to keep it fresh */ + if (ctx->attempts % 1000 == 0) { + current_ts = time(NULL); + } + + /* Create and sign the event with this nonce */ + signed_event = nostr_create_and_sign_event(ctx->kind, ctx->content, + working_tags, ctx->private_key, + current_ts); + cJSON_Delete(working_tags); + + if (!signed_event) { + nonce += ctx->nonce_stride; + ctx->attempts++; + continue; + } + + /* Check difficulty of the resulting event id */ + { + cJSON *id_item = cJSON_GetObjectItem(signed_event, "id"); + if (!id_item || !cJSON_IsString(id_item)) { + cJSON_Delete(signed_event); + nonce += ctx->nonce_stride; + ctx->attempts++; + continue; + } + event_id = cJSON_GetStringValue(id_item); + } + + difficulty = nostr_calculate_pow_difficulty(event_id); + + /* Track best under mutex */ + pthread_mutex_lock(&ctx->shared->mutex); + ctx->shared->total_attempts++; + if (difficulty > ctx->shared->best_difficulty) { + if (ctx->shared->best_event) { + cJSON_Delete(ctx->shared->best_event); + } + ctx->shared->best_event = cJSON_Duplicate(signed_event, 1); + ctx->shared->best_difficulty = difficulty; + } + if (ctx->shared->target_difficulty > 0 && difficulty >= ctx->shared->target_difficulty) { + ctx->shared->target_reached = 1; + ctx->shared->stop = 1; + } + pthread_mutex_unlock(&ctx->shared->mutex); + + cJSON_Delete(signed_event); + nonce += ctx->nonce_stride; + ctx->attempts++; + } + + return NULL; +} + +/* ---- Main mining coordinator ---- */ + +/* + * Run multithreaded best-effort mining on `event`. + * + * Parameters: + * event - cJSON event to mine (must have kind, content, tags, created_at) + * private_key - 32-byte private key for signing + * target_difficulty - 0 = no target (mine full timeout); >0 = stop when reached + * thread_count - number of worker threads (1..MINER_MAX_THREADS) + * timeout_sec - time budget in seconds (0 = use MINER_SAFETY_TIMEOUT) + * result - output struct (caller frees result->best_event) + * + * Returns 0 on success (result populated), -1 on error. + */ +int miner_run(cJSON *event, const unsigned char *private_key, + int target_difficulty, int thread_count, int timeout_sec, + mine_result_t *result) +{ + mine_shared_state_t shared; + miner_worker_ctx_t *workers = NULL; + pthread_t *threads = NULL; + cJSON *kind_item, *content_item, *tags_item, *created_at_item; + int kind; + const char *content; + time_t start_time; + int effective_timeout; + int i; + int ret = 0; + + if (!event || !private_key || !result) { + return -1; + } + + memset(result, 0, sizeof(mine_result_t)); + + /* Validate event has required fields */ + kind_item = cJSON_GetObjectItem(event, "kind"); + content_item = cJSON_GetObjectItem(event, "content"); + tags_item = cJSON_GetObjectItem(event, "tags"); + created_at_item = cJSON_GetObjectItem(event, "created_at"); + + if (!kind_item || !content_item || !tags_item) { + return -1; + } + + /* If created_at is missing, add it with current time */ + if (!created_at_item) { + cJSON_AddNumberToObject(event, "created_at", (double)time(NULL)); + } + + kind = (int)cJSON_GetNumberValue(kind_item); + content = cJSON_GetStringValue(content_item); + if (!content) { + content = ""; + } + + /* Clamp thread count */ + if (thread_count < 1) thread_count = 1; + if (thread_count > MINER_MAX_THREADS) thread_count = MINER_MAX_THREADS; + + /* Determine effective timeout */ + if (timeout_sec <= 0) { + effective_timeout = MINER_SAFETY_TIMEOUT; + } else { + effective_timeout = timeout_sec; + } + + /* Initialize shared state */ + memset(&shared, 0, sizeof(shared)); + shared.best_event = NULL; + shared.best_difficulty = -1; + shared.total_attempts = 0; + shared.target_difficulty = target_difficulty; + shared.target_reached = 0; + shared.stop = 0; + if (pthread_mutex_init(&shared.mutex, NULL) != 0) { + return -1; + } + + /* Allocate worker contexts and thread array */ + workers = (miner_worker_ctx_t *)calloc(thread_count, sizeof(miner_worker_ctx_t)); + threads = (pthread_t *)calloc(thread_count, sizeof(pthread_t)); + if (!workers || !threads) { + free(workers); + free(threads); + pthread_mutex_destroy(&shared.mutex); + return -1; + } + + start_time = time(NULL); + + /* Set up each worker */ + for (i = 0; i < thread_count; i++) { + workers[i].shared = &shared; + workers[i].kind = kind; + strncpy(workers[i].content, content, sizeof(workers[i].content) - 1); + workers[i].content[sizeof(workers[i].content) - 1] = '\0'; + workers[i].tags_template = cJSON_Duplicate(tags_item, 1); + memcpy(workers[i].private_key, private_key, 32); + workers[i].thread_id = i; + workers[i].nonce_start = (uint64_t)i; + workers[i].nonce_stride = (uint64_t)thread_count; + workers[i].deadline = start_time + effective_timeout; + workers[i].attempts = 0; + + if (!workers[i].tags_template) { + /* Allocation failed for this worker's tags — clean up what we have */ + int j; + for (j = 0; j < i; j++) { + cJSON_Delete(workers[j].tags_template); + } + free(workers); + free(threads); + pthread_mutex_destroy(&shared.mutex); + return -1; + } + } + + /* Start threads */ + for (i = 0; i < thread_count; i++) { + if (pthread_create(&threads[i], NULL, miner_worker, &workers[i]) != 0) { + /* Failed to create thread i — signal stop and join what we have */ + shared.stop = 1; + { + int j; + for (j = 0; j < i; j++) { + pthread_join(threads[j], NULL); + } + } + ret = -1; + goto cleanup; + } + } + + /* Wait for all threads to complete (they stop on target/timeout) */ + for (i = 0; i < thread_count; i++) { + pthread_join(threads[i], NULL); + } + +cleanup: + /* Sum up total attempts from all workers */ + { + uint64_t total = 0; + for (i = 0; i < thread_count; i++) { + total += workers[i].attempts; + } + /* Use the shared counter (more accurate, includes mutex-protected increments) */ + result->total_attempts = shared.total_attempts > 0 ? shared.total_attempts : total; + } + + /* Populate result */ + result->best_event = shared.best_event; /* transfer ownership */ + result->achieved_difficulty = shared.best_difficulty; + result->target_difficulty = target_difficulty; + result->target_reached = shared.target_reached; + result->elapsed_sec = (int)(time(NULL) - start_time); + + /* Clean up worker contexts */ + for (i = 0; i < thread_count; i++) { + if (workers[i].tags_template) { + cJSON_Delete(workers[i].tags_template); + } + /* Zeroize private key copy */ + memset(workers[i].private_key, 0, 32); + } + + free(workers); + free(threads); + pthread_mutex_destroy(&shared.mutex); + + return ret; +} diff --git a/src/server.c b/src/server.c index 29d2dc7..176db34 100644 --- a/src/server.c +++ b/src/server.c @@ -528,6 +528,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size); #include #include #include +#include #include #include #include @@ -1450,6 +1451,33 @@ int server_get_caller(int fd, caller_identity_t *out) { return 0; } +/* ---- Async mine_event support ---- */ + +typedef struct { + int client_fd; + int is_stdio; + dispatcher_ctx_t *dispatcher; + char *request; +} mine_thread_arg_t; + +static void *mine_event_thread(void *arg) { + mine_thread_arg_t *a = (mine_thread_arg_t *)arg; + char *response = NULL; + + response = dispatcher_handle_request(a->dispatcher, a->request); + if (response != NULL) { + (void)transport_send_framed(a->is_stdio ? STDOUT_FILENO : a->client_fd, response); + free(response); + } + + free(a->request); + if (!a->is_stdio) { + close(a->client_fd); + } + free(a); + return NULL; +} + int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { int client_fd; caller_identity_t caller; @@ -1753,6 +1781,47 @@ int server_handle_one(server_ctx_t *ctx, server_activity_cb cb, void *cb_data) { } if (pchk == POLICY_ALLOW && !derivation_error) { + /* + * mine_event is a long-running operation (potentially seconds to minutes). + * Spawn a detached thread so the server stays responsive. The thread + * owns the client_fd and request, and sends the response when mining + * completes. We skip the synchronous response path below. + */ + if (strcmp(method, "mine_event") == 0 && client_fd != STDIN_FILENO) { + mine_thread_arg_t *marg = malloc(sizeof(mine_thread_arg_t)); + if (marg != NULL) { + pthread_t mine_tid; + + marg->client_fd = client_fd; + marg->is_stdio = 0; + marg->dispatcher = ctx->dispatcher; + marg->request = request; + + if (pthread_create(&mine_tid, NULL, mine_event_thread, marg) == 0) { + pthread_detach(mine_tid); + /* Thread owns client_fd and request — prevent cleanup below */ + request = NULL; + response = NULL; + verdict = "ALLOWED"; + source_label = "async-mine"; + (void)snprintf(activity, + sizeof(activity), + "%s %s(%s) %s:%s", + caller.caller_id, + method, + role_name, + "ALLOWED", + "async-mine"); + if (cb != NULL) { + cb(activity, cb_data); + } + return 1; + } + /* pthread_create failed — fall through to synchronous path */ + free(marg); + } + } + verdict = "ALLOWED"; response = dispatcher_handle_request(ctx->dispatcher, request); if (response == NULL) { diff --git a/tests/test_mine_event.c b/tests/test_mine_event.c new file mode 100644 index 0000000..a5fde0e --- /dev/null +++ b/tests/test_mine_event.c @@ -0,0 +1,459 @@ +/* Test for mine_event verb (NIP-13 Proof-of-Work) */ +/* NSIGNER_HEADERLESS_DECLS_BEGIN */ +#include +#include +#include +#include + +/* from secure_mem.h */ + +typedef struct { + void *data; + size_t size; + int locked; +} secure_buf_t; + +int secure_buf_alloc(secure_buf_t *buf, size_t size); +void secure_buf_free(secure_buf_t *buf); +void secure_memzero(void *ptr, size_t len); + +/* from mnemonic.h */ + +#define MNEMONIC_MAX_LEN 256 + +typedef struct { + secure_buf_t buf; + int loaded; + int word_count; +} mnemonic_state_t; + +void mnemonic_init(mnemonic_state_t *state); +int mnemonic_load(mnemonic_state_t *state, const char *phrase); +void mnemonic_unload(mnemonic_state_t *state); +int mnemonic_is_loaded(const mnemonic_state_t *state); +const char *mnemonic_get_phrase(const mnemonic_state_t *state); +int mnemonic_generate(int word_count, char *out, size_t out_len); + +/* from role_table.h */ + +#define ROLE_NAME_MAX 64 +#define ROLE_PATH_MAX 128 +#define ROLE_PURPOSE_MAX 32 +#define ROLE_CURVE_MAX 16 +#define ROLE_PUBKEY_HEX_MAX 66 +#define ROLE_TABLE_MAX_ENTRIES 256 + +typedef enum { + PURPOSE_NOSTR = 0, + PURPOSE_BITCOIN, + PURPOSE_SSH, + PURPOSE_AGE, + PURPOSE_FIPS, + PURPOSE_UNKNOWN +} role_purpose_t; + +typedef enum { + CURVE_SECP256K1 = 0, + CURVE_ED25519, + CURVE_X25519, + CURVE_UNKNOWN +} role_curve_t; + +typedef enum { + SELECTOR_NOSTR_INDEX, + SELECTOR_ROLE_PATH +} role_selector_type_t; + +typedef struct { + char name[ROLE_NAME_MAX]; + char purpose_str[ROLE_PURPOSE_MAX]; + char curve_str[ROLE_CURVE_MAX]; + role_purpose_t purpose; + role_curve_t curve; + role_selector_type_t selector_type; + int nostr_index; + char role_path[ROLE_PATH_MAX]; + char pubkey_hex[ROLE_PUBKEY_HEX_MAX]; + int derived; +} role_entry_t; + +typedef struct { + role_entry_t entries[ROLE_TABLE_MAX_ENTRIES]; + int count; +} role_table_t; + +void role_table_init(role_table_t *table); +int role_table_add(role_table_t *table, const role_entry_t *entry); +role_entry_t *role_table_find_by_name(role_table_t *table, const char *name); +role_entry_t *role_table_find_by_nostr_index(role_table_t *table, int index); +role_entry_t *role_table_find_by_path(role_table_t *table, const char *path); +role_entry_t *role_table_get_default(role_table_t *table); +role_purpose_t role_purpose_from_str(const char *s); +role_curve_t role_curve_from_str(const char *s); +const char *role_purpose_to_str(role_purpose_t p); +const char *role_curve_to_str(role_curve_t c); +int role_table_register_nostr_index(role_table_t *table, int nostr_index); + +/* from selector.h */ + +#define SELECTOR_OK 0 +#define SELECTOR_ERR_AMBIGUOUS -1 +#define SELECTOR_ERR_NOT_FOUND -2 +#define SELECTOR_ERR_NO_DEFAULT -3 + +typedef struct { + int has_role; + char role_name[ROLE_NAME_MAX]; + int has_nostr_index; + int nostr_index; + int has_role_path; + char role_path[ROLE_PATH_MAX]; +} selector_request_t; + +void selector_request_init(selector_request_t *req); +int selector_resolve(const selector_request_t *req, role_table_t *table, role_entry_t **out); +const char *selector_strerror(int err); + +/* from enforcement.h */ + +#define ENFORCE_OK 0 +#define ENFORCE_ERR_PURPOSE -1 +#define ENFORCE_ERR_CURVE -2 +#define ENFORCE_ERR_UNKNOWN_VERB -3 + +#define VERB_SIGN_EVENT "sign_event" +#define VERB_GET_PUBLIC_KEY "get_public_key" +#define VERB_NIP44_ENCRYPT "nip44_encrypt" +#define VERB_NIP44_DECRYPT "nip44_decrypt" +#define VERB_NIP04_ENCRYPT "nip04_encrypt" +#define VERB_NIP04_DECRYPT "nip04_decrypt" +#define VERB_MINE_EVENT "mine_event" + +int enforce_verb_role(const char *verb, const role_entry_t *role); +const char *enforce_strerror(int err); + +/* from policy.h */ + +#define POLICY_MAX_ENTRIES 32 +#define POLICY_MAX_VERBS 16 +#define POLICY_MAX_ROLES 16 +#define POLICY_MAX_PURPOSES 8 +#define POLICY_VERB_MAX_LEN 32 +#define POLICY_CALLER_MAX_LEN 160 + +typedef enum { + PROMPT_NEVER = 0, + PROMPT_FIRST_PER_BOOT, + PROMPT_EVERY_REQUEST, + PROMPT_DENY +} prompt_mode_t; + +typedef enum { + POLICY_SOURCE_DEFAULT = 0, + POLICY_SOURCE_PREAPPROVE, + POLICY_SOURCE_SESSION_GRANT +} policy_source_t; + +typedef struct { + char caller[POLICY_CALLER_MAX_LEN]; + char verbs[POLICY_MAX_VERBS][POLICY_VERB_MAX_LEN]; + int verb_count; + char roles[POLICY_MAX_ROLES][ROLE_NAME_MAX]; + int role_count; + char purposes[POLICY_MAX_PURPOSES][ROLE_PURPOSE_MAX]; + int purpose_count; + prompt_mode_t prompt; + policy_source_t source; +} policy_entry_t; + +typedef struct { + policy_entry_t entries[POLICY_MAX_ENTRIES]; + int count; +} policy_table_t; + +#define POLICY_ALLOW 0 +#define POLICY_DENY -1 +#define POLICY_PROMPT -2 +#define POLICY_NO_MATCH -3 + +void policy_table_init(policy_table_t *table); +void policy_init_default(policy_table_t *table, uid_t owner_uid); +int policy_table_add(policy_table_t *table, const policy_entry_t *entry); +int policy_check(const policy_table_t *table, const char *caller_id, + const char *verb, const char *role_name, const char *purpose, + policy_source_t *out_source); +prompt_mode_t prompt_mode_from_str(const char *s); +const char *prompt_mode_to_str(prompt_mode_t m); + +/* from crypto.h */ + +typedef struct { + secure_buf_t private_key; + unsigned char public_key[32]; + char pubkey_hex[65]; + char npub[128]; + int valid; +} derived_key_t; + +typedef struct { + derived_key_t keys[ROLE_TABLE_MAX_ENTRIES]; + int count; +} key_store_t; + +int crypto_derive_all(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic); +int crypto_derive_one(key_store_t *store, role_table_t *table, const mnemonic_state_t *mnemonic, int role_index); +const unsigned char *crypto_get_private_key(const key_store_t *store, int role_index); +const char *crypto_get_pubkey_hex(const key_store_t *store, int role_index); +char *crypto_sign_event(const key_store_t *store, int role_index, const char *event_json); +char *crypto_mine_event(const key_store_t *store, int role_index, + const char *event_json, int difficulty, + int threads, int timeout_sec); +char *crypto_nip44_encrypt(const key_store_t *store, int role_index, + const char *recipient_pubkey_hex, const char *plaintext); +char *crypto_nip44_decrypt(const key_store_t *store, int role_index, + const char *sender_pubkey_hex, const char *ciphertext); +char *crypto_nip04_encrypt(const key_store_t *store, int role_index, + const char *recipient_pubkey_hex, const char *plaintext); +char *crypto_nip04_decrypt(const key_store_t *store, int role_index, + const char *sender_pubkey_hex, const char *ciphertext); +void crypto_wipe(key_store_t *store); + +/* from dispatcher.h */ + +typedef struct { + role_table_t *role_table; + mnemonic_state_t *mnemonic; + key_store_t *key_store; +} dispatcher_ctx_t; + +void dispatcher_init(dispatcher_ctx_t *ctx, role_table_t *table, mnemonic_state_t *mnemonic, key_store_t *key_store); +char *dispatcher_handle_request(dispatcher_ctx_t *ctx, const char *json_request); + +/* NSIGNER_HEADERLESS_DECLS_END */ + +#include +#include + +#include +#include +#include + +static int g_passes = 0; +static int g_total = 0; + +static void check_condition(const char *name, int condition) { + g_total++; + if (condition) { + printf("PASS: %s\n", name); + g_passes++; + } else { + printf("FAIL: %s\n", name); + } +} + +static int response_has(const char *response, const char *needle) { + return (response != NULL && needle != NULL && strstr(response, needle) != NULL); +} + +/* Parse a dispatcher response and extract the result object (for mine_event). + * Returns a newly-allocated cJSON result object, or NULL on failure. + * Caller must delete the returned object. */ +static cJSON *extract_result_object(const char *response) { + cJSON *resp_json = NULL; + cJSON *result_str = NULL; + cJSON *result_obj = NULL; + + if (response == NULL) return NULL; + + resp_json = cJSON_Parse(response); + if (!resp_json) return NULL; + + result_str = cJSON_GetObjectItemCaseSensitive(resp_json, "result"); + if (!result_str || !cJSON_IsString(result_str)) { + cJSON_Delete(resp_json); + return NULL; + } + + result_obj = cJSON_Parse(result_str->valuestring); + cJSON_Delete(resp_json); + return result_obj; +} + +/* Check if target_reached in the result object matches expected */ +static int check_target_reached(const char *response, int expected) { + cJSON *result_obj = extract_result_object(response); + cJSON *tr; + int val = -1; + + if (!result_obj) return 0; + + tr = cJSON_GetObjectItemCaseSensitive(result_obj, "target_reached"); + if (tr && cJSON_IsBool(tr)) { + val = cJSON_IsTrue(tr) ? 1 : 0; + } + + cJSON_Delete(result_obj); + return (val == expected); +} + +static role_entry_t make_nostr_entry(const char *name, int idx) { + role_entry_t e; + memset(&e, 0, sizeof(e)); + strncpy(e.name, name, sizeof(e.name) - 1); + strncpy(e.purpose_str, "nostr", sizeof(e.purpose_str) - 1); + strncpy(e.curve_str, "secp256k1", sizeof(e.curve_str) - 1); + e.purpose = role_purpose_from_str(e.purpose_str); + e.curve = role_curve_from_str(e.curve_str); + e.selector_type = SELECTOR_NOSTR_INDEX; + e.nostr_index = idx; + e.derived = 0; + return e; +} + +int main(void) { + role_table_t table; + role_entry_t main_role; + mnemonic_state_t mnemonic; + dispatcher_ctx_t dispatcher; + key_store_t key_store; + const char *valid_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + char *resp; + int derived; + + role_table_init(&table); + main_role = make_nostr_entry("main", 0); + role_table_add(&table, &main_role); + + mnemonic_init(&mnemonic); + mnemonic_load(&mnemonic, valid_12); + + memset(&key_store, 0, sizeof(key_store)); + dispatcher_init(&dispatcher, &table, &mnemonic, &key_store); + + derived = nostr_init(); + check_condition("nostr_init succeeds", derived == 0); + + derived = crypto_derive_all(&key_store, &table, &mnemonic); + check_condition("crypto_derive_all derives at least one key", derived >= 1); + + /* 1. mine_event with low difficulty (2) and short timeout (5 sec) — should reach target */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"1\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"hello pow\\\",\\\"tags\\\":[]}\"," + "{\"difficulty\":2,\"threads\":2,\"timeout_sec\":5}]}"); + check_condition("mine_event low difficulty returns result object", + response_has(resp, "\"id\":\"1\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty")); + check_condition("mine_event low difficulty returns signed event with nonce tag", + response_has(resp, "nonce") && response_has(resp, "sig") && response_has(resp, "pubkey")); + check_condition("mine_event low difficulty target_reached is true", + check_target_reached(resp, 1)); + free(resp); + + /* 2. mine_event with high difficulty (30) and short timeout (2 sec) — should NOT reach target */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"2\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"hard pow\\\",\\\"tags\\\":[]}\"," + "{\"difficulty\":30,\"threads\":4,\"timeout_sec\":2}]}"); + check_condition("mine_event high difficulty returns result object", + response_has(resp, "\"id\":\"2\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty")); + check_condition("mine_event high difficulty target_reached is false", + check_target_reached(resp, 0)); + check_condition("mine_event high difficulty still returns a signed event", + response_has(resp, "nonce") && response_has(resp, "sig")); + free(resp); + + /* 3. mine_event with only timeout (no difficulty) — should mine for full duration */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"3\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"timeout only\\\",\\\"tags\\\":[]}\"," + "{\"threads\":2,\"timeout_sec\":2}]}"); + check_condition("mine_event timeout-only returns result", + response_has(resp, "\"id\":\"3\"") && response_has(resp, "\"result\"") && response_has(resp, "achieved_difficulty")); + check_condition("mine_event timeout-only target_reached is false (no target set)", + check_target_reached(resp, 0)); + free(resp); + + /* 4. mine_event with neither difficulty nor timeout — should error */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"4\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"no params\\\",\\\"tags\\\":[]}\"," + "{\"threads\":2}]}"); + check_condition("mine_event with no termination condition returns 1007", + response_has(resp, "\"id\":\"4\"") && response_has(resp, "\"code\":1007") && response_has(resp, "no_termination_condition")); + free(resp); + + /* 5. mine_event with only difficulty (no timeout) — should use safety timeout and reach low target */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"5\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"difficulty only\\\",\\\"tags\\\":[]}\"," + "{\"difficulty\":1,\"threads\":2}]}"); + check_condition("mine_event difficulty-only returns result and reaches target", + response_has(resp, "\"id\":\"5\"") && response_has(resp, "\"result\"") && check_target_reached(resp, 1)); + free(resp); + + /* 6. mine_event with invalid event JSON */ + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"6\",\"method\":\"mine_event\",\"params\":[" + "\"not valid json\"," + "{\"difficulty\":1,\"timeout_sec\":2}]}"); + check_condition("mine_event with invalid event returns error", + response_has(resp, "\"id\":\"6\"") && (response_has(resp, "\"code\":1008") || response_has(resp, "\"code\":-32602"))); + free(resp); + + /* 7. Verify the mined event's difficulty via nip013 validation */ + { + cJSON *resp_json, *result_str, *result_obj, *event_str, *event_json, *id_item; + const char *id_hex; + int pow_diff; + + resp = dispatcher_handle_request(&dispatcher, + "{\"id\":\"7\",\"method\":\"mine_event\",\"params\":[" + "\"{\\\"kind\\\":1,\\\"content\\\":\\\"verify pow\\\",\\\"tags\\\":[]}\"," + "{\"difficulty\":4,\"threads\":4,\"timeout_sec\":5}]}"); + + resp_json = cJSON_Parse(resp); + if (resp_json) { + result_str = cJSON_GetObjectItemCaseSensitive(resp_json, "result"); + if (result_str && cJSON_IsString(result_str)) { + result_obj = cJSON_Parse(result_str->valuestring); + if (result_obj) { + event_str = cJSON_GetObjectItemCaseSensitive(result_obj, "event"); + if (event_str && cJSON_IsString(event_str)) { + event_json = cJSON_Parse(event_str->valuestring); + if (event_json) { + id_item = cJSON_GetObjectItemCaseSensitive(event_json, "id"); + if (id_item && cJSON_IsString(id_item)) { + id_hex = cJSON_GetStringValue(id_item); + pow_diff = nostr_calculate_pow_difficulty(id_hex); + check_condition("mined event has difficulty >= 4", + pow_diff >= 4); + } else { + check_condition("mined event has difficulty >= 4", 0); + } + cJSON_Delete(event_json); + } else { + check_condition("mined event has difficulty >= 4", 0); + } + } else { + check_condition("mined event has difficulty >= 4", 0); + } + cJSON_Delete(result_obj); + } else { + check_condition("mined event has difficulty >= 4", 0); + } + } else { + check_condition("mined event has difficulty >= 4", 0); + } + cJSON_Delete(resp_json); + } else { + check_condition("mined event has difficulty >= 4", 0); + } + free(resp); + } + + crypto_wipe(&key_store); + nostr_cleanup(); + + printf("%d/%d tests passed\n", g_passes, g_total); + return (g_passes == g_total) ? 0 : 1; +}