Skip to content

Commit f39e789

Browse files
committed
feat: lnurl utility commands
1 parent aa9f551 commit f39e789

2 files changed

Lines changed: 337 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
aut
44
node_modules
55
docker/lnd
6+
docker/lnurl-server-data
67
artifacts
78
WARP.md
89
.ai

docker/bitcoin-cli

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
set -euo pipefail
44

55
CLI_NAME="$(basename $0)"
6+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
67
BITCOIN_CONTAINER="bitcoind"
78
LND_CONTAINER="lnd"
89
LND_DIR="/home/lnd/.lnd"
910
RPC_USER=polaruser
1011
RPC_PASS=polarpass
1112
RPC_PORT=43782
13+
LNURL_SERVER_PORT="${LNURL_SERVER_PORT:-30001}"
14+
LNURL_SERVER_URL="${LNURL_SERVER_URL:-http://localhost:${LNURL_SERVER_PORT}}"
1215

1316
BASE_COMMAND=(docker compose exec $BITCOIN_CONTAINER bitcoin-cli -rpcport=$RPC_PORT -rpcuser=$RPC_USER -rpcpassword=$RPC_PASS)
1417
LNCLI_CMD=(docker compose exec -T $LND_CONTAINER lncli --lnddir "$LND_DIR" --network regtest)
@@ -34,6 +37,12 @@ Commands:
3437
holdinvoice [amount] [-m memo] Create a hold invoice
3538
settleinvoice <preimage> Reveal a preimage and use it to settle the corresponding invoice
3639
cancelinvoice <payment_hash> Cancels a currently open invoice
40+
LNURL (requires LNURL server, default: ${LNURL_SERVER_URL}):
41+
startlnurlserver [--port N] Start LNURL server using current e2e Bitcoin/LND
42+
stoplnurlserver Stop LNURL server container
43+
checklnurl Diagnose LNURL health, routing, and adb reverse
44+
getlnurlpay --msat N Create LNURL-pay with fixed msat amount
45+
getlnurlwithdraw --msat N Create LNURL-withdraw with fixed msat amount
3746
EOF
3847
}
3948

@@ -273,6 +282,333 @@ if [[ "$command" = "getlninvoice" ]]; then
273282
exit
274283
fi
275284

285+
# Start LNURL server stack (bitkit-docker)
286+
if [[ "$command" = "startlnurlserver" ]]; then
287+
shift
288+
289+
BITKIT_DOCKER_DIR="${BITKIT_DOCKER_DIR:-$SCRIPT_DIR/../../bitkit-docker}"
290+
LNURL_IMAGE="${LNURL_IMAGE:-bitkit-lnurl-server:local}"
291+
E2E_DOCKER_DIR="$SCRIPT_DIR"
292+
LNURL_DATA_DIR="${LNURL_DATA_DIR:-$SCRIPT_DIR/lnurl-server-data}"
293+
runtime_port="$LNURL_SERVER_PORT"
294+
295+
while [[ $# -gt 0 ]]; do
296+
case "$1" in
297+
--port)
298+
runtime_port="${2:-}"
299+
shift 2
300+
;;
301+
-*)
302+
echo "Unknown option $1"
303+
echo "Usage: $CLI_NAME startlnurlserver [--port N]"
304+
exit 1
305+
;;
306+
*)
307+
if [[ "$1" =~ ^[0-9]+$ ]]; then
308+
runtime_port="$1"
309+
shift
310+
else
311+
echo "Invalid port '$1' (expected integer)"
312+
exit 1
313+
fi
314+
;;
315+
esac
316+
done
317+
318+
if ! [[ "$runtime_port" =~ ^[0-9]+$ ]]; then
319+
echo "Invalid port '$runtime_port' (expected integer)"
320+
exit 1
321+
fi
322+
323+
if [[ ! -d "$BITKIT_DOCKER_DIR" ]]; then
324+
echo "bitkit-docker directory not found at '$BITKIT_DOCKER_DIR'"
325+
echo "Tip: set BITKIT_DOCKER_DIR to your path, e.g."
326+
echo " BITKIT_DOCKER_DIR=../bitkit-docker $CLI_NAME startlnurlserver"
327+
exit 1
328+
fi
329+
330+
if [[ ! -f "$BITKIT_DOCKER_DIR/docker-compose.yml" ]]; then
331+
echo "No docker-compose.yml found in '$BITKIT_DOCKER_DIR'"
332+
exit 1
333+
fi
334+
335+
if [[ ! -d "$E2E_DOCKER_DIR/lnd" ]]; then
336+
echo "Expected LND data at '$E2E_DOCKER_DIR/lnd' but it was not found."
337+
exit 1
338+
fi
339+
340+
if ! docker image inspect "$LNURL_IMAGE" >/dev/null 2>&1; then
341+
echo "→ Building LNURL image '$LNURL_IMAGE' from '$BITKIT_DOCKER_DIR/lnurl-server'..."
342+
docker build -t "$LNURL_IMAGE" "$BITKIT_DOCKER_DIR/lnurl-server" >/dev/null || {
343+
echo "Failed to build LNURL image"
344+
exit 1
345+
}
346+
fi
347+
348+
mkdir -p "$LNURL_DATA_DIR"
349+
350+
echo "→ Starting LNURL server wired to current e2e docker stack on port ${runtime_port}..."
351+
docker rm -f lnurl-server >/dev/null 2>&1 || true
352+
docker run -d \
353+
--name lnurl-server \
354+
-p "${runtime_port}:3000" \
355+
-e NODE_ENV=production \
356+
-e PORT=3000 \
357+
-e DOMAIN="http://localhost:${runtime_port}" \
358+
-e BITCOIN_RPC_HOST=host.docker.internal \
359+
-e BITCOIN_RPC_PORT=43782 \
360+
-e BITCOIN_RPC_USER=polaruser \
361+
-e BITCOIN_RPC_PASS=polarpass \
362+
-e LND_REST_HOST=host.docker.internal \
363+
-e LND_REST_PORT=8080 \
364+
-e LND_MACAROON_PATH=/lnd-certs/data/chain/bitcoin/regtest/admin.macaroon \
365+
-e LND_TLS_CERT_PATH=/lnd-certs/tls.cert \
366+
-v "$E2E_DOCKER_DIR/lnd:/lnd-certs:ro" \
367+
-v "$LNURL_DATA_DIR:/data" \
368+
"$LNURL_IMAGE" >/dev/null || {
369+
echo "Failed to start LNURL server container"
370+
exit 1
371+
}
372+
373+
echo "✓ LNURL server started"
374+
echo "Health check:"
375+
echo " curl -s http://localhost:${runtime_port}/health | jq"
376+
exit
377+
fi
378+
379+
# Stop LNURL server
380+
if [[ "$command" = "stoplnurlserver" ]]; then
381+
container_id=$(docker ps -a --filter "name=^lnurl-server$" --format "{{.ID}}" | head -n 1)
382+
if [[ -n "$container_id" ]]; then
383+
docker rm -f lnurl-server >/dev/null 2>&1 || true
384+
echo "✓ LNURL server stopped"
385+
else
386+
echo "LNURL server is not running"
387+
fi
388+
exit
389+
fi
390+
391+
# Check LNURL setup and prerequisites
392+
if [[ "$command" = "checklnurl" ]]; then
393+
health_url="${LNURL_SERVER_URL%/}/health"
394+
395+
echo "→ Checking LNURL health at ${health_url}..."
396+
health_json=""
397+
for _ in $(seq 1 10); do
398+
health_json=$(curl -s "$health_url" 2>/dev/null || true)
399+
if [[ -n "$health_json" ]]; then
400+
break
401+
fi
402+
sleep 1
403+
done
404+
if [[ -z "$health_json" ]]; then
405+
echo "✗ LNURL health endpoint is unreachable"
406+
echo " Start server: $CLI_NAME startlnurlserver --port ${LNURL_SERVER_PORT}"
407+
echo " Or set LNURL_SERVER_URL to your endpoint."
408+
exit 1
409+
fi
410+
411+
status=$(echo "$health_json" | jq -r '.status // "unknown"' 2>/dev/null || echo "unknown")
412+
btc_ok=$(echo "$health_json" | jq -r '.bitcoin_connected // false' 2>/dev/null || echo "false")
413+
lnd_ok=$(echo "$health_json" | jq -r '.lnd_connected // false' 2>/dev/null || echo "false")
414+
block_height=$(echo "$health_json" | jq -r '.block_height // "n/a"' 2>/dev/null || echo "n/a")
415+
416+
echo " status: $status"
417+
echo " bitcoin_connected: $btc_ok"
418+
echo " lnd_connected: $lnd_ok"
419+
echo " block_height: $block_height"
420+
421+
echo ""
422+
echo "→ Checking local LND channel/liquidity..."
423+
channel_balance=$(docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T "$LND_CONTAINER" \
424+
lncli --lnddir "$LND_DIR" --network regtest channelbalance 2>/dev/null || true)
425+
channels=$(docker compose -f "$SCRIPT_DIR/docker-compose.yml" exec -T "$LND_CONTAINER" \
426+
lncli --lnddir "$LND_DIR" --network regtest listchannels 2>/dev/null || true)
427+
428+
if [[ -z "$channel_balance" || -z "$channels" ]]; then
429+
echo " ⚠ Could not read local LND state from e2e docker stack."
430+
else
431+
local_sat=$(echo "$channel_balance" | jq -r '.local_balance.sat // "0"' 2>/dev/null || echo "0")
432+
remote_sat=$(echo "$channel_balance" | jq -r '.remote_balance.sat // "0"' 2>/dev/null || echo "0")
433+
active_count=$(echo "$channels" | jq -r '[.channels[] | select(.active==true)] | length' 2>/dev/null || echo "0")
434+
total_count=$(echo "$channels" | jq -r '.channels | length // 0' 2>/dev/null || echo "0")
435+
436+
echo " channels: ${active_count} active / ${total_count} total"
437+
echo " lnd outbound: ${local_sat} sats (can pay)"
438+
echo " lnd inbound: ${remote_sat} sats (can receive)"
439+
fi
440+
441+
echo ""
442+
echo "→ Checking Android adb reverse for port ${LNURL_SERVER_PORT}..."
443+
if ! command -v adb >/dev/null 2>&1; then
444+
echo " adb not found (skip)"
445+
else
446+
devices=$(adb devices 2>/dev/null | awk 'NR>1 && $2=="device" {print $1}')
447+
if [[ -z "$devices" ]]; then
448+
echo " no connected Android devices/emulators"
449+
else
450+
reverse_list=$(adb reverse --list 2>/dev/null || true)
451+
if echo "$reverse_list" | grep -q "tcp:${LNURL_SERVER_PORT}[[:space:]]\+tcp:${LNURL_SERVER_PORT}"; then
452+
echo " ✓ adb reverse is configured for tcp:${LNURL_SERVER_PORT}"
453+
else
454+
echo " ⚠ adb reverse missing for tcp:${LNURL_SERVER_PORT}"
455+
echo " Run: adb reverse tcp:${LNURL_SERVER_PORT} tcp:${LNURL_SERVER_PORT}"
456+
fi
457+
fi
458+
fi
459+
460+
echo ""
461+
if [[ "$status" = "healthy" && "$btc_ok" = "true" && "$lnd_ok" = "true" ]]; then
462+
echo "✓ LNURL diagnostics look good."
463+
exit 0
464+
fi
465+
466+
echo "⚠ LNURL diagnostics found issues."
467+
exit 1
468+
fi
469+
470+
# Create LNURL-pay (fixed msat amount)
471+
if [[ "$command" = "getlnurlpay" ]]; then
472+
shift
473+
474+
msat=""
475+
comment_allowed=0
476+
477+
while [[ $# -gt 0 ]]; do
478+
case "$1" in
479+
--msat)
480+
msat="${2:-}"
481+
shift 2
482+
;;
483+
--comment-allowed)
484+
comment_allowed="${2:-0}"
485+
shift 2
486+
;;
487+
-*)
488+
echo "Unknown option $1"
489+
echo "Usage: $CLI_NAME getlnurlpay --msat N [--comment-allowed N]"
490+
exit 1
491+
;;
492+
*)
493+
if [[ "$1" =~ ^[0-9]+$ ]]; then
494+
msat="$1"
495+
shift
496+
else
497+
echo "Invalid value '$1' (expected integer msats)"
498+
exit 1
499+
fi
500+
;;
501+
esac
502+
done
503+
504+
if [[ -z "$msat" ]]; then
505+
echo "Usage: $CLI_NAME getlnurlpay --msat N [--comment-allowed N]"
506+
exit 1
507+
fi
508+
509+
if ! [[ "$msat" =~ ^[0-9]+$ ]]; then
510+
echo "Invalid msat '$msat' (expected integer)"
511+
exit 1
512+
fi
513+
514+
if ! [[ "$comment_allowed" =~ ^[0-9]+$ ]]; then
515+
echo "Invalid comment length '$comment_allowed' (expected integer)"
516+
exit 1
517+
fi
518+
519+
url="${LNURL_SERVER_URL%/}/generate/pay?minSendable=${msat}&maxSendable=${msat}&commentAllowed=${comment_allowed}"
520+
echo "→ Creating LNURL-pay (${msat} msat fixed amount) via ${LNURL_SERVER_URL}..."
521+
522+
result=$(curl -sf "$url") || {
523+
echo "Failed to reach LNURL server at ${LNURL_SERVER_URL}"
524+
echo "Tip: verify server with '$CLI_NAME checklnurl' or set LNURL_SERVER_URL."
525+
exit 1
526+
}
527+
528+
lnurl=$(echo "$result" | jq -r '.lnurl // empty' 2>/dev/null) || lnurl=""
529+
if [[ -z "$lnurl" ]]; then
530+
echo "${result:-LNURL server returned no output}"
531+
exit 1
532+
fi
533+
534+
echo ""
535+
echo "$lnurl"
536+
echo ""
537+
538+
if command -v pbcopy &>/dev/null; then
539+
echo "$lnurl" | pbcopy
540+
echo "LNURL copied to clipboard."
541+
fi
542+
543+
exit
544+
fi
545+
546+
# Create LNURL-withdraw (fixed msat amount)
547+
if [[ "$command" = "getlnurlwithdraw" ]]; then
548+
shift
549+
550+
msat=""
551+
552+
while [[ $# -gt 0 ]]; do
553+
case "$1" in
554+
--msat)
555+
msat="${2:-}"
556+
shift 2
557+
;;
558+
-*)
559+
echo "Unknown option $1"
560+
echo "Usage: $CLI_NAME getlnurlwithdraw --msat N"
561+
exit 1
562+
;;
563+
*)
564+
if [[ "$1" =~ ^[0-9]+$ ]]; then
565+
msat="$1"
566+
shift
567+
else
568+
echo "Invalid value '$1' (expected integer msats)"
569+
exit 1
570+
fi
571+
;;
572+
esac
573+
done
574+
575+
if [[ -z "$msat" ]]; then
576+
echo "Usage: $CLI_NAME getlnurlwithdraw --msat N"
577+
exit 1
578+
fi
579+
580+
if ! [[ "$msat" =~ ^[0-9]+$ ]]; then
581+
echo "Invalid msat '$msat' (expected integer)"
582+
exit 1
583+
fi
584+
585+
url="${LNURL_SERVER_URL%/}/generate/withdraw?minWithdrawable=${msat}&maxWithdrawable=${msat}"
586+
echo "→ Creating LNURL-withdraw (${msat} msat fixed amount) via ${LNURL_SERVER_URL}..."
587+
588+
result=$(curl -sf "$url") || {
589+
echo "Failed to reach LNURL server at ${LNURL_SERVER_URL}"
590+
echo "Tip: verify server with '$CLI_NAME checklnurl' or set LNURL_SERVER_URL."
591+
exit 1
592+
}
593+
594+
lnurl=$(echo "$result" | jq -r '.lnurl // empty' 2>/dev/null) || lnurl=""
595+
if [[ -z "$lnurl" ]]; then
596+
echo "${result:-LNURL server returned no output}"
597+
exit 1
598+
fi
599+
600+
echo ""
601+
echo "$lnurl"
602+
echo ""
603+
604+
if command -v pbcopy &>/dev/null; then
605+
echo "$lnurl" | pbcopy
606+
echo "LNURL copied to clipboard."
607+
fi
608+
609+
exit
610+
fi
611+
276612
# Open channel from LND to a node
277613
if [[ "$command" = "openchannel" ]]; then
278614
shift

0 commit comments

Comments
 (0)