Compare commits

...
7 Commits
24 changed files with 2653 additions and 167 deletions
+58 -37
View File
@@ -8,41 +8,58 @@ FROM alpine:3.19 AS builder
# Re-declare build argument in this stage
ARG DEBUG_BUILD=false
# Install build dependencies
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
nghttp2-dev \
nghttp2-static \
c-ares-dev \
c-ares-static \
libpsl-dev \
libpsl-static \
libidn2-dev \
libidn2-static \
libunistring-dev \
libunistring-static \
brotli-dev \
brotli-static \
zstd-dev \
zstd-static \
sqlite-dev \
sqlite-static \
linux-headers \
wget \
bash
# Install build dependencies (with retry logic for transient mirror/network failures)
RUN set -eux; \
printf '%s\n' \
'https://dl-cdn.alpinelinux.org/alpine/v3.19/main' \
'https://dl-cdn.alpinelinux.org/alpine/v3.19/community' \
> /etc/apk/repositories; \
APK_OK=0; \
for attempt in 1 2 3 4 5; do \
if apk update && apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
nghttp2-dev \
nghttp2-static \
c-ares-dev \
c-ares-static \
libpsl-dev \
libpsl-static \
libidn2-dev \
libidn2-static \
libunistring-dev \
libunistring-static \
brotli-dev \
brotli-static \
zstd-dev \
zstd-static \
sqlite-dev \
sqlite-static \
linux-headers \
wget \
bash; then \
APK_OK=1; \
break; \
fi; \
echo "apk dependency install failed (attempt ${attempt}/5), retrying..."; \
sleep $((attempt * 2)); \
done; \
if [ "$APK_OK" -ne 1 ]; then \
echo "ERROR: failed to install Alpine dependencies after 5 attempts"; \
exit 33; \
fi
# Set working directory
WORKDIR /build
@@ -68,6 +85,7 @@ COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
sed -i 's/CC="aarch64-linux-gnu-gcc"/CC="gcc"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=all
@@ -78,7 +96,10 @@ COPY Makefile /build/Makefile
# Build didactyl with full static linking (only rebuilds when src/ changes)
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
# Use conditional compilation flags based on DEBUG_BUILD argument
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1) && \
if [ -z "$NOSTR_LIB" ]; then echo "ERROR: nostr_core_lib .a not found"; exit 1; fi && \
echo "Using nostr library: $NOSTR_LIB" && \
if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
echo "Building with DEBUG symbols enabled (optimized with -O2)"; \
@@ -103,7 +124,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/trigger_manager.c \
src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
-o /build/didactyl_static \
nostr_core_lib/libnostr_core_x64.a \
$NOSTR_LIB \
-lsecp256k1 \
$OPENSSL_LIBS \
$CURL_LIBS \
+8 -6
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)
+17 -2
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.0.84
## Current Status — v0.1.1
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.84 — Remove duplicated read-only tool payload fields and make slash output prefer content
> Last release update: v0.1.1 — Fix 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`.
+106 -20
View File
@@ -11,8 +11,44 @@ DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
# Parse command line arguments
DEBUG_BUILD=false
if [[ "$1" == "--debug" ]]; then
DEBUG_BUILD=true
TARGET_PLATFORM=""
ALL_PLATFORMS=false
for arg in "$@"; do
case "$arg" in
--debug)
DEBUG_BUILD=true
;;
--platform=*)
TARGET_PLATFORM="${arg#*=}"
;;
--all-platforms|--all_platforms|-a)
ALL_PLATFORMS=true
;;
esac
done
if [ "$ALL_PLATFORMS" = true ] && [ -n "$TARGET_PLATFORM" ]; then
echo "ERROR: --all-platforms cannot be used together with --platform"
exit 1
fi
if [ "$ALL_PLATFORMS" = true ]; then
echo "Building all supported platforms (linux/amd64 + linux/arm64)..."
echo ""
DEBUG_FLAG=""
if [ "$DEBUG_BUILD" = true ]; then
DEBUG_FLAG="--debug"
fi
"$SCRIPT_DIR/build_static.sh" $DEBUG_FLAG --platform=linux/amd64
"$SCRIPT_DIR/build_static.sh" $DEBUG_FLAG --platform=linux/arm64
echo ""
echo "✓ Multi-platform build complete"
exit 0
fi
if [ "$DEBUG_BUILD" = true ]; then
echo "=========================================="
echo "Didactyl MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
@@ -61,24 +97,45 @@ DOCKER_CMD="docker"
echo "✓ Docker is available and running"
echo ""
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="didactyl_static_x86_64"
;;
aarch64|arm64)
PLATFORM="linux/arm64"
OUTPUT_NAME="didactyl_static_arm64"
;;
*)
echo "WARNING: Unknown architecture: $ARCH"
echo "Defaulting to linux/amd64"
PLATFORM="linux/amd64"
OUTPUT_NAME="didactyl_static_${ARCH}"
;;
esac
# Detect architecture (or use explicit target platform override)
if [ -n "$TARGET_PLATFORM" ]; then
PLATFORM="$TARGET_PLATFORM"
case "$PLATFORM" in
linux/amd64)
OUTPUT_NAME="didactyl_static_x86_64"
;;
linux/arm64)
OUTPUT_NAME="didactyl_static_arm64"
;;
linux/arm/v7)
OUTPUT_NAME="didactyl_static_armv7"
;;
linux/arm/v6)
OUTPUT_NAME="didactyl_static_armv6"
;;
*)
OUTPUT_NAME="didactyl_static_custom"
;;
esac
else
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="didactyl_static_x86_64"
;;
aarch64|arm64)
PLATFORM="linux/arm64"
OUTPUT_NAME="didactyl_static_arm64"
;;
*)
echo "WARNING: Unknown architecture: $ARCH"
echo "Defaulting to linux/amd64"
PLATFORM="linux/amd64"
OUTPUT_NAME="didactyl_static_${ARCH}"
;;
esac
fi
# Append _debug suffix to output name for debug builds
if [ "$DEBUG_BUILD" = true ]; then
@@ -89,6 +146,34 @@ echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
# Verify Docker can execute the requested target platform (QEMU/binfmt for cross-arch)
HOST_ARCH=$(uname -m)
case "$HOST_ARCH" in
x86_64) HOST_PLATFORM="linux/amd64" ;;
aarch64|arm64) HOST_PLATFORM="linux/arm64" ;;
armv7l) HOST_PLATFORM="linux/arm/v7" ;;
armv6l) HOST_PLATFORM="linux/arm/v6" ;;
*) HOST_PLATFORM="unknown" ;;
esac
if [ "$HOST_PLATFORM" != "$PLATFORM" ]; then
echo "Cross-architecture build detected: host=$HOST_PLATFORM target=$PLATFORM"
echo "Checking Docker emulation support for $PLATFORM..."
if ! $DOCKER_CMD run --rm --platform "$PLATFORM" alpine:3.19 uname -m > /dev/null 2>&1; then
echo ""
echo "ERROR: Docker cannot execute $PLATFORM containers on this host"
echo "This usually means QEMU/binfmt is not configured."
echo "Run this once, then retry:"
echo " docker run --rm --privileged multiarch/qemu-user-static --reset -p yes"
echo "Optional verify command:"
echo " docker run --rm --platform $PLATFORM alpine:3.19 uname -m"
echo ""
exit 1
fi
echo "✓ Docker emulation for $PLATFORM is available"
echo ""
fi
# Check if Alpine base image is cached
echo "Checking for cached Alpine Docker image..."
if ! docker images alpine:3.19 --format "{{.Repository}}:{{.Tag}}" | grep -q "alpine:3.19"; then
@@ -118,6 +203,7 @@ echo " - Compile didactyl with full static linking"
echo ""
$DOCKER_CMD build \
--network host \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
-f "$DOCKERFILE" \
+15
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.
BIN
View File
Binary file not shown.
+13
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 |
+57 -67
View File
@@ -53,8 +53,7 @@ show_usage() {
echo " -M, --major: Increment major version, zero minor+patch (v0.1.0 → v1.0.0)"
echo ""
echo "RELEASE MODE (-r flag):"
echo " - Build static binary using build_static.sh"
echo " - Create source tarball"
echo " - Build static x86_64 and arm64 binaries using build_static.sh"
echo " - Git add, commit, push, and create Gitea release with assets"
echo " - Can be combined with version increment flags"
echo ""
@@ -321,9 +320,9 @@ git_commit_and_push_no_tag() {
fi
}
# Function to build release binary
# Function to build release binaries
build_release_binary() {
print_status "Building release binary..."
print_status "Building release binaries (x86_64 + arm64)..."
# Check if build_static.sh exists
if [[ ! -f "build_static.sh" ]]; then
@@ -331,35 +330,12 @@ build_release_binary() {
return 1
fi
# Run the static build script
if ./build_static.sh > /dev/null 2>&1; then
print_success "Built static binary successfully"
# Run the static build script for both platforms
if ./build_static.sh --all-platforms > /dev/null 2>&1; then
print_success "Built static binaries successfully"
return 0
else
print_error "Failed to build static binary"
return 1
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"
print_error "Failed to build one or more static binaries"
return 1
fi
}
@@ -367,8 +343,8 @@ create_source_tarball() {
# Function to upload release assets to Gitea
upload_release_assets() {
local release_id="$1"
local binary_path="$2"
local tarball_path="$3"
local binary_x86_path="$2"
local binary_arm64_path="$3"
print_status "Uploading release assets..."
@@ -383,9 +359,9 @@ upload_release_assets() {
local assets_url="$api_url/releases/$release_id/assets"
print_status "Assets URL: $assets_url"
# Upload binary
if [[ -f "$binary_path" ]]; then
print_status "Uploading binary: $(basename "$binary_path")"
# Upload x86_64 binary
if [[ -f "$binary_x86_path" ]]; then
print_status "Uploading binary: $(basename "$binary_x86_path")"
# Retry loop for eventual consistency
local max_attempts=3
@@ -394,11 +370,11 @@ upload_release_assets() {
print_status "Upload attempt $attempt/$max_attempts"
local binary_response=$(curl -fS -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
-F "name=$(basename "$binary_path")")
-F "attachment=@$binary_x86_path;filename=$(basename "$binary_x86_path")" \
-F "name=$(basename "$binary_x86_path")")
if echo "$binary_response" | grep -q '"id"'; then
print_success "Uploaded binary successfully"
print_success "Uploaded x86_64 binary successfully"
break
else
print_warning "Upload attempt $attempt failed"
@@ -406,7 +382,7 @@ upload_release_assets() {
print_status "Retrying in 2 seconds..."
sleep 2
else
print_error "Failed to upload binary after $max_attempts attempts"
print_error "Failed to upload x86_64 binary after $max_attempts attempts"
print_error "Response: $binary_response"
fi
fi
@@ -414,19 +390,36 @@ 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")")
# Upload arm64 binary
if [[ -f "$binary_arm64_path" ]]; then
print_status "Uploading binary: $(basename "$binary_arm64_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
local max_attempts=3
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
print_status "Upload attempt $attempt/$max_attempts"
local binary_response=$(curl -fS -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_arm64_path;filename=$(basename "$binary_arm64_path")" \
-F "name=$(basename "$binary_arm64_path")")
if echo "$binary_response" | grep -q '"id"'; then
print_success "Uploaded arm64 binary successfully"
break
else
print_warning "Upload attempt $attempt failed"
if [[ $attempt -lt $max_attempts ]]; then
print_status "Retrying in 2 seconds..."
sleep 2
else
print_error "Failed to upload arm64 binary after $max_attempts attempts"
print_error "Response: $binary_response"
fi
fi
((attempt++))
done
fi
}
# Function to create Gitea release
@@ -515,33 +508,30 @@ main() {
# Commit and push
git_commit_and_push_no_tag
# Build release binary
# Build release binaries
local binary_x86_path=""
local binary_arm64_path=""
if build_release_binary; then
local binary_path="build/didactyl_static_x86_64"
[[ -f "build/didactyl_static_x86_64" ]] && binary_x86_path="build/didactyl_static_x86_64"
[[ -f "build/didactyl_static_arm64" ]] && binary_arm64_path="build/didactyl_static_arm64"
else
print_warning "Binary build failed, continuing with release creation"
if [[ -f "build/didactyl_static_x86_64" ]]; then
print_status "Using existing binary from previous build"
binary_path="build/didactyl_static_x86_64"
else
binary_path=""
print_status "Using existing x86_64 binary from previous build"
binary_x86_path="build/didactyl_static_x86_64"
fi
if [[ -f "build/didactyl_static_arm64" ]]; then
print_status "Using existing arm64 binary from previous build"
binary_arm64_path="build/didactyl_static_arm64"
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_path" || -n "$tarball_path") ]]; then
upload_release_assets "$release_id" "$binary_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
+190
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
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 |
+5 -2
View File
@@ -741,8 +741,11 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
result_json = strdup("{\"success\":false,\"error\":\"direct tool execution failed\"}");
}
char* markdown_result = format_result_markdown_local(result_json);
const char* dm_payload = markdown_result ? markdown_result : result_json;
size_t tool_name_len = strlen(tool_name);
int send_raw_json = (tool_name_len >= 4U && strcmp(tool_name + tool_name_len - 4U, "_get") == 0);
char* markdown_result = send_raw_json ? NULL : format_result_markdown_local(result_json);
const char* dm_payload = send_raw_json ? result_json : (markdown_result ? markdown_result : result_json);
size_t log_cap = strlen(message) + strlen(result_json ? result_json : "") + strlen(dm_payload ? dm_payload : "") + 192U;
char* log_payload = (char*)malloc(log_cap);
+989
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
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
+105
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");
+10
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;
+71 -20
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;
@@ -813,24 +814,29 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha
return rc;
}
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) {
if (!cfg) return -1;
cJSON* llm = cJSON_CreateObject();
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
int llm_rc = 0;
if (persist_llm_config) {
cJSON* llm = cJSON_CreateObject();
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
int llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
} else {
DEBUG_INFO("[didactyl] startup phase: skipping llm_config publish because it was recalled from Nostr");
}
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
@@ -846,15 +852,18 @@ static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
}
static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
if (!cfg) {
return;
return 0;
}
int llm_recalled_from_nostr = 0;
if (!llm_config_is_complete(&cfg->llm)) {
char* llm_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
if (apply_recalled_llm_config(cfg, llm_plaintext) == 0) {
llm_recalled_from_nostr = 1;
DEBUG_INFO("[didactyl] startup phase: recovered llm_config from Nostr");
} else {
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled llm_config");
@@ -885,6 +894,8 @@ static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
}
free(agent_plaintext);
return llm_recalled_from_nostr;
}
int main(int argc, char** argv) {
@@ -1063,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)) {
@@ -1103,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();
@@ -1129,8 +1157,8 @@ int main(int argc, char** argv) {
}
startup_step_begin(2, "Recover runtime config from Nostr");
recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", NULL);
int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "llm_config recalled" : NULL);
startup_step_begin(3, "Validate LLM config");
if (!llm_config_is_complete(&cfg.llm)) {
@@ -1184,7 +1212,7 @@ int main(int argc, char** argv) {
}
}
if (persist_runtime_config_to_nostr(&cfg) != 0) {
if (persist_runtime_config_to_nostr(&cfg, llm_recalled_from_nostr ? 0 : 1) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
@@ -1357,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);
@@ -1393,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);
@@ -1413,6 +1463,7 @@ int main(int argc, char** argv) {
http_api_cleanup();
}
cashu_wallet_cleanup();
agent_cleanup();
nostr_handler_cleanup();
llm_cleanup();
+3 -3
View File
@@ -11,9 +11,9 @@
// Version information (auto-updated by build system)
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 84
#define DIDACTYL_VERSION "v0.0.84"
#define DIDACTYL_VERSION_MINOR 1
#define DIDACTYL_VERSION_PATCH 1
#define DIDACTYL_VERSION "v0.1.1"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+229
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");
}
+5 -2
View File
@@ -143,6 +143,9 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
return json_error_local("config_store requires content");
}
char d_tag_buf[128];
snprintf(d_tag_buf, sizeof(d_tag_buf), "%s", d_tag_j->valuestring);
char* content_text = NULL;
if (cJSON_IsString(content_j) && content_j->valuestring) {
content_text = strdup(content_j->valuestring);
@@ -180,7 +183,7 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_j->valuestring));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_buf));
cJSON_AddItemToArray(tags, d_tag);
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
@@ -208,7 +211,7 @@ char* execute_config_store(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "d_tag", d_tag_j->valuestring);
cJSON_AddStringToObject(out, "d_tag", d_tag_buf);
cJSON_AddNumberToObject(out, "kind", CONFIG_KIND);
cJSON_AddNumberToObject(out, "content_length", (double)strlen(content_text));
cJSON_AddStringToObject(out, "event_id", publish_result.event_id);
+28
View File
@@ -108,6 +108,25 @@ static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* cha
return 0;
}
static void mask_api_key_local(const char* api_key, char* out, size_t out_size) {
if (!out || out_size == 0) return;
out[0] = '\0';
if (!api_key || api_key[0] == '\0') {
return;
}
size_t api_len = strlen(api_key);
if (api_len >= 8U) {
snprintf(out,
out_size,
"%.4s...%s",
api_key,
api_key + api_len - 4U);
} else {
snprintf(out, out_size, "%s", "(set)");
}
}
char* execute_model_get(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
@@ -120,10 +139,15 @@ char* execute_model_get(const char* args_json) {
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
char api_key_masked[64] = {0};
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddStringToObject(out, "api_key", api_key_masked);
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
@@ -196,10 +220,14 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
char api_key_masked[64] = {0};
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddStringToObject(out, "api_key", api_key_masked);
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
cJSON_AddBoolToObject(out, "persisted", persisted ? 1 : 0);
+24
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");
}
+10
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);
+205
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;
+217 -8
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);
}