200 lines
7.9 KiB
Bash
Executable File
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
|