v0.1.1 - Fix cron trigger firing with validation, shorthand normalization, immediate polling, and status/log diagnostics

This commit is contained in:
Your Name
2026-03-19 17:16:06 -04:00
parent 9505ee16bf
commit e9fb1fa0b8
19 changed files with 2366 additions and 67 deletions

View File

@@ -11,6 +11,7 @@ SRCS = \
$(SRC_DIR)/llm.c \
$(SRC_DIR)/nostr_handler.c \
$(SRC_DIR)/agent.c \
$(SRC_DIR)/cashu_wallet.c \
$(SRC_DIR)/tools/tools_common.c \
$(SRC_DIR)/tools/tools_schema.c \
$(SRC_DIR)/tools/tools_dispatch.c \
@@ -31,6 +32,7 @@ SRCS = \
$(SRC_DIR)/tools/tool_nostr_post.c \
$(SRC_DIR)/tools/tool_memory.c \
$(SRC_DIR)/tools/tool_config.c \
$(SRC_DIR)/tools/tool_cashu_wallet.c \
$(SRC_DIR)/trigger_manager.c \
$(SRC_DIR)/prompt_template.c \
$(SRC_DIR)/http_api.c \
@@ -41,11 +43,11 @@ SRCS = \
INCLUDES = \
-I$(SRC_DIR) \
-I$(SRC_DIR)/tools \
-I../nostr_core_lib \
-I../nostr_core_lib/cjson \
-I../nostr_core_lib/nostr_core
-I./nostr_core_lib \
-I./nostr_core_lib/cjson \
-I./nostr_core_lib/nostr_core
NOSTR_LIB = $(firstword $(wildcard ../nostr_core_lib/libnostr_core_*.a))
NOSTR_LIB = $(firstword $(wildcard ./nostr_core_lib/libnostr_core_*.a))
LDFLAGS = -lcurl -lssl -lcrypto -lm -lpthread -ldl -lz -L/usr/local/lib -lsecp256k1
@@ -60,7 +62,7 @@ $(BUILD_DIR):
# Build nostr_core_lib
$(NOSTR_LIB):
@echo "Building nostr_core_lib with all NIPs..."
cd ../nostr_core_lib && ./build.sh --nips=all
cd ./nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061
# Update main.h version information (requires main.h to exist)
src/main.h:
@@ -102,7 +104,7 @@ $(TARGET): src/main.h $(SRCS) $(NOSTR_LIB)
$(CC) $(CFLAGS) $(INCLUDES) -o $@ $(SRCS) $(NOSTR_LIB) $(LDFLAGS)
deps:
cd ../nostr_core_lib && ./build.sh --nips=all
cd ./nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061
clean:
rm -f $(TARGET)

View File

@@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
## Current Status — v0.1.0
## Current Status — v0.1.1
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.1.0Release dual-architecture static binaries (x86_64 + arm64)
> Last release update: v0.1.1Fix cron trigger firing with validation, shorthand normalization, immediate polling, and status/log diagnostics
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
@@ -124,6 +124,13 @@ Edit [`genesis.jsonc`](genesis.jsonc):
"max_tokens": 512,
"temperature": 0.7
},
"cashu_wallet": {
"enabled": true,
"mint_urls": ["https://mint.minibits.cash/Bitcoin"],
"unit": "sat",
"auto_load": true,
"mint_timeout_seconds": 30
},
"tools": {
"enabled": true,
"max_turns": 8,
@@ -216,6 +223,14 @@ CLI debugger notes:
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool nostr_file_md_to_longform_post '{"file":"docs/SKILLS.md","title":"SKILLS"}'
```
Cashu wallet tool examples:
```bash
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_balance '{}'
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_info '{"mint_url":"https://mint.minibits.cash/Bitcoin"}'
./didactyl_static_x86_64 --config ./genesis.jsonc --test-tool cashu_wallet_mint_quote '{"mint_url":"https://mint.minibits.cash/Bitcoin","amount":1000,"unit":"sat"}'
```
### Talk to it
Send an encrypted DM to the agent pubkey using any Nostr client (Damus, Amethyst, Primal, etc.): ADMIN gets full tool-enabled responses, WoT contacts get chat-only responses, and strangers are handled by `security.tiers.stranger` + `security.stranger_response`.

View File

@@ -35,6 +35,21 @@
"temperature": 0.7 // sampling temperature (0.0 2.0)
},
// ─── Cashu Wallet (NIP-60) ────────────────────────────────────────
// Optional wallet runtime used by cashu_wallet_* tools.
// If enabled=true and auto_load=true, Didactyl will attempt to load
// wallet/token events from relays at startup and create a new wallet
// from mint_urls if none is found.
"cashu_wallet": {
"enabled": false,
"mint_urls": [
"https://mint.minibits.cash/Bitcoin"
],
"unit": "sat",
"auto_load": true,
"mint_timeout_seconds": 30
},
// ─── Tools & Runtime Limits ───────────────────────────────────────
// Centralized turn and timeout limits used by DM agent loops,
// triggered skills, HTTP API prompt runs, and local HTTP fetch tool.

Binary file not shown.

View File

@@ -107,6 +107,19 @@ These tools manage skill and trigger lifecycle; skill semantics and trigger exec
| `local_file_write` | Write text content to a local file in the configured working directory |
| `tool_list` | List available tools with name, description, and JSON parameter schema |
### Cashu Wallet Tools (NIP-60)
| Tool | Description |
|---|---|
| `cashu_wallet_balance` | Return wallet balances aggregated by mint and unit from loaded token proofs |
| `cashu_wallet_info` | Fetch mint info for a specific `mint_url` (or configured default mint) |
| `cashu_wallet_mint_quote` | Request a mint quote for an amount and unit |
| `cashu_wallet_mint_check` | Check whether a mint quote is paid/issued |
| `cashu_wallet_mint_claim` | Claim newly minted proofs for a paid quote and persist token event |
| `cashu_wallet_melt_quote` | Request a melt quote for a Lightning invoice/payment request |
| `cashu_wallet_melt_pay` | Pay a melt quote using selected proofs and persist history/token rollover |
| `cashu_wallet_check_proofs` | Ask mint for current proof states and summarize unspent/pending/spent proofs |
### Content Publishing Conveniences
| Tool | Description |

View File

@@ -54,7 +54,6 @@ show_usage() {
echo ""
echo "RELEASE MODE (-r flag):"
echo " - Build static x86_64 and arm64 binaries using build_static.sh"
echo " - Create source tarball"
echo " - Git add, commit, push, and create Gitea release with assets"
echo " - Can be combined with version increment flags"
echo ""
@@ -341,35 +340,11 @@ build_release_binary() {
fi
}
# Function to create source tarball
create_source_tarball() {
print_status "Creating source tarball..."
local tarball_name="didactyl-${NEW_VERSION#v}.tar.gz"
# Create tarball excluding build artifacts and git files
if tar -czf "$tarball_name" \
--exclude='build/*' \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
--exclude='c-relay/*' \
. > /dev/null 2>&1; then
print_success "Created source tarball: $tarball_name"
echo "$tarball_name"
return 0
else
print_error "Failed to create source tarball"
return 1
fi
}
# Function to upload release assets to Gitea
upload_release_assets() {
local release_id="$1"
local binary_x86_path="$2"
local binary_arm64_path="$3"
local tarball_path="$4"
print_status "Uploading release assets..."
@@ -445,19 +420,6 @@ upload_release_assets() {
done
fi
# Upload source tarball
if [[ -f "$tarball_path" ]]; then
print_status "Uploading source tarball: $(basename "$tarball_path")"
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
if echo "$tarball_response" | grep -q '"id"'; then
print_success "Uploaded source tarball successfully"
else
print_warning "Failed to upload source tarball: $tarball_response"
fi
fi
}
# Function to create Gitea release
@@ -564,20 +526,12 @@ main() {
fi
fi
# Create source tarball
local tarball_path=""
if tarball_path=$(create_source_tarball); then
: # tarball_path is set by the function
else
print_warning "Source tarball creation failed, continuing with release creation"
fi
# Create Gitea release
local release_id=""
if release_id=$(create_gitea_release); then
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
if [[ -n "$release_id" && (-n "$binary_x86_path" || -n "$binary_arm64_path" || -n "$tarball_path") ]]; then
upload_release_assets "$release_id" "$binary_x86_path" "$binary_arm64_path" "$tarball_path"
if [[ -n "$release_id" && (-n "$binary_x86_path" || -n "$binary_arm64_path") ]]; then
upload_release_assets "$release_id" "$binary_x86_path" "$binary_arm64_path"
fi
print_success "Release $NEW_VERSION completed successfully!"
else

View File

@@ -0,0 +1,190 @@
# Fix: Cron Triggers Not Firing
## Root Cause Analysis
After tracing the full cron trigger lifecycle in [`src/trigger_manager.c`](src/trigger_manager.c), I identified **three bugs** and **one usability gap** that together explain why cron triggers never fire.
### Bug 1: `last_poll_at` initialized to `time(NULL)` — first poll always skipped
In [`trigger_manager_init()`](src/trigger_manager.c:615):
```c
mgr->last_poll_at = time(NULL);
```
Then in [`trigger_manager_poll()`](src/trigger_manager.c:1263):
```c
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
return 0; // skip
}
```
This means the **first 30 seconds** after init, all cron polls are silently skipped. This is a minor delay, not the primary cause, but it compounds with Bug 2.
**Fix:** Initialize `last_poll_at = 0` so the first poll runs immediately.
### Bug 2: Cron triggers loaded AFTER init — poll window already consumed
The startup sequence in [`src/main.c`](src/main.c:1450) is:
1. `trigger_manager_init()` — sets `last_poll_at = time(NULL)`
2. `trigger_manager_load_from_startup_events()` — loads cron triggers
3. `trigger_manager_load_from_skills()` — loads more cron triggers (after EOSE, async)
4. Main loop starts calling `trigger_manager_poll()`
Because `last_poll_at` is set at init time, and loading happens after init, the first poll after loading may still be within the 30-second window. Combined with Bug 1, this means the first cron evaluation is delayed.
This is not the primary cause either — after 30 seconds, polls should start working. But it contributes to the perception that cron is broken.
### Bug 3 (PRIMARY): Invalid cron expressions — `*` instead of `* * * * *`
The agent's diagnosis shows:
- `infrastructure-monitor` has cron filter `*` — a single asterisk
- `c-relay-memory-watch` has cron filter `0 21,22,23,0,1,2 * * *` — valid 5-field
The [`cron_matches_now()`](src/trigger_manager.c:207) function strictly requires exactly 5 whitespace-separated fields:
```c
if (nf != 5 || tok != NULL) {
return 0; // silently rejects
}
```
A single `*` produces `nf == 1`, which fails the `nf != 5` check. **This is the primary reason `infrastructure-monitor` never fires.**
For `c-relay-memory-watch` with `0 21,22,23,0,1,2 * * *` — this is a valid 5-field expression. The hour field `21,22,23,0,1,2` means hours 21-23 and 0-2 UTC. If the agent was tested outside those hours, it would correctly not fire. However, the agent reports `last_fired: 0` which means it has **never** fired, suggesting either:
- The agent hasn't been running during those hours, OR
- There's a secondary issue with how the expression was stored
### Usability Gap: No cron shorthand support
Standard cron implementations support shorthands like `@hourly`, `@daily`, `@every_5m`. Didactyl only supports raw 5-field expressions. The LLM creating skills may generate `*` thinking it means "every minute" when it should be `* * * * *`.
### Usability Gap: No validation or error logging on invalid cron expressions
When [`cron_matches_now()`](src/trigger_manager.c:223) rejects an expression, it returns 0 silently. There is no log message indicating the expression was invalid. This makes debugging impossible without reading the source code.
Similarly, [`trigger_manager_add()`](src/trigger_manager.c:987) copies the filter to `cron_expr` without validating it:
```c
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
}
```
No validation that `filter_json` is a valid 5-field cron expression.
---
## Fix Plan
### Fix 1: Initialize `last_poll_at` to 0
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:615)
Change:
```c
mgr->last_poll_at = time(NULL);
```
To:
```c
mgr->last_poll_at = 0;
```
This allows the first poll to run immediately after triggers are loaded.
### Fix 2: Add cron expression validation in `trigger_manager_add` and `trigger_manager_update`
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:987)
Add a static validation function:
```c
static int cron_expr_valid(const char* expr) {
if (!expr || expr[0] == '\0') return 0;
char buf[128];
snprintf(buf, sizeof(buf), "%s", expr);
int nf = 0;
char* saveptr = NULL;
char* tok = strtok_r(buf, " \t", &saveptr);
while (tok && nf < 6) { nf++; tok = strtok_r(NULL, " \t", &saveptr); }
return (nf == 5 && tok == NULL) ? 1 : 0;
}
```
Call it in `trigger_manager_add()` and `trigger_manager_update()` when `trigger_type == TRIGGER_TYPE_CRON`, logging a warning if invalid.
### Fix 3: Add cron shorthand expansion
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:207)
Add a helper that expands common shorthands before parsing:
| Shorthand | Expansion |
|-----------|-----------|
| `*` | `* * * * *` |
| `@yearly` / `@annually` | `0 0 1 1 *` |
| `@monthly` | `0 0 1 * *` |
| `@weekly` | `0 0 * * 0` |
| `@daily` / `@midnight` | `0 0 * * *` |
| `@hourly` | `0 * * * *` |
This directly fixes the `infrastructure-monitor` case where `*` should mean "every minute".
### Fix 4: Add debug logging for cron evaluation
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:1253)
Add `DEBUG_INFO` or `DEBUG_WARN` logging in `trigger_manager_poll()` when:
- A cron expression fails to parse — log the expression and skill d_tag
- A cron expression matches — log the match before firing
- The poll is skipped due to the 30-second throttle — log at DEBUG level
### Fix 5: Add `last_cron_fire` and `cron_expr` to status JSON
**File:** [`src/trigger_manager.c`](src/trigger_manager.c:1387)
In `trigger_manager_status_json()`, add the cron-specific fields so the agent can self-diagnose:
```c
if (t->trigger_type == TRIGGER_TYPE_CRON) {
cJSON_AddStringToObject(item, "cron_expr", t->cron_expr);
cJSON_AddNumberToObject(item, "last_cron_fire", (double)t->last_cron_fire);
}
```
---
## Flow After Fix
```mermaid
flowchart TD
INIT[trigger_manager_init - last_poll_at=0] --> LOAD[Load triggers from startup/skills]
LOAD --> POLL[trigger_manager_poll called from main loop]
POLL --> CHECK{now - last_poll_at >= 30?}
CHECK -->|No| SKIP[Return 0 - throttled]
CHECK -->|Yes| ITER[Iterate triggers]
ITER --> CRON{trigger_type == CRON?}
CRON -->|No| NEXT[Next trigger]
CRON -->|Yes| EXPAND[Expand shorthand if needed]
EXPAND --> VALID{Valid 5-field expr?}
VALID -->|No| WARN[Log warning + skip]
VALID -->|Yes| MATCH{cron_matches_now?}
MATCH -->|No| NEXT
MATCH -->|Yes| DEDUP{last_cron_fire < 50s ago?}
DEDUP -->|Yes| NEXT
DEDUP -->|No| FIRE[execute_llm_action]
FIRE --> NEXT
WARN --> NEXT
```
## Files Changed
| File | Change |
|------|--------|
| [`src/trigger_manager.c`](src/trigger_manager.c:615) | Fix `last_poll_at` init to 0 |
| [`src/trigger_manager.c`](src/trigger_manager.c:207) | Add `cron_expand_shorthand()` helper |
| [`src/trigger_manager.c`](src/trigger_manager.c:987) | Add `cron_expr_valid()` validation + warning log |
| [`src/trigger_manager.c`](src/trigger_manager.c:1253) | Add debug logging in poll loop |
| [`src/trigger_manager.c`](src/trigger_manager.c:1387) | Add cron fields to status JSON |

264
plans/nip60_cashu_wallet.md Normal file
View File

@@ -0,0 +1,264 @@
# NIP-60 Cashu Wallet for Didactyl Agents
## Overview
Add a NIP-60 Cashu wallet to every Didactyl agent. The wallet stores its state on Nostr relays (encrypted with the agent's keys) and interacts with Cashu mints over HTTP. The agent manages the wallet autonomously — it can check balances, mint tokens (fund via Lightning), melt tokens (pay Lightning invoices), swap tokens, and persist all state transitions as Nostr events per the NIP-60 spec.
## Available `nostr_core_lib` Primitives
### NIP-60 Event Layer (`nip060.h`)
| Function | Purpose |
|----------|---------|
| `nostr_nip60_create_wallet_event()` | Create kind:17375 wallet event (privkey + mint URLs, NIP-44 encrypted) |
| `nostr_nip60_parse_wallet_event()` | Decrypt and parse wallet event |
| `nostr_nip60_free_wallet_data()` | Free wallet data |
| `nostr_nip60_create_token_event()` | Create kind:7375 token event (proofs, NIP-44 encrypted) |
| `nostr_nip60_parse_token_event()` | Decrypt and parse token event |
| `nostr_nip60_free_token_data()` | Free token data |
| `nostr_nip60_create_token_deletion()` | Create kind:5 deletion for spent tokens |
| `nostr_nip60_create_rollover_token()` | Create new token with remaining proofs + del references |
| `nostr_nip60_create_history_event()` | Create kind:7376 spending history event |
| `nostr_nip60_parse_history_event()` | Decrypt and parse history event |
| `nostr_nip60_free_history_data()` | Free history data |
| `nostr_nip60_create_quote_event()` | Create kind:7374 quote tracking event |
| `nostr_nip60_parse_quote_event()` | Decrypt and parse quote event |
| `nostr_nip60_sum_proofs()` | Sum proof amounts |
| `nostr_nip60_proofs_to_json()` / `nostr_nip60_proofs_from_json()` | Proof serialization |
| `nostr_nip60_create_wallet_filter()` | Build REQ filter for kinds 17375+7375 |
| `nostr_nip60_create_history_filter()` | Build REQ filter for kind 7376 |
### Cashu Mint HTTP Client (`cashu_mint.h`)
| Function | Purpose |
|----------|---------|
| `cashu_mint_get_info()` | GET /v1/info — mint name, pubkey, keysets |
| `cashu_mint_request_mint_quote()` | POST /v1/mint/quote/bolt11 — get Lightning invoice to fund wallet |
| `cashu_mint_check_mint_quote()` | GET /v1/mint/quote/bolt11/{id} — check if invoice paid |
| `cashu_mint_mint_tokens()` | POST /v1/mint/bolt11 — claim tokens after invoice paid |
| `cashu_mint_request_melt_quote()` | POST /v1/melt/quote/bolt11 — get quote to pay Lightning invoice |
| `cashu_mint_check_melt_quote()` | GET /v1/melt/quote/bolt11/{id} — check melt status |
| `cashu_mint_melt_tokens()` | POST /v1/melt/bolt11 — pay Lightning invoice with tokens |
| `cashu_mint_swap()` | POST /v1/swap — swap proofs (split/merge denominations) |
| `cashu_mint_check_proofs_state()` | POST /v1/checkstate — verify proof spendability |
## Architecture
```mermaid
graph TD
A[Agent Main Loop] --> B[Cashu Wallet Manager]
B --> C[In-Memory Wallet State]
C --> D[nostr_core_lib NIP-60 Events]
C --> E[nostr_core_lib Cashu Mint HTTP]
D --> F[Nostr Relays]
E --> G[Cashu Mint Servers]
H[LLM Tool Calls] --> I[cashu_wallet_balance]
H --> J[cashu_wallet_mint_quote]
H --> K[cashu_wallet_mint_claim]
H --> L[cashu_wallet_melt_quote]
H --> M[cashu_wallet_melt_pay]
H --> N[cashu_wallet_info]
H --> O[cashu_wallet_check_proofs]
I --> B
J --> B
K --> B
L --> B
M --> B
N --> B
O --> B
```
### Module Boundaries
#### 1. `src/cashu_wallet.h` / `src/cashu_wallet.c` — Cashu Wallet Manager
The core wallet module that owns in-memory state and orchestrates all operations.
**State:**
- Parsed `nostr_nip60_wallet_data_t` (privkey, mint URLs)
- Array of `nostr_nip60_token_data_t` with their event IDs (unspent proofs)
- Wallet initialized flag
- Mutex for thread safety
**Lifecycle:**
- `cashu_wallet_init(config)` — called during agent startup
- `cashu_wallet_load_from_relays()` — fetch kind:17375 + kind:7375 events, decrypt, populate state
- `cashu_wallet_create_new(mint_urls, mint_count)` — generate wallet privkey, publish kind:17375
- `cashu_wallet_cleanup()` — free all state
**Operations:**
- `cashu_wallet_balance()` — sum all unspent proofs, grouped by mint
- `cashu_wallet_balance_total()` — total across all mints
- `cashu_wallet_mint_quote(mint_url, amount, unit)` — request Lightning invoice from mint
- `cashu_wallet_check_mint_quote(mint_url, quote_id)` — poll quote status
- `cashu_wallet_claim_tokens(mint_url, quote_id, amount)` — mint tokens after invoice paid, publish kind:7375
- `cashu_wallet_melt_quote(mint_url, payment_request, unit)` — get quote to pay LN invoice
- `cashu_wallet_melt_pay(mint_url, quote_id, proofs)` — pay LN invoice, delete spent tokens, rollover change
- `cashu_wallet_swap(mint_url, proofs, target_amounts)` — split/merge denominations
- `cashu_wallet_check_proofs(mint_url)` — validate proof spendability, clean up spent
- `cashu_wallet_get_info(mint_url)` — fetch mint info
**State Persistence (Nostr events):**
- After every balance-changing operation, publish updated kind:7375 token events
- Delete old kind:7375 events via kind:5 when proofs are spent
- Publish kind:7376 history events for audit trail
- Optionally publish kind:7374 quote events for cross-device state
#### 2. `src/tools/tool_cashu_wallet.c` — LLM Tool Interface
Exposes wallet operations as tools the LLM can call.
#### 3. Config additions in `src/config.h` / `src/config.c`
New `cashu_wallet_config_t` section.
## Config Schema Changes
Add to `didactyl_config_t`:
```c
typedef struct {
int enabled; // master switch
char** mint_urls; // default mint URLs
int mint_count;
char unit[16]; // default unit, e.g. "sat"
int auto_load; // load wallet from relays on startup
int mint_timeout_seconds; // HTTP timeout for mint operations
} cashu_wallet_config_t;
```
Example `config.jsonc`:
```jsonc
"cashu_wallet": {
"enabled": true,
"mints": [
"https://mint.minibits.cash",
"https://stablenut.umint.cash"
],
"unit": "sat",
"auto_load": true,
"mint_timeout_seconds": 30
}
```
## Tool Definitions
### `cashu_wallet_balance`
- **Description:** Check the agent wallet balance across all mints
- **Parameters:** none (or optional `mint_url` to filter)
- **Returns:** `{ "total_sats": 1234, "by_mint": [{ "mint": "...", "balance": 500 }] }`
### `cashu_wallet_info`
- **Description:** Get information about a Cashu mint
- **Parameters:** `{ "mint_url": "https://..." }`
- **Returns:** `{ "name": "...", "version": "...", "keysets": [...] }`
### `cashu_wallet_mint_quote`
- **Description:** Request a Lightning invoice to fund the wallet
- **Parameters:** `{ "mint_url": "https://...", "amount": 1000, "unit": "sat" }`
- **Returns:** `{ "quote_id": "...", "payment_request": "lnbc...", "expiry": ... }`
### `cashu_wallet_mint_check`
- **Description:** Check if a mint quote invoice has been paid
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "..." }`
- **Returns:** `{ "paid": true/false, "amount": 1000 }`
### `cashu_wallet_mint_claim`
- **Description:** Claim tokens after a mint quote invoice is paid
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "...", "amount": 1000 }`
- **Returns:** `{ "success": true, "new_balance": 2234 }`
### `cashu_wallet_melt_quote`
- **Description:** Get a quote to pay a Lightning invoice from the wallet
- **Parameters:** `{ "mint_url": "https://...", "payment_request": "lnbc...", "unit": "sat" }`
- **Returns:** `{ "quote_id": "...", "amount": 500, "fee_reserve": 2 }`
### `cashu_wallet_melt_pay`
- **Description:** Pay a Lightning invoice using wallet tokens
- **Parameters:** `{ "mint_url": "https://...", "quote_id": "..." }`
- **Returns:** `{ "success": true, "paid": true, "new_balance": 1732 }`
### `cashu_wallet_check_proofs`
- **Description:** Validate that stored proofs are still spendable
- **Parameters:** `{ "mint_url": "https://..." }` (optional, checks all mints if omitted)
- **Returns:** `{ "valid": 15, "spent": 2, "cleaned": true }`
## Phased Implementation Plan
### Phase 1: Cashu Wallet Core Module
- [ ] Create `src/cashu_wallet.h` with wallet state structs and function declarations
- [ ] Create `src/cashu_wallet.c` with:
- [ ] `cashu_wallet_init()` / `cashu_wallet_cleanup()` lifecycle
- [ ] In-memory state: wallet data + token array with event IDs
- [ ] Mutex protection for thread safety
- [ ] `cashu_wallet_create_new()` — generate random privkey, build `nostr_nip60_wallet_data_t`, publish kind:17375 via `nostr_handler_publish_kind_event()`
- [ ] `cashu_wallet_load_from_relays()` — use `nostr_nip60_create_wallet_filter()` + `nostr_handler_query_json()` to fetch and parse wallet + token events
- [ ] `cashu_wallet_balance()` / `cashu_wallet_balance_total()` — iterate in-memory tokens, call `nostr_nip60_sum_proofs()`
### Phase 2: Mint Interaction (Fund & Pay)
- [ ] Add to `src/cashu_wallet.c`:
- [ ] `cashu_wallet_mint_quote()` — call `cashu_mint_request_mint_quote()`
- [ ] `cashu_wallet_check_mint_quote()` — call `cashu_mint_check_mint_quote()`
- [ ] `cashu_wallet_claim_tokens()` — call `cashu_mint_mint_tokens()`, parse response proofs via `nostr_nip60_proofs_from_json()`, create and publish kind:7375 token event, publish kind:7376 history
- [ ] `cashu_wallet_melt_quote()` — call `cashu_mint_request_melt_quote()`
- [ ] `cashu_wallet_melt_pay()` — select proofs to spend, call `cashu_mint_melt_tokens()`, delete spent token events via `nostr_nip60_create_token_deletion()`, rollover change via `nostr_nip60_create_rollover_token()`, publish kind:7376 history
- [ ] `cashu_wallet_swap()` — call `cashu_mint_swap()`, update token events
- [ ] `cashu_wallet_check_proofs()` — call `cashu_mint_check_proofs_state()`, clean up spent proofs
- [ ] `cashu_wallet_get_info()` — call `cashu_mint_get_info()`
### Phase 3: Config & Startup Integration
- [ ] Add `cashu_wallet_config_t` to `src/config.h`
- [ ] Parse `"cashu_wallet"` section in `src/config.c`
- [ ] Call `cashu_wallet_init()` from `src/main.c` after `agent_init()`
- [ ] If `auto_load` is true, call `cashu_wallet_load_from_relays()` during startup
- [ ] If no wallet event found and mints configured, call `cashu_wallet_create_new()`
- [ ] Call `cashu_wallet_cleanup()` in shutdown path
- [ ] Add `src/cashu_wallet.c` to `Makefile` SRCS
### Phase 4: LLM Tools
- [ ] Create `src/tools/tool_cashu_wallet.c` implementing all 8 tool execute functions
- [ ] Add tool declarations to `src/tools/tools_internal.h`
- [ ] Register tools in `src/tools/tools_dispatch.c`
- [ ] Add JSON schemas in `src/tools/tools_schema.c`
- [ ] Add `src/tools/tool_cashu_wallet.c` to `Makefile` SRCS
### Phase 5: Testing & Validation
- [ ] Unit test: create wallet, publish, reload from relay, verify state
- [ ] Integration test: mint quote → claim → check balance → melt quote → pay
- [ ] Test proof rollover: spend partial proofs, verify deletion + new token event
- [ ] Test startup: fresh agent creates wallet; existing agent loads wallet
- [ ] Test error handling: mint offline, invalid proofs, insufficient balance
## Key Design Decisions
1. **Wallet privkey is NOT the agent's Nostr privkey** — per NIP-60 spec, a separate privkey is generated and stored encrypted in the kind:17375 event. This privkey is used for P2PK ecash locking (future NIP-61 support).
2. **State lives on relays** — the in-memory state is a cache. On startup the agent fetches its wallet state from relays. After every mutation, updated events are published back.
3. **Proof selection for spending** — when melting, the wallet needs to select proofs that sum to at least the required amount. Implement a simple greedy algorithm (largest-first) with change output via swap.
4. **Thread safety** — wallet operations are called from the LLM tool loop which may run concurrently with triggered skills. A mutex protects all state mutations.
5. **No NIP-61 (nutzaps) in this phase** — receiving/sending nutzaps is a separate feature that builds on top of this wallet foundation.
## File Changes Summary
| File | Change |
|------|--------|
| `src/cashu_wallet.h` | **NEW** — cashu wallet manager header |
| `src/cashu_wallet.c` | **NEW** — cashu wallet manager implementation |
| `src/tools/tool_cashu_wallet.c` | **NEW** — LLM tool implementations |
| `src/config.h` | Add `cashu_wallet_config_t` to `didactyl_config_t` |
| `src/config.c` | Parse `"cashu_wallet"` config section |
| `src/main.c` | Call `cashu_wallet_init()` / `cashu_wallet_cleanup()` |
| `src/tools/tools_internal.h` | Declare `execute_cashu_wallet_*` functions |
| `src/tools/tools_dispatch.c` | Register cashu wallet tool dispatch entries |
| `src/tools/tools_schema.c` | Add cashu wallet tool JSON schemas |
| `Makefile` | Add `cashu_wallet.c` and `tool_cashu_wallet.c` to SRCS |

989
src/cashu_wallet.c Normal file
View File

@@ -0,0 +1,989 @@
#define _POSIX_C_SOURCE 200809L
#include "cashu_wallet.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#include "debug.h"
#include "nostr_handler.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
#define CASHU_WALLET_QUERY_TIMEOUT_MS 6000
#define CASHU_WALLET_MAX_SPLIT_PARTS 64
typedef struct {
char event_id[65];
nostr_nip60_token_data_t token;
} wallet_token_entry_t;
typedef struct {
didactyl_config_t* cfg;
int initialized;
int wallet_loaded;
pthread_mutex_t mutex;
nostr_nip60_wallet_data_t wallet;
wallet_token_entry_t* tokens;
int token_count;
} cashu_wallet_state_t;
static cashu_wallet_state_t g_wallet = {0};
static void wallet_clear_tokens_locked(void) {
if (!g_wallet.tokens) {
g_wallet.token_count = 0;
return;
}
for (int i = 0; i < g_wallet.token_count; i++) {
nostr_nip60_free_token_data(&g_wallet.tokens[i].token);
}
free(g_wallet.tokens);
g_wallet.tokens = NULL;
g_wallet.token_count = 0;
}
static void wallet_clear_all_locked(void) {
wallet_clear_tokens_locked();
nostr_nip60_free_wallet_data(&g_wallet.wallet);
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
g_wallet.wallet_loaded = 0;
}
static int wallet_append_token_locked(const char* event_id, nostr_nip60_token_data_t* token) {
if (!token) return -1;
wallet_token_entry_t* grown =
(wallet_token_entry_t*)realloc(g_wallet.tokens, (size_t)(g_wallet.token_count + 1) * sizeof(wallet_token_entry_t));
if (!grown) {
return -1;
}
g_wallet.tokens = grown;
wallet_token_entry_t* dst = &g_wallet.tokens[g_wallet.token_count];
memset(dst, 0, sizeof(*dst));
if (event_id && event_id[0] != '\0') {
snprintf(dst->event_id, sizeof(dst->event_id), "%s", event_id);
}
dst->token = *token;
memset(token, 0, sizeof(*token));
g_wallet.token_count++;
return 0;
}
static int publish_from_signed_event_template(cJSON* ev_template, char out_event_id[65]) {
if (!ev_template) return -1;
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev_template, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev_template, "content");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(ev_template, "tags");
if (!kind || !cJSON_IsNumber(kind) || !content || !cJSON_IsString(content)) {
return -1;
}
cJSON* tags_dup = tags ? cJSON_Duplicate(tags, 1) : cJSON_CreateArray();
if (!tags_dup) return -1;
nostr_publish_result_t result;
memset(&result, 0, sizeof(result));
int rc = nostr_handler_publish_kind_event((int)kind->valuedouble,
content->valuestring ? content->valuestring : "",
tags_dup,
&result);
cJSON_Delete(tags_dup);
if (rc != 0) {
nostr_handler_publish_result_free(&result);
return -1;
}
if (out_event_id) {
snprintf(out_event_id, 65, "%s", result.event_id);
}
nostr_handler_publish_result_free(&result);
return 0;
}
static int publish_wallet_event_and_store_locked(const nostr_nip60_wallet_data_t* wallet_data, char out_event_id[65]) {
cJSON* ev = nostr_nip60_create_wallet_event(wallet_data, g_wallet.cfg->keys.private_key, time(NULL));
if (!ev) return -1;
int rc = publish_from_signed_event_template(ev, out_event_id);
cJSON_Delete(ev);
if (rc != 0) return -1;
nostr_nip60_free_wallet_data(&g_wallet.wallet);
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
g_wallet.wallet.privkey[0] = '\0';
snprintf(g_wallet.wallet.privkey, sizeof(g_wallet.wallet.privkey), "%s", wallet_data->privkey);
g_wallet.wallet.mint_count = wallet_data->mint_count;
if (wallet_data->mint_count > 0) {
g_wallet.wallet.mint_urls = (char**)calloc((size_t)wallet_data->mint_count, sizeof(char*));
if (!g_wallet.wallet.mint_urls) {
return -1;
}
for (int i = 0; i < wallet_data->mint_count; i++) {
g_wallet.wallet.mint_urls[i] = strdup(wallet_data->mint_urls[i] ? wallet_data->mint_urls[i] : "");
if (!g_wallet.wallet.mint_urls[i]) {
nostr_nip60_free_wallet_data(&g_wallet.wallet);
memset(&g_wallet.wallet, 0, sizeof(g_wallet.wallet));
return -1;
}
}
}
g_wallet.wallet_loaded = 1;
return 0;
}
static int publish_token_event_and_store_locked(nostr_nip60_token_data_t* token_data, char out_event_id[65]) {
if (!token_data) return -1;
cJSON* ev = nostr_nip60_create_token_event(token_data, g_wallet.cfg->keys.private_key, time(NULL));
if (!ev) return -1;
char event_id[65] = {0};
int rc = publish_from_signed_event_template(ev, event_id);
cJSON_Delete(ev);
if (rc != 0) {
return -1;
}
if (wallet_append_token_locked(event_id, token_data) != 0) {
return -1;
}
if (out_event_id) {
snprintf(out_event_id, 65, "%s", event_id);
}
return 0;
}
static int publish_history_event(nostr_nip60_direction_t direction,
uint64_t amount,
const char* primary_event_id,
nostr_nip60_ref_marker_t marker) {
nostr_nip60_history_ref_t ref;
memset(&ref, 0, sizeof(ref));
if (primary_event_id && primary_event_id[0] != '\0') {
snprintf(ref.event_id, sizeof(ref.event_id), "%s", primary_event_id);
}
ref.marker = marker;
nostr_nip60_history_data_t history;
memset(&history, 0, sizeof(history));
history.direction = direction;
history.amount = amount;
history.refs = &ref;
history.ref_count = (ref.event_id[0] != '\0') ? 1 : 0;
cJSON* ev = nostr_nip60_create_history_event(&history, g_wallet.cfg->keys.private_key, time(NULL));
if (!ev) return -1;
int rc = publish_from_signed_event_template(ev, NULL);
cJSON_Delete(ev);
return rc;
}
static int publish_token_deletion_event(const char* event_id) {
cJSON* del = nostr_nip60_create_token_deletion(event_id, g_wallet.cfg->keys.private_key, time(NULL));
if (!del) return -1;
int rc = publish_from_signed_event_template(del, NULL);
cJSON_Delete(del);
return rc;
}
static const char* resolve_mint_url_locked(const char* mint_url) {
if (mint_url && mint_url[0] != '\0') return mint_url;
if (g_wallet.wallet_loaded && g_wallet.wallet.mint_count > 0 && g_wallet.wallet.mint_urls && g_wallet.wallet.mint_urls[0]) {
return g_wallet.wallet.mint_urls[0];
}
if (g_wallet.cfg && g_wallet.cfg->cashu_wallet.mint_count > 0 && g_wallet.cfg->cashu_wallet.mint_urls && g_wallet.cfg->cashu_wallet.mint_urls[0]) {
return g_wallet.cfg->cashu_wallet.mint_urls[0];
}
return NULL;
}
static int token_proofs_total_for_mint_locked(const char* mint_url, uint64_t* out_total) {
if (!out_total) return -1;
uint64_t total = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
const wallet_token_entry_t* entry = &g_wallet.tokens[i];
if (!entry->token.mint_url) continue;
if (mint_url && mint_url[0] != '\0' && strcmp(entry->token.mint_url, mint_url) != 0) continue;
total += nostr_nip60_sum_proofs(entry->token.proofs, entry->token.proof_count);
}
*out_total = total;
return 0;
}
int cashu_wallet_init(didactyl_config_t* cfg) {
if (!cfg) return -1;
if (!g_wallet.initialized) {
if (pthread_mutex_init(&g_wallet.mutex, NULL) != 0) {
return -1;
}
g_wallet.initialized = 1;
}
pthread_mutex_lock(&g_wallet.mutex);
g_wallet.cfg = cfg;
wallet_clear_all_locked();
pthread_mutex_unlock(&g_wallet.mutex);
return 0;
}
void cashu_wallet_cleanup(void) {
if (!g_wallet.initialized) return;
pthread_mutex_lock(&g_wallet.mutex);
wallet_clear_all_locked();
g_wallet.cfg = NULL;
pthread_mutex_unlock(&g_wallet.mutex);
pthread_mutex_destroy(&g_wallet.mutex);
memset(&g_wallet, 0, sizeof(g_wallet));
}
int cashu_wallet_create_new_from_config(void) {
if (!g_wallet.initialized || !g_wallet.cfg) return -1;
if (!g_wallet.cfg->cashu_wallet.enabled) return -1;
pthread_mutex_lock(&g_wallet.mutex);
if (!g_wallet.cfg->cashu_wallet.mint_urls || g_wallet.cfg->cashu_wallet.mint_count <= 0) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
unsigned char priv[32] = {0};
unsigned char pub[32] = {0};
if (nostr_generate_keypair(priv, pub) != NOSTR_SUCCESS) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
nostr_nip60_wallet_data_t wallet_data;
memset(&wallet_data, 0, sizeof(wallet_data));
nostr_bytes_to_hex(priv, 32, wallet_data.privkey);
wallet_data.mint_count = g_wallet.cfg->cashu_wallet.mint_count;
wallet_data.mint_urls = (char**)calloc((size_t)wallet_data.mint_count, sizeof(char*));
if (!wallet_data.mint_urls) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
for (int i = 0; i < wallet_data.mint_count; i++) {
wallet_data.mint_urls[i] = strdup(g_wallet.cfg->cashu_wallet.mint_urls[i]);
if (!wallet_data.mint_urls[i]) {
nostr_nip60_free_wallet_data(&wallet_data);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
}
int rc = publish_wallet_event_and_store_locked(&wallet_data, NULL);
nostr_nip60_free_wallet_data(&wallet_data);
pthread_mutex_unlock(&g_wallet.mutex);
return rc;
}
int cashu_wallet_load_from_relays(void) {
if (!g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
cJSON* filter = nostr_nip60_create_wallet_filter(g_wallet.cfg->keys.public_key_hex);
if (!filter) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
char* events_json = nostr_handler_query_json(filter, CASHU_WALLET_QUERY_TIMEOUT_MS);
cJSON_Delete(filter);
if (!events_json) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cJSON* arr = cJSON_Parse(events_json);
free(events_json);
if (!arr || !cJSON_IsArray(arr)) {
cJSON_Delete(arr);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
nostr_nip60_wallet_data_t newest_wallet;
memset(&newest_wallet, 0, sizeof(newest_wallet));
int have_wallet = 0;
double newest_created_at = -1;
int n = cJSON_GetArraySize(arr);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(arr, i);
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* created_at = ev ? cJSON_GetObjectItemCaseSensitive(ev, "created_at") : NULL;
if (!kind || !cJSON_IsNumber(kind) || !created_at || !cJSON_IsNumber(created_at)) continue;
if ((int)kind->valuedouble != NOSTR_NIP60_WALLET_KIND) continue;
nostr_nip60_wallet_data_t parsed;
memset(&parsed, 0, sizeof(parsed));
if (nostr_nip60_parse_wallet_event(ev, g_wallet.cfg->keys.private_key, &parsed) != 0) {
continue;
}
if (!have_wallet || created_at->valuedouble > newest_created_at) {
nostr_nip60_free_wallet_data(&newest_wallet);
newest_wallet = parsed;
newest_created_at = created_at->valuedouble;
have_wallet = 1;
} else {
nostr_nip60_free_wallet_data(&parsed);
}
}
wallet_clear_all_locked();
if (!have_wallet) {
cJSON_Delete(arr);
pthread_mutex_unlock(&g_wallet.mutex);
return NOSTR_ERROR_NIP60_INVALID_WALLET;
}
g_wallet.wallet = newest_wallet;
g_wallet.wallet_loaded = 1;
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(arr, i);
cJSON* kind = ev ? cJSON_GetObjectItemCaseSensitive(ev, "kind") : NULL;
cJSON* id = ev ? cJSON_GetObjectItemCaseSensitive(ev, "id") : NULL;
if (!kind || !cJSON_IsNumber(kind) || !id || !cJSON_IsString(id) || !id->valuestring) continue;
if ((int)kind->valuedouble != NOSTR_NIP60_TOKEN_KIND) continue;
nostr_nip60_token_data_t token;
memset(&token, 0, sizeof(token));
if (nostr_nip60_parse_token_event(ev, g_wallet.cfg->keys.private_key, &token) != 0) {
continue;
}
if (wallet_append_token_locked(id->valuestring, &token) != 0) {
nostr_nip60_free_token_data(&token);
cJSON_Delete(arr);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
}
cJSON_Delete(arr);
pthread_mutex_unlock(&g_wallet.mutex);
return 0;
}
int cashu_wallet_balance_json(cJSON** out_json) {
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
cJSON* root = cJSON_CreateObject();
cJSON* by_mint = cJSON_CreateArray();
if (!root || !by_mint) {
cJSON_Delete(root);
cJSON_Delete(by_mint);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cJSON_AddBoolToObject(root, "success", 1);
uint64_t total = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
uint64_t amount = nostr_nip60_sum_proofs(g_wallet.tokens[i].token.proofs, g_wallet.tokens[i].token.proof_count);
total += amount;
cJSON* item = cJSON_CreateObject();
if (!item) continue;
cJSON_AddStringToObject(item, "mint", g_wallet.tokens[i].token.mint_url ? g_wallet.tokens[i].token.mint_url : "");
cJSON_AddNumberToObject(item, "amount", (double)amount);
cJSON_AddStringToObject(item, "token_event_id", g_wallet.tokens[i].event_id);
cJSON_AddItemToArray(by_mint, item);
}
cJSON_AddNumberToObject(root, "total", (double)total);
cJSON_AddItemToObject(root, "entries", by_mint);
cJSON_AddBoolToObject(root, "wallet_loaded", g_wallet.wallet_loaded ? 1 : 0);
*out_json = root;
pthread_mutex_unlock(&g_wallet.mutex);
return 0;
}
int cashu_wallet_info_json(const char* mint_url, cJSON** out_json) {
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_mint_info_t info;
memset(&info, 0, sizeof(info));
if (cashu_mint_get_info(resolved, &info, timeout) != 0) {
return -1;
}
cJSON* root = cJSON_CreateObject();
cJSON* keysets = cJSON_CreateArray();
if (!root || !keysets) {
cJSON_Delete(root);
cJSON_Delete(keysets);
cashu_mint_free_info(&info);
return -1;
}
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "name", info.name ? info.name : "");
cJSON_AddStringToObject(root, "pubkey", info.pubkey ? info.pubkey : "");
cJSON_AddStringToObject(root, "version", info.version ? info.version : "");
for (int i = 0; i < info.keyset_count; i++) {
cJSON* ks = cJSON_CreateObject();
if (!ks) continue;
cJSON_AddStringToObject(ks, "id", info.keysets[i].id);
cJSON_AddStringToObject(ks, "unit", info.keysets[i].unit);
cJSON_AddBoolToObject(ks, "active", info.keysets[i].active ? 1 : 0);
cJSON_AddItemToArray(keysets, ks);
}
cJSON_AddItemToObject(root, "keysets", keysets);
*out_json = root;
cashu_mint_free_info(&info);
return 0;
}
int cashu_wallet_mint_quote_json(const char* mint_url, uint64_t amount, const char* unit, cJSON** out_json) {
if (!out_json || amount == 0 || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
const char* resolved_unit = (unit && unit[0] != '\0') ? unit :
(g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat");
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_mint_quote_t quote;
memset(&quote, 0, sizeof(quote));
if (cashu_mint_request_mint_quote(resolved, amount, resolved_unit, &quote, timeout) != 0) {
return -1;
}
cJSON* root = cJSON_CreateObject();
if (!root) return -1;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "unit", resolved_unit);
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
cJSON_AddStringToObject(root, "payment_request", quote.payment_request);
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
*out_json = root;
return 0;
}
int cashu_wallet_mint_check_json(const char* mint_url, const char* quote_id, cJSON** out_json) {
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_mint_quote_t quote;
memset(&quote, 0, sizeof(quote));
if (cashu_mint_check_mint_quote(resolved, quote_id, &quote, timeout) != 0) {
return -1;
}
cJSON* root = cJSON_CreateObject();
if (!root) return -1;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
cJSON_AddStringToObject(root, "payment_request", quote.payment_request);
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
*out_json = root;
return 0;
}
int cashu_wallet_mint_claim_json(const char* mint_url, const char* quote_id, uint64_t amount, cJSON** out_json) {
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
const char* unit = g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat";
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_mint_quote_t quote;
memset(&quote, 0, sizeof(quote));
if (cashu_mint_check_mint_quote(resolved, quote_id, &quote, timeout) != 0) {
return -1;
}
if (!quote.paid) {
return NOSTR_ERROR_CASHU_QUOTE_NOT_PAID;
}
uint64_t claim_amount = amount > 0 ? amount : quote.amount;
if (claim_amount == 0) return -1;
cashu_mint_info_t info;
memset(&info, 0, sizeof(info));
if (cashu_mint_get_info(resolved, &info, timeout) != 0) {
return -1;
}
cashu_keyset_t keyset;
memset(&keyset, 0, sizeof(keyset));
if (cashu_mint_get_active_keyset(&info, unit, &keyset) != 0) {
cashu_mint_free_info(&info);
return -1;
}
cashu_mint_free_info(&info);
uint64_t parts[CASHU_WALLET_MAX_SPLIT_PARTS] = {0};
int part_count = 0;
if (cashu_mint_plan_split_amounts(claim_amount, parts, CASHU_WALLET_MAX_SPLIT_PARTS, &part_count) != 0 || part_count <= 0) {
return -1;
}
cashu_blinded_output_t* outputs = (cashu_blinded_output_t*)calloc((size_t)part_count, sizeof(cashu_blinded_output_t));
if (!outputs) return -1;
for (int i = 0; i < part_count; i++) {
snprintf(outputs[i].id, sizeof(outputs[i].id), "%s", keyset.id);
outputs[i].amount = parts[i];
snprintf(outputs[i].B_, sizeof(outputs[i].B_), "placeholder_%d", i + 1);
}
cashu_mint_tokens_request_t req;
memset(&req, 0, sizeof(req));
snprintf(req.quote_id, sizeof(req.quote_id), "%s", quote_id);
snprintf(req.unit, sizeof(req.unit), "%s", unit);
req.outputs = outputs;
req.output_count = part_count;
cJSON* request_body = NULL;
int rc = cashu_build_mint_tokens_request(&req, &request_body);
free(outputs);
if (rc != 0 || !request_body) {
cJSON_Delete(request_body);
return -1;
}
cJSON* response_body = NULL;
rc = cashu_mint_mint_tokens(resolved, request_body, &response_body, timeout);
cJSON_Delete(request_body);
if (rc != 0 || !response_body) {
cJSON_Delete(response_body);
return -1;
}
cashu_mint_tokens_response_t parsed;
memset(&parsed, 0, sizeof(parsed));
rc = cashu_parse_mint_tokens_response(response_body, &parsed);
cJSON_Delete(response_body);
if (rc != 0 || parsed.signature_count <= 0) {
cashu_mint_free_mint_tokens_response(&parsed);
return -1;
}
nostr_nip60_token_data_t token;
memset(&token, 0, sizeof(token));
token.mint_url = strdup(resolved);
token.proof_count = parsed.signature_count;
token.proofs = (nostr_cashu_proof_t*)calloc((size_t)token.proof_count, sizeof(nostr_cashu_proof_t));
if (!token.mint_url || !token.proofs) {
nostr_nip60_free_token_data(&token);
cashu_mint_free_mint_tokens_response(&parsed);
return -1;
}
for (int i = 0; i < parsed.signature_count; i++) {
snprintf(token.proofs[i].id, sizeof(token.proofs[i].id), "%s", parsed.signatures[i].id);
token.proofs[i].amount = parsed.signatures[i].amount;
token.proofs[i].secret = strdup(parsed.signatures[i].C_);
token.proofs[i].C = strdup(parsed.signatures[i].C_);
if (!token.proofs[i].secret || !token.proofs[i].C) {
nostr_nip60_free_token_data(&token);
cashu_mint_free_mint_tokens_response(&parsed);
return -1;
}
}
cashu_mint_free_mint_tokens_response(&parsed);
char token_event_id[65] = {0};
pthread_mutex_lock(&g_wallet.mutex);
rc = publish_token_event_and_store_locked(&token, token_event_id);
pthread_mutex_unlock(&g_wallet.mutex);
if (rc != 0) {
nostr_nip60_free_token_data(&token);
return -1;
}
(void)publish_history_event(NOSTR_NIP60_DIRECTION_IN,
claim_amount,
token_event_id,
NOSTR_NIP60_REF_CREATED);
cJSON* root = cJSON_CreateObject();
if (!root) return -1;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "quote_id", quote_id);
cJSON_AddNumberToObject(root, "claimed_amount", (double)claim_amount);
cJSON_AddStringToObject(root, "token_event_id", token_event_id);
*out_json = root;
return 0;
}
int cashu_wallet_melt_quote_json(const char* mint_url, const char* payment_request, const char* unit, cJSON** out_json) {
if (!out_json || !payment_request || payment_request[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
const char* resolved_unit = (unit && unit[0] != '\0') ? unit :
(g_wallet.cfg->cashu_wallet.unit[0] ? g_wallet.cfg->cashu_wallet.unit : "sat");
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_melt_quote_t quote;
memset(&quote, 0, sizeof(quote));
if (cashu_mint_request_melt_quote(resolved, payment_request, resolved_unit, &quote, timeout) != 0) {
return -1;
}
cJSON* root = cJSON_CreateObject();
if (!root) return -1;
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "quote_id", quote.quote_id);
cJSON_AddNumberToObject(root, "amount", (double)quote.amount);
cJSON_AddNumberToObject(root, "fee_reserve", (double)quote.fee_reserve);
cJSON_AddBoolToObject(root, "paid", quote.paid ? 1 : 0);
cJSON_AddNumberToObject(root, "expiry", (double)quote.expiry);
*out_json = root;
return 0;
}
int cashu_wallet_melt_pay_json(const char* mint_url, const char* quote_id, cJSON** out_json) {
if (!out_json || !quote_id || quote_id[0] == '\0' || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
cashu_melt_quote_t quote;
memset(&quote, 0, sizeof(quote));
if (cashu_mint_check_melt_quote(resolved, quote_id, &quote, timeout) != 0) {
return -1;
}
uint64_t required = quote.amount + quote.fee_reserve;
if (required == 0) return -1;
pthread_mutex_lock(&g_wallet.mutex);
int proof_count = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
proof_count += g_wallet.tokens[i].token.proof_count;
}
if (proof_count <= 0) {
pthread_mutex_unlock(&g_wallet.mutex);
return NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS;
}
nostr_cashu_proof_t* flattened = (nostr_cashu_proof_t*)calloc((size_t)proof_count, sizeof(nostr_cashu_proof_t));
int* proof_to_token = (int*)calloc((size_t)proof_count, sizeof(int));
if (!flattened || !proof_to_token) {
free(flattened);
free(proof_to_token);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
int idx = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
for (int j = 0; j < g_wallet.tokens[i].token.proof_count; j++) {
flattened[idx] = g_wallet.tokens[i].token.proofs[j];
proof_to_token[idx] = i;
idx++;
}
}
int* selected = (int*)calloc((size_t)proof_count, sizeof(int));
uint64_t selected_total = 0;
int selected_count = cashu_mint_select_proofs_for_amount(flattened,
proof_count,
required,
selected,
proof_count,
&selected_total);
if (selected_count <= 0) {
free(selected);
free(proof_to_token);
free(flattened);
pthread_mutex_unlock(&g_wallet.mutex);
return NOSTR_ERROR_NIP60_INSUFFICIENT_FUNDS;
}
nostr_cashu_proof_t* melt_inputs = (nostr_cashu_proof_t*)calloc((size_t)selected_count, sizeof(nostr_cashu_proof_t));
int* used_token_flags = (int*)calloc((size_t)g_wallet.token_count, sizeof(int));
if (!melt_inputs || !used_token_flags) {
free(melt_inputs);
free(used_token_flags);
free(selected);
free(proof_to_token);
free(flattened);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
for (int i = 0; i < selected_count; i++) {
int pidx = selected[i];
melt_inputs[i] = flattened[pidx];
used_token_flags[proof_to_token[pidx]] = 1;
}
free(selected);
free(proof_to_token);
free(flattened);
cashu_melt_tokens_request_t req;
memset(&req, 0, sizeof(req));
snprintf(req.quote_id, sizeof(req.quote_id), "%s", quote_id);
req.inputs = melt_inputs;
req.input_count = selected_count;
cJSON* request_body = NULL;
int rc = cashu_build_melt_tokens_request(&req, &request_body);
free(melt_inputs);
if (rc != 0 || !request_body) {
free(used_token_flags);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cJSON* response_body = NULL;
rc = cashu_mint_melt_tokens(resolved, request_body, &response_body, timeout);
cJSON_Delete(request_body);
if (rc != 0 || !response_body) {
free(used_token_flags);
cJSON_Delete(response_body);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cashu_melt_tokens_response_t melt;
memset(&melt, 0, sizeof(melt));
rc = cashu_parse_melt_tokens_response(response_body, &melt);
cJSON_Delete(response_body);
if (rc != 0) {
free(used_token_flags);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
int deleted_count = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (!used_token_flags[i]) continue;
if (g_wallet.tokens[i].event_id[0] != '\0') {
(void)publish_token_deletion_event(g_wallet.tokens[i].event_id);
deleted_count++;
}
nostr_nip60_free_token_data(&g_wallet.tokens[i].token);
memset(&g_wallet.tokens[i], 0, sizeof(g_wallet.tokens[i]));
}
int compact = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (g_wallet.tokens[i].event_id[0] == '\0' && !g_wallet.tokens[i].token.mint_url) continue;
if (compact != i) g_wallet.tokens[compact] = g_wallet.tokens[i];
compact++;
}
g_wallet.token_count = compact;
if (g_wallet.token_count == 0) {
free(g_wallet.tokens);
g_wallet.tokens = NULL;
}
uint64_t remaining = 0;
token_proofs_total_for_mint_locked(resolved, &remaining);
cJSON* root = cJSON_CreateObject();
if (!root) {
free(used_token_flags);
cashu_mint_free_melt_tokens_response(&melt);
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddStringToObject(root, "quote_id", quote_id);
cJSON_AddBoolToObject(root, "paid", melt.paid ? 1 : 0);
cJSON_AddNumberToObject(root, "deleted_token_events", deleted_count);
cJSON_AddNumberToObject(root, "remaining_balance", (double)remaining);
if (melt.payment_preimage && melt.payment_preimage[0] != '\0') {
cJSON_AddStringToObject(root, "payment_preimage", melt.payment_preimage);
}
*out_json = root;
(void)publish_history_event(NOSTR_NIP60_DIRECTION_OUT,
required,
NULL,
NOSTR_NIP60_REF_DESTROYED);
free(used_token_flags);
cashu_mint_free_melt_tokens_response(&melt);
pthread_mutex_unlock(&g_wallet.mutex);
return 0;
}
int cashu_wallet_check_proofs_json(const char* mint_url, cJSON** out_json) {
if (!out_json || !g_wallet.initialized || !g_wallet.cfg) return -1;
pthread_mutex_lock(&g_wallet.mutex);
const char* resolved = resolve_mint_url_locked(mint_url);
int timeout = g_wallet.cfg->cashu_wallet.mint_timeout_seconds > 0 ? g_wallet.cfg->cashu_wallet.mint_timeout_seconds : 30;
pthread_mutex_unlock(&g_wallet.mutex);
if (!resolved) return -1;
pthread_mutex_lock(&g_wallet.mutex);
int target_proofs = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
target_proofs += g_wallet.tokens[i].token.proof_count;
}
if (target_proofs <= 0) {
cJSON* root = cJSON_CreateObject();
if (!root) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddNumberToObject(root, "proof_count", 0);
cJSON_AddNumberToObject(root, "spent_count", 0);
cJSON_AddNumberToObject(root, "unknown_count", 0);
*out_json = root;
pthread_mutex_unlock(&g_wallet.mutex);
return 0;
}
const char** ys = (const char**)calloc((size_t)target_proofs, sizeof(char*));
if (!ys) {
pthread_mutex_unlock(&g_wallet.mutex);
return -1;
}
int y_idx = 0;
for (int i = 0; i < g_wallet.token_count; i++) {
if (!g_wallet.tokens[i].token.mint_url || strcmp(g_wallet.tokens[i].token.mint_url, resolved) != 0) continue;
for (int j = 0; j < g_wallet.tokens[i].token.proof_count; j++) {
ys[y_idx++] = g_wallet.tokens[i].token.proofs[j].C ? g_wallet.tokens[i].token.proofs[j].C : "";
}
}
pthread_mutex_unlock(&g_wallet.mutex);
cJSON* request_body = NULL;
if (cashu_build_checkstate_request(ys, y_idx, &request_body) != 0 || !request_body) {
free(ys);
cJSON_Delete(request_body);
return -1;
}
cJSON* response_body = NULL;
int rc = cashu_mint_check_proofs_state(resolved, request_body, &response_body, timeout);
cJSON_Delete(request_body);
free(ys);
if (rc != 0 || !response_body) {
cJSON_Delete(response_body);
return -1;
}
cashu_checkstate_response_t parsed;
memset(&parsed, 0, sizeof(parsed));
rc = cashu_parse_checkstate_response(response_body, &parsed);
cJSON_Delete(response_body);
if (rc != 0) {
return -1;
}
int spent_count = 0;
int unknown_count = 0;
for (int i = 0; i < parsed.state_count; i++) {
if (strcasecmp(parsed.states[i].state, "SPENT") == 0) {
spent_count++;
} else if (strcasecmp(parsed.states[i].state, "UNSPENT") != 0) {
unknown_count++;
}
}
cJSON* root = cJSON_CreateObject();
if (!root) {
cashu_mint_free_checkstate_response(&parsed);
return -1;
}
cJSON_AddBoolToObject(root, "success", 1);
cJSON_AddStringToObject(root, "mint", resolved);
cJSON_AddNumberToObject(root, "proof_count", y_idx);
cJSON_AddNumberToObject(root, "spent_count", spent_count);
cJSON_AddNumberToObject(root, "unknown_count", unknown_count);
*out_json = root;
cashu_mint_free_checkstate_response(&parsed);
return 0;
}

24
src/cashu_wallet.h Normal file
View File

@@ -0,0 +1,24 @@
#ifndef DIDACTYL_CASHU_WALLET_H
#define DIDACTYL_CASHU_WALLET_H
#include <stdint.h>
#include "config.h"
#include "cjson/cJSON.h"
int cashu_wallet_init(didactyl_config_t* cfg);
void cashu_wallet_cleanup(void);
int cashu_wallet_load_from_relays(void);
int cashu_wallet_create_new_from_config(void);
int cashu_wallet_balance_json(cJSON** out_json);
int cashu_wallet_info_json(const char* mint_url, cJSON** out_json);
int cashu_wallet_mint_quote_json(const char* mint_url, uint64_t amount, const char* unit, cJSON** out_json);
int cashu_wallet_mint_check_json(const char* mint_url, const char* quote_id, cJSON** out_json);
int cashu_wallet_mint_claim_json(const char* mint_url, const char* quote_id, uint64_t amount, cJSON** out_json);
int cashu_wallet_melt_quote_json(const char* mint_url, const char* payment_request, const char* unit, cJSON** out_json);
int cashu_wallet_melt_pay_json(const char* mint_url, const char* quote_id, cJSON** out_json);
int cashu_wallet_check_proofs_json(const char* mint_url, cJSON** out_json);
#endif

View File

@@ -514,6 +514,97 @@ static int parse_api_config(cJSON* root, didactyl_config_t* config) {
return 0;
}
static void free_cashu_wallet_mints(cashu_wallet_config_t* wallet) {
if (!wallet || !wallet->mint_urls) {
return;
}
for (int i = 0; i < wallet->mint_count; i++) {
free(wallet->mint_urls[i]);
}
free(wallet->mint_urls);
wallet->mint_urls = NULL;
wallet->mint_count = 0;
}
static int parse_cashu_wallet_config(cJSON* root, didactyl_config_t* config) {
cJSON* wallet = cJSON_GetObjectItemCaseSensitive(root, "cashu_wallet");
if (!wallet || !cJSON_IsObject(wallet)) {
return 0;
}
cJSON* enabled = cJSON_GetObjectItemCaseSensitive(wallet, "enabled");
if (enabled && cJSON_IsBool(enabled)) {
config->cashu_wallet.enabled = cJSON_IsTrue(enabled) ? 1 : 0;
}
cJSON* auto_load = cJSON_GetObjectItemCaseSensitive(wallet, "auto_load");
if (auto_load && cJSON_IsBool(auto_load)) {
config->cashu_wallet.auto_load = cJSON_IsTrue(auto_load) ? 1 : 0;
}
cJSON* timeout = cJSON_GetObjectItemCaseSensitive(wallet, "mint_timeout_seconds");
if (timeout && cJSON_IsNumber(timeout)) {
config->cashu_wallet.mint_timeout_seconds = (int)timeout->valuedouble;
}
if (copy_json_string(wallet,
"unit",
config->cashu_wallet.unit,
sizeof(config->cashu_wallet.unit),
0) != 0) {
return -1;
}
cJSON* mints = cJSON_GetObjectItemCaseSensitive(wallet, "mints");
if (mints) {
if (!cJSON_IsArray(mints)) {
return -1;
}
int count = cJSON_GetArraySize(mints);
char** urls = NULL;
if (count > 0) {
urls = (char**)calloc((size_t)count, sizeof(char*));
if (!urls) {
return -1;
}
}
for (int i = 0; i < count; i++) {
cJSON* item = cJSON_GetArrayItem(mints, i);
if (!item || !cJSON_IsString(item) || !item->valuestring || item->valuestring[0] == '\0') {
for (int j = 0; j < i; j++) {
free(urls[j]);
}
free(urls);
return -1;
}
urls[i] = strdup(item->valuestring);
if (!urls[i]) {
for (int j = 0; j < i; j++) {
free(urls[j]);
}
free(urls);
return -1;
}
}
free_cashu_wallet_mints(&config->cashu_wallet);
config->cashu_wallet.mint_urls = urls;
config->cashu_wallet.mint_count = count;
}
if (config->cashu_wallet.unit[0] == '\0') {
snprintf(config->cashu_wallet.unit, sizeof(config->cashu_wallet.unit), "%s", "sat");
}
if (config->cashu_wallet.mint_timeout_seconds < 1) {
config->cashu_wallet.mint_timeout_seconds = 30;
}
return 0;
}
static cJSON* find_tag_value_string(cJSON* tags, const char* tag_key) {
if (!tags || !cJSON_IsArray(tags) || !tag_key) {
return NULL;
@@ -1115,6 +1206,8 @@ void config_free(didactyl_config_t* config) {
free(config->default_skill.content);
free(config->default_skill.tags_json);
free_cashu_wallet_mints(&config->cashu_wallet);
memset(config, 0, sizeof(*config));
}
@@ -1174,6 +1267,13 @@ int config_load(const char* path, didactyl_config_t* config) {
config->default_skill.content = NULL;
config->default_skill.tags_json = NULL;
config->cashu_wallet.enabled = 0;
config->cashu_wallet.mint_urls = NULL;
config->cashu_wallet.mint_count = 0;
snprintf(config->cashu_wallet.unit, sizeof(config->cashu_wallet.unit), "%s", "sat");
config->cashu_wallet.auto_load = 1;
config->cashu_wallet.mint_timeout_seconds = 30;
char* raw_buf = NULL;
size_t raw_len = 0;
if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) {
@@ -1315,6 +1415,11 @@ int config_load(const char* path, didactyl_config_t* config) {
goto cleanup;
}
if (parse_cashu_wallet_config(root, config) != 0) {
config_set_error("invalid cashu_wallet configuration");
goto cleanup;
}
if (config->keys.nsec[0] != '\0') {
if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) {
config_set_error("keys.nsec/key.nsec must be valid nsec1... or 64-char hex private key");

View File

@@ -105,6 +105,15 @@ typedef struct {
char bind_address[OW_MAX_URL_LEN];
} api_config_t;
typedef struct {
int enabled;
char** mint_urls;
int mint_count;
char unit[16];
int auto_load;
int mint_timeout_seconds;
} cashu_wallet_config_t;
typedef struct {
agent_keys_t keys;
admin_config_t admin;
@@ -117,6 +126,7 @@ typedef struct {
admin_context_config_t admin_context;
triggers_config_t triggers;
api_config_t api;
cashu_wallet_config_t cashu_wallet;
startup_event_t* startup_events;
int startup_event_count;
default_skill_config_t default_skill;

View File

@@ -19,6 +19,7 @@
#include "cjson/cJSON.h"
#include "http_api.h"
#include "setup_wizard.h"
#include "cashu_wallet.h"
static volatile sig_atomic_t g_running = 1;
@@ -1073,8 +1074,22 @@ int main(int argc, char** argv) {
return 1;
}
int wallet_initialized = 0;
int exit_code = 0;
if (cashu_wallet_init(&cfg) == 0) {
wallet_initialized = 1;
if (cfg.cashu_wallet.enabled) {
int wallet_rc = 0;
if (cfg.cashu_wallet.auto_load) {
wallet_rc = cashu_wallet_load_from_relays();
}
if (wallet_rc != 0 && cfg.cashu_wallet.mint_count > 0) {
(void)cashu_wallet_create_new_from_config();
}
}
}
if (test_tool_name) {
const int timeout_ms = 15000;
if (!wait_for_connected_relays(timeout_ms)) {
@@ -1113,6 +1128,9 @@ int main(int argc, char** argv) {
}
tools_cleanup(&tools_ctx);
if (wallet_initialized) {
cashu_wallet_cleanup();
}
nostr_handler_cleanup();
config_free(&cfg);
llm_cleanup();
@@ -1367,6 +1385,28 @@ int main(int argc, char** argv) {
DEBUG_INFO("[didactyl] startup phase: subscribe DMs end");
startup_step_ok(15, "Subscribe DMs", NULL);
if (cfg.cashu_wallet.enabled) {
startup_step_begin(16, "Initialize cashu wallet");
if (cashu_wallet_init(&cfg) != 0) {
DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)");
startup_step_fail(16, "Initialize cashu wallet", "cashu_wallet_init failed; wallet features disabled for this run");
} else {
int wallet_rc = 0;
if (cfg.cashu_wallet.auto_load) {
wallet_rc = cashu_wallet_load_from_relays();
}
if (wallet_rc != 0 && cfg.cashu_wallet.mint_count > 0) {
wallet_rc = cashu_wallet_create_new_from_config();
}
if (wallet_rc != 0) {
DEBUG_WARN("[didactyl] startup phase: cashu wallet load/create failed (continuing)");
startup_step_fail(16, "Initialize cashu wallet", "wallet load/create failed; tools may return errors until wallet is configured");
} else {
startup_step_ok(16, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized");
}
}
}
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
signal(SIGPIPE, SIG_IGN);
@@ -1403,7 +1443,7 @@ int main(int argc, char** argv) {
nostr_handler_refresh_relay_statuses();
startup_step_ok(16, "READY", "agent online; entering main poll loop");
startup_step_ok(17, "READY", "agent online; entering main poll loop");
DEBUG_INFO("[didactyl] entering main poll loop");
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
@@ -1423,6 +1463,7 @@ int main(int argc, char** argv) {
http_api_cleanup();
}
cashu_wallet_cleanup();
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 1
#define DIDACTYL_VERSION_PATCH 0
#define DIDACTYL_VERSION "v0.1.0"
#define DIDACTYL_VERSION_PATCH 1
#define DIDACTYL_VERSION "v0.1.1"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -0,0 +1,229 @@
#define _POSIX_C_SOURCE 200809L
#include "tools_internal.h"
#include <stdlib.h>
#include <string.h>
#include "cjson/cJSON.h"
#include "../cashu_wallet.h"
static char* json_error_local(const char* msg) {
cJSON* root = cJSON_CreateObject();
if (!root) return NULL;
cJSON_AddBoolToObject(root, "success", 0);
cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error");
char* out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return out;
}
static cJSON* parse_args_local(const char* args_json) {
const char* raw = args_json ? args_json : "{}";
cJSON* args = cJSON_Parse(raw);
if (!args || !cJSON_IsObject(args)) {
cJSON_Delete(args);
return NULL;
}
return args;
}
static char* wrap_wallet_json_result(int rc, cJSON* obj, const char* default_error) {
if (rc != 0 || !obj) {
cJSON_Delete(obj);
return json_error_local(default_error ? default_error : "cashu wallet operation failed");
}
char* out = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
return out;
}
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* out = NULL;
int rc = cashu_wallet_balance_json(&out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_balance failed");
}
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_info requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_info_json(mint_url, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_info failed (check mint_url and mint connectivity)");
}
char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* amount = cJSON_GetObjectItemCaseSensitive(args, "amount");
if (!amount || !cJSON_IsNumber(amount) || amount->valuedouble <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_quote requires numeric amount > 0");
}
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_quote requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_mint_quote_json(mint_url, (uint64_t)amount->valuedouble, unit_s, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_quote failed");
}
char* execute_cashu_wallet_mint_check(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_check requires quote_id");
}
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_check requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_mint_check_json(mint_url, quote_id->valuestring, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_check failed");
}
char* execute_cashu_wallet_mint_claim(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_claim requires quote_id");
}
uint64_t amount = 0;
cJSON* amount_item = cJSON_GetObjectItemCaseSensitive(args, "amount");
if (amount_item) {
if (!cJSON_IsNumber(amount_item) || amount_item->valuedouble < 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_claim amount must be a non-negative number");
}
amount = (uint64_t)amount_item->valuedouble;
}
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_mint_claim requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_mint_claim_json(mint_url, quote_id->valuestring, amount, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_mint_claim failed");
}
char* execute_cashu_wallet_melt_quote(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* payment_request = cJSON_GetObjectItemCaseSensitive(args, "payment_request");
if (!payment_request || !cJSON_IsString(payment_request) || !payment_request->valuestring || payment_request->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_quote requires payment_request");
}
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
cJSON* unit = cJSON_GetObjectItemCaseSensitive(args, "unit");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
const char* unit_s = (unit && cJSON_IsString(unit) && unit->valuestring) ? unit->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_quote requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_melt_quote_json(mint_url, payment_request->valuestring, unit_s, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_melt_quote failed");
}
char* execute_cashu_wallet_melt_pay(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* quote_id = cJSON_GetObjectItemCaseSensitive(args, "quote_id");
if (!quote_id || !cJSON_IsString(quote_id) || !quote_id->valuestring || quote_id->valuestring[0] == '\0') {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_pay requires quote_id");
}
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_melt_pay requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_melt_pay_json(mint_url, quote_id->valuestring, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_melt_pay failed");
}
char* execute_cashu_wallet_check_proofs(tools_context_t* ctx, const char* args_json) {
if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable");
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
cJSON* mint = cJSON_GetObjectItemCaseSensitive(args, "mint_url");
const char* mint_url = (mint && cJSON_IsString(mint) && mint->valuestring) ? mint->valuestring : NULL;
if ((!mint_url || mint_url[0] == '\0') && ctx->cfg->cashu_wallet.mint_count <= 0) {
cJSON_Delete(args);
return json_error_local("cashu_wallet_check_proofs requires mint_url when no cashu_wallet.mint_urls are configured");
}
cJSON* out = NULL;
int rc = cashu_wallet_check_proofs_json(mint_url, &out);
cJSON_Delete(args);
return wrap_wallet_json_result(rc, out, "cashu_wallet_check_proofs failed");
}

View File

@@ -232,6 +232,30 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch
if (strcmp(tool_name, "trigger_event") == 0) {
return execute_trigger_event(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_balance") == 0) {
return execute_cashu_wallet_balance(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_info") == 0) {
return execute_cashu_wallet_info(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_mint_quote") == 0) {
return execute_cashu_wallet_mint_quote(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_mint_check") == 0) {
return execute_cashu_wallet_mint_check(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_mint_claim") == 0) {
return execute_cashu_wallet_mint_claim(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_melt_quote") == 0) {
return execute_cashu_wallet_melt_quote(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_melt_pay") == 0) {
return execute_cashu_wallet_melt_pay(ctx, args_json);
}
if (strcmp(tool_name, "cashu_wallet_check_proofs") == 0) {
return execute_cashu_wallet_check_proofs(ctx, args_json);
}
return json_error("unknown tool");
}

View File

@@ -68,6 +68,16 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json);
char* execute_config_recall(tools_context_t* ctx, const char* args_json);
char* execute_adopted_skills(tools_context_t* ctx, const char* args_json);
char* execute_trigger_event(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_balance(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_info(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_mint_quote(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_mint_check(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_mint_claim(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_melt_quote(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_melt_pay(tools_context_t* ctx, const char* args_json);
char* execute_cashu_wallet_check_proofs(tools_context_t* ctx, const char* args_json);
int memory_init(tools_context_t* ctx);
void memory_cleanup(void);

View File

@@ -1477,6 +1477,211 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
cJSON_AddItemToObject(t50, "function", t50_fn);
cJSON_AddItemToArray(tools, t50);
cJSON* t51 = cJSON_CreateObject();
cJSON* t51_fn = cJSON_CreateObject();
cJSON* t51_params = cJSON_CreateObject();
cJSON* t51_props = cJSON_CreateObject();
cJSON_AddStringToObject(t51, "type", "function");
cJSON_AddStringToObject(t51_fn, "name", "cashu_wallet_balance");
cJSON_AddStringToObject(t51_fn, "description", "Return current wallet balances aggregated by mint and unit");
cJSON_AddStringToObject(t51_params, "type", "object");
cJSON_AddItemToObject(t51_params, "properties", t51_props);
cJSON_AddItemToObject(t51_fn, "parameters", t51_params);
cJSON_AddItemToObject(t51, "function", t51_fn);
cJSON_AddItemToArray(tools, t51);
cJSON* t52 = cJSON_CreateObject();
cJSON* t52_fn = cJSON_CreateObject();
cJSON* t52_params = cJSON_CreateObject();
cJSON* t52_props = cJSON_CreateObject();
cJSON_AddStringToObject(t52, "type", "function");
cJSON_AddStringToObject(t52_fn, "name", "cashu_wallet_info");
cJSON_AddStringToObject(t52_fn, "description", "Fetch mint info for a specific mint_url or the default configured mint");
cJSON_AddStringToObject(t52_params, "type", "object");
cJSON_AddItemToObject(t52_params, "properties", t52_props);
cJSON* p_wallet_info_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_info_mint, "type", "string");
cJSON_AddStringToObject(p_wallet_info_mint, "description", "Optional mint base URL; uses configured default when omitted");
cJSON_AddItemToObject(t52_props, "mint_url", p_wallet_info_mint);
cJSON_AddItemToObject(t52_fn, "parameters", t52_params);
cJSON_AddItemToObject(t52, "function", t52_fn);
cJSON_AddItemToArray(tools, t52);
cJSON* t53 = cJSON_CreateObject();
cJSON* t53_fn = cJSON_CreateObject();
cJSON* t53_params = cJSON_CreateObject();
cJSON* t53_props = cJSON_CreateObject();
cJSON* t53_required = cJSON_CreateArray();
cJSON_AddStringToObject(t53, "type", "function");
cJSON_AddStringToObject(t53_fn, "name", "cashu_wallet_mint_quote");
cJSON_AddStringToObject(t53_fn, "description", "Request a mint quote for a target amount");
cJSON_AddStringToObject(t53_params, "type", "object");
cJSON_AddItemToObject(t53_params, "properties", t53_props);
cJSON_AddItemToObject(t53_params, "required", t53_required);
cJSON* p_wallet_mq_amount = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mq_amount, "type", "integer");
cJSON_AddStringToObject(p_wallet_mq_amount, "description", "Amount to mint in the selected unit");
cJSON_AddItemToObject(t53_props, "amount", p_wallet_mq_amount);
cJSON* p_wallet_mq_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mq_mint, "type", "string");
cJSON_AddStringToObject(p_wallet_mq_mint, "description", "Optional mint base URL; uses configured default when omitted");
cJSON_AddItemToObject(t53_props, "mint_url", p_wallet_mq_mint);
cJSON* p_wallet_mq_unit = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mq_unit, "type", "string");
cJSON_AddStringToObject(p_wallet_mq_unit, "description", "Optional unit, e.g. sat");
cJSON_AddItemToObject(t53_props, "unit", p_wallet_mq_unit);
cJSON_AddItemToArray(t53_required, cJSON_CreateString("amount"));
cJSON_AddItemToObject(t53_fn, "parameters", t53_params);
cJSON_AddItemToObject(t53, "function", t53_fn);
cJSON_AddItemToArray(tools, t53);
cJSON* t54 = cJSON_CreateObject();
cJSON* t54_fn = cJSON_CreateObject();
cJSON* t54_params = cJSON_CreateObject();
cJSON* t54_props = cJSON_CreateObject();
cJSON* t54_required = cJSON_CreateArray();
cJSON_AddStringToObject(t54, "type", "function");
cJSON_AddStringToObject(t54_fn, "name", "cashu_wallet_mint_check");
cJSON_AddStringToObject(t54_fn, "description", "Check a previously requested mint quote status");
cJSON_AddStringToObject(t54_params, "type", "object");
cJSON_AddItemToObject(t54_params, "properties", t54_props);
cJSON_AddItemToObject(t54_params, "required", t54_required);
cJSON* p_wallet_mc_quote = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mc_quote, "type", "string");
cJSON_AddItemToObject(t54_props, "quote_id", p_wallet_mc_quote);
cJSON* p_wallet_mc_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mc_mint, "type", "string");
cJSON_AddItemToObject(t54_props, "mint_url", p_wallet_mc_mint);
cJSON_AddItemToArray(t54_required, cJSON_CreateString("quote_id"));
cJSON_AddItemToObject(t54_fn, "parameters", t54_params);
cJSON_AddItemToObject(t54, "function", t54_fn);
cJSON_AddItemToArray(tools, t54);
cJSON* t55 = cJSON_CreateObject();
cJSON* t55_fn = cJSON_CreateObject();
cJSON* t55_params = cJSON_CreateObject();
cJSON* t55_props = cJSON_CreateObject();
cJSON* t55_required = cJSON_CreateArray();
cJSON_AddStringToObject(t55, "type", "function");
cJSON_AddStringToObject(t55_fn, "name", "cashu_wallet_mint_claim");
cJSON_AddStringToObject(t55_fn, "description", "Claim minted proofs for a paid quote_id and add them to wallet state");
cJSON_AddStringToObject(t55_params, "type", "object");
cJSON_AddItemToObject(t55_params, "properties", t55_props);
cJSON_AddItemToObject(t55_params, "required", t55_required);
cJSON* p_wallet_mclaim_quote = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mclaim_quote, "type", "string");
cJSON_AddItemToObject(t55_props, "quote_id", p_wallet_mclaim_quote);
cJSON* p_wallet_mclaim_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mclaim_mint, "type", "string");
cJSON_AddItemToObject(t55_props, "mint_url", p_wallet_mclaim_mint);
cJSON* p_wallet_mclaim_amount = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mclaim_amount, "type", "integer");
cJSON_AddStringToObject(p_wallet_mclaim_amount, "description", "Optional target amount; if omitted wallet attempts best-effort claim");
cJSON_AddItemToObject(t55_props, "amount", p_wallet_mclaim_amount);
cJSON_AddItemToArray(t55_required, cJSON_CreateString("quote_id"));
cJSON_AddItemToObject(t55_fn, "parameters", t55_params);
cJSON_AddItemToObject(t55, "function", t55_fn);
cJSON_AddItemToArray(tools, t55);
cJSON* t56 = cJSON_CreateObject();
cJSON* t56_fn = cJSON_CreateObject();
cJSON* t56_params = cJSON_CreateObject();
cJSON* t56_props = cJSON_CreateObject();
cJSON* t56_required = cJSON_CreateArray();
cJSON_AddStringToObject(t56, "type", "function");
cJSON_AddStringToObject(t56_fn, "name", "cashu_wallet_melt_quote");
cJSON_AddStringToObject(t56_fn, "description", "Request a melt quote for a Lightning payment request");
cJSON_AddStringToObject(t56_params, "type", "object");
cJSON_AddItemToObject(t56_params, "properties", t56_props);
cJSON_AddItemToObject(t56_params, "required", t56_required);
cJSON* p_wallet_meq_pr = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_meq_pr, "type", "string");
cJSON_AddItemToObject(t56_props, "payment_request", p_wallet_meq_pr);
cJSON* p_wallet_meq_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_meq_mint, "type", "string");
cJSON_AddItemToObject(t56_props, "mint_url", p_wallet_meq_mint);
cJSON* p_wallet_meq_unit = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_meq_unit, "type", "string");
cJSON_AddItemToObject(t56_props, "unit", p_wallet_meq_unit);
cJSON_AddItemToArray(t56_required, cJSON_CreateString("payment_request"));
cJSON_AddItemToObject(t56_fn, "parameters", t56_params);
cJSON_AddItemToObject(t56, "function", t56_fn);
cJSON_AddItemToArray(tools, t56);
cJSON* t57 = cJSON_CreateObject();
cJSON* t57_fn = cJSON_CreateObject();
cJSON* t57_params = cJSON_CreateObject();
cJSON* t57_props = cJSON_CreateObject();
cJSON* t57_required = cJSON_CreateArray();
cJSON_AddStringToObject(t57, "type", "function");
cJSON_AddStringToObject(t57_fn, "name", "cashu_wallet_melt_pay");
cJSON_AddStringToObject(t57_fn, "description", "Pay a previously quoted melt by quote_id");
cJSON_AddStringToObject(t57_params, "type", "object");
cJSON_AddItemToObject(t57_params, "properties", t57_props);
cJSON_AddItemToObject(t57_params, "required", t57_required);
cJSON* p_wallet_mp_quote = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mp_quote, "type", "string");
cJSON_AddItemToObject(t57_props, "quote_id", p_wallet_mp_quote);
cJSON* p_wallet_mp_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_mp_mint, "type", "string");
cJSON_AddItemToObject(t57_props, "mint_url", p_wallet_mp_mint);
cJSON_AddItemToArray(t57_required, cJSON_CreateString("quote_id"));
cJSON_AddItemToObject(t57_fn, "parameters", t57_params);
cJSON_AddItemToObject(t57, "function", t57_fn);
cJSON_AddItemToArray(tools, t57);
cJSON* t58 = cJSON_CreateObject();
cJSON* t58_fn = cJSON_CreateObject();
cJSON* t58_params = cJSON_CreateObject();
cJSON* t58_props = cJSON_CreateObject();
cJSON_AddStringToObject(t58, "type", "function");
cJSON_AddStringToObject(t58_fn, "name", "cashu_wallet_check_proofs");
cJSON_AddStringToObject(t58_fn, "description", "Check current wallet proofs state against mint");
cJSON_AddStringToObject(t58_params, "type", "object");
cJSON_AddItemToObject(t58_params, "properties", t58_props);
cJSON* p_wallet_cp_mint = cJSON_CreateObject();
cJSON_AddStringToObject(p_wallet_cp_mint, "type", "string");
cJSON_AddItemToObject(t58_props, "mint_url", p_wallet_cp_mint);
cJSON_AddItemToObject(t58_fn, "parameters", t58_params);
cJSON_AddItemToObject(t58, "function", t58_fn);
cJSON_AddItemToArray(tools, t58);
char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools);
return out;

View File

@@ -204,18 +204,202 @@ static int cron_field_matches(const char* field, int value, int min_v, int max_v
return 0;
}
static int cron_normalize_expression(const char* expr, char* out, size_t out_sz, int* out_expanded) {
if (out_expanded) {
*out_expanded = 0;
}
if (!expr || !out || out_sz == 0) {
return -1;
}
while (*expr && isspace((unsigned char)*expr)) {
expr++;
}
if (*expr == '\0') {
return -1;
}
const char* mapped = NULL;
if (strcmp(expr, "*") == 0) {
mapped = "* * * * *";
} else if (strcmp(expr, "@yearly") == 0 || strcmp(expr, "@annually") == 0) {
mapped = "0 0 1 1 *";
} else if (strcmp(expr, "@monthly") == 0) {
mapped = "0 0 1 * *";
} else if (strcmp(expr, "@weekly") == 0) {
mapped = "0 0 * * 0";
} else if (strcmp(expr, "@daily") == 0 || strcmp(expr, "@midnight") == 0) {
mapped = "0 0 * * *";
} else if (strcmp(expr, "@hourly") == 0) {
mapped = "0 * * * *";
}
const char* src = mapped ? mapped : expr;
int n = snprintf(out, out_sz, "%s", src);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
if (mapped && out_expanded) {
*out_expanded = 1;
}
return 0;
}
static int cron_field_token_valid(const char* token, int min_v, int max_v) {
if (!token || token[0] == '\0') {
return 0;
}
if (strcmp(token, "*") == 0) {
return 1;
}
const char* slash = strchr(token, '/');
int step = 0;
char base[64];
if (slash) {
size_t base_len = (size_t)(slash - token);
if (base_len == 0 || base_len >= sizeof(base)) {
return 0;
}
memcpy(base, token, base_len);
base[base_len] = '\0';
char* endptr = NULL;
long step_l = strtol(slash + 1, &endptr, 10);
if (endptr == slash + 1 || *endptr != '\0' || step_l <= 0 || step_l > 1024) {
return 0;
}
step = (int)step_l;
} else {
int n = snprintf(base, sizeof(base), "%s", token);
if (n < 0 || (size_t)n >= sizeof(base)) {
return 0;
}
}
if (strcmp(base, "*") == 0) {
return step == 0 || step <= (max_v - min_v + 1);
}
const char* dash = strchr(base, '-');
if (dash) {
char left[32];
char right[32];
size_t left_len = (size_t)(dash - base);
size_t right_len = strlen(dash + 1);
if (left_len == 0 || right_len == 0 || left_len >= sizeof(left) || right_len >= sizeof(right)) {
return 0;
}
memcpy(left, base, left_len);
left[left_len] = '\0';
memcpy(right, dash + 1, right_len + 1U);
char* e1 = NULL;
char* e2 = NULL;
long a = strtol(left, &e1, 10);
long b = strtol(right, &e2, 10);
if (!e1 || *e1 != '\0' || !e2 || *e2 != '\0') {
return 0;
}
if ((int)a < min_v || (int)b > max_v || (int)a > (int)b) {
return 0;
}
return 1;
}
char* e = NULL;
long n = strtol(base, &e, 10);
if (!e || *e != '\0') {
return 0;
}
if ((int)n < min_v || (int)n > max_v) {
return 0;
}
return 1;
}
static int cron_field_valid(const char* field, int min_v, int max_v) {
if (!field || field[0] == '\0') {
return 0;
}
char tmp[128];
int n = snprintf(tmp, sizeof(tmp), "%s", field);
if (n < 0 || (size_t)n >= sizeof(tmp)) {
return 0;
}
int tokens = 0;
char* saveptr = NULL;
char* token = strtok_r(tmp, ",", &saveptr);
while (token) {
while (*token == ' ') token++;
if (!cron_field_token_valid(token, min_v, max_v)) {
return 0;
}
tokens++;
token = strtok_r(NULL, ",", &saveptr);
}
return tokens > 0;
}
static int cron_expr_is_valid(const char* expr, char* normalized_out, size_t normalized_out_size) {
if (!normalized_out || normalized_out_size == 0) {
return 0;
}
if (cron_normalize_expression(expr, normalized_out, normalized_out_size, NULL) != 0) {
return 0;
}
char parse_buf[TRIGGER_FILTER_JSON_MAX];
int n = snprintf(parse_buf, sizeof(parse_buf), "%s", normalized_out);
if (n < 0 || (size_t)n >= sizeof(parse_buf)) {
return 0;
}
char* fields[5] = {0};
int nf = 0;
char* saveptr = NULL;
char* tok = strtok_r(parse_buf, " \t", &saveptr);
while (tok && nf < 5) {
fields[nf++] = tok;
tok = strtok_r(NULL, " \t", &saveptr);
}
if (nf != 5 || tok != NULL) {
return 0;
}
if (!cron_field_valid(fields[0], 0, 59)) return 0;
if (!cron_field_valid(fields[1], 0, 23)) return 0;
if (!cron_field_valid(fields[2], 1, 31)) return 0;
if (!cron_field_valid(fields[3], 1, 12)) return 0;
if (!cron_field_valid(fields[4], 0, 6)) return 0;
return 1;
}
static int cron_matches_now(const char* expr, time_t now_ts) {
if (!expr || expr[0] == '\0') {
return 0;
}
char buf[TRIGGER_FILTER_JSON_MAX];
snprintf(buf, sizeof(buf), "%s", expr);
char normalized[TRIGGER_FILTER_JSON_MAX];
if (!cron_expr_is_valid(expr, normalized, sizeof(normalized))) {
return 0;
}
char parse_buf[TRIGGER_FILTER_JSON_MAX];
snprintf(parse_buf, sizeof(parse_buf), "%s", normalized);
char* fields[5] = {0};
int nf = 0;
char* saveptr = NULL;
char* tok = strtok_r(buf, " \t", &saveptr);
char* tok = strtok_r(parse_buf, " \t", &saveptr);
while (tok && nf < 5) {
fields[nf++] = tok;
tok = strtok_r(NULL, " \t", &saveptr);
@@ -612,7 +796,7 @@ int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
return -1;
}
mgr->last_poll_at = time(NULL);
mgr->last_poll_at = 0;
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
return 0;
@@ -985,7 +1169,14 @@ int trigger_manager_add(trigger_manager_t* mgr,
t->last_seen_created_at = 0;
t->last_cron_fire = 0;
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
char normalized_cron[sizeof(t->cron_expr)] = {0};
if (!cron_expr_is_valid(filter_json, normalized_cron, sizeof(normalized_cron))) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger add rejected: invalid cron expression d_tag=%s expr=%s", skill_d_tag, filter_json);
memset(t, 0, sizeof(*t));
return -1;
}
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", normalized_cron);
}
snprintf(t->llm_spec, sizeof(t->llm_spec), "%s", llm_spec ? llm_spec : "");
snprintf(t->tools_policy, sizeof(t->tools_policy), "%s", tools_policy ? tools_policy : "");
@@ -1208,7 +1399,13 @@ int trigger_manager_update(trigger_manager_t* mgr,
t->enabled = clamp_enabled(enabled);
t->trigger_type = trigger_type_from_string(trigger_type_str);
if (t->trigger_type == TRIGGER_TYPE_CRON) {
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", filter_json);
char normalized_cron[sizeof(t->cron_expr)] = {0};
if (!cron_expr_is_valid(filter_json, normalized_cron, sizeof(normalized_cron))) {
pthread_mutex_unlock(&mgr->mutex);
DEBUG_WARN("[didactyl] trigger update rejected: invalid cron expression d_tag=%s expr=%s", skill_d_tag, filter_json);
return -1;
}
snprintf(t->cron_expr, sizeof(t->cron_expr), "%s", normalized_cron);
} else {
t->cron_expr[0] = '\0';
}
@@ -1261,6 +1458,7 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
}
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
DEBUG_LOG("[didactyl] trigger cron poll throttled (delta=%ld)", (long)(now - mgr->last_poll_at));
return 0;
}
mgr->last_poll_at = now;
@@ -1276,9 +1474,16 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
}
const char* expr = (t->cron_expr[0] != '\0') ? t->cron_expr : t->filter_json;
if (!cron_matches_now(expr, now)) {
char normalized_expr[64] = {0};
if (!cron_expr_is_valid(expr, normalized_expr, sizeof(normalized_expr))) {
DEBUG_WARN("[didactyl] cron trigger skipped: invalid expression d_tag=%s expr=%s", t->skill_d_tag, expr ? expr : "");
continue;
}
if (!cron_matches_now(normalized_expr, now)) {
continue;
}
DEBUG_INFO("[didactyl] cron trigger matched d_tag=%s expr=%s", t->skill_d_tag, normalized_expr);
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
continue;
@@ -1296,7 +1501,7 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
if (event) {
cJSON_AddStringToObject(event, "type", "cron");
cJSON_AddStringToObject(event, "d_tag", trigger_copy.skill_d_tag);
cJSON_AddStringToObject(event, "cron_expr", expr);
cJSON_AddStringToObject(event, "cron_expr", normalized_expr);
cJSON_AddNumberToObject(event, "created_at", (double)now);
(void)mgr;
execute_llm_action(&trigger_copy, event, "cron");
@@ -1438,6 +1643,10 @@ char* trigger_manager_status_json(trigger_manager_t* mgr) {
if (t->has_seed) cJSON_AddNumberToObject(item, "seed", t->seed);
cJSON_AddNumberToObject(item, "last_fired", (double)t->last_fired);
cJSON_AddNumberToObject(item, "last_seen_created_at", (double)t->last_seen_created_at);
if (t->trigger_type == TRIGGER_TYPE_CRON) {
cJSON_AddStringToObject(item, "cron_expr", t->cron_expr);
cJSON_AddNumberToObject(item, "last_cron_fire", (double)t->last_cron_fire);
}
cJSON_AddItemToArray(arr, item);
}