From afe3e4787f0e3bbfa493c1cf94372016ab52c9a5 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Fri, 20 Mar 2026 10:23:21 -0400 Subject: [PATCH] Add live NIP-60 Cashu receive integration test using nofee.testnut mint --- VERSION | 2 +- nostr_core/nostr_core.h | 4 +- tests/nip60_live_receive_test.c | 387 ++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 tests/nip60_live_receive_test.c diff --git a/VERSION b/VERSION index 7d856835..d1d899fa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.4 +0.5.5 diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 58904137..8cb9665d 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.5.4" +#define VERSION "v0.5.5" #define VERSION_MAJOR 0 #define VERSION_MINOR 5 -#define VERSION_PATCH 4 +#define VERSION_PATCH 5 /* * NOSTR Core Library - Complete API Reference diff --git a/tests/nip60_live_receive_test.c b/tests/nip60_live_receive_test.c new file mode 100644 index 00000000..b8bad91e --- /dev/null +++ b/tests/nip60_live_receive_test.c @@ -0,0 +1,387 @@ +/* + * NIP-60 Live Cashu Receive Integration Test + * + * This test performs a live mint against testnut mints, constructs a cashuA token + * string from the returned signature, and verifies our receive/decode path. + */ + +#include +#include +#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) + +typedef struct { + char* data; + size_t size; + size_t capacity; +} http_buf_t; + +static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) { + http_buf_t* b = (http_buf_t*)userp; + size_t n = size * nmemb; + + if (b->size + n + 1 > b->capacity) { + size_t cap = b->capacity ? b->capacity * 2 : 1024; + while (cap < b->size + n + 1) cap *= 2; + char* p = (char*)realloc(b->data, cap); + if (!p) return 0; + b->data = p; + b->capacity = cap; + } + + memcpy(b->data + b->size, contents, n); + b->size += n; + b->data[b->size] = '\0'; + return n; +} + +static int http_get_json(const char* url, cJSON** out_json) { + if (!url || !out_json) return NOSTR_ERROR_INVALID_INPUT; + *out_json = NULL; + + CURL* curl = curl_easy_init(); + if (!curl) return NOSTR_ERROR_NETWORK_FAILED; + + http_buf_t b; + memset(&b, 0, sizeof(b)); + b.capacity = 2048; + b.data = (char*)malloc(b.capacity); + if (!b.data) { + curl_easy_cleanup(curl); + return NOSTR_ERROR_MEMORY_FAILED; + } + b.data[0] = '\0'; + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &b); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); + 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); + + CURLcode rc = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_easy_cleanup(curl); + + if (rc != CURLE_OK || status < 200 || status >= 300) { + free(b.data); + return NOSTR_ERROR_CASHU_HTTP_FAILED; + } + + cJSON* json = cJSON_Parse(b.data); + free(b.data); + if (!json) return NOSTR_ERROR_CASHU_JSON_PARSE_FAILED; + + *out_json = json; + return NOSTR_SUCCESS; +} + +static int get_active_keyset_id(const char* mint_url, char out_id[65]) { + if (!mint_url || !out_id) return NOSTR_ERROR_INVALID_INPUT; + out_id[0] = '\0'; + + char url[1024]; + snprintf(url, sizeof(url), "%s/v1/keysets", mint_url); + + cJSON* root = NULL; + int rc = http_get_json(url, &root); + if (rc != NOSTR_SUCCESS) return rc; + + cJSON* arr = cJSON_GetObjectItem(root, "keysets"); + if (!arr || !cJSON_IsArray(arr)) { + cJSON_Delete(root); + return NOSTR_ERROR_CASHU_JSON_PARSE_FAILED; + } + + cJSON* selected = NULL; + int n = cJSON_GetArraySize(arr); + for (int i = 0; i < n; i++) { + cJSON* it = cJSON_GetArrayItem(arr, i); + if (!it || !cJSON_IsObject(it)) continue; + cJSON* active = cJSON_GetObjectItem(it, "active"); + if (active && cJSON_IsBool(active) && cJSON_IsTrue(active)) { + selected = it; + break; + } + } + + if (!selected && n > 0) { + selected = cJSON_GetArrayItem(arr, 0); + } + + if (!selected) { + cJSON_Delete(root); + return NOSTR_ERROR_CASHU_INVALID_KEYSET; + } + + cJSON* id = cJSON_GetObjectItem(selected, "id"); + const char* id_s = (id && cJSON_IsString(id)) ? cJSON_GetStringValue(id) : NULL; + if (!id_s || id_s[0] == '\0') { + cJSON_Delete(root); + return NOSTR_ERROR_CASHU_INVALID_KEYSET; + } + + snprintf(out_id, 65, "%s", id_s); + cJSON_Delete(root); + return NOSTR_SUCCESS; +} + +static int base64_to_base64url_no_pad(const unsigned char* in, size_t in_len, char* out, size_t out_size) { + if (!in || !out || out_size == 0) return NOSTR_ERROR_INVALID_INPUT; + + size_t needed = ((in_len + 2) / 3) * 4 + 1; + char* b64 = (char*)malloc(needed); + if (!b64) return NOSTR_ERROR_MEMORY_FAILED; + + size_t n = base64_encode(in, in_len, b64, needed); + if (n == 0) { + free(b64); + return NOSTR_ERROR_INVALID_INPUT; + } + + size_t j = 0; + for (size_t i = 0; i < n && b64[i] != '\0'; i++) { + char c = b64[i]; + if (c == '=') continue; + if (c == '+') c = '-'; + else if (c == '/') c = '_'; + + if (j + 1 >= out_size) { + free(b64); + return NOSTR_ERROR_INVALID_INPUT; + } + out[j++] = c; + } + + out[j] = '\0'; + free(b64); + return NOSTR_SUCCESS; +} + +static int build_cashuA_token_string(const char* mint_url, + const char* keyset_id, + uint64_t amount, + const char* secret, + const char* C_hex, + char* out_token, + size_t out_token_size) { + if (!mint_url || !keyset_id || !secret || !C_hex || !out_token || out_token_size == 0) { + return NOSTR_ERROR_INVALID_INPUT; + } + + cJSON* root = cJSON_CreateObject(); + cJSON* token_arr = cJSON_CreateArray(); + cJSON* entry = cJSON_CreateObject(); + cJSON* proofs = cJSON_CreateArray(); + cJSON* proof = cJSON_CreateObject(); + + if (!root || !token_arr || !entry || !proofs || !proof) { + cJSON_Delete(root); + cJSON_Delete(token_arr); + cJSON_Delete(entry); + cJSON_Delete(proofs); + cJSON_Delete(proof); + return NOSTR_ERROR_MEMORY_FAILED; + } + + cJSON_AddStringToObject(entry, "mint", mint_url); + + cJSON_AddStringToObject(proof, "id", keyset_id); + cJSON_AddNumberToObject(proof, "amount", (double)amount); + cJSON_AddStringToObject(proof, "secret", secret); + cJSON_AddStringToObject(proof, "C", C_hex); + + cJSON_AddItemToArray(proofs, proof); + cJSON_AddItemToObject(entry, "proofs", proofs); + + cJSON_AddItemToArray(token_arr, entry); + cJSON_AddItemToObject(root, "token", token_arr); + + char* json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) return NOSTR_ERROR_MEMORY_FAILED; + + char b64url[8192]; + int rc = base64_to_base64url_no_pad((const unsigned char*)json, strlen(json), b64url, sizeof(b64url)); + free(json); + if (rc != NOSTR_SUCCESS) return rc; + + int written = snprintf(out_token, out_token_size, "cashuA%s", b64url); + if (written < 0 || (size_t)written >= out_token_size) { + return NOSTR_ERROR_INVALID_INPUT; + } + + return NOSTR_SUCCESS; +} + +static void test_live_mint_and_receive(const char* mint_url) { + printf("\n=== test_live_mint_and_receive: %s ===\n", mint_url); + + cashu_mint_info_t info; + memset(&info, 0, sizeof(info)); + int rc = cashu_mint_get_info(mint_url, &info, 15); + TEST_ASSERT(rc == NOSTR_SUCCESS, "get mint info"); + if (rc != NOSTR_SUCCESS) return; + + TEST_ASSERT(info.pubkey != NULL && strlen(info.pubkey) == 66, "mint pubkey available"); + if (!info.pubkey || strlen(info.pubkey) != 66) { + cashu_mint_free_info(&info); + return; + } + + char keyset_id[65]; + memset(keyset_id, 0, sizeof(keyset_id)); + rc = get_active_keyset_id(mint_url, keyset_id); + TEST_ASSERT(rc == NOSTR_SUCCESS, "fetch active keyset id"); + if (rc != NOSTR_SUCCESS) { + cashu_mint_free_info(&info); + return; + } + + cashu_mint_quote_t quote; + memset("e, 0, sizeof(quote)); + rc = cashu_mint_request_mint_quote(mint_url, 1, "sat", "e, 15); + TEST_ASSERT(rc == NOSTR_SUCCESS, "request mint quote"); + if (rc != NOSTR_SUCCESS) { + cashu_mint_free_info(&info); + return; + } + + cashu_mint_quote_t quote_check; + memset("e_check, 0, sizeof(quote_check)); + rc = cashu_mint_check_mint_quote(mint_url, quote.quote_id, "e_check, 15); + TEST_ASSERT(rc == NOSTR_SUCCESS, "check mint quote"); + TEST_ASSERT(quote_check.paid == 1, "mint quote paid"); + if (rc != NOSTR_SUCCESS || quote_check.paid != 1) { + cashu_mint_free_info(&info); + return; + } + + char secret[160]; + snprintf(secret, sizeof(secret), "nostr-live-receive-test-%ld-%s", (long)time(NULL), mint_url); + + char B_hex[67]; + unsigned char r[32]; + rc = cashu_blind_message(secret, info.pubkey, B_hex, r); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blind message"); + if (rc != NOSTR_SUCCESS) { + cashu_mint_free_info(&info); + return; + } + + cashu_blinded_output_t output; + memset(&output, 0, sizeof(output)); + + size_t keyset_len = strlen(keyset_id); + TEST_ASSERT(keyset_len < sizeof(output.id), "keyset id fits library struct"); + if (keyset_len >= sizeof(output.id)) { + cashu_mint_free_info(&info); + return; + } + + memcpy(output.id, keyset_id, keyset_len + 1); + output.amount = 1; + snprintf(output.B_, sizeof(output.B_), "%s", B_hex); + + cashu_mint_tokens_request_t mint_req; + memset(&mint_req, 0, sizeof(mint_req)); + snprintf(mint_req.quote_id, sizeof(mint_req.quote_id), "%s", quote.quote_id); + snprintf(mint_req.unit, sizeof(mint_req.unit), "sat"); + mint_req.outputs = &output; + mint_req.output_count = 1; + + cJSON* mint_body = NULL; + rc = cashu_build_mint_tokens_request(&mint_req, &mint_body); + TEST_ASSERT(rc == NOSTR_SUCCESS && mint_body != NULL, "build mint tokens request"); + if (rc != NOSTR_SUCCESS || !mint_body) { + cashu_mint_free_info(&info); + return; + } + + cJSON* mint_resp_json = NULL; + rc = cashu_mint_mint_tokens(mint_url, mint_body, &mint_resp_json, 15); + cJSON_Delete(mint_body); + TEST_ASSERT(rc == NOSTR_SUCCESS && mint_resp_json != NULL, "mint tokens call"); + if (rc != NOSTR_SUCCESS || !mint_resp_json) { + cJSON_Delete(mint_resp_json); + cashu_mint_free_info(&info); + return; + } + + cashu_mint_tokens_response_t mint_resp; + memset(&mint_resp, 0, sizeof(mint_resp)); + rc = cashu_parse_mint_tokens_response(mint_resp_json, &mint_resp); + cJSON_Delete(mint_resp_json); + TEST_ASSERT(rc == NOSTR_SUCCESS, "parse mint tokens response"); + TEST_ASSERT(mint_resp.signature_count >= 1, "mint returned signatures"); + if (rc != NOSTR_SUCCESS || mint_resp.signature_count < 1) { + cashu_mint_free_mint_tokens_response(&mint_resp); + cashu_mint_free_info(&info); + return; + } + + char C_hex[67]; + rc = cashu_unblind_signature(mint_resp.signatures[0].C_, info.pubkey, r, C_hex); + TEST_ASSERT(rc == NOSTR_SUCCESS, "unblind signature"); + if (rc != NOSTR_SUCCESS) { + cashu_mint_free_mint_tokens_response(&mint_resp); + cashu_mint_free_info(&info); + return; + } + + char token_string[10000]; + rc = build_cashuA_token_string(mint_url, keyset_id, 1, secret, C_hex, token_string, sizeof(token_string)); + TEST_ASSERT(rc == NOSTR_SUCCESS, "build cashuA token string"); + if (rc != NOSTR_SUCCESS) { + cashu_mint_free_mint_tokens_response(&mint_resp); + cashu_mint_free_info(&info); + return; + } + + cashu_decoded_token_t decoded; + memset(&decoded, 0, sizeof(decoded)); + rc = cashu_decode_token(token_string, &decoded); + TEST_ASSERT(rc == NOSTR_SUCCESS, "decode token (receive path)"); + TEST_ASSERT(decoded.mint_url != NULL && strcmp(decoded.mint_url, mint_url) == 0, "decoded mint url matches"); + TEST_ASSERT(decoded.proof_count == 1, "decoded proof count"); + TEST_ASSERT(decoded.proofs != NULL && decoded.proofs[0].amount == 1, "decoded proof amount"); + TEST_ASSERT(decoded.proofs != NULL && strcmp(decoded.proofs[0].id, keyset_id) == 0, "decoded keyset id"); + TEST_ASSERT(decoded.proofs != NULL && decoded.proofs[0].secret != NULL && strcmp(decoded.proofs[0].secret, secret) == 0, + "decoded secret matches"); + + cashu_free_decoded_token(&decoded); + cashu_mint_free_mint_tokens_response(&mint_resp); + cashu_mint_free_info(&info); +} + +int main(void) { + printf("NIP-60 Live Cashu Receive Integration Test\n"); + printf("========================================\n"); + + if (nostr_init() != NOSTR_SUCCESS) { + printf("❌ Failed to initialize NOSTR library\n"); + return 1; + } + + test_live_mint_and_receive("https://nofee.testnut.cashu.space"); + + printf("\n=== Test Summary ===\n"); + printf("Passed: %d/%d\n", tests_passed, tests_run); + + nostr_cleanup(); + return (tests_passed == tests_run) ? 0 : 1; +}