הצטרף ל-Nostr
2025-07-11 08:47:34 UTC

Automated Coinos Withdrawals to Your Lightning Node

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: 1. Checks your Coinos balance via their API 2. Creates a Lightning invoice on your node for the available amount 3. Pays that invoice through Coinos, effectively withdrawing to your node 4. 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, and docker installed on your system

Setup Instructions

  1. Get your Coinos API token:

  2. 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
  3. Make executable and test:

    chmod +x coinos_payout.sh
    ./coinos_payout.sh
    
  4. 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 token
  • RESERVE_AMOUNT: Sats to keep in Coinos (default: 2000)
  • MIN_WITHDRAWAL: Minimum withdrawal threshold (default: 100 sats)
  • LND_CONTAINER: Docker container name for your LND node
  • LOG_FILE: Where to store logs

Why Use This?

  • Self-custody: Automatically move sats from custodial Coinos to your node
  • Dollar-cost averaging: Small regular withdrawals instead of manual large ones
  • 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 https://stacker.news/items/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"

https://stacker.news/items/1034295