Files
chef/scripts/lndhub/credit-account.sh
T

200 lines
7.9 KiB
Bash
Executable File

#!/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 <path/to/.env> \
# --login <login> --sats <N> [--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 <path/to/.env> --login <login> --sats <N> [--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