Onchain / Lightning swaps on Voltage using Loop
8 min
Lightning Loop on Voltage — Full REST Examples
What you need (one-time)
- Your node’s public API host (from the Voltage dashboard).
- Your SUPER ADMIN MACAROON (from the Voltage dashboard).
- Requests authenticate with header Grpc-Metadata-macaroon carrying the hex of your macaroon.
GUI alternative: You can also do all of this from Terminal Web (LiT bundles Loop). Pair via LNC from your Voltage dashboard
Quick env setup
# Required: set your host and macaroon (hex).
export HOST="<your-node-hostname>:443"
# If you have the file locally:
# export MACAROON_HEX=$(xxd -ps -u -c 1000 /path/to/loop.macaroon)
# Sanity check:
curl -s "https://$HOST/v1/loop/info" \
-H "Grpc-Metadata-macaroon: $MACAROON_HEX" | jq .
/v1/loop/info returns daemon details when auth is good. (Lightning Engineering)
Drop-in script with both swaps and full lifecycle
Save as loop_examples.sh, run chmod +x loop_examples.sh. to make it executable.
Requires jq and xxd.
#!/usr/bin/env bash
set -euo pipefail
# -------- CONFIG YOU CAN TWEAK --------
: "${HOST:?Set HOST (e.g. node-123.voltageapp.io OR node-123.voltageapp.io:443)}"
: "${MACAROON_HEX:?Set MACAROON_HEX (hex of Super Admin macaroon)}"
# Fee policy defaults (simple + safe starting points).
# For server fee: ~1% of amt, hard-capped.
SWAP_FEE_PERCENT_DEFAULT=1 # 1%
SWAP_FEE_HARDCAP_SATS_DEFAULT=3000
# Miner fee cap (sweep/publish): start with quote's miner fee * 2 as a buffer.
MINER_FEE_MULTIPLIER_DEFAULT=2
# Off-chain routing fee cap (Loop Out):
ROUTE_FEE_MSAT_DEFAULT=50000 # 50k msat
# Sweep/publish conf target:
CONF_TARGET_DEFAULT=6
# Poll interval:
POLL_SECS=15
POLL_TIMEOUT_SECS=$((60*60)) # 1 hour
hdr() { echo -n "Grpc-Metadata-macaroon: $MACAROON_HEX"; }
get_json() {
local path="$1"
curl -sS "https://$HOST$path" -H "$(hdr)"
}
post_json() {
local path="$1" body="$2"
curl -sS "https://$HOST$path" -H "$(hdr)" -H 'Content-Type: application/json' -d "$body"
}
ceil_div() { awk -v n="$1" -v d="$2" 'BEGIN{printf "%d\n", (n + d - 1)/d}'; }
budget_caps_from_amt() {
local amt_sats="$1"
local fee_pct="${2:-$SWAP_FEE_PERCENT_DEFAULT}"
local hardcap="${3:-$SWAP_FEE_HARDCAP_SATS_DEFAULT}"
local pct_fee=$(( amt_sats * fee_pct / 100 ))
if (( pct_fee > hardcap )); then echo "$hardcap"; else echo "$pct_fee"; fi
}
poll_swap() {
local id="$1" start_ts
start_ts="$(date +%s)"
echo "Polling /v1/loop/swap/{id} every ${POLL_SECS}s… (timeout ${POLL_TIMEOUT_SECS}s)"
while :; do
local now js state
now="$(date +%s)"
if (( now - start_ts > POLL_TIMEOUT_SECS )); then
echo "Timeout reached; last known state:" >&2
get_json "/v1/loop/swap/${id}" | jq .
exit 1
fi
js="$(get_json "/v1/loop/swap/${id}")"
state="$(jq -r '.state' <<<"$js")"
echo "$(date -Is) state=$state"
case "$state" in
SUCCESS|SUCCEEDED) echo "Swap succeeded ✅"; jq . <<<"$js"; break;;
FAILED) echo "Swap failed ❌"; jq . <<<"$js"; exit 2;;
*) sleep "$POLL_SECS";;
esac
done
}
# ============= LOOP OUT (Lightning -> On-chain) =============
loop_out() {
local amt_sats="$1" dest_addr="$2"
local conf_target="${3:-$CONF_TARGET_DEFAULT}"
echo "== Loop Out: terms =="
get_json "/v1/loop/out/terms" | jq . # min/max, cltv ranges
echo "== Loop Out: quote for ${amt_sats} sats (conf_target=$conf_target) =="
local quote
quote="$(get_json "/v1/loop/out/quote/${amt_sats}?conf_target=${conf_target}")"
echo "$quote" | jq .
local swap_fee prepay_amt sweep_fee
swap_fee="$(jq -r '.swap_fee_sat|tonumber' <<<"$quote")"
prepay_amt="$(jq -r '.prepay_amt_sat|tonumber' <<<"$quote")"
sweep_fee="$(jq -r '.htlc_sweep_fee_sat|tonumber' <<<"$quote")"
# Compute budget caps
local max_swap_fee max_miner_fee max_route_msat
max_swap_fee="$(budget_caps_from_amt "$amt_sats")" # includes prepay by contract
max_miner_fee=$(( sweep_fee * MINER_FEE_MULTIPLIER_DEFAULT ))
max_route_msat="${ROUTE_FEE_MSAT_DEFAULT}"
echo "== Budget check =="
echo "Server fee quote: ${swap_fee} sats (prepay ${prepay_amt} sats is part of server fee)"
echo "Miner (sweep) fee est: ${sweep_fee} sats; cap we will set: ${max_miner_fee} sats"
echo "Server fee cap we will set: ${max_swap_fee} sats (policy ~1% up to ${SWAP_FEE_HARDCAP_SATS_DEFAULT})"
if (( swap_fee > max_swap_fee )); then
echo "✋ Quote swap_fee (${swap_fee}) exceeds cap (${max_swap_fee}). Tune caps or amount." >&2
exit 3
fi
echo "== Loop Out: create swap =="
local body created id
body="$(jq -n --argjson amt "$amt_sats" \
--arg dest "$dest_addr" \
--argjson max_swap_fee "$max_swap_fee" \
--argjson max_miner_fee "$max_miner_fee" \
--argjson max_prepay_amt "$prepay_amt" \
--argjson max_swap_routing_fee "$max_route_msat" \
--argjson sweep_conf_target "$conf_target" \
'{amt, dest, max_swap_fee, max_miner_fee, max_prepay_amt, max_swap_routing_fee, sweep_conf_target}')"
created="$(post_json "/v1/loop/out" "$body")"
echo "$created" | jq .
id="$(jq -r '.id' <<<"$created")"
echo "== Loop Out: monitor swap id =="
poll_swap "$id"
}
# ============= LOOP IN (On-chain -> Lightning) =============
loop_in() {
local amt_sats="$1"
local conf_target="${2:-$CONF_TARGET_DEFAULT}"
echo "== Loop In: terms =="
get_json "/v1/loop/in/terms" | jq .
echo "== (optional) Probe routeability for ${amt_sats} sats =="
get_json "/v1/loop/in/probe/${amt_sats}" | jq .
echo "== Loop In: quote for ${amt_sats} sats (htlc conf_target=$conf_target) =="
local quote
quote="$(get_json "/v1/loop/in/quote/${amt_sats}?conf_target=${conf_target}")"
echo "$quote" | jq .
local swap_fee publish_fee
swap_fee="$(jq -r '.swap_fee_sat|tonumber' <<<"$quote")"
publish_fee="$(jq -r '.htlc_publish_fee_sat|tonumber' <<<"$quote")"
# Compute budget caps
local max_swap_fee max_miner_fee
max_swap_fee="$(budget_caps_from_amt "$amt_sats")"
max_miner_fee=$(( publish_fee * MINER_FEE_MULTIPLIER_DEFAULT ))
echo "== Budget check =="
echo "Server fee quote: ${swap_fee} sats"
echo "Publish fee est: ${publish_fee} sats; cap we will set: ${max_miner_fee} sats"
echo "Server fee cap we will set: ${max_swap_fee} sats (policy ~1% up to ${SWAP_FEE_HARDCAP_SATS_DEFAULT})"
if (( swap_fee > max_swap_fee )); then
echo "✋ Quote swap_fee (${swap_fee}) exceeds cap (${max_swap_fee}). Tune caps or amount." >&2
exit 3
fi
echo "== Loop In: create swap =="
local body created id
body="$(jq -n --argjson amt "$amt_sats" \
--argjson max_swap_fee "$max_swap_fee" \
--argjson max_miner_fee "$max_miner_fee" \
--argjson htlc_conf_target "$conf_target" \
'{amt, max_swap_fee, max_miner_fee, htlc_conf_target}')"
created="$(post_json "/v1/loop/in" "$body")"
echo "$created" | jq .
id="$(jq -r '.id' <<<"$created")"
echo "== Loop In: monitor swap id =="
poll_swap "$id"
}
# -------- CL Interface --------
usage() {
cat <<USAGE
Usage:
$0 out <AMT_SATS> <ONCHAIN_DEST> [CONF_TARGET]
$0 in <AMT_SATS> [CONF_TARGET]
Examples:
$0 out 250000 bc1qyourdest... 6
$0 in 250000 6
USAGE
}
cmd="${1:-}"; shift || true
case "${cmd:-}" in
out) : "${1:?AMT_SATS}"; : "${2:?DEST_ADDR}"; loop_out "$1" "$2" "${3:-}";;
in) : "${1:?AMT_SATS}"; loop_in "$1" "${2:-}";;
*) usage; exit 1;;
esac
What this script demonstrates (mapping to Loop API)
- Terms: GET /v1/loop/out/terms and GET /v1/loop/in/terms for bounds (min/max, CLTV ranges). (Lightning Engineering)
- Quote: GET /v1/loop/out/quote/{amt} (returns swap_fee_sat, prepay_amt_sat, htlc_sweep_fee_sat) and GET /v1/loop/in/quote/{amt} (returns swap_fee_sat, htlc_publish_fee_sat). (Lightning Engineering)
- Budget check: We compare the quote vs caps you’ll actually send in the create-swap call:
- Loop Out caps: max_swap_fee (includes prepay), max_miner_fee, max_swap_routing_fee, sweep_conf_target. (Lightning Engineering)
- Loop In caps: max_swap_fee, max_miner_fee, htlc_conf_target. (Lightning Engineering)
- Create swap: POST /v1/loop/out and POST /v1/loop/in; response includes the id you’ll use to monitor. (Lightning Engineering)
- Poll: GET /v1/loop/swap/{id}. The state eventually becomes SUCCESS or FAILED; we poll until a terminal state. (These states are enumerated in Monitor/ListSwaps docs.) (Lightning Engineering)
Fee & reliability playbook (simple policy you can adjust)
- Always fetch terms → quote first; populate your caps from the quote. (Lightning Engineering)
- Start with ≈1% of amount for max_swap_fee (cap with a hard ceiling you’re comfortable with, e.g., 100–3000 sats).
- Keep max_miner_fee ≈ 2× the miner fee estimate from the quote to absorb fee volatility; tune *_conf_target to trade speed vs cost. (Loop Out uses sweep_conf_target; Loop In uses htlc_conf_target.) (Lightning Engineering)
- If a Loop Out fails due to routing, retry once with a higher max_swap_routing_fee (keep server fee cap the same).
- For Loop In, you can probe with /v1/loop/in/probe/{amt} to sanity-check routeability before creating the swap. (Lightning Engineering)
Voltage-specific notes
- TLS: your node’s API host uses a trusted CA-signed certificate; you don’t need to pass a local tls.cert to curl.
- Macaroons: Loop requires Super Admin Macaroon to use it throught the API on Voltage.
- Access control: If you’ve enabled IP whitelisting on your node, be sure your client IP is allowed for both REST and gRPC APIs. (Voltage Documentation)
- UI: Prefer a GUI? Use Terminal Web (LiT bundles Loop/Pool/Faraday) via an LNC pairing phrase from your Voltage dashboard.
Endpoint reference (official)
- Monitro Single Swap: /v1/loop/swap/{id} - https://lightning.engineering/api-docs/api/loop/swap-client/swap-info/
- Monitor Multiple Swaps: /v1/loop/swaps - https://lightning.engineering/api-docs/api/loop/swap-client/list-swaps/