feat: receive invoices to apps (#2466)

* feat: receive invoices to apps

* fix: handle cleared receive selector
This commit is contained in:
Alchemist 2026-08-04 11:18:22 +01:00 committed by GitHub
parent 417cb16d97
commit 816e0dda6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 217 additions and 5 deletions

View file

@ -46,7 +46,7 @@ type API interface {
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
Description string `json:"description"`
ToAppID *uint `json:"toAppId"`
}
type ResetRouterRequest struct {

View file

@ -12,12 +12,17 @@ import (
"github.com/sirupsen/logrus"
)
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil {
return nil, errors.New("app does not exist")
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
if err != nil {
return nil, err
}

62
api/transactions_test.go Normal file
View file

@ -0,0 +1,62 @@
package api
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/tests"
"github.com/getAlby/hub/tests/mocks"
"github.com/getAlby/hub/transactions"
)
func TestCreateInvoice_ToApp(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
app, _, err := tests.CreateApp(testSvc)
require.NoError(t, err)
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
svc.On("GetTransactionsService").Return(transactions.NewTransactionsService(testSvc.DB, testSvc.EventPublisher))
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &app.ID)
require.NoError(t, err)
require.NotNil(t, transaction.AppId)
assert.Equal(t, app.ID, *transaction.AppId)
}
func TestCreateInvoice_ToAppNotFound(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
missingAppId := uint(999)
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &missingAppId)
assert.Nil(t, transaction)
require.Error(t, err)
assert.Equal(t, "app does not exist", err.Error())
}

View file

@ -16,6 +16,7 @@ export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
export const LIST_TRANSACTIONS_LIMIT = 20;
export const LIST_APPS_LIMIT = 20;
export const MAX_FREE_SUBWALLETS = 3;
export const APP_SELECT_APPS_LIMIT = 100;
export const PAY_FROM_SELECT_APPS_LIMIT = 100;
export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;

View file

@ -46,6 +46,7 @@ import { useInfo } from "src/hooks/useInfo";
import { useTransaction } from "src/hooks/useTransaction";
import { copyToClipboard } from "src/lib/clipboard";
import { cn } from "src/lib/utils";
import ReceiveToSelect from "src/screens/wallet/receive/ReceiveToSelect";
import { CreateInvoiceRequest, Transaction } from "src/types";
import { request } from "src/utils/request";
@ -60,6 +61,7 @@ export default function ReceiveInvoice() {
React.useState(false);
const [amountSat, setAmountSat] = React.useState<string>("");
const [description, setDescription] = React.useState<string>("");
const [toAppId, setToAppId] = React.useState<number>();
const [transaction, setTransaction] = React.useState<Transaction | null>(
null
);
@ -125,6 +127,7 @@ export default function ReceiveInvoice() {
body: JSON.stringify({
amountMsat: (parseInt(amountSat) || 0) * 1000,
description,
toAppId,
} as CreateInvoiceRequest),
});
@ -337,6 +340,7 @@ export default function ReceiveInvoice() {
}}
/>
</div>
<ReceiveToSelect appId={toAppId} onChange={setToAppId} />
<LoadingButton
className={cn(
"w-full",

View file

@ -0,0 +1,138 @@
"use client";
import { WalletIcon } from "lucide-react";
import React from "react";
import AppAvatar from "src/components/AppAvatar";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} from "src/components/ui/combobox";
import { InputGroupAddon } from "src/components/ui/input-group";
import { Label } from "src/components/ui/label";
import { APP_SELECT_APPS_LIMIT } from "src/constants";
import { useApps } from "src/hooks/useApps";
import { getAppDisplayName } from "src/lib/utils";
import { App } from "src/types";
const LIGHTNING_BALANCE = "lightning-balance";
const LIGHTNING_BALANCE_LABEL = "Lightning Balance";
type ReceiveToOption = {
value: string;
label: string;
app?: App;
};
type Props = {
appId?: number;
onChange(appId: number | undefined): void;
};
function LightningOption() {
return (
<div className="flex items-center gap-3">
<div className="flex size-6 items-center justify-center rounded-lg bg-muted">
<WalletIcon className="size-3 text-muted-foreground" />
</div>
<div>{LIGHTNING_BALANCE_LABEL}</div>
</div>
);
}
function AppOption({ app }: { app: App }) {
return (
<div className="flex items-center gap-3">
<AppAvatar app={app} className="size-6 rounded-lg" />
<div className="min-w-0">
<div>{getAppDisplayName(app.name)}</div>
</div>
</div>
);
}
export default function ReceiveToSelect({ appId, onChange }: Props) {
const anchorRef = useComboboxAnchor();
const [search, setSearch] = React.useState("");
const { data: appsData } = useApps(APP_SELECT_APPS_LIMIT, undefined, {
name: search,
});
const apps = React.useMemo(
() =>
[...(appsData?.apps || [])].sort((a, b) =>
getAppDisplayName(a.name).localeCompare(getAppDisplayName(b.name))
),
[appsData?.apps]
);
const options = React.useMemo<ReceiveToOption[]>(
() => [
{ value: LIGHTNING_BALANCE, label: LIGHTNING_BALANCE_LABEL },
...apps.map((app) => ({
value: app.id.toString(),
label: getAppDisplayName(app.name),
app,
})),
],
[apps]
);
const selectedOption = options.find((opt) =>
appId ? opt.value === appId.toString() : undefined
);
return (
<div className="grid w-full gap-1.5">
<Label>Receive to</Label>
<Combobox
items={options}
value={selectedOption}
itemToStringValue={(option) => option.value}
onInputValueChange={setSearch}
onValueChange={(option) =>
onChange(
!option || option.value === LIGHTNING_BALANCE
? undefined
: Number(option.value)
)
}
>
<div ref={anchorRef} className="w-full">
<ComboboxInput placeholder={LIGHTNING_BALANCE_LABEL}>
<InputGroupAddon>
{selectedOption?.app ? (
<AppAvatar
app={selectedOption.app}
className="size-6 rounded-full"
/>
) : (
<div className="flex size-6 items-center justify-center rounded-sm bg-muted">
<WalletIcon className="size-3 text-muted-foreground" />
</div>
)}
</InputGroupAddon>
</ComboboxInput>
</div>
<ComboboxContent anchor={anchorRef}>
<ComboboxEmpty>No connections found.</ComboboxEmpty>
<ComboboxList>
{(option: ReceiveToOption) => (
<ComboboxItem key={option.value} value={option}>
{option.app ? (
<AppOption app={option.app} />
) : (
<LightningOption />
)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}

View file

@ -378,6 +378,7 @@ export type CreateInvoiceRequest = {
amountSat?: number;
amountMsat?: number;
description: string;
toAppId?: number;
};
export type PayInvoiceRequest = {

View file

@ -687,7 +687,7 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
amountMsat = *resolvedAmountMsat
}
invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description, makeInvoiceRequest.ToAppID)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{

View file

@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
if resolvedAmountMsat != nil {
amountMsat = *resolvedAmountMsat
}
invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, makeInvoiceRequest.ToAppID)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}