Ctrl K
ComunidadGuías…

internal/store/postgres/donations.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"errors"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

type DonationStore struct {
	db sqlcgen.DBTX
}

func NewDonationStore(db sqlcgen.DBTX) *DonationStore {
	return &DonationStore{db: db}
}

func (s *DonationStore) WalletSeed(ctx context.Context) ([]byte, []byte, bool, error) {
	var seed, nonce []byte
	err := s.db.QueryRow(ctx, `SELECT encrypted_seed, nonce FROM donation_wallet WHERE id = 1`).Scan(&seed, &nonce)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, nil, false, nil
	}
	if err != nil {
		return nil, nil, false, fmt.Errorf("load donation wallet seed: %w", err)
	}
	return seed, nonce, true, nil
}

func (s *DonationStore) CreateWalletSeed(ctx context.Context, encryptedSeed, nonce []byte) error {
	_, err := s.db.Exec(ctx, `INSERT INTO donation_wallet (id, encrypted_seed, nonce) VALUES (1, $1, $2)`, encryptedSeed, nonce)
	if err != nil {
		if isUniqueViolation(err) {
			return domain.ErrDonationWalletAlreadyExists
		}
		return fmt.Errorf("create donation wallet seed: %w", err)
	}
	return nil
}

func (s *DonationStore) DonationAddressForUser(ctx context.Context, userID int64) (domain.DonationAddress, bool, error) {
	var out domain.DonationAddress
	err := s.db.QueryRow(ctx, `SELECT user_id, derivation_index, address, created_at
FROM donation_addresses WHERE user_id = $1`, userID).
		Scan(&out.UserID, &out.DerivationIndex, &out.Address, &out.CreatedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.DonationAddress{}, false, nil
	}
	if err != nil {
		return domain.DonationAddress{}, false, fmt.Errorf("load donation address: %w", err)
	}
	return out, true, nil
}

func (s *DonationStore) DonationUserByAddress(ctx context.Context, address string) (int64, bool, error) {
	var userID int64
	err := s.db.QueryRow(ctx, `SELECT user_id FROM donation_addresses WHERE address = $1`, address).Scan(&userID)
	if errors.Is(err, pgx.ErrNoRows) {
		return 0, false, nil
	}
	if err != nil {
		return 0, false, fmt.Errorf("look up donation address owner: %w", err)
	}
	return userID, true, nil
}

func (s *DonationStore) NextDonationAddressIndex(ctx context.Context) (int64, error) {
	var index int64
	if err := s.db.QueryRow(ctx, `SELECT nextval('donation_address_index_seq')`).Scan(&index); err != nil {
		return 0, fmt.Errorf("reserve donation address index: %w", err)
	}
	return index, nil
}

func (s *DonationStore) InsertDonationAddress(ctx context.Context, userID, index int64, address string) (domain.DonationAddress, error) {
	var out domain.DonationAddress
	err := s.db.QueryRow(ctx, `
INSERT INTO donation_addresses (user_id, derivation_index, address) VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET user_id = donation_addresses.user_id
RETURNING user_id, derivation_index, address, created_at`, userID, index, address).
		Scan(&out.UserID, &out.DerivationIndex, &out.Address, &out.CreatedAt)
	if err != nil {
		return domain.DonationAddress{}, fmt.Errorf("insert donation address: %w", err)
	}
	return out, nil
}

func (s *DonationStore) EnabledDonationChains(ctx context.Context) ([]domain.DonationChain, error) {
	rows, err := s.db.Query(ctx, `SELECT chain_key, name, chain_id, rpc_url, ws_url, native_symbol, native_decimals,
confirmations_required, price_feed_address, manual_usd_rate_micros, enabled
FROM donation_chains WHERE enabled ORDER BY chain_key`)
	if err != nil {
		return nil, fmt.Errorf("list enabled donation chains: %w", err)
	}
	defer rows.Close()
	return scanDonationChains(rows)
}

func (s *DonationStore) DonationChain(ctx context.Context, chainKey string) (domain.DonationChain, bool, error) {
	var out domain.DonationChain
	err := s.db.QueryRow(ctx, `SELECT chain_key, name, chain_id, rpc_url, ws_url, native_symbol, native_decimals,
confirmations_required, price_feed_address, manual_usd_rate_micros, enabled
FROM donation_chains WHERE chain_key = $1`, chainKey).Scan(
		&out.Key, &out.Name, &out.ChainID, &out.RPCURL, &out.WSURL, &out.NativeSymbol, &out.NativeDecimals,
		&out.ConfirmationsRequired, &out.PriceFeedAddress, &out.ManualUSDRateMicros, &out.Enabled)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.DonationChain{}, false, nil
	}
	if err != nil {
		return domain.DonationChain{}, false, fmt.Errorf("load donation chain: %w", err)
	}
	return out, true, nil
}

func (s *DonationStore) UpdateDonationChainConfig(ctx context.Context, upd domain.DonationChainConfigUpdate) (domain.DonationChain, error) {
	var out domain.DonationChain
	err := s.db.QueryRow(ctx, `UPDATE donation_chains SET
    rpc_url = $2, ws_url = $3, confirmations_required = $4, price_feed_address = $5,
    manual_usd_rate_micros = $6, enabled = $7
WHERE chain_key = $1
RETURNING chain_key, name, chain_id, rpc_url, ws_url, native_symbol, native_decimals,
    confirmations_required, price_feed_address, manual_usd_rate_micros, enabled`,
		upd.ChainKey, upd.RPCURL, upd.WSURL, upd.ConfirmationsRequired, upd.PriceFeedAddress,
		upd.ManualUSDRateMicros, upd.Enabled).Scan(
		&out.Key, &out.Name, &out.ChainID, &out.RPCURL, &out.WSURL, &out.NativeSymbol, &out.NativeDecimals,
		&out.ConfirmationsRequired, &out.PriceFeedAddress, &out.ManualUSDRateMicros, &out.Enabled)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.DonationChain{}, domain.ErrDonationChainNotFound
	}
	if err != nil {
		return domain.DonationChain{}, fmt.Errorf("update donation chain config: %w", err)
	}
	return out, nil
}

func scanDonationChains(rows pgx.Rows) ([]domain.DonationChain, error) {
	out := make([]domain.DonationChain, 0)
	for rows.Next() {
		var c domain.DonationChain
		if err := rows.Scan(&c.Key, &c.Name, &c.ChainID, &c.RPCURL, &c.WSURL, &c.NativeSymbol, &c.NativeDecimals,
			&c.ConfirmationsRequired, &c.PriceFeedAddress, &c.ManualUSDRateMicros, &c.Enabled); err != nil {
			return nil, fmt.Errorf("scan donation chain: %w", err)
		}
		out = append(out, c)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate donation chains: %w", err)
	}
	return out, nil
}

func (s *DonationStore) DonationTokens(ctx context.Context, chainKey string) ([]domain.DonationToken, error) {
	rows, err := s.db.Query(ctx, `SELECT chain_key, symbol, contract_address, decimals
FROM donation_tokens WHERE chain_key = $1 ORDER BY symbol`, chainKey)
	if err != nil {
		return nil, fmt.Errorf("list donation tokens: %w", err)
	}
	defer rows.Close()
	out := make([]domain.DonationToken, 0)
	for rows.Next() {
		var t domain.DonationToken
		if err := rows.Scan(&t.ChainKey, &t.Symbol, &t.ContractAddress, &t.Decimals); err != nil {
			return nil, fmt.Errorf("scan donation token: %w", err)
		}
		out = append(out, t)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate donation tokens: %w", err)
	}
	return out, nil
}

func (s *DonationStore) DonationChainCursor(ctx context.Context, chainKey string) (int64, error) {
	var block int64
	err := s.db.QueryRow(ctx, `SELECT last_scanned_block FROM donation_chain_cursors WHERE chain_key = $1`, chainKey).Scan(&block)
	if errors.Is(err, pgx.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, fmt.Errorf("load donation chain cursor: %w", err)
	}
	return block, nil
}

func (s *DonationStore) SetDonationChainCursor(ctx context.Context, chainKey string, block int64) error {
	_, err := s.db.Exec(ctx, `
INSERT INTO donation_chain_cursors (chain_key, last_scanned_block, updated_at) VALUES ($1, $2, now())
ON CONFLICT (chain_key) DO UPDATE SET last_scanned_block = EXCLUDED.last_scanned_block, updated_at = now()`,
		chainKey, block)
	if err != nil {
		return fmt.Errorf("set donation chain cursor: %w", err)
	}
	return nil
}

// donationDepositScanner is the common surface of pgx.Row and pgx.Rows, so
// one scan routine serves every deposit query below regardless of whether
// it reads one row or iterates many.
type donationDepositScanner interface {
	Scan(dest ...any) error
}

// donationDepositColumns is the SELECT list every deposit query below uses,
// in the exact order scanDonationDeposit expects.
const donationDepositColumns = `id, user_id, chain_key, token_symbol, tx_hash, log_index, block_number, amount_raw::text,
       usd_value_micros, stars_credited, status, confirmations, detected_at, credited_at`

// scanDonationDeposit reads one donationDepositColumns row. credited_at is
// nullable (a deposit that hasn't been credited yet), so it scans through
// pgtype.Timestamptz rather than directly into time.Time.
func scanDonationDeposit(row donationDepositScanner) (domain.DonationDeposit, error) {
	var d domain.DonationDeposit
	var status string
	var creditedAt pgtype.Timestamptz
	if err := row.Scan(&d.ID, &d.UserID, &d.ChainKey, &d.TokenSymbol, &d.TxHash, &d.LogIndex, &d.BlockNumber,
		&d.AmountRaw, &d.USDValueMicros, &d.StarsCredited, &status, &d.Confirmations, &d.DetectedAt, &creditedAt); err != nil {
		return domain.DonationDeposit{}, err
	}
	d.Status = domain.DonationDepositStatus(status)
	if creditedAt.Valid {
		d.CreditedAt = creditedAt.Time
	}
	return d, nil
}

func scanDonationDeposits(rows pgx.Rows) ([]domain.DonationDeposit, error) {
	out := make([]domain.DonationDeposit, 0)
	for rows.Next() {
		d, err := scanDonationDeposit(rows)
		if err != nil {
			return nil, fmt.Errorf("scan donation deposit: %w", err)
		}
		out = append(out, d)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate donation deposits: %w", err)
	}
	return out, nil
}

func (s *DonationStore) RecordDonationDeposit(ctx context.Context, deposit domain.DonationDeposit) (domain.DonationDeposit, bool, error) {
	row := s.db.QueryRow(ctx, `
WITH inserted AS (
    INSERT INTO donation_deposits
        (user_id, chain_key, token_symbol, tx_hash, log_index, block_number, amount_raw, status, confirmations, detected_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
    ON CONFLICT (chain_key, tx_hash, log_index) DO NOTHING
    RETURNING *
)
SELECT `+donationDepositColumns+`, true AS created
FROM inserted
UNION ALL
SELECT `+donationDepositColumns+`, false AS created
FROM donation_deposits WHERE chain_key = $2 AND tx_hash = $4 AND log_index = $5
AND NOT EXISTS (SELECT 1 FROM inserted)
LIMIT 1`,
		deposit.UserID, deposit.ChainKey, deposit.TokenSymbol, deposit.TxHash, deposit.LogIndex,
		deposit.BlockNumber, deposit.AmountRaw, string(deposit.Status), deposit.Confirmations)
	var d domain.DonationDeposit
	var status string
	var creditedAt pgtype.Timestamptz
	var created bool
	if err := row.Scan(&d.ID, &d.UserID, &d.ChainKey, &d.TokenSymbol, &d.TxHash, &d.LogIndex, &d.BlockNumber,
		&d.AmountRaw, &d.USDValueMicros, &d.StarsCredited, &status, &d.Confirmations, &d.DetectedAt, &creditedAt, &created); err != nil {
		return domain.DonationDeposit{}, false, fmt.Errorf("record donation deposit: %w", err)
	}
	d.Status = domain.DonationDepositStatus(status)
	if creditedAt.Valid {
		d.CreditedAt = creditedAt.Time
	}
	return d, created, nil
}

func (s *DonationStore) UpdateDonationDepositConfirmations(ctx context.Context, chainKey, txHash string, logIndex int, confirmations int) (domain.DonationDeposit, error) {
	row := s.db.QueryRow(ctx, `
UPDATE donation_deposits SET
    confirmations = $4,
    status = CASE WHEN status = 'pending' AND $4 >= (
        SELECT confirmations_required FROM donation_chains WHERE chain_key = donation_deposits.chain_key
    ) THEN 'confirmed' ELSE status END
WHERE chain_key = $1 AND tx_hash = $2 AND log_index = $3 AND status = 'pending'
RETURNING `+donationDepositColumns, chainKey, txHash, logIndex, confirmations)
	out, err := scanDonationDeposit(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return domain.DonationDeposit{}, nil // already past pending, or never existed; not an error
	}
	if err != nil {
		return domain.DonationDeposit{}, fmt.Errorf("update donation deposit confirmations: %w", err)
	}
	return out, nil
}

func (s *DonationStore) PendingDonationDeposits(ctx context.Context, chainKey string) ([]domain.DonationDeposit, error) {
	rows, err := s.db.Query(ctx, `SELECT `+donationDepositColumns+`
FROM donation_deposits WHERE chain_key = $1 AND status = 'pending' ORDER BY id`, chainKey)
	if err != nil {
		return nil, fmt.Errorf("list pending donation deposits: %w", err)
	}
	defer rows.Close()
	return scanDonationDeposits(rows)
}

func (s *DonationStore) ConfirmedUncreditedDeposits(ctx context.Context, chainKey string, limit int) ([]domain.DonationDeposit, error) {
	if limit <= 0 {
		limit = 50
	}
	rows, err := s.db.Query(ctx, `SELECT `+donationDepositColumns+`
FROM donation_deposits WHERE chain_key = $1 AND status = 'confirmed' ORDER BY id LIMIT $2`, chainKey, limit)
	if err != nil {
		return nil, fmt.Errorf("list confirmed donation deposits: %w", err)
	}
	defer rows.Close()
	return scanDonationDeposits(rows)
}

// CreditDonationDeposit is the only place a deposit's stars_credited is ever
// written, guarded by the WHERE status='confirmed' below: a deposit already
// credited (or somehow orphaned) matches zero rows, so a duplicate watcher
// pass or a retried call can never double-credit.
func (s *DonationStore) CreditDonationDeposit(ctx context.Context, depositID int64, usdValueMicros, stars int64, date int) (domain.DonationDeposit, bool, error) {
	if depositID <= 0 || stars <= 0 {
		return domain.DonationDeposit{}, false, domain.ErrDonationDepositInvalid
	}
	var credited bool
	err := withTx(ctx, s.db, "credit donation deposit", func(tx pgx.Tx) error {
		var userID int64
		var chainKey, tokenSymbol string
		err := tx.QueryRow(ctx, `
UPDATE donation_deposits SET status = 'credited', usd_value_micros = $2, stars_credited = $3, credited_at = now()
WHERE id = $1 AND status = 'confirmed'
RETURNING user_id, chain_key, token_symbol`, depositID, usdValueMicros, stars).Scan(&userID, &chainKey, &tokenSymbol)
		if errors.Is(err, pgx.ErrNoRows) {
			return nil // not confirmed (already credited, or never got there) -- not an error, just nothing to do.
		}
		if err != nil {
			return fmt.Errorf("mark donation deposit credited: %w", err)
		}
		symbol := tokenSymbol
		if symbol == "" {
			if chain, found, err := (&DonationStore{db: tx}).DonationChain(ctx, chainKey); err == nil && found {
				symbol = chain.NativeSymbol
			}
		}
		if _, err := tx.Exec(ctx, `
INSERT INTO stars_balances (user_id, balance, updated_at) VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()`,
			userID, stars); err != nil {
			return fmt.Errorf("credit stars balance for donation: %w", err)
		}
		if err := insertStarsTxn(ctx, tx, userID, stars, domain.StarsReasonDonationDeposit, domain.Peer{}, date,
			"Crypto donation", fmt.Sprintf("%s deposit on %s", symbol, chainKey)); err != nil {
			return err
		}
		credited = true
		return nil
	})
	if err != nil {
		return domain.DonationDeposit{}, false, err
	}
	if !credited {
		return domain.DonationDeposit{}, false, nil
	}
	return domain.DonationDeposit{ID: depositID, Status: domain.DonationDepositCredited, USDValueMicros: usdValueMicros, StarsCredited: stars}, true, nil
}

func (s *DonationStore) UserDonationDeposits(ctx context.Context, userID int64, limit int) ([]domain.DonationDeposit, error) {
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	rows, err := s.db.Query(ctx, `SELECT `+donationDepositColumns+`
FROM donation_deposits WHERE user_id = $1 ORDER BY detected_at DESC LIMIT $2`, userID, limit)
	if err != nil {
		return nil, fmt.Errorf("list user donation deposits: %w", err)
	}
	defer rows.Close()
	return scanDonationDeposits(rows)
}