mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
* fix: publish transaction events only after the database transaction commits markTransactionSettled and markPaymentFailed published nwc_payment_sent / nwc_payment_received / nwc_payment_failed (and checkBudgetUsage published nwc_budget_warning) while still inside the caller's database transaction, so connected apps and the Alby API could be notified of a payment whose row was never committed, and subscribers reading the database in response to an event could race with the commit. Every function that writes transaction state now owns its own database transaction and publishes its events only after the commit succeeds: - markTransactionSettled and markPaymentFailed open their own transaction; callers no longer wrap them in db.Transaction - new createSettledTransactionFromNotification inserts transactions reported by LNClient notifications for payments the hub has no record of (external payments, received keysends) directly in their settled state, removing the transient PENDING row and the zombie row left behind on duplicate events - markPaymentFailed now refuses to mark a settled transaction as failed, replacing CancelHoldInvoice's in-transaction ACCEPTED re-check and also protecting the SendPaymentSync error path from a racing settle - checkBudgetUsage returns the budget warning event instead of publishing it Closes #2506 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: serialize payment failure with settlement and propagate lock errors Address review findings on the previous commit: - markPaymentFailed now takes the same payment-hash row lock as settlement (postgres), so the settled-state guard cannot be bypassed by a concurrent settle between the state check and the update; it also returns not-found instead of publishing an event when the transaction row no longer exists, and reports whether this call transitioned the row so CancelHoldInvoice only publishes nwc_hold_invoice_canceled when it performed the cancellation - findSettledTransaction propagates errors from the lock query and the settled-transaction lookup instead of treating a failed lookup as "no settled transaction exists", which could defeat the dedup guard - TestMarkSettled_Twice no longer shares one transaction struct between concurrent goroutines and collects errors instead of asserting inside them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: mark failed keysend payments via markPaymentFailed The SendKeysend failure path updated the transaction directly, which never zeroed the fee reserve, recorded no failure reason, published no nwc_payment_failed event, and had no guard against overwriting a concurrently settled payment. Route it through markPaymentFailed like SendPaymentSync, and allow MockLn keysends to fail so the path is testable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1769 lines
60 KiB
Go
1769 lines
60 KiB
Go
package transactions
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
decodepay "github.com/nbd-wtf/ln-decodepay"
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"github.com/getAlby/hub/constants"
|
|
"github.com/getAlby/hub/db"
|
|
"github.com/getAlby/hub/db/queries"
|
|
"github.com/getAlby/hub/events"
|
|
"github.com/getAlby/hub/lnclient"
|
|
"github.com/getAlby/hub/logger"
|
|
)
|
|
|
|
type transactionsService struct {
|
|
db *gorm.DB
|
|
eventPublisher events.EventPublisher
|
|
}
|
|
|
|
type TransactionsService interface {
|
|
events.EventSubscriber
|
|
MakeInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint, throughNodePubkey *string) (*Transaction, error)
|
|
LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error)
|
|
ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool, filters *ListTransactionsFilters) (transactions []Transaction, totalCount uint64, err error)
|
|
SendPaymentSync(payReq string, amountMsat *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
|
|
SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
|
|
MakeHoldInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, paymentHash string, minCltvExpiryDelta *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error)
|
|
SettleHoldInvoice(ctx context.Context, preimage string, lnClient lnclient.LNClient) (*Transaction, error)
|
|
CancelHoldInvoice(ctx context.Context, paymentHash string, lnClient lnclient.LNClient) error
|
|
SetTransactionMetadata(ctx context.Context, id uint, metadata map[string]interface{}) error
|
|
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
|
|
}
|
|
|
|
const (
|
|
BoostagramTlvType = 7629169
|
|
WhatsatTlvType = 34349334
|
|
CustomKeyTlvType = 696969
|
|
)
|
|
|
|
// Prevent races when checking the current balance and creating payment
|
|
// transactions from concurrent goroutines.
|
|
var balanceValidationLock = &sync.Mutex{}
|
|
|
|
type Transaction = db.Transaction
|
|
|
|
type ListTransactionsFilters struct {
|
|
Type *string
|
|
MinAmountMsat *uint64
|
|
HideFailed bool
|
|
SearchTerm string
|
|
}
|
|
|
|
var paymentHashRegex = regexp.MustCompile("^[0-9a-f]{64}$")
|
|
|
|
// escapeLikePattern makes a string match literally in a LIKE ... ESCAPE '\'
|
|
// clause by escaping the wildcard characters % and _. This is not an SQL
|
|
// injection concern (search terms are always passed as bound parameters);
|
|
// without it a term like "50%" would behave as a wildcard pattern.
|
|
// The backslash must be escaped first.
|
|
func escapeLikePattern(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, "%", `\%`)
|
|
s = strings.ReplaceAll(s, "_", `\_`)
|
|
return s
|
|
}
|
|
|
|
type Boostagram struct {
|
|
AppName string `json:"app_name"`
|
|
Name string `json:"name"`
|
|
Podcast string `json:"podcast"`
|
|
URL string `json:"url"`
|
|
Episode StringOrNumber `json:"episode,omitempty"`
|
|
FeedId StringOrNumber `json:"feedID,omitempty"`
|
|
ItemId StringOrNumber `json:"itemID,omitempty"`
|
|
Timestamp int64 `json:"ts,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
SenderId StringOrNumber `json:"sender_id"`
|
|
SenderName string `json:"sender_name"`
|
|
Time string `json:"time"`
|
|
Action string `json:"action"`
|
|
ValueMsatTotal int64 `json:"value_msat_total"`
|
|
}
|
|
|
|
type StringOrNumber struct {
|
|
StringData string
|
|
NumberData int64
|
|
}
|
|
|
|
func (sn *StringOrNumber) UnmarshalJSON(data []byte) error {
|
|
if err := json.Unmarshal(data, &sn.StringData); err == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &sn.NumberData); err == nil {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("cannot unmarshal %s into StringOrNumber type", data)
|
|
}
|
|
|
|
func (sn StringOrNumber) String() string {
|
|
if sn.StringData != "" {
|
|
return sn.StringData
|
|
}
|
|
return fmt.Sprintf("%d", sn.NumberData)
|
|
}
|
|
|
|
type notFoundError struct {
|
|
}
|
|
|
|
func NewNotFoundError() error {
|
|
return ¬FoundError{}
|
|
}
|
|
|
|
func (err *notFoundError) Error() string {
|
|
return "The transaction requested was not found"
|
|
}
|
|
|
|
type insufficientBalanceError struct {
|
|
}
|
|
|
|
func NewInsufficientBalanceError() error {
|
|
return &insufficientBalanceError{}
|
|
}
|
|
|
|
func (err *insufficientBalanceError) Error() string {
|
|
return "Insufficient balance remaining to make the requested payment"
|
|
}
|
|
|
|
type quotaExceededError struct {
|
|
}
|
|
|
|
func NewQuotaExceededError() error {
|
|
return "aExceededError{}
|
|
}
|
|
|
|
func (err *quotaExceededError) Error() string {
|
|
return "Your app does not have enough budget remaining to make this payment. Please review this app in the connections page of your Alby Hub."
|
|
}
|
|
|
|
func NewTransactionsService(db *gorm.DB, eventPublisher events.EventPublisher) *transactionsService {
|
|
return &transactionsService{
|
|
db: db,
|
|
eventPublisher: eventPublisher,
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) MakeInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint, throughNodePubkey *string) (*Transaction, error) {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"app_id": appId,
|
|
"request_event_id": requestEventId,
|
|
"amount_msat": amountMsat,
|
|
"description": description,
|
|
"description_hash": descriptionHash,
|
|
"expiry": expiry,
|
|
"metadata": metadata,
|
|
}).Debug("Making invoice")
|
|
|
|
if amountMsat%1000 != 0 {
|
|
return nil, errors.New("the amount must be a whole number of satoshis")
|
|
}
|
|
if amountMsat < 1000 {
|
|
return nil, errors.New("the amount must be at least 1 satoshi")
|
|
}
|
|
|
|
var metadataBytes []byte
|
|
if metadata != nil {
|
|
var err error
|
|
metadataBytes, err = json.Marshal(metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize metadata")
|
|
return nil, err
|
|
}
|
|
if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
|
|
return nil, fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
|
|
}
|
|
}
|
|
|
|
if metadata["app_id"] != nil {
|
|
overwriteAppIdType, ok := metadata["app_id"].(float64)
|
|
if !ok {
|
|
return nil, errors.New("failed to overwrite app ID")
|
|
}
|
|
overwriteAppId := uint(overwriteAppIdType)
|
|
logger.Logger.WithField("app_id", overwriteAppId).Info("Making invoice with overwritten app ID")
|
|
appId = &overwriteAppId
|
|
}
|
|
|
|
lnClientTransaction, err := lnClient.MakeInvoice(ctx, int64(amountMsat), description, descriptionHash, int64(expiry), throughNodePubkey)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create transaction")
|
|
return nil, err
|
|
}
|
|
|
|
var preimage *string
|
|
if lnClientTransaction.Preimage != "" {
|
|
preimage = &lnClientTransaction.Preimage
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if lnClientTransaction.ExpiresAt != nil {
|
|
expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
|
|
expiresAt = &expiresAtValue
|
|
}
|
|
|
|
dbTransaction := db.Transaction{
|
|
AppId: appId,
|
|
RequestEventId: requestEventId,
|
|
Type: lnClientTransaction.Type,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
AmountMsat: uint64(lnClientTransaction.AmountMsat),
|
|
FeeMsat: uint64(max(lnClientTransaction.FeesPaidMsat, 0)),
|
|
Description: description,
|
|
DescriptionHash: descriptionHash,
|
|
PaymentRequest: lnClientTransaction.Invoice,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
ExpiresAt: expiresAt,
|
|
Preimage: preimage,
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
}
|
|
err = svc.db.Create(&dbTransaction).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create DB transaction")
|
|
return nil, err
|
|
}
|
|
return &dbTransaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) MakeHoldInvoice(ctx context.Context, amountMsat uint64, description string, descriptionHash string, expiry uint64, paymentHash string, minCltvExpiryDelta *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
|
|
var err error
|
|
var metadataBytes []byte
|
|
if metadata != nil {
|
|
metadataBytes, err = json.Marshal(metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize metadata")
|
|
return nil, err
|
|
}
|
|
if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
|
|
return nil, fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
|
|
}
|
|
}
|
|
|
|
lnClientTransaction, err := lnClient.MakeHoldInvoice(ctx, int64(amountMsat), description, descriptionHash, int64(expiry), paymentHash, minCltvExpiryDelta)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create hold invoice via LN client")
|
|
return nil, err
|
|
}
|
|
|
|
var preimage *string
|
|
if lnClientTransaction.Preimage != "" {
|
|
preimage = &lnClientTransaction.Preimage
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if lnClientTransaction.ExpiresAt != nil {
|
|
expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
|
|
expiresAt = &expiresAtValue
|
|
}
|
|
|
|
dbTransaction := db.Transaction{
|
|
AppId: appId,
|
|
RequestEventId: requestEventId,
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
AmountMsat: uint64(lnClientTransaction.AmountMsat),
|
|
Description: description,
|
|
DescriptionHash: descriptionHash,
|
|
PaymentRequest: lnClientTransaction.Invoice,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
ExpiresAt: expiresAt,
|
|
Preimage: preimage,
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
Hold: true,
|
|
}
|
|
err = svc.db.Create(&dbTransaction).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create hold invoice DB transaction")
|
|
return nil, err
|
|
}
|
|
return &dbTransaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) SendPaymentSync(payReq string, amountMsat *uint64, metadata map[string]interface{}, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
|
|
var metadataBytes []byte
|
|
if metadata != nil {
|
|
var err error
|
|
metadataBytes, err = json.Marshal(metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize metadata")
|
|
return nil, err
|
|
}
|
|
if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
|
|
return nil, fmt.Errorf("encoded payment metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
|
|
}
|
|
}
|
|
|
|
payReq = strings.ToLower(payReq)
|
|
paymentRequest, err := decodepay.Decodepay(payReq)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": payReq,
|
|
}).Errorf("Failed to decode bolt11 invoice: %v", err)
|
|
|
|
return nil, err
|
|
}
|
|
|
|
if time.Now().After(time.Unix(int64(paymentRequest.CreatedAt+paymentRequest.Expiry), 0)) {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": payReq,
|
|
"expiry": time.Unix(int64(paymentRequest.CreatedAt+paymentRequest.Expiry), 0),
|
|
}).Errorf("this invoice has expired")
|
|
|
|
return nil, errors.New("this invoice has expired")
|
|
}
|
|
|
|
// A payment is a self-payment if we have an incoming transaction for the
|
|
// exact same invoice (i.e. it was generated by this hub).
|
|
selfPayment := false
|
|
var incomingTransaction db.Transaction
|
|
result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
// NOTE: filter by payment hash so the payment hash index is used,
|
|
// but also match the payment request as wrapped invoices share
|
|
// the same hash but have different payment requests
|
|
PaymentHash: paymentRequest.PaymentHash,
|
|
PaymentRequest: payReq,
|
|
})
|
|
if result.Error == nil && result.RowsAffected > 0 {
|
|
selfPayment = true
|
|
}
|
|
|
|
var dbTransaction db.Transaction
|
|
|
|
paymentAmountMsat := uint64(paymentRequest.MSatoshi)
|
|
if amountMsat != nil && paymentRequest.MSatoshi == 0 {
|
|
paymentAmountMsat = *amountMsat
|
|
}
|
|
|
|
err = func() error {
|
|
balanceValidationLock.Lock()
|
|
defer balanceValidationLock.Unlock()
|
|
return svc.db.Transaction(func(tx *gorm.DB) error {
|
|
var existingSettledTransaction db.Transaction
|
|
if tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
PaymentHash: paymentRequest.PaymentHash,
|
|
PaymentRequest: payReq,
|
|
State: constants.TRANSACTION_STATE_SETTLED,
|
|
}).RowsAffected > 0 {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": payReq,
|
|
"payment_hash": paymentRequest.PaymentHash,
|
|
}).Debug("this invoice has already been paid")
|
|
return errors.New("this invoice has already been paid")
|
|
}
|
|
if tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
PaymentHash: paymentRequest.PaymentHash,
|
|
PaymentRequest: payReq,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
}).RowsAffected > 0 {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": payReq,
|
|
"payment_hash": paymentRequest.PaymentHash,
|
|
}).Debug("this invoice is already being paid")
|
|
return errors.New("there is already a payment pending for this invoice")
|
|
}
|
|
|
|
err := svc.validateCanPay(tx, appId, paymentAmountMsat, paymentRequest.Description, selfPayment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if paymentRequest.Expiry > 0 {
|
|
expiresAtValue := time.Now().Add(time.Duration(paymentRequest.Expiry) * time.Second)
|
|
expiresAt = &expiresAtValue
|
|
}
|
|
dbTransaction = db.Transaction{
|
|
AppId: appId,
|
|
RequestEventId: requestEventId,
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
FeeReserveMsat: CalculateFeeReserveMsat(paymentAmountMsat),
|
|
AmountMsat: paymentAmountMsat,
|
|
PaymentRequest: payReq,
|
|
PaymentHash: paymentRequest.PaymentHash,
|
|
Description: paymentRequest.Description,
|
|
DescriptionHash: paymentRequest.DescriptionHash,
|
|
ExpiresAt: expiresAt,
|
|
SelfPayment: selfPayment,
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
}
|
|
err = tx.Create(&dbTransaction).Error
|
|
return err
|
|
})
|
|
}()
|
|
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": payReq,
|
|
}).WithError(err).Error("Failed to create DB transaction")
|
|
return nil, err
|
|
}
|
|
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"app_id": appId,
|
|
"request_event_id": requestEventId,
|
|
"amount_msat": paymentAmountMsat,
|
|
"description": paymentRequest.Description,
|
|
"description_hash": paymentRequest.DescriptionHash,
|
|
"expiry": paymentRequest.Expiry,
|
|
"self_payment": selfPayment,
|
|
"metadata": metadata,
|
|
}).Debug("Initiating payment")
|
|
|
|
var response *lnclient.PayInvoiceResponse
|
|
if selfPayment {
|
|
response, err = svc.interceptSelfPayment(payReq, paymentRequest.PaymentHash, lnClient)
|
|
} else {
|
|
response, err = lnClient.SendPaymentSync(payReq, amountMsat)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": payReq,
|
|
}).WithError(err).Error("Failed to send payment")
|
|
|
|
if _, markFailedErr := svc.markPaymentFailed(&dbTransaction, err.Error()); markFailedErr != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": payReq,
|
|
}).WithError(markFailedErr).Error("Failed to mark payment as failed")
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
// the payment definitely succeeded
|
|
settledTransaction, err := svc.markTransactionSettled(&dbTransaction, response.Preimage, response.FeeMsat, selfPayment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return settledTransaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) SendKeysend(amountMsat uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
|
|
if preimage == "" {
|
|
preImageBytes, err := makePreimageHex()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
preimage = hex.EncodeToString(preImageBytes)
|
|
}
|
|
|
|
preImageBytes, err := hex.DecodeString(preimage)
|
|
if err != nil || len(preImageBytes) != 32 {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"preimage": preimage,
|
|
}).WithError(err).Error("Invalid preimage")
|
|
return nil, err
|
|
}
|
|
|
|
paymentHash256 := sha256.New()
|
|
paymentHash256.Write(preImageBytes)
|
|
paymentHashBytes := paymentHash256.Sum(nil)
|
|
paymentHash := hex.EncodeToString(paymentHashBytes)
|
|
|
|
metadata := map[string]interface{}{}
|
|
|
|
metadata["destination"] = destination
|
|
|
|
metadata["tlv_records"] = customRecords
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize transaction metadata")
|
|
return nil, err
|
|
}
|
|
boostagramBytes := svc.getBoostagramBytesFromCustomRecords(customRecords)
|
|
|
|
var dbTransaction db.Transaction
|
|
|
|
selfPayment := destination == lnClient.GetPubkey()
|
|
|
|
err = func() error {
|
|
balanceValidationLock.Lock()
|
|
defer balanceValidationLock.Unlock()
|
|
return svc.db.Transaction(func(tx *gorm.DB) error {
|
|
err := svc.validateCanPay(tx, appId, amountMsat, "", selfPayment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dbTransaction = db.Transaction{
|
|
AppId: appId,
|
|
Description: svc.getDescriptionFromCustomRecords(customRecords),
|
|
RequestEventId: requestEventId,
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
FeeReserveMsat: CalculateFeeReserveMsat(uint64(amountMsat)),
|
|
AmountMsat: amountMsat,
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
Boostagram: datatypes.JSON(boostagramBytes),
|
|
PaymentHash: paymentHash,
|
|
Preimage: &preimage,
|
|
SelfPayment: selfPayment,
|
|
}
|
|
err = tx.Create(&dbTransaction).Error
|
|
|
|
return err
|
|
})
|
|
}()
|
|
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"destination": destination,
|
|
"amount_msat": amountMsat,
|
|
}).WithError(err).Error("Failed to create DB transaction")
|
|
return nil, err
|
|
}
|
|
|
|
var payKeysendResponse *lnclient.PayKeysendResponse
|
|
|
|
if selfPayment {
|
|
// for keysend self-payments we need to create an incoming payment at the time of the payment
|
|
recipientAppId := svc.getAppIdFromCustomRecords(customRecords, svc.db)
|
|
dbTransaction := db.Transaction{
|
|
AppId: recipientAppId,
|
|
RequestEventId: nil, // it is related to this request but for a different app
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
AmountMsat: amountMsat,
|
|
PaymentHash: paymentHash,
|
|
Preimage: &preimage,
|
|
Description: svc.getDescriptionFromCustomRecords(customRecords),
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
Boostagram: datatypes.JSON(boostagramBytes),
|
|
SelfPayment: true,
|
|
}
|
|
err = svc.db.Create(&dbTransaction).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to create DB transaction")
|
|
return nil, err
|
|
}
|
|
|
|
_, err = svc.interceptSelfPayment("", paymentHash, lnClient)
|
|
if err == nil {
|
|
payKeysendResponse = &lnclient.PayKeysendResponse{
|
|
FeeMsat: 0,
|
|
}
|
|
}
|
|
} else {
|
|
payKeysendResponse, err = lnClient.SendKeysend(amountMsat, destination, customRecords, preimage)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"destination": destination,
|
|
"amount_msat": amountMsat,
|
|
}).WithError(err).Error("Failed to send payment")
|
|
|
|
if _, markFailedErr := svc.markPaymentFailed(&dbTransaction, err.Error()); markFailedErr != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"destination": destination,
|
|
"amount_msat": amountMsat,
|
|
}).WithError(markFailedErr).Error("Failed to mark payment as failed")
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
// the payment definitely succeeded
|
|
settledTransaction, err := svc.markTransactionSettled(&dbTransaction, preimage, payKeysendResponse.FeeMsat, selfPayment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return settledTransaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error) {
|
|
transaction := db.Transaction{}
|
|
|
|
tx := svc.db
|
|
|
|
var isIsolatedApp bool
|
|
if appId != nil {
|
|
err := svc.db.
|
|
Model(&db.App{}).
|
|
Where("id", *appId).
|
|
Pluck("isolated", &isIsolatedApp).
|
|
Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, NewNotFoundError()
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if isIsolatedApp {
|
|
tx = tx.Where("app_id = ?", *appId)
|
|
}
|
|
|
|
if transactionType != nil {
|
|
tx = tx.Where("type = ?", *transactionType)
|
|
}
|
|
|
|
// order settled first, otherwise by created date, as there can be multiple outgoing payments
|
|
// for the same payment hash (if you tried to pay an invoice multiple times - e.g. the first time failed)
|
|
result := tx.Order("settled_at desc, created_at desc").Limit(1).Find(&transaction, &db.Transaction{
|
|
// Type: transactionType,
|
|
PaymentHash: paymentHash,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithError(result.Error).Error("Failed to lookup transaction")
|
|
return nil, result.Error
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
"app_id": appId,
|
|
}).WithError(result.Error).Error("transaction not found")
|
|
return nil, NewNotFoundError()
|
|
}
|
|
|
|
if transaction.State == constants.TRANSACTION_STATE_PENDING {
|
|
svc.checkUnsettledTransaction(ctx, &transaction, lnClient)
|
|
}
|
|
|
|
return &transaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool, filters *ListTransactionsFilters) (transactions []Transaction, totalCount uint64, err error) {
|
|
svc.checkUnsettledTransactions(ctx, lnClient)
|
|
|
|
var isIsolatedApp bool
|
|
if appId != nil {
|
|
err := svc.db.
|
|
Model(&db.App{}).
|
|
Where("id", *appId).
|
|
Pluck("isolated", &isIsolatedApp).
|
|
Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, 0, NewNotFoundError()
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
tx := svc.db
|
|
|
|
if isIsolatedApp || forceFilterByAppId {
|
|
tx = tx.Where("app_id = ?", *appId)
|
|
}
|
|
|
|
if !unpaidOutgoing && !unpaidIncoming {
|
|
tx = tx.Where("state = ?", constants.TRANSACTION_STATE_SETTLED)
|
|
} else if unpaidOutgoing && !unpaidIncoming {
|
|
tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_OUTGOING)
|
|
} else if unpaidIncoming && !unpaidOutgoing {
|
|
tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_INCOMING)
|
|
}
|
|
|
|
if filters != nil {
|
|
if filters.Type != nil {
|
|
tx = tx.Where("type = ?", *filters.Type)
|
|
}
|
|
if filters.MinAmountMsat != nil {
|
|
tx = tx.Where("amount_msat >= ?", *filters.MinAmountMsat)
|
|
}
|
|
if filters.HideFailed {
|
|
tx = tx.Where("state != ?", constants.TRANSACTION_STATE_FAILED)
|
|
}
|
|
if searchTerm := strings.ToLower(strings.TrimSpace(filters.SearchTerm)); searchTerm != "" {
|
|
likePattern := "%" + escapeLikePattern(searchTerm) + "%"
|
|
labelsCondition := `EXISTS (SELECT 1 FROM json_each(transactions.metadata, '$.user_labels') AS user_labels WHERE LOWER(user_labels.key) LIKE ? ESCAPE '\' OR LOWER(user_labels.value) LIKE ? ESCAPE '\')`
|
|
if svc.db.Dialector.Name() == "postgres" {
|
|
labelsCondition = `EXISTS (SELECT 1 FROM jsonb_each_text((transactions.metadata->'user_labels')::jsonb) AS user_labels WHERE LOWER(user_labels.key) LIKE ? ESCAPE '\' OR LOWER(user_labels.value) LIKE ? ESCAPE '\')`
|
|
}
|
|
conditions := `LOWER(description) LIKE ? ESCAPE '\' OR ` + labelsCondition
|
|
args := []interface{}{likePattern, likePattern, likePattern}
|
|
|
|
paymentHash := ""
|
|
if paymentHashRegex.MatchString(searchTerm) {
|
|
paymentHash = searchTerm
|
|
} else if strings.HasPrefix(searchTerm, "ln") {
|
|
if paymentRequest, err := decodepay.Decodepay(searchTerm); err == nil {
|
|
paymentHash = strings.ToLower(paymentRequest.PaymentHash)
|
|
}
|
|
}
|
|
if paymentHash != "" {
|
|
conditions += " OR payment_hash = ?"
|
|
args = append(args, paymentHash)
|
|
}
|
|
|
|
tx = tx.Where(conditions, args...)
|
|
}
|
|
}
|
|
|
|
if from > 0 {
|
|
tx = tx.Where("updated_at >= ?", time.Unix(int64(from), 0))
|
|
}
|
|
if until > 0 {
|
|
tx = tx.Where("updated_at <= ?", time.Unix(int64(until), 0))
|
|
}
|
|
|
|
var totalCount64 int64
|
|
result := tx.Model(&db.Transaction{}).Count(&totalCount64)
|
|
if result.Error != nil {
|
|
logger.Logger.WithError(result.Error).Error("Failed to count DB transactions")
|
|
return nil, 0, result.Error
|
|
}
|
|
totalCount = uint64(totalCount64)
|
|
|
|
tx = tx.Order("updated_at desc")
|
|
|
|
if limit > 0 {
|
|
tx = tx.Limit(int(limit))
|
|
}
|
|
if offset > 0 {
|
|
tx = tx.Offset(int(offset))
|
|
}
|
|
|
|
result = tx.Find(&transactions)
|
|
if result.Error != nil {
|
|
logger.Logger.WithError(result.Error).Error("Failed to list DB transactions")
|
|
return nil, 0, result.Error
|
|
}
|
|
|
|
return transactions, totalCount, nil
|
|
}
|
|
|
|
func (svc *transactionsService) checkUnsettledTransactions(ctx context.Context, lnClient lnclient.LNClient) {
|
|
// Only check unsettled transactions for clients that don't support async events
|
|
// checkUnsettledTransactions does not work for keysend payments!
|
|
if slices.Contains(lnClient.GetSupportedNIP47NotificationTypes(), "payment_received") {
|
|
return
|
|
}
|
|
|
|
// check pending payments less than a day old
|
|
transactions := []Transaction{}
|
|
result := svc.db.Where("state = ? AND created_at > ?", constants.TRANSACTION_STATE_PENDING, time.Now().Add(-24*time.Hour)).Find(&transactions)
|
|
if result.Error != nil {
|
|
logger.Logger.WithError(result.Error).Error("Failed to list DB transactions")
|
|
return
|
|
}
|
|
for _, transaction := range transactions {
|
|
svc.checkUnsettledTransaction(ctx, &transaction, lnClient)
|
|
}
|
|
}
|
|
func (svc *transactionsService) checkUnsettledTransaction(ctx context.Context, transaction *db.Transaction, lnClient lnclient.LNClient) {
|
|
if slices.Contains(lnClient.GetSupportedNIP47NotificationTypes(), "payment_received") {
|
|
return
|
|
}
|
|
|
|
lnClientTransaction, err := lnClient.LookupInvoice(ctx, transaction.PaymentHash)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"bolt11": transaction.PaymentRequest,
|
|
}).WithError(err).Error("Failed to check transaction")
|
|
return
|
|
}
|
|
// update transaction state
|
|
if lnClientTransaction.SettledAt != nil {
|
|
_, err = svc.markTransactionSettled(transaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to mark payment sent when checking unsettled transaction")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
|
|
switch event.Event {
|
|
case "nwc_lnclient_payment_received":
|
|
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
|
|
if !ok {
|
|
logger.Logger.WithField("event", event).Error("Failed to cast event")
|
|
return
|
|
}
|
|
|
|
var dbTransaction db.Transaction
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(result.Error).Error("Failed to find transaction")
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
var appId *uint
|
|
description := lnClientTransaction.Description
|
|
var metadataBytes []byte
|
|
var boostagramBytes []byte
|
|
if lnClientTransaction.Metadata != nil {
|
|
var err error
|
|
metadataBytes, err = json.Marshal(lnClientTransaction.Metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize transaction metadata")
|
|
return
|
|
}
|
|
|
|
var customRecords []lnclient.TLVRecord
|
|
customRecords, _ = lnClientTransaction.Metadata["tlv_records"].([]lnclient.TLVRecord)
|
|
boostagramBytes = svc.getBoostagramBytesFromCustomRecords(customRecords)
|
|
extractedDescription := svc.getDescriptionFromCustomRecords(customRecords)
|
|
if extractedDescription != "" {
|
|
description = extractedDescription
|
|
}
|
|
// find app by custom key/value records
|
|
appId = svc.getAppIdFromCustomRecords(customRecords, svc.db)
|
|
}
|
|
var expiresAt *time.Time
|
|
if lnClientTransaction.ExpiresAt != nil {
|
|
expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
|
|
expiresAt = &expiresAtValue
|
|
}
|
|
dbTransaction = db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
AmountMsat: uint64(lnClientTransaction.AmountMsat),
|
|
PaymentRequest: lnClientTransaction.Invoice,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
Description: description,
|
|
DescriptionHash: lnClientTransaction.DescriptionHash,
|
|
ExpiresAt: expiresAt,
|
|
Metadata: datatypes.JSON(metadataBytes),
|
|
Boostagram: datatypes.JSON(boostagramBytes),
|
|
AppId: appId,
|
|
}
|
|
if _, err := svc.createSettledTransactionFromNotification(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to create settled transaction")
|
|
}
|
|
return
|
|
}
|
|
|
|
if _, err := svc.markTransactionSettled(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to mark transaction as settled")
|
|
}
|
|
|
|
case "nwc_lnclient_hold_invoice_accepted":
|
|
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
|
|
if !ok {
|
|
logger.Logger.WithField("event", event).Error("Failed to cast event properties for hold invoice accepted")
|
|
return
|
|
}
|
|
if lnClientTransaction.SettleDeadline == nil {
|
|
logger.Logger.WithField("event", event).Error("Transaction has no settle deadline")
|
|
return
|
|
}
|
|
svc.markHoldInvoiceAccepted(lnClientTransaction.Invoice, lnClientTransaction.PaymentHash, *lnClientTransaction.SettleDeadline, false)
|
|
|
|
case "nwc_lnclient_payment_sent":
|
|
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
|
|
if !ok {
|
|
logger.Logger.WithField("event", event).Error("Failed to cast event")
|
|
return
|
|
}
|
|
|
|
var dbTransaction db.Transaction
|
|
|
|
// first lookup by pending
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(result.Error).Error("Failed to find transaction")
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
// if no pending payment was found, lookup by failed, latest updated first
|
|
result := svc.db.Limit(1).Order("updated_at DESC").Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
State: constants.TRANSACTION_STATE_FAILED,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(result.Error).Error("Failed to find transaction")
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(result.Error).Error("Failed to find transaction")
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
dbTransaction = db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
AmountMsat: uint64(lnClientTransaction.AmountMsat),
|
|
FeeReserveMsat: 0,
|
|
PaymentRequest: lnClientTransaction.Invoice,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
Description: lnClientTransaction.Description,
|
|
DescriptionHash: lnClientTransaction.DescriptionHash,
|
|
}
|
|
|
|
if lnClientTransaction.ExpiresAt != nil {
|
|
expiresAtValue := time.Unix(*lnClientTransaction.ExpiresAt, 0)
|
|
dbTransaction.ExpiresAt = &expiresAtValue
|
|
}
|
|
|
|
if _, err := svc.createSettledTransactionFromNotification(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to create settled transaction")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, err := svc.markTransactionSettled(&dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false); err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to update transaction")
|
|
}
|
|
case "nwc_lnclient_payment_failed":
|
|
paymentFailedAsyncProperties, ok := event.Properties.(*lnclient.PaymentFailedEventProperties)
|
|
if !ok {
|
|
logger.Logger.WithField("event", event).Error("Failed to cast event")
|
|
return
|
|
}
|
|
|
|
lnClientTransaction := paymentFailedAsyncProperties.Transaction
|
|
|
|
var dbTransaction db.Transaction
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
PaymentHash: lnClientTransaction.PaymentHash,
|
|
})
|
|
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("event", event).Error("Failed to find pending outgoing transaction by payment hash")
|
|
return
|
|
}
|
|
|
|
if _, err := svc.markPaymentFailed(&dbTransaction, paymentFailedAsyncProperties.Reason); err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": lnClientTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to mark payment as failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) markHoldInvoiceAccepted(paymentRequest string, paymentHash string, settleDeadline uint32, selfPayment bool) {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
"self_payment": selfPayment,
|
|
}).Info("Processing hold invoice accepted event")
|
|
|
|
var dbTransaction db.Transaction
|
|
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
|
// NOTE: filter by payment hash so the payment hash index is used,
|
|
// but also match the payment request as wrapped invoices share the same hash
|
|
result := tx.Where("payment_hash = ? AND payment_request = ? AND type = ? AND state = ?", paymentHash, paymentRequest, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_PENDING).First(&dbTransaction)
|
|
if result.Error != nil {
|
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
}).Warn("No corresponding pending incoming transaction found in DB for accepted hold invoice")
|
|
}
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
}).WithError(result.Error).Error("Failed to query DB for accepted hold invoice")
|
|
return result.Error
|
|
}
|
|
|
|
err := tx.Model(&dbTransaction).UpdateColumns(map[string]interface{}{
|
|
"state": constants.TRANSACTION_STATE_ACCEPTED,
|
|
"self_payment": selfPayment,
|
|
"settle_deadline": settleDeadline,
|
|
}).Error
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
"id": dbTransaction.ID,
|
|
}).WithError(err).Error("Failed to update hold invoice state to accepted in DB")
|
|
return err
|
|
}
|
|
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
"id": dbTransaction.ID,
|
|
}).Info("Updated hold invoice state to accepted in DB")
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
"id": dbTransaction.ID,
|
|
}).WithError(err).Error("Failed DB transaction for hold invoice accepted event")
|
|
} else {
|
|
svc.eventPublisher.Publish(&events.Event{
|
|
Event: "nwc_hold_invoice_accepted",
|
|
Properties: &dbTransaction,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) interceptSelfPayment(paymentRequest string, paymentHash string, lnClient lnclient.LNClient) (*lnclient.PayInvoiceResponse, error) {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_request": paymentRequest,
|
|
"payment_hash": paymentHash,
|
|
}).Debug("Intercepting self payment")
|
|
incomingTransaction := db.Transaction{}
|
|
result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
State: constants.TRANSACTION_STATE_PENDING,
|
|
// NOTE: for keysend, payment request will be ""
|
|
PaymentRequest: paymentRequest,
|
|
PaymentHash: paymentHash,
|
|
})
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
return nil, NewNotFoundError()
|
|
}
|
|
|
|
if incomingTransaction.Hold {
|
|
return svc.interceptSelfHoldPayment(paymentRequest, paymentHash, lnClient)
|
|
}
|
|
|
|
if incomingTransaction.Preimage == nil {
|
|
return nil, errors.New("preimage is not set on transaction. Self payments not supported")
|
|
}
|
|
|
|
_, err := svc.markTransactionSettled(&incomingTransaction, *incomingTransaction.Preimage, uint64(0), true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &lnclient.PayInvoiceResponse{
|
|
Preimage: *incomingTransaction.Preimage,
|
|
FeeMsat: 0,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *transactionsService) interceptSelfHoldPayment(paymentRequest string, paymentHash string, lnClient lnclient.LNClient) (*lnclient.PayInvoiceResponse, error) {
|
|
settledChannel := make(chan *db.Transaction)
|
|
canceledChannel := make(chan *db.Transaction)
|
|
|
|
holdInvoiceUpdatedConsumer := newHoldInvoiceUpdatedConsumer(paymentRequest, settledChannel, canceledChannel)
|
|
|
|
svc.eventPublisher.RegisterSubscriber(holdInvoiceUpdatedConsumer)
|
|
defer svc.eventPublisher.RemoveSubscriber(holdInvoiceUpdatedConsumer)
|
|
|
|
clientInfo, err := lnClient.GetInfo(context.Background())
|
|
if err != nil {
|
|
return nil, errors.New("failed to get client info")
|
|
}
|
|
if clientInfo.BlockHeight == 0 {
|
|
return nil, errors.New("invalid client block height")
|
|
}
|
|
|
|
fakeSettleDeadline := clientInfo.BlockHeight + 24
|
|
|
|
svc.markHoldInvoiceAccepted(paymentRequest, paymentHash, fakeSettleDeadline, true)
|
|
|
|
select {
|
|
case settledTransaction := <-settledChannel:
|
|
logger.Logger.WithField("settled_transaction", settledTransaction).Info("self hold payment was settled")
|
|
if settledTransaction.Preimage == nil {
|
|
return nil, errors.New("preimage is not set on self hold payment")
|
|
}
|
|
|
|
return &lnclient.PayInvoiceResponse{
|
|
Preimage: *settledTransaction.Preimage,
|
|
FeeMsat: 0,
|
|
}, nil
|
|
case canceledTransaction := <-canceledChannel:
|
|
logger.Logger.WithField("canceled_transaction", canceledTransaction).Info("self hold payment was canceled")
|
|
return nil, lnclient.NewHoldInvoiceCanceledError()
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amountMsat uint64, description string, selfPayment bool) error {
|
|
amountWithFeeReserveMsat := amountMsat
|
|
if !selfPayment {
|
|
amountWithFeeReserveMsat += CalculateFeeReserveMsat(amountMsat)
|
|
}
|
|
|
|
// ensure balance for isolated apps
|
|
if appId != nil {
|
|
var app db.App
|
|
result := tx.Limit(1).Find(&app, &db.App{
|
|
ID: *appId,
|
|
})
|
|
if result.RowsAffected == 0 {
|
|
return NewNotFoundError()
|
|
}
|
|
|
|
var appPermission db.AppPermission
|
|
result = tx.Limit(1).Find(&appPermission, &db.AppPermission{
|
|
AppId: *appId,
|
|
Scope: constants.PAY_INVOICE_SCOPE,
|
|
})
|
|
if result.RowsAffected == 0 {
|
|
return errors.New("app does not have pay_invoice scope")
|
|
}
|
|
|
|
if app.Isolated {
|
|
balanceMsat, err := queries.GetIsolatedBalanceMsat(tx, appPermission.AppId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate isolated balance for app: %w", err)
|
|
}
|
|
|
|
if int64(amountWithFeeReserveMsat) > balanceMsat {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"balance_msat": balanceMsat,
|
|
"self_payment": selfPayment,
|
|
"amount_msat": amountMsat,
|
|
"amount_with_fee_reserve_msat": amountWithFeeReserveMsat,
|
|
}).Debug("Insufficient budget to make payment from isolated app")
|
|
message := NewInsufficientBalanceError().Error()
|
|
if description != "" {
|
|
message += " " + description
|
|
}
|
|
|
|
svc.eventPublisher.Publish(&events.Event{
|
|
Event: "nwc_permission_denied",
|
|
Properties: map[string]interface{}{
|
|
"app_name": app.Name,
|
|
"code": constants.ERROR_INSUFFICIENT_BALANCE,
|
|
"message": message,
|
|
},
|
|
})
|
|
return NewInsufficientBalanceError()
|
|
}
|
|
}
|
|
|
|
if appPermission.MaxAmountSat > 0 {
|
|
budgetUsageMsat, err := queries.GetBudgetUsageMsat(tx, &appPermission)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to calculate budget usage for app: %w", err)
|
|
}
|
|
if int(amountWithFeeReserveMsat/1000) > appPermission.MaxAmountSat-int(budgetUsageMsat/1000) {
|
|
message := NewQuotaExceededError().Error()
|
|
if description != "" {
|
|
message += " " + description
|
|
}
|
|
svc.eventPublisher.Publish(&events.Event{
|
|
Event: "nwc_permission_denied",
|
|
Properties: map[string]interface{}{
|
|
"app_name": app.Name,
|
|
"code": constants.ERROR_QUOTA_EXCEEDED,
|
|
"message": message,
|
|
},
|
|
})
|
|
return NewQuotaExceededError()
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// max of 1% or 10000 millisats (10 sats)
|
|
func CalculateFeeReserveMsat(amountMsat uint64) uint64 {
|
|
return uint64(math.Max(math.Ceil(float64(amountMsat)*0.01), 10000))
|
|
}
|
|
|
|
func makePreimageHex() ([]byte, error) {
|
|
bytes := make([]byte, 32) // 32 bytes * 8 bits/byte = 256 bits
|
|
_, err := rand.Read(bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return bytes, nil
|
|
}
|
|
|
|
func (svc *transactionsService) getBoostagramBytesFromCustomRecords(customRecords []lnclient.TLVRecord) []byte {
|
|
for _, record := range customRecords {
|
|
if record.Type == BoostagramTlvType {
|
|
bytes, err := hex.DecodeString(record.Value)
|
|
if err != nil {
|
|
logger.Logger.WithField("value", record.Value).WithError(err).Error("failed to decode boostagram tlv hex value")
|
|
return nil
|
|
}
|
|
|
|
// ensure the boostagram is valid json
|
|
var boostagram Boostagram
|
|
if err := json.Unmarshal(bytes, &boostagram); err != nil {
|
|
logger.Logger.WithField("value", string(bytes)).WithError(err).Error("failed to unmarshal boostagram to json")
|
|
return nil
|
|
}
|
|
|
|
return bytes
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *transactionsService) getDescriptionFromCustomRecords(customRecords []lnclient.TLVRecord) string {
|
|
var description string
|
|
|
|
for _, record := range customRecords {
|
|
switch record.Type {
|
|
case BoostagramTlvType:
|
|
bytes, err := hex.DecodeString(record.Value)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var boostagram Boostagram
|
|
if err := json.Unmarshal(bytes, &boostagram); err != nil {
|
|
continue
|
|
}
|
|
return boostagram.Message
|
|
|
|
// TODO: consider adding support for this in LDK
|
|
case WhatsatTlvType:
|
|
bytes, err := hex.DecodeString(record.Value)
|
|
if err == nil {
|
|
description = string(bytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
return description
|
|
}
|
|
|
|
func (svc *transactionsService) getAppIdFromCustomRecords(customRecords []lnclient.TLVRecord, tx *gorm.DB) *uint {
|
|
app := db.App{}
|
|
for _, record := range customRecords {
|
|
if record.Type == CustomKeyTlvType {
|
|
decodedString, err := hex.DecodeString(record.Value)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to parse custom key TLV record as hex")
|
|
continue
|
|
}
|
|
customValue, err := strconv.ParseUint(string(decodedString), 10, 64)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to parse custom key TLV record as number")
|
|
continue
|
|
}
|
|
err = tx.Take(&app, &db.App{
|
|
ID: uint(customValue),
|
|
}).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to find app by id from custom key TLV record")
|
|
continue
|
|
}
|
|
return &app.ID
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *transactionsService) SettleHoldInvoice(ctx context.Context, preimage string, lnClient lnclient.LNClient) (*Transaction, error) {
|
|
if len(preimage) != 64 {
|
|
return nil, errors.New("invalid preimage format")
|
|
}
|
|
preimageBytes, err := hex.DecodeString(preimage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid preimage hex: %w", err)
|
|
}
|
|
|
|
paymentHashBytes := sha256.Sum256(preimageBytes)
|
|
paymentHash := hex.EncodeToString(paymentHashBytes[:])
|
|
|
|
var dbTransaction db.Transaction
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
State: constants.TRANSACTION_STATE_ACCEPTED,
|
|
PaymentHash: paymentHash,
|
|
})
|
|
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("payment_hash", paymentHash).Error("Failed to find accepted hold invoice")
|
|
return nil, errors.New("failed to find accepted hold invoice")
|
|
}
|
|
|
|
if !dbTransaction.SelfPayment {
|
|
err = lnClient.SettleHoldInvoice(ctx, preimage)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"preimage": preimage,
|
|
}).WithError(err).Error("Failed to settle hold invoice via LN client")
|
|
// Don't mark DB as failed here, as the settle might succeed later or might have already succeeded.
|
|
return nil, err
|
|
}
|
|
|
|
settledTransaction, err := svc.markTransactionSettled(&dbTransaction, preimage, 0, dbTransaction.SelfPayment)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
"preimage": preimage,
|
|
}).WithError(err).Error("Failed to mark hold invoice as settled")
|
|
return nil, err
|
|
}
|
|
|
|
return settledTransaction, nil
|
|
}
|
|
|
|
func (svc *transactionsService) CancelHoldInvoice(ctx context.Context, paymentHash string, lnClient lnclient.LNClient) error {
|
|
|
|
var dbTransaction db.Transaction
|
|
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
|
Type: constants.TRANSACTION_TYPE_INCOMING,
|
|
State: constants.TRANSACTION_STATE_ACCEPTED,
|
|
PaymentHash: paymentHash,
|
|
})
|
|
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("payment_hash", paymentHash).Error("Failed to find accepted hold invoice")
|
|
return NewNotFoundError()
|
|
}
|
|
|
|
if !dbTransaction.SelfPayment {
|
|
err := lnClient.CancelHoldInvoice(ctx, paymentHash)
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
}).WithError(err).Error("Failed to cancel hold invoice via LN client")
|
|
// Don't mark DB as failed here, cancellation might have already happened or might succeed later.
|
|
return err
|
|
}
|
|
}
|
|
|
|
markedFailed, err := svc.markPaymentFailed(&dbTransaction, "Hold invoice was cancelled")
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
}).WithError(err).Error("Failed to mark hold invoice as failed due to cancellation")
|
|
return err
|
|
}
|
|
|
|
if !markedFailed {
|
|
// a concurrent cancellation already marked the invoice as failed and
|
|
// published the canceled event
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
}).Info("Hold invoice was already marked as failed")
|
|
return nil
|
|
}
|
|
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": paymentHash,
|
|
}).Info("Marked hold invoice as failed in DB due to cancellation")
|
|
|
|
svc.eventPublisher.Publish(&events.Event{
|
|
Event: "nwc_hold_invoice_canceled",
|
|
Properties: &dbTransaction,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *transactionsService) SetTransactionMetadata(ctx context.Context, id uint, metadata map[string]interface{}) error {
|
|
var metadataBytes []byte
|
|
metadataBytes, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
logger.Logger.WithError(err).Error("Failed to serialize metadata")
|
|
return err
|
|
}
|
|
if len(metadataBytes) > constants.INVOICE_METADATA_MAX_LENGTH {
|
|
return fmt.Errorf("encoded invoice metadata provided is too large. Limit: %d Received: %d", constants.INVOICE_METADATA_MAX_LENGTH, len(metadataBytes))
|
|
}
|
|
|
|
// UpdateColumn so we don't bump updated_at — metadata edits (e.g. user
|
|
// labels) shouldn't reorder the transaction in the list.
|
|
err = svc.db.Model(&db.Transaction{}).Where("id", id).UpdateColumn("metadata", datatypes.JSON(metadataBytes)).Error
|
|
if err != nil {
|
|
logger.Logger.WithError(err).WithField("metadata", metadata).Error("Failed to update transaction metadata")
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *transactionsService) SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error {
|
|
transaction := db.Transaction{}
|
|
err := svc.db.WithContext(ctx).First(&transaction, id).Error
|
|
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return NewNotFoundError()
|
|
}
|
|
return err
|
|
}
|
|
|
|
metadata := map[string]interface{}{}
|
|
if transaction.Metadata != nil {
|
|
if err := json.Unmarshal(transaction.Metadata, &metadata); err != nil {
|
|
return fmt.Errorf("failed to decode existing metadata: %w", err)
|
|
}
|
|
}
|
|
|
|
sanitizedLabels := map[string]string{}
|
|
for key, value := range labels {
|
|
normalizedKey := strings.TrimSpace(key)
|
|
normalizedValue := strings.TrimSpace(value)
|
|
if normalizedKey == "" || normalizedValue == "" {
|
|
continue
|
|
}
|
|
sanitizedLabels[normalizedKey] = normalizedValue
|
|
}
|
|
|
|
if len(sanitizedLabels) == 0 {
|
|
delete(metadata, "user_labels")
|
|
} else {
|
|
metadata["user_labels"] = sanitizedLabels
|
|
}
|
|
|
|
return svc.SetTransactionMetadata(ctx, id, metadata)
|
|
}
|
|
|
|
// markTransactionSettled marks an existing transaction as settled in its own
|
|
// database transaction and publishes the corresponding events after it
|
|
// commits, so subscribers never observe uncommitted state.
|
|
func (svc *transactionsService) markTransactionSettled(dbTransaction *db.Transaction, preimage string, feeMsat uint64, selfPayment bool) (*db.Transaction, error) {
|
|
if preimage == "" {
|
|
return nil, errors.New("no preimage in payment")
|
|
}
|
|
|
|
var settledTransaction *db.Transaction
|
|
var eventsToPublish []*events.Event
|
|
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
|
existingSettledTransaction, err := svc.findSettledTransaction(tx, dbTransaction)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existingSettledTransaction != nil {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Debug("payment already marked as sent")
|
|
settledTransaction = existingSettledTransaction
|
|
return nil
|
|
}
|
|
|
|
settledAt := time.Now()
|
|
err = tx.Model(dbTransaction).Updates(map[string]interface{}{
|
|
"State": constants.TRANSACTION_STATE_SETTLED,
|
|
"Preimage": &preimage,
|
|
"FeeMsat": feeMsat,
|
|
"FeeReserveMsat": 0,
|
|
"SettledAt": &settledAt,
|
|
"SelfPayment": selfPayment,
|
|
}).Error
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": dbTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to update DB transaction")
|
|
return err
|
|
}
|
|
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": dbTransaction.PaymentHash,
|
|
"type": dbTransaction.Type,
|
|
}).Info("Marked transaction as settled")
|
|
|
|
settledTransaction = dbTransaction
|
|
eventsToPublish = svc.afterTransactionSettled(tx, dbTransaction, &settledAt)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
svc.publishEvents(eventsToPublish)
|
|
|
|
return settledTransaction, nil
|
|
}
|
|
|
|
// createSettledTransactionFromNotification inserts a transaction directly in
|
|
// its settled state, in its own database transaction, and publishes the
|
|
// corresponding events after it commits. It is for the case where the
|
|
// LNClient notifies us of a sent or received payment we didn't already know
|
|
// about (e.g. if the LNClient is an external node, and the payment was made
|
|
// or received outside of Alby Hub, or a received keysend, which has no
|
|
// invoice created upfront).
|
|
func (svc *transactionsService) createSettledTransactionFromNotification(dbTransaction *db.Transaction, preimage string, feeMsat uint64, selfPayment bool) (*db.Transaction, error) {
|
|
if preimage == "" {
|
|
return nil, errors.New("no preimage in payment")
|
|
}
|
|
|
|
var settledTransaction *db.Transaction
|
|
var eventsToPublish []*events.Event
|
|
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
|
existingSettledTransaction, err := svc.findSettledTransaction(tx, dbTransaction)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existingSettledTransaction != nil {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Debug("payment already marked as settled")
|
|
settledTransaction = existingSettledTransaction
|
|
return nil
|
|
}
|
|
|
|
settledAt := time.Now()
|
|
dbTransaction.State = constants.TRANSACTION_STATE_SETTLED
|
|
dbTransaction.Preimage = &preimage
|
|
dbTransaction.FeeMsat = feeMsat
|
|
dbTransaction.FeeReserveMsat = 0
|
|
dbTransaction.SettledAt = &settledAt
|
|
dbTransaction.SelfPayment = selfPayment
|
|
if err := tx.Create(dbTransaction).Error; err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": dbTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to create settled DB transaction")
|
|
return err
|
|
}
|
|
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": dbTransaction.PaymentHash,
|
|
"type": dbTransaction.Type,
|
|
}).Info("Created settled transaction")
|
|
|
|
settledTransaction = dbTransaction
|
|
eventsToPublish = svc.afterTransactionSettled(tx, dbTransaction, &settledAt)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
svc.publishEvents(eventsToPublish)
|
|
|
|
return settledTransaction, nil
|
|
}
|
|
|
|
// lockTransactionsByPaymentHash takes a row lock on all transactions with the
|
|
// given payment hash on postgres, so that concurrent state changes for the
|
|
// same payment serialize (in sqlite transactions are serializable by default).
|
|
func (svc *transactionsService) lockTransactionsByPaymentHash(tx *gorm.DB, paymentHash string) error {
|
|
if tx.Dialector.Name() != "postgres" {
|
|
return nil
|
|
}
|
|
transactionsWithPaymentHash := []db.Transaction{}
|
|
err := tx.Where(&db.Transaction{
|
|
PaymentHash: paymentHash,
|
|
}).Clauses(clause.Locking{Strength: "UPDATE"}).Find(&transactionsWithPaymentHash).Error
|
|
if err != nil {
|
|
logger.Logger.WithField("payment_hash", paymentHash).WithError(err).Error("Failed to lock transactions by payment hash")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// findSettledTransaction returns the already-settled transaction matching
|
|
// dbTransaction if one exists, locking all transactions with the same payment
|
|
// hash to ensure only one transaction is settled per payment.
|
|
func (svc *transactionsService) findSettledTransaction(tx *gorm.DB, dbTransaction *db.Transaction) (*db.Transaction, error) {
|
|
if err := svc.lockTransactionsByPaymentHash(tx, dbTransaction.PaymentHash); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var existingSettledTransaction db.Transaction
|
|
result := tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
|
|
Type: dbTransaction.Type,
|
|
PaymentRequest: dbTransaction.PaymentRequest,
|
|
PaymentHash: dbTransaction.PaymentHash,
|
|
State: constants.TRANSACTION_STATE_SETTLED,
|
|
})
|
|
if result.Error != nil {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).WithError(result.Error).Error("Failed to check for existing settled transaction")
|
|
return nil, result.Error
|
|
}
|
|
if result.RowsAffected > 0 {
|
|
return &existingSettledTransaction, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// afterTransactionSettled runs the post-settlement side effects within the
|
|
// caller's database transaction and returns the events to publish after it
|
|
// commits.
|
|
func (svc *transactionsService) afterTransactionSettled(tx *gorm.DB, dbTransaction *db.Transaction, settledAt *time.Time) []*events.Event {
|
|
event := "nwc_payment_sent"
|
|
if dbTransaction.Type == constants.TRANSACTION_TYPE_INCOMING {
|
|
event = "nwc_payment_received"
|
|
}
|
|
|
|
eventsToPublish := []*events.Event{{
|
|
Event: event,
|
|
Properties: dbTransaction,
|
|
}}
|
|
|
|
if dbTransaction.AppId != nil {
|
|
var app db.App
|
|
result := tx.Limit(1).Find(&app, &db.App{
|
|
ID: *dbTransaction.AppId,
|
|
})
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id")
|
|
return eventsToPublish
|
|
}
|
|
|
|
svc.updateAppLastSettledTransactionAt(&app, tx, settledAt)
|
|
|
|
if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING {
|
|
if budgetWarningEvent := svc.checkBudgetUsage(&app, dbTransaction, tx); budgetWarningEvent != nil {
|
|
eventsToPublish = append(eventsToPublish, budgetWarningEvent)
|
|
}
|
|
}
|
|
}
|
|
|
|
return eventsToPublish
|
|
}
|
|
|
|
func (svc *transactionsService) publishEvents(eventsToPublish []*events.Event) {
|
|
for _, event := range eventsToPublish {
|
|
svc.eventPublisher.Publish(event)
|
|
}
|
|
}
|
|
|
|
func (svc *transactionsService) updateAppLastSettledTransactionAt(app *db.App, gormTransaction *gorm.DB, settledAt *time.Time) {
|
|
if err := gormTransaction.Model(app).Update("last_settled_transaction_at", settledAt).Error; err != nil {
|
|
logger.Logger.WithField("app_id", app.ID).WithError(err).Error("failed to update app last settled transaction time")
|
|
return
|
|
}
|
|
}
|
|
|
|
// checkBudgetUsage returns a budget warning event to publish after the
|
|
// caller's database transaction commits, or nil if no warning is due.
|
|
func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.Transaction, gormTransaction *gorm.DB) *events.Event {
|
|
if app.Isolated {
|
|
return nil
|
|
}
|
|
|
|
var appPermission db.AppPermission
|
|
result := gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{
|
|
AppId: app.ID,
|
|
Scope: constants.PAY_INVOICE_SCOPE,
|
|
})
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find pay_invoice scope")
|
|
return nil
|
|
}
|
|
|
|
budgetUsageMsat, err := queries.GetBudgetUsageMsat(gormTransaction, &appPermission)
|
|
if err != nil {
|
|
logger.Logger.WithField("app_id", dbTransaction.AppId).WithError(err).Error("failed to get budget usage")
|
|
return nil
|
|
}
|
|
budgetUsageSat := budgetUsageMsat / 1000
|
|
warningUsage := uint64(math.Floor(float64(appPermission.MaxAmountSat) * 0.8))
|
|
if budgetUsageSat >= warningUsage && budgetUsageSat-dbTransaction.AmountMsat/1000 < warningUsage {
|
|
return &events.Event{
|
|
Event: "nwc_budget_warning",
|
|
Properties: map[string]interface{}{
|
|
"name": app.Name,
|
|
"id": app.ID,
|
|
},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// markPaymentFailed marks the transaction as failed in its own database
|
|
// transaction and publishes the failed event after it commits, so subscribers
|
|
// never observe uncommitted state. It returns whether this call transitioned
|
|
// the transaction to failed (false if it was already failed), and refuses to
|
|
// mark a settled transaction as failed.
|
|
func (svc *transactionsService) markPaymentFailed(dbTransaction *db.Transaction, reason string) (bool, error) {
|
|
markedFailed := false
|
|
var eventsToPublish []*events.Event
|
|
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
|
// lock all transactions with the same payment hash so a concurrent
|
|
// settlement cannot slip in between the state check and the update
|
|
if err := svc.lockTransactionsByPaymentHash(tx, dbTransaction.PaymentHash); err != nil {
|
|
return err
|
|
}
|
|
|
|
var existingTransaction db.Transaction
|
|
result := tx.Limit(1).Find(&existingTransaction, &db.Transaction{
|
|
ID: dbTransaction.ID,
|
|
})
|
|
|
|
if result.Error != nil {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).WithError(result.Error).Error("could not find transaction to mark as failed")
|
|
return result.Error
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Error("could not find transaction to mark as failed")
|
|
return NewNotFoundError()
|
|
}
|
|
|
|
if existingTransaction.State == constants.TRANSACTION_STATE_FAILED {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("payment already marked as failed")
|
|
return nil
|
|
}
|
|
|
|
if existingTransaction.State == constants.TRANSACTION_STATE_SETTLED {
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Error("cannot mark settled payment as failed")
|
|
return errors.New("cannot mark settled payment as failed")
|
|
}
|
|
|
|
err := tx.Model(dbTransaction).Updates(map[string]interface{}{
|
|
"State": constants.TRANSACTION_STATE_FAILED,
|
|
"FeeReserveMsat": 0,
|
|
"FailureReason": reason,
|
|
}).Error
|
|
if err != nil {
|
|
logger.Logger.WithFields(logrus.Fields{
|
|
"payment_hash": dbTransaction.PaymentHash,
|
|
}).WithError(err).Error("Failed to mark transaction as failed")
|
|
return err
|
|
}
|
|
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("Marked transaction as failed")
|
|
|
|
markedFailed = true
|
|
eventsToPublish = append(eventsToPublish, &events.Event{
|
|
Event: "nwc_payment_failed",
|
|
Properties: dbTransaction,
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
svc.publishEvents(eventsToPublish)
|
|
return markedFailed, nil
|
|
}
|