diff --git a/api/api.go b/api/api.go index f828a92a..0a670a16 100644 --- a/api/api.go +++ b/api/api.go @@ -505,6 +505,42 @@ func (api *api) GetApp(dbApp *db.App) (*App, error) { return &response, nil } +func (api *api) ListConnectionIssues(appId uint, limit uint64) ([]ConnectionIssue, error) { + if limit == 0 || limit > 20 { + limit = 10 + } + + dbIssues := []db.ConnectionIssue{} + err := api.db. + Where("app_id = ?", appId). + Order("created_at DESC"). + Limit(int(limit)). + Find(&dbIssues). + Error + if err != nil { + logger.Logger.WithError(err).WithFields(logrus.Fields{ + "app_id": appId, + }).Error("Failed to list connection issues") + return nil, err + } + + issues := make([]ConnectionIssue, len(dbIssues)) + for i, issue := range dbIssues { + issues[i] = ConnectionIssue{ + ID: issue.ID, + AppId: issue.AppId, + RequestEventId: issue.RequestEventId, + Method: issue.Method, + Category: issue.Category, + ErrorCode: issue.ErrorCode, + ErrorMessage: issue.ErrorMessage, + CreatedAt: issue.CreatedAt, + } + } + + return issues, nil +} + func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) { // TODO: join dbApps and permissions dbApps := []db.App{} diff --git a/api/models.go b/api/models.go index 375a9556..6b37894c 100644 --- a/api/models.go +++ b/api/models.go @@ -19,6 +19,7 @@ type API interface { DeleteApp(app *db.App) error GetApp(app *db.App) (*App, error) ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error) + ListConnectionIssues(appId uint, limit uint64) ([]ConnectionIssue, error) CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error DeleteLightningAddress(ctx context.Context, appId uint) error ListChannels(ctx context.Context) ([]Channel, error) @@ -129,6 +130,17 @@ type ListAppsResponse struct { TotalBalanceMsat *int64 `json:"totalBalanceMsat,omitempty"` } +type ConnectionIssue struct { + ID uint `json:"id"` + AppId uint `json:"appId"` + RequestEventId uint `json:"requestEventId"` + Method string `json:"method"` + Category string `json:"category"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + CreatedAt time.Time `json:"createdAt"` +} + type UpdateAppRequest struct { Name *string `json:"name"` MaxAmount *uint64 `json:"maxAmount"` // deprecated diff --git a/cmd/db_migrate/main.go b/cmd/db_migrate/main.go index c10f4a3e..1c8f5ed0 100644 --- a/cmd/db_migrate/main.go +++ b/cmd/db_migrate/main.go @@ -19,6 +19,7 @@ var expectedTables = []string{ "app_permissions", "request_events", "response_events", + "connection_issues", "transactions", "swaps", "user_configs", @@ -151,6 +152,11 @@ func migrateDB(from, to *gorm.DB) error { return fmt.Errorf("failed to migrate response_events: %w", err) } + logger.Logger.Info("migrating connection_issues...") + if err := migrateTable[db.ConnectionIssue](from, tx); err != nil { + return fmt.Errorf("failed to migrate connection_issues: %w", err) + } + logger.Logger.Info("migrating transactions...") if err := migrateTable[db.Transaction](from, tx); err != nil { return fmt.Errorf("failed to migrate transactions: %w", err) @@ -270,6 +276,7 @@ func resetSequences(db *gorm.DB) error { {"app_permissions", "app_permissions_2_id_seq"}, {"request_events", "request_events_id_seq"}, {"response_events", "response_events_id_seq"}, + {"connection_issues", "connection_issues_id_seq"}, {"transactions", "transactions_id_seq"}, {"user_configs", "user_configs_id_seq"}, } diff --git a/db/migrations/202605121200_connection_issues.go b/db/migrations/202605121200_connection_issues.go new file mode 100644 index 00000000..2fd2ecd7 --- /dev/null +++ b/db/migrations/202605121200_connection_issues.go @@ -0,0 +1,38 @@ +package migrations + +import ( + "text/template" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +const connectionIssuesMigration = ` +CREATE TABLE connection_issues( + id {{ .AutoincrementPrimaryKey }}, + app_id integer NOT NULL, + request_event_id integer NOT NULL, + method text, + category text NOT NULL, + error_code text, + error_message text, + created_at {{ .Timestamp }}, + updated_at {{ .Timestamp }}, + CONSTRAINT fk_connection_issues_app FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE, + CONSTRAINT fk_connection_issues_request_event FOREIGN KEY (request_event_id) REFERENCES request_events(id) ON DELETE CASCADE +); +CREATE INDEX idx_connection_issues_app_id_created_at ON connection_issues(app_id, created_at); +CREATE INDEX idx_connection_issues_request_event_id ON connection_issues(request_event_id); +` + +var connectionIssuesMigrationTmpl = template.Must(template.New("connectionIssuesMigration").Parse(connectionIssuesMigration)) + +var _202605121200_connection_issues = &gormigrate.Migration{ + ID: "202605121200_connection_issues", + Migrate: func(tx *gorm.DB) error { + return exec(tx, connectionIssuesMigrationTmpl) + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 89158a40..d8b64646 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -39,6 +39,7 @@ func Migrate(gormDB *gorm.DB) error { _202508192137_forwards, _202509031250_transactions_updated_at_index, _202604081200_app_last_settled_transaction, + _202605121200_connection_issues, }) return m.Migrate() diff --git a/db/models.go b/db/models.go index be60711b..3fba241f 100644 --- a/db/models.go +++ b/db/models.go @@ -63,6 +63,20 @@ type ResponseEvent struct { UpdatedAt time.Time } +type ConnectionIssue struct { + ID uint + AppId uint `validate:"required"` + App App + RequestEventId uint `validate:"required"` + RequestEvent RequestEvent + Method string + Category string + ErrorCode string + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time +} + type Transaction struct { ID uint AppId *uint diff --git a/frontend/src/components/DisconnectPeerDialogContent.tsx b/frontend/src/components/DisconnectPeerDialogContent.tsx index fae9e9f3..f2318bd3 100644 --- a/frontend/src/components/DisconnectPeerDialogContent.tsx +++ b/frontend/src/components/DisconnectPeerDialogContent.tsx @@ -37,7 +37,7 @@ export function DisconnectPeerDialogContent({ peer }: Props) { await reloadPeers(); } catch (e) { console.error(e); - toast.error("Failed to disconnect peer", { + toast.error("Peer was not disconnected", { description: "" + e, }); } diff --git a/frontend/src/components/InsufficientLightningBalanceAlert.tsx b/frontend/src/components/InsufficientLightningBalanceAlert.tsx index 769a07ab..70faa48f 100644 --- a/frontend/src/components/InsufficientLightningBalanceAlert.tsx +++ b/frontend/src/components/InsufficientLightningBalanceAlert.tsx @@ -38,11 +38,11 @@ export function InsufficientLightningBalanceAlert({ return ( - Maximum Spendable Balance Too Low + Not enough spendable balance

- Your payment will likely fail because your maximum spendable balance - in your lightning channels for the next payment is currently{" "} + This payment is above the wallet's current spendable balance. The most + you can send right now is{" "} .

diff --git a/frontend/src/components/LowReceivingCapacityAlert.tsx b/frontend/src/components/LowReceivingCapacityAlert.tsx index b81e1356..563d5b94 100644 --- a/frontend/src/components/LowReceivingCapacityAlert.tsx +++ b/frontend/src/components/LowReceivingCapacityAlert.tsx @@ -10,9 +10,10 @@ export default function LowReceivingCapacityAlert() { return ( - Low receiving capacity + You need more receiving capacity - You likely won't be able to receive payments until you{" "} + This wallet cannot receive larger payments right now. Add receiving + capacity,{" "} spend @@ -22,7 +23,7 @@ export default function LowReceivingCapacityAlert() { , or{" "} - increase your receiving capacity. + open an incoming channel. diff --git a/frontend/src/components/PaymentFailedAlert.tsx b/frontend/src/components/PaymentFailedAlert.tsx index 9ccb4aab..074cbe9b 100644 --- a/frontend/src/components/PaymentFailedAlert.tsx +++ b/frontend/src/components/PaymentFailedAlert.tsx @@ -44,11 +44,11 @@ export function PaymentFailedAlert({ return ( - Payment Failed + Payment was not sent

- Try the payment again, read our payments guide, and optionally send - details about the failed payment to help improve Alby Hub. + Alby Hub could not complete this payment. No sats were sent unless you + see it in your transactions.

void; + showTimestamp?: boolean; +}) { + const copy = getConnectionIssueCopy(appName, issue, onViewDetails); + + return ( + + + {copy.title} + +

{copy.description}

+

+ {issue.errorCode}: {issue.errorMessage} +

+ {showTimestamp && ( +

+ {new Date(issue.createdAt).toLocaleString()} +

+ )} +
+ + {copy.href ? ( + + ) : ( + + )} + +
+ ); +} + +export function ConnectionIssuesCard({ + appName, + issues, + onViewDetails, +}: { + appName: string; + issues: ConnectionIssue[] | undefined; + onViewDetails: () => void; +}) { + if (!issues?.length) { + return null; + } + + return ( + + + Recent Connection Issues + + + {issues.map((issue) => ( + + ))} + + + ); +} diff --git a/frontend/src/hooks/useApp.ts b/frontend/src/hooks/useApp.ts index d4610d7b..2683fd70 100644 --- a/frontend/src/hooks/useApp.ts +++ b/frontend/src/hooks/useApp.ts @@ -1,6 +1,6 @@ import useSWR, { SWRConfiguration } from "swr"; -import { App } from "src/types"; +import { App, ConnectionIssue } from "src/types"; import { swrFetcher } from "src/utils/swr"; const pollConfiguration: SWRConfiguration = { @@ -14,3 +14,11 @@ export function useApp(id: number | undefined, poll = false) { poll ? pollConfiguration : undefined ); } + +export function useConnectionIssues(appId: number | undefined) { + return useSWR( + !!appId && `/api/v2/apps/${appId}/issues?limit=5`, + swrFetcher, + pollConfiguration + ); +} diff --git a/frontend/src/lib/connectionIssues.ts b/frontend/src/lib/connectionIssues.ts new file mode 100644 index 00000000..8296a0ba --- /dev/null +++ b/frontend/src/lib/connectionIssues.ts @@ -0,0 +1,96 @@ +import { ConnectionIssue } from "src/types"; + +export type ConnectionIssueCopy = { + title: string; + description: string; + action: string; + href?: string; + onClick?: () => void; +}; + +export function getMethodLabel(method: string) { + switch (method) { + case "pay_invoice": + case "multi_pay_invoice": + case "pay_keysend": + case "multi_pay_keysend": + return "send payments"; + case "make_invoice": + return "create invoices"; + case "lookup_invoice": + return "look up invoices"; + case "list_transactions": + return "read transaction history"; + case "get_balance": + return "read the balance"; + case "get_info": + return "read wallet info"; + case "sign_message": + return "sign messages"; + default: + return "use this wallet feature"; + } +} + +export function getConnectionIssueCopy( + appName: string, + issue: ConnectionIssue, + onViewDetails: () => void +): ConnectionIssueCopy { + switch (issue.category) { + case "missing_permission": + return { + title: "App needs permission", + description: `This connection does not allow ${getMethodLabel(issue.method)} yet.`, + action: "Review connection", + href: `/apps/${issue.appId}?edit`, + }; + case "unknown_method": + return { + title: "App requested an unknown feature", + description: + "Alby Hub does not recognize this app request. No wallet action was taken.", + action: "View details", + onClick: onViewDetails, + }; + case "expired_connection": + return { + title: "Connection expired", + description: + "This app connection has expired. Review the connection to renew access.", + action: "Review connection", + href: `/apps/${issue.appId}?edit`, + }; + case "budget_exceeded": + return { + title: "Connection budget reached", + description: + "This payment is above the connection's remaining budget. No payment was sent.", + action: "Review budget", + href: `/apps/${issue.appId}?edit`, + }; + case "low_balance": + return { + title: "Not enough spendable balance", + description: + "This wallet does not have enough spendable sats for the app request.", + action: "View details", + onClick: onViewDetails, + }; + case "payment_failed": + return { + title: "Payment was not sent", + description: `Alby Hub could not complete the payment requested by ${appName}. Check transactions if you are unsure.`, + action: "View details", + onClick: onViewDetails, + }; + default: + return { + title: `${appName} request failed`, + description: + "Alby Hub could not complete this app request. No wallet action was taken unless you see it in your transactions.", + action: "View details", + onClick: onViewDetails, + }; + } +} diff --git a/frontend/src/screens/MigrateNode.tsx b/frontend/src/screens/MigrateNode.tsx index 762114b8..1f7f9d19 100644 --- a/frontend/src/screens/MigrateNode.tsx +++ b/frontend/src/screens/MigrateNode.tsx @@ -65,7 +65,7 @@ export function MigrateNode() { navigate("/create-node-migration-file-success"); } catch (error) { - handleRequestError("Failed to backup the node", error); + handleRequestError("Backup did not finish", error); } finally { setLoading(false); } diff --git a/frontend/src/screens/apps/AppDetails.tsx b/frontend/src/screens/apps/AppDetails.tsx index 22ebad81..4d3d4a85 100644 --- a/frontend/src/screens/apps/AppDetails.tsx +++ b/frontend/src/screens/apps/AppDetails.tsx @@ -29,6 +29,7 @@ import { AppLinksCard } from "src/components/connections/AppLinksCard"; import { AppTransactionList } from "src/components/connections/AppTransactionList"; import { AppUsage } from "src/components/connections/AppUsage"; import { ConnectionDetailsModal } from "src/components/connections/ConnectionDetailsModal"; +import { ConnectionIssuesCard } from "src/components/connections/ConnectionIssuesCard"; import { DisconnectApp } from "src/components/connections/DisconnectApp"; import { getAppStoreApp } from "src/components/connections/SuggestedAppData"; import Loading from "src/components/Loading"; @@ -64,7 +65,7 @@ import { SUBWALLET_APPSTORE_APP_ID, } from "src/constants"; import { useAlbyMe } from "src/hooks/useAlbyMe"; -import { useApp } from "src/hooks/useApp"; +import { useApp, useConnectionIssues } from "src/hooks/useApp"; import { useAppsForAppStoreApp } from "src/hooks/useApps"; import { useCapabilities } from "src/hooks/useCapabilities"; import { cn, getAppDisplayName } from "src/lib/utils"; @@ -105,6 +106,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) { React.useState(false); const [showDisconnectAppDialog, setShowDisconnectAppDialog] = React.useState(false); + const { data: connectionIssues } = useConnectionIssues(app.id); const { data: albyMe } = useAlbyMe(); @@ -399,6 +401,11 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
)} + setShowConnectionDetails(true)} + /> )} {isEditingPermissions ? ( diff --git a/frontend/src/screens/channels/CurrentChannelOrder.tsx b/frontend/src/screens/channels/CurrentChannelOrder.tsx index fbffb3f7..98fa50fa 100644 --- a/frontend/src/screens/channels/CurrentChannelOrder.tsx +++ b/frontend/src/screens/channels/CurrentChannelOrder.tsx @@ -482,7 +482,7 @@ function PayBitcoinChannelOrderWithSpendableFunds({ }); } catch (error) { console.error(error); - toast.error("Something went wrong", { + toast.error("Channel was not opened", { description: "" + error, }); } @@ -608,7 +608,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) { } setLspOrderResponse(response); } catch (error) { - toast.error("Something went wrong", { + toast.error("Channel order could not be created", { description: "" + error, }); } @@ -737,7 +737,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) { }); toast("Channel successfully requested"); } catch (e) { - toast.error("Failed to send: ", { + toast.error("Channel payment was not sent", { description: "" + e, }); console.error(e); diff --git a/frontend/src/screens/channels/IncreaseIncomingCapacity.tsx b/frontend/src/screens/channels/IncreaseIncomingCapacity.tsx index df803b04..e94c4ace 100644 --- a/frontend/src/screens/channels/IncreaseIncomingCapacity.tsx +++ b/frontend/src/screens/channels/IncreaseIncomingCapacity.tsx @@ -92,7 +92,10 @@ function NewChannelInternal({ React.useEffect(() => { if (channelPeerSuggestionsError) { - toast.error("Failed to load channel suggestions"); + toast.error("Channel suggestions could not be loaded", { + description: + "Alby Hub could not load receiving capacity options. You can still open a channel manually.", + }); navigate("/channels/outgoing"); } }, [channelPeerSuggestionsError, navigate]); @@ -183,9 +186,9 @@ function NewChannelInternal({ } if (!bestPartner) { - toast.error("No channel partner found", { + toast.error("No channel partner fits this request", { description: - "No ideal channel partner found. Please choose from the advanced options to continue", + "Choose a different amount or use advanced options to continue.", }); return; } @@ -200,7 +203,7 @@ function NewChannelInternal({ useChannelOrderStore.getState().setOrder(nextOrder as NewChannelOrder); navigate("/channels/order"); } catch (error) { - toast.error("Something went wrong", { + toast.error("Channel order could not be created", { description: "" + error, }); console.error(error); diff --git a/frontend/src/screens/channels/IncreaseOutgoingCapacity.tsx b/frontend/src/screens/channels/IncreaseOutgoingCapacity.tsx index 87bc38f0..0c4a01c9 100644 --- a/frontend/src/screens/channels/IncreaseOutgoingCapacity.tsx +++ b/frontend/src/screens/channels/IncreaseOutgoingCapacity.tsx @@ -193,7 +193,7 @@ function NewChannelInternal({ setShowConfirmModal(false); navigate("/channels/order"); } catch (error) { - toast.error("Something went wrong", { + toast.error("Channel order could not be created", { description: `${error}`, }); setShowConfirmModal(false); diff --git a/frontend/src/screens/peers/ConnectPeer.tsx b/frontend/src/screens/peers/ConnectPeer.tsx index b6c1583c..8e76349a 100644 --- a/frontend/src/screens/peers/ConnectPeer.tsx +++ b/frontend/src/screens/peers/ConnectPeer.tsx @@ -55,7 +55,7 @@ export default function ConnectPeer() { setConnectionString(""); navigate("/peers"); } catch (e) { - toast.error("Failed to connect peer", { + toast.error("Peer is not connected", { description: "" + e, }); console.error(e); diff --git a/frontend/src/screens/settings/AlbyAccount.tsx b/frontend/src/screens/settings/AlbyAccount.tsx index b6284fff..d782d166 100644 --- a/frontend/src/screens/settings/AlbyAccount.tsx +++ b/frontend/src/screens/settings/AlbyAccount.tsx @@ -34,9 +34,13 @@ export function AlbyAccount() { ) : albyMeError ? ( - -

- Failed to load Alby Account details + +

+ Alby Account details could not be loaded +

+

+ Your Alby Account is still linked, but Alby Hub could not load + account details right now.

diff --git a/frontend/src/screens/setup/RestoreNode.tsx b/frontend/src/screens/setup/RestoreNode.tsx index e581ac2e..62f98e44 100644 --- a/frontend/src/screens/setup/RestoreNode.tsx +++ b/frontend/src/screens/setup/RestoreNode.tsx @@ -93,7 +93,7 @@ export function RestoreNode() { setRestored(true); } catch (error) { - handleRequestError("Failed to restore backup", error); + handleRequestError("Restore did not finish", error); } finally { setShowAlert(false); setLoading(false); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a8adbf98..0cc887a6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -141,6 +141,17 @@ export interface AppPermissions { isolated: boolean; } +export interface ConnectionIssue { + id: number; + appId: number; + requestEventId: number; + method: string; + category: string; + errorCode: string; + errorMessage: string; + createdAt: string; +} + export interface InfoResponse { backendType: BackendType; setupCompleted: boolean; diff --git a/http/http_service.go b/http/http_service.go index 74ec814e..6a20d7ba 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -127,6 +127,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { readOnlyApiGroup.GET("/apps", httpSvc.appsListHandler) readOnlyApiGroup.GET("/apps/:pubkey", httpSvc.appsShowByPubkeyHandler) readOnlyApiGroup.GET("/v2/apps/:id", httpSvc.appsShowHandler) + readOnlyApiGroup.GET("/v2/apps/:id/issues", httpSvc.appConnectionIssuesHandler) readOnlyApiGroup.GET("/channels", httpSvc.channelsListHandler) readOnlyApiGroup.GET("/channels/suggestions", httpSvc.channelPeerSuggestionsHandler) readOnlyApiGroup.GET("/channel-offer", httpSvc.channelOfferHandler) @@ -1142,6 +1143,43 @@ func (httpSvc *HttpService) appsShowHandler(c echo.Context) error { return c.JSON(http.StatusOK, response) } +func (httpSvc *HttpService) appConnectionIssuesHandler(c echo.Context) error { + appIdStr := c.Param("id") + if appIdStr == "" { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "App ID is required", + }) + } + + appId, err := strconv.ParseUint(appIdStr, 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, ErrorResponse{ + Message: "Invalid App ID", + }) + } + + dbApp := httpSvc.appsSvc.GetAppById(uint(appId)) + if dbApp == nil { + return c.JSON(http.StatusNotFound, ErrorResponse{ + Message: "App not found", + }) + } + + limit := uint64(0) + if limitParam := c.QueryParam("limit"); limitParam != "" { + limit, _ = strconv.ParseUint(limitParam, 10, 64) + } + + issues, err := httpSvc.api.ListConnectionIssues(uint(appId), limit) + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: err.Error(), + }) + } + + return c.JSON(http.StatusOK, issues) +} + func (httpSvc *HttpService) appsUpdateHandler(c echo.Context) error { var requestData api.UpdateAppRequest if err := c.Bind(&requestData); err != nil { diff --git a/nip47/connection_issue.go b/nip47/connection_issue.go new file mode 100644 index 00000000..ee497bec --- /dev/null +++ b/nip47/connection_issue.go @@ -0,0 +1,73 @@ +package nip47 + +import ( + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/logger" + "github.com/getAlby/hub/nip47/models" + "github.com/sirupsen/logrus" +) + +const ( + connectionIssueMissingPermission = "missing_permission" + connectionIssueUnknownMethod = "unknown_method" + connectionIssueExpiredConnection = "expired_connection" + connectionIssueBudgetExceeded = "budget_exceeded" + connectionIssueLowBalance = "low_balance" + connectionIssuePaymentFailed = "payment_failed" +) + +func categorizeConnectionIssue(method string, responseError *models.Error) (string, bool) { + if responseError.Code == constants.ERROR_RESTRICTED { + return connectionIssueMissingPermission, true + } + if responseError.Code == constants.ERROR_EXPIRED { + return connectionIssueExpiredConnection, true + } + if responseError.Code == constants.ERROR_QUOTA_EXCEEDED { + return connectionIssueBudgetExceeded, true + } + if responseError.Code == constants.ERROR_INSUFFICIENT_BALANCE { + return connectionIssueLowBalance, true + } + if responseError.Code == constants.ERROR_NOT_IMPLEMENTED { + return connectionIssueUnknownMethod, true + } + if method == models.PAY_INVOICE_METHOD || + method == models.MULTI_PAY_INVOICE_METHOD || + method == models.PAY_KEYSEND_METHOD || + method == models.MULTI_PAY_KEYSEND_METHOD { + return connectionIssuePaymentFailed, true + } + + return "", false +} + +func (svc *nip47Service) recordConnectionIssue(app *db.App, requestEvent *db.RequestEvent, response *models.Response) { + if app == nil || response == nil || response.Error == nil { + return + } + + category, ok := categorizeConnectionIssue(requestEvent.Method, response.Error) + if !ok { + return + } + + issue := db.ConnectionIssue{ + AppId: app.ID, + RequestEventId: requestEvent.ID, + Method: requestEvent.Method, + Category: category, + ErrorCode: response.Error.Code, + ErrorMessage: response.Error.Message, + } + + err := svc.db.Create(&issue).Error + if err != nil { + logger.Logger.WithFields(logrus.Fields{ + "appId": app.ID, + "requestEventId": requestEvent.ID, + "method": requestEvent.Method, + }).WithError(err).Error("Failed to record connection issue") + } +} diff --git a/nip47/event_handler.go b/nip47/event_handler.go index 365133f4..a2c0b23a 100644 --- a/nip47/event_handler.go +++ b/nip47/event_handler.go @@ -285,6 +285,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.Simpl // TODO: update all previous occurrences of svc.publishResponseEvent to also use the channel publishResponse := func(nip47Response *models.Response, tags nostr.Tags) { var state string + svc.recordConnectionIssue(&app, &requestEvent, nip47Response) resp, err := svc.CreateResponse(event, nip47Response, tags, nip47Cipher, appWalletPrivKey) if err != nil { logger.Logger.WithFields(logrus.Fields{