diff --git a/scripts/lndhub/credit-account.sh b/scripts/lndhub/credit-account.sh new file mode 100755 index 0000000..ff1cfec --- /dev/null +++ b/scripts/lndhub/credit-account.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Manually record an incoming Lightning payment to credit a lndhub.go user account. +# +# Mirrors the canonical incoming-settlement path in lib/service/invoicesubscription.go +# (ProcessInvoiceUpdate): inserts a settled `invoices` row plus a `transaction_entries` +# row crediting the user's `current` account and debiting their `incoming` account +# (which is exempt from the non-negative check_balance() trigger, so it may go negative). +# +# Usage: +# ./gitno/credit-account.sh --env-file \ +# --login --sats [--memo "manual credit"] +# +# The --env-file is the lndhub.go .env file (the same one loaded by godotenv in +# cmd/server/main.go). Only DATABASE_URI is read from it; the value from the file +# ALWAYS takes precedence over any DATABASE_URI already present in the environment. +# +# Requires: psql. The whole operation runs in one transaction; ON_ERROR_STOP=1 +# aborts on any error so the tx is rolled back and psql exits non-zero. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: credit-account.sh --env-file --login --sats [--memo "..."] + +Options: + --env-file Path to the lndhub.go .env file (required). Only DATABASE_URI is + read from it; the file value always wins over the environment. + --login User login (required) + --sats Amount in satoshis to credit (required, positive integer) + --memo Memo for the recorded invoice (default: "manual credit") + -h, --help +EOF + exit "${1:-0}" +} + +env_file="" +login="" +sats="" +memo="manual credit" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) env_file="$2"; shift 2 ;; + --login) login="$2"; shift 2 ;; + --sats) sats="$2"; shift 2 ;; + --memo) memo="$2"; shift 2 ;; + -h|--help) usage 0 ;; + *) echo "Unknown argument: $1" >&2; usage 1 ;; + esac +done + +[[ -n "$env_file" ]] || { echo "Error: --env-file is required" >&2; usage 1; } +[[ -n "$login" ]] || { echo "Error: --login is required" >&2; usage 1; } +[[ -n "$sats" ]] || { echo "Error: --sats is required" >&2; usage 1; } +[[ "$sats" =~ ^[1-9][0-9]*$ ]] || { echo "Error: --sats must be a positive integer" >&2; usage 1; } + +# Sanity-check psql is available. +command -v psql >/dev/null || { echo "Error: psql not found in PATH" >&2; exit 1; } + +# Extract a single KEY=VALUE entry from a .env file, matching godotenv semantics: +# - skips blank lines and lines whose first non-whitespace char is '#' +# - tolerates an optional leading 'export ' +# - case-insensitive key match +# - strips one pair of surrounding matching quotes (" or ') from the value +# Writes the result to stdout; empty output means "not found". +extract_env_key() { + local file="$1" key="$2" line k v + while IFS= read -r line || [[ -n "$line" ]]; do + # Trim leading whitespace; skip blanks and comments + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue + # Strip optional 'export ' prefix + [[ "$line" == export\ * ]] && line="${line#export }" + # Split on the first '=' + [[ "$line" != *=* ]] && continue + k="${line%%=*}" + v="${line#*=}" + # Case-insensitive key compare + if [[ "${k,,}" == "${key,,}" ]]; then + # Strip one pair of surrounding matching single or double quotes + if [[ ${#v} -ge 2 ]]; then + if [[ "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then v="${v:1:-1}"; + elif [[ "${v:0:1}" == "'" && "${v: -1}" == "'" ]]; then v="${v:1:-1}"; fi + fi + printf '%s\n' "$v" + return 0 + fi + done < "$file" + return 1 +} + +[[ -r "$env_file" ]] || { echo "Error: --env-file not readable: $env_file" >&2; exit 1; } + +# DATABASE_URI is sourced exclusively from --env-file; the file value always wins +# over any DATABASE_URI already present in the environment. +if ! DATABASE_URI="$(extract_env_key "$env_file" DATABASE_URI)"; then + echo "Error: DATABASE_URI not found in $env_file" >&2 + exit 1 +fi +[[ -n "$DATABASE_URI" ]] || { echo "Error: DATABASE_URI is empty in $env_file" >&2; exit 1; } + +case "$DATABASE_URI" in + postgres://*|postgresql://*) ;; + *) echo "Error: DATABASE_URI must start with postgres:// or postgresql:// (got: $DATABASE_URI)" >&2; exit 1 ;; +esac + +psql "$DATABASE_URI" \ + -v ON_ERROR_STOP=1 \ + -v login="$login" \ + -v sats="$sats" \ + -v memo="$memo" \ + <<'SQL' +\set ON_ERROR_STOP on +\echo Resolving user, accounts, and recording the credit in one transaction... + +-- Pass the CLI args into server-side GUCs so the DO block (dollar-quoted, where +-- psql :var substitution does NOT happen) can read them via current_setting(). +-- These SETs run outside any dollar-quote, so :'login'/:sats/:'memo' are substituted +-- by psql as a properly-quoted SQL literal / bare token here. +SET app.lndhub_login = :'login'; +SET app.lndhub_sats = :sats; +SET app.lndhub_memo = :'memo'; + +BEGIN; + +-- Look up user + accounts, validate, and insert the invoice + ledger entry. +-- Raises an exception (aborting the tx, psql exits non-zero via ON_ERROR_STOP) +-- with a helpful message if the user or either account is missing. +DO $$ +DECLARE + v_uid bigint; + v_curr bigint; + v_inc bigint; + v_inv_id bigint; + v_login text := current_setting('app.lndhub_login'); + v_sats bigint := current_setting('app.lndhub_sats')::bigint; + v_memo text := current_setting('app.lndhub_memo'); +BEGIN + SELECT id INTO v_uid FROM users WHERE login = v_login; + IF NOT FOUND THEN + RAISE EXCEPTION 'no user found with login = %', v_login; + END IF; + + SELECT id INTO v_curr FROM accounts WHERE user_id = v_uid AND type = 'current'; + IF NOT FOUND THEN + RAISE EXCEPTION 'user % has no current account', v_uid; + END IF; + + SELECT id INTO v_inc FROM accounts WHERE user_id = v_uid AND type = 'incoming'; + IF NOT FOUND THEN + RAISE EXCEPTION 'user % has no incoming account', v_uid; + END IF; + + RAISE NOTICE 'user_id=% current_acct=% incoming_acct=% amount=% sats', + v_uid, v_curr, v_inc, v_sats; + + -- Settled incoming invoice. Constraints honored: + -- * destination_pubkey_hex NOT NULL -> 66-hex dummy + -- * settled => preimage NOT NULL (check_primage_exists) + -- * fresh r_hash/preimage per run keeps rows distinct + -- * uses gen_random_uuid() (built-in core since PG13, no pgcrypto needed); + -- 32 hex chars is sufficient for a dummy identifier + INSERT INTO invoices + (type, user_id, amount, memo, destination_pubkey_hex, r_hash, preimage, + state, settled_at, created_at, internal) + VALUES + ('incoming', v_uid, v_sats, v_memo, + '000000000000000000000000000000000000000000000000000000000000000000', + replace(gen_random_uuid()::text, '-', ''), + replace(gen_random_uuid()::text, '-', ''), + 'settled', now(), now(), false) + RETURNING id INTO v_inv_id; + + RAISE NOTICE 'invoice_id=%', v_inv_id; + + -- Ledger entry: credit current (+amount), debit incoming (-amount). + -- * debit_account_id != credit_account_id (current != incoming) -> check_not_same_account ok + -- * debit on `incoming` is exempt from check_balance() -> may go negative + -- * fresh invoice_id keeps unique_tx_entry_tuple satisfied + INSERT INTO transaction_entries + (user_id, invoice_id, credit_account_id, debit_account_id, amount, entry_type, created_at) + VALUES + (v_uid, v_inv_id, v_curr, v_inc, v_sats, 'incoming', now()); + + RAISE NOTICE 'credited % sats to user % (login=%)', v_sats, v_uid, v_login; +END $$; + +COMMIT; + +-- Resulting current-account balance (same query as CurrentUserBalance in +-- lib/service/user.go: sum over account_ledgers for the user's current account). +SELECT + (SELECT sum(al.amount) + FROM account_ledgers al + JOIN accounts a ON a.id = al.account_id + WHERE a.user_id = (SELECT id FROM users WHERE login = current_setting('app.lndhub_login')) + AND a.type = 'current') AS new_balance_sat; +SQL