#include "tollgate_client.h" #include "config.h" #include "nucula_wallet.h" #include "esp_log.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" #include "cJSON.h" #include #include static const char *TAG = "tg_client"; static tollgate_client_state_t s_state = TG_CLIENT_IDLE; static tollgate_discovery_t s_discovery = {0}; static char s_gw_ip[TG_CLIENT_MAX_GW_IP_LEN] = {0}; static int64_t s_allotment_ms = 0; static int64_t s_remaining_ms = -1; static int64_t s_last_pay_time_ms = 0; static int s_retry_count = 0; static int64_t get_time_ms(void) { return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); } static esp_err_t http_get(const char *url, char *resp_buf, size_t resp_buf_size, int *status_out) { esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_GET, .timeout_ms = 10000, .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return ESP_FAIL; esp_err_t err = esp_http_client_open(client, 0); if (err != ESP_OK) { esp_http_client_cleanup(client); return ESP_FAIL; } int content_length = esp_http_client_fetch_headers(client); (void)content_length; int status = esp_http_client_get_status_code(client); if (status_out) *status_out = status; int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1); esp_http_client_cleanup(client); if (resp_len < 0) return ESP_FAIL; resp_buf[resp_len] = '\0'; return ESP_OK; } static esp_err_t http_post_text(const char *url, const char *body, char *resp_buf, size_t resp_buf_size, int *status_out) { esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_POST, .timeout_ms = 15000, .crt_bundle_attach = esp_crt_bundle_attach, }; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return ESP_FAIL; esp_http_client_set_header(client, "Content-Type", "text/plain"); esp_err_t err = esp_http_client_open(client, strlen(body)); if (err != ESP_OK) { esp_http_client_cleanup(client); return ESP_FAIL; } esp_http_client_write(client, body, strlen(body)); int content_length = esp_http_client_fetch_headers(client); (void)content_length; int status = esp_http_client_get_status_code(client); if (status_out) *status_out = status; int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1); esp_http_client_cleanup(client); if (resp_len < 0) return ESP_FAIL; resp_buf[resp_len] = '\0'; return ESP_OK; } static bool parse_discovery_response(const char *json_str, tollgate_discovery_t *out) { cJSON *root = cJSON_Parse(json_str); if (!root) return false; cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind"); if (!kind || !cJSON_IsNumber(kind) || kind->valueint != 10021) { cJSON_Delete(root); return false; } memset(out, 0, sizeof(tollgate_discovery_t)); out->is_tollgate = true; cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags"); if (!tags || !cJSON_IsArray(tags)) { cJSON_Delete(root); return true; } int tag_count = cJSON_GetArraySize(tags); for (int i = 0; i < tag_count; i++) { cJSON *tag = cJSON_GetArrayItem(tags, i); if (!tag || !cJSON_IsArray(tag)) continue; int tag_len = cJSON_GetArraySize(tag); if (tag_len < 2) continue; cJSON *tag_name = cJSON_GetArrayItem(tag, 0); if (!tag_name || !cJSON_IsString(tag_name)) continue; if (strcmp(tag_name->valuestring, "metric") == 0) { cJSON *val = cJSON_GetArrayItem(tag, 1); if (val && cJSON_IsString(val)) { strncpy(out->metric, val->valuestring, sizeof(out->metric) - 1); } } else if (strcmp(tag_name->valuestring, "step_size") == 0) { cJSON *val = cJSON_GetArrayItem(tag, 1); if (val && cJSON_IsString(val)) { out->step_size_ms = atoi(val->valuestring); } } else if (strcmp(tag_name->valuestring, "price_per_step") == 0 && tag_len >= 6) { cJSON *amount = cJSON_GetArrayItem(tag, 2); cJSON *mint = cJSON_GetArrayItem(tag, 4); if (amount && cJSON_IsString(amount)) { out->price_per_step = atoi(amount->valuestring); } if (mint && cJSON_IsString(mint)) { strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1); } } } cJSON_Delete(root); return true; } static bool parse_session_response(const char *json_str, int64_t *allotment_ms_out) { cJSON *root = cJSON_Parse(json_str); if (!root) return false; cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind"); if (!kind || !cJSON_IsNumber(kind)) { cJSON_Delete(root); return false; } if (kind->valueint != 1022) { cJSON_Delete(root); return false; } cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags"); if (tags && cJSON_IsArray(tags)) { int tag_count = cJSON_GetArraySize(tags); for (int i = 0; i < tag_count; i++) { cJSON *tag = cJSON_GetArrayItem(tags, i); if (!tag || !cJSON_IsArray(tag)) continue; cJSON *tag_name = cJSON_GetArrayItem(tag, 0); if (tag_name && cJSON_IsString(tag_name) && strcmp(tag_name->valuestring, "allotment") == 0) { cJSON *val = cJSON_GetArrayItem(tag, 1); if (val && cJSON_IsString(val)) { *allotment_ms_out = atoll(val->valuestring); } } } } cJSON_Delete(root); return true; } static bool parse_usage_response(const char *resp, int64_t *remaining_out, int64_t *total_out) { char remaining_str[32] = {0}; char total_str[32] = {0}; const char *slash = strchr(resp, '/'); if (!slash) return false; size_t rlen = slash - resp; if (rlen >= sizeof(remaining_str)) return false; memcpy(remaining_str, resp, rlen); strncpy(total_str, slash + 1, sizeof(total_str) - 1); *remaining_out = atoll(remaining_str); *total_out = atoll(total_str); return true; } esp_err_t tollgate_client_detect(const char *gw_ip, tollgate_discovery_t *discovery) { char url[128]; snprintf(url, sizeof(url), "http://%s:2121/", gw_ip); char *resp_buf = malloc(4096); if (!resp_buf) return ESP_ERR_NO_MEM; int status = 0; esp_err_t err = http_get(url, resp_buf, 4096, &status); if (err != ESP_OK || status != 200) { ESP_LOGI(TAG, "detect: no TollGate at %s (status=%d, err=%s)", gw_ip, status, esp_err_to_name(err)); free(resp_buf); return ESP_ERR_NOT_FOUND; } bool found = parse_discovery_response(resp_buf, discovery); free(resp_buf); if (found && discovery->is_tollgate) { ESP_LOGI(TAG, "TollGate detected at %s: price=%d sats, step=%dms, mint=%s, metric=%s", gw_ip, discovery->price_per_step, discovery->step_size_ms, discovery->mint_url, discovery->metric); return ESP_OK; } ESP_LOGI(TAG, "detect: response at %s not a TollGate", gw_ip); return ESP_ERR_NOT_FOUND; } static esp_err_t tollgate_client_pay(const char *gw_ip, int amount_sats, int64_t *allotment_ms_out) { uint64_t balance = nucula_wallet_balance(); if (balance < (uint64_t)amount_sats) { ESP_LOGW(TAG, "insufficient balance: %llu < %d", (unsigned long long)balance, amount_sats); return ESP_ERR_INVALID_STATE; } char token_buf[8192]; esp_err_t err = nucula_wallet_send((uint64_t)amount_sats, token_buf, sizeof(token_buf)); if (err != ESP_OK) { ESP_LOGE(TAG, "wallet send failed: %s", esp_err_to_name(err)); return err; } ESP_LOGI(TAG, "created token (%d sats), posting to %s:2121", amount_sats, gw_ip); char url[128]; snprintf(url, sizeof(url), "http://%s:2121/", gw_ip); char *resp_buf = malloc(8192); if (!resp_buf) return ESP_ERR_NO_MEM; int status = 0; err = http_post_text(url, token_buf, resp_buf, 8192, &status); if (err != ESP_OK) { ESP_LOGE(TAG, "payment POST failed: %s", esp_err_to_name(err)); free(resp_buf); return err; } ESP_LOGI(TAG, "payment response: status=%d, body=%s", status, resp_buf); int64_t allotment = 0; if (status == 200 && parse_session_response(resp_buf, &allotment)) { *allotment_ms_out = allotment; ESP_LOGI(TAG, "payment accepted: allotment=%lldms", (long long)allotment); free(resp_buf); return ESP_OK; } ESP_LOGE(TAG, "payment rejected: status=%d", status); free(resp_buf); return ESP_FAIL; } static esp_err_t tollgate_client_query_usage(const char *gw_ip, int64_t *remaining_ms, int64_t *total_ms) { char url[128]; snprintf(url, sizeof(url), "http://%s:2121/usage", gw_ip); char resp_buf[256]; int status = 0; esp_err_t err = http_get(url, resp_buf, sizeof(resp_buf), &status); if (err != ESP_OK || status != 200) { return ESP_FAIL; } return parse_usage_response(resp_buf, remaining_ms, total_ms) ? ESP_OK : ESP_FAIL; } esp_err_t tollgate_client_init(void) { s_state = TG_CLIENT_IDLE; memset(&s_discovery, 0, sizeof(s_discovery)); memset(s_gw_ip, 0, sizeof(s_gw_ip)); s_allotment_ms = 0; s_remaining_ms = -1; s_last_pay_time_ms = 0; s_retry_count = 0; return ESP_OK; } esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str) { const tollgate_config_t *cfg = tollgate_config_get(); if (!cfg->client_enabled) { ESP_LOGI(TAG, "client disabled, skipping upstream detection"); return ESP_OK; } strncpy(s_gw_ip, gw_ip_str, sizeof(s_gw_ip) - 1); s_state = TG_CLIENT_DETECTING; s_retry_count = 0; ESP_LOGI(TAG, "detecting upstream TollGate at %s", gw_ip_str); esp_err_t err = tollgate_client_detect(gw_ip_str, &s_discovery); if (err != ESP_OK) { s_state = TG_CLIENT_NO_TOLLGATE; ESP_LOGI(TAG, "no upstream TollGate detected"); return ESP_OK; } s_state = TG_CLIENT_NEEDS_PAY; int steps = cfg->client_steps_to_buy; if (steps <= 0) steps = 1; int amount_sats = steps * s_discovery.price_per_step; s_state = TG_CLIENT_PAYING; int64_t allotment = 0; err = tollgate_client_pay(gw_ip_str, amount_sats, &allotment); if (err != ESP_OK) { s_state = TG_CLIENT_ERROR; ESP_LOGE(TAG, "upstream payment failed"); return err; } s_allotment_ms = allotment; s_remaining_ms = allotment; s_last_pay_time_ms = get_time_ms(); s_state = TG_CLIENT_PAID; ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment); return ESP_OK; } void tollgate_client_on_sta_disconnected(void) { ESP_LOGI(TAG, "STA disconnected, resetting client state"); s_state = TG_CLIENT_IDLE; memset(&s_discovery, 0, sizeof(s_discovery)); memset(s_gw_ip, 0, sizeof(s_gw_ip)); s_allotment_ms = 0; s_remaining_ms = -1; s_last_pay_time_ms = 0; s_retry_count = 0; } void tollgate_client_tick(void) { if (s_state != TG_CLIENT_PAID && s_state != TG_CLIENT_RENEWING && s_state != TG_CLIENT_ERROR) { return; } if (s_state == TG_CLIENT_ERROR) { const tollgate_config_t *cfg = tollgate_config_get(); int64_t now = get_time_ms(); int64_t elapsed = now - s_last_pay_time_ms; if (elapsed < cfg->client_retry_interval_ms) return; if (s_gw_ip[0] == '\0') return; s_state = TG_CLIENT_PAYING; int steps = cfg->client_steps_to_buy; if (steps <= 0) steps = 1; int amount_sats = steps * s_discovery.price_per_step; int64_t allotment = 0; esp_err_t err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment); if (err == ESP_OK) { s_allotment_ms = allotment; s_remaining_ms = allotment; s_last_pay_time_ms = get_time_ms(); s_state = TG_CLIENT_PAID; s_retry_count = 0; ESP_LOGI(TAG, "retry payment succeeded: %lldms", (long long)allotment); } else { s_last_pay_time_ms = get_time_ms(); s_retry_count++; s_state = TG_CLIENT_ERROR; ESP_LOGW(TAG, "retry payment failed (attempt %d)", s_retry_count); } return; } if (s_gw_ip[0] == '\0') return; int64_t remaining = 0, total = 0; esp_err_t err = tollgate_client_query_usage(s_gw_ip, &remaining, &total); if (err == ESP_OK) { s_remaining_ms = remaining; s_allotment_ms = total; } const tollgate_config_t *cfg = tollgate_config_get(); int threshold_pct = cfg->client_renewal_threshold_pct; if (threshold_pct <= 0) threshold_pct = 20; if (s_allotment_ms > 0 && s_remaining_ms >= 0) { int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms); if (remaining_pct <= threshold_pct) { ESP_LOGI(TAG, "session nearing expiry (%lld/%lldms, %d%%), renewing", (long long)s_remaining_ms, (long long)s_allotment_ms, remaining_pct); s_state = TG_CLIENT_RENEWING; int steps = cfg->client_steps_to_buy; if (steps <= 0) steps = 1; int amount_sats = steps * s_discovery.price_per_step; int64_t allotment = 0; err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment); if (err == ESP_OK) { s_allotment_ms = allotment; s_remaining_ms = allotment; s_last_pay_time_ms = get_time_ms(); s_state = TG_CLIENT_PAID; ESP_LOGI(TAG, "renewal succeeded: %lldms", (long long)allotment); } else { s_state = TG_CLIENT_ERROR; s_last_pay_time_ms = get_time_ms(); ESP_LOGE(TAG, "renewal payment failed"); } } } } tollgate_client_state_t tollgate_client_get_state(void) { return s_state; } const tollgate_discovery_t *tollgate_client_get_discovery(void) { return &s_discovery; } int64_t tollgate_client_get_remaining_ms(void) { return s_remaining_ms; } int64_t tollgate_client_get_allotment_ms(void) { return s_allotment_ms; }