fix: return optional total balance in list apps response for subwallets (#2057)

* fix: return optional total balance in list apps response for subwallets

* chore: add error handling to subwallet balance query

* chore: add METADATA_APPSTORE_APP_ID_KEY constant

* chore: add MAX_FREE_SUBWALLETS constant

* chore: use subwallet query and total count for limit check
This commit is contained in:
Adithya Vardhan 2026-02-26 12:25:06 +05:30 committed by GitHub
parent 9701c726ac
commit a2af8fd598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 183 additions and 56 deletions

View file

@ -177,7 +177,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
}).Error("Failed to deserialize app metadata") }).Error("Failed to deserialize app metadata")
return err return err
} }
if existingMetadata["app_store_app_id"] == constants.SUBWALLET_APPSTORE_APP_ID { if existingMetadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID {
return errors.New("Cannot update sub-wallet to be non-isolated") return errors.New("Cannot update sub-wallet to be non-isolated")
} }
} }
@ -487,7 +487,7 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
} }
if filters.AppStoreAppId != "" { if filters.AppStoreAppId != "" {
query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, "app_store_app_id")) query = query.Where(datatypes.JSONQuery("metadata").Equals(filters.AppStoreAppId, constants.METADATA_APPSTORE_APP_ID_KEY))
} }
if filters.Unused { if filters.Unused {
@ -495,12 +495,16 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour)) query = query.Where("last_used_at IS NULL OR last_used_at < ?", time.Now().Add(-60*24*time.Hour))
} }
if filters.SubWallets != nil && !*filters.SubWallets { if filters.SubWallets != nil {
// exclude subwallets :scream: if *filters.SubWallets {
if api.db.Dialector.Name() == "sqlite" { query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
query = query.Where("metadata is NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') IS NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') != ?", constants.SUBWALLET_APPSTORE_APP_ID)
} else { } else {
query = query.Where("metadata IS NULL OR metadata->>'app_store_app_id' IS NULL OR metadata->>'app_store_app_id' != ?", constants.SUBWALLET_APPSTORE_APP_ID) // exclude subwallets :scream:
if api.db.Dialector.Name() == "sqlite" {
query = query.Where(fmt.Sprintf("metadata is NULL OR JSON_EXTRACT(metadata, '$.%s') IS NULL OR JSON_EXTRACT(metadata, '$.%s') != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
} else {
query = query.Where(fmt.Sprintf("metadata IS NULL OR metadata->>'%s' IS NULL OR metadata->>'%s' != ?", constants.METADATA_APPSTORE_APP_ID_KEY, constants.METADATA_APPSTORE_APP_ID_KEY), constants.SUBWALLET_APPSTORE_APP_ID)
}
} }
} }
@ -523,6 +527,17 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
logger.Logger.WithError(result.Error).Error("Failed to count DB apps") logger.Logger.WithError(result.Error).Error("Failed to count DB apps")
return nil, result.Error return nil, result.Error
} }
var totalBalance *int64
if filters.SubWallets != nil && *filters.SubWallets {
totalBalanceMsat, err := queries.GetTotalSubwalletBalance(api.db)
if err != nil {
logger.Logger.WithError(err).Error("Failed to calculate total subwallet balance")
return nil, err
}
totalBalance = &totalBalanceMsat
}
query = query.Offset(int(offset)).Limit(int(limit)) query = query.Offset(int(offset)).Limit(int(limit))
err := query.Find(&dbApps).Error err := query.Find(&dbApps).Error
@ -598,8 +613,9 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
apiApps = append(apiApps, apiApp) apiApps = append(apiApps, apiApp)
} }
return &ListAppsResponse{ return &ListAppsResponse{
Apps: apiApps, Apps: apiApps,
TotalCount: uint64(totalCount), TotalCount: uint64(totalCount),
TotalBalance: totalBalance,
}, nil }, nil
} }

View file

@ -113,8 +113,9 @@ type ListAppsFilters struct {
} }
type ListAppsResponse struct { type ListAppsResponse struct {
Apps []App `json:"apps"` Apps []App `json:"apps"`
TotalCount uint64 `json:"totalCount"` TotalCount uint64 `json:"totalCount"`
TotalBalance *int64 `json:"totalBalance,omitempty"`
} }
type UpdateAppRequest struct { type UpdateAppRequest struct {

View file

@ -76,6 +76,8 @@ const (
ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2" ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2"
) )
const METADATA_APPSTORE_APP_ID_KEY = "app_store_app_id"
const SUBWALLET_APPSTORE_APP_ID = "uncle-jim" const SUBWALLET_APPSTORE_APP_ID = "uncle-jim"
const ( const (

View file

@ -0,0 +1,40 @@
package queries
import (
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func GetTotalSubwalletBalance(tx *gorm.DB) (int64, error) {
subwalletAppIDsQuery := tx.Model(&db.App{}).
Select("id").
Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
var received struct {
Sum int64
}
res := tx.
Table("transactions").
Select("SUM(amount_msat) as sum").
Where("app_id IN (?) AND type = ? AND state = ?", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).
Scan(&received)
if res.Error != nil {
return 0, res.Error
}
var spent struct {
Sum int64
}
res = tx.
Table("transactions").
Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum").
Where("app_id IN (?) AND type = ? AND (state = ? OR state = ?)", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).
Scan(&spent)
if res.Error != nil {
return 0, res.Error
}
return received.Sum - spent.Sum, nil
}

View file

@ -0,0 +1,64 @@
package queries
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/tests"
)
func TestGetTotalSubwalletBalance(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
subwalletA, _, err := tests.CreateApp(svc)
require.NoError(t, err)
subwalletA.Isolated = true
subwalletA.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID)))
svc.DB.Save(&subwalletA)
subwalletB, _, err := tests.CreateApp(svc)
require.NoError(t, err)
subwalletB.Isolated = true
subwalletB.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID)))
svc.DB.Save(&subwalletB)
incomingSubwalletTx := db.Transaction{
AppId: &subwalletA.ID,
Type: constants.TRANSACTION_TYPE_INCOMING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 5000,
}
svc.DB.Save(&incomingSubwalletTx)
outgoingSettledSubwalletTx := db.Transaction{
AppId: &subwalletA.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 1000,
FeeMsat: 100,
FeeReserveMsat: 0,
}
svc.DB.Save(&outgoingSettledSubwalletTx)
outgoingPendingSubwalletTx := db.Transaction{
AppId: &subwalletB.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 2000,
FeeMsat: 0,
FeeReserveMsat: 300,
}
svc.DB.Save(&outgoingPendingSubwalletTx)
total, err := GetTotalSubwalletBalance(svc.DB)
require.NoError(t, err)
assert.Equal(t, int64(1600), total)
}

View file

@ -12,6 +12,7 @@ export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
export const LIST_TRANSACTIONS_LIMIT = 20; export const LIST_TRANSACTIONS_LIMIT = 20;
export const LIST_APPS_LIMIT = 20; export const LIST_APPS_LIMIT = 20;
export const MAX_FREE_SUBWALLETS = 3;
export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`; export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;
export const SUPPORT_ALBY_LIGHTNING_ADDRESS = "hub@getalby.com"; export const SUPPORT_ALBY_LIGHTNING_ADDRESS = "hub@getalby.com";

View file

@ -8,7 +8,7 @@ import ResponsiveExternalLinkButton from "src/components/ResponsiveExternalLinkB
import { LoadingButton } from "src/components/ui/custom/loading-button"; import { LoadingButton } from "src/components/ui/custom/loading-button";
import { Input } from "src/components/ui/input"; import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label"; import { Label } from "src/components/ui/label";
import { SUBWALLET_APPSTORE_APP_ID } from "src/constants"; import { MAX_FREE_SUBWALLETS, SUBWALLET_APPSTORE_APP_ID } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps"; import { useApps } from "src/hooks/useApps";
import { useInfo } from "src/hooks/useInfo"; import { useInfo } from "src/hooks/useInfo";
@ -19,11 +19,11 @@ import { handleRequestError } from "src/utils/handleRequestError";
export function NewSubwallet() { export function NewSubwallet() {
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
const { data: appsData } = useApps( const { data: subwalletAppsData } = useApps(
undefined, undefined,
undefined, undefined,
{ {
appStoreAppId: SUBWALLET_APPSTORE_APP_ID, subWallets: true,
}, },
"created_at" "created_at"
); );
@ -34,20 +34,21 @@ export function NewSubwallet() {
if ( if (
!info || !info ||
!appsData || !subwalletAppsData ||
(info.albyAccountConnected && !albyMe && !albyMeError) (info.albyAccountConnected && !albyMe && !albyMeError)
) { ) {
return <Loading />; return <Loading />;
} }
const subwalletApps = appsData?.apps;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setLoading(true); setLoading(true);
try { try {
if (!albyMe?.subscription.plan_code && subwalletApps?.length >= 3) { if (
!albyMe?.subscription.plan_code &&
subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS
) {
throw new Error( throw new Error(
"Max limit reached. Please upgrade to Pro to create more sub-wallets." "Max limit reached. Please upgrade to Pro to create more sub-wallets."
); );

View file

@ -27,7 +27,7 @@ import {
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { LinkButton } from "src/components/ui/custom/link-button"; import { LinkButton } from "src/components/ui/custom/link-button";
import { UpgradeDialog } from "src/components/UpgradeDialog"; import { UpgradeDialog } from "src/components/UpgradeDialog";
import { LIST_APPS_LIMIT, SUBWALLET_APPSTORE_APP_ID } from "src/constants"; import { LIST_APPS_LIMIT, MAX_FREE_SUBWALLETS } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps"; import { useApps } from "src/hooks/useApps";
import { useBalances } from "src/hooks/useBalances"; import { useBalances } from "src/hooks/useBalances";
@ -38,11 +38,11 @@ export function SubwalletList() {
const { data: info } = useInfo(); const { data: info } = useInfo();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const appsListRef = useRef<HTMLDivElement>(null); const appsListRef = useRef<HTMLDivElement>(null);
const { data: appsData } = useApps( const { data: subwalletAppsData } = useApps(
undefined, undefined,
page, page,
{ {
appStoreAppId: SUBWALLET_APPSTORE_APP_ID, subWallets: true,
}, },
"created_at" "created_at"
); );
@ -59,21 +59,20 @@ export function SubwalletList() {
if ( if (
!info || !info ||
!appsData || !subwalletAppsData ||
!balances || !balances ||
(info.albyAccountConnected && !albyMe && !albyMeError) (info.albyAccountConnected && !albyMe && !albyMeError)
) { ) {
return <Loading />; return <Loading />;
} }
const subwalletApps = appsData.apps; const subwalletApps = subwalletAppsData.apps;
if (!subwalletApps.length) { if (!subwalletAppsData.totalCount) {
return <SubwalletIntro />; return <SubwalletIntro />;
} }
const subwalletTotalAmount = const subwalletTotalAmount = subwalletAppsData.totalBalance || 0;
subwalletApps.reduce((total, app) => total + app.balance, 0) || 0;
const isSufficientlyBacked = const isSufficientlyBacked =
subwalletTotalAmount <= balances.lightning.totalSpendable; subwalletTotalAmount <= balances.lightning.totalSpendable;
@ -91,7 +90,8 @@ export function SubwalletList() {
> >
<HelpCircle className="size-4" /> <HelpCircle className="size-4" />
</ExternalLinkButton> </ExternalLinkButton>
{!albyMe?.subscription.plan_code && subwalletApps?.length >= 3 ? ( {!albyMe?.subscription.plan_code &&
subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS ? (
<UpgradeDialog> <UpgradeDialog>
<ResponsiveButton icon={CirclePlusIcon} text="New Sub-wallet" /> <ResponsiveButton icon={CirclePlusIcon} text="New Sub-wallet" />
</UpgradeDialog> </UpgradeDialog>
@ -106,26 +106,27 @@ export function SubwalletList() {
} }
/> />
{!albyMe?.subscription.plan_code && subwalletApps.length >= 3 && ( {!albyMe?.subscription.plan_code &&
<> subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS && (
<Alert> <>
<InfoIcon /> <Alert>
<AlertTitle>Need more Sub-wallets?</AlertTitle> <InfoIcon />
<AlertDescription className="flex flex-row gap-3"> <AlertTitle>Need more Sub-wallets?</AlertTitle>
<p className="grow"> <AlertDescription className="flex flex-row gap-3">
Upgrade your subscription plan to Pro unlock unlimited number of <p className="grow">
Sub-wallets. Upgrade your subscription plan to Pro unlock unlimited number
</p> of Sub-wallets.
<UpgradeDialog> </p>
<Button> <UpgradeDialog>
<SparklesIcon /> <Button>
Upgrade <SparklesIcon />
</Button> Upgrade
</UpgradeDialog> </Button>
</AlertDescription> </UpgradeDialog>
</Alert> </AlertDescription>
</> </Alert>
)} </>
)}
{!isSufficientlyBacked && ( {!isSufficientlyBacked && (
<Alert variant="warning"> <Alert variant="warning">
@ -168,8 +169,8 @@ export function SubwalletList() {
<CardContent className="grow flex flex-col gap-4"> <CardContent className="grow flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-2xl font-medium"> <span className="text-2xl font-medium">
{subwalletApps.length} /{" "} {subwalletAppsData.totalCount} /{" "}
{albyMe?.subscription.plan_code ? "∞" : 3} {albyMe?.subscription.plan_code ? "∞" : MAX_FREE_SUBWALLETS}
</span> </span>
{isSufficientlyBacked ? ( {isSufficientlyBacked ? (
<div className="flex items-center text-positive-foreground text-sm"> <div className="flex items-center text-positive-foreground text-sm">
@ -202,7 +203,7 @@ export function SubwalletList() {
<CustomPagination <CustomPagination
limit={LIST_APPS_LIMIT} limit={LIST_APPS_LIMIT}
totalCount={appsData.totalCount} totalCount={subwalletAppsData.totalCount}
page={page} page={page}
handlePageChange={handlePageChange} handlePageChange={handlePageChange}
/> />

View file

@ -640,6 +640,7 @@ export type OnchainTransaction = {
export type ListAppsResponse = { export type ListAppsResponse = {
apps: App[]; apps: App[];
totalCount: number; totalCount: number;
totalBalance?: number;
}; };
export type ListTransactionsResponse = { export type ListTransactionsResponse = {

View file

@ -91,7 +91,7 @@ func (controller *nip47Controller) HandleGetInfoEvent(ctx context.Context, nip47
if !app.Isolated { if !app.Isolated {
lightningAddress, _ := controller.albyOAuthService.GetLightningAddress() lightningAddress, _ := controller.albyOAuthService.GetLightningAddress()
responsePayload.LightningAddress = &lightningAddress responsePayload.LightningAddress = &lightningAddress
} else if metadata["app_store_app_id"] == constants.SUBWALLET_APPSTORE_APP_ID && metadata["lud16"] != nil { } else if metadata[constants.METADATA_APPSTORE_APP_ID_KEY] == constants.SUBWALLET_APPSTORE_APP_ID && metadata["lud16"] != nil {
lightningAddress := metadata["lud16"].(string) lightningAddress := metadata["lud16"].(string)
responsePayload.LightningAddress = &lightningAddress responsePayload.LightningAddress = &lightningAddress
} }

View file

@ -85,8 +85,8 @@ func TestHandleGetInfoEvent_SubwalletNoPermission(t *testing.T) {
lightningAddress := "hello@getalby.com" lightningAddress := "hello@getalby.com"
metadata := map[string]interface{}{ metadata := map[string]interface{}{
"app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY: constants.SUBWALLET_APPSTORE_APP_ID,
"lud16": lightningAddress, "lud16": lightningAddress,
} }
svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "") svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "")
@ -248,9 +248,9 @@ func TestHandleGetInfoEvent_SubwalletWithMetadata(t *testing.T) {
lightningAddress := "hello@getalby.com" lightningAddress := "hello@getalby.com"
metadata := map[string]interface{}{ metadata := map[string]interface{}{
"app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY: constants.SUBWALLET_APPSTORE_APP_ID,
"lud16": lightningAddress, "lud16": lightningAddress,
"a": 123, "a": 123,
} }
svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "") svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "")