Add live NIP-60 Cashu receive integration test using nofee.testnut mint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <curl/curl.h>
|
||||
#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;
|
||||
}
|
||||
Reference in New Issue
Block a user