From a2af8fd598ef43eba6c2812151e7f66f5af35563 Mon Sep 17 00:00:00 2001 From: Adithya Vardhan Date: Thu, 26 Feb 2026 12:25:06 +0530 Subject: [PATCH] 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 --- api/api.go | 34 +++++++--- api/models.go | 5 +- constants/constants.go | 2 + db/queries/get_total_subwallet_balance.go | 40 ++++++++++++ .../get_total_subwallet_balance_test.go | 64 ++++++++++++++++++ frontend/src/constants.ts | 1 + .../src/screens/subwallets/NewSubwallet.tsx | 15 +++-- .../src/screens/subwallets/SubwalletList.tsx | 65 ++++++++++--------- frontend/src/types.ts | 1 + nip47/controllers/get_info_controller.go | 2 +- nip47/controllers/get_info_controller_test.go | 10 +-- 11 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 db/queries/get_total_subwallet_balance.go create mode 100644 db/queries/get_total_subwallet_balance_test.go diff --git a/api/api.go b/api/api.go index 4bfebe1d..d6ca8d6d 100644 --- a/api/api.go +++ b/api/api.go @@ -177,7 +177,7 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e }).Error("Failed to deserialize app metadata") 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") } } @@ -487,7 +487,7 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o } 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 { @@ -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)) } - if filters.SubWallets != nil && !*filters.SubWallets { - // exclude subwallets :scream: - if api.db.Dialector.Name() == "sqlite" { - 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) + if filters.SubWallets != nil { + if *filters.SubWallets { + query = query.Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY)) } 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") 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)) err := query.Find(&dbApps).Error @@ -598,8 +613,9 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o apiApps = append(apiApps, apiApp) } return &ListAppsResponse{ - Apps: apiApps, - TotalCount: uint64(totalCount), + Apps: apiApps, + TotalCount: uint64(totalCount), + TotalBalance: totalBalance, }, nil } diff --git a/api/models.go b/api/models.go index 7d0f544e..3d7f1025 100644 --- a/api/models.go +++ b/api/models.go @@ -113,8 +113,9 @@ type ListAppsFilters struct { } type ListAppsResponse struct { - Apps []App `json:"apps"` - TotalCount uint64 `json:"totalCount"` + Apps []App `json:"apps"` + TotalCount uint64 `json:"totalCount"` + TotalBalance *int64 `json:"totalBalance,omitempty"` } type UpdateAppRequest struct { diff --git a/constants/constants.go b/constants/constants.go index f24553d2..97c941f4 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -76,6 +76,8 @@ const ( ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2" ) +const METADATA_APPSTORE_APP_ID_KEY = "app_store_app_id" + const SUBWALLET_APPSTORE_APP_ID = "uncle-jim" const ( diff --git a/db/queries/get_total_subwallet_balance.go b/db/queries/get_total_subwallet_balance.go new file mode 100644 index 00000000..42d82a4a --- /dev/null +++ b/db/queries/get_total_subwallet_balance.go @@ -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 +} diff --git a/db/queries/get_total_subwallet_balance_test.go b/db/queries/get_total_subwallet_balance_test.go new file mode 100644 index 00000000..150c7557 --- /dev/null +++ b/db/queries/get_total_subwallet_balance_test.go @@ -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) +} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index eef39ccc..65e8a1f6 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,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 SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`; export const SUPPORT_ALBY_LIGHTNING_ADDRESS = "hub@getalby.com"; diff --git a/frontend/src/screens/subwallets/NewSubwallet.tsx b/frontend/src/screens/subwallets/NewSubwallet.tsx index cda47054..0a80443e 100644 --- a/frontend/src/screens/subwallets/NewSubwallet.tsx +++ b/frontend/src/screens/subwallets/NewSubwallet.tsx @@ -8,7 +8,7 @@ import ResponsiveExternalLinkButton from "src/components/ResponsiveExternalLinkB import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Input } from "src/components/ui/input"; 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 { useApps } from "src/hooks/useApps"; import { useInfo } from "src/hooks/useInfo"; @@ -19,11 +19,11 @@ import { handleRequestError } from "src/utils/handleRequestError"; export function NewSubwallet() { const navigate = useNavigate(); const [name, setName] = React.useState(""); - const { data: appsData } = useApps( + const { data: subwalletAppsData } = useApps( undefined, undefined, { - appStoreAppId: SUBWALLET_APPSTORE_APP_ID, + subWallets: true, }, "created_at" ); @@ -34,20 +34,21 @@ export function NewSubwallet() { if ( !info || - !appsData || + !subwalletAppsData || (info.albyAccountConnected && !albyMe && !albyMeError) ) { return ; } - const subwalletApps = appsData?.apps; - const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setLoading(true); try { - if (!albyMe?.subscription.plan_code && subwalletApps?.length >= 3) { + if ( + !albyMe?.subscription.plan_code && + subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS + ) { throw new Error( "Max limit reached. Please upgrade to Pro to create more sub-wallets." ); diff --git a/frontend/src/screens/subwallets/SubwalletList.tsx b/frontend/src/screens/subwallets/SubwalletList.tsx index 240761c4..0d57d318 100644 --- a/frontend/src/screens/subwallets/SubwalletList.tsx +++ b/frontend/src/screens/subwallets/SubwalletList.tsx @@ -27,7 +27,7 @@ import { import { ExternalLinkButton } from "src/components/ui/custom/external-link-button"; import { LinkButton } from "src/components/ui/custom/link-button"; 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 { useApps } from "src/hooks/useApps"; import { useBalances } from "src/hooks/useBalances"; @@ -38,11 +38,11 @@ export function SubwalletList() { const { data: info } = useInfo(); const [page, setPage] = useState(1); const appsListRef = useRef(null); - const { data: appsData } = useApps( + const { data: subwalletAppsData } = useApps( undefined, page, { - appStoreAppId: SUBWALLET_APPSTORE_APP_ID, + subWallets: true, }, "created_at" ); @@ -59,21 +59,20 @@ export function SubwalletList() { if ( !info || - !appsData || + !subwalletAppsData || !balances || (info.albyAccountConnected && !albyMe && !albyMeError) ) { return ; } - const subwalletApps = appsData.apps; + const subwalletApps = subwalletAppsData.apps; - if (!subwalletApps.length) { + if (!subwalletAppsData.totalCount) { return ; } - const subwalletTotalAmount = - subwalletApps.reduce((total, app) => total + app.balance, 0) || 0; + const subwalletTotalAmount = subwalletAppsData.totalBalance || 0; const isSufficientlyBacked = subwalletTotalAmount <= balances.lightning.totalSpendable; @@ -91,7 +90,8 @@ export function SubwalletList() { > - {!albyMe?.subscription.plan_code && subwalletApps?.length >= 3 ? ( + {!albyMe?.subscription.plan_code && + subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS ? ( @@ -106,26 +106,27 @@ export function SubwalletList() { } /> - {!albyMe?.subscription.plan_code && subwalletApps.length >= 3 && ( - <> - - - Need more Sub-wallets? - -

- Upgrade your subscription plan to Pro unlock unlimited number of - Sub-wallets. -

- - - -
-
- - )} + {!albyMe?.subscription.plan_code && + subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS && ( + <> + + + Need more Sub-wallets? + +

+ Upgrade your subscription plan to Pro unlock unlimited number + of Sub-wallets. +

+ + + +
+
+ + )} {!isSufficientlyBacked && ( @@ -168,8 +169,8 @@ export function SubwalletList() {
- {subwalletApps.length} /{" "} - {albyMe?.subscription.plan_code ? "∞" : 3} + {subwalletAppsData.totalCount} /{" "} + {albyMe?.subscription.plan_code ? "∞" : MAX_FREE_SUBWALLETS} {isSufficientlyBacked ? (
@@ -202,7 +203,7 @@ export function SubwalletList() { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6b1930dd..cdf4e8a6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -640,6 +640,7 @@ export type OnchainTransaction = { export type ListAppsResponse = { apps: App[]; totalCount: number; + totalBalance?: number; }; export type ListTransactionsResponse = { diff --git a/nip47/controllers/get_info_controller.go b/nip47/controllers/get_info_controller.go index 5800a266..a81ab112 100644 --- a/nip47/controllers/get_info_controller.go +++ b/nip47/controllers/get_info_controller.go @@ -91,7 +91,7 @@ func (controller *nip47Controller) HandleGetInfoEvent(ctx context.Context, nip47 if !app.Isolated { lightningAddress, _ := controller.albyOAuthService.GetLightningAddress() 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) responsePayload.LightningAddress = &lightningAddress } diff --git a/nip47/controllers/get_info_controller_test.go b/nip47/controllers/get_info_controller_test.go index d3bf0c6d..73289873 100644 --- a/nip47/controllers/get_info_controller_test.go +++ b/nip47/controllers/get_info_controller_test.go @@ -85,8 +85,8 @@ func TestHandleGetInfoEvent_SubwalletNoPermission(t *testing.T) { lightningAddress := "hello@getalby.com" metadata := map[string]interface{}{ - "app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID, - "lud16": lightningAddress, + constants.METADATA_APPSTORE_APP_ID_KEY: constants.SUBWALLET_APPSTORE_APP_ID, + "lud16": lightningAddress, } svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "") @@ -248,9 +248,9 @@ func TestHandleGetInfoEvent_SubwalletWithMetadata(t *testing.T) { lightningAddress := "hello@getalby.com" metadata := map[string]interface{}{ - "app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID, - "lud16": lightningAddress, - "a": 123, + constants.METADATA_APPSTORE_APP_ID_KEY: constants.SUBWALLET_APPSTORE_APP_ID, + "lud16": lightningAddress, + "a": 123, } svc.Cfg.SetUpdate("LNBackendType", config.LDKBackendType, "")