v0.0.45 - Display qrexec service name (qubes.NsignerRpc) in signer connection info; use human-readable timestamps in activity log; fix static release build by adding miner.c to Dockerfile.alpine-musl

This commit is contained in:
Laan Tungir
2026-07-11 19:12:34 -04:00
parent 1b5af2fd33
commit 6fd7b8ce1f
14 changed files with 1644 additions and 12 deletions

View File

@@ -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 \

View File

@@ -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)

View File

@@ -178,6 +178,7 @@ Request shape is JSON-RPC with NIP-46-style methods and optional trailing select
{ "id": "2", "method": "sign_event", "params": ["<event_json>", { "role": "main" }] }
{ "id": "3", "method": "sign_event", "params": ["<event_json>", { "nostr_index": 7 }] }
{ "id": "4", "method": "sign_event", "params": ["<event_json>", { "role_path": "m/84'/0'/0'/0/5", "purpose": "bitcoin", "curve": "secp256k1" }] }
{ "id": "5", "method": "mine_event", "params": ["<event_json>", { "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": "<signed event JSON with nonce tag>",
"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.

View File

@@ -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

View File

@@ -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.");

View File

@@ -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.")

430
plans/mine_event_pow.md Normal file
View File

@@ -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 <nostr_core/nip013.h>`, 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 |

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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":"<signed event json>","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 <nostr_core/nip001.h>
#include <nostr_core/nip004.h>
#include <nostr_core/nip006.h>
#include <nostr_core/nip013.h>
#include <nostr_core/nip019.h>
#include <nostr_core/nip044.h>
#include <nostr_core/utils.h>
@@ -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;

View File

@@ -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 <unistd.h>
#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 '<json>'", 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 <target_qube> %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 '<json>'", socket_name);
if (bridge_source_trusted) {
connection_info_add("qrexec service: %s", NSIGNER_QREXEC_SERVICE_NAME);
connection_info_add("qrexec caller: qrexec-client-vm <target_qube> %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);

368
src/miner.c Normal file
View File

@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#include <pthread.h>
#include <cJSON.h>
#include <nostr_core/nip001.h>
#include <nostr_core/nip013.h>
#include <nostr_core/utils.h>
/* ---- 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", "<nonce>", "<target>"] */
{
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;
}

View File

@@ -528,6 +528,7 @@ int transport_recv_framed(int fd, char **out_payload, size_t max_size);
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
@@ -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) {

459
tests/test_mine_event.c Normal file
View File

@@ -0,0 +1,459 @@
/* Test for mine_event verb (NIP-13 Proof-of-Work) */
/* NSIGNER_HEADERLESS_DECLS_BEGIN */
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
#include <cJSON.h>
/* 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 <nostr_core/nostr_common.h>
#include <nostr_core/nip013.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}