diff options
| author | Your Name <you@example.com> | 2026-05-15 22:27:14 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-15 22:27:14 +0530 |
| commit | 1263d86314fc0760d9be8eea415ccecbc047a5eb (patch) | |
| tree | 778130f0beb59d52f68e0e5f11388bf4b1470130 /main/tollgate_api.c | |
| parent | a7d0a672d59bf8985a6fc0e61b49015fabd96513 (diff) | |
Phase 2 WIP: Cashu payment endpoints, session tracking, updated checklist
- Add cashu.c/h: Cashu token decode (cashuA/base64url), proof state check via mint API, allotment calculator
- Add session.c/h: time-based session management with allotment/expiry, spent secret tracking
- Add tollgate_api.c/h: HTTP server on :2121 with GET / (kind=10021 discovery), POST / (payment processing), /usage, /whoami
- Update captive portal HTML: replace Grant Free Access with Cashu token paste form + Pay & Connect button
- Update tollgate_main.c: wire in session manager, TollGate API, 1s session tick loop
- Add tests/phase2.mjs: Phase 2 test suite (discovery, invalid token, wrong mint, valid payment)
- Update CHECKLIST.md: reflect Phase 1 complete, Phase 2 in progress with known bugs
Known issues (not yet flashed):
- Stack overflow crash in httpd POST handler (need stack_size=16384 + heap allocations)
- cashu_decode_token uses 2KB stack buffer (needs heap alloc)
- Mint URL should be testnut.cashu.space (nofee.testnut has API compat issues)
Diffstat (limited to 'main/tollgate_api.c')
| -rw-r--r-- | main/tollgate_api.c | 355 |
1 files changed, 355 insertions, 0 deletions
diff --git a/main/tollgate_api.c b/main/tollgate_api.c new file mode 100644 index 0000000..5ada3c7 --- /dev/null +++ b/main/tollgate_api.c | |||
| @@ -0,0 +1,355 @@ | |||
| 1 | #include "tollgate_api.h" | ||
| 2 | #include "cashu.h" | ||
| 3 | #include "config.h" | ||
| 4 | #include "session.h" | ||
| 5 | #include "esp_log.h" | ||
| 6 | #include "cJSON.h" | ||
| 7 | #include "lwip/sockets.h" | ||
| 8 | #include "lwip/netdb.h" | ||
| 9 | #include <string.h> | ||
| 10 | |||
| 11 | static const char *TAG = "tollgate_api"; | ||
| 12 | static httpd_handle_t s_api_server = NULL; | ||
| 13 | |||
| 14 | static const char *TOLLGATE_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000000"; | ||
| 15 | |||
| 16 | static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out) | ||
| 17 | { | ||
| 18 | int sockfd = httpd_req_to_sockfd(req); | ||
| 19 | struct sockaddr_in addr; | ||
| 20 | socklen_t addr_len = sizeof(addr); | ||
| 21 | if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_len) == 0) { | ||
| 22 | *ip_out = addr.sin_addr.s_addr; | ||
| 23 | return ESP_OK; | ||
| 24 | } | ||
| 25 | return ESP_FAIL; | ||
| 26 | } | ||
| 27 | |||
| 28 | static cJSON *create_notice(const char *level, const char *code, const char *content) | ||
| 29 | { | ||
| 30 | cJSON *root = cJSON_CreateObject(); | ||
| 31 | cJSON_AddNumberToObject(root, "kind", 21023); | ||
| 32 | cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); | ||
| 33 | cJSON *tags = cJSON_CreateArray(); | ||
| 34 | cJSON *level_tag = cJSON_CreateArray(); | ||
| 35 | cJSON_AddItemToArray(level_tag, cJSON_CreateString("level")); | ||
| 36 | cJSON_AddItemToArray(level_tag, cJSON_CreateString(level)); | ||
| 37 | cJSON_AddItemToArray(tags, level_tag); | ||
| 38 | cJSON *code_tag = cJSON_CreateArray(); | ||
| 39 | cJSON_AddItemToArray(code_tag, cJSON_CreateString("code")); | ||
| 40 | cJSON_AddItemToArray(code_tag, cJSON_CreateString(code)); | ||
| 41 | cJSON_AddItemToArray(tags, code_tag); | ||
| 42 | cJSON_AddItemToObject(root, "tags", tags); | ||
| 43 | cJSON_AddStringToObject(root, "content", content); | ||
| 44 | return root; | ||
| 45 | } | ||
| 46 | |||
| 47 | static cJSON *create_session_event(uint32_t client_ip, uint64_t allotment_ms) | ||
| 48 | { | ||
| 49 | cJSON *root = cJSON_CreateObject(); | ||
| 50 | cJSON_AddNumberToObject(root, "kind", 1022); | ||
| 51 | cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); | ||
| 52 | |||
| 53 | cJSON *tags = cJSON_CreateArray(); | ||
| 54 | |||
| 55 | cJSON *p_tag = cJSON_CreateArray(); | ||
| 56 | cJSON_AddItemToArray(p_tag, cJSON_CreateString("p")); | ||
| 57 | cJSON_AddItemToArray(p_tag, cJSON_CreateString("unknown")); | ||
| 58 | cJSON_AddItemToArray(tags, p_tag); | ||
| 59 | |||
| 60 | esp_ip4_addr_t ip = { .addr = client_ip }; | ||
| 61 | char ip_str[16]; | ||
| 62 | snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip)); | ||
| 63 | cJSON *dev_tag = cJSON_CreateArray(); | ||
| 64 | cJSON_AddItemToArray(dev_tag, cJSON_CreateString("device-identifier")); | ||
| 65 | cJSON_AddItemToArray(dev_tag, cJSON_CreateString("mac")); | ||
| 66 | cJSON_AddItemToArray(dev_tag, cJSON_CreateString(ip_str)); | ||
| 67 | cJSON_AddItemToArray(tags, dev_tag); | ||
| 68 | |||
| 69 | cJSON *allotment_tag = cJSON_CreateArray(); | ||
| 70 | cJSON_AddItemToArray(allotment_tag, cJSON_CreateString("allotment")); | ||
| 71 | char allotment_str[32]; | ||
| 72 | snprintf(allotment_str, sizeof(allotment_str), "%llu", (unsigned long long)allotment_ms); | ||
| 73 | cJSON_AddItemToArray(allotment_tag, cJSON_CreateString(allotment_str)); | ||
| 74 | cJSON_AddItemToArray(tags, allotment_tag); | ||
| 75 | |||
| 76 | cJSON *metric_tag = cJSON_CreateArray(); | ||
| 77 | cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric")); | ||
| 78 | cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds")); | ||
| 79 | cJSON_AddItemToArray(tags, metric_tag); | ||
| 80 | |||
| 81 | cJSON_AddItemToObject(root, "tags", tags); | ||
| 82 | cJSON_AddStringToObject(root, "content", ""); | ||
| 83 | return root; | ||
| 84 | } | ||
| 85 | |||
| 86 | static esp_err_t api_get_discovery(httpd_req_t *req) | ||
| 87 | { | ||
| 88 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 89 | |||
| 90 | cJSON *root = cJSON_CreateObject(); | ||
| 91 | cJSON_AddNumberToObject(root, "kind", 10021); | ||
| 92 | cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); | ||
| 93 | |||
| 94 | cJSON *tags = cJSON_CreateArray(); | ||
| 95 | |||
| 96 | cJSON *metric_tag = cJSON_CreateArray(); | ||
| 97 | cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric")); | ||
| 98 | cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds")); | ||
| 99 | cJSON_AddItemToArray(tags, metric_tag); | ||
| 100 | |||
| 101 | cJSON *step_tag = cJSON_CreateArray(); | ||
| 102 | cJSON_AddItemToArray(step_tag, cJSON_CreateString("step_size")); | ||
| 103 | char step_str[32]; | ||
| 104 | snprintf(step_str, sizeof(step_str), "%d", cfg->step_size_ms); | ||
| 105 | cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str)); | ||
| 106 | cJSON_AddItemToArray(tags, step_tag); | ||
| 107 | |||
| 108 | cJSON *price_tag = cJSON_CreateArray(); | ||
| 109 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); | ||
| 110 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); | ||
| 111 | char price_str[32]; | ||
| 112 | snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); | ||
| 113 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); | ||
| 114 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); | ||
| 115 | cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); | ||
| 116 | cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); | ||
| 117 | cJSON_AddItemToArray(tags, price_tag); | ||
| 118 | |||
| 119 | cJSON *tips_tag = cJSON_CreateArray(); | ||
| 120 | cJSON_AddItemToArray(tips_tag, cJSON_CreateString("tips")); | ||
| 121 | cJSON_AddItemToArray(tips_tag, cJSON_CreateString("1")); | ||
| 122 | cJSON_AddItemToArray(tips_tag, cJSON_CreateString("2")); | ||
| 123 | cJSON_AddItemToArray(tips_tag, cJSON_CreateString("5")); | ||
| 124 | cJSON_AddItemToArray(tags, tips_tag); | ||
| 125 | |||
| 126 | cJSON_AddItemToObject(root, "tags", tags); | ||
| 127 | cJSON_AddStringToObject(root, "content", ""); | ||
| 128 | |||
| 129 | char *json = cJSON_PrintUnformatted(root); | ||
| 130 | httpd_resp_set_type(req, "application/json"); | ||
| 131 | httpd_resp_send(req, json, strlen(json)); | ||
| 132 | cJSON_free(json); | ||
| 133 | cJSON_Delete(root); | ||
| 134 | return ESP_OK; | ||
| 135 | } | ||
| 136 | |||
| 137 | static esp_err_t api_post_payment(httpd_req_t *req) | ||
| 138 | { | ||
| 139 | uint32_t client_ip = 0; | ||
| 140 | get_client_ip(req, &client_ip); | ||
| 141 | |||
| 142 | int content_len = req->content_len; | ||
| 143 | if (content_len <= 0 || content_len > 16384) { | ||
| 144 | cJSON *notice = create_notice("error", "payment-error-invalid", "Invalid request body"); | ||
| 145 | char *json = cJSON_PrintUnformatted(notice); | ||
| 146 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 147 | httpd_resp_set_type(req, "application/json"); | ||
| 148 | httpd_resp_send(req, json, strlen(json)); | ||
| 149 | cJSON_free(json); | ||
| 150 | cJSON_Delete(notice); | ||
| 151 | return ESP_OK; | ||
| 152 | } | ||
| 153 | |||
| 154 | char *body = malloc(content_len + 1); | ||
| 155 | if (!body) { | ||
| 156 | cJSON *notice = create_notice("error", "session-error", "Out of memory"); | ||
| 157 | char *json = cJSON_PrintUnformatted(notice); | ||
| 158 | httpd_resp_set_status(req, "503 Service Unavailable"); | ||
| 159 | httpd_resp_set_type(req, "application/json"); | ||
| 160 | httpd_resp_send(req, json, strlen(json)); | ||
| 161 | cJSON_free(json); | ||
| 162 | cJSON_Delete(notice); | ||
| 163 | return ESP_OK; | ||
| 164 | } | ||
| 165 | int received = httpd_req_recv(req, body, content_len); | ||
| 166 | if (received <= 0) { | ||
| 167 | free(body); | ||
| 168 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 169 | httpd_resp_set_type(req, "text/plain"); | ||
| 170 | httpd_resp_send(req, "bad request", 11); | ||
| 171 | return ESP_OK; | ||
| 172 | } | ||
| 173 | body[received] = '\0'; | ||
| 174 | |||
| 175 | ESP_LOGI(TAG, "Payment received: %d bytes", received); | ||
| 176 | |||
| 177 | cashu_token_t token; | ||
| 178 | esp_err_t err = cashu_decode_token(body, &token); | ||
| 179 | free(body); | ||
| 180 | |||
| 181 | if (err != ESP_OK) { | ||
| 182 | cJSON *notice = create_notice("error", "payment-error-invalid", "Failed to decode Cashu token"); | ||
| 183 | char *json = cJSON_PrintUnformatted(notice); | ||
| 184 | httpd_resp_set_status(req, "400 Bad Request"); | ||
| 185 | httpd_resp_set_type(req, "application/json"); | ||
| 186 | httpd_resp_send(req, json, strlen(json)); | ||
| 187 | cJSON_free(json); | ||
| 188 | cJSON_Delete(notice); | ||
| 189 | return ESP_OK; | ||
| 190 | } | ||
| 191 | |||
| 192 | const char *mint_url = token.mint_url[0] ? token.mint_url : tollgate_config_get()->mint_url; | ||
| 193 | if (!cashu_is_mint_accepted(mint_url)) { | ||
| 194 | cJSON *notice = create_notice("error", "payment-error-mint-not-accepted", "Mint not accepted"); | ||
| 195 | char *json = cJSON_PrintUnformatted(notice); | ||
| 196 | httpd_resp_set_status(req, "402 Payment Required"); | ||
| 197 | httpd_resp_set_type(req, "application/json"); | ||
| 198 | httpd_resp_send(req, json, strlen(json)); | ||
| 199 | cJSON_free(json); | ||
| 200 | cJSON_Delete(notice); | ||
| 201 | return ESP_OK; | ||
| 202 | } | ||
| 203 | |||
| 204 | for (int i = 0; i < token.proof_count; i++) { | ||
| 205 | if (session_is_secret_spent(token.proofs[i].secret)) { | ||
| 206 | cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); | ||
| 207 | char *json = cJSON_PrintUnformatted(notice); | ||
| 208 | httpd_resp_set_status(req, "402 Payment Required"); | ||
| 209 | httpd_resp_set_type(req, "application/json"); | ||
| 210 | httpd_resp_send(req, json, strlen(json)); | ||
| 211 | cJSON_free(json); | ||
| 212 | cJSON_Delete(notice); | ||
| 213 | return ESP_OK; | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | cashu_proof_state_t states[CASHU_MAX_PROOFS]; | ||
| 218 | int state_count = 0; | ||
| 219 | err = cashu_check_proof_states(mint_url, &token, states, &state_count); | ||
| 220 | if (err != ESP_OK) { | ||
| 221 | cJSON *notice = create_notice("error", "payment-error-verification", "Failed to verify token with mint"); | ||
| 222 | char *json = cJSON_PrintUnformatted(notice); | ||
| 223 | httpd_resp_set_status(req, "502 Bad Gateway"); | ||
| 224 | httpd_resp_set_type(req, "application/json"); | ||
| 225 | httpd_resp_send(req, json, strlen(json)); | ||
| 226 | cJSON_free(json); | ||
| 227 | cJSON_Delete(notice); | ||
| 228 | return ESP_OK; | ||
| 229 | } | ||
| 230 | |||
| 231 | for (int i = 0; i < state_count; i++) { | ||
| 232 | if (states[i].spent) { | ||
| 233 | cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); | ||
| 234 | char *json = cJSON_PrintUnformatted(notice); | ||
| 235 | httpd_resp_set_status(req, "402 Payment Required"); | ||
| 236 | httpd_resp_set_type(req, "application/json"); | ||
| 237 | httpd_resp_send(req, json, strlen(json)); | ||
| 238 | cJSON_free(json); | ||
| 239 | cJSON_Delete(notice); | ||
| 240 | return ESP_OK; | ||
| 241 | } | ||
| 242 | } | ||
| 243 | |||
| 244 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 245 | uint64_t allotment = cashu_calculate_allotment_ms(token.total_amount, cfg->price_per_step, cfg->step_size_ms); | ||
| 246 | if (allotment == 0) { | ||
| 247 | cJSON *notice = create_notice("error", "payment-error-insufficient", "Token value too low"); | ||
| 248 | char *json = cJSON_PrintUnformatted(notice); | ||
| 249 | httpd_resp_set_status(req, "402 Payment Required"); | ||
| 250 | httpd_resp_set_type(req, "application/json"); | ||
| 251 | httpd_resp_send(req, json, strlen(json)); | ||
| 252 | cJSON_free(json); | ||
| 253 | cJSON_Delete(notice); | ||
| 254 | return ESP_OK; | ||
| 255 | } | ||
| 256 | |||
| 257 | const char *secrets[5]; | ||
| 258 | for (int i = 0; i < token.proof_count && i < 5; i++) { | ||
| 259 | secrets[i] = token.proofs[i].secret; | ||
| 260 | } | ||
| 261 | session_t *session = session_create(client_ip, allotment, secrets, token.proof_count); | ||
| 262 | if (!session) { | ||
| 263 | cJSON *notice = create_notice("error", "session-error", "Failed to create session"); | ||
| 264 | char *json = cJSON_PrintUnformatted(notice); | ||
| 265 | httpd_resp_set_status(req, "503 Service Unavailable"); | ||
| 266 | httpd_resp_set_type(req, "application/json"); | ||
| 267 | httpd_resp_send(req, json, strlen(json)); | ||
| 268 | cJSON_free(json); | ||
| 269 | cJSON_Delete(notice); | ||
| 270 | return ESP_OK; | ||
| 271 | } | ||
| 272 | |||
| 273 | cJSON *session_event = create_session_event(client_ip, allotment); | ||
| 274 | char *json = cJSON_PrintUnformatted(session_event); | ||
| 275 | httpd_resp_set_type(req, "application/json"); | ||
| 276 | httpd_resp_send(req, json, strlen(json)); | ||
| 277 | cJSON_free(json); | ||
| 278 | cJSON_Delete(session_event); | ||
| 279 | return ESP_OK; | ||
| 280 | } | ||
| 281 | |||
| 282 | static esp_err_t api_get_usage(httpd_req_t *req) | ||
| 283 | { | ||
| 284 | uint32_t client_ip = 0; | ||
| 285 | get_client_ip(req, &client_ip); | ||
| 286 | |||
| 287 | session_t *session = session_find_by_ip(client_ip); | ||
| 288 | if (!session || !session->active) { | ||
| 289 | httpd_resp_set_type(req, "text/plain"); | ||
| 290 | httpd_resp_send(req, "-1/-1", 5); | ||
| 291 | return ESP_OK; | ||
| 292 | } | ||
| 293 | |||
| 294 | int64_t elapsed = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS - session->start_time_ms; | ||
| 295 | int64_t remaining = session->allotment_ms - elapsed; | ||
| 296 | if (remaining < 0) remaining = 0; | ||
| 297 | |||
| 298 | char resp[64]; | ||
| 299 | snprintf(resp, sizeof(resp), "%lld/%llu", (long long)remaining, (unsigned long long)session->allotment_ms); | ||
| 300 | httpd_resp_set_type(req, "text/plain"); | ||
| 301 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 302 | return ESP_OK; | ||
| 303 | } | ||
| 304 | |||
| 305 | static esp_err_t api_get_whoami(httpd_req_t *req) | ||
| 306 | { | ||
| 307 | uint32_t client_ip = 0; | ||
| 308 | char resp[64]; | ||
| 309 | if (get_client_ip(req, &client_ip) == ESP_OK) { | ||
| 310 | esp_ip4_addr_t ip = { .addr = client_ip }; | ||
| 311 | snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip)); | ||
| 312 | } else { | ||
| 313 | snprintf(resp, sizeof(resp), "mac=unknown"); | ||
| 314 | } | ||
| 315 | httpd_resp_set_type(req, "text/plain"); | ||
| 316 | httpd_resp_send(req, resp, strlen(resp)); | ||
| 317 | return ESP_OK; | ||
| 318 | } | ||
| 319 | |||
| 320 | static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; | ||
| 321 | static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; | ||
| 322 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; | ||
| 323 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; | ||
| 324 | |||
| 325 | esp_err_t tollgate_api_start(void) | ||
| 326 | { | ||
| 327 | if (s_api_server) return ESP_OK; | ||
| 328 | |||
| 329 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); | ||
| 330 | config.server_port = 2121; | ||
| 331 | config.ctrl_port = 32769; | ||
| 332 | config.max_uri_handlers = 10; | ||
| 333 | |||
| 334 | esp_err_t ret = httpd_start(&s_api_server, &config); | ||
| 335 | if (ret != ESP_OK) { | ||
| 336 | ESP_LOGE(TAG, "Failed to start API server: %s", esp_err_to_name(ret)); | ||
| 337 | return ret; | ||
| 338 | } | ||
| 339 | |||
| 340 | httpd_register_uri_handler(s_api_server, &uri_discovery); | ||
| 341 | httpd_register_uri_handler(s_api_server, &uri_payment); | ||
| 342 | httpd_register_uri_handler(s_api_server, &uri_usage); | ||
| 343 | httpd_register_uri_handler(s_api_server, &uri_whoami); | ||
| 344 | |||
| 345 | ESP_LOGI(TAG, "TollGate API started on port 2121"); | ||
| 346 | return ESP_OK; | ||
| 347 | } | ||
| 348 | |||
| 349 | void tollgate_api_stop(void) | ||
| 350 | { | ||
| 351 | if (s_api_server) { | ||
| 352 | httpd_stop(s_api_server); | ||
| 353 | s_api_server = NULL; | ||
| 354 | } | ||
| 355 | } | ||