diff --git a/VERSION b/VERSION index 1f771699..5f749c13 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.13 +0.4.11 diff --git a/build.sh b/build.sh index c4f8ddfd..08171f82 100755 --- a/build.sh +++ b/build.sh @@ -147,6 +147,8 @@ if [ "$HELP" = true ]; then echo " 044 - Encryption (modern)" echo " 046 - Remote signing" echo " 059 - Gift Wrap" + echo " 060 - Cashu Wallet" + echo " 061 - Nutzaps" echo "" echo "Examples:" echo " $0 # Auto-detect NIPs, build for current arch" @@ -195,7 +197,7 @@ print_info "Auto-detecting needed NIPs from your source code..." NEEDED_NIPS="" if [ -n "$FORCE_NIPS" ]; then if [ "$FORCE_NIPS" = "all" ]; then - NEEDED_NIPS="001 004 005 006 011 013 017 019 021 042 044 046 059" + NEEDED_NIPS="001 004 005 006 011 013 017 019 042 044 046 059 060 061" print_info "Forced: Building all available NIPs" else # Convert comma-separated list to space-separated with 3-digit format @@ -214,7 +216,7 @@ else # Check for nostr_core.h (includes everything) if grep -q '#include[[:space:]]*["\<]nostr_core\.h["\>]' *.c 2>/dev/null; then print_info "Found #include \"nostr_core.h\" - building all NIPs" - NEEDED_NIPS="001 004 005 006 011 013 019 021 042 044 046 059" + NEEDED_NIPS="001 004 005 006 011 013 019 042 044 046 059 060 061" elif [ -n "$DETECTED" ]; then NEEDED_NIPS="$DETECTED" print_success "Auto-detected NIPs: $(echo $NEEDED_NIPS | tr ' ' ',')" @@ -232,7 +234,7 @@ fi # If building tests or examples, include all NIPs to ensure compatibility if ([ "$BUILD_TESTS" = true ] || [ "$BUILD_EXAMPLES" = true ]) && [ -z "$FORCE_NIPS" ]; then - NEEDED_NIPS="001 004 005 006 011 013 017 019 021 042 044 046 059" + NEEDED_NIPS="001 004 005 006 011 013 017 019 021 042 044 046 059 060 061" print_info "Building tests/examples - including all available NIPs for compatibility" fi @@ -526,12 +528,18 @@ for nip in $NEEDED_NIPS; do 044) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-044(Encrypt)" ;; 046) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-046(Remote-Signing)" ;; 059) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-059(Gift-Wrap)" ;; + 060) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-060(Cashu-Wallet)" ;; + 061) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-061(Nutzaps)" ;; esac else print_warning "NIP file not found: $NIP_FILE - skipping" fi done +if echo "$NEEDED_NIPS" | grep -Eq '(^| )060( |$)|(^| )061( |$)'; then + SOURCES="$SOURCES nostr_core/cashu_mint.c" +fi + # Build flags CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2" CFLAGS="$CFLAGS -DENABLE_FILE_LOGGING -DENABLE_WEBSOCKET_LOGGING -DENABLE_DEBUG_LOGGING" diff --git a/examples/cashu_wallet.c b/examples/cashu_wallet.c new file mode 100644 index 00000000..5484ed27 --- /dev/null +++ b/examples/cashu_wallet.c @@ -0,0 +1,121 @@ +/* + * Example: NIP-60/NIP-61 Cashu Wallet Flow + */ + +#include +#include +#include +#include "../nostr_core/nostr_core.h" + +static void print_event(const char* title, cJSON* evt) { + if (!evt) { + printf("%s: \n", title); + return; + } + char* s = cJSON_Print(evt); + if (s) { + printf("\n%s\n%s\n", title, s); + free(s); + } +} + +int main(void) { + if (nostr_init() != NOSTR_SUCCESS) { + printf("Failed to initialize library\n"); + return 1; + } + + const char* user_sk_hex = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"; + unsigned char user_sk[32]; + if (nostr_hex_to_bytes(user_sk_hex, user_sk, 32) != 0) { + printf("Invalid private key\n"); + nostr_cleanup(); + return 1; + } + + /* 1) Create wallet event (kind:17375) */ + char* mint_urls[] = { + "https://mint1.example.com", + "https://mint2.example.com" + }; + + nostr_nip60_wallet_data_t wallet_data; + memset(&wallet_data, 0, sizeof(wallet_data)); + strcpy(wallet_data.privkey, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + wallet_data.mint_urls = mint_urls; + wallet_data.mint_count = 2; + + cJSON* wallet_event = nostr_nip60_create_wallet_event(&wallet_data, user_sk, 0); + print_event("Wallet Event (kind:17375)", wallet_event); + + /* 2) Create token event (kind:7375) */ + nostr_cashu_proof_t proofs[2]; + memset(proofs, 0, sizeof(proofs)); + + strcpy(proofs[0].id, "005c2502034d4f12"); + proofs[0].amount = 1; + proofs[0].secret = "secret-1"; + proofs[0].C = "0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46"; + + strcpy(proofs[1].id, "005c2502034d4f12"); + proofs[1].amount = 2; + proofs[1].secret = "secret-2"; + proofs[1].C = "02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f"; + + nostr_nip60_token_data_t token_data; + memset(&token_data, 0, sizeof(token_data)); + token_data.mint_url = "https://mint1.example.com"; + token_data.proofs = proofs; + token_data.proof_count = 2; + + cJSON* token_event = nostr_nip60_create_token_event(&token_data, user_sk, 0); + print_event("Token Event (kind:7375)", token_event); + + /* 3) Create spend history event (kind:7376) */ + nostr_nip60_history_ref_t refs[1]; + memset(refs, 0, sizeof(refs)); + strcpy(refs[0].event_id, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + refs[0].marker = NOSTR_NIP60_REF_CREATED; + + nostr_nip60_history_data_t hist; + memset(&hist, 0, sizeof(hist)); + hist.direction = NOSTR_NIP60_DIRECTION_IN; + hist.amount = 3; + hist.refs = refs; + hist.ref_count = 1; + + cJSON* history_event = nostr_nip60_create_history_event(&hist, user_sk, 0); + print_event("History Event (kind:7376)", history_event); + + /* 4) Create nutzap info event (kind:10019) */ + char* relays[] = {"wss://relay1.example.com", "wss://relay2.example.com"}; + char* mint_units[] = {"sat"}; + + nostr_nip61_mint_entry_t mint_entry; + memset(&mint_entry, 0, sizeof(mint_entry)); + mint_entry.url = "https://mint1.example.com"; + mint_entry.units = mint_units; + mint_entry.unit_count = 1; + + nostr_nip61_nutzap_info_t info; + memset(&info, 0, sizeof(info)); + info.relay_urls = relays; + info.relay_count = 2; + info.mints = &mint_entry; + info.mint_count = 1; + strcpy(info.pubkey, "02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed"); + + cJSON* info_event = nostr_nip61_create_nutzap_info_event(&info, user_sk, 0); + print_event("Nutzap Info Event (kind:10019)", info_event); + + /* 5) Optionally call Cashu mint HTTP endpoints with [cashu_mint_get_info()] */ + printf("\nCashu mint integration is available via cashu_mint_* APIs.\n"); + + cJSON_Delete(info_event); + cJSON_Delete(history_event); + cJSON_Delete(token_event); + cJSON_Delete(wallet_event); + + nostr_cleanup(); + return 0; +} diff --git a/nostr_core/cashu_mint.c b/nostr_core/cashu_mint.c new file mode 100644 index 00000000..2cf90371 --- /dev/null +++ b/nostr_core/cashu_mint.c @@ -0,0 +1,395 @@ +/* + * Cashu Mint HTTP Client Implementation + */ + +#include "cashu_mint.h" +#include "../cjson/cJSON.h" +#include +#include +#include + +#include + +typedef struct { + char* data; + size_t size; + size_t capacity; +} cashu_http_response_t; + +static char* cashu_strdup(const char* s) { + if (!s) return NULL; + size_t len = strlen(s); + char* out = (char*)malloc(len + 1); + if (!out) return NULL; + memcpy(out, s, len + 1); + return out; +} + +static size_t cashu_write_callback(void* contents, size_t size, size_t nmemb, void* userp) { + cashu_http_response_t* response = (cashu_http_response_t*)userp; + size_t total_size = size * nmemb; + + if (response->size + total_size + 1 > response->capacity) { + size_t new_capacity = response->capacity ? response->capacity * 2 : 1024; + while (new_capacity < response->size + total_size + 1) { + new_capacity *= 2; + } + + char* new_data = (char*)realloc(response->data, new_capacity); + if (!new_data) return 0; + + response->data = new_data; + response->capacity = new_capacity; + } + + memcpy(response->data + response->size, contents, total_size); + response->size += total_size; + response->data[response->size] = '\0'; + + return total_size; +} + +static int cashu_http_json_request(const char* method, + const char* url, + const char* body, + int timeout_seconds, + char** response_out, + long* status_out) { + if (!method || !url || !response_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + *response_out = NULL; + if (status_out) *status_out = 0; + + CURL* curl = curl_easy_init(); + if (!curl) { + return NOSTR_ERROR_NETWORK_FAILED; + } + + cashu_http_response_t response = {0}; + response.capacity = 1024; + response.data = (char*)malloc(response.capacity); + if (!response.data) { + curl_easy_cleanup(curl); + return NOSTR_ERROR_MEMORY_FAILED; + } + response.data[0] = '\0'; + + struct curl_slist* headers = NULL; + headers = curl_slist_append(headers, "Accept: application/json"); + headers = curl_slist_append(headers, "Content-Type: application/json"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)cashu_write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)(timeout_seconds > 0 ? timeout_seconds : 10)); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-core/1.0"); + + if (strcmp(method, "POST") == 0) { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}"); + } + + CURLcode res = curl_easy_perform(curl); + long response_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (status_out) *status_out = response_code; + + if (res != CURLE_OK) { + free(response.data); + return NOSTR_ERROR_CASHU_HTTP_FAILED; + } + + *response_out = response.data; + return NOSTR_SUCCESS; +} + +static int cashu_parse_quote_common(cJSON* json, + cashu_mint_quote_t* mint_quote_out, + cashu_melt_quote_t* melt_quote_out) { + if (!json) return NOSTR_ERROR_CASHU_JSON_PARSE_FAILED; + + cJSON* quote_id = cJSON_GetObjectItem(json, "quote"); + cJSON* request = cJSON_GetObjectItem(json, "request"); + cJSON* paid = cJSON_GetObjectItem(json, "paid"); + cJSON* amount = cJSON_GetObjectItem(json, "amount"); + cJSON* expiry = cJSON_GetObjectItem(json, "expiry"); + + if (mint_quote_out) { + memset(mint_quote_out, 0, sizeof(*mint_quote_out)); + if (quote_id && cJSON_IsString(quote_id)) { + strncpy(mint_quote_out->quote_id, cJSON_GetStringValue(quote_id), sizeof(mint_quote_out->quote_id) - 1); + } + if (request && cJSON_IsString(request)) { + strncpy(mint_quote_out->payment_request, cJSON_GetStringValue(request), sizeof(mint_quote_out->payment_request) - 1); + } + if (paid && cJSON_IsBool(paid)) mint_quote_out->paid = cJSON_IsTrue(paid) ? 1 : 0; + if (amount && cJSON_IsNumber(amount)) mint_quote_out->amount = (uint64_t)cJSON_GetNumberValue(amount); + if (expiry && cJSON_IsNumber(expiry)) mint_quote_out->expiry = (time_t)cJSON_GetNumberValue(expiry); + } + + if (melt_quote_out) { + memset(melt_quote_out, 0, sizeof(*melt_quote_out)); + if (quote_id && cJSON_IsString(quote_id)) { + strncpy(melt_quote_out->quote_id, cJSON_GetStringValue(quote_id), sizeof(melt_quote_out->quote_id) - 1); + } + if (paid && cJSON_IsBool(paid)) melt_quote_out->paid = cJSON_IsTrue(paid) ? 1 : 0; + if (amount && cJSON_IsNumber(amount)) melt_quote_out->amount = (uint64_t)cJSON_GetNumberValue(amount); + if (expiry && cJSON_IsNumber(expiry)) melt_quote_out->expiry = (time_t)cJSON_GetNumberValue(expiry); + + cJSON* fee_reserve = cJSON_GetObjectItem(json, "fee_reserve"); + if (fee_reserve && cJSON_IsNumber(fee_reserve)) { + melt_quote_out->fee_reserve = (uint64_t)cJSON_GetNumberValue(fee_reserve); + } + + cJSON* preimage = cJSON_GetObjectItem(json, "payment_preimage"); + if (preimage && cJSON_IsString(preimage)) { + melt_quote_out->payment_preimage = cashu_strdup(cJSON_GetStringValue(preimage)); + } + } + + return NOSTR_SUCCESS; +} + +static int cashu_call_raw(const char* mint_url, + const char* endpoint, + const char* method, + const char* body, + cJSON** response_out, + int timeout_seconds) { + if (!mint_url || !endpoint || !method || !response_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + *response_out = NULL; + + char url[1024]; + snprintf(url, sizeof(url), "%s%s", mint_url, endpoint); + + char* response = NULL; + long status = 0; + int rc = cashu_http_json_request(method, url, body, timeout_seconds, &response, &status); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* json = cJSON_Parse(response ? response : "{}"); + free(response); + + if (!json) return NOSTR_ERROR_CASHU_JSON_PARSE_FAILED; + + if (status < 200 || status >= 300) { + cJSON_Delete(json); + return NOSTR_ERROR_CASHU_MINT_ERROR; + } + + *response_out = json; + return NOSTR_SUCCESS; +} + +int cashu_mint_get_info(const char* mint_url, + cashu_mint_info_t* info_out, + int timeout_seconds) { + if (!mint_url || !info_out) return NOSTR_ERROR_INVALID_INPUT; + + memset(info_out, 0, sizeof(*info_out)); + + cJSON* json = NULL; + int rc = cashu_call_raw(mint_url, "/v1/info", "GET", NULL, &json, timeout_seconds); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* name = cJSON_GetObjectItem(json, "name"); + cJSON* pubkey = cJSON_GetObjectItem(json, "pubkey"); + cJSON* version = cJSON_GetObjectItem(json, "version"); + + if (name && cJSON_IsString(name)) info_out->name = cashu_strdup(cJSON_GetStringValue(name)); + if (pubkey && cJSON_IsString(pubkey)) info_out->pubkey = cashu_strdup(cJSON_GetStringValue(pubkey)); + if (version && cJSON_IsString(version)) info_out->version = cashu_strdup(cJSON_GetStringValue(version)); + + cJSON* keysets = cJSON_GetObjectItem(json, "keysets"); + if (keysets && cJSON_IsArray(keysets)) { + int n = cJSON_GetArraySize(keysets); + if (n > 0) { + info_out->keysets = (cashu_keyset_t*)calloc((size_t)n, sizeof(cashu_keyset_t)); + if (!info_out->keysets) { + cJSON_Delete(json); + cashu_mint_free_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + + for (int i = 0; i < n; i++) { + cJSON* ks = cJSON_GetArrayItem(keysets, i); + if (!ks || !cJSON_IsObject(ks)) continue; + + cJSON* id = cJSON_GetObjectItem(ks, "id"); + cJSON* unit = cJSON_GetObjectItem(ks, "unit"); + cJSON* active = cJSON_GetObjectItem(ks, "active"); + + if (id && cJSON_IsString(id)) { + strncpy(info_out->keysets[info_out->keyset_count].id, + cJSON_GetStringValue(id), sizeof(info_out->keysets[info_out->keyset_count].id) - 1); + } + if (unit && cJSON_IsString(unit)) { + strncpy(info_out->keysets[info_out->keyset_count].unit, + cJSON_GetStringValue(unit), sizeof(info_out->keysets[info_out->keyset_count].unit) - 1); + } + if (active && cJSON_IsBool(active)) { + info_out->keysets[info_out->keyset_count].active = cJSON_IsTrue(active) ? 1 : 0; + } + info_out->keyset_count++; + } + } + } + + cJSON_Delete(json); + return NOSTR_SUCCESS; +} + +void cashu_mint_free_info(cashu_mint_info_t* info) { + if (!info) return; + free(info->name); + free(info->pubkey); + free(info->version); + free(info->keysets); + memset(info, 0, sizeof(*info)); +} + +int cashu_mint_request_mint_quote(const char* mint_url, + uint64_t amount, + const char* unit, + cashu_mint_quote_t* quote_out, + int timeout_seconds) { + if (!mint_url || !unit || !quote_out) return NOSTR_ERROR_INVALID_INPUT; + + cJSON* req = cJSON_CreateObject(); + cJSON_AddNumberToObject(req, "amount", (double)amount); + cJSON_AddStringToObject(req, "unit", unit); + char* body = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + + cJSON* json = NULL; + int rc = cashu_call_raw(mint_url, "/v1/mint/quote/bolt11", "POST", body, &json, timeout_seconds); + free(body); + if (rc != NOSTR_SUCCESS) return rc; + + rc = cashu_parse_quote_common(json, quote_out, NULL); + cJSON_Delete(json); + return rc; +} + +int cashu_mint_check_mint_quote(const char* mint_url, + const char* quote_id, + cashu_mint_quote_t* quote_out, + int timeout_seconds) { + if (!mint_url || !quote_id || !quote_out) return NOSTR_ERROR_INVALID_INPUT; + + char endpoint[512]; + snprintf(endpoint, sizeof(endpoint), "/v1/mint/quote/bolt11/%s", quote_id); + + cJSON* json = NULL; + int rc = cashu_call_raw(mint_url, endpoint, "GET", NULL, &json, timeout_seconds); + if (rc != NOSTR_SUCCESS) return rc; + + rc = cashu_parse_quote_common(json, quote_out, NULL); + cJSON_Delete(json); + return rc; +} + +int cashu_mint_request_melt_quote(const char* mint_url, + const char* payment_request, + const char* unit, + cashu_melt_quote_t* quote_out, + int timeout_seconds) { + if (!mint_url || !payment_request || !unit || !quote_out) return NOSTR_ERROR_INVALID_INPUT; + + cJSON* req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "request", payment_request); + cJSON_AddStringToObject(req, "unit", unit); + char* body = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + + cJSON* json = NULL; + int rc = cashu_call_raw(mint_url, "/v1/melt/quote/bolt11", "POST", body, &json, timeout_seconds); + free(body); + if (rc != NOSTR_SUCCESS) return rc; + + rc = cashu_parse_quote_common(json, NULL, quote_out); + cJSON_Delete(json); + return rc; +} + +int cashu_mint_check_melt_quote(const char* mint_url, + const char* quote_id, + cashu_melt_quote_t* quote_out, + int timeout_seconds) { + if (!mint_url || !quote_id || !quote_out) return NOSTR_ERROR_INVALID_INPUT; + + char endpoint[512]; + snprintf(endpoint, sizeof(endpoint), "/v1/melt/quote/bolt11/%s", quote_id); + + cJSON* json = NULL; + int rc = cashu_call_raw(mint_url, endpoint, "GET", NULL, &json, timeout_seconds); + if (rc != NOSTR_SUCCESS) return rc; + + rc = cashu_parse_quote_common(json, NULL, quote_out); + cJSON_Delete(json); + return rc; +} + +int cashu_mint_swap(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds) { + if (!request_body) return NOSTR_ERROR_INVALID_INPUT; + char* body = cJSON_PrintUnformatted(request_body); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + int rc = cashu_call_raw(mint_url, "/v1/swap", "POST", body, response_out, timeout_seconds); + free(body); + return rc; +} + +int cashu_mint_mint_tokens(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds) { + if (!request_body) return NOSTR_ERROR_INVALID_INPUT; + char* body = cJSON_PrintUnformatted(request_body); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + int rc = cashu_call_raw(mint_url, "/v1/mint/bolt11", "POST", body, response_out, timeout_seconds); + free(body); + return rc; +} + +int cashu_mint_melt_tokens(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds) { + if (!request_body) return NOSTR_ERROR_INVALID_INPUT; + char* body = cJSON_PrintUnformatted(request_body); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + int rc = cashu_call_raw(mint_url, "/v1/melt/bolt11", "POST", body, response_out, timeout_seconds); + free(body); + return rc; +} + +int cashu_mint_check_proofs_state(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds) { + if (!request_body) return NOSTR_ERROR_INVALID_INPUT; + char* body = cJSON_PrintUnformatted(request_body); + if (!body) return NOSTR_ERROR_MEMORY_FAILED; + int rc = cashu_call_raw(mint_url, "/v1/checkstate", "POST", body, response_out, timeout_seconds); + free(body); + return rc; +} diff --git a/nostr_core/cashu_mint.h b/nostr_core/cashu_mint.h new file mode 100644 index 00000000..666f9b03 --- /dev/null +++ b/nostr_core/cashu_mint.h @@ -0,0 +1,102 @@ +/* + * Cashu Mint HTTP Client + */ + +#ifndef NOSTR_CASHU_MINT_H +#define NOSTR_CASHU_MINT_H + +#include +#include +#include "nostr_common.h" +#include "nip060.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define CASHU_API_VERSION "v1" + +typedef struct { + char id[17]; + char unit[16]; + int active; +} cashu_keyset_t; + +typedef struct { + char* name; + char* pubkey; + char* version; + cashu_keyset_t* keysets; + int keyset_count; +} cashu_mint_info_t; + +typedef struct { + char quote_id[128]; + char payment_request[2048]; + int paid; + uint64_t amount; + time_t expiry; +} cashu_mint_quote_t; + +typedef struct { + char quote_id[128]; + uint64_t amount; + uint64_t fee_reserve; + int paid; + char* payment_preimage; + time_t expiry; +} cashu_melt_quote_t; + +int cashu_mint_get_info(const char* mint_url, + cashu_mint_info_t* info_out, + int timeout_seconds); + +void cashu_mint_free_info(cashu_mint_info_t* info); + +int cashu_mint_request_mint_quote(const char* mint_url, + uint64_t amount, + const char* unit, + cashu_mint_quote_t* quote_out, + int timeout_seconds); + +int cashu_mint_check_mint_quote(const char* mint_url, + const char* quote_id, + cashu_mint_quote_t* quote_out, + int timeout_seconds); + +int cashu_mint_request_melt_quote(const char* mint_url, + const char* payment_request, + const char* unit, + cashu_melt_quote_t* quote_out, + int timeout_seconds); + +int cashu_mint_check_melt_quote(const char* mint_url, + const char* quote_id, + cashu_melt_quote_t* quote_out, + int timeout_seconds); + +int cashu_mint_swap(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds); + +int cashu_mint_mint_tokens(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds); + +int cashu_mint_melt_tokens(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds); + +int cashu_mint_check_proofs_state(const char* mint_url, + cJSON* request_body, + cJSON** response_out, + int timeout_seconds); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_CASHU_MINT_H */ diff --git a/nostr_core/nip060.c b/nostr_core/nip060.c new file mode 100644 index 00000000..8ca8b413 --- /dev/null +++ b/nostr_core/nip060.c @@ -0,0 +1,791 @@ +/* + * NIP-60: Cashu Wallet Implementation + * https://github.com/nostr-protocol/nips/blob/master/60.md + */ + +#include "nip060.h" +#include "nip044.h" +#include "utils.h" +#include +#include +#include + +int nostr_ec_public_key_from_private_key(const unsigned char* private_key, unsigned char* public_key); + +static char* nip60_strdup(const char* s) { + if (!s) return NULL; + size_t len = strlen(s); + char* out = (char*)malloc(len + 1); + if (!out) return NULL; + memcpy(out, s, len + 1); + return out; +} + +static const char* nip60_marker_to_string(nostr_nip60_ref_marker_t marker) { + switch (marker) { + case NOSTR_NIP60_REF_CREATED: return "created"; + case NOSTR_NIP60_REF_DESTROYED: return "destroyed"; + case NOSTR_NIP60_REF_REDEEMED: return "redeemed"; + default: return "created"; + } +} + +static nostr_nip60_ref_marker_t nip60_string_to_marker(const char* s) { + if (!s) return NOSTR_NIP60_REF_CREATED; + if (strcmp(s, "created") == 0) return NOSTR_NIP60_REF_CREATED; + if (strcmp(s, "destroyed") == 0) return NOSTR_NIP60_REF_DESTROYED; + if (strcmp(s, "redeemed") == 0) return NOSTR_NIP60_REF_REDEEMED; + return NOSTR_NIP60_REF_CREATED; +} + +static const char* nip60_direction_to_string(nostr_nip60_direction_t direction) { + return (direction == NOSTR_NIP60_DIRECTION_OUT) ? "out" : "in"; +} + +static nostr_nip60_direction_t nip60_string_to_direction(const char* s) { + if (s && strcmp(s, "out") == 0) return NOSTR_NIP60_DIRECTION_OUT; + return NOSTR_NIP60_DIRECTION_IN; +} + +static int nip60_encrypt_self(const unsigned char* private_key, + const char* plaintext, + char* output, + size_t output_size) { + if (!private_key || !plaintext || !output || output_size == 0) { + return NOSTR_ERROR_INVALID_INPUT; + } + + unsigned char pubkey[32]; + if (nostr_ec_public_key_from_private_key(private_key, pubkey) != 0) { + return NOSTR_ERROR_CRYPTO_FAILED; + } + + return nostr_nip44_encrypt(private_key, pubkey, plaintext, output, output_size); +} + +static int nip60_decrypt_event_content(cJSON* event, + const unsigned char* private_key, + char* output, + size_t output_size) { + if (!event || !private_key || !output || output_size == 0) { + return NOSTR_ERROR_INVALID_INPUT; + } + + cJSON* pubkey_item = cJSON_GetObjectItem(event, "pubkey"); + cJSON* content_item = cJSON_GetObjectItem(event, "content"); + if (!pubkey_item || !content_item || !cJSON_IsString(pubkey_item) || !cJSON_IsString(content_item)) { + return NOSTR_ERROR_NIP60_DECRYPT_FAILED; + } + + const char* pubkey_hex = cJSON_GetStringValue(pubkey_item); + const char* content = cJSON_GetStringValue(content_item); + if (!pubkey_hex || !content) { + return NOSTR_ERROR_NIP60_DECRYPT_FAILED; + } + + unsigned char sender_pubkey[32]; + if (nostr_hex_to_bytes(pubkey_hex, sender_pubkey, 32) != 0) { + return NOSTR_ERROR_NIP60_DECRYPT_FAILED; + } + + int rc = nostr_nip44_decrypt(private_key, sender_pubkey, content, output, output_size); + if (rc != NOSTR_SUCCESS) { + return NOSTR_ERROR_NIP60_DECRYPT_FAILED; + } + + return NOSTR_SUCCESS; +} + +uint64_t nostr_nip60_sum_proofs(const nostr_cashu_proof_t* proofs, int proof_count) { + if (!proofs || proof_count <= 0) return 0; + + uint64_t total = 0; + for (int i = 0; i < proof_count; i++) { + total += proofs[i].amount; + } + return total; +} + +void nostr_nip60_free_proofs(nostr_cashu_proof_t* proofs, int proof_count) { + if (!proofs) return; + + for (int i = 0; i < proof_count; i++) { + free(proofs[i].secret); + free(proofs[i].C); + proofs[i].secret = NULL; + proofs[i].C = NULL; + } + free(proofs); +} + +cJSON* nostr_nip60_proofs_to_json(const nostr_cashu_proof_t* proofs, int proof_count) { + if (!proofs || proof_count < 0) return NULL; + + cJSON* arr = cJSON_CreateArray(); + if (!arr) return NULL; + + for (int i = 0; i < proof_count; i++) { + cJSON* obj = cJSON_CreateObject(); + if (!obj) { + cJSON_Delete(arr); + return NULL; + } + + cJSON_AddStringToObject(obj, "id", proofs[i].id); + cJSON_AddNumberToObject(obj, "amount", (double)proofs[i].amount); + cJSON_AddStringToObject(obj, "secret", proofs[i].secret ? proofs[i].secret : ""); + cJSON_AddStringToObject(obj, "C", proofs[i].C ? proofs[i].C : ""); + cJSON_AddItemToArray(arr, obj); + } + + return arr; +} + +int nostr_nip60_proofs_from_json(cJSON* json_array, + nostr_cashu_proof_t** proofs_out, + int* proof_count_out) { + if (!json_array || !cJSON_IsArray(json_array) || !proofs_out || !proof_count_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + *proofs_out = NULL; + *proof_count_out = 0; + + int count = cJSON_GetArraySize(json_array); + if (count <= 0) { + return NOSTR_SUCCESS; + } + + nostr_cashu_proof_t* proofs = (nostr_cashu_proof_t*)calloc((size_t)count, sizeof(nostr_cashu_proof_t)); + if (!proofs) { + return NOSTR_ERROR_MEMORY_FAILED; + } + + for (int i = 0; i < count; i++) { + cJSON* p = cJSON_GetArrayItem(json_array, i); + if (!p || !cJSON_IsObject(p)) { + nostr_nip60_free_proofs(proofs, count); + return NOSTR_ERROR_NIP60_INVALID_PROOFS; + } + + cJSON* id_item = cJSON_GetObjectItem(p, "id"); + cJSON* amount_item = cJSON_GetObjectItem(p, "amount"); + cJSON* secret_item = cJSON_GetObjectItem(p, "secret"); + cJSON* c_item = cJSON_GetObjectItem(p, "C"); + + if (!id_item || !amount_item || !secret_item || !c_item || + !cJSON_IsString(id_item) || !cJSON_IsNumber(amount_item) || + !cJSON_IsString(secret_item) || !cJSON_IsString(c_item)) { + nostr_nip60_free_proofs(proofs, count); + return NOSTR_ERROR_NIP60_INVALID_PROOFS; + } + + const char* id = cJSON_GetStringValue(id_item); + const char* secret = cJSON_GetStringValue(secret_item); + const char* C = cJSON_GetStringValue(c_item); + if (!id || !secret || !C) { + nostr_nip60_free_proofs(proofs, count); + return NOSTR_ERROR_NIP60_INVALID_PROOFS; + } + + strncpy(proofs[i].id, id, sizeof(proofs[i].id) - 1); + proofs[i].id[sizeof(proofs[i].id) - 1] = '\0'; + proofs[i].amount = (uint64_t)cJSON_GetNumberValue(amount_item); + proofs[i].secret = nip60_strdup(secret); + proofs[i].C = nip60_strdup(C); + + if (!proofs[i].secret || !proofs[i].C) { + nostr_nip60_free_proofs(proofs, count); + return NOSTR_ERROR_MEMORY_FAILED; + } + } + + *proofs_out = proofs; + *proof_count_out = count; + return NOSTR_SUCCESS; +} + +cJSON* nostr_nip60_create_wallet_event(const nostr_nip60_wallet_data_t* wallet_data, + const unsigned char* private_key, + time_t timestamp) { + if (!wallet_data || !private_key || wallet_data->mint_count <= 0) { + return NULL; + } + + cJSON* payload = cJSON_CreateArray(); + if (!payload) return NULL; + + cJSON* priv_row = cJSON_CreateArray(); + if (!priv_row) { + cJSON_Delete(payload); + return NULL; + } + cJSON_AddItemToArray(priv_row, cJSON_CreateString("privkey")); + cJSON_AddItemToArray(priv_row, cJSON_CreateString(wallet_data->privkey)); + cJSON_AddItemToArray(payload, priv_row); + + for (int i = 0; i < wallet_data->mint_count; i++) { + if (!wallet_data->mint_urls || !wallet_data->mint_urls[i]) continue; + cJSON* mint_row = cJSON_CreateArray(); + if (!mint_row) { + cJSON_Delete(payload); + return NULL; + } + cJSON_AddItemToArray(mint_row, cJSON_CreateString("mint")); + cJSON_AddItemToArray(mint_row, cJSON_CreateString(wallet_data->mint_urls[i])); + cJSON_AddItemToArray(payload, mint_row); + } + + char* plain = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (!plain) return NULL; + + char encrypted[65536]; + int rc = nip60_encrypt_self(private_key, plain, encrypted, sizeof(encrypted)); + free(plain); + if (rc != NOSTR_SUCCESS) return NULL; + + return nostr_create_and_sign_event(NOSTR_NIP60_WALLET_KIND, encrypted, NULL, private_key, timestamp); +} + +int nostr_nip60_parse_wallet_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_wallet_data_t* wallet_data_out) { + if (!event || !private_key || !wallet_data_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + memset(wallet_data_out, 0, sizeof(*wallet_data_out)); + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP60_WALLET_KIND) { + return NOSTR_ERROR_NIP60_INVALID_WALLET; + } + + char decrypted[65536]; + int rc = nip60_decrypt_event_content(event, private_key, decrypted, sizeof(decrypted)); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* payload = cJSON_Parse(decrypted); + if (!payload || !cJSON_IsArray(payload)) { + cJSON_Delete(payload); + return NOSTR_ERROR_NIP60_INVALID_WALLET; + } + + int mint_count = 0; + cJSON* row = NULL; + cJSON_ArrayForEach(row, payload) { + if (!cJSON_IsArray(row) || cJSON_GetArraySize(row) < 2) continue; + cJSON* k = cJSON_GetArrayItem(row, 0); + cJSON* v = cJSON_GetArrayItem(row, 1); + if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v)) continue; + const char* key = cJSON_GetStringValue(k); + if (key && strcmp(key, "mint") == 0) mint_count++; + } + + if (mint_count > 0) { + wallet_data_out->mint_urls = (char**)calloc((size_t)mint_count, sizeof(char*)); + if (!wallet_data_out->mint_urls) { + cJSON_Delete(payload); + return NOSTR_ERROR_MEMORY_FAILED; + } + } + + int mint_idx = 0; + cJSON_ArrayForEach(row, payload) { + if (!cJSON_IsArray(row) || cJSON_GetArraySize(row) < 2) continue; + cJSON* k = cJSON_GetArrayItem(row, 0); + cJSON* v = cJSON_GetArrayItem(row, 1); + if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v)) continue; + + const char* key = cJSON_GetStringValue(k); + const char* val = cJSON_GetStringValue(v); + if (!key || !val) continue; + + if (strcmp(key, "privkey") == 0) { + strncpy(wallet_data_out->privkey, val, sizeof(wallet_data_out->privkey) - 1); + wallet_data_out->privkey[sizeof(wallet_data_out->privkey) - 1] = '\0'; + } else if (strcmp(key, "mint") == 0 && mint_idx < mint_count) { + wallet_data_out->mint_urls[mint_idx] = nip60_strdup(val); + if (!wallet_data_out->mint_urls[mint_idx]) { + cJSON_Delete(payload); + nostr_nip60_free_wallet_data(wallet_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + mint_idx++; + } + } + + wallet_data_out->mint_count = mint_idx; + cJSON_Delete(payload); + + if (wallet_data_out->privkey[0] == '\0' || wallet_data_out->mint_count == 0) { + nostr_nip60_free_wallet_data(wallet_data_out); + return NOSTR_ERROR_NIP60_INVALID_WALLET; + } + + return NOSTR_SUCCESS; +} + +void nostr_nip60_free_wallet_data(nostr_nip60_wallet_data_t* data) { + if (!data) return; + if (data->mint_urls) { + for (int i = 0; i < data->mint_count; i++) { + free(data->mint_urls[i]); + } + free(data->mint_urls); + } + memset(data, 0, sizeof(*data)); +} + +cJSON* nostr_nip60_create_token_event(const nostr_nip60_token_data_t* token_data, + const unsigned char* private_key, + time_t timestamp) { + if (!token_data || !private_key || !token_data->mint_url || + !token_data->proofs || token_data->proof_count <= 0) { + return NULL; + } + + cJSON* payload = cJSON_CreateObject(); + if (!payload) return NULL; + + cJSON_AddStringToObject(payload, "mint", token_data->mint_url); + + cJSON* proofs = nostr_nip60_proofs_to_json(token_data->proofs, token_data->proof_count); + if (!proofs) { + cJSON_Delete(payload); + return NULL; + } + cJSON_AddItemToObject(payload, "proofs", proofs); + + if (token_data->deleted_token_ids && token_data->deleted_count > 0) { + cJSON* del = cJSON_CreateArray(); + if (!del) { + cJSON_Delete(payload); + return NULL; + } + for (int i = 0; i < token_data->deleted_count; i++) { + if (token_data->deleted_token_ids[i]) { + cJSON_AddItemToArray(del, cJSON_CreateString(token_data->deleted_token_ids[i])); + } + } + cJSON_AddItemToObject(payload, "del", del); + } + + char* plain = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (!plain) return NULL; + + char encrypted[131072]; + int rc = nip60_encrypt_self(private_key, plain, encrypted, sizeof(encrypted)); + free(plain); + if (rc != NOSTR_SUCCESS) return NULL; + + return nostr_create_and_sign_event(NOSTR_NIP60_TOKEN_KIND, encrypted, NULL, private_key, timestamp); +} + +int nostr_nip60_parse_token_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_token_data_t* token_data_out) { + if (!event || !private_key || !token_data_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + memset(token_data_out, 0, sizeof(*token_data_out)); + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP60_TOKEN_KIND) { + return NOSTR_ERROR_NIP60_INVALID_TOKEN; + } + + char decrypted[131072]; + int rc = nip60_decrypt_event_content(event, private_key, decrypted, sizeof(decrypted)); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* payload = cJSON_Parse(decrypted); + if (!payload || !cJSON_IsObject(payload)) { + cJSON_Delete(payload); + return NOSTR_ERROR_NIP60_INVALID_TOKEN; + } + + cJSON* mint_item = cJSON_GetObjectItem(payload, "mint"); + cJSON* proofs_item = cJSON_GetObjectItem(payload, "proofs"); + if (!mint_item || !proofs_item || !cJSON_IsString(mint_item) || !cJSON_IsArray(proofs_item)) { + cJSON_Delete(payload); + return NOSTR_ERROR_NIP60_INVALID_TOKEN; + } + + const char* mint_url = cJSON_GetStringValue(mint_item); + token_data_out->mint_url = nip60_strdup(mint_url ? mint_url : ""); + if (!token_data_out->mint_url) { + cJSON_Delete(payload); + return NOSTR_ERROR_MEMORY_FAILED; + } + + rc = nostr_nip60_proofs_from_json(proofs_item, &token_data_out->proofs, &token_data_out->proof_count); + if (rc != NOSTR_SUCCESS) { + cJSON_Delete(payload); + nostr_nip60_free_token_data(token_data_out); + return rc; + } + + cJSON* del_item = cJSON_GetObjectItem(payload, "del"); + if (del_item && cJSON_IsArray(del_item)) { + int del_count = cJSON_GetArraySize(del_item); + if (del_count > 0) { + token_data_out->deleted_token_ids = (char**)calloc((size_t)del_count, sizeof(char*)); + if (!token_data_out->deleted_token_ids) { + cJSON_Delete(payload); + nostr_nip60_free_token_data(token_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + + for (int i = 0; i < del_count; i++) { + cJSON* it = cJSON_GetArrayItem(del_item, i); + if (!it || !cJSON_IsString(it)) continue; + const char* s = cJSON_GetStringValue(it); + if (!s) continue; + token_data_out->deleted_token_ids[token_data_out->deleted_count] = nip60_strdup(s); + if (!token_data_out->deleted_token_ids[token_data_out->deleted_count]) { + cJSON_Delete(payload); + nostr_nip60_free_token_data(token_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + token_data_out->deleted_count++; + } + } + } + + cJSON_Delete(payload); + return NOSTR_SUCCESS; +} + +void nostr_nip60_free_token_data(nostr_nip60_token_data_t* data) { + if (!data) return; + + free(data->mint_url); + nostr_nip60_free_proofs(data->proofs, data->proof_count); + + if (data->deleted_token_ids) { + for (int i = 0; i < data->deleted_count; i++) { + free(data->deleted_token_ids[i]); + } + free(data->deleted_token_ids); + } + + memset(data, 0, sizeof(*data)); +} + +cJSON* nostr_nip60_create_token_deletion(const char* token_event_id, + const unsigned char* private_key, + time_t timestamp) { + if (!token_event_id || !private_key) return NULL; + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + cJSON* e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("e")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(token_event_id)); + cJSON_AddItemToArray(tags, e_tag); + + cJSON* k_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(k_tag, cJSON_CreateString("k")); + cJSON_AddItemToArray(k_tag, cJSON_CreateString("7375")); + cJSON_AddItemToArray(tags, k_tag); + + cJSON* evt = nostr_create_and_sign_event(5, "NIP-60 token spent", tags, private_key, timestamp); + cJSON_Delete(tags); + return evt; +} + +cJSON* nostr_nip60_create_rollover_token(const nostr_nip60_token_data_t* remaining_proofs, + const char** deleted_event_ids, + int deleted_count, + const unsigned char* private_key, + time_t timestamp) { + if (!remaining_proofs || !private_key) return NULL; + + nostr_nip60_token_data_t token = *remaining_proofs; + token.deleted_token_ids = (char**)deleted_event_ids; + token.deleted_count = deleted_count; + + return nostr_nip60_create_token_event(&token, private_key, timestamp); +} + +cJSON* nostr_nip60_create_history_event(const nostr_nip60_history_data_t* history_data, + const unsigned char* private_key, + time_t timestamp) { + if (!history_data || !private_key) return NULL; + + cJSON* payload = cJSON_CreateArray(); + if (!payload) return NULL; + + cJSON* dir = cJSON_CreateArray(); + cJSON_AddItemToArray(dir, cJSON_CreateString("direction")); + cJSON_AddItemToArray(dir, cJSON_CreateString(nip60_direction_to_string(history_data->direction))); + cJSON_AddItemToArray(payload, dir); + + char amount_buf[32]; + snprintf(amount_buf, sizeof(amount_buf), "%llu", (unsigned long long)history_data->amount); + cJSON* amt = cJSON_CreateArray(); + cJSON_AddItemToArray(amt, cJSON_CreateString("amount")); + cJSON_AddItemToArray(amt, cJSON_CreateString(amount_buf)); + cJSON_AddItemToArray(payload, amt); + + for (int i = 0; i < history_data->ref_count; i++) { + cJSON* e = cJSON_CreateArray(); + cJSON_AddItemToArray(e, cJSON_CreateString("e")); + cJSON_AddItemToArray(e, cJSON_CreateString(history_data->refs[i].event_id)); + cJSON_AddItemToArray(e, cJSON_CreateString(history_data->refs[i].relay_hint)); + cJSON_AddItemToArray(e, cJSON_CreateString(nip60_marker_to_string(history_data->refs[i].marker))); + cJSON_AddItemToArray(payload, e); + } + + char* plain = cJSON_PrintUnformatted(payload); + cJSON_Delete(payload); + if (!plain) return NULL; + + char encrypted[65536]; + int rc = nip60_encrypt_self(private_key, plain, encrypted, sizeof(encrypted)); + free(plain); + if (rc != NOSTR_SUCCESS) return NULL; + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + for (int i = 0; i < history_data->ref_count; i++) { + if (history_data->refs[i].marker != NOSTR_NIP60_REF_REDEEMED) continue; + + cJSON* e = cJSON_CreateArray(); + cJSON_AddItemToArray(e, cJSON_CreateString("e")); + cJSON_AddItemToArray(e, cJSON_CreateString(history_data->refs[i].event_id)); + cJSON_AddItemToArray(e, cJSON_CreateString(history_data->refs[i].relay_hint)); + cJSON_AddItemToArray(e, cJSON_CreateString("redeemed")); + cJSON_AddItemToArray(tags, e); + } + + cJSON* evt = nostr_create_and_sign_event(NOSTR_NIP60_HISTORY_KIND, encrypted, tags, private_key, timestamp); + cJSON_Delete(tags); + return evt; +} + +int nostr_nip60_parse_history_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_history_data_t* history_data_out) { + if (!event || !private_key || !history_data_out) { + return NOSTR_ERROR_INVALID_INPUT; + } + + memset(history_data_out, 0, sizeof(*history_data_out)); + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP60_HISTORY_KIND) { + return NOSTR_ERROR_NIP60_INVALID_HISTORY; + } + + char decrypted[65536]; + int rc = nip60_decrypt_event_content(event, private_key, decrypted, sizeof(decrypted)); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* payload = cJSON_Parse(decrypted); + if (!payload || !cJSON_IsArray(payload)) { + cJSON_Delete(payload); + return NOSTR_ERROR_NIP60_INVALID_HISTORY; + } + + int ref_count = 0; + cJSON* row = NULL; + cJSON_ArrayForEach(row, payload) { + if (!cJSON_IsArray(row) || cJSON_GetArraySize(row) < 2) continue; + cJSON* k = cJSON_GetArrayItem(row, 0); + if (!k || !cJSON_IsString(k)) continue; + const char* key = cJSON_GetStringValue(k); + if (key && strcmp(key, "e") == 0) ref_count++; + } + + if (ref_count > 0) { + history_data_out->refs = (nostr_nip60_history_ref_t*)calloc((size_t)ref_count, sizeof(nostr_nip60_history_ref_t)); + if (!history_data_out->refs) { + cJSON_Delete(payload); + return NOSTR_ERROR_MEMORY_FAILED; + } + } + + int ref_idx = 0; + cJSON_ArrayForEach(row, payload) { + if (!cJSON_IsArray(row) || cJSON_GetArraySize(row) < 2) continue; + + cJSON* k = cJSON_GetArrayItem(row, 0); + cJSON* v = cJSON_GetArrayItem(row, 1); + if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v)) continue; + + const char* key = cJSON_GetStringValue(k); + const char* val = cJSON_GetStringValue(v); + if (!key || !val) continue; + + if (strcmp(key, "direction") == 0) { + history_data_out->direction = nip60_string_to_direction(val); + } else if (strcmp(key, "amount") == 0) { + history_data_out->amount = (uint64_t)strtoull(val, NULL, 10); + } else if (strcmp(key, "e") == 0 && ref_idx < ref_count) { + strncpy(history_data_out->refs[ref_idx].event_id, val, + sizeof(history_data_out->refs[ref_idx].event_id) - 1); + + cJSON* relay_item = cJSON_GetArrayItem(row, 2); + cJSON* marker_item = cJSON_GetArrayItem(row, 3); + + if (relay_item && cJSON_IsString(relay_item)) { + const char* relay = cJSON_GetStringValue(relay_item); + if (relay) { + strncpy(history_data_out->refs[ref_idx].relay_hint, relay, + sizeof(history_data_out->refs[ref_idx].relay_hint) - 1); + } + } + + if (marker_item && cJSON_IsString(marker_item)) { + const char* m = cJSON_GetStringValue(marker_item); + history_data_out->refs[ref_idx].marker = nip60_string_to_marker(m); + } else { + history_data_out->refs[ref_idx].marker = NOSTR_NIP60_REF_CREATED; + } + + ref_idx++; + } + } + + history_data_out->ref_count = ref_idx; + cJSON_Delete(payload); + return NOSTR_SUCCESS; +} + +void nostr_nip60_free_history_data(nostr_nip60_history_data_t* data) { + if (!data) return; + free(data->refs); + memset(data, 0, sizeof(*data)); +} + +cJSON* nostr_nip60_create_quote_event(const char* quote_id, + const char* mint_url, + time_t expiration, + const unsigned char* private_key, + time_t timestamp) { + if (!quote_id || !mint_url || !private_key || expiration <= 0) { + return NULL; + } + + char encrypted[4096]; + int rc = nip60_encrypt_self(private_key, quote_id, encrypted, sizeof(encrypted)); + if (rc != NOSTR_SUCCESS) return NULL; + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + cJSON* exp_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(exp_tag, cJSON_CreateString("expiration")); + + char exp_buf[32]; + snprintf(exp_buf, sizeof(exp_buf), "%lld", (long long)expiration); + cJSON_AddItemToArray(exp_tag, cJSON_CreateString(exp_buf)); + cJSON_AddItemToArray(tags, exp_tag); + + cJSON* mint_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(mint_tag, cJSON_CreateString("mint")); + cJSON_AddItemToArray(mint_tag, cJSON_CreateString(mint_url)); + cJSON_AddItemToArray(tags, mint_tag); + + cJSON* evt = nostr_create_and_sign_event(NOSTR_NIP60_QUOTE_KIND, encrypted, tags, private_key, timestamp); + cJSON_Delete(tags); + return evt; +} + +int nostr_nip60_parse_quote_event(cJSON* event, + const unsigned char* private_key, + char* quote_id_out, + size_t quote_id_size, + char* mint_url_out, + size_t mint_url_size, + time_t* expiration_out) { + if (!event || !private_key || !quote_id_out || quote_id_size == 0 || + !mint_url_out || mint_url_size == 0) { + return NOSTR_ERROR_INVALID_INPUT; + } + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP60_QUOTE_KIND) { + return NOSTR_ERROR_NIP60_INVALID_QUOTE; + } + + char decrypted[4096]; + int rc = nip60_decrypt_event_content(event, private_key, decrypted, sizeof(decrypted)); + if (rc != NOSTR_SUCCESS) return rc; + + strncpy(quote_id_out, decrypted, quote_id_size - 1); + quote_id_out[quote_id_size - 1] = '\0'; + + mint_url_out[0] = '\0'; + if (expiration_out) *expiration_out = 0; + + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + if (tags && cJSON_IsArray(tags)) { + cJSON* tag = NULL; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + cJSON* k = cJSON_GetArrayItem(tag, 0); + cJSON* v = cJSON_GetArrayItem(tag, 1); + if (!k || !v || !cJSON_IsString(k) || !cJSON_IsString(v)) continue; + + const char* key = cJSON_GetStringValue(k); + const char* val = cJSON_GetStringValue(v); + if (!key || !val) continue; + + if (strcmp(key, "mint") == 0) { + strncpy(mint_url_out, val, mint_url_size - 1); + mint_url_out[mint_url_size - 1] = '\0'; + } else if (strcmp(key, "expiration") == 0 && expiration_out) { + *expiration_out = (time_t)strtoll(val, NULL, 10); + } + } + } + + return NOSTR_SUCCESS; +} + +cJSON* nostr_nip60_create_wallet_filter(const char* pubkey_hex) { + if (!pubkey_hex) return NULL; + + cJSON* filter = cJSON_CreateObject(); + if (!filter) return NULL; + + cJSON* kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_WALLET_KIND)); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_TOKEN_KIND)); + cJSON_AddItemToObject(filter, "kinds", kinds); + + cJSON* authors = cJSON_CreateArray(); + cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + + return filter; +} + +cJSON* nostr_nip60_create_history_filter(const char* pubkey_hex, time_t since) { + if (!pubkey_hex) return NULL; + + cJSON* filter = cJSON_CreateObject(); + if (!filter) return NULL; + + cJSON* kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_HISTORY_KIND)); + cJSON_AddItemToObject(filter, "kinds", kinds); + + cJSON* authors = cJSON_CreateArray(); + cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + + if (since > 0) { + cJSON_AddNumberToObject(filter, "since", (double)since); + } + + return filter; +} diff --git a/nostr_core/nip060.h b/nostr_core/nip060.h new file mode 100644 index 00000000..c0c4eb6f --- /dev/null +++ b/nostr_core/nip060.h @@ -0,0 +1,146 @@ +/* + * NIP-60: Cashu Wallet + * https://github.com/nostr-protocol/nips/blob/master/60.md + */ + +#ifndef NOSTR_NIP060_H +#define NOSTR_NIP060_H + +#include +#include +#include +#include "nip001.h" +#include "nostr_common.h" +#include "../cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define NOSTR_NIP60_WALLET_KIND 17375 +#define NOSTR_NIP60_TOKEN_KIND 7375 +#define NOSTR_NIP60_HISTORY_KIND 7376 +#define NOSTR_NIP60_QUOTE_KIND 7374 + +#define NOSTR_CASHU_KEYSET_ID_HEX_SIZE 17 +#define NOSTR_CASHU_PUBKEY_HEX_SIZE 67 +#define NOSTR_CASHU_EVENT_ID_HEX_SIZE 65 + +typedef struct { + char id[NOSTR_CASHU_KEYSET_ID_HEX_SIZE]; + uint64_t amount; + char* secret; + char* C; +} nostr_cashu_proof_t; + +typedef struct { + char privkey[65]; + char** mint_urls; + int mint_count; +} nostr_nip60_wallet_data_t; + +typedef struct { + char* mint_url; + nostr_cashu_proof_t* proofs; + int proof_count; + char** deleted_token_ids; + int deleted_count; +} nostr_nip60_token_data_t; + +typedef enum { + NOSTR_NIP60_DIRECTION_IN = 0, + NOSTR_NIP60_DIRECTION_OUT = 1 +} nostr_nip60_direction_t; + +typedef enum { + NOSTR_NIP60_REF_CREATED = 0, + NOSTR_NIP60_REF_DESTROYED = 1, + NOSTR_NIP60_REF_REDEEMED = 2 +} nostr_nip60_ref_marker_t; + +typedef struct { + char event_id[NOSTR_CASHU_EVENT_ID_HEX_SIZE]; + char relay_hint[256]; + nostr_nip60_ref_marker_t marker; +} nostr_nip60_history_ref_t; + +typedef struct { + nostr_nip60_direction_t direction; + uint64_t amount; + nostr_nip60_history_ref_t* refs; + int ref_count; +} nostr_nip60_history_data_t; + +cJSON* nostr_nip60_create_wallet_event(const nostr_nip60_wallet_data_t* wallet_data, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip60_parse_wallet_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_wallet_data_t* wallet_data_out); + +void nostr_nip60_free_wallet_data(nostr_nip60_wallet_data_t* data); + +cJSON* nostr_nip60_create_token_event(const nostr_nip60_token_data_t* token_data, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip60_parse_token_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_token_data_t* token_data_out); + +void nostr_nip60_free_token_data(nostr_nip60_token_data_t* data); + +cJSON* nostr_nip60_create_token_deletion(const char* token_event_id, + const unsigned char* private_key, + time_t timestamp); + +cJSON* nostr_nip60_create_rollover_token(const nostr_nip60_token_data_t* remaining_proofs, + const char** deleted_event_ids, + int deleted_count, + const unsigned char* private_key, + time_t timestamp); + +cJSON* nostr_nip60_create_history_event(const nostr_nip60_history_data_t* history_data, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip60_parse_history_event(cJSON* event, + const unsigned char* private_key, + nostr_nip60_history_data_t* history_data_out); + +void nostr_nip60_free_history_data(nostr_nip60_history_data_t* data); + +cJSON* nostr_nip60_create_quote_event(const char* quote_id, + const char* mint_url, + time_t expiration, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip60_parse_quote_event(cJSON* event, + const unsigned char* private_key, + char* quote_id_out, + size_t quote_id_size, + char* mint_url_out, + size_t mint_url_size, + time_t* expiration_out); + +uint64_t nostr_nip60_sum_proofs(const nostr_cashu_proof_t* proofs, int proof_count); + +cJSON* nostr_nip60_proofs_to_json(const nostr_cashu_proof_t* proofs, int proof_count); + +int nostr_nip60_proofs_from_json(cJSON* json_array, + nostr_cashu_proof_t** proofs_out, + int* proof_count_out); + +void nostr_nip60_free_proofs(nostr_cashu_proof_t* proofs, int proof_count); + +cJSON* nostr_nip60_create_wallet_filter(const char* pubkey_hex); + +cJSON* nostr_nip60_create_history_filter(const char* pubkey_hex, time_t since); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_NIP060_H */ diff --git a/nostr_core/nip061.c b/nostr_core/nip061.c new file mode 100644 index 00000000..67bab88f --- /dev/null +++ b/nostr_core/nip061.c @@ -0,0 +1,540 @@ +/* + * NIP-61: Nutzaps Implementation + * https://github.com/nostr-protocol/nips/blob/master/61.md + */ + +#include "nip061.h" +#include "nip060.h" +#include +#include +#include + +static char* nip61_strdup(const char* s) { + if (!s) return NULL; + size_t len = strlen(s); + char* out = (char*)malloc(len + 1); + if (!out) return NULL; + memcpy(out, s, len + 1); + return out; +} + +static int nip61_mint_in_info(const char* mint_url, const nostr_nip61_nutzap_info_t* info) { + if (!mint_url || !info) return 0; + for (int i = 0; i < info->mint_count; i++) { + if (info->mints[i].url && strcmp(info->mints[i].url, mint_url) == 0) { + return 1; + } + } + return 0; +} + +cJSON* nostr_nip61_create_nutzap_info_event(const nostr_nip61_nutzap_info_t* info, + const unsigned char* private_key, + time_t timestamp) { + if (!info || !private_key || info->mint_count <= 0 || info->relay_count <= 0 || info->pubkey[0] == '\0') { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + for (int i = 0; i < info->relay_count; i++) { + if (!info->relay_urls || !info->relay_urls[i]) continue; + cJSON* relay = cJSON_CreateArray(); + cJSON_AddItemToArray(relay, cJSON_CreateString("relay")); + cJSON_AddItemToArray(relay, cJSON_CreateString(info->relay_urls[i])); + cJSON_AddItemToArray(tags, relay); + } + + for (int i = 0; i < info->mint_count; i++) { + if (!info->mints || !info->mints[i].url) continue; + cJSON* mint = cJSON_CreateArray(); + cJSON_AddItemToArray(mint, cJSON_CreateString("mint")); + cJSON_AddItemToArray(mint, cJSON_CreateString(info->mints[i].url)); + for (int u = 0; u < info->mints[i].unit_count; u++) { + if (info->mints[i].units && info->mints[i].units[u]) { + cJSON_AddItemToArray(mint, cJSON_CreateString(info->mints[i].units[u])); + } + } + cJSON_AddItemToArray(tags, mint); + } + + cJSON* pub = cJSON_CreateArray(); + cJSON_AddItemToArray(pub, cJSON_CreateString("pubkey")); + cJSON_AddItemToArray(pub, cJSON_CreateString(info->pubkey)); + cJSON_AddItemToArray(tags, pub); + + cJSON* evt = nostr_create_and_sign_event(NOSTR_NIP61_NUTZAP_INFO_KIND, "", tags, private_key, timestamp); + cJSON_Delete(tags); + return evt; +} + +int nostr_nip61_parse_nutzap_info_event(cJSON* event, + nostr_nip61_nutzap_info_t* info_out) { + if (!event || !info_out) return NOSTR_ERROR_INVALID_INPUT; + + memset(info_out, 0, sizeof(*info_out)); + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP61_NUTZAP_INFO_KIND || + !tags || !cJSON_IsArray(tags)) { + return NOSTR_ERROR_NIP61_INVALID_INFO; + } + + int relay_count = 0; + int mint_count = 0; + + cJSON* tag = NULL; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + cJSON* t0 = cJSON_GetArrayItem(tag, 0); + if (!t0 || !cJSON_IsString(t0)) continue; + const char* key = cJSON_GetStringValue(t0); + if (!key) continue; + if (strcmp(key, "relay") == 0) relay_count++; + if (strcmp(key, "mint") == 0) mint_count++; + } + + if (relay_count > 0) { + info_out->relay_urls = (char**)calloc((size_t)relay_count, sizeof(char*)); + if (!info_out->relay_urls) return NOSTR_ERROR_MEMORY_FAILED; + } + + if (mint_count > 0) { + info_out->mints = (nostr_nip61_mint_entry_t*)calloc((size_t)mint_count, sizeof(nostr_nip61_mint_entry_t)); + if (!info_out->mints) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + } + + int relay_idx = 0; + int mint_idx = 0; + + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + cJSON* t0 = cJSON_GetArrayItem(tag, 0); + cJSON* t1 = cJSON_GetArrayItem(tag, 1); + if (!t0 || !t1 || !cJSON_IsString(t0) || !cJSON_IsString(t1)) continue; + + const char* key = cJSON_GetStringValue(t0); + const char* val = cJSON_GetStringValue(t1); + if (!key || !val) continue; + + if (strcmp(key, "relay") == 0 && relay_idx < relay_count) { + info_out->relay_urls[relay_idx] = nip61_strdup(val); + if (!info_out->relay_urls[relay_idx]) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + relay_idx++; + } else if (strcmp(key, "mint") == 0 && mint_idx < mint_count) { + info_out->mints[mint_idx].url = nip61_strdup(val); + if (!info_out->mints[mint_idx].url) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + + int total = cJSON_GetArraySize(tag); + int unit_count = (total > 2) ? (total - 2) : 0; + if (unit_count > 0) { + info_out->mints[mint_idx].units = (char**)calloc((size_t)unit_count, sizeof(char*)); + if (!info_out->mints[mint_idx].units) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + + for (int u = 0; u < unit_count; u++) { + cJSON* unit_item = cJSON_GetArrayItem(tag, u + 2); + if (unit_item && cJSON_IsString(unit_item)) { + const char* us = cJSON_GetStringValue(unit_item); + if (us) { + info_out->mints[mint_idx].units[info_out->mints[mint_idx].unit_count] = nip61_strdup(us); + if (!info_out->mints[mint_idx].units[info_out->mints[mint_idx].unit_count]) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + info_out->mints[mint_idx].unit_count++; + } + } + } + } + mint_idx++; + } else if (strcmp(key, "pubkey") == 0) { + strncpy(info_out->pubkey, val, sizeof(info_out->pubkey) - 1); + info_out->pubkey[sizeof(info_out->pubkey) - 1] = '\0'; + } + } + + info_out->relay_count = relay_idx; + info_out->mint_count = mint_idx; + + if (info_out->pubkey[0] == '\0' || info_out->mint_count == 0) { + nostr_nip61_free_nutzap_info(info_out); + return NOSTR_ERROR_NIP61_INVALID_INFO; + } + + return NOSTR_SUCCESS; +} + +void nostr_nip61_free_nutzap_info(nostr_nip61_nutzap_info_t* info) { + if (!info) return; + + if (info->relay_urls) { + for (int i = 0; i < info->relay_count; i++) { + free(info->relay_urls[i]); + } + free(info->relay_urls); + } + + if (info->mints) { + for (int i = 0; i < info->mint_count; i++) { + free(info->mints[i].url); + if (info->mints[i].units) { + for (int u = 0; u < info->mints[i].unit_count; u++) { + free(info->mints[i].units[u]); + } + free(info->mints[i].units); + } + } + free(info->mints); + } + + memset(info, 0, sizeof(*info)); +} + +cJSON* nostr_nip61_create_nutzap_event(const nostr_nip61_nutzap_data_t* nutzap_data, + const unsigned char* sender_private_key, + time_t timestamp) { + if (!nutzap_data || !sender_private_key || !nutzap_data->mint_url || + !nutzap_data->proofs || nutzap_data->proof_count <= 0 || + nutzap_data->recipient_pubkey[0] == '\0') { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + for (int i = 0; i < nutzap_data->proof_count; i++) { + cJSON* proof_json = cJSON_CreateObject(); + cJSON_AddStringToObject(proof_json, "id", nutzap_data->proofs[i].id); + cJSON_AddNumberToObject(proof_json, "amount", (double)nutzap_data->proofs[i].amount); + cJSON_AddStringToObject(proof_json, "secret", nutzap_data->proofs[i].secret ? nutzap_data->proofs[i].secret : ""); + cJSON_AddStringToObject(proof_json, "C", nutzap_data->proofs[i].C ? nutzap_data->proofs[i].C : ""); + + char* proof_str = cJSON_PrintUnformatted(proof_json); + cJSON_Delete(proof_json); + if (!proof_str) { + cJSON_Delete(tags); + return NULL; + } + + cJSON* proof_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(proof_tag, cJSON_CreateString("proof")); + cJSON_AddItemToArray(proof_tag, cJSON_CreateString(proof_str)); + cJSON_AddItemToArray(tags, proof_tag); + free(proof_str); + } + + cJSON* u = cJSON_CreateArray(); + cJSON_AddItemToArray(u, cJSON_CreateString("u")); + cJSON_AddItemToArray(u, cJSON_CreateString(nutzap_data->mint_url)); + cJSON_AddItemToArray(tags, u); + + cJSON* p = cJSON_CreateArray(); + cJSON_AddItemToArray(p, cJSON_CreateString("p")); + cJSON_AddItemToArray(p, cJSON_CreateString(nutzap_data->recipient_pubkey)); + cJSON_AddItemToArray(tags, p); + + if (nutzap_data->nutzapped_event_id[0] != '\0') { + cJSON* e = cJSON_CreateArray(); + cJSON_AddItemToArray(e, cJSON_CreateString("e")); + cJSON_AddItemToArray(e, cJSON_CreateString(nutzap_data->nutzapped_event_id)); + cJSON_AddItemToArray(e, cJSON_CreateString(nutzap_data->nutzapped_relay_hint)); + cJSON_AddItemToArray(tags, e); + + if (nutzap_data->nutzapped_kind > 0) { + cJSON* k = cJSON_CreateArray(); + char kind_buf[16]; + snprintf(kind_buf, sizeof(kind_buf), "%d", nutzap_data->nutzapped_kind); + cJSON_AddItemToArray(k, cJSON_CreateString("k")); + cJSON_AddItemToArray(k, cJSON_CreateString(kind_buf)); + cJSON_AddItemToArray(tags, k); + } + } + + const char* content = nutzap_data->content ? nutzap_data->content : ""; + cJSON* evt = nostr_create_and_sign_event(NOSTR_NIP61_NUTZAP_KIND, content, tags, sender_private_key, timestamp); + cJSON_Delete(tags); + return evt; +} + +int nostr_nip61_parse_nutzap_event(cJSON* event, + nostr_nip61_nutzap_data_t* nutzap_data_out) { + if (!event || !nutzap_data_out) return NOSTR_ERROR_INVALID_INPUT; + + memset(nutzap_data_out, 0, sizeof(*nutzap_data_out)); + + cJSON* kind_item = cJSON_GetObjectItem(event, "kind"); + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + cJSON* content = cJSON_GetObjectItem(event, "content"); + + if (!kind_item || !cJSON_IsNumber(kind_item) || (int)cJSON_GetNumberValue(kind_item) != NOSTR_NIP61_NUTZAP_KIND || + !tags || !cJSON_IsArray(tags) || !content || !cJSON_IsString(content)) { + return NOSTR_ERROR_NIP61_INVALID_NUTZAP; + } + + const char* content_str = cJSON_GetStringValue(content); + nutzap_data_out->content = nip61_strdup(content_str ? content_str : ""); + if (!nutzap_data_out->content) return NOSTR_ERROR_MEMORY_FAILED; + + int proof_count = 0; + cJSON* tag = NULL; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + cJSON* t0 = cJSON_GetArrayItem(tag, 0); + if (t0 && cJSON_IsString(t0) && strcmp(cJSON_GetStringValue(t0), "proof") == 0) { + proof_count++; + } + } + + if (proof_count > 0) { + nutzap_data_out->proofs = (nostr_cashu_proof_t*)calloc((size_t)proof_count, sizeof(nostr_cashu_proof_t)); + if (!nutzap_data_out->proofs) { + nostr_nip61_free_nutzap_data(nutzap_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + } + + int proof_idx = 0; + cJSON_ArrayForEach(tag, tags) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) continue; + cJSON* t0 = cJSON_GetArrayItem(tag, 0); + cJSON* t1 = cJSON_GetArrayItem(tag, 1); + if (!t0 || !t1 || !cJSON_IsString(t0) || !cJSON_IsString(t1)) continue; + + const char* key = cJSON_GetStringValue(t0); + const char* val = cJSON_GetStringValue(t1); + if (!key || !val) continue; + + if (strcmp(key, "proof") == 0 && proof_idx < proof_count) { + cJSON* proof_obj = cJSON_Parse(val); + if (!proof_obj || !cJSON_IsObject(proof_obj)) { + cJSON_Delete(proof_obj); + continue; + } + + cJSON* id = cJSON_GetObjectItem(proof_obj, "id"); + cJSON* amount = cJSON_GetObjectItem(proof_obj, "amount"); + cJSON* secret = cJSON_GetObjectItem(proof_obj, "secret"); + cJSON* C = cJSON_GetObjectItem(proof_obj, "C"); + + if (id && cJSON_IsString(id) && amount && cJSON_IsNumber(amount) && + secret && cJSON_IsString(secret) && C && cJSON_IsString(C)) { + const char* id_s = cJSON_GetStringValue(id); + const char* sec_s = cJSON_GetStringValue(secret); + const char* c_s = cJSON_GetStringValue(C); + + if (id_s && sec_s && c_s) { + strncpy(nutzap_data_out->proofs[proof_idx].id, id_s, + sizeof(nutzap_data_out->proofs[proof_idx].id) - 1); + nutzap_data_out->proofs[proof_idx].amount = (uint64_t)cJSON_GetNumberValue(amount); + nutzap_data_out->proofs[proof_idx].secret = nip61_strdup(sec_s); + nutzap_data_out->proofs[proof_idx].C = nip61_strdup(c_s); + if (!nutzap_data_out->proofs[proof_idx].secret || !nutzap_data_out->proofs[proof_idx].C) { + cJSON_Delete(proof_obj); + nostr_nip61_free_nutzap_data(nutzap_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + proof_idx++; + } + } + + cJSON_Delete(proof_obj); + } else if (strcmp(key, "u") == 0) { + free(nutzap_data_out->mint_url); + nutzap_data_out->mint_url = nip61_strdup(val); + if (!nutzap_data_out->mint_url) { + nostr_nip61_free_nutzap_data(nutzap_data_out); + return NOSTR_ERROR_MEMORY_FAILED; + } + } else if (strcmp(key, "p") == 0) { + strncpy(nutzap_data_out->recipient_pubkey, val, sizeof(nutzap_data_out->recipient_pubkey) - 1); + } else if (strcmp(key, "e") == 0) { + strncpy(nutzap_data_out->nutzapped_event_id, val, sizeof(nutzap_data_out->nutzapped_event_id) - 1); + cJSON* t2 = cJSON_GetArrayItem(tag, 2); + if (t2 && cJSON_IsString(t2)) { + const char* relay = cJSON_GetStringValue(t2); + if (relay) strncpy(nutzap_data_out->nutzapped_relay_hint, relay, + sizeof(nutzap_data_out->nutzapped_relay_hint) - 1); + } + } else if (strcmp(key, "k") == 0) { + nutzap_data_out->nutzapped_kind = atoi(val); + } + } + + nutzap_data_out->proof_count = proof_idx; + + if (!nutzap_data_out->mint_url || nutzap_data_out->recipient_pubkey[0] == '\0' || proof_idx == 0) { + nostr_nip61_free_nutzap_data(nutzap_data_out); + return NOSTR_ERROR_NIP61_INVALID_NUTZAP; + } + + return NOSTR_SUCCESS; +} + +void nostr_nip61_free_nutzap_data(nostr_nip61_nutzap_data_t* data) { + if (!data) return; + free(data->content); + free(data->mint_url); + nostr_nip60_free_proofs(data->proofs, data->proof_count); + memset(data, 0, sizeof(*data)); +} + +cJSON* nostr_nip61_create_redemption_event(const char* nutzap_event_id, + const char* nutzap_relay_hint, + const char* sender_pubkey, + const char* created_token_event_id, + const char* created_token_relay_hint, + uint64_t amount, + const unsigned char* private_key, + time_t timestamp) { + if (!nutzap_event_id || !sender_pubkey || !created_token_event_id || !private_key) { + return NULL; + } + + nostr_nip60_history_ref_t refs[1]; + memset(refs, 0, sizeof(refs)); + strncpy(refs[0].event_id, created_token_event_id, sizeof(refs[0].event_id) - 1); + if (created_token_relay_hint) { + strncpy(refs[0].relay_hint, created_token_relay_hint, sizeof(refs[0].relay_hint) - 1); + } + refs[0].marker = NOSTR_NIP60_REF_CREATED; + + nostr_nip60_history_data_t hist; + memset(&hist, 0, sizeof(hist)); + hist.direction = NOSTR_NIP60_DIRECTION_IN; + hist.amount = amount; + hist.refs = refs; + hist.ref_count = 1; + + cJSON* evt = nostr_nip60_create_history_event(&hist, private_key, timestamp); + if (!evt) return NULL; + + cJSON* tags = cJSON_GetObjectItem(evt, "tags"); + if (!tags || !cJSON_IsArray(tags)) return evt; + + cJSON* e = cJSON_CreateArray(); + cJSON_AddItemToArray(e, cJSON_CreateString("e")); + cJSON_AddItemToArray(e, cJSON_CreateString(nutzap_event_id)); + cJSON_AddItemToArray(e, cJSON_CreateString(nutzap_relay_hint ? nutzap_relay_hint : "")); + cJSON_AddItemToArray(e, cJSON_CreateString("redeemed")); + cJSON_AddItemToArray(tags, e); + + cJSON* p = cJSON_CreateArray(); + cJSON_AddItemToArray(p, cJSON_CreateString("p")); + cJSON_AddItemToArray(p, cJSON_CreateString(sender_pubkey)); + cJSON_AddItemToArray(tags, p); + + return evt; +} + +int nostr_nip61_verify_nutzap(cJSON* nutzap_event, cJSON* nutzap_info_event) { + if (!nutzap_event || !nutzap_info_event) return NOSTR_ERROR_INVALID_INPUT; + + nostr_nip61_nutzap_data_t nutzap; + memset(&nutzap, 0, sizeof(nutzap)); + int rc = nostr_nip61_parse_nutzap_event(nutzap_event, &nutzap); + if (rc != NOSTR_SUCCESS) return rc; + + nostr_nip61_nutzap_info_t info; + memset(&info, 0, sizeof(info)); + rc = nostr_nip61_parse_nutzap_info_event(nutzap_info_event, &info); + if (rc != NOSTR_SUCCESS) { + nostr_nip61_free_nutzap_data(&nutzap); + return rc; + } + + if (!nip61_mint_in_info(nutzap.mint_url, &info)) { + nostr_nip61_free_nutzap_data(&nutzap); + nostr_nip61_free_nutzap_info(&info); + return NOSTR_ERROR_NIP61_MINT_MISMATCH; + } + + int key_match = 0; + if (strlen(info.pubkey) == 64) { + for (int i = 0; i < nutzap.proof_count; i++) { + if (nutzap.proofs[i].secret && strstr(nutzap.proofs[i].secret, info.pubkey) != NULL) { + key_match = 1; + break; + } + } + } else if (strlen(info.pubkey) == 66 && strncmp(info.pubkey, "02", 2) == 0) { + const char* xonly = info.pubkey + 2; + for (int i = 0; i < nutzap.proof_count; i++) { + if (nutzap.proofs[i].secret && strstr(nutzap.proofs[i].secret, xonly) != NULL) { + key_match = 1; + break; + } + } + } + + nostr_nip61_free_nutzap_data(&nutzap); + nostr_nip61_free_nutzap_info(&info); + + if (!key_match) { + return NOSTR_ERROR_NIP61_PUBKEY_MISMATCH; + } + + return NOSTR_SUCCESS; +} + +cJSON* nostr_nip61_create_nutzap_info_filter(const char* pubkey_hex) { + if (!pubkey_hex) return NULL; + + cJSON* filter = cJSON_CreateObject(); + if (!filter) return NULL; + + cJSON* kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP61_NUTZAP_INFO_KIND)); + cJSON_AddItemToObject(filter, "kinds", kinds); + + cJSON* authors = cJSON_CreateArray(); + cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + + return filter; +} + +cJSON* nostr_nip61_create_nutzap_filter(const char* recipient_pubkey_hex, + const char** mint_urls, + int mint_count, + time_t since) { + if (!recipient_pubkey_hex) return NULL; + + cJSON* filter = cJSON_CreateObject(); + if (!filter) return NULL; + + cJSON* kinds = cJSON_CreateArray(); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP61_NUTZAP_KIND)); + cJSON_AddItemToObject(filter, "kinds", kinds); + + cJSON* pvals = cJSON_CreateArray(); + cJSON_AddItemToArray(pvals, cJSON_CreateString(recipient_pubkey_hex)); + cJSON_AddItemToObject(filter, "#p", pvals); + + if (mint_urls && mint_count > 0) { + cJSON* uvals = cJSON_CreateArray(); + for (int i = 0; i < mint_count; i++) { + if (mint_urls[i]) cJSON_AddItemToArray(uvals, cJSON_CreateString(mint_urls[i])); + } + cJSON_AddItemToObject(filter, "#u", uvals); + } + + if (since > 0) { + cJSON_AddNumberToObject(filter, "since", (double)since); + } + + return filter; +} diff --git a/nostr_core/nip061.h b/nostr_core/nip061.h new file mode 100644 index 00000000..a1498bef --- /dev/null +++ b/nostr_core/nip061.h @@ -0,0 +1,89 @@ +/* + * NIP-61: Nutzaps + * https://github.com/nostr-protocol/nips/blob/master/61.md + */ + +#ifndef NOSTR_NIP061_H +#define NOSTR_NIP061_H + +#include +#include +#include +#include "nip001.h" +#include "nip060.h" +#include "nostr_common.h" +#include "../cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define NOSTR_NIP61_NUTZAP_INFO_KIND 10019 +#define NOSTR_NIP61_NUTZAP_KIND 9321 + +typedef struct { + char* url; + char** units; + int unit_count; +} nostr_nip61_mint_entry_t; + +typedef struct { + char** relay_urls; + int relay_count; + nostr_nip61_mint_entry_t* mints; + int mint_count; + char pubkey[NOSTR_CASHU_PUBKEY_HEX_SIZE]; +} nostr_nip61_nutzap_info_t; + +typedef struct { + char* content; + nostr_cashu_proof_t* proofs; + int proof_count; + char* mint_url; + char recipient_pubkey[65]; + char nutzapped_event_id[NOSTR_CASHU_EVENT_ID_HEX_SIZE]; + char nutzapped_relay_hint[256]; + int nutzapped_kind; +} nostr_nip61_nutzap_data_t; + +cJSON* nostr_nip61_create_nutzap_info_event(const nostr_nip61_nutzap_info_t* info, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip61_parse_nutzap_info_event(cJSON* event, + nostr_nip61_nutzap_info_t* info_out); + +void nostr_nip61_free_nutzap_info(nostr_nip61_nutzap_info_t* info); + +cJSON* nostr_nip61_create_nutzap_event(const nostr_nip61_nutzap_data_t* nutzap_data, + const unsigned char* sender_private_key, + time_t timestamp); + +int nostr_nip61_parse_nutzap_event(cJSON* event, + nostr_nip61_nutzap_data_t* nutzap_data_out); + +void nostr_nip61_free_nutzap_data(nostr_nip61_nutzap_data_t* data); + +cJSON* nostr_nip61_create_redemption_event(const char* nutzap_event_id, + const char* nutzap_relay_hint, + const char* sender_pubkey, + const char* created_token_event_id, + const char* created_token_relay_hint, + uint64_t amount, + const unsigned char* private_key, + time_t timestamp); + +int nostr_nip61_verify_nutzap(cJSON* nutzap_event, cJSON* nutzap_info_event); + +cJSON* nostr_nip61_create_nutzap_info_filter(const char* pubkey_hex); + +cJSON* nostr_nip61_create_nutzap_filter(const char* recipient_pubkey_hex, + const char** mint_urls, + int mint_count, + time_t since); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_NIP061_H */ diff --git a/nostr_core/nostr_common.c b/nostr_core/nostr_common.c index 06e52433..3bc09f68 100644 --- a/nostr_core/nostr_common.c +++ b/nostr_core/nostr_common.c @@ -64,6 +64,26 @@ const char* nostr_strerror(int error_code) { case NOSTR_ERROR_NIP46_UNKNOWN_METHOD: return "NIP-46: Unknown method"; case NOSTR_ERROR_NIP46_AUTH_CHALLENGE: return "NIP-46: Auth challenge required"; case NOSTR_ERROR_NIP46_NOT_CONNECTED: return "NIP-46: Not connected"; + case NOSTR_ERROR_NIP60_INVALID_WALLET: return "NIP-60: Invalid wallet event"; + case NOSTR_ERROR_NIP60_INVALID_TOKEN: return "NIP-60: Invalid token event"; + case NOSTR_ERROR_NIP60_INVALID_HISTORY: return "NIP-60: Invalid history event"; + case NOSTR_ERROR_NIP60_INVALID_QUOTE: return "NIP-60: Invalid quote event"; + case NOSTR_ERROR_NIP60_DECRYPT_FAILED: return "NIP-60: Decryption failed"; + case NOSTR_ERROR_NIP60_INVALID_PROOFS: return "NIP-60: Invalid proofs payload"; + case NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS: return "NIP-60: Insufficient funds"; + case NOSTR_ERROR_NIP61_INVALID_INFO: return "NIP-61: Invalid info event"; + case NOSTR_ERROR_NIP61_INVALID_NUTZAP: return "NIP-61: Invalid nutzap event"; + case NOSTR_ERROR_NIP61_MINT_MISMATCH: return "NIP-61: Mint mismatch"; + case NOSTR_ERROR_NIP61_PUBKEY_MISMATCH: return "NIP-61: Pubkey mismatch"; + case NOSTR_ERROR_NIP61_VERIFICATION_FAILED: return "NIP-61: Verification failed"; + case NOSTR_ERROR_CASHU_HTTP_FAILED: return "Cashu: HTTP request failed"; + case NOSTR_ERROR_CASHU_JSON_PARSE_FAILED: return "Cashu: JSON parsing failed"; + case NOSTR_ERROR_CASHU_MINT_ERROR: return "Cashu: Mint returned an error"; + case NOSTR_ERROR_CASHU_QUOTE_NOT_PAID: return "Cashu: Quote not paid"; + case NOSTR_ERROR_CASHU_QUOTE_EXPIRED: return "Cashu: Quote expired"; + case NOSTR_ERROR_CASHU_PROOFS_SPENT: return "Cashu: One or more proofs are already spent"; + case NOSTR_ERROR_CASHU_CRYPTO_FAILED: return "Cashu: Cryptographic operation failed"; + case NOSTR_ERROR_CASHU_INVALID_KEYSET: return "Cashu: Invalid keyset"; default: return "Unknown error"; } } diff --git a/nostr_core/nostr_common.h b/nostr_core/nostr_common.h index 66a8a049..89d9e20f 100644 --- a/nostr_core/nostr_common.h +++ b/nostr_core/nostr_common.h @@ -76,6 +76,32 @@ #define NOSTR_ERROR_NIP46_AUTH_CHALLENGE -310 #define NOSTR_ERROR_NIP46_NOT_CONNECTED -311 +// NIP-60 Cashu Wallet error codes +#define NOSTR_ERROR_NIP60_INVALID_WALLET -400 +#define NOSTR_ERROR_NIP60_INVALID_TOKEN -401 +#define NOSTR_ERROR_NIP60_INVALID_HISTORY -402 +#define NOSTR_ERROR_NIP60_INVALID_QUOTE -403 +#define NOSTR_ERROR_NIP60_DECRYPT_FAILED -404 +#define NOSTR_ERROR_NIP60_INVALID_PROOFS -405 +#define NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS -406 + +// NIP-61 Nutzap error codes +#define NOSTR_ERROR_NIP61_INVALID_INFO -410 +#define NOSTR_ERROR_NIP61_INVALID_NUTZAP -411 +#define NOSTR_ERROR_NIP61_MINT_MISMATCH -412 +#define NOSTR_ERROR_NIP61_PUBKEY_MISMATCH -413 +#define NOSTR_ERROR_NIP61_VERIFICATION_FAILED -414 + +// Cashu Mint client error codes +#define NOSTR_ERROR_CASHU_HTTP_FAILED -420 +#define NOSTR_ERROR_CASHU_JSON_PARSE_FAILED -421 +#define NOSTR_ERROR_CASHU_MINT_ERROR -422 +#define NOSTR_ERROR_CASHU_QUOTE_NOT_PAID -423 +#define NOSTR_ERROR_CASHU_QUOTE_EXPIRED -424 +#define NOSTR_ERROR_CASHU_PROOFS_SPENT -425 +#define NOSTR_ERROR_CASHU_CRYPTO_FAILED -426 +#define NOSTR_ERROR_CASHU_INVALID_KEYSET -427 + // Constants #define NOSTR_PRIVATE_KEY_SIZE 32 #define NOSTR_PUBLIC_KEY_SIZE 32 diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 9bde6408..2d55c907 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.4.13" +#define VERSION "v0.4.11" #define VERSION_MAJOR 0 #define VERSION_MINOR 4 -#define VERSION_PATCH 13 +#define VERSION_PATCH 11 /* * NOSTR Core Library - Complete API Reference @@ -189,6 +189,9 @@ extern "C" { #include "nip044.h" // Encryption (modern) #include "nip046.h" // Remote signing #include "nip059.h" // Gift Wrap +#include "nip060.h" // Cashu Wallet +#include "nip061.h" // Nutzaps +#include "cashu_mint.h" // Cashu mint HTTP client // Authentication and request validation system #include "request_validator.h" // Request validation and authentication rules diff --git a/plans/nip60_nip61_plan.md b/plans/nip60_nip61_plan.md new file mode 100644 index 00000000..8ad4dba9 --- /dev/null +++ b/plans/nip60_nip61_plan.md @@ -0,0 +1,778 @@ +# NIP-60 Cashu Wallet & NIP-61 Nutzaps — Implementation Plan + +## Overview + +**NIP-60** defines a protocol for storing Cashu wallet state on Nostr relays, enabling cross-application wallet portability. The wallet stores unspent proofs as encrypted events, tracks spending history, and manages mint relationships. + +**NIP-61** defines "Nutzaps" — P2PK-locked Cashu tokens sent as zaps between Nostr users, where the payment itself serves as the receipt. + +This implementation covers: +- **NIP-60**: Wallet event creation/parsing (kind:17375), token events (kind:7375), spending history (kind:7376), quote events (kind:7374) +- **NIP-61**: Nutzap info events (kind:10019), nutzap events (kind:9321), nutzap redemption +- **Cashu Mint HTTP Client**: Mint info, token swap, melt, and mint operations via curl + +## Architecture + +### Component Dependency Map + +```mermaid +graph TD + NIP60[nip060.c/h - Cashu Wallet Events] --> NIP44[nip044 - NIP-44 Encryption] + NIP60 --> NIP01[nip001 - Event Creation/Signing] + NIP60 --> CJSON[cJSON - JSON Handling] + NIP60 --> UTILS[utils - Hex/Bytes/Base64] + NIP60 --> COMMON[nostr_common - Error Codes] + + NIP61[nip061.c/h - Nutzaps] --> NIP44 + NIP61 --> NIP01 + NIP61 --> CJSON + NIP61 --> UTILS + NIP61 --> COMMON + + CASHU[cashu_mint.c/h - Mint HTTP Client] --> CJSON + CASHU --> CURL[libcurl - HTTP] + CASHU --> COMMON + + NIP60 -.->|optional| CASHU + NIP61 -.->|optional| CASHU +``` + +### NIP-60 High-Level Flow + +```mermaid +sequenceDiagram + participant App as Application + participant W as NIP-60 Wallet Lib + participant R as Relay + participant M as Cashu Mint + + Note over App: Initialize wallet + App->>W: nostr_nip60_create_wallet_event + W->>R: Publish kind:17375 + + Note over App: Receive tokens + App->>M: Mint tokens via cashu_mint_mint_tokens + M-->>App: Cashu proofs + App->>W: nostr_nip60_create_token_event + W->>R: Publish kind:7375 + + Note over App: Spend tokens + App->>W: nostr_nip60_spend_token + W->>M: Swap proofs via cashu_mint_swap + M-->>W: Change proofs + W->>R: Delete old kind:7375 + W->>R: Publish new kind:7375 with change + W->>R: Publish kind:7376 history +``` + +### NIP-61 Nutzap Flow + +```mermaid +sequenceDiagram + participant Alice as Alice - Sender + participant R as Relay + participant Bob as Bob - Recipient + participant M as Cashu Mint + + Note over Bob: Setup + Bob->>R: Publish kind:10019 with mints + p2pk pubkey + + Note over Alice: Send nutzap + Alice->>R: Fetch Bobs kind:10019 + Alice->>M: Mint/swap P2PK-locked tokens + Alice->>R: Publish kind:9321 nutzap + + Note over Bob: Receive nutzap + Bob->>R: Fetch kind:9321 events + Bob->>M: Swap P2PK tokens into wallet + Bob->>R: Publish kind:7376 redemption history +``` + +### Token State Transition + +```mermaid +stateDiagram-v2 + [*] --> Created: Mint/Receive tokens + Created --> Published: Publish kind 7375 + Published --> Spending: Spend some proofs + Spending --> Deleted: NIP-09 delete old event + Spending --> RolledOver: New kind 7375 with unspent + change + RolledOver --> Published + Deleted --> [*] + Published --> Redeemed: All proofs spent + Redeemed --> Deleted +``` + +## File Structure + +``` +nostr_core/ + nip060.h # NIP-60 Cashu wallet types and function declarations + nip060.c # NIP-60 implementation + nip061.h # NIP-61 Nutzap types and function declarations + nip061.c # NIP-61 implementation + cashu_mint.h # Cashu mint HTTP client types and functions + cashu_mint.c # Cashu mint HTTP client implementation +tests/ + nip60_test.c # NIP-60 unit tests + nip61_test.c # NIP-61 unit tests + cashu_mint_test.c # Cashu mint client tests +examples/ + cashu_wallet.c # Complete wallet example +``` + +## Data Structures + +### NIP-60 Core Types + +```c +// Event kinds +#define NOSTR_NIP60_WALLET_KIND 17375 +#define NOSTR_NIP60_TOKEN_KIND 7375 +#define NOSTR_NIP60_HISTORY_KIND 7376 +#define NOSTR_NIP60_QUOTE_KIND 7374 + +// A single Cashu proof +typedef struct { + char id[17]; // Keyset ID, e.g. "005c2502034d4f12" + uint64_t amount; // Denomination amount + char* secret; // Proof secret (base64 or P2PK JSON) + char* C; // Blinded signature (hex compressed point) +} nostr_cashu_proof_t; + +// Token content - decrypted payload of kind:7375 +typedef struct { + char* mint_url; // Mint URL + nostr_cashu_proof_t* proofs; // Array of proofs + int proof_count; // Number of proofs + char** deleted_token_ids; // Event IDs of tokens destroyed + int deleted_count; // Number of deleted token IDs +} nostr_nip60_token_data_t; + +// Wallet content - decrypted payload of kind:17375 +typedef struct { + char privkey[65]; // Hex private key for P2PK + char** mint_urls; // Array of mint URLs + int mint_count; // Number of mints +} nostr_nip60_wallet_data_t; + +// Spending history entry direction +typedef enum { + NOSTR_NIP60_DIRECTION_IN, // Received funds + NOSTR_NIP60_DIRECTION_OUT // Sent funds +} nostr_nip60_direction_t; + +// History event reference +typedef enum { + NOSTR_NIP60_REF_CREATED, // New token event created + NOSTR_NIP60_REF_DESTROYED, // Token event destroyed + NOSTR_NIP60_REF_REDEEMED // Nutzap redeemed +} nostr_nip60_ref_marker_t; + +typedef struct { + char event_id[65]; // Referenced event ID + char relay_hint[256]; // Optional relay hint + nostr_nip60_ref_marker_t marker; // Reference type +} nostr_nip60_history_ref_t; + +// Spending history data - decrypted payload of kind:7376 +typedef struct { + nostr_nip60_direction_t direction; // in or out + uint64_t amount; // Amount in sats + nostr_nip60_history_ref_t* refs; // Event references + int ref_count; // Number of references +} nostr_nip60_history_data_t; +``` + +### NIP-61 Core Types + +```c +// Event kinds +#define NOSTR_NIP61_NUTZAP_INFO_KIND 10019 +#define NOSTR_NIP61_NUTZAP_KIND 9321 + +// Mint entry in kind:10019 +typedef struct { + char* url; // Mint URL + char** units; // Supported units, e.g. "sat", "usd" + int unit_count; // Number of units +} nostr_nip61_mint_entry_t; + +// Nutzap info - parsed kind:10019 +typedef struct { + char** relay_urls; // Relays for receiving nutzaps + int relay_count; + nostr_nip61_mint_entry_t* mints; // Trusted mints + int mint_count; + char pubkey[67]; // P2PK pubkey (with 02 prefix) +} nostr_nip61_nutzap_info_t; + +// Nutzap event data - parsed kind:9321 +typedef struct { + char* content; // Optional comment + nostr_cashu_proof_t* proofs; // P2PK-locked proofs + int proof_count; + char* mint_url; // Mint URL + char recipient_pubkey[65]; // Recipient Nostr pubkey + char nutzapped_event_id[65]; // Event being nutzapped (optional) + char nutzapped_relay_hint[256]; // Relay hint for nutzapped event + int nutzapped_kind; // Kind of nutzapped event +} nostr_nip61_nutzap_data_t; +``` + +### Cashu Mint Client Types + +```c +// Cashu protocol version +#define CASHU_API_VERSION "v1" + +// Mint keyset +typedef struct { + char id[17]; // Keyset ID + char unit[8]; // Unit, e.g. "sat" + int active; // Whether keyset is active + uint64_t* amounts; // Denomination amounts + char** pubkeys; // Corresponding public keys + int key_count; // Number of keys +} cashu_keyset_t; + +// Mint info +typedef struct { + char* name; + char* description; + char* version; + char** supported_nuts; // NUT numbers supported + int nut_count; + cashu_keyset_t* keysets; + int keyset_count; +} cashu_mint_info_t; + +// Mint quote (for minting new tokens) +typedef struct { + char quote_id[128]; + char payment_request[1024]; // Lightning invoice + int paid; + uint64_t amount; + time_t expiry; +} cashu_mint_quote_t; + +// Melt quote (for paying Lightning invoices) +typedef struct { + char quote_id[128]; + uint64_t amount; + uint64_t fee_reserve; + int paid; + char* payment_preimage; + time_t expiry; +} cashu_melt_quote_t; + +// Blind message for minting/swapping +typedef struct { + uint64_t amount; + char* B_; // Blinded secret (hex) + char id[17]; // Keyset ID +} cashu_blinded_message_t; + +// Blind signature from mint +typedef struct { + uint64_t amount; + char* C_; // Blinded signature (hex) + char id[17]; // Keyset ID +} cashu_blind_signature_t; +``` + +## API Functions + +### NIP-60 Wallet Functions + +```c +// === Wallet Event (kind:17375) === + +// Create a wallet event with encrypted content +cJSON* nostr_nip60_create_wallet_event( + const nostr_nip60_wallet_data_t* wallet_data, + const unsigned char* private_key, + time_t timestamp); + +// Parse and decrypt a wallet event +int nostr_nip60_parse_wallet_event( + cJSON* event, + const unsigned char* private_key, + nostr_nip60_wallet_data_t* wallet_data_out); + +// Free wallet data +void nostr_nip60_free_wallet_data(nostr_nip60_wallet_data_t* data); + +// === Token Event (kind:7375) === + +// Create a token event with encrypted proofs +cJSON* nostr_nip60_create_token_event( + const nostr_nip60_token_data_t* token_data, + const unsigned char* private_key, + time_t timestamp); + +// Parse and decrypt a token event +int nostr_nip60_parse_token_event( + cJSON* event, + const unsigned char* private_key, + nostr_nip60_token_data_t* token_data_out); + +// Free token data +void nostr_nip60_free_token_data(nostr_nip60_token_data_t* data); + +// === Token Spending === + +// Create a NIP-09 deletion event for a spent token +cJSON* nostr_nip60_create_token_deletion( + const char* token_event_id, + const unsigned char* private_key, + time_t timestamp); + +// Create a rollover token event (unspent proofs + change from old token) +cJSON* nostr_nip60_create_rollover_token( + const nostr_nip60_token_data_t* remaining_proofs, + const char** deleted_event_ids, + int deleted_count, + const unsigned char* private_key, + time_t timestamp); + +// === Spending History (kind:7376) === + +// Create a spending history event +cJSON* nostr_nip60_create_history_event( + const nostr_nip60_history_data_t* history_data, + const unsigned char* private_key, + time_t timestamp); + +// Parse and decrypt a spending history event +int nostr_nip60_parse_history_event( + cJSON* event, + const unsigned char* private_key, + nostr_nip60_history_data_t* history_data_out); + +// Free history data +void nostr_nip60_free_history_data(nostr_nip60_history_data_t* data); + +// === Quote Event (kind:7374) === + +// Create a mint quote event +cJSON* nostr_nip60_create_quote_event( + const char* quote_id, + const char* mint_url, + time_t expiration, + const unsigned char* private_key, + time_t timestamp); + +// Parse a quote event +int nostr_nip60_parse_quote_event( + cJSON* event, + const unsigned char* private_key, + char* quote_id_out, + size_t quote_id_size, + char* mint_url_out, + size_t mint_url_size); + +// === Utility: Proof helpers === + +// Calculate total amount from an array of proofs +uint64_t nostr_nip60_sum_proofs( + const nostr_cashu_proof_t* proofs, + int proof_count); + +// Serialize proofs to JSON array +cJSON* nostr_nip60_proofs_to_json( + const nostr_cashu_proof_t* proofs, + int proof_count); + +// Parse proofs from JSON array +int nostr_nip60_proofs_from_json( + cJSON* json_array, + nostr_cashu_proof_t** proofs_out, + int* proof_count_out); + +// Free proof array +void nostr_nip60_free_proofs( + nostr_cashu_proof_t* proofs, + int proof_count); + +// === Filter helpers === + +// Create a filter to fetch wallet and token events +cJSON* nostr_nip60_create_wallet_filter(const char* pubkey_hex); + +// Create a filter to fetch spending history +cJSON* nostr_nip60_create_history_filter(const char* pubkey_hex, time_t since); +``` + +### NIP-61 Nutzap Functions + +```c +// === Nutzap Info Event (kind:10019) === + +// Create nutzap info event +cJSON* nostr_nip61_create_nutzap_info_event( + const nostr_nip61_nutzap_info_t* info, + const unsigned char* private_key, + time_t timestamp); + +// Parse nutzap info event +int nostr_nip61_parse_nutzap_info_event( + cJSON* event, + nostr_nip61_nutzap_info_t* info_out); + +// Free nutzap info +void nostr_nip61_free_nutzap_info(nostr_nip61_nutzap_info_t* info); + +// === Nutzap Event (kind:9321) === + +// Create a nutzap event +cJSON* nostr_nip61_create_nutzap_event( + const nostr_nip61_nutzap_data_t* nutzap_data, + const unsigned char* sender_private_key, + time_t timestamp); + +// Parse a nutzap event +int nostr_nip61_parse_nutzap_event( + cJSON* event, + nostr_nip61_nutzap_data_t* nutzap_data_out); + +// Free nutzap data +void nostr_nip61_free_nutzap_data(nostr_nip61_nutzap_data_t* data); + +// === Nutzap Redemption === + +// Create a redemption history event (kind:7376 with redeemed marker) +cJSON* nostr_nip61_create_redemption_event( + const char* nutzap_event_id, + const char* nutzap_relay_hint, + const char* sender_pubkey, + const char* created_token_event_id, + const char* created_token_relay_hint, + uint64_t amount, + const unsigned char* private_key, + time_t timestamp); + +// === Nutzap Verification === + +// Verify a nutzap against recipients kind:10019 +int nostr_nip61_verify_nutzap( + cJSON* nutzap_event, + cJSON* nutzap_info_event); + +// === Filter helpers === + +// Create filter to fetch nutzap info for a user +cJSON* nostr_nip61_create_nutzap_info_filter(const char* pubkey_hex); + +// Create filter to fetch incoming nutzaps +cJSON* nostr_nip61_create_nutzap_filter( + const char* recipient_pubkey_hex, + const char** mint_urls, + int mint_count, + time_t since); +``` + +### Cashu Mint HTTP Client Functions + +```c +// === Mint Connection === + +// Get mint info +int cashu_mint_get_info( + const char* mint_url, + cashu_mint_info_t* info_out, + int timeout_seconds); + +// Free mint info +void cashu_mint_free_info(cashu_mint_info_t* info); + +// === Keysets === + +// Get active keysets from mint +int cashu_mint_get_keysets( + const char* mint_url, + cashu_keyset_t** keysets_out, + int* keyset_count_out, + int timeout_seconds); + +// Get specific keyset keys +int cashu_mint_get_keys( + const char* mint_url, + const char* keyset_id, + cashu_keyset_t* keyset_out, + int timeout_seconds); + +// Free keysets +void cashu_mint_free_keysets(cashu_keyset_t* keysets, int count); + +// === Minting (receiving Lightning) === + +// Request a mint quote (get Lightning invoice) +int cashu_mint_request_mint_quote( + const char* mint_url, + uint64_t amount, + const char* unit, + cashu_mint_quote_t* quote_out, + int timeout_seconds); + +// Check mint quote status +int cashu_mint_check_mint_quote( + const char* mint_url, + const char* quote_id, + cashu_mint_quote_t* quote_out, + int timeout_seconds); + +// Mint tokens (after quote is paid) +int cashu_mint_mint_tokens( + const char* mint_url, + const char* quote_id, + const cashu_blinded_message_t* blinded_messages, + int message_count, + cashu_blind_signature_t** signatures_out, + int* signature_count_out, + int timeout_seconds); + +// === Melting (paying Lightning) === + +// Request a melt quote (estimate fee for paying invoice) +int cashu_mint_request_melt_quote( + const char* mint_url, + const char* payment_request, + const char* unit, + cashu_melt_quote_t* quote_out, + int timeout_seconds); + +// Check melt quote status +int cashu_mint_check_melt_quote( + const char* mint_url, + const char* quote_id, + cashu_melt_quote_t* quote_out, + int timeout_seconds); + +// Melt tokens (pay Lightning invoice) +int cashu_mint_melt_tokens( + const char* mint_url, + const char* quote_id, + const nostr_cashu_proof_t* proofs, + int proof_count, + cashu_melt_quote_t* result_out, + cashu_blind_signature_t** change_out, + int* change_count_out, + int timeout_seconds); + +// === Swapping === + +// Swap proofs (split/combine denominations) +int cashu_mint_swap( + const char* mint_url, + const nostr_cashu_proof_t* inputs, + int input_count, + const cashu_blinded_message_t* outputs, + int output_count, + cashu_blind_signature_t** signatures_out, + int* signature_count_out, + int timeout_seconds); + +// === Token State === + +// Check if proofs have been spent +int cashu_mint_check_proofs_state( + const char* mint_url, + const nostr_cashu_proof_t* proofs, + int proof_count, + int* states_out, + int timeout_seconds); + +// === Crypto Helpers === + +// Generate blinded messages for a target amount +int cashu_create_blinded_messages( + uint64_t amount, + const char* keyset_id, + cashu_blinded_message_t** messages_out, + int* message_count_out, + char*** secrets_out, + char*** rs_out); + +// Unblind signatures to get proofs +int cashu_unblind_signatures( + const cashu_blind_signature_t* signatures, + int signature_count, + const char** secrets, + const char** rs, + const cashu_keyset_t* keyset, + nostr_cashu_proof_t** proofs_out, + int* proof_count_out); + +// Create P2PK-locked blinded messages +int cashu_create_p2pk_blinded_messages( + uint64_t amount, + const char* keyset_id, + const char* recipient_pubkey_hex, + cashu_blinded_message_t** messages_out, + int* message_count_out, + char*** secrets_out, + char*** rs_out); + +// Free blinded messages +void cashu_free_blinded_messages(cashu_blinded_message_t* messages, int count); + +// Free blind signatures +void cashu_free_blind_signatures(cashu_blind_signature_t* signatures, int count); +``` + +## Error Codes + +New error codes to add to `nostr_common.h`: + +```c +// NIP-60 Cashu Wallet error codes +#define NOSTR_ERROR_NIP60_INVALID_WALLET -400 +#define NOSTR_ERROR_NIP60_INVALID_TOKEN -401 +#define NOSTR_ERROR_NIP60_INVALID_HISTORY -402 +#define NOSTR_ERROR_NIP60_INVALID_QUOTE -403 +#define NOSTR_ERROR_NIP60_DECRYPT_FAILED -404 +#define NOSTR_ERROR_NIP60_INVALID_PROOFS -405 +#define NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS -406 + +// NIP-61 Nutzap error codes +#define NOSTR_ERROR_NIP61_INVALID_INFO -410 +#define NOSTR_ERROR_NIP61_INVALID_NUTZAP -411 +#define NOSTR_ERROR_NIP61_MINT_MISMATCH -412 +#define NOSTR_ERROR_NIP61_PUBKEY_MISMATCH -413 +#define NOSTR_ERROR_NIP61_VERIFICATION_FAILED -414 + +// Cashu Mint client error codes +#define NOSTR_ERROR_CASHU_HTTP_FAILED -420 +#define NOSTR_ERROR_CASHU_JSON_PARSE_FAILED -421 +#define NOSTR_ERROR_CASHU_MINT_ERROR -422 +#define NOSTR_ERROR_CASHU_QUOTE_NOT_PAID -423 +#define NOSTR_ERROR_CASHU_QUOTE_EXPIRED -424 +#define NOSTR_ERROR_CASHU_PROOFS_SPENT -425 +#define NOSTR_ERROR_CASHU_CRYPTO_FAILED -426 +#define NOSTR_ERROR_CASHU_INVALID_KEYSET -427 +``` + +## Build System Changes + +### build.sh Updates + +1. Add `060` and `061` to the NIP detection patterns +2. Add `cashu_mint.c` as a source when NIP-60 or NIP-61 is detected +3. Add NIP descriptions: `NIP-060(Cashu-Wallet)`, `NIP-061(Nutzaps)` +4. Update the `--nips=all` list to include `060` and `061` +5. Update the help text with new NIP descriptions + +### nostr_core.h Updates + +Add includes: +```c +#include "nip060.h" // Cashu Wallet +#include "nip061.h" // Nutzaps +#include "cashu_mint.h" // Cashu Mint HTTP Client +``` + +Add NIP-60/61 functions to the quick reference comment block. + +## Implementation Order + +The implementation should proceed in this order due to dependencies: + +1. **Error codes** in `nostr_common.h` — no dependencies +2. **Cashu proof types** — shared between NIP-60 and NIP-61 +3. **`cashu_mint.h/c`** — Cashu mint HTTP client (depends on curl, cJSON) +4. **`nip060.h/c`** — NIP-60 wallet events (depends on NIP-44, NIP-01, cashu types) +5. **`nip061.h/c`** — NIP-61 nutzaps (depends on NIP-60 types, cashu types) +6. **Build system** — update `build.sh` and `nostr_core.h` +7. **Tests** — `nip60_test.c`, `nip61_test.c`, `cashu_mint_test.c` +8. **Example** — `cashu_wallet.c` + +## Key Design Decisions + +### 1. Self-encryption for NIP-44 +NIP-60 encrypts content to the user's own key (sender = recipient). The NIP-44 encrypt/decrypt functions take sender_private_key and recipient_public_key. For self-encryption, we derive the user's public key from their private key and use that as the recipient. + +### 2. Cashu Crypto (Blinding) +The Cashu protocol requires elliptic curve blinding operations (hash-to-curve, blind/unblind). These use secp256k1 which is already a dependency. The blinding math is: +- `B_ = Y + r*G` where Y = hash_to_curve(secret), r = random blinding factor +- `C_ = k*B_` (mint signs) +- `C = C_ - r*K` where K = mint's public key (unblind) + +### 3. P2PK Secrets +For NIP-61 nutzaps, proof secrets use the P2PK format: +```json +["P2PK", {"nonce": "", "data": "02"}] +``` +The `02` prefix is required for nostr-cashu compatibility. + +### 4. Curl Usage +Following the same pattern as `nip005.c` — direct curl usage with write callbacks. No abstraction layer needed since curl is already a project dependency. + +## Test Strategy + +### NIP-60 Tests +- Create and parse wallet events (round-trip encryption) +- Create and parse token events with multiple proofs +- Token deletion event creation +- Rollover token creation with del references +- History event creation and parsing +- Quote event creation and parsing +- Proof sum calculation +- Filter creation helpers + +### NIP-61 Tests +- Create and parse nutzap info events +- Create and parse nutzap events +- Nutzap verification against kind:10019 +- Redemption history event creation +- Filter creation helpers + +### Cashu Mint Tests +- Mint info parsing from JSON +- Keyset parsing +- Blinded message creation +- Signature unblinding +- P2PK secret generation +- Proof state checking (mock or live mint) + +## Example Usage + +```c +#include "nostr_core/nostr_core.h" + +// Create a wallet +nostr_nip60_wallet_data_t wallet = {0}; +strcpy(wallet.privkey, ""); +wallet.mint_urls = (char*[]){"https://mint.example.com"}; +wallet.mint_count = 1; + +cJSON* wallet_event = nostr_nip60_create_wallet_event(&wallet, my_privkey, 0); +// Publish wallet_event to relays... + +// Store tokens +nostr_cashu_proof_t proofs[2] = { + {.id = "00ad268c4d1f5826", .amount = 1, .secret = "...", .C = "02..."}, + {.id = "00ad268c4d1f5826", .amount = 4, .secret = "...", .C = "02..."} +}; +nostr_nip60_token_data_t token = { + .mint_url = "https://mint.example.com", + .proofs = proofs, + .proof_count = 2 +}; + +cJSON* token_event = nostr_nip60_create_token_event(&token, my_privkey, 0); +// Publish token_event to relays... + +// Send a nutzap +nostr_nip61_nutzap_data_t nutzap = { + .content = "Great post!", + .proofs = p2pk_locked_proofs, + .proof_count = 1, + .mint_url = "https://mint.example.com", + .recipient_pubkey = "", + .nutzapped_event_id = "" +}; + +cJSON* nutzap_event = nostr_nip61_create_nutzap_event(&nutzap, my_privkey, 0); +// Publish nutzap_event to relays... +``` diff --git a/tests/bip32_test b/tests/bip32_test index 66ca8332..7025059e 100755 Binary files a/tests/bip32_test and b/tests/bip32_test differ diff --git a/tests/cashu_mint_test b/tests/cashu_mint_test new file mode 100755 index 00000000..ac3585a7 Binary files /dev/null and b/tests/cashu_mint_test differ diff --git a/tests/cashu_mint_test.c b/tests/cashu_mint_test.c new file mode 100644 index 00000000..2c9dd1e2 --- /dev/null +++ b/tests/cashu_mint_test.c @@ -0,0 +1,70 @@ +/* + * Cashu Mint HTTP Client Test Suite + */ + +#include +#include +#include "../nostr_core/nostr_core.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static void test_invalid_inputs(void) { + printf("\n=== test_invalid_inputs ===\n"); + + cashu_mint_info_t info; + memset(&info, 0, sizeof(info)); + + int rc = cashu_mint_get_info(NULL, &info, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "get_info null url"); + + cashu_mint_quote_t mquote; + memset(&mquote, 0, sizeof(mquote)); + rc = cashu_mint_request_mint_quote(NULL, 10, "sat", &mquote, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "mint quote null url"); + + rc = cashu_mint_request_mint_quote("https://mint.example.com", 10, NULL, &mquote, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "mint quote null unit"); + + rc = cashu_mint_check_mint_quote("https://mint.example.com", NULL, &mquote, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "check mint quote null id"); + + cashu_melt_quote_t melt; + memset(&melt, 0, sizeof(melt)); + rc = cashu_mint_request_melt_quote("https://mint.example.com", NULL, "sat", &melt, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "melt quote null request"); + + rc = cashu_mint_check_melt_quote("https://mint.example.com", NULL, &melt, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "check melt quote null id"); + + cJSON* out = NULL; + rc = cashu_mint_swap("https://mint.example.com", NULL, &out, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "swap null request body"); + + rc = cashu_mint_mint_tokens("https://mint.example.com", NULL, &out, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "mint tokens null request body"); + + rc = cashu_mint_melt_tokens("https://mint.example.com", NULL, &out, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "melt tokens null request body"); + + rc = cashu_mint_check_proofs_state("https://mint.example.com", NULL, &out, 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "checkstate null request body"); +} + +int main(void) { + printf("Cashu Mint HTTP Client Tests\n"); + printf("============================\n"); + + test_invalid_inputs(); + + printf("\n=== Test Summary ===\n"); + printf("Passed: %d/%d\n", tests_passed, tests_run); + + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/nip01_test b/tests/nip01_test index 2aa7c7d6..2be5063d 100755 Binary files a/tests/nip01_test and b/tests/nip01_test differ diff --git a/tests/nip04_test b/tests/nip04_test index 94aaa64b..84c1f44d 100755 Binary files a/tests/nip04_test and b/tests/nip04_test differ diff --git a/tests/nip05_test b/tests/nip05_test index fa154789..0fc0fd35 100755 Binary files a/tests/nip05_test and b/tests/nip05_test differ diff --git a/tests/nip11_test b/tests/nip11_test index 99694ddd..ffbdc837 100755 Binary files a/tests/nip11_test and b/tests/nip11_test differ diff --git a/tests/nip13_test b/tests/nip13_test index 3ed0259e..07c322f5 100755 Binary files a/tests/nip13_test and b/tests/nip13_test differ diff --git a/tests/nip21_test b/tests/nip21_test index 215252e2..d37ed263 100755 Binary files a/tests/nip21_test and b/tests/nip21_test differ diff --git a/tests/nip42_pool_test b/tests/nip42_pool_test index 22ff5cce..a69cad3a 100755 Binary files a/tests/nip42_pool_test and b/tests/nip42_pool_test differ diff --git a/tests/nip42_test b/tests/nip42_test index 223cb2ec..53981cdd 100755 Binary files a/tests/nip42_test and b/tests/nip42_test differ diff --git a/tests/nip44_test b/tests/nip44_test index 84b89828..d3fbda27 100755 Binary files a/tests/nip44_test and b/tests/nip44_test differ diff --git a/tests/nip46_test b/tests/nip46_test index 940f3c8f..42176412 100755 Binary files a/tests/nip46_test and b/tests/nip46_test differ diff --git a/tests/nip60_test b/tests/nip60_test new file mode 100755 index 00000000..c85db960 Binary files /dev/null and b/tests/nip60_test differ diff --git a/tests/nip60_test.c b/tests/nip60_test.c new file mode 100644 index 00000000..33bf33e4 --- /dev/null +++ b/tests/nip60_test.c @@ -0,0 +1,185 @@ +/* + * NIP-60 Cashu Wallet Test Suite + */ + +#include +#include +#include +#include "../nostr_core/nostr_core.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static int hex_to_bytes32(const char* hex, unsigned char out[32]) { + return nostr_hex_to_bytes(hex, out, 32) == 0 ? 0 : -1; +} + +static void test_wallet_roundtrip(void) { + printf("\n=== test_wallet_roundtrip ===\n"); + + const char* sk_hex = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + char* mints[] = { + "https://mint1.example.com", + "https://mint2.example.com" + }; + + nostr_nip60_wallet_data_t in; + memset(&in, 0, sizeof(in)); + strcpy(in.privkey, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + in.mint_urls = mints; + in.mint_count = 2; + + cJSON* evt = nostr_nip60_create_wallet_event(&in, sk, 0); + TEST_ASSERT(evt != NULL, "create wallet event"); + + nostr_nip60_wallet_data_t out; + memset(&out, 0, sizeof(out)); + int rc = nostr_nip60_parse_wallet_event(evt, sk, &out); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse wallet event"); + TEST_ASSERT(strcmp(out.privkey, in.privkey) == 0, "wallet privkey preserved"); + TEST_ASSERT(out.mint_count == 2, "wallet mint count"); + TEST_ASSERT(strcmp(out.mint_urls[0], mints[0]) == 0, "wallet mint 0"); + TEST_ASSERT(strcmp(out.mint_urls[1], mints[1]) == 0, "wallet mint 1"); + + nostr_nip60_free_wallet_data(&out); + cJSON_Delete(evt); +} + +static void test_token_roundtrip_and_sum(void) { + printf("\n=== test_token_roundtrip_and_sum ===\n"); + + const char* sk_hex = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + nostr_cashu_proof_t proofs[2]; + memset(proofs, 0, sizeof(proofs)); + + strcpy(proofs[0].id, "005c2502034d4f12"); + proofs[0].amount = 1; + proofs[0].secret = "secret-1"; + proofs[0].C = "0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46"; + + strcpy(proofs[1].id, "005c2502034d4f12"); + proofs[1].amount = 8; + proofs[1].secret = "secret-8"; + proofs[1].C = "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + char* del_ids[] = {"event-id-1"}; + + nostr_nip60_token_data_t token; + memset(&token, 0, sizeof(token)); + token.mint_url = "https://mint.example.com"; + token.proofs = proofs; + token.proof_count = 2; + token.deleted_token_ids = del_ids; + token.deleted_count = 1; + + TEST_ASSERT(nostr_nip60_sum_proofs(proofs, 2) == 9, "proof sum"); + + cJSON* evt = nostr_nip60_create_token_event(&token, sk, 0); + TEST_ASSERT(evt != NULL, "create token event"); + + nostr_nip60_token_data_t out; + memset(&out, 0, sizeof(out)); + int rc = nostr_nip60_parse_token_event(evt, sk, &out); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse token event"); + TEST_ASSERT(strcmp(out.mint_url, token.mint_url) == 0, "token mint"); + TEST_ASSERT(out.proof_count == 2, "token proof count"); + TEST_ASSERT(out.deleted_count == 1, "token del count"); + TEST_ASSERT(nostr_nip60_sum_proofs(out.proofs, out.proof_count) == 9, "parsed proof sum"); + + cJSON* del_evt = nostr_nip60_create_token_deletion("event-id-1", sk, 0); + TEST_ASSERT(del_evt != NULL, "create token deletion event"); + + nostr_nip60_free_token_data(&out); + cJSON_Delete(del_evt); + cJSON_Delete(evt); +} + +static void test_history_quote_and_filters(void) { + printf("\n=== test_history_quote_and_filters ===\n"); + + const char* sk_hex = "1111111111111111111111111111111111111111111111111111111111111111"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + nostr_nip60_history_ref_t refs[2]; + memset(refs, 0, sizeof(refs)); + strcpy(refs[0].event_id, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + strcpy(refs[0].relay_hint, "wss://relay.example.com"); + refs[0].marker = NOSTR_NIP60_REF_DESTROYED; + strcpy(refs[1].event_id, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + refs[1].marker = NOSTR_NIP60_REF_CREATED; + + nostr_nip60_history_data_t hist; + memset(&hist, 0, sizeof(hist)); + hist.direction = NOSTR_NIP60_DIRECTION_OUT; + hist.amount = 4; + hist.refs = refs; + hist.ref_count = 2; + + cJSON* hist_evt = nostr_nip60_create_history_event(&hist, sk, 0); + TEST_ASSERT(hist_evt != NULL, "create history event"); + + nostr_nip60_history_data_t parsed; + memset(&parsed, 0, sizeof(parsed)); + int rc = nostr_nip60_parse_history_event(hist_evt, sk, &parsed); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse history event"); + TEST_ASSERT(parsed.direction == NOSTR_NIP60_DIRECTION_OUT, "history direction"); + TEST_ASSERT(parsed.amount == 4, "history amount"); + TEST_ASSERT(parsed.ref_count == 2, "history refs"); + + time_t exp = 2000000000; + cJSON* quote_evt = nostr_nip60_create_quote_event("quote-123", "https://mint.example.com", exp, sk, 0); + TEST_ASSERT(quote_evt != NULL, "create quote event"); + + char quote_id[128]; + char mint[256]; + time_t exp_out = 0; + rc = nostr_nip60_parse_quote_event(quote_evt, sk, quote_id, sizeof(quote_id), mint, sizeof(mint), &exp_out); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse quote event"); + TEST_ASSERT(strcmp(quote_id, "quote-123") == 0, "quote id"); + TEST_ASSERT(strcmp(mint, "https://mint.example.com") == 0, "quote mint"); + TEST_ASSERT(exp_out == exp, "quote expiration"); + + cJSON* wf = nostr_nip60_create_wallet_filter("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + cJSON* hf = nostr_nip60_create_history_filter("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 1700000000); + TEST_ASSERT(wf != NULL, "wallet filter"); + TEST_ASSERT(hf != NULL, "history filter"); + + cJSON_Delete(wf); + cJSON_Delete(hf); + cJSON_Delete(quote_evt); + nostr_nip60_free_history_data(&parsed); + cJSON_Delete(hist_evt); +} + +int main(void) { + printf("NIP-60 Cashu Wallet Tests\n"); + printf("==========================\n"); + + if (nostr_init() != NOSTR_SUCCESS) { + printf("❌ Failed to initialize NOSTR library\n"); + return 1; + } + + test_wallet_roundtrip(); + test_token_roundtrip_and_sum(); + test_history_quote_and_filters(); + + printf("\n=== Test Summary ===\n"); + printf("Passed: %d/%d\n", tests_passed, tests_run); + + nostr_cleanup(); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/nip61_test b/tests/nip61_test new file mode 100755 index 00000000..eae18db8 Binary files /dev/null and b/tests/nip61_test differ diff --git a/tests/nip61_test.c b/tests/nip61_test.c new file mode 100644 index 00000000..09f63bb1 --- /dev/null +++ b/tests/nip61_test.c @@ -0,0 +1,199 @@ +/* + * NIP-61 Nutzaps Test Suite + */ + +#include +#include +#include +#include "../nostr_core/nostr_core.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static int hex_to_bytes32(const char* hex, unsigned char out[32]) { + return nostr_hex_to_bytes(hex, out, 32) == 0 ? 0 : -1; +} + +static void test_nutzap_info_roundtrip(void) { + printf("\n=== test_nutzap_info_roundtrip ===\n"); + + const char* sk_hex = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + char* relays[] = {"wss://relay1.example.com", "wss://relay2.example.com"}; + + char* mint1_units[] = {"usd", "sat"}; + char* mint2_units[] = {"sat"}; + + nostr_nip61_mint_entry_t mints[2]; + memset(mints, 0, sizeof(mints)); + mints[0].url = "https://mint1.example.com"; + mints[0].units = mint1_units; + mints[0].unit_count = 2; + mints[1].url = "https://mint2.example.com"; + mints[1].units = mint2_units; + mints[1].unit_count = 1; + + nostr_nip61_nutzap_info_t info; + memset(&info, 0, sizeof(info)); + info.relay_urls = relays; + info.relay_count = 2; + info.mints = mints; + info.mint_count = 2; + strcpy(info.pubkey, "02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed"); + + cJSON* evt = nostr_nip61_create_nutzap_info_event(&info, sk, 0); + TEST_ASSERT(evt != NULL, "create nutzap info event"); + + nostr_nip61_nutzap_info_t out; + memset(&out, 0, sizeof(out)); + int rc = nostr_nip61_parse_nutzap_info_event(evt, &out); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse nutzap info event"); + TEST_ASSERT(out.relay_count == 2, "relay count"); + TEST_ASSERT(out.mint_count == 2, "mint count"); + TEST_ASSERT(strcmp(out.pubkey, info.pubkey) == 0, "pubkey value"); + + nostr_nip61_free_nutzap_info(&out); + cJSON_Delete(evt); +} + +static void test_nutzap_event_and_filters(void) { + printf("\n=== test_nutzap_event_and_filters ===\n"); + + const char* sk_hex = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + nostr_cashu_proof_t proofs[1]; + memset(proofs, 0, sizeof(proofs)); + strcpy(proofs[0].id, "005c2502034d4f12"); + proofs[0].amount = 1; + proofs[0].secret = "[\"P2PK\",{\"nonce\":\"n1\",\"data\":\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\"}]"; + proofs[0].C = "02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f"; + + nostr_nip61_nutzap_data_t in; + memset(&in, 0, sizeof(in)); + in.content = "Thanks for this great idea."; + in.proofs = proofs; + in.proof_count = 1; + in.mint_url = "https://mint1.example.com"; + strcpy(in.recipient_pubkey, "e9fbced3a42dcf551486650cc752ab354347dd413b307484e4fd1818ab53f991"); + strcpy(in.nutzapped_event_id, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + strcpy(in.nutzapped_relay_hint, "wss://relay.example.com"); + in.nutzapped_kind = 1; + + cJSON* evt = nostr_nip61_create_nutzap_event(&in, sk, 0); + TEST_ASSERT(evt != NULL, "create nutzap event"); + + nostr_nip61_nutzap_data_t out; + memset(&out, 0, sizeof(out)); + int rc = nostr_nip61_parse_nutzap_event(evt, &out); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse nutzap event"); + TEST_ASSERT(out.proof_count == 1, "proof count"); + TEST_ASSERT(strcmp(out.mint_url, in.mint_url) == 0, "mint url"); + TEST_ASSERT(strcmp(out.recipient_pubkey, in.recipient_pubkey) == 0, "recipient pubkey"); + + cJSON* info_filter = nostr_nip61_create_nutzap_info_filter(in.recipient_pubkey); + TEST_ASSERT(info_filter != NULL, "create nutzap info filter"); + + const char* mints[] = {"https://mint1.example.com", "https://mint2.example.com"}; + cJSON* nz_filter = nostr_nip61_create_nutzap_filter(in.recipient_pubkey, mints, 2, 1700000000); + TEST_ASSERT(nz_filter != NULL, "create nutzap filter"); + + cJSON* redeem_evt = nostr_nip61_create_redemption_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "wss://sender-relay.example.com", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "wss://my-relay.example.com", + 1, + sk, + 0 + ); + TEST_ASSERT(redeem_evt != NULL, "create redemption event"); + + nostr_nip61_free_nutzap_data(&out); + cJSON_Delete(redeem_evt); + cJSON_Delete(nz_filter); + cJSON_Delete(info_filter); + cJSON_Delete(evt); +} + +static void test_nutzap_verify(void) { + printf("\n=== test_nutzap_verify ===\n"); + + const char* sk_hex = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220"; + unsigned char sk[32]; + TEST_ASSERT(hex_to_bytes32(sk_hex, sk) == 0, "parse private key"); + + char* relays[] = {"wss://relay.example.com"}; + char* mint_units[] = {"sat"}; + + nostr_nip61_mint_entry_t mint; + memset(&mint, 0, sizeof(mint)); + mint.url = "https://mint-verify.example.com"; + mint.units = mint_units; + mint.unit_count = 1; + + nostr_nip61_nutzap_info_t info; + memset(&info, 0, sizeof(info)); + info.relay_urls = relays; + info.relay_count = 1; + info.mints = &mint; + info.mint_count = 1; + strcpy(info.pubkey, "02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed"); + + cJSON* info_evt = nostr_nip61_create_nutzap_info_event(&info, sk, 0); + TEST_ASSERT(info_evt != NULL, "create info event for verify"); + + nostr_cashu_proof_t proof; + memset(&proof, 0, sizeof(proof)); + strcpy(proof.id, "005c2502034d4f12"); + proof.amount = 1; + proof.secret = "[\"P2PK\",{\"nonce\":\"n1\",\"data\":\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\"}]"; + proof.C = "02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f"; + + nostr_nip61_nutzap_data_t nz; + memset(&nz, 0, sizeof(nz)); + nz.content = "zap"; + nz.proofs = &proof; + nz.proof_count = 1; + nz.mint_url = "https://mint-verify.example.com"; + strcpy(nz.recipient_pubkey, "e9fbced3a42dcf551486650cc752ab354347dd413b307484e4fd1818ab53f991"); + + cJSON* nz_evt = nostr_nip61_create_nutzap_event(&nz, sk, 0); + TEST_ASSERT(nz_evt != NULL, "create nutzap event for verify"); + + int rc = nostr_nip61_verify_nutzap(nz_evt, info_evt); + TEST_ASSERT(rc == NOSTR_SUCCESS, "verify nutzap against info"); + + cJSON_Delete(nz_evt); + cJSON_Delete(info_evt); +} + +int main(void) { + printf("NIP-61 Nutzaps Tests\n"); + printf("====================\n"); + + if (nostr_init() != NOSTR_SUCCESS) { + printf("❌ Failed to initialize NOSTR library\n"); + return 1; + } + + test_nutzap_info_roundtrip(); + test_nutzap_event_and_filters(); + test_nutzap_verify(); + + printf("\n=== Test Summary ===\n"); + printf("Passed: %d/%d\n", tests_passed, tests_run); + + nostr_cleanup(); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/relay_synchronous_test b/tests/relay_synchronous_test index b002f423..5debeb81 100755 Binary files a/tests/relay_synchronous_test and b/tests/relay_synchronous_test differ diff --git a/tests/simple_init_test b/tests/simple_init_test index 1f03446e..1cbeff44 100755 Binary files a/tests/simple_init_test and b/tests/simple_init_test differ diff --git a/tests/sync_relay_test b/tests/sync_relay_test index d4a557d6..e4db92d8 100755 Binary files a/tests/sync_relay_test and b/tests/sync_relay_test differ diff --git a/tests/websocket_debug b/tests/websocket_debug index d8a10a8d..61555e46 100755 Binary files a/tests/websocket_debug and b/tests/websocket_debug differ