I've been using Coinos as a Lightning wallet for receiving small payments, but I wanted to automatically sweep the balance to my own Lightning node instead of keeping sats on a custodial service.
What This Script Does
This bash script automatically:
- Checks your Coinos balance via their API
- Creates a Lightning invoice on your node for the available amount
- Pays that invoice through Coinos, effectively withdrawing to your node
- Keeps a configurable reserve amount in your Coinos account
Prerequisites
- A running LND Lightning node accessible via Docker
- Coinos account with API access
curl
,jq
, anddocker
installed on your system
Setup Instructions
-
Get your Coinos API token:
- Go to https://coinos.io/docs
- Copy the token
-
Configure the script:
- Replace
TOKEN="secret"
with your actual API token - Adjust
RESERVE_AMOUNT
(default: 2000 sats) - amount to keep in Coinos - Adjust
MIN_WITHDRAWAL
(default: 100 sats) - minimum amount to withdraw - Change
LND_CONTAINER
if your Docker container has a different name
- Replace
-
Make executable and test:
chmod +x coinos_payout.sh ./coinos_payout.sh
-
Automate with cron (optional):
# Run every hour 0 * * * * /path/to/coinos_payout.sh
Configuration Options
All the important settings are at the top of the script:
TOKEN
: Your Coinos API tokenRESERVE_AMOUNT
: Sats to keep in Coinos (default: 2000)MIN_WITHDRAWAL
: Minimum withdrawal threshold (default: 100 sats)LND_CONTAINER
: Docker container name for your LND nodeLOG_FILE
: Where to store logs
Why Use This?
- Self-custody: Automatically move sats from custodial Coinos to your node
- Automation: Set it and forget it with cron
- Reserve buffer: Keeps some sats in Coinos for immediate spending
The script includes error handling and logging, so you can monitor what's happening and troubleshoot if needed.
Security Notes
- Keep your API token secure and never commit it to version control
- Review the code before running to understand what it does
- The script has input validation and other security measures. But might not be bulletproof.
Notes
- Coinos does have an autowithdrawal feature to a Bitcoin or LN address. But after #1001011 I'd rather have my custom script.
- This assumes you're running LND in Docker. If you have a different setup, you'll need to modify the
lncli
command accordingly.
#!/bin/bash
# =============================================================================
# Coinos Auto-Payout Script (Security Hardened)
# =============================================================================
# This script automatically withdraws your Coinos balance via Lightning Network
# by creating an invoice on your Lightning node and paying it through Coinos API
#
# Requirements:
# - curl, jq, docker installed
# - Running LND node accessible via docker
# - Coinos API token with payment permissions
# =============================================================================
# Exit on any error, undefined variable, or pipe failure
set -euo pipefail
# =============================================================================
# CONFIGURATION - MODIFY THESE VALUES
# =============================================================================
# Your Coinos API token (get from https://coinos.io/docs)
# IMPORTANT: Replace "secret" with your actual token
TOKEN="secret"
# Coinos API base URL (usually no need to change)
API_BASE="https://coinos.io/api"
# Amount to keep as reserve in your Coinos account (in sats)
# This prevents withdrawing everything and accounts for potential fees
RESERVE_AMOUNT=2000
# Minimum withdrawal amount (in sats)
# Won't create withdrawal if available amount is below this threshold
MIN_WITHDRAWAL=100
# Log file location (in a secure directory)
LOG_FILE="$HOME/.coinos_payout.log"
# Docker container name for your LND node
# Change this if your LND container has a different name
LND_CONTAINER="lnd"
# Invoice memo/description
INVOICE_MEMO="coinos withdrawal"
# API timeout in seconds
API_TIMEOUT=30
# =============================================================================
# SCRIPT LOGIC - DO NOT MODIFY BELOW THIS LINE
# =============================================================================
# Function to log with timestamp
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
# Function to handle errors
error_exit() {
log "ERROR: $1"
exit 1
}
# Function to validate numeric input
validate_number() {
local value="$1"
local name="$2"
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
error_exit "Invalid $name format: must be a positive integer"
fi
}
# Function to validate container name
validate_container_name() {
local container="$1"
if ! [[ "$container" =~ ^[a-zA-Z0-9_-]+$ ]]; then
error_exit "Invalid container name: must contain only alphanumeric characters, underscores, and hyphens"
fi
}
# Function to validate Lightning invoice format
validate_invoice() {
local invoice="$1"
# Basic Lightning invoice validation (should start with ln)
if ! [[ "$invoice" =~ ^ln[a-zA-Z0-9]+$ ]]; then
error_exit "Invalid Lightning invoice format"
fi
# Check reasonable length (Lightning invoices are typically 200-400 characters)
local len=${#invoice}
if [ "$len" -lt 100 ] || [ "$len" -gt 1000 ]; then
error_exit "Lightning invoice has unusual length: $len characters"
fi
}
# Function to validate API token
validate_token() {
local token="$1"
if [ "$token" = "secret" ]; then
error_exit "Please set your actual API token in the TOKEN variable"
fi
if [ ${#token} -lt 10 ]; then
error_exit "API token appears to be too short"
fi
}
# Function to make secure API call
make_api_call() {
local endpoint="$1"
local method="${2:-GET}"
local data="${3:-}"
local curl_opts=(
-s
-f # Fail on HTTP errors
--max-time "$API_TIMEOUT"
--connect-timeout 10
-H "content-type: application/json"
-H "Authorization: Bearer $TOKEN"
)
if [ "$method" = "POST" ] && [ -n "$data" ]; then
curl_opts+=(-d "$data")
fi
local response
if ! response=$(curl "${curl_opts[@]}" "$endpoint" 2>/dev/null); then
error_exit "API call failed: $endpoint"
fi
echo "$response"
}
# Function to safely execute docker command
execute_docker_command() {
local container="$1"
local amount="$2"
local memo="$3"
# Validate inputs
validate_container_name "$container"
validate_number "$amount" "invoice amount"
# Sanitize memo (remove potentially dangerous characters)
local safe_memo
safe_memo=$(echo "$memo" | tr -cd '[:alnum:][:space:].-_')
# Execute with proper quoting
local response
if ! response=$(docker exec "$container" lncli addinvoice --amt "$amount" --memo "$safe_memo" 2>/dev/null); then
error_exit "Failed to create Lightning invoice (check if LND container '$container' is running)"
fi
echo "$response"
}
# Initialize secure log file
init_log_file() {
# Create log file with restricted permissions
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
}
# =============================================================================
# MAIN EXECUTION
# =============================================================================
# Check if required tools are available
command -v curl >/dev/null 2>&1 || error_exit "curl is required but not installed"
command -v jq >/dev/null 2>&1 || error_exit "jq is required but not installed"
command -v docker >/dev/null 2>&1 || error_exit "docker is required but not installed"
# Initialize secure log file
init_log_file
# Validate configuration
validate_token "$TOKEN"
validate_container_name "$LND_CONTAINER"
validate_number "$RESERVE_AMOUNT" "reserve amount"
validate_number "$MIN_WITHDRAWAL" "minimum withdrawal"
log "Starting Coinos payout process..."
# Get current balance
log "Getting balance..."
BALANCE_RESPONSE=$(make_api_call "$API_BASE/me")
# Validate and extract balance
if ! echo "$BALANCE_RESPONSE" | jq -e '.balance' >/dev/null 2>&1; then
error_exit "Invalid API response format - missing balance field"
fi
BALANCE=$(echo "$BALANCE_RESPONSE" | jq -r '.balance')
validate_number "$BALANCE" "balance"
log "Current balance: $BALANCE sats"
# Calculate amount to withdraw (balance minus reserve)
AMOUNT=$((BALANCE - RESERVE_AMOUNT))
log "Amount to withdraw: $AMOUNT sats (keeping $RESERVE_AMOUNT sats as reserve)"
# Check if there's enough to withdraw
if [ "$AMOUNT" -le "$MIN_WITHDRAWAL" ]; then
log "No funds available to withdraw (available: $AMOUNT sats, minimum: $MIN_WITHDRAWAL sats)"
exit 0
fi
# Validate withdrawal amount
if [ "$AMOUNT" -le 0 ]; then
error_exit "Invalid withdrawal amount: $AMOUNT sats"
fi
# Create Lightning invoice
log "Creating Lightning invoice for $AMOUNT sats..."
INVOICE_RESPONSE=$(execute_docker_command "$LND_CONTAINER" "$AMOUNT" "$INVOICE_MEMO")
log "Raw invoice response received"
# Validate and extract payment request
if ! echo "$INVOICE_RESPONSE" | jq -e '.payment_request' >/dev/null 2>&1; then
error_exit "Invalid invoice response format - missing payment_request field"
fi
INVOICE=$(echo "$INVOICE_RESPONSE" | jq -r '.payment_request' | tr -d '\r\n"' | sed 's/[[:space:]]//g')
if [ "$INVOICE" = "null" ] || [ -z "$INVOICE" ]; then
error_exit "Could not extract payment request from invoice response"
fi
# Validate invoice format
validate_invoice "$INVOICE"
log "Invoice created: ${INVOICE:0:50}..."
# Prepare payment data
PAYMENT_DATA=$(jq -n --arg payreq "$INVOICE" '{payreq: $payreq}')
# Pay the invoice through Coinos
log "Submitting payment request to Coinos..."
PAYMENT_RESPONSE=$(make_api_call "$API_BASE/payments" "POST" "$PAYMENT_DATA")
# Validate payment response
if ! echo "$PAYMENT_RESPONSE" | jq -e '.' >/dev/null 2>&1; then
error_exit "Invalid payment response format"
fi
# Check if payment was successful (this depends on the API structure)
if echo "$PAYMENT_RESPONSE" | jq -e '.error' >/dev/null 2>&1; then
ERROR_MSG=$(echo "$PAYMENT_RESPONSE" | jq -r '.error')
error_exit "Payment failed: $ERROR_MSG"
fi
log "Payment response: $PAYMENT_RESPONSE"
log "Payout process completed successfully"