upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/main/tollgate_client.c
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-17 04:21:39 +0530
committerYour Name <you@example.com>2026-05-17 04:21:39 +0530
commit78dd599277b8e8b2ddc39a4ae710ec91d737272e (patch)
tree9fbd89695cede00b8ff3b12ce428e96a2aa70e9b /main/tollgate_client.c
parentb0d7394e089f00a9ffa67a2b33a502e47b778a93 (diff)
Phase 4: TollGate client detection + auto-payment
- New tollgate_client.c/h: detect upstream TollGate (kind=10021), auto-pay via nucula wallet, session monitoring with 20% renewal - State machine: IDLE→DETECTING→NEEDS_PAY→PAYING→PAID→RENEWING - Blocking: upstream payment before local services start - Synchronous wallet init (was async task) - Client config: enabled, steps_to_buy, renewal_threshold_pct - Updated PLAN.md with Phases 4-7 (client, payout, bytes, CVM) - Updated CHECKLIST.md with all new phase items - 30 new unit tests (all passing), 116 total
Diffstat (limited to 'main/tollgate_client.c')
-rw-r--r--main/tollgate_client.c457
1 files changed, 457 insertions, 0 deletions
diff --git a/main/tollgate_client.c b/main/tollgate_client.c
new file mode 100644
index 0000000..ac8dcfe
--- /dev/null
+++ b/main/tollgate_client.c
@@ -0,0 +1,457 @@
1#include "tollgate_client.h"
2#include "config.h"
3#include "nucula_wallet.h"
4#include "esp_log.h"
5#include "esp_http_client.h"
6#include "esp_crt_bundle.h"
7#include "cJSON.h"
8#include <string.h>
9#include <stdlib.h>
10
11static const char *TAG = "tg_client";
12
13static tollgate_client_state_t s_state = TG_CLIENT_IDLE;
14static tollgate_discovery_t s_discovery = {0};
15static char s_gw_ip[TG_CLIENT_MAX_GW_IP_LEN] = {0};
16static int64_t s_allotment_ms = 0;
17static int64_t s_remaining_ms = -1;
18static int64_t s_last_pay_time_ms = 0;
19static int s_retry_count = 0;
20
21static int64_t get_time_ms(void) {
22 return (int64_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
23}
24
25static esp_err_t http_get(const char *url, char *resp_buf, size_t resp_buf_size, int *status_out)
26{
27 esp_http_client_config_t config = {
28 .url = url,
29 .method = HTTP_METHOD_GET,
30 .timeout_ms = 10000,
31 .crt_bundle_attach = esp_crt_bundle_attach,
32 };
33 esp_http_client_handle_t client = esp_http_client_init(&config);
34 if (!client) return ESP_FAIL;
35
36 esp_err_t err = esp_http_client_open(client, 0);
37 if (err != ESP_OK) {
38 esp_http_client_cleanup(client);
39 return ESP_FAIL;
40 }
41
42 int content_length = esp_http_client_fetch_headers(client);
43 (void)content_length;
44 int status = esp_http_client_get_status_code(client);
45 if (status_out) *status_out = status;
46
47 int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1);
48 esp_http_client_cleanup(client);
49
50 if (resp_len < 0) return ESP_FAIL;
51 resp_buf[resp_len] = '\0';
52 return ESP_OK;
53}
54
55static esp_err_t http_post_text(const char *url, const char *body, char *resp_buf, size_t resp_buf_size, int *status_out)
56{
57 esp_http_client_config_t config = {
58 .url = url,
59 .method = HTTP_METHOD_POST,
60 .timeout_ms = 15000,
61 .crt_bundle_attach = esp_crt_bundle_attach,
62 };
63 esp_http_client_handle_t client = esp_http_client_init(&config);
64 if (!client) return ESP_FAIL;
65
66 esp_http_client_set_header(client, "Content-Type", "text/plain");
67 esp_err_t err = esp_http_client_open(client, strlen(body));
68 if (err != ESP_OK) {
69 esp_http_client_cleanup(client);
70 return ESP_FAIL;
71 }
72
73 esp_http_client_write(client, body, strlen(body));
74
75 int content_length = esp_http_client_fetch_headers(client);
76 (void)content_length;
77 int status = esp_http_client_get_status_code(client);
78 if (status_out) *status_out = status;
79
80 int resp_len = esp_http_client_read(client, resp_buf, resp_buf_size - 1);
81 esp_http_client_cleanup(client);
82
83 if (resp_len < 0) return ESP_FAIL;
84 resp_buf[resp_len] = '\0';
85 return ESP_OK;
86}
87
88static bool parse_discovery_response(const char *json_str, tollgate_discovery_t *out)
89{
90 cJSON *root = cJSON_Parse(json_str);
91 if (!root) return false;
92
93 cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind");
94 if (!kind || !cJSON_IsNumber(kind) || kind->valueint != 10021) {
95 cJSON_Delete(root);
96 return false;
97 }
98
99 memset(out, 0, sizeof(tollgate_discovery_t));
100 out->is_tollgate = true;
101
102 cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags");
103 if (!tags || !cJSON_IsArray(tags)) {
104 cJSON_Delete(root);
105 return true;
106 }
107
108 int tag_count = cJSON_GetArraySize(tags);
109 for (int i = 0; i < tag_count; i++) {
110 cJSON *tag = cJSON_GetArrayItem(tags, i);
111 if (!tag || !cJSON_IsArray(tag)) continue;
112
113 int tag_len = cJSON_GetArraySize(tag);
114 if (tag_len < 2) continue;
115
116 cJSON *tag_name = cJSON_GetArrayItem(tag, 0);
117 if (!tag_name || !cJSON_IsString(tag_name)) continue;
118
119 if (strcmp(tag_name->valuestring, "metric") == 0) {
120 cJSON *val = cJSON_GetArrayItem(tag, 1);
121 if (val && cJSON_IsString(val)) {
122 strncpy(out->metric, val->valuestring, sizeof(out->metric) - 1);
123 }
124 } else if (strcmp(tag_name->valuestring, "step_size") == 0) {
125 cJSON *val = cJSON_GetArrayItem(tag, 1);
126 if (val && cJSON_IsString(val)) {
127 out->step_size_ms = atoi(val->valuestring);
128 }
129 } else if (strcmp(tag_name->valuestring, "price_per_step") == 0 && tag_len >= 6) {
130 cJSON *amount = cJSON_GetArrayItem(tag, 2);
131 cJSON *mint = cJSON_GetArrayItem(tag, 4);
132
133 if (amount && cJSON_IsString(amount)) {
134 out->price_per_step = atoi(amount->valuestring);
135 }
136 if (mint && cJSON_IsString(mint)) {
137 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
138 }
139 }
140 }
141
142 cJSON_Delete(root);
143 return true;
144}
145
146static bool parse_session_response(const char *json_str, int64_t *allotment_ms_out)
147{
148 cJSON *root = cJSON_Parse(json_str);
149 if (!root) return false;
150
151 cJSON *kind = cJSON_GetObjectItemCaseSensitive(root, "kind");
152 if (!kind || !cJSON_IsNumber(kind)) {
153 cJSON_Delete(root);
154 return false;
155 }
156
157 if (kind->valueint != 1022) {
158 cJSON_Delete(root);
159 return false;
160 }
161
162 cJSON *tags = cJSON_GetObjectItemCaseSensitive(root, "tags");
163 if (tags && cJSON_IsArray(tags)) {
164 int tag_count = cJSON_GetArraySize(tags);
165 for (int i = 0; i < tag_count; i++) {
166 cJSON *tag = cJSON_GetArrayItem(tags, i);
167 if (!tag || !cJSON_IsArray(tag)) continue;
168 cJSON *tag_name = cJSON_GetArrayItem(tag, 0);
169 if (tag_name && cJSON_IsString(tag_name) && strcmp(tag_name->valuestring, "allotment") == 0) {
170 cJSON *val = cJSON_GetArrayItem(tag, 1);
171 if (val && cJSON_IsString(val)) {
172 *allotment_ms_out = atoll(val->valuestring);
173 }
174 }
175 }
176 }
177
178 cJSON_Delete(root);
179 return true;
180}
181
182static bool parse_usage_response(const char *resp, int64_t *remaining_out, int64_t *total_out)
183{
184 char remaining_str[32] = {0};
185 char total_str[32] = {0};
186 const char *slash = strchr(resp, '/');
187 if (!slash) return false;
188
189 size_t rlen = slash - resp;
190 if (rlen >= sizeof(remaining_str)) return false;
191 memcpy(remaining_str, resp, rlen);
192 strncpy(total_str, slash + 1, sizeof(total_str) - 1);
193
194 *remaining_out = atoll(remaining_str);
195 *total_out = atoll(total_str);
196 return true;
197}
198
199esp_err_t tollgate_client_detect(const char *gw_ip, tollgate_discovery_t *discovery)
200{
201 char url[128];
202 snprintf(url, sizeof(url), "http://%s:2121/", gw_ip);
203
204 char *resp_buf = malloc(4096);
205 if (!resp_buf) return ESP_ERR_NO_MEM;
206
207 int status = 0;
208 esp_err_t err = http_get(url, resp_buf, 4096, &status);
209
210 if (err != ESP_OK || status != 200) {
211 ESP_LOGI(TAG, "detect: no TollGate at %s (status=%d, err=%s)", gw_ip, status, esp_err_to_name(err));
212 free(resp_buf);
213 return ESP_ERR_NOT_FOUND;
214 }
215
216 bool found = parse_discovery_response(resp_buf, discovery);
217 free(resp_buf);
218
219 if (found && discovery->is_tollgate) {
220 ESP_LOGI(TAG, "TollGate detected at %s: price=%d sats, step=%dms, mint=%s, metric=%s",
221 gw_ip, discovery->price_per_step, discovery->step_size_ms,
222 discovery->mint_url, discovery->metric);
223 return ESP_OK;
224 }
225
226 ESP_LOGI(TAG, "detect: response at %s not a TollGate", gw_ip);
227 return ESP_ERR_NOT_FOUND;
228}
229
230static esp_err_t tollgate_client_pay(const char *gw_ip, int amount_sats, int64_t *allotment_ms_out)
231{
232 uint64_t balance = nucula_wallet_balance();
233 if (balance < (uint64_t)amount_sats) {
234 ESP_LOGW(TAG, "insufficient balance: %llu < %d", (unsigned long long)balance, amount_sats);
235 return ESP_ERR_INVALID_STATE;
236 }
237
238 char token_buf[8192];
239 esp_err_t err = nucula_wallet_send((uint64_t)amount_sats, token_buf, sizeof(token_buf));
240 if (err != ESP_OK) {
241 ESP_LOGE(TAG, "wallet send failed: %s", esp_err_to_name(err));
242 return err;
243 }
244
245 ESP_LOGI(TAG, "created token (%d sats), posting to %s:2121", amount_sats, gw_ip);
246
247 char url[128];
248 snprintf(url, sizeof(url), "http://%s:2121/", gw_ip);
249
250 char *resp_buf = malloc(8192);
251 if (!resp_buf) return ESP_ERR_NO_MEM;
252
253 int status = 0;
254 err = http_post_text(url, token_buf, resp_buf, 8192, &status);
255 if (err != ESP_OK) {
256 ESP_LOGE(TAG, "payment POST failed: %s", esp_err_to_name(err));
257 free(resp_buf);
258 return err;
259 }
260
261 ESP_LOGI(TAG, "payment response: status=%d, body=%s", status, resp_buf);
262
263 int64_t allotment = 0;
264 if (status == 200 && parse_session_response(resp_buf, &allotment)) {
265 *allotment_ms_out = allotment;
266 ESP_LOGI(TAG, "payment accepted: allotment=%lldms", (long long)allotment);
267 free(resp_buf);
268 return ESP_OK;
269 }
270
271 ESP_LOGE(TAG, "payment rejected: status=%d", status);
272 free(resp_buf);
273 return ESP_FAIL;
274}
275
276static esp_err_t tollgate_client_query_usage(const char *gw_ip, int64_t *remaining_ms, int64_t *total_ms)
277{
278 char url[128];
279 snprintf(url, sizeof(url), "http://%s:2121/usage", gw_ip);
280
281 char resp_buf[256];
282 int status = 0;
283 esp_err_t err = http_get(url, resp_buf, sizeof(resp_buf), &status);
284 if (err != ESP_OK || status != 200) {
285 return ESP_FAIL;
286 }
287
288 return parse_usage_response(resp_buf, remaining_ms, total_ms) ? ESP_OK : ESP_FAIL;
289}
290
291esp_err_t tollgate_client_init(void)
292{
293 s_state = TG_CLIENT_IDLE;
294 memset(&s_discovery, 0, sizeof(s_discovery));
295 memset(s_gw_ip, 0, sizeof(s_gw_ip));
296 s_allotment_ms = 0;
297 s_remaining_ms = -1;
298 s_last_pay_time_ms = 0;
299 s_retry_count = 0;
300 return ESP_OK;
301}
302
303esp_err_t tollgate_client_on_sta_connected(const char *gw_ip_str)
304{
305 const tollgate_config_t *cfg = tollgate_config_get();
306
307 if (!cfg->client_enabled) {
308 ESP_LOGI(TAG, "client disabled, skipping upstream detection");
309 return ESP_OK;
310 }
311
312 strncpy(s_gw_ip, gw_ip_str, sizeof(s_gw_ip) - 1);
313 s_state = TG_CLIENT_DETECTING;
314 s_retry_count = 0;
315
316 ESP_LOGI(TAG, "detecting upstream TollGate at %s", gw_ip_str);
317
318 esp_err_t err = tollgate_client_detect(gw_ip_str, &s_discovery);
319 if (err != ESP_OK) {
320 s_state = TG_CLIENT_NO_TOLLGATE;
321 ESP_LOGI(TAG, "no upstream TollGate detected");
322 return ESP_OK;
323 }
324
325 s_state = TG_CLIENT_NEEDS_PAY;
326
327 int steps = cfg->client_steps_to_buy;
328 if (steps <= 0) steps = 1;
329 int amount_sats = steps * s_discovery.price_per_step;
330
331 s_state = TG_CLIENT_PAYING;
332 int64_t allotment = 0;
333 err = tollgate_client_pay(gw_ip_str, amount_sats, &allotment);
334 if (err != ESP_OK) {
335 s_state = TG_CLIENT_ERROR;
336 ESP_LOGE(TAG, "upstream payment failed");
337 return err;
338 }
339
340 s_allotment_ms = allotment;
341 s_remaining_ms = allotment;
342 s_last_pay_time_ms = get_time_ms();
343 s_state = TG_CLIENT_PAID;
344
345 ESP_LOGI(TAG, "upstream TollGate paid: %lldms allotment", (long long)allotment);
346 return ESP_OK;
347}
348
349void tollgate_client_on_sta_disconnected(void)
350{
351 ESP_LOGI(TAG, "STA disconnected, resetting client state");
352 s_state = TG_CLIENT_IDLE;
353 memset(&s_discovery, 0, sizeof(s_discovery));
354 memset(s_gw_ip, 0, sizeof(s_gw_ip));
355 s_allotment_ms = 0;
356 s_remaining_ms = -1;
357 s_last_pay_time_ms = 0;
358 s_retry_count = 0;
359}
360
361void tollgate_client_tick(void)
362{
363 if (s_state != TG_CLIENT_PAID && s_state != TG_CLIENT_RENEWING && s_state != TG_CLIENT_ERROR) {
364 return;
365 }
366
367 if (s_state == TG_CLIENT_ERROR) {
368 const tollgate_config_t *cfg = tollgate_config_get();
369 int64_t now = get_time_ms();
370 int64_t elapsed = now - s_last_pay_time_ms;
371 if (elapsed < cfg->client_retry_interval_ms) return;
372
373 if (s_gw_ip[0] == '\0') return;
374
375 s_state = TG_CLIENT_PAYING;
376 int steps = cfg->client_steps_to_buy;
377 if (steps <= 0) steps = 1;
378 int amount_sats = steps * s_discovery.price_per_step;
379
380 int64_t allotment = 0;
381 esp_err_t err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment);
382 if (err == ESP_OK) {
383 s_allotment_ms = allotment;
384 s_remaining_ms = allotment;
385 s_last_pay_time_ms = get_time_ms();
386 s_state = TG_CLIENT_PAID;
387 s_retry_count = 0;
388 ESP_LOGI(TAG, "retry payment succeeded: %lldms", (long long)allotment);
389 } else {
390 s_last_pay_time_ms = get_time_ms();
391 s_retry_count++;
392 s_state = TG_CLIENT_ERROR;
393 ESP_LOGW(TAG, "retry payment failed (attempt %d)", s_retry_count);
394 }
395 return;
396 }
397
398 if (s_gw_ip[0] == '\0') return;
399
400 int64_t remaining = 0, total = 0;
401 esp_err_t err = tollgate_client_query_usage(s_gw_ip, &remaining, &total);
402 if (err == ESP_OK) {
403 s_remaining_ms = remaining;
404 s_allotment_ms = total;
405 }
406
407 const tollgate_config_t *cfg = tollgate_config_get();
408 int threshold_pct = cfg->client_renewal_threshold_pct;
409 if (threshold_pct <= 0) threshold_pct = 20;
410
411 if (s_allotment_ms > 0 && s_remaining_ms >= 0) {
412 int remaining_pct = (int)((s_remaining_ms * 100) / s_allotment_ms);
413 if (remaining_pct <= threshold_pct) {
414 ESP_LOGI(TAG, "session nearing expiry (%lld/%lldms, %d%%), renewing",
415 (long long)s_remaining_ms, (long long)s_allotment_ms, remaining_pct);
416
417 s_state = TG_CLIENT_RENEWING;
418 int steps = cfg->client_steps_to_buy;
419 if (steps <= 0) steps = 1;
420 int amount_sats = steps * s_discovery.price_per_step;
421
422 int64_t allotment = 0;
423 err = tollgate_client_pay(s_gw_ip, amount_sats, &allotment);
424 if (err == ESP_OK) {
425 s_allotment_ms = allotment;
426 s_remaining_ms = allotment;
427 s_last_pay_time_ms = get_time_ms();
428 s_state = TG_CLIENT_PAID;
429 ESP_LOGI(TAG, "renewal succeeded: %lldms", (long long)allotment);
430 } else {
431 s_state = TG_CLIENT_ERROR;
432 s_last_pay_time_ms = get_time_ms();
433 ESP_LOGE(TAG, "renewal payment failed");
434 }
435 }
436 }
437}
438
439tollgate_client_state_t tollgate_client_get_state(void)
440{
441 return s_state;
442}
443
444const tollgate_discovery_t *tollgate_client_get_discovery(void)
445{
446 return &s_discovery;
447}
448
449int64_t tollgate_client_get_remaining_ms(void)
450{
451 return s_remaining_ms;
452}
453
454int64_t tollgate_client_get_allotment_ms(void)
455{
456 return s_allotment_ms;
457}