#!/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