From be3a32b488a0f52c784a954b2f354f599ec0023e Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Thu, 16 Jul 2026 20:37:06 -0400 Subject: [PATCH] Fix NIP-03 OpenTimestamps parsing, multi-calendar proofs, upgrades, tests, and CLI tooling --- .gitignore | 6 + CMakeLists.txt | 53 ++ README.md | 8 + VERSION | 2 +- examples/ots_tool.c | 680 ++++++++++++++++++++++ examples/timestamping.c | 328 +++++++++++ nostr_core/nip003.c | 954 ++++++++++++++++++++++++++++++- nostr_core/nip003.h | 175 +++++- nostr_core/nostr_core.h | 4 +- plans/nip03_timestamping_plan.md | 135 +++++ tests/nip03_live_test.c | 60 +- tests/nip03_test.c | 175 ++++++ 12 files changed, 2537 insertions(+), 43 deletions(-) create mode 100644 examples/ots_tool.c create mode 100644 examples/timestamping.c create mode 100644 plans/nip03_timestamping_plan.md diff --git a/.gitignore b/.gitignore index 09bd0a51..c894f7ca 100644 --- a/.gitignore +++ b/.gitignore @@ -29,8 +29,14 @@ build/ /examples/keypair_generation /examples/mnemonic_derivation /examples/mnemonic_generation +/examples/ots_tool /examples/relay_pool /examples/send_nip17_dm /examples/simple_keygen +/examples/timestamping /examples/utility_functions /examples/version_test + +# Local OpenTimestamps CLI output +/examples/timestamped_* +/examples/*.ots diff --git a/CMakeLists.txt b/CMakeLists.txt index 6110aa5a..4ae74ac5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,16 +42,37 @@ else() nostr_core/nostr_log.c nostr_core/request_validator.c nostr_core/nip001.c + nostr_core/nip003.c + nostr_core/nip004.c + nostr_core/nip005.c nostr_core/nip006.c + nostr_core/nip011.c + nostr_core/nip013.c + nostr_core/nip017.c nostr_core/nip019.c + nostr_core/nip021.c + nostr_core/nip042.c + nostr_core/nip044.c + nostr_core/nip046.c + nostr_core/nip059.c + nostr_core/nip060.c + nostr_core/nip061.c nostr_core/nostr_signer.c nostr_core/nsigner_transport.c nostr_core/nsigner_client.c nostr_core/utils.c + nostr_core/nostr_http.c + nostr_core/core_relays.c + nostr_core/core_relay_pool.c + nostr_core/cashu_mint.c + nostr_core/blossom_client.c nostr_core/crypto/nostr_secp256k1.c nostr_core/crypto/nostr_aes.c nostr_core/crypto/nostr_chacha20.c + nostr_core/crypto/nostr_poly1305.c + nostr_core/crypto/nostr_chacha20poly1305.c cjson/cJSON.c + nostr_websocket/nostr_websocket_openssl.c platform/linux.c ) @@ -63,4 +84,36 @@ else() nostr_websocket ) target_compile_definitions(nostr_core PRIVATE NOSTR_ENABLE_NSIGNER_CLIENT=1) + target_compile_definitions(nostr_core PRIVATE ENABLE_FILE_LOGGING ENABLE_WEBSOCKET_LOGGING ENABLE_DEBUG_LOGGING) + + # Link system dependencies + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(SECP256K1 QUIET libsecp256k1) + pkg_check_modules(OPENSSL QUIET openssl) + pkg_check_modules(CURL QUIET libcurl) + endif() + + if(SECP256K1_FOUND) + target_include_directories(nostr_core PRIVATE ${SECP256K1_INCLUDE_DIRS}) + target_link_libraries(nostr_core PRIVATE ${SECP256K1_LIBRARIES}) + else() + target_link_libraries(nostr_core PRIVATE secp256k1) + endif() + + if(OPENSSL_FOUND) + target_include_directories(nostr_core PRIVATE ${OPENSSL_INCLUDE_DIRS}) + target_link_libraries(nostr_core PRIVATE ${OPENSSL_LIBRARIES}) + else() + target_link_libraries(nostr_core PRIVATE ssl crypto) + endif() + + if(CURL_FOUND) + target_include_directories(nostr_core PRIVATE ${CURL_INCLUDE_DIRS}) + target_link_libraries(nostr_core PRIVATE ${CURL_LIBRARIES}) + else() + target_link_libraries(nostr_core PRIVATE curl) + endif() + + target_link_libraries(nostr_core PRIVATE z dl pthread m) endif() diff --git a/README.md b/README.md index 982d9199..c0295551 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,14 @@ Covered modules and functions: - NIP-01 (`nostr_core/nip001.h`) - `cJSON* nostr_create_and_sign_event_with_signer(int kind, const char* content, cJSON* tags, nostr_signer_t* signer, time_t timestamp);` +- NIP-03 OpenTimestamps (`nostr_core/nip003.h`) + - `cJSON* nostr_nip03_create_proof_event_with_signer(const char* target_event_id, int target_event_kind, const char* ots_data_base64, const char* relay_url, nostr_signer_t* signer);` + - `char* nostr_nip03_request_timestamp(const char* event_id_hex, const char* calendar_url, int timeout_seconds);` + - `int nostr_nip03_is_proof_complete(const char* ots_data_base64);` + - `int nostr_nip03_get_attestation_info(const char* ots_data_base64, int* has_bitcoin, int* has_litecoin, int* has_pending, uint64_t* bitcoin_height, uint64_t* litecoin_height, char** pending_uri_out);` + - `char* nostr_nip03_upgrade_proof(const char* ots_data_base64, const char* calendar_url, int timeout_seconds);` + - `int nostr_nip03_verify_proof(const char* ots_data_base64, const char* target_digest_hex, uint64_t* block_height_out, time_t* block_time_out);` + - NIP-17 (`nostr_core/nip017.h`) - `cJSON* nostr_nip17_create_relay_list_event_with_signer(const char** relay_urls, int num_relays, nostr_signer_t* signer);` - `int nostr_nip17_send_dm_with_signer(cJSON* dm_event, const char** recipient_pubkeys, int num_recipients, nostr_signer_t* signer, cJSON** gift_wraps_out, int max_gift_wraps, long max_delay_sec);` diff --git a/VERSION b/VERSION index 05e8a459..2228cad4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.6 +0.6.7 diff --git a/examples/ots_tool.c b/examples/ots_tool.c new file mode 100644 index 00000000..601ab5b7 --- /dev/null +++ b/examples/ots_tool.c @@ -0,0 +1,680 @@ +/* + * OpenTimestamps Command-Line Tool + * + * A simple tool for timestamping files with OpenTimestamps. + * + * Usage: + * ./ots_tool stamp Interactive: type text, save to file, submit to OTS + * ./ots_tool stamp Timestamp an existing file + * ./ots_tool check Check if a proof is complete + * ./ots_tool verify Verify a proof against a file + * ./ots_tool upgrade Upgrade a pending proof + * + * The "stamp" command: + * 1. If no file argument, prompts for text input and saves it to a .txt file + * 2. Computes the SHA-256 hash of the file + * 3. Submits the hash to an OpenTimestamps calendar + * 4. Saves the initial .ots proof to .ots + * 5. Polls every 60 seconds to check if the proof is complete + * 6. When complete, saves the upgraded proof and verifies it + * + * Options: + * --calendar Use a specific OTS calendar (default: https://alice.btc.calendar.opentimestamps.org) + * --interval Poll interval in seconds (default: 60) + * --max-wait Maximum wait time in minutes (default: 120 = 2 hours) + */ + +#define _POSIX_C_SOURCE 200809L + +#include "nostr_core/nostr_core.h" +#include "nostr_core/nip003.h" +#include +#include +#include +#include +#include +#include + +#define DEFAULT_CALENDAR "https://alice.btc.calendar.opentimestamps.org" +#define DEFAULT_INTERVAL_SEC 60 +#define DEFAULT_MAX_WAIT_MIN 120 + +/* Read an entire file into a malloc'd buffer. Returns NULL on error. */ +static unsigned char* read_file(const char* path, size_t* len_out) { + FILE* f = fopen(path, "rb"); + if (!f) return NULL; + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size < 0) { + fclose(f); + return NULL; + } + + unsigned char* buf = (unsigned char*)malloc(size + 1); + if (!buf) { + fclose(f); + return NULL; + } + + size_t nread = fread(buf, 1, size, f); + fclose(f); + + if (nread != (size_t)size) { + free(buf); + return NULL; + } + + buf[size] = '\0'; + *len_out = (size_t)size; + return buf; +} + +/* Write data to a file. Returns 0 on success, -1 on error. */ +static int write_file(const char* path, const unsigned char* data, size_t len) { + FILE* f = fopen(path, "wb"); + if (!f) return -1; + + size_t nwritten = fwrite(data, 1, len, f); + fclose(f); + + return (nwritten == len) ? 0 : -1; +} + +/* Compute SHA-256 of a file and return as hex string (64 chars + null). */ +static int compute_file_sha256_hex(const char* path, char* hex_out) { + size_t file_len = 0; + unsigned char* file_data = read_file(path, &file_len); + if (!file_data) return -1; + + unsigned char digest[32]; + int rc = nostr_sha256(file_data, file_len, digest); + free(file_data); + + if (rc != NOSTR_SUCCESS) return -1; + + nostr_bytes_to_hex(digest, 32, hex_out); + return 0; +} + +/* Convert base64 string to binary file and save. */ +static int save_base64_to_file(const char* path, const char* b64) { + if (!b64) return -1; + + size_t b64_len = strlen(b64); + size_t max_decoded = (b64_len / 4) * 3 + 3; + unsigned char* data = (unsigned char*)malloc(max_decoded); + if (!data) return -1; + + size_t data_len = base64_decode(b64, data); + if (data_len == 0) { + free(data); + return -1; + } + + int rc = write_file(path, data, data_len); + free(data); + return rc; +} + +/* Read a binary file and convert to base64 string. Caller must free. */ +static char* read_file_as_base64(const char* path) { + size_t file_len = 0; + unsigned char* file_data = read_file(path, &file_len); + if (!file_data) return NULL; + + size_t b64_size = ((file_len + 2) / 3) * 4 + 1; + char* b64 = (char*)malloc(b64_size); + if (!b64) { + free(file_data); + return NULL; + } + + base64_encode(file_data, file_len, b64, b64_size); + free(file_data); + return b64; +} + +static void print_usage(const char* prog) { + printf("OpenTimestamps Command-Line Tool\n"); + printf("\n"); + printf("Usage:\n"); + printf(" %s stamp Interactive: type text, save, submit to OTS\n", prog); + printf(" %s stamp Timestamp an existing file\n", prog); + printf(" %s check Check if a proof is complete\n", prog); + printf(" %s verify Verify a proof against a file\n", prog); + printf(" %s upgrade [file] Upgrade a pending proof (optional: original file)\n", prog); + printf("\n"); + printf("Options:\n"); + printf(" --calendar OTS calendar URL (default: %s)\n", DEFAULT_CALENDAR); + printf(" --interval Poll interval in seconds (default: %d)\n", DEFAULT_INTERVAL_SEC); + printf(" --max-wait Max wait time in minutes (default: %d)\n", DEFAULT_MAX_WAIT_MIN); + printf("\n"); + printf("Examples:\n"); + printf(" %s stamp # Type text, timestamp it\n", prog); + printf(" %s stamp mydocument.txt # Timestamp a file\n", prog); + printf(" %s check mydocument.txt.ots # Check proof status\n", prog); + printf(" %s verify mydocument.txt mydocument.txt.ots # Verify proof\n", prog); +} + +static int do_stamp(const char* file_path, const char* calendar_url, + int interval_sec, int max_wait_min) { + char auto_file[256] = {0}; + const char* target_file = file_path; + + /* If no file specified, prompt for text input */ + if (!target_file) { + printf("๐Ÿ“ Enter text to timestamp (press Enter twice to finish):\n\n"); + + /* Read multi-line input until empty line */ + char* content = NULL; + size_t content_cap = 0; + size_t content_len = 0; + char line[1024]; + int empty_count = 0; + + while (fgets(line, sizeof(line), stdin)) { + if (line[0] == '\n') { + empty_count++; + if (empty_count >= 1 && content_len > 0) { + break; + } + } else { + empty_count = 0; + } + + size_t line_len = strlen(line); + if (content_len + line_len + 1 > content_cap) { + content_cap = (content_len + line_len + 1) * 2; + char* new_content = (char*)realloc(content, content_cap); + if (!new_content) { + free(content); + printf("โŒ Out of memory\n"); + return 1; + } + content = new_content; + } + memcpy(content + content_len, line, line_len); + content_len += line_len; + content[content_len] = '\0'; + } + + if (!content || content_len == 0) { + printf("โŒ No text entered.\n"); + free(content); + return 1; + } + + /* Generate a filename based on timestamp */ + time_t now = time(NULL); + struct tm* tm_info = localtime(&now); + strftime(auto_file, sizeof(auto_file), "timestamped_%Y%m%d_%H%M%S.txt", tm_info); + + /* Save the text to a file */ + if (write_file(auto_file, (const unsigned char*)content, content_len) != 0) { + printf("โŒ Failed to save text to file: %s\n", auto_file); + free(content); + return 1; + } + + printf("\n๐Ÿ’พ Saved text to: %s (%zu bytes)\n", auto_file, content_len); + free(content); + target_file = auto_file; + } + + /* Compute SHA-256 of the file */ + char digest_hex[65]; + if (compute_file_sha256_hex(target_file, digest_hex) != 0) { + printf("โŒ Failed to compute SHA-256 of file: %s\n", target_file); + return 1; + } + + printf("๐Ÿ” SHA-256: %s\n", digest_hex); + + /* Submit to multiple OTS calendars for redundancy */ + const char* default_calendars[] = { + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com", + "https://btc.calendar.catallaxy.com", + }; + int num_calendars = 4; + + /* If a specific calendar was specified, use only that one */ + const char** calendars_to_use; + int num_to_use; + if (strcmp(calendar_url, DEFAULT_CALENDAR) != 0) { + calendars_to_use = &calendar_url; + num_to_use = 1; + } else { + calendars_to_use = default_calendars; + num_to_use = num_calendars; + } + + printf("\n๐Ÿ“ค Submitting to %d OpenTimestamps calendar(s)...\n", num_to_use); + + /* Submit to all calendars and create a combined DetachedTimestampFile */ + char* ots_b64 = nostr_nip03_stamp_with_multiple_calendars( + digest_hex, calendars_to_use, num_to_use, 30); + if (!ots_b64) { + printf("โŒ Failed to submit to any calendar.\n"); + printf(" Check your internet connection and try again.\n"); + return 1; + } + + /* Save the .ots proof */ + char ots_path[512]; + snprintf(ots_path, sizeof(ots_path), "%s.ots", target_file); + + if (save_base64_to_file(ots_path, ots_b64) != 0) { + printf("โŒ Failed to save .ots proof to: %s\n", ots_path); + free(ots_b64); + return 1; + } + + printf("โœ… Proof saved to: %s\n", ots_path); + + /* Show attestation info - list all pending calendars */ + char** pending_uris = NULL; + int pending_count = nostr_nip03_get_pending_uris(ots_b64, &pending_uris, 16); + if (pending_count > 0) { + printf(" Pending at %d calendar(s):\n", pending_count); + for (int i = 0; i < pending_count; i++) { + printf(" โ€ข %s\n", pending_uris[i]); + free(pending_uris[i]); + } + free(pending_uris); + } + + /* Check if already complete (unlikely but possible) */ + int complete = nostr_nip03_is_proof_complete(ots_b64); + if (complete == 1) { + printf("\n๐ŸŽ‰ Proof is already complete!\n"); + free(ots_b64); + return 0; + } + + /* Poll for completion */ + int max_attempts = (max_wait_min * 60) / interval_sec; + printf("\nโณ Waiting for Bitcoin attestation...\n"); + printf(" Polling every %d seconds (max %d minutes)\n", interval_sec, max_wait_min); + printf(" Press Ctrl+C to stop. The .ots file can be checked later with 'check'.\n\n"); + + int attempts = 0; + while (!complete && attempts < max_attempts) { + attempts++; + + time_t now = time(NULL); + char time_str[26]; + ctime_r(&now, time_str); + time_str[24] = '\0'; + + printf("[%s] Attempt %d/%d: %s\n", time_str, attempts, max_attempts, + complete ? "โœ… COMPLETE" : "โณ PENDING"); + fflush(stdout); + + if (complete) break; + + sleep(interval_sec); + + /* Try to upgrade the proof by re-submitting to all calendars */ + char* upgraded = nostr_nip03_stamp_with_multiple_calendars( + digest_hex, calendars_to_use, num_to_use, 30); + if (upgraded) { + free(ots_b64); + ots_b64 = upgraded; + + /* Save the upgraded proof */ + save_base64_to_file(ots_path, ots_b64); + + /* Check if now complete */ + complete = nostr_nip03_is_proof_complete(ots_b64); + } + } + + if (complete) { + printf("\n๐ŸŽ‰ SUCCESS! Proof is complete with a Bitcoin attestation!\n"); + + /* Save the final proof */ + save_base64_to_file(ots_path, ots_b64); + printf(" Final proof saved to: %s\n", ots_path); + + /* Show attestation details */ + int has_bitcoin = 0; + uint64_t btc_height = 0; + if (nostr_nip03_get_attestation_info(ots_b64, &has_bitcoin, NULL, NULL, + &btc_height, NULL, NULL) == 0) { + if (has_bitcoin) { + printf(" Bitcoin block height: %llu\n", (unsigned long long)btc_height); + } + } + + /* Verify the proof */ + uint64_t verify_height = 0; + int verify_rc = nostr_nip03_verify_proof(ots_b64, digest_hex, &verify_height, NULL); + if (verify_rc == 0) { + printf(" โœ… Verified: file digest matches, Bitcoin height=%llu\n", + (unsigned long long)verify_height); + } else { + printf(" โš ๏ธ Verification returned: %d\n", verify_rc); + } + } else { + printf("\nโณ Proof is still pending after %d minutes.\n", max_wait_min); + printf(" The .ots file has been saved: %s\n", ots_path); + printf(" Check again later with: ./ots_tool check %s\n", ots_path); + printf(" Or upgrade with: ./ots_tool upgrade %s\n", ots_path); + } + + free(ots_b64); + return complete ? 0 : 2; +} + +static int do_check(const char* ots_path) { + printf("๐Ÿ”Ž Checking proof: %s\n\n", ots_path); + + char* ots_b64 = read_file_as_base64(ots_path); + if (!ots_b64) { + printf("โŒ Failed to read .ots file: %s\n", ots_path); + return 1; + } + + int complete = nostr_nip03_is_proof_complete(ots_b64); + + if (complete == 1) { + printf("โœ… Proof is COMPLETE (has Bitcoin/Litecoin attestation)\n"); + } else if (complete == 0) { + printf("โณ Proof is PENDING (no Bitcoin attestation yet)\n"); + } else { + printf("โŒ Failed to parse proof (invalid OTS file?)\n"); + free(ots_b64); + return 1; + } + + /* Show detailed attestation info */ + int has_bitcoin = 0, has_litecoin = 0, has_pending = 0; + uint64_t btc_height = 0, ltc_height = 0; + + if (nostr_nip03_get_attestation_info(ots_b64, &has_bitcoin, &has_litecoin, &has_pending, + &btc_height, <c_height, NULL) == 0) { + if (has_bitcoin) { + printf(" โœ… Bitcoin attestation: block height %llu\n", (unsigned long long)btc_height); + } + if (has_litecoin) { + printf(" โœ… Litecoin attestation: block height %llu\n", (unsigned long long)ltc_height); + } + } + + /* Show all pending calendar URIs */ + if (has_pending) { + char** pending_uris = NULL; + int pending_count = nostr_nip03_get_pending_uris(ots_b64, &pending_uris, 16); + if (pending_count > 0) { + printf(" โณ Pending at %d calendar(s):\n", pending_count); + for (int i = 0; i < pending_count; i++) { + printf(" โ€ข %s\n", pending_uris[i]); + free(pending_uris[i]); + } + free(pending_uris); + } + } + + free(ots_b64); + return 0; +} + +static int do_verify(const char* file_path, const char* ots_path) { + printf("๐Ÿ” Verifying proof...\n"); + printf(" File: %s\n", file_path); + printf(" Proof: %s\n\n", ots_path); + + /* Compute SHA-256 of the file */ + char digest_hex[65]; + if (compute_file_sha256_hex(file_path, digest_hex) != 0) { + printf("โŒ Failed to compute SHA-256 of file: %s\n", file_path); + return 1; + } + printf(" File SHA-256: %s\n", digest_hex); + + /* Read the .ots file */ + char* ots_b64 = read_file_as_base64(ots_path); + if (!ots_b64) { + printf("โŒ Failed to read .ots file: %s\n", ots_path); + return 1; + } + + /* Verify the proof */ + uint64_t block_height = 0; + time_t block_time = 0; + int rc = nostr_nip03_verify_proof(ots_b64, digest_hex, &block_height, &block_time); + + switch (rc) { + case 0: + printf("\nโœ… Proof VERIFIED!\n"); + printf(" Bitcoin block height: %llu\n", (unsigned long long)block_height); + printf(" The file's hash is committed in the Bitcoin blockchain.\n"); + free(ots_b64); + return 0; + + case 1: + printf("\nโณ Proof is valid but still PENDING (no Bitcoin attestation yet).\n"); + printf(" The file digest matches the proof.\n"); + printf(" Wait for the calendar to commit to the blockchain, then run:\n"); + printf(" ./ots_tool upgrade %s\n", ots_path); + free(ots_b64); + return 1; + + case -1: + printf("\nโŒ Failed to parse OTS proof. The .ots file may be corrupted.\n"); + free(ots_b64); + return 1; + + case -2: + printf("\nโŒ DIGEST MISMATCH! The proof does not match this file.\n"); + printf(" The file may have been modified after timestamping.\n"); + free(ots_b64); + return 1; + + default: + printf("\nโŒ Unknown error: %d\n", rc); + free(ots_b64); + return 1; + } +} + +static int do_upgrade(const char* ots_path, const char* file_path, + const char* calendar_url) { + printf("โฌ†๏ธ Upgrading proof: %s\n\n", ots_path); + + char* ots_b64 = read_file_as_base64(ots_path); + if (!ots_b64) { + printf("โŒ Failed to read .ots file: %s\n", ots_path); + return 1; + } + + /* Check current status */ + int was_complete = nostr_nip03_is_proof_complete(ots_b64); + if (was_complete == 1) { + printf("โœ… Proof is already complete! No upgrade needed.\n"); + free(ots_b64); + return 0; + } + + /* Use multiple calendars for upgrade */ + const char* default_calendars[] = { + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com", + "https://btc.calendar.catallaxy.com", + }; + + /* Determine which calendars to use */ + const char** calendars_to_use; + int num_to_use; + if (strcmp(calendar_url, DEFAULT_CALENDAR) != 0) { + calendars_to_use = &calendar_url; + num_to_use = 1; + } else { + calendars_to_use = default_calendars; + num_to_use = 4; + } + + printf("๐Ÿ“ค Re-submitting to %d calendar(s)...\n", num_to_use); + + /* Get the digest: try extracting from proof, or compute from file */ + char digest_hex[65] = {0}; + int have_digest = 0; + + /* Try extracting from the proof (works for full DetachedTimestampFile) */ + /* The upgrade_proof function does this internally, but we need the digest + * for the multi-calendar stamp function */ + if (file_path) { + if (compute_file_sha256_hex(file_path, digest_hex) == 0) { + have_digest = 1; + } + } + + char* upgraded = NULL; + if (have_digest) { + printf(" Digest: %s\n", digest_hex); + upgraded = nostr_nip03_stamp_with_multiple_calendars( + digest_hex, calendars_to_use, num_to_use, 30); + } else { + /* Fall back to single-calendar upgrade via embedded digest */ + upgraded = nostr_nip03_upgrade_proof(ots_b64, calendar_url, 30); + } + + if (!upgraded) { + printf("โณ No upgrade available yet. The proof is still pending.\n"); + printf(" Bitcoin attestations typically take 1-12 hours.\n"); + if (!file_path) { + printf(" Tip: provide the original file path to enable digest-based upgrade:\n"); + printf(" ./ots_tool upgrade %s \n", ots_path); + } + free(ots_b64); + return 1; + } + + /* Save the upgraded proof */ + if (save_base64_to_file(ots_path, upgraded) != 0) { + printf("โŒ Failed to save upgraded proof.\n"); + free(ots_b64); + free(upgraded); + return 1; + } + + /* Check if now complete */ + int complete = nostr_nip03_is_proof_complete(upgraded); + if (complete == 1) { + printf("โœ… Upgrade successful! Proof is now COMPLETE with a Bitcoin attestation.\n"); + + int has_bitcoin = 0; + uint64_t btc_height = 0; + if (nostr_nip03_get_attestation_info(upgraded, &has_bitcoin, NULL, NULL, + &btc_height, NULL, NULL) == 0) { + if (has_bitcoin) { + printf(" Bitcoin block height: %llu\n", (unsigned long long)btc_height); + } + } + } else { + printf("โณ Upgrade received but proof is still pending.\n"); + printf(" The upgraded proof has been saved. Try again later.\n"); + } + + printf(" Updated proof saved to: %s\n", ots_path); + + free(ots_b64); + free(upgraded); + return 0; +} + +int main(int argc, char* argv[]) { + if (nostr_init() != NOSTR_SUCCESS) { + fprintf(stderr, "Failed to initialize NOSTR library\n"); + return 1; + } + + const char* calendar_url = DEFAULT_CALENDAR; + int interval_sec = DEFAULT_INTERVAL_SEC; + int max_wait_min = DEFAULT_MAX_WAIT_MIN; + + enum { MODE_NONE, MODE_STAMP, MODE_CHECK, MODE_VERIFY, MODE_UPGRADE } mode = MODE_NONE; + const char* arg1 = NULL; + const char* arg2 = NULL; + + /* Parse arguments */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + print_usage(argv[0]); + nostr_cleanup(); + return 0; + } else if (strcmp(argv[i], "--calendar") == 0 && i + 1 < argc) { + calendar_url = argv[++i]; + } else if (strcmp(argv[i], "--interval") == 0 && i + 1 < argc) { + interval_sec = atoi(argv[++i]); + } else if (strcmp(argv[i], "--max-wait") == 0 && i + 1 < argc) { + max_wait_min = atoi(argv[++i]); + } else if (strcmp(argv[i], "stamp") == 0) { + mode = MODE_STAMP; + } else if (strcmp(argv[i], "check") == 0) { + mode = MODE_CHECK; + } else if (strcmp(argv[i], "verify") == 0) { + mode = MODE_VERIFY; + } else if (strcmp(argv[i], "upgrade") == 0) { + mode = MODE_UPGRADE; + } else if (argv[i][0] != '-') { + if (!arg1) { + arg1 = argv[i]; + } else if (!arg2) { + arg2 = argv[i]; + } + } + } + + int rc = 0; + + switch (mode) { + case MODE_STAMP: + rc = do_stamp(arg1, calendar_url, interval_sec, max_wait_min); + break; + + case MODE_CHECK: + if (!arg1) { + printf("โŒ Missing .ots file path\n"); + print_usage(argv[0]); + rc = 1; + } else { + rc = do_check(arg1); + } + break; + + case MODE_VERIFY: + if (!arg1 || !arg2) { + printf("โŒ Missing file or .ots path\n"); + print_usage(argv[0]); + rc = 1; + } else { + rc = do_verify(arg1, arg2); + } + break; + + case MODE_UPGRADE: + if (!arg1) { + printf("โŒ Missing .ots file path\n"); + print_usage(argv[0]); + rc = 1; + } else { + rc = do_upgrade(arg1, arg2, calendar_url); + } + break; + + default: + print_usage(argv[0]); + rc = 1; + break; + } + + nostr_cleanup(); + return rc; +} diff --git a/examples/timestamping.c b/examples/timestamping.c new file mode 100644 index 00000000..11a106ce --- /dev/null +++ b/examples/timestamping.c @@ -0,0 +1,328 @@ +/* + * NIP-03 OpenTimestamps Example + * + * This example demonstrates the full NIP-03 timestamping workflow: + * 1. Create a Nostr event + * 2. Submit the event ID to an OpenTimestamps calendar + * 3. Poll for proof completion (Bitcoin attestation) + * 4. Create a NIP-03 proof event (kind 1040) + * 5. Verify the proof + * + * Usage: + * ./timestamping # Create and timestamp a test event + * ./timestamping # Timestamp an existing event ID + * ./timestamping --verify # Verify a proof + * + * Note: Bitcoin attestations typically take 1-12 hours to appear. + * The OpenTimestamps calendars commit to the Bitcoin blockchain + * periodically, so you may need to wait and poll for completion. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "nostr_core/nostr_core.h" +#include "nostr_core/nip003.h" +#include +#include +#include +#include +#include + +#define DEFAULT_CALENDAR_URL "https://alice.btc.calendar.opentimestamps.org" +#define POLL_INTERVAL_SEC 300 /* 5 minutes */ +#define MAX_POLL_ATTEMPTS 144 /* 12 hours total */ + +static void print_usage(const char* prog_name) { + printf("NIP-03 OpenTimestamps Example\n"); + printf("\n"); + printf("Usage:\n"); + printf(" %s Create and timestamp a test event\n", prog_name); + printf(" %s Timestamp an existing event ID (64-char hex)\n", prog_name); + printf(" %s --verify Verify a base64-encoded OTS proof\n", prog_name); + printf(" %s --check Check if a proof is complete\n", prog_name); + printf("\n"); + printf("Options:\n"); + printf(" --calendar Use a specific OTS calendar URL\n"); + printf(" --interval Poll interval in seconds (default: %d)\n", POLL_INTERVAL_SEC); + printf(" --max-attempts Max poll attempts (default: %d)\n", MAX_POLL_ATTEMPTS); + printf("\n"); + printf("Common calendar URLs:\n"); + printf(" https://alice.btc.calendar.opentimestamps.org\n"); + printf(" https://bob.btc.calendar.opentimestamps.org\n"); + printf(" https://finney.calendar.eternitywall.com\n"); +} + +static void print_attestation_info(const char* ots_b64) { + int has_bitcoin = 0, has_litecoin = 0, has_pending = 0; + uint64_t btc_height = 0, ltc_height = 0; + char* pending_uri = NULL; + + if (nostr_nip03_get_attestation_info(ots_b64, &has_bitcoin, &has_litecoin, &has_pending, + &btc_height, <c_height, &pending_uri) != 0) { + printf(" โš ๏ธ Failed to parse attestation info\n"); + return; + } + + if (has_bitcoin) { + printf(" โœ… Bitcoin attestation: block height %llu\n", (unsigned long long)btc_height); + } + if (has_litecoin) { + printf(" โœ… Litecoin attestation: block height %llu\n", (unsigned long long)ltc_height); + } + if (has_pending) { + if (pending_uri) { + printf(" โณ Pending at: %s\n", pending_uri); + free(pending_uri); + } + } + if (!has_bitcoin && !has_litecoin && !has_pending) { + printf(" โš ๏ธ No attestations found\n"); + } +} + +static int do_timestamp(const char* event_id_hex, const char* calendar_url, + int poll_interval, int max_attempts) { + printf("๐Ÿ“… NIP-03 Timestamping\n"); + printf(" Event ID: %s\n", event_id_hex); + printf(" Calendar: %s\n", calendar_url); + printf("\n"); + + /* Step 1: Submit to calendar */ + printf("๐Ÿ“ค Submitting to OpenTimestamps calendar...\n"); + char* ots_b64 = nostr_nip03_request_timestamp(event_id_hex, calendar_url, 30); + if (!ots_b64) { + printf("โŒ Failed to submit to calendar.\n"); + printf(" Check your internet connection and the calendar URL.\n"); + return 1; + } + + printf("โœ… Initial proof received.\n"); + print_attestation_info(ots_b64); + printf("\n"); + + /* Step 2: Poll for completion */ + printf("โณ Polling for Bitcoin attestation (every %d seconds, max %d attempts)...\n", + poll_interval, max_attempts); + + int complete = 0; + int attempts = 0; + + while (!complete && attempts < max_attempts) { + attempts++; + complete = nostr_nip03_is_proof_complete(ots_b64); + + time_t now = time(NULL); + char time_str[26]; + ctime_r(&now, time_str); + time_str[24] = '\0'; + + printf("[%s] Attempt %d/%d: %s\n", time_str, attempts, max_attempts, + complete ? "โœ… COMPLETE" : "โณ PENDING"); + + if (!complete) { + sleep(poll_interval); + + /* Try to upgrade the proof */ + char* upgraded = nostr_nip03_upgrade_proof(ots_b64, calendar_url, 30); + if (upgraded) { + free(ots_b64); + ots_b64 = upgraded; + } + } + } + + if (complete) { + printf("\n๐ŸŽ‰ SUCCESS! The proof is now complete with a Bitcoin attestation.\n"); + print_attestation_info(ots_b64); + + /* Verify the proof */ + uint64_t block_height = 0; + int verify_rc = nostr_nip03_verify_proof(ots_b64, event_id_hex, &block_height, NULL); + if (verify_rc == 0) { + printf(" โœ… Proof verified: digest matches, Bitcoin height=%llu\n", + (unsigned long long)block_height); + } else { + printf(" โš ๏ธ Proof verification returned: %d\n", verify_rc); + } + + /* Create the NIP-03 proof event */ + unsigned char private_key[32]; + unsigned char public_key[32]; + nostr_generate_keypair(private_key, public_key); + + cJSON* proof_event = nostr_nip03_create_proof_event(event_id_hex, 1, ots_b64, NULL, private_key); + if (proof_event) { + char* json = cJSON_Print(proof_event); + printf("\n๐Ÿ“„ NIP-03 Proof Event (kind 1040):\n%s\n", json); + free(json); + cJSON_Delete(proof_event); + } + + /* Print the base64 OTS data for storage */ + printf("\n๐Ÿ“ฆ OTS proof (base64, save this):\n%s\n", ots_b64); + } else { + printf("\nโณ Proof is still pending after %d attempts.\n", attempts); + printf(" Bitcoin attestations can take several hours.\n"); + printf(" Save the OTS proof and check again later:\n%s\n", ots_b64); + } + + free(ots_b64); + return complete ? 0 : 2; +} + +static int do_verify(const char* ots_b64, const char* target_digest_hex) { + printf("๐Ÿ” Verifying OTS proof...\n\n"); + + uint64_t block_height = 0; + time_t block_time = 0; + + int rc = nostr_nip03_verify_proof(ots_b64, target_digest_hex, &block_height, &block_time); + + switch (rc) { + case 0: + printf("โœ… Proof verified!\n"); + printf(" Bitcoin block height: %llu\n", (unsigned long long)block_height); + print_attestation_info(ots_b64); + return 0; + case 1: + printf("โณ Proof is valid but has no Bitcoin attestation (still pending).\n"); + print_attestation_info(ots_b64); + return 1; + case -1: + printf("โŒ Failed to parse OTS proof.\n"); + return 1; + case -2: + printf("โŒ Digest mismatch! The proof does not match the expected digest.\n"); + return 1; + default: + printf("โŒ Unknown error: %d\n", rc); + return 1; + } +} + +static int do_check(const char* ots_b64) { + printf("๐Ÿ”Ž Checking OTS proof status...\n\n"); + + int complete = nostr_nip03_is_proof_complete(ots_b64); + + if (complete == 1) { + printf("โœ… Proof is COMPLETE (has Bitcoin/Litecoin attestation)\n"); + } else if (complete == 0) { + printf("โณ Proof is PENDING (no Bitcoin attestation yet)\n"); + } else { + printf("โŒ Failed to parse proof (error)\n"); + return 1; + } + + print_attestation_info(ots_b64); + return 0; +} + +int main(int argc, char* argv[]) { + if (nostr_init() != NOSTR_SUCCESS) { + fprintf(stderr, "Failed to initialize NOSTR library\n"); + return 1; + } + + const char* calendar_url = DEFAULT_CALENDAR_URL; + int poll_interval = POLL_INTERVAL_SEC; + int max_attempts = MAX_POLL_ATTEMPTS; + const char* event_id = NULL; + const char* ots_b64 = NULL; + const char* verify_digest = NULL; + + enum { MODE_TIMESTAMP, MODE_VERIFY, MODE_CHECK } mode = MODE_TIMESTAMP; + + /* Parse arguments */ + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + print_usage(argv[0]); + nostr_cleanup(); + return 0; + } else if (strcmp(argv[i], "--verify") == 0) { + mode = MODE_VERIFY; + /* OTS data is provided as a positional argument */ + } else if (strcmp(argv[i], "--check") == 0) { + mode = MODE_CHECK; + /* OTS data is provided as a positional argument */ + } else if (strcmp(argv[i], "--calendar") == 0 && i + 1 < argc) { + calendar_url = argv[++i]; + } else if (strcmp(argv[i], "--interval") == 0 && i + 1 < argc) { + poll_interval = atoi(argv[++i]); + } else if (strcmp(argv[i], "--max-attempts") == 0 && i + 1 < argc) { + max_attempts = atoi(argv[++i]); + } else if (strcmp(argv[i], "--digest") == 0 && i + 1 < argc) { + verify_digest = argv[++i]; + } else if (argv[i][0] != '-' && !event_id && !ots_b64) { + if (mode == MODE_VERIFY || mode == MODE_CHECK) { + if (!ots_b64) ots_b64 = argv[i]; + } else { + event_id = argv[i]; + } + } + } + + int rc = 0; + + switch (mode) { + case MODE_TIMESTAMP: + if (!event_id) { + /* Create a test event */ + printf("๐Ÿงช Creating a test event to timestamp...\n"); + unsigned char private_key[32]; + unsigned char public_key[32]; + nostr_generate_keypair(private_key, public_key); + + char content[128]; + snprintf(content, sizeof(content), "NIP-03 timestamp test at %ld", time(NULL)); + + cJSON* event = nostr_create_and_sign_event(1, content, NULL, private_key, 0); + if (!event) { + printf("โŒ Failed to create test event\n"); + nostr_cleanup(); + return 1; + } + + cJSON* id_item = cJSON_GetObjectItem(event, "id"); + if (!id_item || !cJSON_IsString(id_item)) { + printf("โŒ Failed to get event ID\n"); + cJSON_Delete(event); + nostr_cleanup(); + return 1; + } + + char* event_json = cJSON_Print(event); + printf("๐Ÿ“„ Test event:\n%s\n\n", event_json); + free(event_json); + + rc = do_timestamp(id_item->valuestring, calendar_url, poll_interval, max_attempts); + cJSON_Delete(event); + } else { + rc = do_timestamp(event_id, calendar_url, poll_interval, max_attempts); + } + break; + + case MODE_VERIFY: + if (!ots_b64) { + printf("โŒ Missing OTS proof (base64)\n"); + print_usage(argv[0]); + rc = 1; + } else { + rc = do_verify(ots_b64, verify_digest); + } + break; + + case MODE_CHECK: + if (!ots_b64) { + printf("โŒ Missing OTS proof (base64)\n"); + print_usage(argv[0]); + rc = 1; + } else { + rc = do_check(ots_b64); + } + break; + } + + nostr_cleanup(); + return rc; +} diff --git a/nostr_core/nip003.c b/nostr_core/nip003.c index 87784c0e..c7888042 100644 --- a/nostr_core/nip003.c +++ b/nostr_core/nip003.c @@ -6,10 +6,379 @@ #include "nip001.h" #include "nostr_http.h" #include "utils.h" +#include "nostr_signer.h" #include #include #include +/* Default OTS calendars for multi-calendar stamping */ +const char* NOSTR_OTS_DEFAULT_CALENDARS[NOSTR_OTS_DEFAULT_CALENDAR_COUNT] = { + "https://alice.btc.calendar.opentimestamps.org", + "https://bob.btc.calendar.opentimestamps.org", + "https://finney.calendar.eternitywall.com", + "https://btc.calendar.catallaxy.com" +}; + +/* ========================================================================== */ +/* OTS Binary Format Parser */ +/* ========================================================================== */ + +/* + * OTS File Format (verified against real .ots files from opentimestamps.org): + * + * 1. Header magic (31 bytes): + * \x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94 + * 2. Major version (LEB128 varuint): typically 0x01 + * 3. File hash op (1 byte): 0x08 = SHA256 + * 4. File digest (32 bytes for SHA256) + * 5. Timestamp tree (recursive) + * + * Timestamp tree: + * - Read tag byte + * - 0xff = separator (process next tag, keep looping) + * - 0x00 = attestation follows + * - other = op code (process op, recursively parse sub-timestamp) + * + * Attestation (after 0x00): + * - 8-byte tag: + * 83dfe30d2ef90c8e = PendingAttestation + * 0588960d73d71901 = BitcoinBlockHeaderAttestation + * 06856e0d73d71901 = LitecoinBlockHeaderAttestation + * - varuint payload length + * - payload (Pending: varbytes URI; Bitcoin/Litecoin: varuint height) + * + * Op codes: + * 0x02 = SHA1, 0x03 = RIPEMD160, 0x08 = SHA256, 0x67 = KECCAK256 + * 0xf0 = Append (varbytes arg), 0xf1 = Prepend (varbytes arg) + * 0xf2 = Reverse, 0xf3 = Hexlify + */ + +/* OTS Header magic (31 bytes) */ +static const unsigned char OTS_HEADER_MAGIC[] = { + 0x00, 'O', 'p', 'e', 'n', 'T', 'i', 'm', 'e', 's', 't', 'a', 'm', 'p', 's', 0x00, + 0x00, 'P', 'r', 'o', 'o', 'f', 0x00, + 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94 +}; +#define OTS_HEADER_MAGIC_LEN 31 + +/* Attestation tags (8 bytes each) */ +static const unsigned char OTS_PENDING_TAG[] = { + 0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e +}; +static const unsigned char OTS_BITCOIN_TAG[] = { + 0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01 +}; +static const unsigned char OTS_LITECOIN_TAG[] = { + 0x06, 0x85, 0x6e, 0x0d, 0x73, 0xd7, 0x19, 0x01 +}; + +/* Op code constants */ +#define OTS_OP_SHA1 0x02 +#define OTS_OP_RIPEMD160 0x03 +#define OTS_OP_SHA256 0x08 +#define OTS_OP_KECCAK256 0x67 +#define OTS_OP_APPEND 0xf0 +#define OTS_OP_PREPEND 0xf1 +#define OTS_OP_REVERSE 0xf2 +#define OTS_OP_HEXLIFY 0xf3 + +#define OTS_TAG_SEPARATOR 0xff +#define OTS_TAG_ATTESTATION 0x00 + +/* Digest lengths for hash ops */ +static size_t ots_digest_length(unsigned char op) { + switch (op) { + case OTS_OP_SHA1: return 20; + case OTS_OP_RIPEMD160: return 20; + case OTS_OP_SHA256: return 32; + case OTS_OP_KECCAK256: return 32; + default: return 0; /* unknown */ + } +} + +/* Read a LEB128-encoded varuint. Returns 0 on success, -1 on error. */ +static int ots_read_varuint(const unsigned char* data, size_t data_len, + size_t* pos, uint64_t* value_out) { + uint64_t value = 0; + int shift = 0; + + while (*pos < data_len) { + unsigned char b = data[*pos]; + (*pos)++; + + value |= (uint64_t)(b & 0x7f) << shift; + + if (!(b & 0x80)) { + *value_out = value; + return 0; + } + + shift += 7; + if (shift >= 64) { + return -1; /* overflow */ + } + } + + return -1; /* truncated */ +} + +/* Read varbytes (varuint length prefix + data). Returns 0 on success, -1 on error. */ +static int ots_read_varbytes(const unsigned char* data, size_t data_len, + size_t* pos, const unsigned char** bytes_out, + size_t* len_out) { + uint64_t length = 0; + + if (ots_read_varuint(data, data_len, pos, &length) != 0) { + return -1; + } + + if (*pos + length > data_len) { + return -1; /* truncated */ + } + + *bytes_out = data + *pos; + *len_out = (size_t)length; + *pos += (size_t)length; + + return 0; +} + +/* Attestation types */ +typedef enum { + OTS_ATTESTATION_UNKNOWN = 0, + OTS_ATTESTATION_PENDING, + OTS_ATTESTATION_BITCOIN, + OTS_ATTESTATION_LITECOIN +} ots_attestation_type_t; + +/* Parsed attestation info */ +typedef struct { + ots_attestation_type_t type; + uint64_t height; /* Bitcoin/Litecoin block height */ + const char* uri; /* Pending attestation URI (points into data, NOT null-terminated) */ + size_t uri_len; /* URI length */ +} ots_attestation_t; + +/* Maximum attestations we can find in one proof */ +#define OTS_MAX_ATTESTATIONS 32 + +/* Context for collecting attestations */ +typedef struct { + ots_attestation_t attestations[OTS_MAX_ATTESTATIONS]; + int count; +} ots_attestation_list_t; + +/* Parse a single attestation (after the 0x00 tag). Returns 0 on success, -1 on error. */ +static int ots_parse_attestation(const unsigned char* data, size_t data_len, + size_t* pos, ots_attestation_t* attestation) { + if (*pos + 8 > data_len) { + return -1; /* truncated */ + } + + const unsigned char* tag = data + *pos; + *pos += 8; + + /* Read payload (varbytes) */ + const unsigned char* payload; + size_t payload_len; + if (ots_read_varbytes(data, data_len, pos, &payload, &payload_len) != 0) { + return -1; + } + + memset(attestation, 0, sizeof(*attestation)); + + if (memcmp(tag, OTS_PENDING_TAG, 8) == 0) { + /* Pending: payload is varbytes (URI) */ + size_t payload_pos = 0; + const unsigned char* uri; + size_t uri_len; + if (ots_read_varbytes(payload, payload_len, &payload_pos, &uri, &uri_len) != 0) { + return -1; + } + attestation->type = OTS_ATTESTATION_PENDING; + attestation->uri = (const char*)uri; + attestation->uri_len = uri_len; + } else if (memcmp(tag, OTS_BITCOIN_TAG, 8) == 0) { + /* Bitcoin: payload is varuint (height) */ + size_t payload_pos = 0; + uint64_t height; + if (ots_read_varuint(payload, payload_len, &payload_pos, &height) != 0) { + return -1; + } + attestation->type = OTS_ATTESTATION_BITCOIN; + attestation->height = height; + } else if (memcmp(tag, OTS_LITECOIN_TAG, 8) == 0) { + /* Litecoin: payload is varuint (height) */ + size_t payload_pos = 0; + uint64_t height; + if (ots_read_varuint(payload, payload_len, &payload_pos, &height) != 0) { + return -1; + } + attestation->type = OTS_ATTESTATION_LITECOIN; + attestation->height = height; + } else { + attestation->type = OTS_ATTESTATION_UNKNOWN; + } + + return 0; +} + +/* Process a single tag (op or attestation). Returns 0 on success, -1 on error. */ +/* Forward declaration */ +static int ots_process_tag(const unsigned char* data, size_t data_len, + size_t* pos, unsigned char tag, + ots_attestation_list_t* list, int depth); + +/* + * Parse a timestamp tree starting at *pos. + * This implements the same logic as python-opentimestamps Timestamp.deserialize(): + * tag = read() + * while tag == 0xff: + * next_tag = read() + * process(next_tag) # separator item + * tag = read() + * process(tag) # final item + * + * Returns 0 on success, -1 on error. + */ +static int ots_parse_timestamp(const unsigned char* data, size_t data_len, + size_t* pos, ots_attestation_list_t* list, int depth) { + /* Prevent stack overflow from deeply nested proofs */ + if (depth > 256) { + return -1; + } + + if (*pos >= data_len) { + return -1; + } + + unsigned char tag = data[*pos]; + (*pos)++; + + /* Handle 0xff separators */ + while (tag == OTS_TAG_SEPARATOR) { + /* Read the actual tag after the separator */ + if (*pos >= data_len) { + return -1; + } + unsigned char sep_tag = data[*pos]; + (*pos)++; + + /* Process the separator item (attestation or op+sub-timestamp) */ + if (ots_process_tag(data, data_len, pos, sep_tag, list, depth) != 0) { + return -1; + } + + /* Read the next tag */ + if (*pos >= data_len) { + return -1; + } + tag = data[*pos]; + (*pos)++; + } + + /* Process the final tag */ + return ots_process_tag(data, data_len, pos, tag, list, depth); +} + +static int ots_process_tag(const unsigned char* data, size_t data_len, + size_t* pos, unsigned char tag, + ots_attestation_list_t* list, int depth) { + /* Prevent stack overflow from deeply nested proofs */ + if (depth > 256) { + return -1; + } + + if (tag == OTS_TAG_ATTESTATION) { + /* Attestation */ + if (list->count >= OTS_MAX_ATTESTATIONS) { + return -1; /* too many attestations */ + } + + ots_attestation_t* att = &list->attestations[list->count]; + if (ots_parse_attestation(data, data_len, pos, att) != 0) { + return -1; + } + list->count++; + return 0; + } + + /* Op code */ + if (tag == OTS_OP_APPEND || tag == OTS_OP_PREPEND) { + /* BinaryOp: has a varbytes argument */ + const unsigned char* arg; + size_t arg_len; + if (ots_read_varbytes(data, data_len, pos, &arg, &arg_len) != 0) { + return -1; + } + } + /* Other ops (SHA1, RIPEMD160, SHA256, KECCAK256, Reverse, Hexlify) have no arguments */ + + /* After the op, recursively parse the sub-timestamp */ + return ots_parse_timestamp(data, data_len, pos, list, depth + 1); +} + +/* + * Parse a complete OTS file or raw timestamp proof and extract all attestations. + * + * Handles two formats: + * 1. Full DetachedTimestampFile: starts with header magic + version + hash op + digest + timestamp tree + * 2. Raw Timestamp: starts directly with timestamp operations (returned by calendar /digest endpoint) + * + * Returns 0 on success, -1 on parse error. + */ +static int ots_parse_file(const unsigned char* data, size_t data_len, + ots_attestation_list_t* list) { + size_t pos = 0; + + memset(list, 0, sizeof(*list)); + + /* Check if this is a full DetachedTimestampFile (has header magic) */ + if (data_len >= OTS_HEADER_MAGIC_LEN && + memcmp(data, OTS_HEADER_MAGIC, OTS_HEADER_MAGIC_LEN) == 0) { + + pos = OTS_HEADER_MAGIC_LEN; + + /* Read major version (varuint) */ + uint64_t major_version; + if (ots_read_varuint(data, data_len, &pos, &major_version) != 0) { + return -1; + } + if (major_version != 1) { + return -1; /* unsupported version */ + } + + /* Read file hash op */ + if (pos >= data_len) { + return -1; + } + unsigned char hash_op = data[pos]; + pos++; + + size_t digest_len = ots_digest_length(hash_op); + if (digest_len == 0) { + return -1; /* unknown hash algorithm */ + } + + /* Skip file digest */ + if (pos + digest_len > data_len) { + return -1; + } + pos += digest_len; + + /* Parse timestamp tree */ + return ots_parse_timestamp(data, data_len, &pos, list, 0); + } + + /* Otherwise, treat as a raw Timestamp (no header) */ + /* This is what calendar /digest endpoints return */ + return ots_parse_timestamp(data, data_len, &pos, list, 0); +} + +/* ========================================================================== */ +/* NIP-03 Public API */ +/* ========================================================================== */ + cJSON* nostr_nip03_create_proof_event(const char* target_event_id, int target_event_kind, const char* ots_data_base64, @@ -18,7 +387,7 @@ cJSON* nostr_nip03_create_proof_event(const char* target_event_id, if (!target_event_id || !ots_data_base64 || !private_key) return NULL; cJSON* tags = cJSON_CreateArray(); - + // ["e", , ] cJSON* e_tag = cJSON_CreateArray(); cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); @@ -42,6 +411,38 @@ cJSON* nostr_nip03_create_proof_event(const char* target_event_id, return event; } +cJSON* nostr_nip03_create_proof_event_with_signer(const char* target_event_id, + int target_event_kind, + const char* ots_data_base64, + const char* relay_url, + nostr_signer_t* signer) { + if (!target_event_id || !ots_data_base64 || !signer) return NULL; + + cJSON* tags = cJSON_CreateArray(); + + // ["e", , ] + cJSON* e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(target_event_id)); + if (relay_url) { + cJSON_AddItemToArray(e_tag, cJSON_CreateString(relay_url)); + } + cJSON_AddItemToArray(tags, e_tag); + + // ["k", ""] + char kind_str[16]; + snprintf(kind_str, sizeof(kind_str), "%d", target_event_kind); + cJSON* k_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(k_tag, cJSON_CreateString("k")); + cJSON_AddItemToArray(k_tag, cJSON_CreateString(kind_str)); + cJSON_AddItemToArray(tags, k_tag); + + cJSON* event = nostr_create_and_sign_event_with_signer(NOSTR_KIND_OT_PROOF, ots_data_base64, tags, signer, 0); + cJSON_Delete(tags); + + return event; +} + char* nostr_nip03_request_timestamp(const char* event_id_hex, const char* calendar_url, int timeout_seconds) { @@ -55,7 +456,7 @@ char* nostr_nip03_request_timestamp(const char* event_id_hex, // OTS calendar digest submission endpoint: POST /digest // The body is the raw 32-byte digest. - + char full_url[512]; snprintf(full_url, sizeof(full_url), "%s/digest", calendar_url); @@ -92,7 +493,7 @@ char* nostr_nip03_request_timestamp(const char* event_id_hex, if (b64) { base64_encode((const unsigned char*)resp.body, resp.body_len, b64, b64_size); } - + nostr_http_response_free(&resp); return b64; } @@ -111,26 +512,140 @@ int nostr_nip03_is_proof_complete(const char* ots_data_base64) { return -1; } - // Very simple heuristic for OTS file completeness: - // A complete OTS file contains a Bitcoin attestation. - // The Bitcoin attestation tag in OTS is 0x05 (Pending) vs 0x00 (Bitcoin). - // This is a simplification; a real OTS parser would be better. - // For now, we look for the Bitcoin block header attestation tag. - - int complete = 0; - // OTS files start with a specific header: "\x00OpenTimestamps\x00\x00\x03\x01" - // We just check if it contains the Bitcoin attestation tag (0x00) - // followed by the Bitcoin genesis block hash or similar markers. - // Actually, the easiest way is to check if it's NOT just a pending attestation. - - // For the purpose of this library, we'll assume if it's > 100 bytes it likely has an attestation. - // Initial pending proofs are usually very small (~40-60 bytes). - if (len > 150) { - complete = 1; + /* Parse the OTS file and look for Bitcoin/Litecoin attestations */ + ots_attestation_list_t list; + int parse_result = ots_parse_file(data, len, &list); + + free(data); + + if (parse_result != 0) { + return -1; /* parse error */ + } + + /* Check if any attestation is a Bitcoin or Litecoin block header attestation */ + for (int i = 0; i < list.count; i++) { + if (list.attestations[i].type == OTS_ATTESTATION_BITCOIN || + list.attestations[i].type == OTS_ATTESTATION_LITECOIN) { + return 1; /* complete */ + } + } + + return 0; /* only pending attestations (or none) */ +} + +int nostr_nip03_get_attestation_info(const char* ots_data_base64, + int* has_bitcoin, + int* has_litecoin, + int* has_pending, + uint64_t* bitcoin_height, + uint64_t* litecoin_height, + char** pending_uri_out) { + if (!ots_data_base64) return -1; + + size_t input_len = strlen(ots_data_base64); + size_t max_decoded_len = (input_len / 4) * 3 + 3; + unsigned char* data = (unsigned char*)malloc(max_decoded_len); + if (!data) return -1; + + size_t len = base64_decode(ots_data_base64, data); + if (len == 0) { + free(data); + return -1; + } + + ots_attestation_list_t list; + int parse_result = ots_parse_file(data, len, &list); + + if (parse_result != 0) { + free(data); + return -1; + } + + /* Initialize outputs */ + if (has_bitcoin) *has_bitcoin = 0; + if (has_litecoin) *has_litecoin = 0; + if (has_pending) *has_pending = 0; + if (bitcoin_height) *bitcoin_height = 0; + if (litecoin_height) *litecoin_height = 0; + if (pending_uri_out) *pending_uri_out = NULL; + + for (int i = 0; i < list.count; i++) { + switch (list.attestations[i].type) { + case OTS_ATTESTATION_BITCOIN: + if (has_bitcoin) *has_bitcoin = 1; + if (bitcoin_height) *bitcoin_height = list.attestations[i].height; + break; + case OTS_ATTESTATION_LITECOIN: + if (has_litecoin) *has_litecoin = 1; + if (litecoin_height) *litecoin_height = list.attestations[i].height; + break; + case OTS_ATTESTATION_PENDING: + if (has_pending) *has_pending = 1; + if (pending_uri_out && list.attestations[i].uri_len > 0) { + /* Copy the URI (it points into the decoded data which we're about to free) */ + *pending_uri_out = (char*)malloc(list.attestations[i].uri_len + 1); + if (*pending_uri_out) { + memcpy(*pending_uri_out, list.attestations[i].uri, list.attestations[i].uri_len); + (*pending_uri_out)[list.attestations[i].uri_len] = '\0'; + } + } + break; + default: + break; + } } free(data); - return complete; + return 0; +} + +int nostr_nip03_get_pending_uris(const char* ots_data_base64, + char*** uris_out, + int max_uris) { + if (!ots_data_base64 || !uris_out || max_uris <= 0) return -1; + + size_t input_len = strlen(ots_data_base64); + size_t max_decoded_len = (input_len / 4) * 3 + 3; + unsigned char* data = (unsigned char*)malloc(max_decoded_len); + if (!data) return -1; + + size_t len = base64_decode(ots_data_base64, data); + if (len == 0) { + free(data); + return -1; + } + + ots_attestation_list_t list; + int parse_result = ots_parse_file(data, len, &list); + + if (parse_result != 0) { + free(data); + return -1; + } + + /* Allocate the array */ + *uris_out = (char**)calloc(max_uris, sizeof(char*)); + if (!*uris_out) { + free(data); + return -1; + } + + int count = 0; + for (int i = 0; i < list.count && count < max_uris; i++) { + if (list.attestations[i].type == OTS_ATTESTATION_PENDING && + list.attestations[i].uri_len > 0) { + char* uri_copy = (char*)malloc(list.attestations[i].uri_len + 1); + if (uri_copy) { + memcpy(uri_copy, list.attestations[i].uri, list.attestations[i].uri_len); + uri_copy[list.attestations[i].uri_len] = '\0'; + (*uris_out)[count] = uri_copy; + count++; + } + } + } + + free(data); + return count; } char* nostr_nip03_upgrade_proof(const char* ots_data_base64, @@ -149,17 +664,54 @@ char* nostr_nip03_upgrade_proof(const char* ots_data_base64, return NULL; } - // OTS upgrade endpoint: POST /upgrade - // Body is the current .ots file content. - + /* Extract the file digest from the proof. + * For full DetachedTimestampFile format, the digest is embedded in the header. + * For raw Timestamp format, we need to extract it from the proof structure. + * The simplest approach: re-submit the original digest to the calendar's + * /digest endpoint, which returns the current (possibly upgraded) proof. */ + + unsigned char digest[32]; + int have_digest = 0; + + /* Check if this is a full DetachedTimestampFile (has header magic) */ + if (len >= OTS_HEADER_MAGIC_LEN && + memcmp(data, OTS_HEADER_MAGIC, OTS_HEADER_MAGIC_LEN) == 0) { + + size_t pos = OTS_HEADER_MAGIC_LEN; + uint64_t version; + if (ots_read_varuint(data, len, &pos, &version) == 0 && version == 1) { + if (pos < len) { + unsigned char hash_op = data[pos++]; + size_t digest_len = ots_digest_length(hash_op); + if (digest_len > 0 && digest_len <= 32 && pos + digest_len <= len) { + memcpy(digest, data + pos, digest_len); + have_digest = 1; + } + } + } + } + + free(data); + + if (!have_digest) { + /* Can't extract digest from raw timestamp format. + * The caller should use nostr_nip03_request_timestamp() with the + * original digest hex to re-fetch the current proof. */ + return NULL; + } + + /* Re-submit the digest to the calendar's /digest endpoint. + * The calendar will return the current proof, which may include + * new Bitcoin attestations if the digest has been committed since + * the original submission. */ char full_url[512]; - snprintf(full_url, sizeof(full_url), "%s/upgrade", calendar_url); + snprintf(full_url, sizeof(full_url), "%s/digest", calendar_url); nostr_http_request_t req = {0}; req.method = "POST"; req.url = full_url; - req.body = data; - req.body_len = len; + req.body = digest; + req.body_len = 32; req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10; req.follow_redirects = 1; req.max_redirects = 3; @@ -173,15 +725,59 @@ char* nostr_nip03_upgrade_proof(const char* ots_data_base64, nostr_http_response_t resp = {0}; int ret = nostr_http_request(&req, &resp); - free(data); if (ret != NOSTR_SUCCESS || resp.status_code != 200 || !resp.body || resp.body_len == 0) { nostr_http_response_free(&resp); return NULL; } - // If the response is the same length as input, it might not be upgraded yet - if (resp.body_len <= len) { + /* The response is the raw .ots file data (raw Timestamp format). + * Base64 encode it for return. */ + size_t b64_size = ((resp.body_len + 2) / 3) * 4 + 1; + char* b64 = (char*)malloc(b64_size); + if (b64) { + base64_encode((const unsigned char*)resp.body, resp.body_len, b64, b64_size); + } + + nostr_http_response_free(&resp); + return b64; +} + +char* nostr_nip03_upgrade_proof_with_digest(const char* digest_hex, + const char* calendar_url, + int timeout_seconds) { + if (!digest_hex || !calendar_url) return NULL; + + /* Convert hex digest to binary */ + unsigned char digest[32]; + if (nostr_hex_to_bytes(digest_hex, digest, 32) != 0) { + return NULL; + } + + /* Re-submit the digest to the calendar's /digest endpoint */ + char full_url[512]; + snprintf(full_url, sizeof(full_url), "%s/digest", calendar_url); + + nostr_http_request_t req = {0}; + req.method = "POST"; + req.url = full_url; + req.body = digest; + req.body_len = 32; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10; + req.follow_redirects = 1; + req.max_redirects = 3; + + const char* headers[] = { + "Content-Type: application/octet-stream", + "Accept: application/octet-stream", + NULL + }; + req.headers = headers; + + nostr_http_response_t resp = {0}; + int ret = nostr_http_request(&req, &resp); + + if (ret != NOSTR_SUCCESS || resp.status_code != 200 || !resp.body || resp.body_len == 0) { nostr_http_response_free(&resp); return NULL; } @@ -191,7 +787,305 @@ char* nostr_nip03_upgrade_proof(const char* ots_data_base64, if (b64) { base64_encode((const unsigned char*)resp.body, resp.body_len, b64, b64_size); } - + nostr_http_response_free(&resp); return b64; } + +int nostr_nip03_verify_proof(const char* ots_data_base64, + const char* target_digest_hex, + uint64_t* block_height_out, + time_t* block_time_out) { + if (!ots_data_base64) return -1; + + size_t input_len = strlen(ots_data_base64); + size_t max_decoded_len = (input_len / 4) * 3 + 3; + unsigned char* data = (unsigned char*)malloc(max_decoded_len); + if (!data) return -1; + + size_t len = base64_decode(ots_data_base64, data); + if (len == 0) { + free(data); + return -1; + } + + ots_attestation_list_t list; + int parse_result = ots_parse_file(data, len, &list); + + if (parse_result != 0) { + free(data); + return -1; + } + + /* Verify the file digest matches the target (only for full DetachedTimestampFile) */ + if (target_digest_hex && len >= OTS_HEADER_MAGIC_LEN && + memcmp(data, OTS_HEADER_MAGIC, OTS_HEADER_MAGIC_LEN) == 0) { + + /* Parse the header to find the file digest */ + size_t pos = OTS_HEADER_MAGIC_LEN; + uint64_t version; + if (ots_read_varuint(data, len, &pos, &version) != 0) { + free(data); + return -1; + } + if (pos >= len) { + free(data); + return -1; + } + unsigned char hash_op = data[pos++]; + size_t digest_len = ots_digest_length(hash_op); + if (digest_len == 0 || pos + digest_len > len) { + free(data); + return -1; + } + + unsigned char target_digest[32]; + if (nostr_hex_to_bytes(target_digest_hex, target_digest, digest_len) != 0) { + free(data); + return -1; + } + + if (memcmp(data + pos, target_digest, digest_len) != 0) { + free(data); + return -2; /* digest mismatch */ + } + } + /* Note: For raw timestamp proofs (no header), we can't verify the digest + * since it's not embedded in the proof. The caller is responsible for + * ensuring the proof corresponds to the correct file. */ + + /* Find a Bitcoin attestation */ + int found = 0; + for (int i = 0; i < list.count; i++) { + if (list.attestations[i].type == OTS_ATTESTATION_BITCOIN) { + if (block_height_out) { + *block_height_out = list.attestations[i].height; + } + /* Note: Full verification requires fetching the Bitcoin block header + * and checking that the merkle root matches the computed digest. + * This function performs structural verification only. + * For full on-chain verification, use an external block explorer API + * to fetch the block header at the given height. */ + if (block_time_out) { + *block_time_out = 0; /* Would be set from block header */ + } + found = 1; + break; + } + } + + free(data); + return found ? 0 : 1; /* 0 = verified, 1 = no bitcoin attestation, -1 = error */ +} + +/* ========================================================================== */ +/* OTS File Creation and Multi-Calendar Stamping */ +/* ========================================================================== */ + +/* + * Build a DetachedTimestampFile from a digest and one or more raw timestamp proofs. + * + * The format is: + * header_magic (31 bytes) + version_varuint (1 byte) + hash_op (1 byte) + + * digest (32 bytes) + timestamp_tree + * + * When there are multiple raw timestamps, they are combined using 0xff separators + * (the OTS "fork" mechanism). + */ +static unsigned char* build_dts_file(const unsigned char* digest, size_t digest_len, + const unsigned char** raw_proofs, + const size_t* raw_proof_lens, + int raw_proof_count, + size_t* out_len) { + /* Calculate total size */ + size_t total = OTS_HEADER_MAGIC_LEN + 1 /* version */ + 1 /* hash_op */ + digest_len; + + /* For the timestamp tree: if multiple proofs, use 0xff separators. + * In the OTS format, 0xff precedes each item except the LAST one. + * So for N proofs: 0xff 0xff ... */ + for (int i = 0; i < raw_proof_count; i++) { + if (i < raw_proof_count - 1) { + total += 1; /* 0xff separator before each proof except the last */ + } + total += raw_proof_lens[i]; + } + + unsigned char* buf = (unsigned char*)malloc(total); + if (!buf) return NULL; + + size_t pos = 0; + + /* Header magic */ + memcpy(buf + pos, OTS_HEADER_MAGIC, OTS_HEADER_MAGIC_LEN); + pos += OTS_HEADER_MAGIC_LEN; + + /* Major version = 1 (varuint) */ + buf[pos++] = 0x01; + + /* Hash op = SHA256 (0x08) */ + buf[pos++] = OTS_OP_SHA256; + + /* File digest */ + memcpy(buf + pos, digest, digest_len); + pos += digest_len; + + /* Timestamp tree: combine multiple raw proofs with 0xff separators. + * 0xff precedes each proof except the LAST one. */ + for (int i = 0; i < raw_proof_count; i++) { + if (i < raw_proof_count - 1) { + buf[pos++] = OTS_TAG_SEPARATOR; + } + memcpy(buf + pos, raw_proofs[i], raw_proof_lens[i]); + pos += raw_proof_lens[i]; + } + + *out_len = pos; + return buf; +} + +char* nostr_nip03_create_ots_file(const char* digest_hex) { + if (!digest_hex) return NULL; + + unsigned char digest[32]; + if (nostr_hex_to_bytes(digest_hex, digest, 32) != 0) { + return NULL; + } + + /* Create a minimal DetachedTimestampFile with just the digest (no proof ops) */ + /* This is just the header + version + hash_op + digest */ + size_t out_len = 0; + unsigned char* ots_data = build_dts_file(digest, 32, NULL, NULL, 0, &out_len); + if (!ots_data) return NULL; + + /* Base64 encode */ + size_t b64_size = ((out_len + 2) / 3) * 4 + 1; + char* b64 = (char*)malloc(b64_size); + if (b64) { + base64_encode(ots_data, out_len, b64, b64_size); + } + + free(ots_data); + return b64; +} + +char* nostr_nip03_stamp_with_multiple_calendars(const char* digest_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds) { + if (!digest_hex || !calendar_urls || calendar_count <= 0) return NULL; + + unsigned char digest[32]; + if (nostr_hex_to_bytes(digest_hex, digest, 32) != 0) { + return NULL; + } + + /* Submit to each calendar and collect raw proofs */ + const unsigned char* raw_proofs[16]; + size_t raw_proof_lens[16]; + unsigned char* proof_buffers[16]; /* owned buffers to free later */ + int proof_count = 0; + int success_count = 0; + + for (int i = 0; i < calendar_count && i < 16; i++) { + if (!calendar_urls[i]) continue; + + char full_url[512]; + snprintf(full_url, sizeof(full_url), "%s/digest", calendar_urls[i]); + + nostr_http_request_t req = {0}; + req.method = "POST"; + req.url = full_url; + req.body = digest; + req.body_len = 32; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10; + req.follow_redirects = 1; + req.max_redirects = 3; + + const char* headers[] = { + "Content-Type: application/octet-stream", + "Accept: application/octet-stream", + NULL + }; + req.headers = headers; + + nostr_http_response_t resp = {0}; + if (nostr_http_request(&req, &resp) != NOSTR_SUCCESS || + resp.status_code != 200 || !resp.body || resp.body_len == 0) { + nostr_http_response_free(&resp); + continue; + } + + /* Copy the raw proof data */ + unsigned char* proof_copy = (unsigned char*)malloc(resp.body_len); + if (proof_copy) { + memcpy(proof_copy, resp.body, resp.body_len); + raw_proofs[success_count] = proof_copy; + raw_proof_lens[success_count] = resp.body_len; + proof_buffers[success_count] = proof_copy; + success_count++; + } + + nostr_http_response_free(&resp); + } + + if (success_count == 0) { + return NULL; + } + + /* Build a combined DetachedTimestampFile */ + size_t out_len = 0; + unsigned char* ots_data = build_dts_file(digest, 32, + raw_proofs, raw_proof_lens, + success_count, &out_len); + + /* Free the individual proof buffers */ + for (int i = 0; i < success_count; i++) { + free(proof_buffers[i]); + } + + if (!ots_data) return NULL; + + /* Base64 encode */ + size_t b64_size = ((out_len + 2) / 3) * 4 + 1; + char* b64 = (char*)malloc(b64_size); + if (b64) { + base64_encode(ots_data, out_len, b64, b64_size); + } + + free(ots_data); + return b64; +} + +char* nostr_nip03_request_timestamp_multi(const char* event_id_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds) { + if (!event_id_hex) return NULL; + + /* Use default calendars if none specified */ + if (!calendar_urls || calendar_count <= 0) { + calendar_urls = NOSTR_OTS_DEFAULT_CALENDARS; + calendar_count = NOSTR_OTS_DEFAULT_CALENDAR_COUNT; + } + + /* The event_id_hex IS the SHA-256 digest for OTS purposes */ + return nostr_nip03_stamp_with_multiple_calendars(event_id_hex, calendar_urls, + calendar_count, timeout_seconds); +} + +char* nostr_nip03_upgrade_proof_multi(const char* digest_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds) { + if (!digest_hex) return NULL; + + /* Use default calendars if none specified */ + if (!calendar_urls || calendar_count <= 0) { + calendar_urls = NOSTR_OTS_DEFAULT_CALENDARS; + calendar_count = NOSTR_OTS_DEFAULT_CALENDAR_COUNT; + } + + /* Re-submit to all calendars to get current proofs */ + return nostr_nip03_stamp_with_multiple_calendars(digest_hex, calendar_urls, + calendar_count, timeout_seconds); +} diff --git a/nostr_core/nip003.h b/nostr_core/nip003.h index 7baf5dff..c91baae7 100644 --- a/nostr_core/nip003.h +++ b/nostr_core/nip003.h @@ -6,7 +6,9 @@ #define NOSTR_NIP03_H #include "nostr_common.h" +#include "nostr_signer.h" #include "cjson/cJSON.h" +#include #ifdef __cplusplus extern "C" { @@ -14,6 +16,10 @@ extern "C" { #define NOSTR_KIND_OT_PROOF 1040 +/* Default OTS calendars for multi-calendar stamping */ +#define NOSTR_OTS_DEFAULT_CALENDAR_COUNT 4 +extern const char* NOSTR_OTS_DEFAULT_CALENDARS[NOSTR_OTS_DEFAULT_CALENDAR_COUNT]; + /** * Create a NIP-03 OpenTimestamps proof event (kind 1040) * @@ -30,6 +36,22 @@ cJSON* nostr_nip03_create_proof_event(const char* target_event_id, const char* relay_url, const unsigned char* private_key); +/** + * Create a NIP-03 OpenTimestamps proof event using a signer (kind 1040) + * + * @param target_event_id The ID of the event being timestamped (hex string) + * @param target_event_kind The kind of the event being timestamped + * @param ots_data_base64 Base64 encoded .ots file content + * @param relay_url Optional relay URL where the target event can be found + * @param signer Signer handle for signing the proof event + * @return cJSON* The signed proof event, or NULL on error + */ +cJSON* nostr_nip03_create_proof_event_with_signer(const char* target_event_id, + int target_event_kind, + const char* ots_data_base64, + const char* relay_url, + nostr_signer_t* signer); + /** * Request a timestamp for an event ID from an OpenTimestamps calendar * @@ -46,25 +68,172 @@ char* nostr_nip03_request_timestamp(const char* event_id_hex, int timeout_seconds); /** - * Check if an OTS proof is complete (has a Bitcoin attestation) + * Check if an OTS proof is complete (has a Bitcoin or Litecoin attestation) + * + * Parses the OTS binary file format and searches for a Bitcoin block header + * attestation (tag 0588960d73d71901) or Litecoin attestation (tag 06856e0d73d71901). + * A proof with only pending attestations is not yet complete. * * @param ots_data_base64 Base64 encoded .ots file content - * @return int 1 if complete, 0 if pending, -1 on error + * @return int 1 if complete (has Bitcoin/Litecoin attestation), 0 if pending, -1 on error */ int nostr_nip03_is_proof_complete(const char* ots_data_base64); +/** + * Get detailed attestation information from an OTS proof + * + * Parses the OTS file and extracts information about all attestations found. + * + * @param ots_data_base64 Base64 encoded .ots file content + * @param has_bitcoin Output: 1 if a Bitcoin attestation is present (optional, NULL to skip) + * @param has_litecoin Output: 1 if a Litecoin attestation is present (optional, NULL to skip) + * @param has_pending Output: 1 if any pending attestations are present (optional, NULL to skip) + * @param bitcoin_height Output: Bitcoin block height (optional, NULL to skip) + * @param litecoin_height Output: Litecoin block height (optional, NULL to skip) + * @param pending_uri_out Output: First pending calendar URI (optional, NULL to skip, caller must free) + * @return int 0 on success, -1 on parse error + */ +int nostr_nip03_get_attestation_info(const char* ots_data_base64, + int* has_bitcoin, + int* has_litecoin, + int* has_pending, + uint64_t* bitcoin_height, + uint64_t* litecoin_height, + char** pending_uri_out); + /** * Upgrade an OTS proof by fetching missing attestations from a calendar * + * Sends the current proof to the calendar's /upgrade endpoint and returns + * the updated proof. The calendar may return the same proof if no new + * attestations are available, or an upgraded proof with Bitcoin/Litecoin + * attestations. + * * @param ots_data_base64 Current base64 encoded .ots file content * @param calendar_url The OTS calendar URL * @param timeout_seconds Request timeout - * @return char* New base64 encoded .ots data, or NULL on error/no change (caller must free) + * @return char* New base64 encoded .ots data, or NULL on error (caller must free) */ char* nostr_nip03_upgrade_proof(const char* ots_data_base64, const char* calendar_url, int timeout_seconds); +/** + * Upgrade an OTS proof by re-submitting the digest to a calendar + * + * This is the preferred upgrade method for raw timestamp proofs (returned by + * the calendar /digest endpoint) which don't contain the file digest. + * Re-submits the digest to the calendar, which returns the current proof + * that may include new Bitcoin attestations. + * + * @param digest_hex The 64-char hex SHA-256 digest of the timestamped data + * @param calendar_url The OTS calendar URL + * @param timeout_seconds Request timeout + * @return char* New base64 encoded .ots data, or NULL on error (caller must free) + */ +char* nostr_nip03_upgrade_proof_with_digest(const char* digest_hex, + const char* calendar_url, + int timeout_seconds); + +/** + * Get all pending calendar URIs from an OTS proof + * + * @param ots_data_base64 Base64 encoded .ots file content + * @param uris_out Array of char* pointers (caller must free each string and the array) + * @param max_uris Maximum number of URIs to return + * @return int Number of pending URIs found, or -1 on error + */ +int nostr_nip03_get_pending_uris(const char* ots_data_base64, + char*** uris_out, + int max_uris); + +/** + * Create a full DetachedTimestampFile (.ots) from a digest + * + * Wraps a SHA-256 digest in the OTS DetachedTimestampFile format with the + * proper header magic, version, hash op, and digest. This is the standard + * .ots file format that can be verified by the `ots` command-line tool. + * + * @param digest_hex The 64-char hex SHA-256 digest + * @return char* Base64 encoded DetachedTimestampFile, or NULL on error (caller must free) + */ +char* nostr_nip03_create_ots_file(const char* digest_hex); + +/** + * Submit a digest to multiple calendars and create a combined proof + * + * Submits the digest to each calendar, collects all the returned timestamps, + * and combines them into a single DetachedTimestampFile with a fork operation. + * This is the recommended way to timestamp data โ€” using multiple calendars + * provides redundancy. + * + * @param digest_hex The 64-char hex SHA-256 digest + * @param calendar_urls Array of calendar URLs + * @param calendar_count Number of calendars + * @param timeout_seconds Per-request timeout + * @return char* Base64 encoded DetachedTimestampFile with all calendar proofs, or NULL on error + */ +char* nostr_nip03_stamp_with_multiple_calendars(const char* digest_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds); + +/** + * Request a timestamp for an event ID from multiple OTS calendars + * + * Submits the event ID to multiple calendars and combines the results into a + * single DetachedTimestampFile. Uses the default calendars if none specified. + * + * @param event_id_hex The event ID to timestamp (64-char hex string) + * @param calendar_urls Array of calendar URLs (NULL to use defaults) + * @param calendar_count Number of calendars (0 to use defaults) + * @param timeout_seconds Per-request timeout + * @return char* Base64 encoded DetachedTimestampFile, or NULL on error (caller must free) + */ +char* nostr_nip03_request_timestamp_multi(const char* event_id_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds); + +/** + * Upgrade an OTS proof by re-submitting to multiple calendars + * + * Re-submits the digest to all specified calendars (or defaults) and returns + * a new combined proof. The proof may now contain Bitcoin attestations if + * any calendar has committed since the original submission. + * + * @param digest_hex The 64-char hex SHA-256 digest + * @param calendar_urls Array of calendar URLs (NULL to use defaults) + * @param calendar_count Number of calendars (0 to use defaults) + * @param timeout_seconds Per-request timeout + * @return char* New base64 encoded DetachedTimestampFile, or NULL on error (caller must free) + */ +char* nostr_nip03_upgrade_proof_multi(const char* digest_hex, + const char** calendar_urls, + int calendar_count, + int timeout_seconds); + +/** + * Verify an OTS proof structurally + * + * Parses the OTS file, optionally verifies the file digest matches the + * expected target, and checks for a Bitcoin block header attestation. + * + * Note: Full on-chain verification (checking the Bitcoin block header's + * merkle root) requires fetching the block header from a blockchain API. + * This function performs structural verification only. + * + * @param ots_data_base64 Base64 encoded .ots file content + * @param target_digest_hex Expected SHA256 digest of the timestamped data (64-char hex, or NULL to skip) + * @param block_height_out Output: Bitcoin block height (optional, NULL to skip) + * @param block_time_out Output: Block time (optional, NULL to skip, always 0 for structural verification) + * @return int 0 on success (verified), 1 if no Bitcoin attestation found, -1 on parse error, -2 on digest mismatch + */ +int nostr_nip03_verify_proof(const char* ots_data_base64, + const char* target_digest_hex, + uint64_t* block_height_out, + time_t* block_time_out); + #ifdef __cplusplus } #endif diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 2e91606b..71bc20de 100644 --- a/nostr_core/nostr_core.h +++ b/nostr_core/nostr_core.h @@ -2,10 +2,10 @@ #define NOSTR_CORE_H // Version information (auto-updated by increment_and_push.sh) -#define VERSION "v0.6.6" +#define VERSION "v0.6.7" #define VERSION_MAJOR 0 #define VERSION_MINOR 6 -#define VERSION_PATCH 6 +#define VERSION_PATCH 7 /* * NOSTR Core Library - Complete API Reference diff --git a/plans/nip03_timestamping_plan.md b/plans/nip03_timestamping_plan.md new file mode 100644 index 00000000..7fc88440 --- /dev/null +++ b/plans/nip03_timestamping_plan.md @@ -0,0 +1,135 @@ +# NIP-03 OpenTimestamps Fix Plan + +## Problem Analysis + +The NIP-03 timestamping implementation in `nostr_core/nip003.c` was never working properly due to several critical bugs in the OTS proof parsing and upgrade logic. + +## OTS Binary Format (Verified against real .ots file) + +### File Structure +1. **Header magic** (31 bytes): `\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94` +2. **Major version** (LEB128 varuint): typically `0x01` +3. **File hash op** (1 byte): `0x08` = SHA256 +4. **File digest** (32 bytes for SHA256) +5. **Timestamp tree** (recursive structure) + +### Timestamp Tree Format +- Read a tag byte +- `0xff` = separator (read next tag, process it, continue looping) +- `0x00` = attestation follows +- Any other byte = op code (process op, then recursively parse sub-timestamp) +- The last tag (not preceded by `0xff`) is the final item + +### Attestation Format (after `0x00` tag) +- 8-byte attestation tag: + - `83dfe30d2ef90c8e` = PendingAttestation + - `0588960d73d71901` = BitcoinBlockHeaderAttestation + - `06856e0d73d71901` = LitecoinBlockHeaderAttestation +- varuint payload length +- payload bytes: + - Pending: varbytes (URI string like "https://alice.btc.calendar.opentimestamps.org") + - Bitcoin: varuint (block height) + - Litecoin: varuint (block height) + +### Op Codes +- `0x02` = SHA1 (CryptOp, 20-byte digest) +- `0x03` = RIPEMD160 (CryptOp, 20-byte digest) +- `0x08` = SHA256 (CryptOp, 32-byte digest) +- `0x67` = KECCAK256 (CryptOp, 32-byte digest) +- `0xf0` = Append (BinaryOp: varbytes argument) +- `0xf1` = Prepend (BinaryOp: varbytes argument) +- `0xf2` = Reverse (UnaryOp) +- `0xf3` = Hexlify (UnaryOp) + +### LEB128 Varuint Format +- Read bytes, each contributes 7 bits (low bits) +- High bit (0x80) = continue reading +- Low bit = final byte + +### Varbytes Format +- varuint length prefix +- followed by that many bytes + +## Bugs Found + +### Bug 1: Broken `is_proof_complete` (nip003.c:128) +```c +if (len > 150) { + complete = 1; +} +``` +Uses a size heuristic instead of parsing the OTS format. A pending proof can be >150 bytes, and a complete proof might be smaller. Should parse the binary format and look for Bitcoin/Litecoin attestation tags. + +### Bug 2: Flawed `upgrade_proof` (nip003.c:184) +```c +if (resp.body_len <= len) { + nostr_http_response_free(&resp); + return NULL; +} +``` +Rejects any response that isn't strictly larger. A calendar may return the same proof if nothing changed, or a differently-serialized proof. Should compare content, not just length. + +### Bug 3: CMakeLists.txt missing NIP-03 +The CMake build only includes a subset of NIPs. `nip003.c` and many other NIP source files are missing from the library build target. + +### Bug 4: No proof verification function +No way to verify a proof is cryptographically valid against the Bitcoin blockchain. + +### Bug 5: No signer variant +All other NIPs have `_with_signer` variants but NIP-03 only has raw private key version. + +## Implementation Plan + +### Step 1: Add OTS parser to nip003.c +Add static helper functions: +- `ots_read_varuint(data, pos)` - read LEB128 varuint +- `ots_read_varbytes(data, pos)` - read varbytes +- `ots_parse_timestamp(data, pos, len, results)` - recursively parse timestamp tree +- `ots_find_attestations(data, len)` - top-level parser that finds all attestations + +### Step 2: Fix `nostr_nip03_is_proof_complete` +Replace the size heuristic with proper parsing: +- Parse the OTS file format +- Walk the timestamp tree +- Return 1 if any Bitcoin (`0588960d73d71901`) or Litecoin (`06856e0d73d71901`) attestation is found +- Return 0 if only pending attestations found +- Return -1 on parse error + +### Step 3: Fix `nostr_nip03_upgrade_proof` +- Remove the `resp.body_len <= len` check +- Accept any valid 200 response with non-empty body +- Return the base64-encoded response + +### Step 4: Add `nostr_nip03_verify_proof` +New function that: +- Parses the OTS proof +- Extracts Bitcoin attestation(s) with block height(s) +- Optionally fetches the Bitcoin block header from a blockchain API +- Verifies the merkle root matches the computed digest +- Returns the timestamp (block time) on success + +### Step 5: Add `nostr_nip03_create_proof_event_with_signer` +Mirror the pattern used by other NIPs for signer support. + +### Step 6: Fix CMakeLists.txt +Add all missing NIP source files to the library build target. + +### Step 7: Add example program +Create `examples/timestamping.c` demonstrating the full workflow: +- Create a Nostr event +- Submit to OTS calendar +- Poll for completion +- Create proof event +- Verify proof + +### Step 8: Fix live test +Fix the broken curl debug command in `tests/nip03_live_test.c`. + +### Step 9: Add unit tests +Add tests for: +- OTS parsing with known test vectors +- Proof completeness detection (pending vs complete) +- Varuint/varbytes encoding/decoding + +### Step 10: Update documentation +Update `README.md` and `nostr_core.h` with NIP-03 function documentation. diff --git a/tests/nip03_live_test.c b/tests/nip03_live_test.c index c42f4d5a..08410471 100644 --- a/tests/nip03_live_test.c +++ b/tests/nip03_live_test.c @@ -2,6 +2,8 @@ * NOSTR Core Library - NIP-03 Live Network Test */ +#define _POSIX_C_SOURCE 200809L + #include "nostr_core/nostr_core.h" #include "nostr_core/nip003.h" #include @@ -51,11 +53,17 @@ int main() { char* ots_b64 = nostr_nip03_request_timestamp(event_id, CALENDAR_URL, 15); if (!ots_b64) { printf("โŒ Failed to submit to calendar. Trying with curl to debug...\n"); + /* Use a temp file for the binary digest to avoid shell escaping issues */ char cmd[1024]; - snprintf(cmd, sizeof(cmd), "curl -s -X POST --data-binary @- %s/digest < /tmp/ots_digest.bin && " + "curl -s -X POST --data-binary @/tmp/ots_digest.bin -H 'Content-Type: application/octet-stream' %s/digest | xxd | head -20", + event_id, CALENDAR_URL); + printf("Running debug command...\n"); + int rc = system(cmd); + (void)rc; + printf("\n"); + cJSON_Delete(event); nostr_cleanup(); return 1; @@ -86,16 +94,54 @@ int main() { if (complete) { printf("\n๐ŸŽ‰ SUCCESS! The proof is now complete with a Bitcoin attestation.\n"); - + + // Get attestation details + int has_bitcoin = 0, has_pending = 0; + uint64_t btc_height = 0; + char* pending_uri = NULL; + if (nostr_nip03_get_attestation_info(ots_b64, &has_bitcoin, NULL, &has_pending, + &btc_height, NULL, &pending_uri) == 0) { + if (has_bitcoin) { + printf(" Bitcoin block height: %llu\n", (unsigned long long)btc_height); + } + if (has_pending && pending_uri) { + printf(" Pending calendar: %s\n", pending_uri); + free(pending_uri); + } + } + + // Verify the proof structurally + uint64_t verify_height = 0; + int verify_rc = nostr_nip03_verify_proof(ots_b64, event_id, &verify_height, NULL); + if (verify_rc == 0) { + printf(" โœ… Proof verified: digest matches, Bitcoin height=%llu\n", + (unsigned long long)verify_height); + } else if (verify_rc == -2) { + printf(" โš ๏ธ Proof verification: digest mismatch\n"); + } else { + printf(" โš ๏ธ Proof verification returned: %d\n", verify_rc); + } + // Create the final Nostr event cJSON* proof_event = nostr_nip03_create_proof_event(event_id, 1, ots_b64, NULL, private_key); char* json = cJSON_Print(proof_event); printf("\nFinal NIP-03 Event:\n%s\n", json); - + free(json); cJSON_Delete(proof_event); } else { - printf("\nโŒ Test timed out after %d minutes. The proof is still pending.\n", attempts); + printf("\nโŒ Test timed out after %d attempts. The proof is still pending.\n", attempts); + + // Show pending attestation info + int has_pending = 0; + char* pending_uri = NULL; + if (nostr_nip03_get_attestation_info(ots_b64, NULL, NULL, &has_pending, + NULL, NULL, &pending_uri) == 0) { + if (has_pending && pending_uri) { + printf(" Pending at: %s\n", pending_uri); + free(pending_uri); + } + } } free(ots_b64); diff --git a/tests/nip03_test.c b/tests/nip03_test.c index 93d57411..9e23ac72 100644 --- a/tests/nip03_test.c +++ b/tests/nip03_test.c @@ -49,6 +49,174 @@ void test_nip03_create_proof_event() { cJSON_Delete(event); } +void test_nip03_create_proof_event_with_signer() { + printf("๐Ÿงช Testing NIP-03 proof event creation with signer...\n"); + + unsigned char private_key[32]; + unsigned char public_key[32]; + nostr_generate_keypair(private_key, public_key); + + nostr_signer_t* signer = nostr_signer_local(private_key); + assert(signer != NULL); + + const char* target_id = "e71c6ea722987debdb60f81f9ea4f604b5ac0664120dd64fb9d23abc4ec7c323"; + int target_kind = 1; + const char* ots_b64 = "base64encodedotsdata"; + + cJSON* event = nostr_nip03_create_proof_event_with_signer(target_id, target_kind, ots_b64, NULL, signer); + assert(event != NULL); + + // Verify kind + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + assert(kind != NULL && kind->valueint == 1040); + + // Verify content + cJSON* content = cJSON_GetObjectItem(event, "content"); + assert(content != NULL && strcmp(content->valuestring, ots_b64) == 0); + + // Verify tags (no relay URL, so e_tag has only 2 elements) + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + assert(tags != NULL && cJSON_GetArraySize(tags) == 2); + + cJSON* e_tag = cJSON_GetArrayItem(tags, 0); + assert(strcmp(cJSON_GetArrayItem(e_tag, 0)->valuestring, "e") == 0); + assert(strcmp(cJSON_GetArrayItem(e_tag, 1)->valuestring, target_id) == 0); + assert(cJSON_GetArraySize(e_tag) == 2); /* no relay URL */ + + printf("โœ… NIP-03 proof event creation with signer test passed!\n"); + cJSON_Delete(event); + nostr_signer_free(signer); +} + +/* + * This is a real pending OTS proof (base64 encoded) from the OpenTimestamps + * calendar. It contains only pending attestations (no Bitcoin attestation), + * so is_proof_complete should return 0. + * + * The proof was generated for a test file and submitted to 3 calendars: + * - alice.btc.calendar.opentimestamps.org + * - bob.btc.calendar.opentimestamps.org + * - finney.calendar.eternitywall.com + * + * File digest (SHA256): dd4861b2521f83430a93696ca83b085b7fb8fd17ed6a2cee9496ec87d9fd0d6c + */ +static const char* PENDING_OTS_B64 = + "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEI3UhhslIfg0MKk2lsqDsIW3+4/Rftaizu" + "lJbsh9n9DWzwEPH1H7z7SBTEU40qNFhcPKcI//AIxd2zXDTCN5cI8BCIgv8EsqQXO7DY4MNvo50e" + "CPEgksMhj36iAZdVcrXi4Q+Y/opid1wVrerjQ+gjGP5DB3wI8QRqVlUo8Ai72PBveP4l/QCD3+MN" + "LvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AIb923" + "oTIXfHkI8BDEnrX0tZZwN/drZnEOh+sACPEgcsd1lsM45YKtSytPZWr+Wkn3MMffWkFzj0qK7ehe" + "90wI8QRqVlUo8AjeBSDPqcrx9QCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9w" + "ZW50aW1lc3RhbXBzLm9yZ/AQjt5nZXTx0ZchzqvQ2HoNoAjxIKzXgsLhE8UXDdGZSuVT9f5FvggB" + "2nwYP7dmO/NjZwO1CPEEalZVKfAIeB6WGBjHG7gAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNh" + "bGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20="; + +void test_nip03_is_proof_complete_pending() { + printf("๐Ÿงช Testing NIP-03 is_proof_complete with pending proof...\n"); + + int result = nostr_nip03_is_proof_complete(PENDING_OTS_B64); + printf(" Result: %d (expected 0 = pending)\n", result); + assert(result == 0); + + printf("โœ… NIP-03 is_proof_complete (pending) test passed!\n"); +} + +void test_nip03_get_attestation_info_pending() { + printf("๐Ÿงช Testing NIP-03 get_attestation_info with pending proof...\n"); + + int has_bitcoin = 0, has_litecoin = 0, has_pending = 0; + uint64_t btc_height = 0, ltc_height = 0; + char* pending_uri = NULL; + + int rc = nostr_nip03_get_attestation_info(PENDING_OTS_B64, + &has_bitcoin, &has_litecoin, &has_pending, + &btc_height, <c_height, &pending_uri); + assert(rc == 0); + assert(has_bitcoin == 0); + assert(has_litecoin == 0); + assert(has_pending == 1); + assert(pending_uri != NULL); + + printf(" Pending URI: %s\n", pending_uri); + /* Should be one of the known calendar URLs */ + assert(strstr(pending_uri, "opentimestamps.org") != NULL || + strstr(pending_uri, "eternitywall.com") != NULL); + + free(pending_uri); + + printf("โœ… NIP-03 get_attestation_info (pending) test passed!\n"); +} + +void test_nip03_is_proof_complete_invalid() { + printf("๐Ÿงช Testing NIP-03 is_proof_complete with invalid data...\n"); + + /* Invalid base64 / not an OTS file */ + int result = nostr_nip03_is_proof_complete("notavalidotsfile"); + printf(" Result: %d (expected -1 = error)\n", result); + assert(result == -1); + + /* NULL input */ + result = nostr_nip03_is_proof_complete(NULL); + assert(result == -1); + + printf("โœ… NIP-03 is_proof_complete (invalid) test passed!\n"); +} + +void test_nip03_verify_proof_pending() { + printf("๐Ÿงช Testing NIP-03 verify_proof with pending proof...\n"); + + /* The file digest for this proof */ + const char* expected_digest = "dd4861b2521f83430a93696ca83b085b7fb8fd17ed6a2cee9496ec87d9fd0d6c"; + + uint64_t block_height = 0; + time_t block_time = 0; + + int rc = nostr_nip03_verify_proof(PENDING_OTS_B64, expected_digest, + &block_height, &block_time); + printf(" Result: %d (expected 1 = no bitcoin attestation)\n", rc); + /* Should return 1 (no Bitcoin attestation found, but digest matches) */ + assert(rc == 1); + + /* Test with wrong digest */ + rc = nostr_nip03_verify_proof(PENDING_OTS_B64, + "0000000000000000000000000000000000000000000000000000000000000000", + &block_height, &block_time); + printf(" Result with wrong digest: %d (expected -2 = digest mismatch)\n", rc); + assert(rc == -2); + + printf("โœ… NIP-03 verify_proof (pending) test passed!\n"); +} + +void test_nip03_create_and_verify_roundtrip() { + printf("๐Ÿงช Testing NIP-03 create proof event + verify roundtrip...\n"); + + unsigned char private_key[32]; + unsigned char public_key[32]; + nostr_generate_keypair(private_key, public_key); + + /* Create a proof event with the pending OTS data */ + const char* target_id = "dd4861b2521f83430a93696ca83b085b7fb8fd17ed6a2cee9496ec87d9fd0d6c"; + cJSON* proof_event = nostr_nip03_create_proof_event(target_id, 1, PENDING_OTS_B64, NULL, private_key); + assert(proof_event != NULL); + + /* Verify the event is valid */ + int validate_rc = nostr_validate_event(proof_event); + assert(validate_rc == NOSTR_SUCCESS); + + /* Extract the OTS data from the event content and check completeness */ + cJSON* content = cJSON_GetObjectItem(proof_event, "content"); + assert(content != NULL && cJSON_IsString(content)); + int complete = nostr_nip03_is_proof_complete(content->valuestring); + assert(complete == 0); /* pending */ + + char* json = cJSON_Print(proof_event); + printf(" Created proof event (kind 1040), %zu bytes\n", strlen(json)); + free(json); + + cJSON_Delete(proof_event); + printf("โœ… NIP-03 create + verify roundtrip test passed!\n"); +} + int main() { if (nostr_init() != NOSTR_SUCCESS) { fprintf(stderr, "Failed to initialize NOSTR library\n"); @@ -56,9 +224,16 @@ int main() { } test_nip03_create_proof_event(); + test_nip03_create_proof_event_with_signer(); + test_nip03_is_proof_complete_pending(); + test_nip03_get_attestation_info_pending(); + test_nip03_is_proof_complete_invalid(); + test_nip03_verify_proof_pending(); + test_nip03_create_and_verify_roundtrip(); // Note: We don't test nostr_nip03_request_timestamp here as it requires a live OTS calendar // and network access, which might be flaky in a test environment. + // See tests/nip03_live_test.c for live network tests. nostr_cleanup(); printf("\n๐ŸŽ‰ All NIP-03 tests passed!\n");