mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
Merge branch 'master' of https://github.com/getAlby/hub into feat/greenlight-backend
This commit is contained in:
commit
6aa34914a4
6 changed files with 605 additions and 291 deletions
|
|
@ -12,7 +12,7 @@ import {
|
|||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import twoFiatLogo from "src/assets/cards/2fiat.png";
|
||||
import freedomiaLogo from "src/assets/cards/freedomia.png";
|
||||
import redotpayLogo from "src/assets/cards/redotpay.png";
|
||||
|
|
@ -34,6 +34,9 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "src/components/ui/dialog";
|
||||
import { FieldError } from "src/components/ui/field";
|
||||
import { Input } from "src/components/ui/input";
|
||||
import { Label } from "src/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -783,8 +786,100 @@ function ConnectCardDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
providers: Provider[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [showOtherCardForm, setShowOtherCardForm] = React.useState(false);
|
||||
const [otherCardName, setOtherCardName] = React.useState("");
|
||||
const [otherCardNameError, setOtherCardNameError] = React.useState("");
|
||||
|
||||
// The dialog is controlled and opened programmatically (no DialogTrigger),
|
||||
// so onOpenChange never fires with true — reset the form here instead.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setShowOtherCardForm(false);
|
||||
setOtherCardName("");
|
||||
setOtherCardNameError("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (o: boolean) => {
|
||||
if (o) {
|
||||
setShowOtherCardForm(false);
|
||||
setOtherCardName("");
|
||||
setOtherCardNameError("");
|
||||
}
|
||||
onOpenChange(o);
|
||||
};
|
||||
|
||||
const handleOtherCardSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const cardName = otherCardName.trim();
|
||||
if (!cardName) {
|
||||
setOtherCardNameError("Enter a card name");
|
||||
return;
|
||||
}
|
||||
sendEvent("debit_card_connect", { name: cardName });
|
||||
onOpenChange(false);
|
||||
navigate(
|
||||
`/apps/new?app=bitcoin-card-topup&name=${encodeURIComponent(`${cardName} - Bitcoin Card Topup`)}`
|
||||
);
|
||||
};
|
||||
|
||||
if (showOtherCardForm) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Name your card</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll use it to label your top-up connection.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleOtherCardSubmit}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="other-card-name">Card name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
id="other-card-name"
|
||||
value={otherCardName}
|
||||
onChange={(e) => {
|
||||
setOtherCardName(e.target.value);
|
||||
setOtherCardNameError("");
|
||||
}}
|
||||
placeholder="e.g. Moon"
|
||||
required
|
||||
autoComplete="off"
|
||||
aria-invalid={!!otherCardNameError || undefined}
|
||||
aria-describedby={
|
||||
otherCardNameError ? "other-card-name-error" : undefined
|
||||
}
|
||||
/>
|
||||
<FieldError id="other-card-name-error">
|
||||
{otherCardNameError}
|
||||
</FieldError>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowOtherCardForm(false)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit">Continue</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pick your card provider</DialogTitle>
|
||||
|
|
@ -835,13 +930,11 @@ function ConnectCardDialog({
|
|||
);
|
||||
})}
|
||||
|
||||
<Link
|
||||
to="/apps/new?app=bitcoin-card-topup"
|
||||
onClick={() => {
|
||||
sendEvent("debit_card_connect", { name: "Other" });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg border border-dashed border-border p-3 hover:bg-accent/40 transition-colors"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setShowOtherCardForm(true)}
|
||||
className="h-auto w-full justify-start gap-3 whitespace-normal rounded-lg border border-dashed border-border p-3 text-left text-base font-normal hover:bg-accent/40 dark:hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center justify-center size-10 rounded-lg shrink-0 bg-secondary text-secondary-foreground">
|
||||
<CreditCardIcon className="size-5" />
|
||||
|
|
@ -853,7 +946,7 @@ function ConnectCardDialog({
|
|||
</p>
|
||||
</div>
|
||||
<ArrowUpRightIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ type MockLn struct {
|
|||
MakeInvoiceErrors []error
|
||||
PayInvoiceResponses []*lnclient.PayInvoiceResponse
|
||||
PayInvoiceErrors []error
|
||||
PayKeysendResponses []*lnclient.PayKeysendResponse
|
||||
PayKeysendErrors []error
|
||||
PaymentDelay *time.Duration
|
||||
Pubkey string
|
||||
MockTransaction *lnclient.Transaction
|
||||
|
|
@ -116,6 +118,14 @@ func (mln *MockLn) SendPaymentSync(payReq string, amountMsat *uint64) (*lnclient
|
|||
}
|
||||
|
||||
func (mln *MockLn) SendKeysend(amountMsat uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) {
|
||||
if len(mln.PayKeysendResponses) > 0 {
|
||||
response := mln.PayKeysendResponses[0]
|
||||
err := mln.PayKeysendErrors[0]
|
||||
mln.PayKeysendResponses = mln.PayKeysendResponses[1:]
|
||||
mln.PayKeysendErrors = mln.PayKeysendErrors[1:]
|
||||
return response, err
|
||||
}
|
||||
|
||||
preimage = mln.SendKeysendPreimage
|
||||
if preimage == "" {
|
||||
return &lnclient.PayKeysendResponse{
|
||||
|
|
|
|||
|
|
@ -62,6 +62,49 @@ func TestSendPaymentSync_App_WithPermission(t *testing.T) {
|
|||
assert.Equal(t, dbRequestEvent.ID, *transaction.RequestEventId)
|
||||
}
|
||||
|
||||
func TestMarkSettled_App_BudgetWarning(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
defer svc.Remove()
|
||||
|
||||
app, _, err := tests.CreateApp(svc)
|
||||
assert.NoError(t, err)
|
||||
|
||||
appPermission := &db.AppPermission{
|
||||
AppId: app.ID,
|
||||
App: *app,
|
||||
Scope: constants.PAY_INVOICE_SCOPE,
|
||||
MaxAmountSat: 100,
|
||||
}
|
||||
err = svc.DB.Create(appPermission).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// settling this payment pushes the app over 80% of its 100 sat budget
|
||||
dbTransaction := db.Transaction{
|
||||
AppId: &app.ID,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
AmountMsat: 90000,
|
||||
}
|
||||
svc.DB.Create(&dbTransaction)
|
||||
|
||||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
_, err = transactionsService.markTransactionSettled(&dbTransaction, "test", 0, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
consumedEvents := mockEventConsumer.GetConsumedEvents()
|
||||
assert.Equal(t, 2, len(consumedEvents))
|
||||
eventNames := []string{}
|
||||
for _, consumedEvent := range consumedEvents {
|
||||
eventNames = append(eventNames, consumedEvent.Event)
|
||||
}
|
||||
assert.Contains(t, eventNames, "nwc_payment_sent")
|
||||
assert.Contains(t, eventNames, "nwc_budget_warning")
|
||||
}
|
||||
|
||||
func TestSendPaymentSync_App_BudgetExceeded(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
|
|
@ -48,6 +49,34 @@ func TestSendKeysend(t *testing.T) {
|
|||
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
|
||||
assert.Equal(t, transaction, settledTransaction)
|
||||
}
|
||||
func TestSendKeysend_FailedRemovesFeeReserve(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
defer svc.Remove()
|
||||
|
||||
svc.LNClient.(*tests.MockLn).PayKeysendErrors = append(svc.LNClient.(*tests.MockLn).PayKeysendErrors, errors.New("Some error"))
|
||||
svc.LNClient.(*tests.MockLn).PayKeysendResponses = append(svc.LNClient.(*tests.MockLn).PayKeysendResponses, nil)
|
||||
|
||||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
transaction, err := transactionsService.SendKeysend(uint64(1000), "fake destination", nil, "", svc.LNClient, nil, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, transaction)
|
||||
|
||||
failedTransaction := db.Transaction{}
|
||||
require.NoError(t, svc.DB.Where("type = ? AND state = ?", constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_FAILED).First(&failedTransaction).Error)
|
||||
|
||||
assert.Equal(t, uint64(1000), failedTransaction.AmountMsat)
|
||||
assert.Zero(t, failedTransaction.FeeReserveMsat)
|
||||
assert.Equal(t, "Some error", failedTransaction.FailureReason)
|
||||
|
||||
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
|
||||
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumedEvents()[0].Event)
|
||||
}
|
||||
|
||||
func TestSendKeysend_CustomPreimage(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/getAlby/hub/constants"
|
||||
"github.com/getAlby/hub/db"
|
||||
|
|
@ -181,10 +180,7 @@ func TestMarkSettled_Sent(t *testing.T) {
|
|||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err = transactionsService.markTransactionSettled(tx, &dbTransaction, "test", 0, false)
|
||||
return err
|
||||
})
|
||||
_, err = transactionsService.markTransactionSettled(&dbTransaction, "test", 0, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
|
||||
|
|
@ -212,29 +208,36 @@ func TestMarkSettled_Twice(t *testing.T) {
|
|||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
var wg sync.WaitGroup
|
||||
n := 10
|
||||
markErrors := make([]error, n)
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
for i := range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
time.Sleep(time.Duration(n) * 10 * time.Millisecond)
|
||||
_, err = transactionsService.markTransactionSettled(tx, &dbTransaction, "test", 0, false)
|
||||
time.Sleep(time.Duration(n) * 10 * time.Millisecond)
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// load an independent copy so goroutines don't share the struct
|
||||
var transactionCopy db.Transaction
|
||||
if err := svc.DB.First(&transactionCopy, dbTransaction.ID).Error; err != nil {
|
||||
markErrors[i] = err
|
||||
return
|
||||
}
|
||||
_, markErrors[i] = transactionsService.markTransactionSettled(&transactionCopy, "test", 0, false)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, markError := range markErrors {
|
||||
assert.NoError(t, markError)
|
||||
}
|
||||
|
||||
// ensure we only mark transaction settled once and only fire
|
||||
// settled notifications once
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
|
||||
var reloadedTransaction db.Transaction
|
||||
require.NoError(t, svc.DB.First(&reloadedTransaction, dbTransaction.ID).Error)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, reloadedTransaction.State)
|
||||
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
|
||||
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumedEvents()[0].Event)
|
||||
settledTransaction := mockEventConsumer.GetConsumedEvents()[0].Properties.(*db.Transaction)
|
||||
assert.Equal(t, &dbTransaction, settledTransaction)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, settledTransaction.State)
|
||||
assert.Equal(t, dbTransaction.PaymentHash, settledTransaction.PaymentHash)
|
||||
}
|
||||
|
||||
func TestMarkSettled_Received(t *testing.T) {
|
||||
|
|
@ -253,10 +256,7 @@ func TestMarkSettled_Received(t *testing.T) {
|
|||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err = transactionsService.markTransactionSettled(tx, &dbTransaction, "test", 0, false)
|
||||
return err
|
||||
})
|
||||
_, err = transactionsService.markTransactionSettled(&dbTransaction, "test", 0, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
|
||||
|
|
@ -284,10 +284,7 @@ func TestDoNotMarkSettledTwice(t *testing.T) {
|
|||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
_, err = transactionsService.markTransactionSettled(tx, &dbTransaction, "test", 0, false)
|
||||
return err
|
||||
})
|
||||
_, err = transactionsService.markTransactionSettled(&dbTransaction, "test", 0, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Zero(t, len(mockEventConsumer.GetConsumedEvents()))
|
||||
|
|
@ -309,11 +306,10 @@ func TestMarkFailed(t *testing.T) {
|
|||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
return transactionsService.markPaymentFailed(tx, &dbTransaction, "some routing error")
|
||||
})
|
||||
markedFailed, err := transactionsService.markPaymentFailed(&dbTransaction, "some routing error")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, markedFailed)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, dbTransaction.State)
|
||||
assert.Equal(t, 1, len(mockEventConsumer.GetConsumedEvents()))
|
||||
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumedEvents()[0].Event)
|
||||
|
|
@ -340,15 +336,43 @@ func TestDoNotMarkFailedTwice(t *testing.T) {
|
|||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
err = svc.DB.Transaction(func(tx *gorm.DB) error {
|
||||
return transactionsService.markPaymentFailed(tx, &dbTransaction, "some routing error")
|
||||
})
|
||||
markedFailed, err := transactionsService.markPaymentFailed(&dbTransaction, "some routing error")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, markedFailed)
|
||||
assert.Equal(t, updatedAt, dbTransaction.UpdatedAt)
|
||||
assert.Zero(t, len(mockEventConsumer.GetConsumedEvents()))
|
||||
}
|
||||
|
||||
func TestDoNotMarkSettledPaymentFailed(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
defer svc.Remove()
|
||||
|
||||
settledAt := time.Now()
|
||||
dbTransaction := db.Transaction{
|
||||
State: constants.TRANSACTION_STATE_SETTLED,
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
|
||||
AmountMsat: 123000,
|
||||
SettledAt: &settledAt,
|
||||
}
|
||||
svc.DB.Create(&dbTransaction)
|
||||
|
||||
mockEventConsumer := tests.NewMockEventConsumer()
|
||||
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
|
||||
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
|
||||
markedFailed, err := transactionsService.markPaymentFailed(&dbTransaction, "some routing error")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.False(t, markedFailed)
|
||||
|
||||
var reloadedTransaction db.Transaction
|
||||
require.NoError(t, svc.DB.First(&reloadedTransaction, dbTransaction.ID).Error)
|
||||
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, reloadedTransaction.State)
|
||||
assert.Zero(t, len(mockEventConsumer.GetConsumedEvents()))
|
||||
}
|
||||
|
||||
func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) {
|
||||
svc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -444,19 +444,17 @@ func (svc *transactionsService) SendPaymentSync(payReq string, amountMsat *uint6
|
|||
"bolt11": payReq,
|
||||
}).WithError(err).Error("Failed to send payment")
|
||||
|
||||
svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
return svc.markPaymentFailed(tx, &dbTransaction, err.Error())
|
||||
})
|
||||
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
|
||||
var settledTransaction *db.Transaction
|
||||
err = svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
settledTransaction, err = svc.markTransactionSettled(tx, &dbTransaction, response.Preimage, response.FeeMsat, selfPayment)
|
||||
return err
|
||||
})
|
||||
settledTransaction, err := svc.markTransactionSettled(&dbTransaction, response.Preimage, response.FeeMsat, selfPayment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -579,15 +577,11 @@ func (svc *transactionsService) SendKeysend(amountMsat uint64, destination strin
|
|||
"amount_msat": amountMsat,
|
||||
}).WithError(err).Error("Failed to send payment")
|
||||
|
||||
dbErr := svc.db.Model(&dbTransaction).Updates(&db.Transaction{
|
||||
PaymentHash: paymentHash,
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
}).Error
|
||||
if dbErr != nil {
|
||||
if _, markFailedErr := svc.markPaymentFailed(&dbTransaction, err.Error()); markFailedErr != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"destination": destination,
|
||||
"amount_msat": amountMsat,
|
||||
}).WithError(dbErr).Error("Failed to update DB transaction")
|
||||
}).WithError(markFailedErr).Error("Failed to mark payment as failed")
|
||||
}
|
||||
|
||||
return nil, err
|
||||
|
|
@ -625,12 +619,7 @@ func (svc *transactionsService) SendKeysend(amountMsat uint64, destination strin
|
|||
}
|
||||
|
||||
// the payment definitely succeeded
|
||||
var settledTransaction *db.Transaction
|
||||
err = svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
settledTransaction, err = svc.markTransactionSettled(tx, &dbTransaction, preimage, payKeysendResponse.FeeMsat, selfPayment)
|
||||
return err
|
||||
})
|
||||
|
||||
settledTransaction, err := svc.markTransactionSettled(&dbTransaction, preimage, payKeysendResponse.FeeMsat, selfPayment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -826,11 +815,7 @@ func (svc *transactionsService) checkUnsettledTransaction(ctx context.Context, t
|
|||
}
|
||||
// update transaction state
|
||||
if lnClientTransaction.SettledAt != nil {
|
||||
err = svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err = svc.markTransactionSettled(tx, transaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false)
|
||||
return err
|
||||
})
|
||||
|
||||
_, 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")
|
||||
}
|
||||
|
|
@ -847,73 +832,72 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
|
|||
}
|
||||
|
||||
var dbTransaction db.Transaction
|
||||
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
result := tx.Limit(1).Find(&dbTransaction, &db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentHash: lnClientTransaction.PaymentHash,
|
||||
})
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
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, tx)
|
||||
}
|
||||
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,
|
||||
}
|
||||
err := tx.Create(&dbTransaction).Error
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": lnClientTransaction.PaymentHash,
|
||||
}).WithError(err).Error("Failed to create transaction")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := svc.markTransactionSettled(tx, &dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false)
|
||||
return err
|
||||
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
PaymentHash: lnClientTransaction.PaymentHash,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if result.Error != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": lnClientTransaction.PaymentHash,
|
||||
}).WithError(err).Error("Failed to execute DB transaction")
|
||||
}).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 {
|
||||
|
|
@ -934,78 +918,79 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
|
|||
}
|
||||
|
||||
var dbTransaction db.Transaction
|
||||
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
// first lookup by pending
|
||||
result := tx.Limit(1).Find(&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_PENDING,
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
PaymentHash: lnClientTransaction.PaymentHash,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
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 := tx.Limit(1).Order("updated_at DESC").Find(&dbTransaction, &db.Transaction{
|
||||
result := svc.db.Limit(1).Find(&dbTransaction, &db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_FAILED,
|
||||
PaymentHash: lnClientTransaction.PaymentHash,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": lnClientTransaction.PaymentHash,
|
||||
}).WithError(result.Error).Error("Failed to find transaction")
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
result := tx.Limit(1).Find(&dbTransaction, &db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
PaymentHash: lnClientTransaction.PaymentHash,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
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 result.RowsAffected == 0 {
|
||||
dbTransaction = db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_OUTGOING,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
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
|
||||
}
|
||||
|
||||
err := tx.Create(&dbTransaction).Error
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": lnClientTransaction.PaymentHash,
|
||||
}).WithError(err).Error("Failed to create outgoing transaction")
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := svc.markTransactionSettled(tx, &dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaidMsat), false)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
case "nwc_lnclient_payment_failed":
|
||||
paymentFailedAsyncProperties, ok := event.Properties.(*lnclient.PaymentFailedEventProperties)
|
||||
|
|
@ -1028,9 +1013,11 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
|
|||
return
|
||||
}
|
||||
|
||||
svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
return svc.markPaymentFailed(tx, &dbTransaction, paymentFailedAsyncProperties.Reason)
|
||||
})
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1125,11 +1112,7 @@ func (svc *transactionsService) interceptSelfPayment(paymentRequest string, paym
|
|||
return nil, errors.New("preimage is not set on transaction. Self payments not supported")
|
||||
}
|
||||
|
||||
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := svc.markTransactionSettled(tx, &incomingTransaction, *incomingTransaction.Preimage, uint64(0), true)
|
||||
return err
|
||||
})
|
||||
|
||||
_, err := svc.markTransactionSettled(&incomingTransaction, *incomingTransaction.Preimage, uint64(0), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1387,18 +1370,12 @@ func (svc *transactionsService) SettleHoldInvoice(ctx context.Context, preimage
|
|||
return nil, err
|
||||
}
|
||||
|
||||
var settledTransaction *db.Transaction
|
||||
err = svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
settledTransaction, err = svc.markTransactionSettled(tx, &dbTransaction, preimage, 0, dbTransaction.SelfPayment)
|
||||
return 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 DB transaction while settling hold invoice")
|
||||
}).WithError(err).Error("Failed to mark hold invoice as settled")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -1430,37 +1407,23 @@ func (svc *transactionsService) CancelHoldInvoice(ctx context.Context, paymentHa
|
|||
}
|
||||
}
|
||||
|
||||
err := svc.db.Transaction(func(tx *gorm.DB) error {
|
||||
var dbTransaction db.Transaction
|
||||
result := tx.Limit(1).Find(&dbTransaction, &db.Transaction{
|
||||
Type: constants.TRANSACTION_TYPE_INCOMING,
|
||||
State: constants.TRANSACTION_STATE_ACCEPTED,
|
||||
PaymentHash: paymentHash,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": paymentHash,
|
||||
}).WithError(result.Error).Error("Failed to find accepted hold invoice in DB for cancellation")
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": paymentHash,
|
||||
}).Warn("No accepted hold invoice found in DB to mark as failed due to cancellation")
|
||||
return NewNotFoundError()
|
||||
}
|
||||
|
||||
return svc.markPaymentFailed(tx, &dbTransaction, "Hold invoice was cancelled")
|
||||
})
|
||||
|
||||
markedFailed, err := svc.markPaymentFailed(&dbTransaction, "Hold invoice was cancelled")
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": paymentHash,
|
||||
}).WithError(err).Error("Failed DB transaction while canceling hold invoice")
|
||||
}).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")
|
||||
|
|
@ -1532,61 +1495,171 @@ func (svc *transactionsService) SetTransactionUserLabels(ctx context.Context, id
|
|||
return svc.SetTransactionMetadata(ctx, id, metadata)
|
||||
}
|
||||
|
||||
func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransaction *db.Transaction, preimage string, feeMsat uint64, selfPayment bool) (*db.Transaction, error) {
|
||||
// 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")
|
||||
}
|
||||
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
// lock based on payment hash to ensure we only mark one transaction as settled
|
||||
// (in sqlite transactions are serializable by default)
|
||||
transactionsWithPaymentHash := []db.Transaction{}
|
||||
tx.Where(&db.Transaction{
|
||||
PaymentHash: dbTransaction.PaymentHash,
|
||||
}).Clauses(clause.Locking{Strength: "UPDATE"}).Find(&transactionsWithPaymentHash)
|
||||
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
|
||||
if tx.Limit(1).Find(&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,
|
||||
}).RowsAffected > 0 {
|
||||
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Debug("payment already marked as sent")
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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 nil, err
|
||||
}
|
||||
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
"payment_hash": dbTransaction.PaymentHash,
|
||||
"type": dbTransaction.Type,
|
||||
}).Info("Marked transaction as settled")
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
svc.eventPublisher.Publish(&events.Event{
|
||||
eventsToPublish := []*events.Event{{
|
||||
Event: event,
|
||||
Properties: dbTransaction,
|
||||
})
|
||||
}}
|
||||
|
||||
if dbTransaction.AppId != nil {
|
||||
var app db.App
|
||||
|
|
@ -1595,17 +1668,25 @@ func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransactio
|
|||
})
|
||||
if result.RowsAffected == 0 {
|
||||
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id")
|
||||
return dbTransaction, nil
|
||||
return eventsToPublish
|
||||
}
|
||||
|
||||
svc.updateAppLastSettledTransactionAt(&app, tx, &settledAt)
|
||||
svc.updateAppLastSettledTransactionAt(&app, tx, settledAt)
|
||||
|
||||
if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING {
|
||||
svc.checkBudgetUsage(&app, dbTransaction, tx)
|
||||
if budgetWarningEvent := svc.checkBudgetUsage(&app, dbTransaction, tx); budgetWarningEvent != nil {
|
||||
eventsToPublish = append(eventsToPublish, budgetWarningEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dbTransaction, nil
|
||||
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) {
|
||||
|
|
@ -1615,9 +1696,11 @@ func (svc *transactionsService) updateAppLastSettledTransactionAt(app *db.App, g
|
|||
}
|
||||
}
|
||||
|
||||
func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.Transaction, gormTransaction *gorm.DB) {
|
||||
// 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
|
||||
return nil
|
||||
}
|
||||
|
||||
var appPermission db.AppPermission
|
||||
|
|
@ -1627,59 +1710,91 @@ func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.
|
|||
})
|
||||
if result.RowsAffected == 0 {
|
||||
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find pay_invoice scope")
|
||||
return
|
||||
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
|
||||
return nil
|
||||
}
|
||||
budgetUsageSat := budgetUsageMsat / 1000
|
||||
warningUsage := uint64(math.Floor(float64(appPermission.MaxAmountSat) * 0.8))
|
||||
if budgetUsageSat >= warningUsage && budgetUsageSat-dbTransaction.AmountMsat/1000 < warningUsage {
|
||||
svc.eventPublisher.Publish(&events.Event{
|
||||
return &events.Event{
|
||||
Event: "nwc_budget_warning",
|
||||
Properties: map[string]interface{}{
|
||||
"name": app.Name,
|
||||
"id": app.ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *transactionsService) markPaymentFailed(tx *gorm.DB, dbTransaction *db.Transaction, reason string) error {
|
||||
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 existingTransaction.State == constants.TRANSACTION_STATE_FAILED {
|
||||
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("payment already marked as failed")
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
svc.eventPublisher.Publish(&events.Event{
|
||||
Event: "nwc_payment_failed",
|
||||
Properties: dbTransaction,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue