feat: move events to transactions service (#510)

* feat: move events to transactions service (WIP)

* fix: tests

* chore: improve quota exceeded error message

* fix: list transactions offset

* fix: add nwc_permission_denied events in transactions service

* fix: check for duplicate payment

* fix: unstable test

* chore: add payment settled tests

* fix: tests race condition

* fix: internal keysend support

* chore: add tests for check unsettled transactions fallback

* chore: add extra tests

* chore: extra tests

* fix: pass metadata in keysend self payments

* fix: throw error if transaction is already marked as sent

* fix: mark transaction settled

* fix: consume events in alby oauth service

* chore: add TODOs

* fix: do not fire duplicate failed payment events

* docs: add basic architecture docs to readme
This commit is contained in:
Roland 2024-08-23 11:52:47 +07:00 committed by GitHub
parent 2cdc20b6e0
commit 5f1b0decdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1104 additions and 377 deletions

View file

@ -425,3 +425,66 @@ In this repository. Or manually download the docker-compose.yml file and then ru
### Render.com
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/getAlby/hub)
## Alby Hub Architecture
### NWC Wallet Service
At a high level Alby Hub is an [NWC](https://nwc.dev) wallet service which allows users to use their single wallet seamlessly within a multitude of apps(clients). Any client that supports NWC and has a valid connection secret can communicate with the wallet service to execute commands on the underlying wallet (internally called LNClient).
### LNClient
The LNClient interface abstracts the differences between wallet implementations and allows users to run Alby Hub with their preferred wallet, such as LDK, LND, Phoenixd, Cashu, Breez, Greenlight.
### Transactions Service
Alby Hub maintains its own database of transactions to enable features like self-payments for isolated app connections (subaccounts), additional metadata (that apps can provide when creating invoices or making keysend payments), and to associate transactions with apps, providing additional context to users about how their wallet is being used across apps.
The transactions service sits between the LNClient and two possible entry points: the NIP-47 handlers, and our internal API which is used by the Alby Hub frontend.
### Event Publisher
Internally Alby Hub uses a basic implementation of the pubsub messaging pattern which allows different parts of the system to fire or consume events. For example, the LNClients can fire events when they asynchronously receive or send a payment, which is consumed by the transaction service to update our internal transaction database, and then fire its own events which can be consumed by the NIP-47 notifier to publish notification events to subscribing apps, and also by the Alby OAuth service to send events to the Alby Account (to enable features such as encrypted static channel backups, email notifications of payments, and more).
#### Published Events
- `nwc_started` - when Alby Hub process starts
- `nwc_stopped` - when Alby Hub process gracefully exits
- `nwc_node_started` - when Alby Hub successfully starts or connects to the configured LNClient.
- `nwc_node_start_failed` - The LNClient failed to sync or could not be connected to (e.g. network error, or incorrect configuration for an external node)
- `nwc_node_stopped` the LNClient was gracefully stopped
- `nwc_node_stop_failed` - failed to request the node to stop. Ideally this never happens.
- `nwc_node_sync_failed` - the node failed to sync onchain, wallet or fee estimates.
- `nwc_unlocked` - when user enters correct password (HTTP only)
- `nwc_channel_ready` - a new channel is opened, active and ready to use
- `nwc_channel_closed` - a channel was closed (could be co-operatively or a force closure)
- `nwc_backup_channels` - send a list of channels that can be used as a SCB.
- `nwc_outgoing_liquidity_required` - when user tries to pay an invoice more than their current outgoing liquidity across active channels
- `nwc_incoming_liquidity_required` - when user tries to creates an invoice more than their current incoming liquidity across active channels
- `nwc_permission_denied` - a NIP-47 request was denied - either due to the app connection not having permission for a certain command, or the app does not have insufficient balance or budget to make the payment.
- `nwc_payment_failed` - failed to make a lightning payment
- `nwc_payment_sent` - successfully made a lightning payment
- `nwc_payment_received` - received a lightning payment
- `nwc_lnclient_*` - underlying LNClient events, consumed only by the transactions service.
### NIP-47 Handlers
Alby Hub subscribes to a standard Nostr relay and listens for whitelisted events from known pubkeys and handles these requests in a similar way as a standard HTTP API controller, and either doing requests to the underling LNClient, or to the transactions service in the case of payments and invoices.
### Frontend
The Alby Hub frontend is a standard React app that can run in one of two modes: as an HTTP server, or desktop app, built by Wails. To abstract away, both the HTTP service and Wails handlers pass requests through to the API, where the business logic is located, for direct requests from user interactions.
#### Authentication
Alby Hub uses simple JWT auth in HTTP mode, which also allows the HTTP API to be exposed to external apps, which can use Alby Hub's API to have access to extra functionality currently not covered by the NIP-47 spec, however there are downsides - this API is not a public spec, and only works over HTTP. Therefore, apps are recommended to use NIP-47 where possible.
### Encryption
Sensitive data such as the seed phrase are saved AES-encrypted by the user's unlock password, and only decrypted in-memory in order to run the lightning node. This data is not logged and is only transferred over encrypted channels, and always requires the user's unlock password to access.
All requests to the wallet service are made with one of the following ways:
- NIP-47 - requests encrypted by NIP-04 using randomly-generated keypairs (one per app connection) and sent via websocket through the configured relay.
- HTTP - requests encrypted by JWT and ideally HTTPS (except self-hosted, which can be protected by firewall)
- Desktop mode - requests are made internally through the Wails router, without any kind of network traffic.

View file

@ -292,7 +292,7 @@ func (svc *albyOAuthService) DrainSharedWallet(ctx context.Context, lnClient lnc
logger.Logger.WithField("amount", amount).WithError(err).Error("Draining Alby shared wallet funds")
transaction, err := transactions.NewTransactionsService(svc.db).MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120, nil, lnClient, nil, nil)
transaction, err := transactions.NewTransactionsService(svc.db, svc.eventPublisher).MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120, nil, lnClient, nil, nil)
if err != nil {
logger.Logger.WithField("amount", amount).WithError(err).Error("Failed to make invoice")
return err
@ -460,11 +460,15 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
}
func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
// run non-blocking
go svc.consumeEvent(ctx, event, globalProperties)
}
defer func() {
// ensure the app cannot panic if firing events to Alby API fails
if r := recover(); r != nil {
logger.Logger.WithField("event", event).WithField("r", r).Error("Failed to consume event in alby oauth service")
}
}()
// TODO: we should have a whitelist rather than a blacklist, so new events are not automatically sent
func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
// TODO: rename this config option to be specific to the alby API
if !svc.cfg.GetEnv().LogEvents {
logger.Logger.WithField("event", event).Debug("Skipped sending to alby events API")
@ -478,6 +482,11 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
return
}
if strings.HasPrefix(event.Event, "nwc_lnclient_") {
// don't consume internal LNClient events
return
}
if event.Event == "nwc_payment_received" {
type paymentReceivedEventProperties struct {
PaymentHash string `json:"payment_hash"`
@ -486,7 +495,7 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
event = &events.Event{
Event: event.Event,
Properties: &paymentReceivedEventProperties{
PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash,
PaymentHash: event.Properties.(*db.Transaction).PaymentHash,
},
}
}
@ -501,20 +510,20 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
event = &events.Event{
Event: event.Event,
Properties: &paymentSentEventProperties{
PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash,
Duration: uint64(*event.Properties.(*lnclient.Transaction).SettledAt - event.Properties.(*lnclient.Transaction).CreatedAt),
PaymentHash: event.Properties.(*db.Transaction).PaymentHash,
Duration: uint64(event.Properties.(*db.Transaction).SettledAt.Unix() - event.Properties.(*db.Transaction).CreatedAt.Unix()),
},
}
}
if event.Event == "nwc_payment_failed_async" {
paymentFailedAsyncProperties, ok := event.Properties.(*events.PaymentFailedAsyncProperties)
if event.Event == "nwc_payment_failed" {
transaction, ok := event.Properties.(*db.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
return
}
type paymentSentEventProperties struct {
type paymentFailedEventProperties struct {
PaymentHash string `json:"payment_hash"`
Reason string `json:"reason"`
}
@ -522,9 +531,9 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
// pass a new custom event with less detail
event = &events.Event{
Event: event.Event,
Properties: &paymentSentEventProperties{
PaymentHash: paymentFailedAsyncProperties.Transaction.PaymentHash,
Reason: paymentFailedAsyncProperties.Reason,
Properties: &paymentFailedEventProperties{
PaymentHash: transaction.PaymentHash,
Reason: transaction.FailureReason,
},
}
}

View file

@ -35,3 +35,17 @@ const (
// each transaction would have to have a maximum size of 10240
// accounting for encryption and other metadata in the response, this is set to 2048 characters
const INVOICE_METADATA_MAX_LENGTH = 2048
// errors used by NIP-47 and the transaction service
const (
ERROR_INTERNAL = "INTERNAL"
ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
ERROR_INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
ERROR_UNAUTHORIZED = "UNAUTHORIZED"
ERROR_EXPIRED = "EXPIRED"
ERROR_RESTRICTED = "RESTRICTED"
ERROR_BAD_REQUEST = "BAD_REQUEST"
ERROR_NOT_FOUND = "NOT_FOUND"
ERROR_OTHER = "OTHER"
)

View file

@ -0,0 +1,26 @@
package migrations
import (
_ "embed"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// This migration removes old app permissions for request methods (now we use scopes)
var _202408191242_transaction_failure_reason = &gormigrate.Migration{
ID: "202408191242_transaction_failure_reason",
Migrate: func(tx *gorm.DB) error {
if err := tx.Exec(`
ALTER TABLE transactions ADD failure_reason string;
`).Error; err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -20,6 +20,7 @@ func Migrate(gormDB *gorm.DB) error {
_202407201604_transactions_indexes,
_202407262257_remove_invalid_scopes,
_202408061737_add_boostagrams_and_use_json,
_202408191242_transaction_failure_reason,
})
return m.Migrate()

View file

@ -82,6 +82,7 @@ type Transaction struct {
Metadata datatypes.JSON
SelfPayment bool
Boostagram datatypes.JSON
FailureReason string
}
type DBService interface {

View file

@ -48,9 +48,8 @@ func (ep *eventPublisher) Publish(event *Event) {
defer ep.subscriberMtx.Unlock()
logger.Logger.WithFields(logrus.Fields{"event": event, "global": ep.globalProperties}).Debug("Publishing event")
for _, listener := range ep.listeners {
// events are consumed in sequence as some listeners depend on earlier consumers
// (e.g. NIP-47 notifier depends on transactions service updating transactions)
listener.ConsumeEvent(context.Background(), event, ep.globalProperties)
// consume event without blocking thread
go listener.ConsumeEvent(context.Background(), event, ep.globalProperties)
}
}

View file

@ -2,8 +2,6 @@ package events
import (
"context"
"github.com/getAlby/hub/lnclient"
)
type EventSubscriber interface {
@ -34,8 +32,3 @@ type ChannelBackupInfo struct {
FundingTxID string `json:"funding_tx_id"`
FundingTxVout uint32 `json:"funding_tx_vout"`
}
type PaymentFailedAsyncProperties struct {
Transaction *lnclient.Transaction
Reason string
}

View file

@ -1314,7 +1314,7 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
}
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: transaction,
})
case ldk_node.EventPaymentSuccessful:
@ -1335,7 +1335,7 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
}
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_sent",
Event: "nwc_lnclient_payment_sent",
Properties: transaction,
})
case ldk_node.EventPaymentFailed:
@ -1358,8 +1358,8 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
reason := ls.getPaymentFailReason(&eventType)
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_failed_async",
Properties: &events.PaymentFailedAsyncProperties{
Event: "nwc_lnclient_payment_failed",
Properties: &lnclient.PaymentFailedEventProperties{
Transaction: transaction,
Reason: reason,
},

View file

@ -495,8 +495,8 @@ func NewLNDService(ctx context.Context, eventPublisher events.EventPublisher, ln
continue
}
eventPublisher.Publish(&events.Event{
Event: "nwc_payment_failed_async",
Properties: &events.PaymentFailedAsyncProperties{
Event: "nwc_lnclient_payment_failed",
Properties: &lnclient.PaymentFailedEventProperties{
Transaction: transaction,
Reason: payment.FailureReason.String(),
},
@ -511,7 +511,7 @@ func NewLNDService(ctx context.Context, eventPublisher events.EventPublisher, ln
continue
}
eventPublisher.Publish(&events.Event{
Event: "nwc_payment_sent",
Event: "nwc_lnclient_payment_sent",
Properties: transaction,
})
default:
@ -561,7 +561,7 @@ func NewLNDService(ctx context.Context, eventPublisher events.EventPublisher, ln
}).Info("Received new invoice")
eventPublisher.Publish(&events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: lndInvoiceToTransaction(invoice),
})
}

View file

@ -171,6 +171,11 @@ type BalancesResponse struct {
type NetworkGraphResponse = interface{}
type PaymentFailedEventProperties struct {
Transaction *Transaction
Reason string
}
// default invoice expiry in seconds (1 day)
const DEFAULT_INVOICE_EXPIRY = 86400

View file

@ -3,6 +3,7 @@ package controllers
import (
"encoding/json"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/sirupsen/logrus"
@ -17,7 +18,7 @@ func decodeRequest(request *models.Request, methodParams interface{}) *models.Re
return &models.Response{
ResultType: request.Method,
Error: &models.Error{
Code: models.ERROR_BAD_REQUEST,
Code: constants.ERROR_BAD_REQUEST,
Message: err.Error(),
}}
}

View file

@ -3,6 +3,7 @@ package controllers
import (
"context"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/db/queries"
"github.com/getAlby/hub/logger"
@ -41,7 +42,7 @@ func (controller *nip47Controller) HandleGetBalanceEvent(ctx context.Context, ni
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})

View file

@ -46,7 +46,7 @@ func TestHandleGetBalanceEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -80,7 +80,7 @@ func TestHandleGetBalanceEvent_IsolatedApp_NoTransactions(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -127,7 +127,7 @@ func TestHandleGetBalanceEvent_IsolatedApp_Transactions(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetBalanceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)

View file

@ -49,7 +49,7 @@ func (controller *nip47Controller) HandleGetInfoEvent(ctx context.Context, nip47
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})

View file

@ -54,7 +54,7 @@ func TestHandleGetInfoEvent_NoPermission(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -102,7 +102,7 @@ func TestHandleGetInfoEvent_WithPermission(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -158,7 +158,7 @@ func TestHandleGetInfoEvent_WithNotifications(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleGetInfoEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)

View file

@ -3,6 +3,7 @@ package controllers
import (
"context"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/nbd-wtf/go-nostr"
@ -57,7 +58,7 @@ func (controller *nip47Controller) HandleListTransactionsEvent(ctx context.Conte
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})

View file

@ -75,7 +75,7 @@ func TestHandleListTransactionsEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleListTransactionsEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse)

View file

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/nbd-wtf/go-nostr"
@ -49,7 +50,7 @@ func (controller *nip47Controller) HandleLookupInvoiceEvent(ctx context.Context,
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()),
},
}, nostr.Tags{})

View file

@ -66,7 +66,7 @@ func TestHandleLookupInvoiceEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleLookupInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse)

View file

@ -3,6 +3,7 @@ package controllers
import (
"context"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/nbd-wtf/go-nostr"
@ -53,7 +54,7 @@ func (controller *nip47Controller) HandleMakeInvoiceEvent(ctx context.Context, n
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})

View file

@ -64,7 +64,7 @@ func TestHandleMakeInvoiceEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMakeInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, *dbRequestEvent.AppId, publishResponse)

View file

@ -3,20 +3,21 @@ package controllers
import (
"errors"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/transactions"
)
func mapNip47Error(err error) *models.Error {
code := models.ERROR_INTERNAL
code := constants.ERROR_INTERNAL
if errors.Is(err, transactions.NewNotFoundError()) {
code = models.ERROR_NOT_FOUND
code = constants.ERROR_NOT_FOUND
}
if errors.Is(err, transactions.NewInsufficientBalanceError()) {
code = models.ERROR_INSUFFICIENT_BALANCE
code = constants.ERROR_INSUFFICIENT_BALANCE
}
if errors.Is(err, transactions.NewQuotaExceededError()) {
code = models.ERROR_QUOTA_EXCEEDED
code = constants.ERROR_QUOTA_EXCEEDED
}
return &models.Error{

View file

@ -6,6 +6,7 @@ import (
"strings"
"sync"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
@ -30,6 +31,7 @@ func (controller *nip47Controller) HandleMultiPayInvoiceEvent(ctx context.Contex
publishResponse(resp, nostr.Tags{})
return
}
logger.Logger.WithField("multiPayParams", multiPayParams).Debug("sending multi payment")
var wg sync.WaitGroup
wg.Add(len(multiPayParams.Invoices))
@ -52,7 +54,7 @@ func (controller *nip47Controller) HandleMultiPayInvoiceEvent(ctx context.Contex
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()),
},
}, nostr.Tags{dTag})

View file

@ -8,11 +8,13 @@ import (
"testing"
"github.com/nbd-wtf/go-nostr"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/getAlby/hub/nip47/permissions"
"github.com/getAlby/hub/tests"
@ -27,22 +29,7 @@ const nip47MultiPayJson = `
"invoice": "lntb1230n1pjypux0pp5xgxzcks5jtx06k784f9dndjh664wc08ucrganpqn52d0ftrh9n8sdqyw3jscqzpgxqyz5vqsp5rkx7cq252p3frx8ytjpzc55rkgyx2mfkzzraa272dqvr2j6leurs9qyyssqhutxa24r5hqxstchz5fxlslawprqjnarjujp5sm3xj7ex73s32sn54fthv2aqlhp76qmvrlvxppx9skd3r5ut5xutgrup8zuc6ay73gqmra29m"
},
{
"invoice": "lntb1230n1pjypux0pp5xgxzcks5jtx06k784f9dndjh664wc08ucrganpqn52d0ftrh9n8sdqyw3jscqzpgxqyz5vqsp5rkx7cq252p3frx8ytjpzc55rkgyx2mfkzzraa272dqvr2j6leurs9qyyssqhutxa24r5hqxstchz5fxlslawprqjnarjujp5sm3xj7ex73s32sn54fthv2aqlhp76qmvrlvxppx9skd3r5ut5xutgrup8zuc6ay73gqmra29m"
}
]
}
}
`
const nip47MultiPayOneOverflowingBudgetJson = `
{
"method": "multi_pay_invoice",
"params": {
"invoices": [{
"invoice": "lnbcrt5u1pjuywzppp5h69dt59cypca2wxu69sw8ga0g39a3yx7dqug5nthrw3rcqgfdu4qdqqcqzzsxqyz5vqsp5gzlpzszyj2k30qmpme7jsfzr24wqlvt9xdmr7ay34lfelz050krs9qyyssq038x07nh8yuv8hdpjh5y8kqp7zcd62ql9na9xh7pla44htjyy02sz23q7qm2tza6ct4ypljk54w9k9qsrsu95usk8ce726ytep6vhhsq9mhf9a"
},
{
"invoice": "lntb1230n1pjypux0pp5xgxzcks5jtx06k784f9dndjh664wc08ucrganpqn52d0ftrh9n8sdqyw3jscqzpgxqyz5vqsp5rkx7cq252p3frx8ytjpzc55rkgyx2mfkzzraa272dqvr2j6leurs9qyyssqhutxa24r5hqxstchz5fxlslawprqjnarjujp5sm3xj7ex73s32sn54fthv2aqlhp76qmvrlvxppx9skd3r5ut5xutgrup8zuc6ay73gqmra29m"
"invoice": "lntbs1230n1pnvxqc2dqqnp4q0w0f29u6f7yrrpr5y6wj45gtnyhtch9u2m2j7qrws8eevrw90c72pp57gnea9rwqh9c62dl67akgyhuxm7dd3fgwufyuyctgx3awuv8f7cqsp56rtp7kryxssfp3lk7h79uv7n55dc4nwuvslva64caxz45ysefmeq9qyysgqcqpcxqyz5vq7trlnnrjjtfkaw3evfgqh7nxayppkvlkxa2nzhg39zs372j7hff8kht7j40hl0elh2ukhu26nzawvk3aqszdl8ppxhzsgtumemewtccq3xryqt"
}
]
}
@ -65,13 +52,22 @@ const nip47MultiPayOneMalformedInvoiceJson = `
}
`
func TestHandleMultiPayInvoiceEvent(t *testing.T) {
func TestHandleMultiPayInvoiceEvent_Success(t *testing.T) {
ctx := context.TODO()
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
var preimages = []string{"123preimage", "123preimage2"}
svc.LNClient.(*tests.MockLn).PayInvoiceResponses = []*lnclient.PayInvoiceResponse{{
Preimage: preimages[0],
}, {
Preimage: preimages[1],
}}
svc.LNClient.(*tests.MockLn).PayInvoiceErrors = []error{nil, nil}
app, _, err := tests.CreateApp(svc)
assert.NoError(t, err)
@ -104,17 +100,30 @@ func TestHandleMultiPayInvoiceEvent(t *testing.T) {
assert.NoError(t, err)
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
assert.Equal(t, 2, len(responses))
for i := 0; i < len(responses); i++ {
assert.Equal(t, "123preimage", responses[i].Result.(payResponse).Preimage)
assert.Equal(t, tests.MockPaymentHash, dTags[i].GetFirst([]string{"d"}).Value())
assert.Nil(t, responses[i].Error)
var paymentHashes = []string{
"320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf",
"f2279e946e05cb8d29bfd7bb6412fc36fcd6c52877124e130b41a3d771874fb0",
}
assert.Equal(t, 2, len(responses))
// we can't guarantee which request was processed first
// so swap them if they are back to front
if dTags[0].GetFirst([]string{"d"}).Value() != paymentHashes[0] {
responses[0], responses[1] = responses[1], responses[0]
dTags[0], dTags[1] = dTags[1], dTags[0]
preimages[0], preimages[1] = preimages[1], preimages[0]
}
for i := 0; i < len(responses); i++ {
assert.Equal(t, preimages[i], responses[i].Result.(payResponse).Preimage)
assert.Equal(t, paymentHashes[i], dTags[i].GetFirst([]string{"d"}).Value())
assert.Nil(t, responses[i].Error)
}
}
func TestHandleMultiPayInvoiceEvent_OneMalformedInvoice(t *testing.T) {
@ -155,7 +164,7 @@ func TestHandleMultiPayInvoiceEvent_OneMalformedInvoice(t *testing.T) {
svc.DB.Save(requestEvent)
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayInvoiceEvent(ctx, nip47Request, requestEvent.ID, app, publishResponse)
@ -170,7 +179,7 @@ func TestHandleMultiPayInvoiceEvent_OneMalformedInvoice(t *testing.T) {
}
assert.Equal(t, "invoiceId123", dTags[0].GetFirst([]string{"d"}).Value())
assert.Equal(t, models.ERROR_INTERNAL, responses[0].Error.Code)
assert.Equal(t, constants.ERROR_INTERNAL, responses[0].Error.Code)
assert.Nil(t, responses[0].Result)
assert.Equal(t, tests.MockPaymentHash, dTags[1].GetFirst([]string{"d"}).Value())
@ -228,7 +237,7 @@ func TestHandleMultiPayInvoiceEvent_IsolatedApp_OneBudgetExceeded(t *testing.T)
assert.NoError(t, err)
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -242,13 +251,22 @@ func TestHandleMultiPayInvoiceEvent_IsolatedApp_OneBudgetExceeded(t *testing.T)
dTags[0], dTags[1] = dTags[1], dTags[0]
}
assert.Equal(t, "320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf", dTags[0].GetFirst([]string{"d"}).Value())
// we cannot guarantee which payment will be made first,
// so ensure we have results for both payment hashes
var paymentHashes = []string{
"320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf",
"f2279e946e05cb8d29bfd7bb6412fc36fcd6c52877124e130b41a3d771874fb0",
}
assert.NotEqual(t, dTags[0].GetFirst([]string{"d"}).Value(), dTags[1].GetFirst([]string{"d"}).Value())
assert.Contains(t, paymentHashes, dTags[0].GetFirst([]string{"d"}).Value())
assert.Equal(t, "123preimage", responses[0].Result.(payResponse).Preimage)
assert.Nil(t, responses[0].Error)
assert.Equal(t, tests.MockPaymentHash, dTags[1].GetFirst([]string{"d"}).Value())
assert.Contains(t, paymentHashes, dTags[1].GetFirst([]string{"d"}).Value())
assert.Nil(t, responses[1].Result)
assert.Equal(t, models.ERROR_INSUFFICIENT_BALANCE, responses[1].Error.Code)
assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, responses[1].Error.Code)
}
func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) {
@ -284,6 +302,10 @@ func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) {
var mu sync.Mutex
publishResponse := func(response *models.Response, tags nostr.Tags) {
logger.Logger.WithFields(logrus.Fields{
"response": response,
"tags": tags,
}).Info("Publish response")
mu.Lock()
defer mu.Unlock()
responses = append(responses, response)
@ -295,12 +317,14 @@ func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) {
assert.NoError(t, err)
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
assert.Equal(t, 2, len(responses))
assert.Equal(t, 2, len(dTags))
logger.Logger.WithField("dTags", dTags).WithField("responses", responses).Info("Got responses")
// we can't guarantee which request was processed first
// so swap them if they are back to front
if responses[0].Result == nil {
@ -308,12 +332,21 @@ func TestHandleMultiPayInvoiceEvent_LNClient_OnePaymentFailed(t *testing.T) {
dTags[0], dTags[1] = dTags[1], dTags[0]
}
assert.Equal(t, "320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf", dTags[0].GetFirst([]string{"d"}).Value())
// we cannot guarantee which payment will be made first,
// so ensure we have results for both payment hashes
var paymentHashes = []string{
"320c2c5a1492ccfd5bc7aa4ad9b657d6aaec3cfcc0d1d98413a29af4ac772ccf",
"f2279e946e05cb8d29bfd7bb6412fc36fcd6c52877124e130b41a3d771874fb0",
}
assert.NotEqual(t, dTags[0].GetFirst([]string{"d"}).Value(), dTags[1].GetFirst([]string{"d"}).Value())
assert.Contains(t, paymentHashes, dTags[0].GetFirst([]string{"d"}).Value())
assert.Equal(t, "123preimage", responses[0].Result.(payResponse).Preimage)
assert.Nil(t, responses[0].Error)
assert.Equal(t, tests.MockPaymentHash, dTags[1].GetFirst([]string{"d"}).Value())
assert.Contains(t, paymentHashes, dTags[1].GetFirst([]string{"d"}).Value())
assert.Nil(t, responses[1].Result)
assert.Equal(t, models.ERROR_INTERNAL, responses[1].Error.Code)
assert.Equal(t, constants.ERROR_INTERNAL, responses[1].Error.Code)
assert.Equal(t, "Some error", responses[1].Error.Message)
}

View file

@ -23,7 +23,7 @@ const nip47MultiPayKeysendJson = `
"params": {
"keysends": [{
"amount": 123000,
"pubkey": "123pubkey",
"pubkey": "123pubkey2",
"tlv_records": [{
"type": 5482373484,
"value": "fajsn341414fq"
@ -31,7 +31,7 @@ const nip47MultiPayKeysendJson = `
},
{
"amount": 123000,
"pubkey": "123pubkey",
"pubkey": "123pubkey2",
"tlv_records": [{
"type": 5482373484,
"value": "fajsn341414fq"
@ -48,7 +48,7 @@ const nip47MultiPayKeysendOneOverflowingBudgetJson = `
"params": {
"keysends": [{
"amount": 123000,
"pubkey": "123pubkey",
"pubkey": "123pubkey2",
"id": "customId",
"tlv_records": [{
"type": 5482373484,
@ -68,7 +68,7 @@ const nip47MultiPayKeysendOneOverflowingBudgetJson = `
}
`
func TestHandleMultiPayKeysendEvent(t *testing.T) {
func TestHandleMultiPayKeysendEvent_Success(t *testing.T) {
ctx := context.TODO()
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
@ -106,7 +106,7 @@ func TestHandleMultiPayKeysendEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -115,7 +115,7 @@ func TestHandleMultiPayKeysendEvent(t *testing.T) {
assert.Equal(t, 64, len(responses[i].Result.(payResponse).Preimage))
assert.Equal(t, uint64(1), responses[i].Result.(payResponse).FeesPaid)
assert.Nil(t, responses[i].Error)
assert.Equal(t, "123pubkey", dTags[i].GetFirst([]string{"d"}).Value())
assert.Equal(t, "123pubkey2", dTags[i].GetFirst([]string{"d"}).Value())
}
}
@ -158,7 +158,7 @@ func TestHandleMultiPayKeysendEvent_OneBudgetExceeded(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandleMultiPayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse)
@ -175,5 +175,5 @@ func TestHandleMultiPayKeysendEvent_OneBudgetExceeded(t *testing.T) {
assert.Equal(t, uint64(1), responses[0].Result.(payResponse).FeesPaid)
assert.Nil(t, responses[1].Result)
assert.Equal(t, models.ERROR_QUOTA_EXCEEDED, responses[1].Error.Code)
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, responses[1].Error.Code)
}

View file

@ -5,8 +5,8 @@ import (
"fmt"
"strings"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/nbd-wtf/go-nostr"
@ -40,7 +40,7 @@ func (controller *nip47Controller) HandlePayInvoiceEvent(ctx context.Context, ni
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: fmt.Sprintf("Failed to decode bolt11 invoice: %s", err.Error()),
},
}, tags)
@ -64,14 +64,6 @@ func (controller *nip47Controller) pay(ctx context.Context, bolt11 string, payme
"app_id": app.ID,
"bolt11": bolt11,
}).Infof("Failed to send payment: %v", err)
controller.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_failed",
Properties: map[string]interface{}{
"error": err.Error(),
"invoice": bolt11,
"amount": paymentRequest.MSatoshi / 1000,
},
})
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: mapNip47Error(err),
@ -79,14 +71,6 @@ func (controller *nip47Controller) pay(ctx context.Context, bolt11 string, payme
return
}
controller.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_succeeded",
Properties: map[string]interface{}{
"bolt11": bolt11,
"amount": paymentRequest.MSatoshi / 1000,
},
})
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Result: payResponse{

View file

@ -66,7 +66,7 @@ func TestHandlePayInvoiceEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{})
@ -105,11 +105,11 @@ func TestHandlePayInvoiceEvent_MalformedInvoice(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandlePayInvoiceEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{})
assert.Nil(t, publishedResponse.Result)
assert.Equal(t, models.ERROR_INTERNAL, publishedResponse.Error.Code)
assert.Equal(t, constants.ERROR_INTERNAL, publishedResponse.Error.Code)
assert.Equal(t, "Failed to decode bolt11 invoice: bolt11 too short", publishedResponse.Error.Message)
}

View file

@ -4,7 +4,6 @@ import (
"context"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
@ -43,27 +42,13 @@ func (controller *nip47Controller) payKeysend(ctx context.Context, payKeysendPar
"appId": app.ID,
"recipientPubkey": payKeysendParams.Pubkey,
}).Infof("Failed to send keysend payment: %v", err)
controller.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_failed",
Properties: map[string]interface{}{
"error": err.Error(),
"keysend": true,
"amount": payKeysendParams.Amount / 1000,
},
})
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: mapNip47Error(err),
}, tags)
return
}
controller.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_succeeded",
Properties: map[string]interface{}{
"keysend": true,
"amount": payKeysendParams.Amount / 1000,
},
})
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Result: payResponse{

View file

@ -21,7 +21,7 @@ const nip47KeysendJson = `
"method": "pay_keysend",
"params": {
"amount": 123000,
"pubkey": "123pubkey",
"pubkey": "123pubkey2",
"tlv_records": [{
"type": 5482373484,
"value": "fajsn341414fq"
@ -35,7 +35,7 @@ const nip47KeysendJsonWithPreimage = `
"method": "pay_keysend",
"params": {
"amount": 123000,
"pubkey": "123pubkey",
"pubkey": "123pubkey2",
"preimage": "018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b",
"tlv_records": [{
"type": 5482373484,
@ -78,7 +78,7 @@ func TestHandlePayKeysendEvent(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandlePayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{})
@ -119,7 +119,7 @@ func TestHandlePayKeysendEvent_WithPreimage(t *testing.T) {
}
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc).
HandlePayKeysendEvent(ctx, nip47Request, dbRequestEvent.ID, app, publishResponse, nostr.Tags{})

View file

@ -3,6 +3,7 @@ package controllers
import (
"context"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/nip47/models"
"github.com/nbd-wtf/go-nostr"
@ -38,7 +39,7 @@ func (controller *nip47Controller) HandleSignMessageEvent(ctx context.Context, n
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})

View file

@ -7,6 +7,7 @@ import (
"fmt"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
@ -70,7 +71,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
}).WithError(err).Error("Failed to save nostr event")
nip47Response = &models.Response{
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: fmt.Sprintf("Failed to save nostr event: %s", err.Error()),
},
}
@ -96,7 +97,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
nip47Response = &models.Response{
Error: &models.Error{
Code: models.ERROR_UNAUTHORIZED,
Code: constants.ERROR_UNAUTHORIZED,
Message: "The public key does not have a wallet connected.",
},
}
@ -128,7 +129,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
nip47Response = &models.Response{
Error: &models.Error{
Code: models.ERROR_UNAUTHORIZED,
Code: constants.ERROR_UNAUTHORIZED,
Message: fmt.Sprintf("Failed to save app to nostr event: %s", err.Error()),
},
}
@ -274,7 +275,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_INTERNAL,
Code: constants.ERROR_INTERNAL,
Message: err.Error(),
},
}, nostr.Tags{})
@ -348,7 +349,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
publishResponse(&models.Response{
ResultType: nip47Request.Method,
Error: &models.Error{
Code: models.ERROR_NOT_IMPLEMENTED,
Code: constants.ERROR_NOT_IMPLEMENTED,
Message: fmt.Sprintf("Unknown method: %s", nip47Request.Method),
},
}, nostr.Tags{})

View file

@ -21,17 +21,6 @@ const (
MULTI_PAY_INVOICE_METHOD = "multi_pay_invoice"
MULTI_PAY_KEYSEND_METHOD = "multi_pay_keysend"
SIGN_MESSAGE_METHOD = "sign_message"
ERROR_INTERNAL = "INTERNAL"
ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
ERROR_INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
ERROR_UNAUTHORIZED = "UNAUTHORIZED"
ERROR_EXPIRED = "EXPIRED"
ERROR_RESTRICTED = "RESTRICTED"
ERROR_BAD_REQUEST = "BAD_REQUEST"
ERROR_NOT_FOUND = "NOT_FOUND"
OTHER = "OTHER"
)
type Transaction struct {

View file

@ -39,7 +39,7 @@ func NewNip47Service(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublis
cfg: cfg,
db: db,
permissionsService: permissions.NewPermissionsService(db, eventPublisher),
transactionsService: transactions.NewTransactionsService(db),
transactionsService: transactions.NewTransactionsService(db, eventPublisher),
eventPublisher: eventPublisher,
keys: keys,
}

View file

@ -44,26 +44,14 @@ func NewNip47Notifier(relay nostrmodels.Relay, db *gorm.DB, cfg config.Config, k
}
func (notifier *Nip47Notifier) ConsumeEvent(ctx context.Context, event *events.Event) {
// TODO: should listen to transaction service events instead
// then self-payments will also trigger NIP-47 notifications
switch event.Event {
case "nwc_payment_received":
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
transaction, ok := event.Properties.(*db.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
return
}
transactionType := constants.TRANSACTION_TYPE_INCOMING
transaction, err := notifier.transactionsService.LookupTransaction(ctx, lnClientTransaction.PaymentHash, &transactionType, notifier.lnClient, nil)
if err != nil {
logger.Logger.
WithField("paymentHash", lnClientTransaction.PaymentHash).
WithError(err).
Error("Failed to lookup transaction by payment hash")
return
}
notification := PaymentReceivedNotification{
Transaction: *models.ToNip47Transaction(transaction),
}
@ -74,21 +62,12 @@ func (notifier *Nip47Notifier) ConsumeEvent(ctx context.Context, event *events.E
}, nostr.Tags{}, transaction.AppId)
case "nwc_payment_sent":
paymentSentEventProperties, ok := event.Properties.(*lnclient.Transaction)
transaction, ok := event.Properties.(*db.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
return
}
transactionType := constants.TRANSACTION_TYPE_OUTGOING
transaction, err := notifier.transactionsService.LookupTransaction(ctx, paymentSentEventProperties.PaymentHash, &transactionType, notifier.lnClient, nil)
if err != nil {
logger.Logger.
WithField("paymentHash", paymentSentEventProperties.PaymentHash).
WithError(err).
Error("Failed to lookup invoice by payment hash")
return
}
notification := PaymentSentNotification{
Transaction: *models.ToNip47Transaction(transaction),
}

View file

@ -49,7 +49,7 @@ func TestSendNotification_PaymentReceived(t *testing.T) {
assert.NoError(t, err)
settledAt := time.Unix(*tests.MockLNClientTransaction.SettledAt, 0)
err = svc.DB.Create(&db.Transaction{
initialTransaction := db.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
Description: tests.MockLNClientTransaction.Description,
@ -60,17 +60,17 @@ func TestSendNotification_PaymentReceived(t *testing.T) {
FeeMsat: uint64(tests.MockLNClientTransaction.FeesPaid),
SettledAt: &settledAt,
AppId: &app.ID,
}).Error
State: constants.TRANSACTION_STATE_SETTLED,
}
err = svc.DB.Create(&initialTransaction).Error
assert.NoError(t, err)
nip47NotificationQueue := NewNip47NotificationQueue()
svc.EventPublisher.RegisterSubscriber(NewMockConsumer(nip47NotificationQueue))
testEvent := &events.Event{
Event: "nwc_payment_received",
Properties: &lnclient.Transaction{
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
},
Event: "nwc_payment_received",
Properties: &initialTransaction,
}
svc.EventPublisher.Publish(testEvent)
@ -81,7 +81,7 @@ func TestSendNotification_PaymentReceived(t *testing.T) {
relay := tests.NewMockRelay()
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc, transactionsSvc, svc.LNClient)
notifier.ConsumeEvent(ctx, receivedEvent)
@ -129,7 +129,7 @@ func TestSendNotification_PaymentSent(t *testing.T) {
assert.NoError(t, err)
settledAt := time.Unix(*tests.MockLNClientTransaction.SettledAt, 0)
err = svc.DB.Create(&db.Transaction{
initialTransaction := db.Transaction{
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
Description: tests.MockLNClientTransaction.Description,
@ -140,17 +140,16 @@ func TestSendNotification_PaymentSent(t *testing.T) {
FeeMsat: uint64(tests.MockLNClientTransaction.FeesPaid),
SettledAt: &settledAt,
AppId: &app.ID,
}).Error
}
err = svc.DB.Create(&initialTransaction).Error
assert.NoError(t, err)
nip47NotificationQueue := NewNip47NotificationQueue()
svc.EventPublisher.RegisterSubscriber(NewMockConsumer(nip47NotificationQueue))
testEvent := &events.Event{
Event: "nwc_payment_sent",
Properties: &lnclient.Transaction{
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
},
Event: "nwc_payment_sent",
Properties: &initialTransaction,
}
svc.EventPublisher.Publish(testEvent)
@ -161,7 +160,7 @@ func TestSendNotification_PaymentSent(t *testing.T) {
relay := tests.NewMockRelay()
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc, transactionsSvc, svc.LNClient)
notifier.ConsumeEvent(ctx, receivedEvent)
@ -221,7 +220,7 @@ func TestSendNotificationNoPermission(t *testing.T) {
relay := tests.NewMockRelay()
permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
transactionsSvc := transactions.NewTransactionsService(svc.DB)
transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
notifier := NewNip47Notifier(relay, svc.DB, svc.Cfg, svc.Keys, permissionsSvc, transactionsSvc, svc.LNClient)
notifier.ConsumeEvent(ctx, receivedEvent)

View file

@ -44,7 +44,7 @@ func (svc *permissionsService) HasPermission(app *db.App, scope string) (result
})
if findPermissionResult.RowsAffected == 0 {
// No permission for this request method
return false, models.ERROR_RESTRICTED, fmt.Sprintf("This app does not have the %s scope", scope)
return false, constants.ERROR_RESTRICTED, fmt.Sprintf("This app does not have the %s scope", scope)
}
expiresAt := appPermission.ExpiresAt
if expiresAt != nil && expiresAt.Before(time.Now()) {
@ -55,7 +55,7 @@ func (svc *permissionsService) HasPermission(app *db.App, scope string) (result
"pubkey": app.NostrPubkey,
}).Info("This pubkey is expired")
return false, models.ERROR_EXPIRED, "This app has expired"
return false, constants.ERROR_EXPIRED, "This app has expired"
}
return true, "", ""

View file

@ -22,7 +22,7 @@ func TestHasPermission_NoPermission(t *testing.T) {
permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher)
result, code, message := permissionsSvc.HasPermission(app, constants.PAY_INVOICE_SCOPE)
assert.False(t, result)
assert.Equal(t, models.ERROR_RESTRICTED, code)
assert.Equal(t, constants.ERROR_RESTRICTED, code)
assert.Equal(t, "This app does not have the pay_invoice scope", message)
}
@ -50,7 +50,7 @@ func TestHasPermission_Expired(t *testing.T) {
permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher)
result, code, message := permissionsSvc.HasPermission(app, constants.PAY_INVOICE_SCOPE)
assert.False(t, result)
assert.Equal(t, models.ERROR_EXPIRED, code)
assert.Equal(t, constants.ERROR_EXPIRED, code)
assert.Equal(t, "This app has expired", message)
}
@ -79,7 +79,7 @@ func TestHasPermission_Expired(t *testing.T) {
permissionsSvc := NewPermissionsService(svc.DB, svc.EventPublisher)
result, code, message := permissionsSvc.HasPermission(app, PAY_INVOICE_SCOPE, 100*1000)
assert.False(t, result)
assert.Equal(t, models.ERROR_QUOTA_EXCEEDED, code)
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, code)
assert.Equal(t, "Insufficient budget remaining to make payment", message)
}*/

View file

@ -104,14 +104,11 @@ func NewService(ctx context.Context) (*service, error) {
eventPublisher: eventPublisher,
albyOAuthSvc: alby.NewAlbyOAuthService(gormDB, cfg, keys, eventPublisher),
nip47Service: nip47.NewNip47Service(gormDB, cfg, keys, eventPublisher),
transactionsService: transactions.NewTransactionsService(gormDB),
transactionsService: transactions.NewTransactionsService(gormDB, eventPublisher),
db: gormDB,
keys: keys,
}
// Note: order is important here: transactions service will update transactions
// from payment events, which will then be consumed by the NIP-47 service to send notifications
// TODO: transactions service should fire its own events
eventPublisher.RegisterSubscriber(svc.transactionsService)
eventPublisher.RegisterSubscriber(svc.nip47Service)
eventPublisher.RegisterSubscriber(svc.albyOAuthSvc)

View file

@ -0,0 +1,28 @@
package tests
import (
"context"
"time"
"github.com/getAlby/hub/events"
)
type mockEventConsumer struct {
consumedEvents []*events.Event
}
func NewMockEventConsumer() *mockEventConsumer {
return &mockEventConsumer{
consumedEvents: []*events.Event{},
}
}
func (e *mockEventConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
e.consumedEvents = append(e.consumedEvents, event)
}
func (e *mockEventConsumer) GetConsumeEvents() []*events.Event {
// events are consumed async - give it a bit of time for tests
time.Sleep(1 * time.Millisecond)
return e.consumedEvents
}

View file

@ -57,9 +57,11 @@ var MockLNClientTransactions = []lnclient.Transaction{
var MockLNClientTransaction = &MockLNClientTransactions[0]
type MockLn struct {
PayInvoiceResponses []*lnclient.PayInvoiceResponse
PayInvoiceErrors []error
Pubkey string
PayInvoiceResponses []*lnclient.PayInvoiceResponse
PayInvoiceErrors []error
Pubkey string
MockTransaction *lnclient.Transaction
SupportedNotificationTypes *[]string
}
func NewMockLn() (*MockLn, error) {
@ -99,6 +101,9 @@ func (mln *MockLn) MakeInvoice(ctx context.Context, amount int64, description st
}
func (mln *MockLn) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) {
if mln.MockTransaction != nil {
return mln.MockTransaction, nil
}
return MockLNClientTransaction, nil
}
@ -178,6 +183,10 @@ func (mln *MockLn) GetSupportedNIP47Methods() []string {
return []string{"pay_invoice", "pay_keysend", "get_balance", "get_info", "make_invoice", "lookup_invoice", "list_transactions", "multi_pay_invoice", "multi_pay_keysend", "sign_message"}
}
func (mln *MockLn) GetSupportedNIP47NotificationTypes() []string {
if mln.SupportedNotificationTypes != nil {
return *mln.SupportedNotificationTypes
}
return []string{"payment_received", "payment_sent"}
}
func (mln *MockLn) GetPubkey() string {

View file

@ -25,7 +25,7 @@ func TestSendPaymentSync_App_NoPermission(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -54,7 +54,7 @@ func TestSendPaymentSync_App_WithPermission(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -88,12 +88,21 @@ func TestSendPaymentSync_App_BudgetExceeded(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
assert.ErrorIs(t, err, NewQuotaExceededError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumeEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["code"])
assert.Equal(t, NewQuotaExceededError().Error(), mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["message"])
}
func TestSendPaymentSync_App_BudgetExceeded_SettledPayment(t *testing.T) {
@ -128,7 +137,7 @@ func TestSendPaymentSync_App_BudgetExceeded_SettledPayment(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -167,7 +176,7 @@ func TestSendPaymentSync_App_BudgetExceeded_UnsettledPayment(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -207,7 +216,7 @@ func TestSendPaymentSync_App_BudgetNotExceeded_FailedPayment(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)

View file

@ -0,0 +1,96 @@
package transactions
import (
"context"
"testing"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/tests"
"github.com/stretchr/testify/assert"
)
func TestCheckUnsettledTransaction(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
}
svc.DB.Create(&dbTransaction)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
settledAt := time.Now().Unix()
svc.LNClient.(*tests.MockLn).MockTransaction = &lnclient.Transaction{
SettledAt: &settledAt,
Preimage: "dummy",
}
// do not allow checking unsettled transactions if notifications are supported
transactionsService.checkUnsettledTransaction(context.TODO(), &dbTransaction, svc.LNClient)
assert.Equal(t, constants.TRANSACTION_STATE_PENDING, dbTransaction.State)
svc.LNClient.(*tests.MockLn).SupportedNotificationTypes = &[]string{}
transactionsService.checkUnsettledTransaction(context.TODO(), &dbTransaction, svc.LNClient)
assert.Nil(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
func TestCheckUnsettledTransactions(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
CreatedAt: time.Now(),
}
svc.DB.Create(&dbTransaction)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
settledAt := time.Now().Unix()
svc.LNClient.(*tests.MockLn).MockTransaction = &lnclient.Transaction{
SettledAt: &settledAt,
Preimage: "dummy",
}
// do not allow checking unsettled transactions if notifications are supported
transactionsService.checkUnsettledTransactions(context.TODO(), svc.LNClient)
svc.DB.Find(&dbTransaction, db.Transaction{
ID: dbTransaction.ID,
})
assert.Equal(t, constants.TRANSACTION_STATE_PENDING, dbTransaction.State)
svc.LNClient.(*tests.MockLn).SupportedNotificationTypes = &[]string{}
transactionsService.checkUnsettledTransactions(context.TODO(), svc.LNClient)
svc.DB.Find(&dbTransaction, db.Transaction{
ID: dbTransaction.ID,
})
assert.Nil(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, dbTransaction.ID, settledTransaction.ID)
}

View file

@ -34,7 +34,7 @@ func TestSendPaymentSync_IsolatedApp_NoBalance(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -73,12 +73,21 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient(t *testing.T) {
AmountMsat: 132000, // invoice is 123000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
assert.ErrorIs(t, err, NewInsufficientBalanceError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumeEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_INSUFFICIENT_BALANCE, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["code"])
assert.Equal(t, NewInsufficientBalanceError().Error(), mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["message"])
}
func TestSendPaymentSync_IsolatedApp_BalanceSufficient(t *testing.T) {
@ -112,7 +121,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient(t *testing.T) {
AmountMsat: 133000, // invoice is 123000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -161,7 +170,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient_OutstandingPayment(t *t
AmountMsat: 1000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -207,7 +216,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceInsufficient_SettledPayment(t *testi
AmountMsat: 1000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -252,7 +261,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient_UnrelatedPayment(t *testi
AmountMsat: 1000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -300,7 +309,7 @@ func TestSendPaymentSync_IsolatedApp_BalanceSufficient_FailedPayment(t *testing.
AmountMsat: 1000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)

View file

@ -2,11 +2,14 @@ package transactions
import (
"context"
"encoding/hex"
"encoding/json"
"strconv"
"testing"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/db/queries"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/tests"
"github.com/stretchr/testify/assert"
@ -19,7 +22,10 @@ func TestSendKeysend(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -35,6 +41,11 @@ func TestSendKeysend(t *testing.T) {
assert.Zero(t, transaction.FeeReserveMsat)
assert.NotNil(t, transaction.Preimage)
assert.Equal(t, 64, len(*transaction.Preimage))
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, transaction, settledTransaction)
}
func TestSendKeysend_CustomPreimage(t *testing.T) {
ctx := context.TODO()
@ -44,7 +55,7 @@ func TestSendKeysend_CustomPreimage(t *testing.T) {
assert.NoError(t, err)
customPreimage := "018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b"
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, customPreimage, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -76,7 +87,7 @@ func TestSendKeysend_App_NoPermission(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.Error(t, err)
@ -106,7 +117,7 @@ func TestSendKeysend_App_WithPermission(t *testing.T) {
err = svc.DB.Create(appPermission).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -149,11 +160,20 @@ func TestSendKeysend_App_BudgetExceeded(t *testing.T) {
err = svc.DB.Create(appPermission).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.ErrorIs(t, err, NewQuotaExceededError())
assert.Nil(t, transaction)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_permission_denied", mockEventConsumer.GetConsumeEvents()[0].Event)
assert.Equal(t, app.Name, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["app_name"])
assert.Equal(t, constants.ERROR_QUOTA_EXCEEDED, mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["code"])
assert.Equal(t, NewQuotaExceededError().Error(), mockEventConsumer.GetConsumeEvents()[0].Properties.(map[string]interface{})["message"])
}
func TestSendKeysend_App_BudgetNotExceeded(t *testing.T) {
ctx := context.TODO()
@ -178,7 +198,7 @@ func TestSendKeysend_App_BudgetNotExceeded(t *testing.T) {
err = svc.DB.Create(appPermission).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -229,7 +249,7 @@ func TestSendKeysend_App_BalanceExceeded(t *testing.T) {
AmountMsat: 10000, // invoice is 1000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.ErrorIs(t, err, NewInsufficientBalanceError())
@ -267,7 +287,7 @@ func TestSendKeysend_App_BalanceSufficient(t *testing.T) {
AmountMsat: 11000, // invoice is 1000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", nil, "", svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -294,7 +314,7 @@ func TestSendKeysend_TLVs(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, uint64(1000), "fake destination", []lnclient.TLVRecord{
{
Type: 7629169,
@ -334,3 +354,174 @@ func TestSendKeysend_TLVs(t *testing.T) {
assert.Equal(t, 64, len(*transaction.Preimage))
assert.Zero(t, transaction.FeeReserveMsat)
}
func TestSendKeysend_IsolatedAppToNoApp(t *testing.T) {
ctx := context.TODO()
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
// setup for self payment
svc.LNClient.(*tests.MockLn).Pubkey = "02a5056398235568fc049a5d563f1adf666041d590b268167e4fa145fbf71aa578"
app, _, err := tests.CreateApp(svc)
assert.NoError(t, err)
app.Isolated = true
err = svc.DB.Save(&app).Error
assert.NoError(t, err)
appPermission := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
}
err = svc.DB.Create(appPermission).Error
assert.NoError(t, err)
// give the isolated app 133 sats
svc.DB.Create(&db.Transaction{
AppId: &app.ID,
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
AmountMsat: 133000, // payment is 123000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
dbRequestEvent := &db.RequestEvent{}
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
mockPreimage := "c8aeb44ae8eb269c8dbfb7ec5c263f0bfa3d755bc0ca641b8ee118673afda657"
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, 123000, "02a5056398235568fc049a5d563f1adf666041d590b268167e4fa145fbf71aa578", []lnclient.TLVRecord{}, mockPreimage, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
assert.NotNil(t, transaction)
assert.Equal(t, uint64(123000), transaction.AmountMsat)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, transaction.State)
assert.Equal(t, mockPreimage, *transaction.Preimage)
assert.Equal(t, app.ID, *transaction.AppId)
assert.Equal(t, dbRequestEvent.ID, *transaction.RequestEventId)
assert.True(t, transaction.SelfPayment)
transactionType := constants.TRANSACTION_TYPE_INCOMING
incomingTransaction, err := transactionsService.LookupTransaction(ctx, transaction.PaymentHash, &transactionType, svc.LNClient, nil)
assert.NoError(t, err)
assert.Equal(t, uint64(123000), incomingTransaction.AmountMsat)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, incomingTransaction.State)
assert.Equal(t, mockPreimage, *incomingTransaction.Preimage)
assert.True(t, incomingTransaction.SelfPayment)
transactions := []db.Transaction{}
result := svc.DB.Find(&transactions)
assert.Equal(t, int64(3), result.RowsAffected)
// expect balance to be decreased
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
}
func TestSendKeysend_IsolatedAppToIsolatedApp(t *testing.T) {
ctx := context.TODO()
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
// setup for self payment
svc.LNClient.(*tests.MockLn).Pubkey = "02a5056398235568fc049a5d563f1adf666041d590b268167e4fa145fbf71aa578"
app, _, err := tests.CreateApp(svc)
assert.NoError(t, err)
app.Isolated = true
err = svc.DB.Save(&app).Error
assert.NoError(t, err)
app2, _, err := tests.CreateApp(svc)
assert.NoError(t, err)
app2.Isolated = true
err = svc.DB.Save(&app2).Error
assert.NoError(t, err)
appPermission := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
}
err = svc.DB.Create(appPermission).Error
assert.NoError(t, err)
// give the isolated app 133 sats
svc.DB.Create(&db.Transaction{
AppId: &app.ID,
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
AmountMsat: 133000, // payment is 123000 msat, but we also calculate fee reserves max of(10 sats or 1%)
})
dbRequestEvent := &db.RequestEvent{}
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
mockPreimage := "c8aeb44ae8eb269c8dbfb7ec5c263f0bfa3d755bc0ca641b8ee118673afda657"
// Keysend from app 1 to app 2
tlvRecords := []lnclient.TLVRecord{
{
Type: 696969,
Value: hex.EncodeToString([]byte(strconv.FormatUint(uint64(app2.ID), 10))),
},
{
Type: 7629169,
Value: "7b22616374696f6e223a22626f6f7374222c2276616c75655f6d736174223a313030302c2276616c75655f6d7361745f746f74616c223a313030302c226170705f6e616d65223a22e29aa1205765624c4e2044656d6f222c226170705f76657273696f6e223a22312e30222c22666565644944223a2268747470733a2f2f66656564732e706f6463617374696e6465782e6f72672f706332302e786d6c222c22706f6463617374223a22506f6463617374696e6720322e30222c22657069736f6465223a22457069736f6465203130343a2041204e65772044756d70222c227473223a32312c226e616d65223a22e29aa1205765624c4e2044656d6f222c2273656e6465725f6e616d65223a225361746f736869204e616b616d6f746f222c226d657373616765223a22476f20706f6463617374696e6721227d",
},
}
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendKeysend(ctx, 123000, "02a5056398235568fc049a5d563f1adf666041d590b268167e4fa145fbf71aa578", tlvRecords, mockPreimage, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
assert.NotNil(t, transaction)
assert.Equal(t, uint64(123000), transaction.AmountMsat)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, transaction.State)
assert.Equal(t, mockPreimage, *transaction.Preimage)
assert.Equal(t, app.ID, *transaction.AppId)
assert.Equal(t, dbRequestEvent.ID, *transaction.RequestEventId)
assert.True(t, transaction.SelfPayment)
transactionType := constants.TRANSACTION_TYPE_INCOMING
incomingTransaction, err := transactionsService.LookupTransaction(ctx, transaction.PaymentHash, &transactionType, svc.LNClient, &app2.ID)
assert.NoError(t, err)
assert.Equal(t, uint64(123000), incomingTransaction.AmountMsat)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, incomingTransaction.State)
assert.Equal(t, mockPreimage, *incomingTransaction.Preimage)
assert.Equal(t, app2.ID, *incomingTransaction.AppId)
assert.True(t, incomingTransaction.SelfPayment)
// receiving app should have the same data as what was sent
assert.Equal(t, transaction.Description, incomingTransaction.Description)
assert.Equal(t, transaction.Metadata, incomingTransaction.Metadata)
assert.Equal(t, transaction.Boostagram, incomingTransaction.Boostagram)
transactions := []db.Transaction{}
result := svc.DB.Find(&transactions)
assert.Equal(t, int64(3), result.RowsAffected)
// expect balance to be decreased
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
// expect app2 to receive the payment
assert.Equal(t, uint64(123000), queries.GetIsolatedBalance(svc.DB, app2.ID))
// check notifications
assert.Equal(t, 2, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[1].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[1].Properties.(*db.Transaction)
assert.Equal(t, transaction.ID, settledTransaction.ID)
assert.Equal(t, "nwc_payment_received", mockEventConsumer.GetConsumeEvents()[0].Event)
receivedTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, incomingTransaction.ID, receivedTransaction.ID)
}

View file

@ -36,7 +36,7 @@ func TestListTransactions(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, false, nil, svc.LNClient, nil)
assert.NoError(t, err)
@ -72,7 +72,7 @@ func TestListTransactions_Unsettled(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 0, 0, true, nil, svc.LNClient, nil)
assert.NoError(t, err)
@ -107,7 +107,7 @@ func TestListTransactions_Limit(t *testing.T) {
Description: "second",
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 0, false, nil, svc.LNClient, nil)
assert.NoError(t, err)
@ -131,7 +131,7 @@ func TestListTransactions_Offset(t *testing.T) {
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "first",
CreatedAt: time.Now().Add(1 * time.Minute),
CreatedAt: time.Now().Add(3 * time.Minute),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
@ -141,14 +141,34 @@ func TestListTransactions_Offset(t *testing.T) {
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "second",
CreatedAt: time.Now().Add(2 * time.Minute),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "third",
CreatedAt: time.Now().Add(1 * time.Minute),
})
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentRequest: tests.MockLNClientTransaction.Invoice,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
Preimage: &mockPreimage,
AmountMsat: 123000,
Description: "fourth",
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 1, false, nil, svc.LNClient, nil)
incomingTransactions, err := transactionsService.ListTransactions(ctx, 0, 0, 1, 2, false, nil, svc.LNClient, nil)
assert.NoError(t, err)
assert.Equal(t, 1, len(incomingTransactions))
assert.Equal(t, "second", incomingTransactions[0].Description)
assert.Equal(t, "third", incomingTransactions[0].Description)
}
func TestListTransactions_FromUntil(t *testing.T) {
@ -189,7 +209,7 @@ func TestListTransactions_FromUntil(t *testing.T) {
Description: "third",
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransactions, err := transactionsService.ListTransactions(ctx, uint64(time.Now().Add(4*time.Minute).Unix()), uint64(time.Now().Add(6*time.Minute).Unix()), 0, 0, false, nil, svc.LNClient, nil)
assert.NoError(t, err)

View file

@ -27,7 +27,7 @@ func TestLookupTransaction_IncomingPayment(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
incomingTransaction, err := transactionsService.LookupTransaction(ctx, tests.MockLNClientTransaction.PaymentHash, nil, svc.LNClient, nil)
assert.NoError(t, err)
@ -54,7 +54,7 @@ func TestLookupTransaction_OutgoingPayment(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
outgoingTransaction, err := transactionsService.LookupTransaction(ctx, tests.MockLNClientTransaction.PaymentHash, nil, svc.LNClient, nil)
assert.NoError(t, err)

View file

@ -22,7 +22,7 @@ func TestMakeInvoice_NoApp(t *testing.T) {
txMetadata := make(map[string]interface{})
txMetadata["randomkey"] = strings.Repeat("a", constants.INVOICE_METADATA_MAX_LENGTH-16) // json encoding adds 16 characters - {"randomkey":""}
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.MakeInvoice(ctx, 1234, "Hello world", "", 0, txMetadata, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -46,7 +46,7 @@ func TestMakeInvoice_MetadataTooLarge(t *testing.T) {
metadata := make(map[string]interface{})
metadata["randomkey"] = strings.Repeat("a", constants.INVOICE_METADATA_MAX_LENGTH-15) // json encoding adds 16 characters
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.MakeInvoice(ctx, 1234, "Hello world", "", 0, metadata, svc.LNClient, nil, nil)
assert.Error(t, err)
@ -68,7 +68,7 @@ func TestMakeInvoice_App(t *testing.T) {
err = svc.DB.Create(&dbRequestEvent).Error
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.MakeInvoice(ctx, 1234, "Hello world", "", 0, nil, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)

View file

@ -30,10 +30,10 @@ func TestNotifications_ReceivedKnownPayment(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
@ -56,10 +56,10 @@ func TestNotifications_ReceivedUnknownPayment(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
@ -83,7 +83,7 @@ func TestNotifications_ReceivedKeysend(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
metadata := map[string]interface{}{}
@ -108,7 +108,7 @@ func TestNotifications_ReceivedKeysend(t *testing.T) {
}
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: transaction,
}, map[string]interface{}{})
@ -158,10 +158,10 @@ func TestNotifications_SentKnownPayment(t *testing.T) {
FeeReserveMsat: uint64(10000),
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_sent",
Event: "nwc_lnclient_payment_sent",
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
@ -185,14 +185,14 @@ func TestNotifications_SentUnknownPayment(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactions := []db.Transaction{}
result := svc.DB.Find(&transactions)
assert.Equal(t, int64(0), result.RowsAffected)
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_sent",
Event: "nwc_lnclient_payment_sent",
Properties: tests.MockLNClientTransaction,
}, map[string]interface{}{})
@ -218,11 +218,11 @@ func TestNotifications_FailedKnownPayment(t *testing.T) {
FeeReserveMsat: uint64(10000),
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transactionsService.ConsumeEvent(ctx, &events.Event{
Event: "nwc_payment_failed_async",
Properties: &events.PaymentFailedAsyncProperties{
Event: "nwc_lnclient_payment_failed",
Properties: &lnclient.PaymentFailedEventProperties{
Transaction: tests.MockLNClientTransaction,
Reason: "Some failure reason",
},

View file

@ -4,11 +4,14 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/tests"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
func TestSendPaymentSync_NoApp(t *testing.T) {
@ -18,7 +21,7 @@ func TestSendPaymentSync_NoApp(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -28,6 +31,169 @@ func TestSendPaymentSync_NoApp(t *testing.T) {
assert.Equal(t, "123preimage", *transaction.Preimage)
}
func TestSendPaymentSync_Duplicate(t *testing.T) {
ctx := context.TODO()
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
svc.DB.Create(&db.Transaction{
State: constants.TRANSACTION_STATE_SETTLED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil)
assert.Error(t, err)
assert.Equal(t, "this invoice has already been paid", err.Error())
assert.Nil(t, transaction)
}
func TestMarkSettled_Sent(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
}
svc.DB.Create(&dbTransaction)
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
})
assert.Nil(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
func TestMarkSettled_Received(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_INCOMING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
}
svc.DB.Create(&dbTransaction)
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
})
assert.Nil(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_SETTLED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_received", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
}
func TestDoNotMarkSettledTwice(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
settledAt := time.Now().Add(time.Duration(-1) * time.Minute)
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)
err = svc.DB.Transaction(func(tx *gorm.DB) error {
_, err = transactionsService.markTransactionSettled(tx, &dbTransaction, "test", 0, false)
return err
})
assert.Nil(t, err)
assert.Zero(t, len(mockEventConsumer.GetConsumeEvents()))
}
func TestMarkFailed(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_PENDING,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
}
svc.DB.Create(&dbTransaction)
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")
})
assert.Nil(t, err)
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, dbTransaction.State)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumeEvents()[0].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, &dbTransaction, settledTransaction)
assert.Equal(t, "some routing error", settledTransaction.FailureReason)
}
func TestDoNotMarkFailedTwice(t *testing.T) {
defer tests.RemoveTestService()
svc, err := tests.CreateTestService()
assert.NoError(t, err)
updatedAt := time.Now().Add(time.Duration(-1) * time.Minute)
dbTransaction := db.Transaction{
State: constants.TRANSACTION_STATE_FAILED,
Type: constants.TRANSACTION_TYPE_OUTGOING,
PaymentHash: tests.MockLNClientTransaction.PaymentHash,
AmountMsat: 123000,
UpdatedAt: updatedAt,
}
svc.DB.Create(&dbTransaction)
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")
})
assert.Nil(t, err)
assert.Equal(t, updatedAt, dbTransaction.UpdatedAt)
assert.Zero(t, len(mockEventConsumer.GetConsumeEvents()))
}
func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) {
ctx := context.TODO()
@ -38,7 +204,10 @@ func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) {
svc.LNClient.(*tests.MockLn).PayInvoiceErrors = append(svc.LNClient.(*tests.MockLn).PayInvoiceErrors, errors.New("Some error"))
svc.LNClient.(*tests.MockLn).PayInvoiceResponses = append(svc.LNClient.(*tests.MockLn).PayInvoiceResponses, nil)
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil)
assert.Error(t, err)
@ -52,6 +221,9 @@ func TestSendPaymentSync_FailedRemovesFeeReserve(t *testing.T) {
assert.Equal(t, constants.TRANSACTION_STATE_FAILED, transaction.State)
assert.Zero(t, transaction.FeeReserveMsat)
assert.Nil(t, transaction.Preimage)
assert.Equal(t, 1, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_failed", mockEventConsumer.GetConsumeEvents()[0].Event)
}
func TestSendPaymentSync_PendingHasFeeReserve(t *testing.T) {
@ -65,7 +237,7 @@ func TestSendPaymentSync_PendingHasFeeReserve(t *testing.T) {
svc.LNClient.(*tests.MockLn).PayInvoiceErrors = append(svc.LNClient.(*tests.MockLn).PayInvoiceErrors, lnclient.NewTimeoutError())
svc.LNClient.(*tests.MockLn).PayInvoiceResponses = append(svc.LNClient.(*tests.MockLn).PayInvoiceResponses, nil)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockLNClientTransaction.Invoice, svc.LNClient, nil, nil)
assert.Error(t, err)

View file

@ -19,7 +19,7 @@ func TestReceiveKeysendWithCustomKey(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
app, _, err := tests.CreateApp(svc)
assert.NoError(t, err)
@ -48,7 +48,7 @@ func TestReceiveKeysendWithCustomKey(t *testing.T) {
}
event := events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: &tx,
}
transactionsService.ConsumeEvent(ctx, &event, map[string]interface{}{})
@ -66,13 +66,13 @@ func TestReceiveKeysend(t *testing.T) {
svc, err := tests.CreateTestService()
assert.NoError(t, err)
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
_, _, err = tests.CreateApp(svc)
assert.NoError(t, err)
tx := tests.MockLNClientTransaction
event := events.Event{
Event: "nwc_payment_received",
Event: "nwc_lnclient_payment_received",
Properties: tx,
}
transactionsService.ConsumeEvent(ctx, &event, map[string]interface{}{})

View file

@ -31,7 +31,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToNoApp(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -83,7 +83,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToIsolatedApp(t *testing.T) {
AppId: &app.ID,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -136,7 +136,7 @@ func TestSendPaymentSync_SelfPayment_NoAppToApp(t *testing.T) {
AppId: &app.ID,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, nil, nil)
assert.NoError(t, err)
@ -205,7 +205,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToNoApp(t *testing.T) {
AmountMsat: 123000,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -280,7 +280,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToApp(t *testing.T) {
AppId: &app2.ID,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -359,7 +359,10 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) {
AppId: &app2.ID,
})
transactionsService := NewTransactionsService(svc.DB)
mockEventConsumer := tests.NewMockEventConsumer()
svc.EventPublisher.RegisterSubscriber(mockEventConsumer)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)
@ -384,6 +387,17 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToIsolatedApp(t *testing.T) {
assert.Equal(t, int64(3), result.RowsAffected)
// expect balance to be decreased
assert.Equal(t, uint64(10000), queries.GetIsolatedBalance(svc.DB, app.ID))
// check notifications
assert.Equal(t, 2, len(mockEventConsumer.GetConsumeEvents()))
assert.Equal(t, "nwc_payment_sent", mockEventConsumer.GetConsumeEvents()[1].Event)
settledTransaction := mockEventConsumer.GetConsumeEvents()[1].Properties.(*db.Transaction)
assert.Equal(t, transaction.ID, settledTransaction.ID)
assert.Equal(t, "nwc_payment_received", mockEventConsumer.GetConsumeEvents()[0].Event)
receivedTransaction := mockEventConsumer.GetConsumeEvents()[0].Properties.(*db.Transaction)
assert.Equal(t, incomingTransaction.ID, receivedTransaction.ID)
}
func TestSendPaymentSync_SelfPayment_IsolatedAppToSelf(t *testing.T) {
@ -433,7 +447,7 @@ func TestSendPaymentSync_SelfPayment_IsolatedAppToSelf(t *testing.T) {
AppId: &app.ID,
})
transactionsService := NewTransactionsService(svc.DB)
transactionsService := NewTransactionsService(svc.DB, svc.EventPublisher)
transaction, err := transactionsService.SendPaymentSync(ctx, tests.MockInvoice, svc.LNClient, &app.ID, &dbRequestEvent.ID)
assert.NoError(t, err)

View file

@ -27,7 +27,8 @@ import (
)
type transactionsService struct {
db *gorm.DB
db *gorm.DB
eventPublisher events.EventPublisher
}
type TransactionsService interface {
@ -94,12 +95,13 @@ func NewQuotaExceededError() error {
}
func (err *quotaExceededError) Error() string {
return "Your wallet has exceeded its spending quota"
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) *transactionsService {
func NewTransactionsService(db *gorm.DB, eventPublisher events.EventPublisher) *transactionsService {
return &transactionsService{
db: db,
db: db,
eventPublisher: eventPublisher,
}
}
@ -172,6 +174,16 @@ func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq stri
var dbTransaction db.Transaction
err = 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,
State: constants.TRANSACTION_STATE_SETTLED,
}).RowsAffected > 0 {
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("this invoice has already been paid")
return errors.New("this invoice has already been paid")
}
err := svc.validateCanPay(tx, appId, uint64(paymentRequest.MSatoshi))
if err != nil {
return err
@ -230,36 +242,24 @@ func (svc *transactionsService) SendPaymentSync(ctx context.Context, payReq stri
}
// As the LNClient did not return a timeout error, we assume the payment definitely failed
dbErr := svc.db.Model(&dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_FAILED,
"FeeReserveMsat": 0,
}).Error
if dbErr != nil {
logger.Logger.WithFields(logrus.Fields{
"bolt11": payReq,
}).WithError(dbErr).Error("Failed to update DB transaction")
}
svc.db.Transaction(func(tx *gorm.DB) error {
return svc.markPaymentFailed(tx, &dbTransaction, err.Error())
})
return nil, err
}
// the payment definitely succeeded
now := time.Now()
dbErr := svc.db.Model(&dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"Preimage": &response.Preimage,
"FeeMsat": response.Fee,
"FeeReserveMsat": 0,
"SettledAt": &now,
}).Error
if dbErr != nil {
logger.Logger.WithFields(logrus.Fields{
"bolt11": payReq,
}).WithError(dbErr).Error("Failed to update DB transaction")
var settledTransaction *db.Transaction
err = svc.db.Transaction(func(tx *gorm.DB) error {
settledTransaction, err = svc.markTransactionSettled(tx, &dbTransaction, response.Preimage, response.Fee, selfPayment)
return err
})
if err != nil {
return nil, err
}
// TODO: check the fields are updated here
return &dbTransaction, nil
return settledTransaction, nil
}
func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64, destination string, customRecords []lnclient.TLVRecord, preimage string, lnClient lnclient.LNClient, appId *uint, requestEventId *uint) (*Transaction, error) {
@ -299,6 +299,8 @@ func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64,
var dbTransaction db.Transaction
selfPayment := destination == lnClient.GetPubkey()
err = svc.db.Transaction(func(tx *gorm.DB) error {
err := svc.validateCanPay(tx, appId, amount)
if err != nil {
@ -317,6 +319,7 @@ func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64,
Boostagram: datatypes.JSON(boostagramBytes),
PaymentHash: paymentHash,
Preimage: &preimage,
SelfPayment: selfPayment,
}
err = tx.Create(&dbTransaction).Error
@ -331,7 +334,39 @@ func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64,
return nil, err
}
payKeysendResponse, err := lnClient.SendKeysend(ctx, amount, destination, customRecords, preimage)
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)
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: amount,
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)
if err == nil {
payKeysendResponse = &lnclient.PayKeysendResponse{
Fee: 0,
}
}
} else {
payKeysendResponse, err = lnClient.SendKeysend(ctx, amount, destination, customRecords, preimage)
}
if err != nil {
logger.Logger.WithFields(logrus.Fields{
@ -377,22 +412,17 @@ func (svc *transactionsService) SendKeysend(ctx context.Context, amount uint64,
}
// the payment definitely succeeded
now := time.Now()
dbErr := svc.db.Model(&dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"FeeMsat": &payKeysendResponse.Fee,
"FeeReserveMsat": 0,
"SettledAt": &now,
}).Error
if dbErr != nil {
logger.Logger.WithFields(logrus.Fields{
"destination": destination,
"amount": amount,
}).WithError(dbErr).Error("Failed to update DB transaction")
var settledTransaction *db.Transaction
err = svc.db.Transaction(func(tx *gorm.DB) error {
settledTransaction, err = svc.markTransactionSettled(tx, &dbTransaction, preimage, payKeysendResponse.Fee, selfPayment)
return err
})
if err != nil {
return nil, err
}
// TODO: check the fields are updated here
return &dbTransaction, nil
return settledTransaction, nil
}
func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHash string, transactionType *string, lnClient lnclient.LNClient, appId *uint) (*Transaction, error) {
@ -419,13 +449,13 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
// 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").Find(&transaction, &db.Transaction{
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 DB transaction")
logger.Logger.WithError(result.Error).Error("Failed to lookup transaction")
return nil, result.Error
}
@ -433,7 +463,7 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
logger.Logger.WithFields(logrus.Fields{
"payment_hash": paymentHash,
"app_id": appId,
}).WithError(result.Error).Error("Failed to lookup DB transaction")
}).WithError(result.Error).Error("transaction not found")
return nil, NewNotFoundError()
}
@ -471,7 +501,7 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
tx = tx.Limit(int(limit))
}
if offset > 0 {
tx = tx.Offset(int(limit))
tx = tx.Offset(int(offset))
}
if appId != nil {
@ -531,26 +561,20 @@ func (svc *transactionsService) checkUnsettledTransaction(ctx context.Context, t
}
// update transaction state
if lnClientTransaction.SettledAt != nil {
// the payment definitely succeeded
now := time.Now()
dbErr := svc.db.Model(transaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"Preimage": &lnClientTransaction.Preimage,
"FeeMsat": lnClientTransaction.FeesPaid,
"FeeReserveMsat": 0,
"SettledAt": &now,
}).Error
if dbErr != nil {
logger.Logger.WithFields(logrus.Fields{
"bolt11": transaction.PaymentRequest,
}).WithError(dbErr).Error("Failed to update DB transaction")
err = svc.db.Transaction(func(tx *gorm.DB) error {
_, err = svc.markTransactionSettled(tx, transaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaid), false)
return err
})
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_payment_received":
case "nwc_lnclient_payment_received":
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
@ -614,22 +638,8 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
}
}
settledAt := time.Now()
err := tx.Model(&dbTransaction).Updates(map[string]interface{}{
"FeeMsat": lnClientTransaction.FeesPaid,
"Preimage": &lnClientTransaction.Preimage,
"State": constants.TRANSACTION_STATE_SETTLED,
"SettledAt": &settledAt,
}).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"payment_hash": lnClientTransaction.PaymentHash,
}).WithError(err).Error("Failed to update transaction")
return err
}
return nil
_, err := svc.markTransactionSettled(tx, &dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaid), false)
return err
})
if err != nil {
@ -638,8 +648,7 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
}).WithError(err).Error("Failed to execute DB transaction")
return
}
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("Marked incoming transaction as settled")
case "nwc_payment_sent":
case "nwc_lnclient_payment_sent":
lnClientTransaction, ok := event.Properties.(*lnclient.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
@ -653,6 +662,10 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
PaymentHash: lnClientTransaction.PaymentHash,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// Note: payments made from outside cannot be associated with an app
// for now this is disabled as it only applies to LND, and we do not import LND transactions either.
@ -660,14 +673,7 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
return NewNotFoundError()
}
settledAt := time.Now()
err := tx.Model(&dbTransaction).Updates(map[string]interface{}{
"FeeMsat": lnClientTransaction.FeesPaid,
"FeeReserveMsat": 0,
"Preimage": &lnClientTransaction.Preimage,
"State": constants.TRANSACTION_STATE_SETTLED,
"SettledAt": &settledAt,
}).Error
_, err := svc.markTransactionSettled(tx, &dbTransaction, lnClientTransaction.Preimage, uint64(lnClientTransaction.FeesPaid), false)
return err
})
@ -677,10 +683,8 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
}).WithError(err).Error("Failed to update transaction")
return
}
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("Marked outgoing transaction as settled")
case "nwc_payment_failed_async":
paymentFailedAsyncProperties, ok := event.Properties.(*events.PaymentFailedAsyncProperties)
case "nwc_lnclient_payment_failed":
paymentFailedAsyncProperties, ok := event.Properties.(*lnclient.PaymentFailedEventProperties)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
return
@ -694,29 +698,19 @@ func (svc *transactionsService) ConsumeEvent(ctx context.Context, event *events.
PaymentHash: lnClientTransaction.PaymentHash,
})
// Note: this will happen for keysend payments since our transaction entry will not have a payment
// hash at this point
if result.RowsAffected == 0 {
logger.Logger.WithField("event", event).Error("Failed to find outgoing transaction by payment hash")
return
}
err := svc.db.Model(&dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_FAILED,
"FeeReserveMsat": 0,
}).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"payment_hash": lnClientTransaction.PaymentHash,
}).WithError(err).Error("Failed to update transaction")
return
}
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Info("Marked outgoing transaction as failed")
svc.db.Transaction(func(tx *gorm.DB) error {
return svc.markPaymentFailed(tx, &dbTransaction, paymentFailedAsyncProperties.Reason)
})
}
}
func (svc *transactionsService) interceptSelfPayment(paymentHash string) (*lnclient.PayInvoiceResponse, error) {
// TODO: extract into separate function
logger.Logger.WithField("payment_hash", paymentHash).Debug("Intercepting self payment")
incomingTransaction := db.Transaction{}
result := svc.db.Limit(1).Find(&incomingTransaction, &db.Transaction{
Type: constants.TRANSACTION_TYPE_INCOMING,
@ -734,19 +728,15 @@ func (svc *transactionsService) interceptSelfPayment(paymentHash string) (*lncli
return nil, errors.New("preimage is not set on transaction. Self payments not supported")
}
// update the incoming transaction
now := time.Now()
err := svc.db.Model(&incomingTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"SettledAt": &now,
"SelfPayment": true,
}).Error
err := svc.db.Transaction(func(tx *gorm.DB) error {
_, err := svc.markTransactionSettled(tx, &incomingTransaction, *incomingTransaction.Preimage, uint64(0), true)
return err
})
if err != nil {
return nil, err
}
// TODO: publish event for self payment
return &lnclient.PayInvoiceResponse{
Preimage: *incomingTransaction.Preimage,
Fee: 0,
@ -779,6 +769,14 @@ func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amount
balance := queries.GetIsolatedBalance(tx, appPermission.AppId)
if amountWithFeeReserve > balance {
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_permission_denied",
Properties: map[string]interface{}{
"app_name": app.Name,
"code": constants.ERROR_INSUFFICIENT_BALANCE,
"message": NewInsufficientBalanceError().Error(),
},
})
return NewInsufficientBalanceError()
}
}
@ -786,6 +784,14 @@ func (svc *transactionsService) validateCanPay(tx *gorm.DB, appId *uint, amount
if appPermission.MaxAmountSat > 0 {
budgetUsageSat := queries.GetBudgetUsageSat(tx, &appPermission)
if int(amountWithFeeReserve/1000) > appPermission.MaxAmountSat-int(budgetUsageSat) {
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_permission_denied",
Properties: map[string]interface{}{
"app_name": app.Name,
"code": constants.ERROR_QUOTA_EXCEEDED,
"message": NewQuotaExceededError().Error(),
},
})
return NewQuotaExceededError()
}
}
@ -877,3 +883,89 @@ func (svc *transactionsService) getAppIdFromCustomRecords(customRecords []lnclie
}
return nil
}
func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransaction *db.Transaction, preimage string, fee uint64, selfPayment bool) (*db.Transaction, error) {
// TODO: it would be better to have a database constraint so we cannot have two pending payments
var existingSettledTransaction db.Transaction
if tx.Limit(1).Find(&existingSettledTransaction, &db.Transaction{
Type: dbTransaction.Type,
PaymentHash: dbTransaction.PaymentHash,
State: constants.TRANSACTION_STATE_SETTLED,
}).RowsAffected > 0 {
logger.Logger.WithField("payment_hash", dbTransaction.PaymentHash).Error("payment already marked as sent")
return &existingSettledTransaction, nil
}
if preimage == "" {
return nil, errors.New("no preimage in payment")
}
now := time.Now()
err := tx.Model(dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"Preimage": &preimage,
"FeeMsat": fee,
"FeeReserveMsat": 0,
"SettledAt": &now,
"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")
event := "nwc_payment_sent"
if dbTransaction.Type == constants.TRANSACTION_TYPE_INCOMING {
event = "nwc_payment_received"
}
svc.eventPublisher.Publish(&events.Event{
Event: event,
Properties: dbTransaction,
})
return dbTransaction, nil
}
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
}