diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/_components/profile-wallet-hbd-interest.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/_components/profile-wallet-hbd-interest.tsx
index 147393f825..25ce7edcc9 100644
--- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/_components/profile-wallet-hbd-interest.tsx
+++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/(token)/_components/profile-wallet-hbd-interest.tsx
@@ -11,21 +11,17 @@ import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import i18next from "i18next";
import { useMemo } from "react";
-import { dayjs, formattedNumber, parseAsset, secondDiff } from "@/utils";
+import {
+ formattedNumber,
+ getHbdSavingsInterestState,
+ MINIMUM_HBD_SAVINGS_AMOUNT
+} from "@/utils";
interface Props {
username: string;
className?: string;
}
-// Hive stores HBD balances with three decimal places, so accruing interest
-// effectively requires holding at least 0.001 HBD in savings. Below that
-// threshold there are no satoshis to accumulate seconds against.
-const MINIMUM_SAVINGS_BALANCE = 0.001;
-const INTEREST_INTERVAL_DAYS = 30;
-const SECONDS_PER_YEAR = 365 * 24 * 60 * 60;
-const UNIX_EPOCH = "1970-01-01T00:00:00";
-
export function ProfileWalletHbdInterest({ username, className }: Props) {
const { activeUser } = useActiveAccount();
const isOwnProfile = activeUser?.username === username;
@@ -44,91 +40,42 @@ export function ProfileWalletHbdInterest({ username, className }: Props) {
const aprAnnualPercent = useMemo(() => hbdInterestRate / 100, [hbdInterestRate]);
- const savingsBalance = useMemo(() => {
- const balanceString = account?.savings_hbd_balance ?? "0.000 HBD";
- return parseAsset(balanceString).amount;
- }, [account?.savings_hbd_balance]);
-
- const trackedHbdSeconds = useMemo(() => {
- const value = account?.savings_hbd_seconds;
-
- if (typeof value === "number" && Number.isFinite(value)) {
- return value / 1000;
- }
-
- if (typeof value === "string") {
- const parsed = Number.parseFloat(value);
- if (Number.isFinite(parsed)) {
- return parsed / 1000;
- }
- }
-
- return 0;
- }, [account?.savings_hbd_seconds]);
-
- const lastUpdate = useMemo(() => {
- const value = account?.savings_hbd_seconds_last_update;
- if (!value || value === UNIX_EPOCH) {
- return null;
- }
-
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed : null;
- }, [account?.savings_hbd_seconds_last_update]);
-
- const lastInterestPayment = useMemo(() => {
- const value = account?.savings_hbd_last_interest_payment;
- if (!value || value === UNIX_EPOCH) {
- return null;
- }
- const parsed = dayjs(value);
- return parsed.isValid() ? parsed : null;
- }, [account?.savings_hbd_last_interest_payment]);
-
- const now = dayjs();
-
- const claimReferenceDate = lastInterestPayment ?? lastUpdate;
-
- const secondsSinceLastUpdate = useMemo(() => {
- const value = account?.savings_hbd_seconds_last_update;
- if (!value || value === UNIX_EPOCH) {
- return 0;
- }
-
- return secondDiff(value);
- }, [account?.savings_hbd_seconds_last_update]);
-
- const pendingSeconds = savingsBalance * secondsSinceLastUpdate;
- const secondsToEstimate = trackedHbdSeconds + pendingSeconds;
-
- const pendingInterest = useMemo(() => {
- if (hbdInterestRate <= 0) {
- return 0;
- }
-
- const aprDecimal = hbdInterestRate / 10000;
- return (secondsToEstimate / SECONDS_PER_YEAR) * aprDecimal;
- }, [hbdInterestRate, secondsToEstimate]);
+ const {
+ savingsBalance,
+ pendingInterest,
+ hasSavingsBalance,
+ hasPendingInterest,
+ isEmpty,
+ nextClaimDate,
+ needsDepositToClaim,
+ canClaim
+ } = useMemo(
+ () =>
+ getHbdSavingsInterestState({
+ savingsHbdBalance: account?.savings_hbd_balance,
+ savingsHbdSeconds: account?.savings_hbd_seconds,
+ savingsHbdSecondsLastUpdate: account?.savings_hbd_seconds_last_update,
+ savingsHbdLastInterestPayment: account?.savings_hbd_last_interest_payment,
+ hbdInterestRate
+ }),
+ [
+ account?.savings_hbd_balance,
+ account?.savings_hbd_seconds,
+ account?.savings_hbd_seconds_last_update,
+ account?.savings_hbd_last_interest_payment,
+ hbdInterestRate
+ ]
+ );
const pendingInterestDisplay = formattedNumber(pendingInterest);
- const hasPendingInterest = pendingInterest >= MINIMUM_SAVINGS_BALANCE;
-
- const nextClaimDate = claimReferenceDate
- ? claimReferenceDate.add(INTEREST_INTERVAL_DAYS, "day")
- : null;
-
- const hasMinimumBalance = savingsBalance >= MINIMUM_SAVINGS_BALANCE;
- const canClaim = Boolean(
- hasMinimumBalance &&
- nextClaimDate &&
- now.isAfter(nextClaimDate) &&
- hasPendingInterest
- );
const nextClaimDescription = (() => {
- if (!hasMinimumBalance) {
- return i18next.t("profile-wallet.hbd-interest.minimum-balance", {
- amount: MINIMUM_SAVINGS_BALANCE.toFixed(3),
+ // Interest keeps accruing on the banked balance-seconds even after the
+ // savings balance is emptied, but releasing it means transferring 0.001 HBD
+ // back out of savings, so it stays stuck until something is deposited.
+ if (needsDepositToClaim) {
+ return i18next.t("profile-wallet.hbd-interest.deposit-to-claim", {
+ amount: MINIMUM_HBD_SAVINGS_AMOUNT.toFixed(3),
});
}
@@ -147,27 +94,21 @@ export function ProfileWalletHbdInterest({ username, className }: Props) {
const nextClaimExact = nextClaimDate?.format("LLL");
- const helperText = hasMinimumBalance
+ const helperText = hasSavingsBalance
? i18next.t("profile-wallet.hbd-interest.note", {
apr: aprAnnualPercent.toFixed(3),
})
- : undefined;
+ : i18next.t("profile-wallet.hbd-interest.minimum-balance", {
+ amount: MINIMUM_HBD_SAVINGS_AMOUNT.toFixed(3),
+ });
- if (savingsBalance < MINIMUM_SAVINGS_BALANCE) {
+ // Nothing saved and nothing accrued: there is no estimate worth a card. A
+ // zero savings balance on its own is not enough to hide it, because the
+ // interest already earned on it is still owed and still claimable.
+ if (isEmpty) {
return null;
}
- const claimButton = (
-
- );
-
return (
- {isOwnProfile && canClaim ? (
+ {/* Only offered once there is interest to collect: below 0.001 HBD the
+ chain has nothing to pay out, so the button would always fail. */}
+ {isOwnProfile && hasPendingInterest && (
- {claimButton}
+
- ) : (
- claimButton
)}
@@ -220,9 +168,7 @@ export function ProfileWalletHbdInterest({ username, className }: Props) {
{savingsBalance.toFixed(3)} HBD
- {helperText && (
-
{helperText}
- )}
+
{helperText}
diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json
index dfe6e2031a..6a901d12ef 100644
--- a/apps/web/src/features/i18n/locales/en-US.json
+++ b/apps/web/src/features/i18n/locales/en-US.json
@@ -3853,6 +3853,7 @@
"next-ready": "Ready to claim now",
"next-in": "Available {{relative}}",
"next-unknown": "Interest schedule unavailable",
+ "deposit-to-claim": "Deposit at least {{amount}} HBD to savings to release this interest.",
"next-date": "On {{date}}",
"balance-label": "Savings balance",
"minimum-balance": "Interest accrues once you hold at least {{amount}} HBD in savings.",
diff --git a/apps/web/src/specs/features/wallet/profile-wallet-hbd-interest.spec.tsx b/apps/web/src/specs/features/wallet/profile-wallet-hbd-interest.spec.tsx
new file mode 100644
index 0000000000..4efb00beb1
--- /dev/null
+++ b/apps/web/src/specs/features/wallet/profile-wallet-hbd-interest.spec.tsx
@@ -0,0 +1,175 @@
+import "@testing-library/jest-dom";
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import dayjs from "@/utils/dayjs";
+import { createTestQueryClient, renderWithQueryClient } from "@/specs/test-utils";
+
+// The global @/utils mock exports only `random` and `getAccessToken`; the card
+// reads formattedNumber and the interest helper through the same barrel.
+vi.mock("@/utils", async () => ({
+ ...(await vi.importActual("@/utils")),
+ random: vi.fn(),
+ getAccessToken: vi.fn(() => "mock-token")
+}));
+
+let account: Record | null = null;
+let activeUser: { username: string } | null = null;
+
+// Hand the queries their data directly: the real query functions would reach a
+// Hive node, and what is under test is what the card renders from an account
+// row, not how the row is fetched.
+vi.mock("@ecency/sdk", async () => {
+ const actual = await vi.importActual("@ecency/sdk");
+ return {
+ ...actual,
+ // Keep the real query options and swap only the fetch. The cache key is
+ // then the app's own, not a literal that could drift from it, and the
+ // seeding below has to agree with QueryKeys or every assertion fails.
+ getAccountFullQueryOptions: (username: string) => ({
+ ...actual.getAccountFullQueryOptions(username),
+ queryFn: async () => account
+ }),
+ getDynamicPropsQueryOptions: () => ({
+ ...actual.getDynamicPropsQueryOptions(),
+ // hbd_interest_rate is in basis points: 1000 is the 10% APR in force.
+ queryFn: async () => ({ hbdInterestRate: 1000 })
+ })
+ };
+});
+
+vi.mock("@/core/hooks/use-active-account", () => ({
+ useActiveAccount: () => ({ activeUser })
+}));
+
+// The dialog only wraps the trigger; mounting the real one drags in the whole
+// broadcast stack for no gain here.
+vi.mock("@/features/wallet", () => ({
+ WalletOperationsDialog: ({ children }: { children: React.ReactNode }) =>
{children}
+}));
+
+const { QueryKeys } = await import("@ecency/sdk");
+const { ProfileWalletHbdInterest } = await import(
+ "@/app/(dynamicPages)/profile/[username]/wallet/(token)/_components/profile-wallet-hbd-interest"
+);
+
+const chainTime = (daysAgo: number) =>
+ dayjs().subtract(daysAgo, "day").utc().format("YYYY-MM-DDTHH:mm:ss");
+
+function setAccount(fields: Partial>) {
+ account = {
+ name: "alice",
+ savings_hbd_balance: "0.000 HBD",
+ savings_hbd_seconds: 0,
+ savings_hbd_seconds_last_update: chainTime(0),
+ savings_hbd_last_interest_payment: chainTime(0),
+ ...fields
+ };
+}
+
+function renderCard() {
+ // Seed both caches before mounting so the first render already has the
+ // account row. Otherwise an assertion that the card renders NOTHING would
+ // pass simply because the queries had not resolved yet.
+ const queryClient = createTestQueryClient();
+ queryClient.setQueryData(QueryKeys.accounts.full("alice"), account);
+ queryClient.setQueryData(QueryKeys.core.dynamicProps(), { hbdInterestRate: 1000 });
+
+ return renderWithQueryClient(, { queryClient });
+}
+
+describe("ProfileWalletHbdInterest", () => {
+ beforeEach(() => {
+ activeUser = { username: "alice" };
+ setAccount({});
+ });
+
+ test("shows what a claim would release, with the button, once interest is due", () => {
+ // 100 HBD held for 60 days at 10% APR.
+ setAccount({
+ savings_hbd_balance: "100.000 HBD",
+ savings_hbd_seconds_last_update: chainTime(60),
+ savings_hbd_last_interest_payment: chainTime(60)
+ });
+
+ renderCard();
+
+ expect(screen.getByText(/^1\.64\d HBD$/)).toBeInTheDocument();
+ const claim = screen.getByRole("button", {
+ name: "profile-wallet.hbd-interest.claim-button"
+ });
+ expect(claim).toBeEnabled();
+ });
+
+ test("shows the estimate but offers no claim before anything has accrued", () => {
+ setAccount({ savings_hbd_balance: "50.000 HBD" });
+
+ renderCard();
+
+ expect(screen.getByText("0.000 HBD")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "profile-wallet.hbd-interest.claim-button" })
+ ).not.toBeInTheDocument();
+ });
+
+ test("offers no claim while interest is accrued but the 30 day interval has not passed", () => {
+ setAccount({
+ savings_hbd_balance: "100.000 HBD",
+ savings_hbd_seconds_last_update: chainTime(10),
+ savings_hbd_last_interest_payment: chainTime(10)
+ });
+
+ renderCard();
+
+ expect(screen.getByText(/^0\.27\d HBD$/)).toBeInTheDocument();
+ // There is interest, so the button is offered, but the chain will not pay
+ // out yet.
+ expect(
+ screen.getByRole("button", { name: "profile-wallet.hbd-interest.claim-button" })
+ ).toBeDisabled();
+ });
+
+ test("keeps showing interest banked before the savings balance was emptied", () => {
+ // Regression: hiding the card on savings balance alone took the estimate
+ // with it, so accrued interest became invisible.
+ setAccount({
+ savings_hbd_balance: "0.000 HBD",
+ savings_hbd_seconds: 23901200496,
+ savings_hbd_seconds_last_update: chainTime(140),
+ savings_hbd_last_interest_payment: chainTime(150)
+ });
+
+ renderCard();
+
+ expect(screen.getByText("0.075 HBD")).toBeInTheDocument();
+ expect(
+ screen.getByText("profile-wallet.hbd-interest.deposit-to-claim")
+ ).toBeInTheDocument();
+ // Claiming broadcasts a transfer out of savings, which an empty balance
+ // cannot cover.
+ expect(
+ screen.getByRole("button", { name: "profile-wallet.hbd-interest.claim-button" })
+ ).toBeDisabled();
+ });
+
+ test("renders nothing when there is neither a balance nor accrued interest", () => {
+ const { container } = renderCard();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ test("never offers the claim on someone else's wallet", () => {
+ activeUser = { username: "bob" };
+ setAccount({
+ savings_hbd_balance: "100.000 HBD",
+ savings_hbd_seconds_last_update: chainTime(60),
+ savings_hbd_last_interest_payment: chainTime(60)
+ });
+
+ renderCard();
+
+ expect(screen.getByText(/^1\.64\d HBD$/)).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "profile-wallet.hbd-interest.claim-button" })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/specs/utils/hbd-savings-accounts.fixture.json b/apps/web/src/specs/utils/hbd-savings-accounts.fixture.json
new file mode 100644
index 0000000000..f29e018f5e
--- /dev/null
+++ b/apps/web/src/specs/utils/hbd-savings-accounts.fixture.json
@@ -0,0 +1,440 @@
+{
+ "capturedAt": "2026-08-23T09:00:51",
+ "hbdInterestRate": 1000,
+ "accounts": [
+ {
+ "name": "a-blockchain",
+ "savings_hbd_balance": "2.000 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-04-12T16:05:45",
+ "savings_hbd_last_interest_payment": "2018-04-12T16:05:45"
+ },
+ {
+ "name": "a-condor",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "166500",
+ "savings_hbd_seconds_last_update": "2017-12-04T20:28:03",
+ "savings_hbd_last_interest_payment": "2017-12-04T20:26:12"
+ },
+ {
+ "name": "c-mon",
+ "savings_hbd_balance": "0.200 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2019-02-06T14:15:30",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "d-a-d",
+ "savings_hbd_balance": "72.118 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-01-24T08:57:54",
+ "savings_hbd_last_interest_payment": "2025-01-24T08:57:54"
+ },
+ {
+ "name": "d-company",
+ "savings_hbd_balance": "0.518 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2026-07-28T03:39:33",
+ "savings_hbd_last_interest_payment": "2026-07-28T03:39:33"
+ },
+ {
+ "name": "d-hive",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "1844050638102",
+ "savings_hbd_seconds_last_update": "2025-01-05T02:11:15",
+ "savings_hbd_last_interest_payment": "2024-12-17T09:37:36"
+ },
+ {
+ "name": "e-haxker",
+ "savings_hbd_balance": "0.159 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2017-12-25T08:38:24",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "e-j",
+ "savings_hbd_balance": "2.000 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-11-04T19:40:00",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "e-mc2",
+ "savings_hbd_balance": "0.041 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-05-17T19:01:45",
+ "savings_hbd_last_interest_payment": "2025-05-17T19:01:45"
+ },
+ {
+ "name": "e-musik",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "722370114",
+ "savings_hbd_seconds_last_update": "2022-08-22T18:57:30",
+ "savings_hbd_last_interest_payment": "2022-08-08T21:57:03"
+ },
+ {
+ "name": "e-r-k-a-n",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "83616395184",
+ "savings_hbd_seconds_last_update": "2022-03-04T20:56:51",
+ "savings_hbd_last_interest_payment": "2022-02-04T13:15:21"
+ },
+ {
+ "name": "e-rich",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "8027094",
+ "savings_hbd_seconds_last_update": "2025-03-25T14:53:57",
+ "savings_hbd_last_interest_payment": "2025-03-25T14:52:15"
+ },
+ {
+ "name": "e-s",
+ "savings_hbd_balance": "5.885 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-01-08T01:58:36",
+ "savings_hbd_last_interest_payment": "2025-01-08T01:58:36"
+ },
+ {
+ "name": "e-sport-gamer",
+ "savings_hbd_balance": "5.309 HBD",
+ "savings_hbd_seconds": "95562",
+ "savings_hbd_seconds_last_update": "2026-04-01T18:22:15",
+ "savings_hbd_last_interest_payment": "2026-04-01T18:21:57"
+ },
+ {
+ "name": "e-sport-girly",
+ "savings_hbd_balance": "5.296 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2026-04-01T18:22:54",
+ "savings_hbd_last_interest_payment": "2026-04-01T18:22:54"
+ },
+ {
+ "name": "f-g",
+ "savings_hbd_balance": "1.000 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-11-04T19:37:06",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "f0c0l1bert4r10",
+ "savings_hbd_balance": "0.236 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2021-10-15T04:04:39",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "f0rtunate",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "10877334048",
+ "savings_hbd_seconds_last_update": "2021-12-22T03:45:42",
+ "savings_hbd_last_interest_payment": "2021-11-24T18:46:30"
+ },
+ {
+ "name": "f10rrn",
+ "savings_hbd_balance": "0.190 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2017-08-14T13:14:42",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "g-h",
+ "savings_hbd_balance": "0.005 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2026-08-09T13:28:24",
+ "savings_hbd_last_interest_payment": "2026-08-09T13:28:24"
+ },
+ {
+ "name": "g-lug",
+ "savings_hbd_balance": "29.945 HBD",
+ "savings_hbd_seconds": "23185837215",
+ "savings_hbd_seconds_last_update": "2022-01-11T22:57:03",
+ "savings_hbd_last_interest_payment": "2021-12-17T00:40:27"
+ },
+ {
+ "name": "g-race-c",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "864402",
+ "savings_hbd_seconds_last_update": "2023-04-13T21:33:54",
+ "savings_hbd_last_interest_payment": "2023-04-13T21:33:33"
+ },
+ {
+ "name": "g-tech",
+ "savings_hbd_balance": "0.034 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-04-03T15:16:27",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "h-hamilton",
+ "savings_hbd_balance": "2.241 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-09-21T12:24:24",
+ "savings_hbd_last_interest_payment": "2023-09-21T12:24:24"
+ },
+ {
+ "name": "i-gordan",
+ "savings_hbd_balance": "6.757 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2019-11-17T15:25:48",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "i-know-that",
+ "savings_hbd_balance": "0.243 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-01-21T06:04:57",
+ "savings_hbd_last_interest_payment": "2018-01-11T12:36:03"
+ },
+ {
+ "name": "j-fy",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "366210",
+ "savings_hbd_seconds_last_update": "2022-02-17T10:22:24",
+ "savings_hbd_last_interest_payment": "2022-02-17T10:22:06"
+ },
+ {
+ "name": "j-rodriguez",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "3335409792",
+ "savings_hbd_seconds_last_update": "2018-02-25T01:24:12",
+ "savings_hbd_last_interest_payment": "2018-02-11T13:16:36"
+ },
+ {
+ "name": "k-o-m-c",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "4264043490",
+ "savings_hbd_seconds_last_update": "2025-03-16T23:15:15",
+ "savings_hbd_last_interest_payment": "2025-02-19T06:59:30"
+ },
+ {
+ "name": "k-rapper",
+ "savings_hbd_balance": "0.233 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2016-12-24T15:19:54",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "l-gasper19",
+ "savings_hbd_balance": "0.005 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-01-19T13:07:24",
+ "savings_hbd_last_interest_payment": "2023-01-19T13:07:24"
+ },
+ {
+ "name": "l-p",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "1217064",
+ "savings_hbd_seconds_last_update": "2025-11-27T02:49:18",
+ "savings_hbd_last_interest_payment": "2025-11-27T02:47:36"
+ },
+ {
+ "name": "l0k1",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "8385740532",
+ "savings_hbd_seconds_last_update": "2017-04-26T11:29:03",
+ "savings_hbd_last_interest_payment": "2017-03-30T07:02:27"
+ },
+ {
+ "name": "m-5",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "1077255194355",
+ "savings_hbd_seconds_last_update": "2025-04-19T04:38:00",
+ "savings_hbd_last_interest_payment": "2025-04-05T01:22:03"
+ },
+ {
+ "name": "m-abel",
+ "savings_hbd_balance": "10.016 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2021-10-08T12:54:51",
+ "savings_hbd_last_interest_payment": "2021-10-08T12:54:51"
+ },
+ {
+ "name": "m-ali",
+ "savings_hbd_balance": "0.003 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2026-03-05T07:33:45",
+ "savings_hbd_last_interest_payment": "2026-03-05T07:33:45"
+ },
+ {
+ "name": "m-mirage-e",
+ "savings_hbd_balance": "1.002 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2022-01-20T23:13:45",
+ "savings_hbd_last_interest_payment": "2022-01-20T23:13:45"
+ },
+ {
+ "name": "n0m0refak3n3ws",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "7884",
+ "savings_hbd_seconds_last_update": "2024-10-24T18:50:09",
+ "savings_hbd_last_interest_payment": "2024-10-24T18:49:51"
+ },
+ {
+ "name": "o-dot2723",
+ "savings_hbd_balance": "1.000 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-11-25T14:19:39",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "o1123581321",
+ "savings_hbd_balance": "0.001 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-06-08T13:47:33",
+ "savings_hbd_last_interest_payment": "2018-06-08T13:47:33"
+ },
+ {
+ "name": "p-dope",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "40173108",
+ "savings_hbd_seconds_last_update": "2018-06-12T18:24:51",
+ "savings_hbd_last_interest_payment": "2018-06-10T20:07:03"
+ },
+ {
+ "name": "p-hbd",
+ "savings_hbd_balance": "525.869 HBD",
+ "savings_hbd_seconds": "6519828",
+ "savings_hbd_seconds_last_update": "2023-05-17T13:20:45",
+ "savings_hbd_last_interest_payment": "2023-05-17T13:20:33"
+ },
+ {
+ "name": "q-tv",
+ "savings_hbd_balance": "6.072 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2021-04-06T00:56:54",
+ "savings_hbd_last_interest_payment": "2021-04-06T00:56:54"
+ },
+ {
+ "name": "q0000",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "14171916",
+ "savings_hbd_seconds_last_update": "2018-03-25T16:55:12",
+ "savings_hbd_last_interest_payment": "2018-03-24T21:25:33"
+ },
+ {
+ "name": "q42",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "188325",
+ "savings_hbd_seconds_last_update": "2021-04-05T01:19:15",
+ "savings_hbd_last_interest_payment": "2021-04-05T01:19:00"
+ },
+ {
+ "name": "r-hive",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "9238531980132",
+ "savings_hbd_seconds_last_update": "2026-02-05T01:47:48",
+ "savings_hbd_last_interest_payment": "2026-01-21T02:29:54"
+ },
+ {
+ "name": "r-nyn",
+ "savings_hbd_balance": "2.033 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-07-09T20:50:42",
+ "savings_hbd_last_interest_payment": "2025-07-09T20:50:42"
+ },
+ {
+ "name": "s-dbraybrook",
+ "savings_hbd_balance": "12.645 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-11-15T15:41:03",
+ "savings_hbd_last_interest_payment": "2018-11-15T15:41:03"
+ },
+ {
+ "name": "s-nijhum",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "2556747648",
+ "savings_hbd_seconds_last_update": "2022-03-06T08:25:57",
+ "savings_hbd_last_interest_payment": "2022-02-20T19:14:45"
+ },
+ {
+ "name": "s-tec",
+ "savings_hbd_balance": "0.012 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-04-09T16:43:21",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "t-angel1989",
+ "savings_hbd_balance": "0.003 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2022-02-13T11:18:06",
+ "savings_hbd_last_interest_payment": "2022-02-13T11:18:06"
+ },
+ {
+ "name": "t-aze",
+ "savings_hbd_balance": "12.243 HBD",
+ "savings_hbd_seconds": "21733259655",
+ "savings_hbd_seconds_last_update": "2018-11-19T21:40:09",
+ "savings_hbd_last_interest_payment": "2018-10-29T14:40:33"
+ },
+ {
+ "name": "t-belema",
+ "savings_hbd_balance": "1.000 HBD",
+ "savings_hbd_seconds": "875502000",
+ "savings_hbd_seconds_last_update": "2018-01-23T13:34:42",
+ "savings_hbd_last_interest_payment": "2018-01-18T11:03:27"
+ },
+ {
+ "name": "t-h-x",
+ "savings_hbd_balance": "0.000 HBD",
+ "savings_hbd_seconds": "252000",
+ "savings_hbd_seconds_last_update": "2017-06-17T12:47:57",
+ "savings_hbd_last_interest_payment": "2017-06-17T12:44:36"
+ },
+ {
+ "name": "t-nil",
+ "savings_hbd_balance": "0.075 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-03-20T00:21:36",
+ "savings_hbd_last_interest_payment": "2023-03-20T00:21:36"
+ },
+ {
+ "name": "v-36",
+ "savings_hbd_balance": "8.022 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2026-04-14T17:13:27",
+ "savings_hbd_last_interest_payment": "2026-04-14T17:13:27"
+ },
+ {
+ "name": "v-x",
+ "savings_hbd_balance": "0.005 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2023-06-10T22:48:00",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "w-o-w-world",
+ "savings_hbd_balance": "0.208 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-03-08T20:31:09",
+ "savings_hbd_last_interest_payment": "2018-03-08T20:31:09"
+ },
+ {
+ "name": "w-t-fi",
+ "savings_hbd_balance": "21.405 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2025-09-16T05:18:06",
+ "savings_hbd_last_interest_payment": "2025-09-16T05:18:06"
+ },
+ {
+ "name": "x-helluva-x",
+ "savings_hbd_balance": "2.002 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2022-04-04T21:26:42",
+ "savings_hbd_last_interest_payment": "2022-04-04T21:26:42"
+ },
+ {
+ "name": "y0ting0",
+ "savings_hbd_balance": "0.984 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2022-06-04T20:16:48",
+ "savings_hbd_last_interest_payment": "1970-01-01T00:00:00"
+ },
+ {
+ "name": "z0rt3r",
+ "savings_hbd_balance": "0.178 HBD",
+ "savings_hbd_seconds": "0",
+ "savings_hbd_seconds_last_update": "2018-01-28T14:55:54",
+ "savings_hbd_last_interest_payment": "2018-01-28T14:55:54"
+ }
+ ]
+}
diff --git a/apps/web/src/specs/utils/hbd-savings-interest-adversarial.spec.ts b/apps/web/src/specs/utils/hbd-savings-interest-adversarial.spec.ts
new file mode 100644
index 0000000000..d0f86beea6
--- /dev/null
+++ b/apps/web/src/specs/utils/hbd-savings-interest-adversarial.spec.ts
@@ -0,0 +1,345 @@
+import { describe, expect, it } from "vitest";
+import dayjs from "@/utils/dayjs";
+import {
+ getHbdSavingsInterestState,
+ MINIMUM_HBD_SAVINGS_AMOUNT
+} from "@/utils/hbd-savings-interest";
+import { formattedNumber } from "@/utils/formatted-number";
+import fixture from "./hbd-savings-accounts.fixture.json";
+
+/**
+ * Adversarial checks on the interest estimate.
+ *
+ * The estimate is a promise about money: it tells someone what a claim will
+ * put in their savings. So rather than assert the numbers this implementation
+ * happens to produce, these tests hold it against an independent reference,
+ * against real account rows read off the chain, and against payloads built to
+ * break it.
+ */
+
+/**
+ * hived's `adjust_savings_balance`, transcribed straight from the chain source
+ * and kept deliberately separate from the implementation under test:
+ *
+ * interest = savings_hbd_seconds / HIVE_SECONDS_PER_YEAR
+ * interest *= hbd_interest_rate
+ * interest /= HIVE_100_PERCENT
+ *
+ * Every step is uint128, so both divisions truncate. A float version of the
+ * same expression rounds instead, which overstates the payout.
+ */
+function chainInterestSatoshis(
+ balanceSatoshis: bigint,
+ bankedSatoshiSeconds: bigint,
+ elapsedSeconds: bigint,
+ rate: bigint
+): bigint {
+ const total = bankedSatoshiSeconds + balanceSatoshis * elapsedSeconds;
+ return ((total / 31536000n) * rate) / 10000n;
+}
+
+const NOW = dayjs(`${fixture.capturedAt}.000Z`);
+const RATE = fixture.hbdInterestRate;
+
+const elapsed = (last: string) =>
+ BigInt(Math.abs(Math.round((NOW.valueOf() - Date.parse(`${last}.000Z`)) / 1000)));
+
+const toSatoshis = (asset: string) => BigInt(asset.replace(/[^0-9]/g, ""));
+
+describe("estimate against the chain's own arithmetic, on real accounts", () => {
+ // Rows captured from condenser_api.get_accounts, every account found with a
+ // savings balance or banked savings seconds.
+ it("matches hived to the satoshi on every captured account", () => {
+ const mismatches = fixture.accounts
+ .map((account) => {
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: account.savings_hbd_balance,
+ savingsHbdSeconds: account.savings_hbd_seconds,
+ savingsHbdSecondsLastUpdate: account.savings_hbd_seconds_last_update,
+ savingsHbdLastInterestPayment: account.savings_hbd_last_interest_payment,
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+ const expected = chainInterestSatoshis(
+ toSatoshis(account.savings_hbd_balance),
+ BigInt(account.savings_hbd_seconds),
+ elapsed(account.savings_hbd_seconds_last_update),
+ BigInt(RATE)
+ );
+ return { name: account.name, got: state.pendingInterestSatoshis, expected };
+ })
+ .filter((row) => BigInt(row.got) !== row.expected);
+
+ expect(mismatches).toEqual([]);
+ });
+
+ it("covers a meaningful spread of real states", () => {
+ // Guards the fixture itself: a file that silently emptied would make the
+ // check above pass while testing nothing.
+ expect(fixture.accounts.length).toBeGreaterThan(20);
+ expect(
+ fixture.accounts.some((a) => BigInt(a.savings_hbd_seconds) > 0n)
+ ).toBe(true);
+ expect(
+ fixture.accounts.some((a) => a.savings_hbd_balance.startsWith("0.000"))
+ ).toBe(true);
+ });
+
+ it("never tells the user more than the chain will pay", () => {
+ // The original float expression overstated 25 of these 62 accounts by
+ // 0.001 HBD, because it rounded where the chain truncates.
+ for (const account of fixture.accounts) {
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: account.savings_hbd_balance,
+ savingsHbdSeconds: account.savings_hbd_seconds,
+ savingsHbdSecondsLastUpdate: account.savings_hbd_seconds_last_update,
+ savingsHbdLastInterestPayment: account.savings_hbd_last_interest_payment,
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+ const expected = chainInterestSatoshis(
+ toSatoshis(account.savings_hbd_balance),
+ BigInt(account.savings_hbd_seconds),
+ elapsed(account.savings_hbd_seconds_last_update),
+ BigInt(RATE)
+ );
+
+ expect(BigInt(state.pendingInterestSatoshis)).toBeLessThanOrEqual(expected);
+ }
+ });
+});
+
+describe("estimate against the chain's own arithmetic, over generated states", () => {
+ // A small deterministic PRNG: a fixed seed keeps a failure reproducible.
+ function makeRandom(seed: number) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+ }
+
+ it("matches hived across 2000 randomised balances, ages and rates", () => {
+ const random = makeRandom(20260823);
+ const failures: unknown[] = [];
+
+ for (let i = 0; i < 2000; i++) {
+ // Spread over the whole plausible range, with plenty of mass right at
+ // the boundaries where truncation decides between 0 and 1 satoshi.
+ const balanceSatoshis = BigInt(Math.floor(random() ** 4 * 5_000_000_000));
+ const banked = BigInt(Math.floor(random() ** 3 * 1e15));
+ const ageSeconds = Math.floor(random() * 400 * 24 * 60 * 60);
+ // Rates that are NOT clean divisors of HIVE_100_PERCENT matter: with a
+ // rate like 1000 the two truncations collapse into one by the nested
+ // floor identity, so only an awkward rate can tell hived's order of
+ // operations apart from the algebraically equivalent single division.
+ const rate = [0, 1, 3, 500, 777, 1000, 1234, 2000, 9999, 10000][
+ Math.floor(random() * 10)
+ ];
+
+ const whole = balanceSatoshis / 1000n;
+ const fraction = (balanceSatoshis % 1000n).toString().padStart(3, "0");
+ const lastUpdate = NOW.subtract(ageSeconds, "second").utc().format("YYYY-MM-DDTHH:mm:ss");
+
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: `${whole}.${fraction} HBD`,
+ savingsHbdSeconds: banked.toString(),
+ savingsHbdSecondsLastUpdate: lastUpdate,
+ savingsHbdLastInterestPayment: lastUpdate,
+ hbdInterestRate: rate,
+ now: NOW
+ });
+
+ const expected = chainInterestSatoshis(
+ balanceSatoshis,
+ banked,
+ BigInt(ageSeconds),
+ BigInt(rate)
+ );
+
+ if (BigInt(state.pendingInterestSatoshis) !== expected) {
+ failures.push({ i, balanceSatoshis, banked, ageSeconds, rate, expected, got: state.pendingInterestSatoshis });
+ }
+ }
+
+ expect(failures).toEqual([]);
+ });
+
+ it("does not let an exponent-notation field inflate the estimate", () => {
+ // savings_hbd_seconds is an integer count. A field that is not one must
+ // read as close to nothing, never as the enormous number a float parse
+ // would make of it: this figure is shown to the user as money.
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: "1e21",
+ savingsHbdSecondsLastUpdate: NOW.format("YYYY-MM-DDTHH:mm:ss"),
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+
+ expect(state.pendingInterestSatoshis).toBe(0);
+ });
+
+ it("keeps a whale's banked seconds exact past the double's safe range", () => {
+ // 2^53 satoshi-seconds is reached by ~100k HBD held for a month, and the
+ // field arrives as a string from nodes that serialize it that way.
+ const banked = "90071992547409910";
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: banked,
+ savingsHbdSecondsLastUpdate: NOW.format("YYYY-MM-DDTHH:mm:ss"),
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+
+ expect(BigInt(state.pendingInterestSatoshis)).toBe(
+ chainInterestSatoshis(0n, BigInt(banked), 0n, BigInt(RATE))
+ );
+ });
+});
+
+describe("invariants that must hold for any input", () => {
+ const HOSTILE: Record[] = [
+ {},
+ { savingsHbdBalance: "" },
+ { savingsHbdBalance: "not-an-asset" },
+ { savingsHbdBalance: "-5.000 HBD" },
+ { savingsHbdBalance: "1.0e3 HBD" },
+ { savingsHbdBalance: "999999999999.999 HBD" },
+ { savingsHbdBalance: "1.9999 HBD" },
+ { savingsHbdBalance: { amount: "1000", precision: 3, nai: "@@000000013" } },
+ { savingsHbdSeconds: Number.NaN },
+ { savingsHbdSeconds: Number.POSITIVE_INFINITY },
+ { savingsHbdSeconds: -1 },
+ { savingsHbdSeconds: "-1" },
+ { savingsHbdSeconds: "1e21" },
+ { savingsHbdSeconds: "12abc" },
+ { savingsHbdSeconds: null },
+ { savingsHbdSecondsLastUpdate: "" },
+ { savingsHbdSecondsLastUpdate: "not-a-date" },
+ { savingsHbdSecondsLastUpdate: "1970-01-01T00:00:00" },
+ // A timestamp in the future: a node ahead of the client's clock.
+ { savingsHbdSecondsLastUpdate: "2099-01-01T00:00:00", savingsHbdBalance: "10.000 HBD" },
+ { savingsHbdLastInterestPayment: "not-a-date", savingsHbdBalance: "10.000 HBD" },
+ { hbdInterestRate: Number.NaN },
+ { hbdInterestRate: -1000 },
+ { hbdInterestRate: Number.POSITIVE_INFINITY },
+ { hbdInterestRate: 1.5 }
+ ];
+
+ it.each(HOSTILE.map((input, i) => [i, input] as const))(
+ "produces a finite, non-negative estimate for hostile input %i",
+ (_i, input) => {
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: "1.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: NOW.subtract(60, "day").format("YYYY-MM-DDTHH:mm:ss"),
+ savingsHbdLastInterestPayment: NOW.subtract(60, "day").format("YYYY-MM-DDTHH:mm:ss"),
+ hbdInterestRate: RATE,
+ now: NOW,
+ ...(input as object)
+ });
+
+ expect(Number.isFinite(state.pendingInterest)).toBe(true);
+ expect(Number.isFinite(state.savingsBalance)).toBe(true);
+ expect(state.pendingInterest).toBeGreaterThanOrEqual(0);
+ expect(state.savingsBalance).toBeGreaterThanOrEqual(0);
+ // The number the user reads is always a whole count of satoshis.
+ expect(state.pendingInterest * 1000).toBeCloseTo(
+ Math.round(state.pendingInterest * 1000),
+ 9
+ );
+ }
+ );
+
+ it.each(HOSTILE.map((input, i) => [i, input] as const))(
+ "never offers a claim that the chain would reject, for hostile input %i",
+ (_i, input) => {
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: "1.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: NOW.subtract(60, "day").format("YYYY-MM-DDTHH:mm:ss"),
+ savingsHbdLastInterestPayment: NOW.subtract(60, "day").format("YYYY-MM-DDTHH:mm:ss"),
+ hbdInterestRate: RATE,
+ now: NOW,
+ ...(input as object)
+ });
+
+ if (state.canClaim) {
+ // Claiming broadcasts transfer_from_savings of 0.001 HBD plus a
+ // cancel. All three preconditions must hold or it fails on chain.
+ expect(state.savingsBalance).toBeGreaterThanOrEqual(MINIMUM_HBD_SAVINGS_AMOUNT);
+ expect(state.pendingInterestSatoshis).toBeGreaterThanOrEqual(1);
+ expect(state.isClaimDue).toBe(true);
+ }
+ }
+ );
+
+ it("never displays an amount the claim gate disagrees with", () => {
+ // The card shows formattedNumber(pendingInterest) and offers the claim on
+ // hasPendingInterest. If the display rounded, a 0.0009 estimate could read
+ // "0.001 HBD" next to no claim button at all. Integer arithmetic makes the
+ // figure a whole number of satoshis, so the two cannot disagree, and this
+ // is what holds that property down.
+ const random = (() => {
+ let state = 4242 >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+ })();
+
+ for (let i = 0; i < 1000; i++) {
+ // Concentrated just under and just over one satoshi of interest, which
+ // is the only place display and gate could ever part company.
+ const satoshis = Math.floor(random() ** 2 * 200_000);
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: `${Math.floor(satoshis / 1000)}.${String(satoshis % 1000).padStart(3, "0")} HBD`,
+ savingsHbdSeconds: Math.floor(random() ** 4 * 4e10),
+ savingsHbdSecondsLastUpdate: NOW.subtract(Math.floor(random() * 90), "day").format(
+ "YYYY-MM-DDTHH:mm:ss"
+ ),
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+
+ const displayed = Number(formattedNumber(state.pendingInterest).replace(/,/g, ""));
+ expect(displayed >= MINIMUM_HBD_SAVINGS_AMOUNT).toBe(state.hasPendingInterest);
+ // And the figure on screen is exactly the estimate, not a rounding of it.
+ expect(displayed).toBe(state.pendingInterest);
+ }
+ });
+
+ it("never hides a card that still has claimable interest", () => {
+ // isEmpty is what removes the card from the page. Anything it hides has to
+ // be genuinely worth nothing to the user.
+ const random = (() => {
+ let state = 7 >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0x100000000;
+ };
+ })();
+
+ for (let i = 0; i < 500; i++) {
+ const satoshis = Math.floor(random() ** 3 * 1_000_000);
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: `${Math.floor(satoshis / 1000)}.${String(satoshis % 1000).padStart(3, "0")} HBD`,
+ savingsHbdSeconds: Math.floor(random() ** 3 * 1e13),
+ savingsHbdSecondsLastUpdate: NOW.subtract(Math.floor(random() * 400), "day").format(
+ "YYYY-MM-DDTHH:mm:ss"
+ ),
+ savingsHbdLastInterestPayment: NOW.subtract(Math.floor(random() * 400), "day").format(
+ "YYYY-MM-DDTHH:mm:ss"
+ ),
+ hbdInterestRate: RATE,
+ now: NOW
+ });
+
+ if (state.isEmpty) {
+ expect(state.pendingInterestSatoshis).toBe(0);
+ expect(state.savingsBalance).toBe(0);
+ }
+ }
+ });
+});
diff --git a/apps/web/src/specs/utils/hbd-savings-interest-timezone.spec.ts b/apps/web/src/specs/utils/hbd-savings-interest-timezone.spec.ts
new file mode 100644
index 0000000000..66b1cfc8cc
--- /dev/null
+++ b/apps/web/src/specs/utils/hbd-savings-interest-timezone.spec.ts
@@ -0,0 +1,93 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+/**
+ * Chain timestamps arrive as "YYYY-MM-DDTHH:mm:ss" with no zone marker and are
+ * UTC. Handed to dayjs as-is they read as LOCAL time, which shifts the claim
+ * schedule by the viewer's offset. At UTC+14 that is 14 hours of saying "ready
+ * to claim now" while the chain would still refuse the payout.
+ *
+ * The rest of the suite runs in whatever timezone CI happens to use, which is
+ * UTC, so it cannot see this. This file runs the same state through the extreme
+ * offsets in both directions.
+ */
+const ZONES = [
+ "UTC",
+ "Pacific/Kiritimati", // UTC+14, the furthest ahead
+ "Pacific/Midway", // UTC-11, the furthest behind
+ "Asia/Kathmandu", // UTC+05:45, a non-hour offset
+ "America/New_York" // a zone that observes DST
+];
+
+const ORIGINAL_TZ = process.env.TZ;
+
+describe("chain timestamps are read as UTC in every timezone", () => {
+ beforeAll(() => {
+ // Node re-reads process.env.TZ per call, so a spec can move the clock's
+ // zone. Confirm that here rather than assume it, otherwise this whole file
+ // would silently degrade into five copies of the UTC case.
+ process.env.TZ = "Pacific/Kiritimati";
+ const shifted =
+ new Date("2026-03-26T10:41:39").getTime() - new Date("2026-03-26T10:41:39.000Z").getTime();
+ process.env.TZ = ORIGINAL_TZ;
+ expect(shifted).not.toBe(0);
+ });
+
+ afterAll(() => {
+ process.env.TZ = ORIGINAL_TZ;
+ });
+
+ async function stateIn(zone: string) {
+ process.env.TZ = zone;
+ // Re-import per zone: dayjs and the helper capture nothing zone-specific,
+ // but resetting modules keeps the zones from sharing any cached state.
+ const { getHbdSavingsInterestState } = await import("@/utils/hbd-savings-interest");
+ const dayjs = (await import("@/utils/dayjs")).default;
+
+ return getHbdSavingsInterestState({
+ savingsHbdBalance: "100.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-07-01T00:00:00",
+ savingsHbdLastInterestPayment: "2026-07-01T00:00:00",
+ hbdInterestRate: 1000,
+ now: dayjs("2026-08-23T06:00:00.000Z")
+ });
+ }
+
+ it.each(ZONES)("resolves the same instant and the same estimate in %s", async (zone) => {
+ const state = await stateIn(zone);
+
+ // 2026-07-01T00:00:00Z plus the 30 day compound interval.
+ expect(state.nextClaimDate?.toISOString()).toBe("2026-07-31T00:00:00.000Z");
+ // Independent of the zone: the elapsed seconds are measured between two
+ // instants, not two wall clocks. Derived here rather than written as a
+ // literal so the expectation cannot drift from the inputs above.
+ const elapsedSeconds =
+ BigInt(
+ (Date.parse("2026-08-23T06:00:00.000Z") - Date.parse("2026-07-01T00:00:00.000Z")) / 1000
+ );
+ expect(state.pendingInterestSatoshis).toBe(
+ Number((((100_000n * elapsedSeconds) / 31536000n) * 1000n) / 10000n)
+ );
+ expect(state.isClaimDue).toBe(true);
+ });
+
+ it.each(ZONES)("does not let %s decide whether a claim is due", async (zone) => {
+ process.env.TZ = zone;
+ const { getHbdSavingsInterestState } = await import("@/utils/hbd-savings-interest");
+ const dayjs = (await import("@/utils/dayjs")).default;
+
+ // One minute short of the interval. No timezone may turn this into "due":
+ // the chain would reject the payout at this instant everywhere on earth.
+ const state = getHbdSavingsInterestState({
+ savingsHbdBalance: "100.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-07-01T00:00:00",
+ savingsHbdLastInterestPayment: "2026-07-01T00:00:00",
+ hbdInterestRate: 1000,
+ now: dayjs("2026-07-30T23:59:00.000Z")
+ });
+
+ expect(state.isClaimDue).toBe(false);
+ expect(state.canClaim).toBe(false);
+ });
+});
diff --git a/apps/web/src/specs/utils/hbd-savings-interest.spec.ts b/apps/web/src/specs/utils/hbd-savings-interest.spec.ts
new file mode 100644
index 0000000000..bc2ab74ddb
--- /dev/null
+++ b/apps/web/src/specs/utils/hbd-savings-interest.spec.ts
@@ -0,0 +1,221 @@
+import { describe, expect, it } from "vitest";
+import dayjs from "@/utils/dayjs";
+import { getHbdSavingsInterestState } from "@/utils/hbd-savings-interest";
+
+/**
+ * The estimate mirrors the chain's own savings interest accounting, so the
+ * fixtures below are real account rows read from condenser_api.get_accounts.
+ */
+const NOW = dayjs("2026-08-23T06:00:00.000Z");
+// hbd_interest_rate is in basis points; 1000 is the 10% APR in force.
+const RATE = 1000;
+
+const state = (input: Partial[0]>) =>
+ getHbdSavingsInterestState({ hbdInterestRate: RATE, now: NOW, ...input });
+
+describe("getHbdSavingsInterestState estimate", () => {
+ it("adds the seconds accrued since the last update to the banked ones", () => {
+ // @good-karma: nothing banked, 1.099 HBD sitting in savings since April.
+ const result = state({
+ savingsHbdBalance: "1.099 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-04-13T13:27:30",
+ savingsHbdLastInterestPayment: "2026-04-13T13:27:30"
+ });
+
+ // The chain truncates twice, so it pays 0.039 where a float estimate
+ // would round the same state up to 0.040.
+ expect(result.pendingInterest).toBe(0.039);
+ expect(result.hasPendingInterest).toBe(true);
+ expect(result.hasSavingsBalance).toBe(true);
+ expect(result.canClaim).toBe(true);
+ });
+
+ it("counts interest banked before the balance was emptied", () => {
+ // @ecency: savings drained, but the chain has not settled the seconds yet.
+ const result = state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: 23901200496,
+ savingsHbdSecondsLastUpdate: "2026-03-31T20:33:15",
+ savingsHbdLastInterestPayment: "2026-03-26T10:41:39"
+ });
+
+ expect(result.pendingInterest).toBe(0.075);
+ expect(result.hasPendingInterest).toBe(true);
+ expect(result.hasSavingsBalance).toBe(false);
+ expect(result.isEmpty).toBe(false);
+ });
+
+ it("reads savings_hbd_seconds sent as a string", () => {
+ const asNumber = state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: 23901200496,
+ savingsHbdSecondsLastUpdate: "2026-03-31T20:33:15"
+ });
+ const asString = state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: "23901200496",
+ savingsHbdSecondsLastUpdate: "2026-03-31T20:33:15"
+ });
+
+ expect(asString.pendingInterest).toBe(asNumber.pendingInterest);
+ });
+
+ it("is zero while the chain reports no interest rate", () => {
+ const result = state({
+ savingsHbdBalance: "1000.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-01-01T00:00:00",
+ hbdInterestRate: 0
+ });
+
+ expect(result.pendingInterest).toBe(0);
+ expect(result.hasPendingInterest).toBe(false);
+ });
+
+ it("treats an epoch timestamp as no history rather than 56 years of accrual", () => {
+ const result = state({
+ savingsHbdBalance: "10.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "1970-01-01T00:00:00",
+ savingsHbdLastInterestPayment: "1970-01-01T00:00:00"
+ });
+
+ expect(result.pendingInterest).toBe(0);
+ expect(result.nextClaimDate).toBeNull();
+ expect(result.canClaim).toBe(false);
+ });
+});
+
+describe("getHbdSavingsInterestState visibility", () => {
+ it("is empty only when nothing is saved and nothing has accrued", () => {
+ expect(
+ state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-08-23T05:00:00"
+ }).isEmpty
+ ).toBe(true);
+ });
+
+ it("stays visible on a drained balance that still holds interest", () => {
+ // Regression: the card was hidden on savings balance alone, which took the
+ // estimate with it and left the accrued interest invisible.
+ expect(
+ state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: 23901200496,
+ savingsHbdSecondsLastUpdate: "2026-03-31T20:33:15",
+ savingsHbdLastInterestPayment: "2026-03-26T10:41:39"
+ }).isEmpty
+ ).toBe(false);
+ });
+
+ it("stays visible on a fresh deposit that has not accrued anything yet", () => {
+ const result = state({
+ savingsHbdBalance: "50.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-08-23T05:59:30",
+ savingsHbdLastInterestPayment: "2026-08-23T05:59:30"
+ });
+
+ expect(result.isEmpty).toBe(false);
+ expect(result.hasPendingInterest).toBe(false);
+ expect(result.canClaim).toBe(false);
+ });
+});
+
+describe("getHbdSavingsInterestState claim eligibility", () => {
+ const banked = {
+ savingsHbdBalance: "100.000 HBD",
+ savingsHbdSeconds: 0
+ };
+
+ it("is not claimable before the 30 day interval has elapsed", () => {
+ const result = state({
+ ...banked,
+ savingsHbdSecondsLastUpdate: "2026-08-10T00:00:00",
+ savingsHbdLastInterestPayment: "2026-08-10T00:00:00"
+ });
+
+ expect(result.hasPendingInterest).toBe(true);
+ expect(result.isClaimDue).toBe(false);
+ expect(result.canClaim).toBe(false);
+ expect(result.nextClaimDate?.toISOString()).toBe("2026-09-09T00:00:00.000Z");
+ });
+
+ it("becomes claimable once the interval has passed", () => {
+ const result = state({
+ ...banked,
+ savingsHbdSecondsLastUpdate: "2026-07-01T00:00:00",
+ savingsHbdLastInterestPayment: "2026-07-01T00:00:00"
+ });
+
+ expect(result.isClaimDue).toBe(true);
+ expect(result.canClaim).toBe(true);
+ expect(result.needsDepositToClaim).toBe(false);
+ });
+
+ it("measures the interval from the last payment, not the last balance change", () => {
+ // The chain compares against savings_hbd_last_interest_payment, so a later
+ // deposit does not push the claim date out.
+ const result = state({
+ ...banked,
+ savingsHbdSecondsLastUpdate: "2026-08-20T00:00:00",
+ savingsHbdLastInterestPayment: "2026-07-01T00:00:00"
+ });
+
+ expect(result.nextClaimDate?.toISOString()).toBe("2026-07-31T00:00:00.000Z");
+ expect(result.canClaim).toBe(true);
+ });
+
+ it("cannot claim a drained balance, because the trigger transfer needs 0.001 HBD", () => {
+ const result = state({
+ savingsHbdBalance: "0.000 HBD",
+ savingsHbdSeconds: 23901200496,
+ savingsHbdSecondsLastUpdate: "2026-03-31T20:33:15",
+ savingsHbdLastInterestPayment: "2026-03-26T10:41:39"
+ });
+
+ expect(result.isClaimDue).toBe(true);
+ expect(result.needsDepositToClaim).toBe(true);
+ expect(result.canClaim).toBe(false);
+ });
+
+ it("does not claim an estimate the chain would round away", () => {
+ // 0.0009 HBD of interest: below the three decimals HBD is stored with.
+ const result = state({
+ savingsHbdBalance: "1.000 HBD",
+ savingsHbdSeconds: 0,
+ savingsHbdSecondsLastUpdate: "2026-08-22T18:00:00",
+ savingsHbdLastInterestPayment: "2026-01-01T00:00:00"
+ });
+
+ expect(result.pendingInterest).toBeLessThan(0.001);
+ expect(result.hasPendingInterest).toBe(false);
+ expect(result.isClaimDue).toBe(true);
+ expect(result.canClaim).toBe(false);
+ });
+});
+
+describe("getHbdSavingsInterestState malformed input", () => {
+ it("does not produce NaN from a missing account", () => {
+ const result = getHbdSavingsInterestState({ hbdInterestRate: RATE, now: NOW });
+
+ expect(result.savingsBalance).toBe(0);
+ expect(result.pendingInterest).toBe(0);
+ expect(result.isEmpty).toBe(true);
+ });
+
+ it("does not produce NaN from unparseable fields", () => {
+ const result = state({
+ savingsHbdBalance: "not-an-asset",
+ savingsHbdSeconds: "not-a-number",
+ savingsHbdSecondsLastUpdate: "not-a-date"
+ });
+
+ expect(Number.isFinite(result.savingsBalance)).toBe(true);
+ expect(Number.isFinite(result.pendingInterest)).toBe(true);
+ expect(result.isEmpty).toBe(true);
+ });
+});
diff --git a/apps/web/src/utils/hbd-savings-interest.ts b/apps/web/src/utils/hbd-savings-interest.ts
new file mode 100644
index 0000000000..62c8854128
--- /dev/null
+++ b/apps/web/src/utils/hbd-savings-interest.ts
@@ -0,0 +1,226 @@
+import dayjs, { type Dayjs } from "./dayjs";
+
+/**
+ * Hive stores HBD with three decimals, so 0.001 HBD is the smallest balance
+ * that can sit in savings at all, and the smallest amount the chain can pay.
+ */
+export const MINIMUM_HBD_SAVINGS_AMOUNT = 0.001;
+/** HIVE_HBD_INTEREST_COMPOUND_INTERVAL_SEC, expressed in days. */
+export const HBD_INTEREST_INTERVAL_DAYS = 30;
+/** HIVE_SECONDS_PER_YEAR. */
+const SECONDS_PER_YEAR = 365n * 24n * 60n * 60n;
+/** HIVE_100_PERCENT: hbd_interest_rate is expressed against this. */
+const HUNDRED_PERCENT = 10000n;
+/** HBD is stored with three decimals, so one satoshi is 0.001 HBD. */
+const SATOSHIS_PER_HBD = 1000n;
+/** What the chain reports for "never happened" on the savings timestamps. */
+const UNIX_EPOCH = "1970-01-01T00:00:00";
+
+interface Input {
+ /**
+ * `savings_hbd_balance`, e.g. "1.099 HBD". Nodes may also serve the
+ * `{ amount, precision, nai }` object form.
+ */
+ savingsHbdBalance?: string | number | { amount?: unknown; precision?: unknown } | null;
+ /** `savings_hbd_seconds`: HBD satoshi-seconds banked since the last payout. */
+ savingsHbdSeconds?: number | string;
+ /** `savings_hbd_seconds_last_update`. */
+ savingsHbdSecondsLastUpdate?: string;
+ /** `savings_hbd_last_interest_payment`. */
+ savingsHbdLastInterestPayment?: string;
+ /** `hbd_interest_rate` from the dynamic global properties, in basis points. */
+ hbdInterestRate: number;
+ /** Injectable for tests; defaults to now. */
+ now?: Dayjs;
+}
+
+export interface HbdSavingsInterestState {
+ savingsBalance: number;
+ /**
+ * Interest accrued but not yet paid out, in HBD. This is exactly what the
+ * chain would pay, to the satoshi, so it always renders whole at three
+ * decimals.
+ */
+ pendingInterest: number;
+ /** The same figure in satoshis, which is the unit the chain settles in. */
+ pendingInterestSatoshis: number;
+ /** The savings balance is at or above the amount the chain can work with. */
+ hasSavingsBalance: boolean;
+ /** The chain would pay at least one satoshi. */
+ hasPendingInterest: boolean;
+ /** Nothing accrued and nothing saved: there is nothing to tell the user. */
+ isEmpty: boolean;
+ /** When the chain will next release interest, or null when unknown. */
+ nextClaimDate: Dayjs | null;
+ /** The 30-day compounding interval has elapsed. */
+ isClaimDue: boolean;
+ /**
+ * Claiming works by broadcasting a 0.001 HBD transfer out of savings, which
+ * makes the chain settle the interest first. With an empty savings balance
+ * that transfer cannot be made, so the interest is real but unreachable
+ * until something is deposited.
+ */
+ needsDepositToClaim: boolean;
+ canClaim: boolean;
+}
+
+/**
+ * `savings_hbd_seconds` counts satoshi-seconds and outgrows a double for large
+ * balances, so nodes may serve it as a string. Read it as an integer either way.
+ */
+function parseBankedSeconds(value: number | string | undefined): bigint {
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? BigInt(Math.trunc(value)) : 0n;
+ }
+
+ if (typeof value === "string") {
+ const digits = value.trim().match(/^\d+/);
+ if (digits) {
+ return BigInt(digits[0]);
+ }
+ }
+
+ return 0n;
+}
+
+/**
+ * The satoshi value of an HBD balance, read off the digits rather than through
+ * a float so a large balance keeps every unit. Accepts the two shapes a Hive
+ * node can serve: "1.099 HBD" from condenser_api, and the
+ * `{ amount, precision, nai }` object from database_api. Anything else, a
+ * negative included, reads as nothing rather than throwing: this runs during
+ * render of the wallet page.
+ */
+function parseHbdSatoshis(value: unknown): bigint {
+ if (typeof value === "number") {
+ return Number.isFinite(value) && value > 0 ? BigInt(Math.round(value * 1000)) : 0n;
+ }
+
+ if (value && typeof value === "object") {
+ const asset = value as { amount?: unknown; precision?: unknown };
+ const digits = String(asset.amount ?? "").trim().match(/^\d+/);
+ const precision = Number(asset.precision ?? 3);
+ if (!digits || !Number.isInteger(precision) || precision < 0 || precision > 12) {
+ return 0n;
+ }
+ // Rescale whatever precision the node used to HBD's three decimals.
+ const raw = BigInt(digits[0]);
+ return precision >= 3
+ ? raw / 10n ** BigInt(precision - 3)
+ : raw * 10n ** BigInt(3 - precision);
+ }
+
+ if (typeof value !== "string") {
+ return 0n;
+ }
+
+ const matched = value.trim().match(/^(\d+)(?:\.(\d{0,3}))?/);
+ if (!matched) {
+ return 0n;
+ }
+
+ const fraction = (matched[2] ?? "").padEnd(3, "0");
+ return BigInt(matched[1]) * SATOSHIS_PER_HBD + BigInt(fraction);
+}
+
+/** Chain timestamps carry no zone marker and are UTC. */
+function asUtcInstant(value: string): string {
+ return value.indexOf(".") !== -1 || value.indexOf("+") !== -1 ? value : `${value}.000Z`;
+}
+
+/**
+ * Reads a chain timestamp as the instant it denotes. Passing the bare string to
+ * dayjs would read it as LOCAL time, which shifts the claim schedule by the
+ * viewer's offset: up to 14 hours, enough to say "ready to claim now" while the
+ * chain would still refuse. The result stays in local mode so it still formats
+ * and reads relative in the viewer's own timezone.
+ */
+function parseChainDate(value: string | undefined): Dayjs | null {
+ if (!value || value === UNIX_EPOCH) {
+ return null;
+ }
+
+ const parsed = dayjs(asUtcInstant(value));
+ return parsed.isValid() ? parsed : null;
+}
+
+/** Mirrors `utils/parse-date`'s secondDiff, but against an injectable `now`. */
+function secondsSince(value: string | undefined, now: Dayjs): number {
+ if (!value || value === UNIX_EPOCH) {
+ return 0;
+ }
+
+ const parsed = new Date(asUtcInstant(value)).getTime();
+ if (!Number.isFinite(parsed)) {
+ return 0;
+ }
+
+ return Math.abs(Math.round((now.valueOf() - parsed) / 1000));
+}
+
+/**
+ * Reproduces the chain's savings interest accounting (`adjust_savings_balance`
+ * in hived) so the wallet can show what a claim would release.
+ *
+ * `savings_hbd_seconds` is the balance-seconds the chain has already banked,
+ * in satoshis, and it only resets when interest is actually paid. Everything
+ * since `savings_hbd_seconds_last_update` has not been banked yet, so it has to
+ * be added back. A payout needs both a savings balance change and 30 days since
+ * the last payment, which is why a zero balance does not imply zero interest:
+ * withdrawing everything inside that window leaves the accrued seconds standing.
+ */
+export function getHbdSavingsInterestState({
+ savingsHbdBalance,
+ savingsHbdSeconds,
+ savingsHbdSecondsLastUpdate,
+ savingsHbdLastInterestPayment,
+ hbdInterestRate,
+ now = dayjs()
+}: Input): HbdSavingsInterestState {
+ const balanceSatoshis = parseHbdSatoshis(savingsHbdBalance);
+ const savingsBalance = Number(balanceSatoshis) / Number(SATOSHIS_PER_HBD);
+
+ // Everything the chain has banked, plus everything earned since it last
+ // looked, in satoshi-seconds.
+ const totalSatoshiSeconds =
+ parseBankedSeconds(savingsHbdSeconds) +
+ balanceSatoshis * BigInt(secondsSince(savingsHbdSecondsLastUpdate, now));
+
+ // hived's own arithmetic, in the same order: both divisions truncate, so
+ // computing this in floating point rounds the estimate UP past what the
+ // chain will actually pay.
+ // A malformed dynamic-properties payload must not throw out of a render.
+ const rate = Number.isFinite(hbdInterestRate)
+ ? BigInt(Math.max(0, Math.trunc(hbdInterestRate)))
+ : 0n;
+ const pendingInterestSatoshis =
+ rate > 0n ? ((totalSatoshiSeconds / SECONDS_PER_YEAR) * rate) / HUNDRED_PERCENT : 0n;
+ const pendingInterest = Number(pendingInterestSatoshis) / Number(SATOSHIS_PER_HBD);
+
+ const hasSavingsBalance = balanceSatoshis >= 1n;
+ const hasPendingInterest = pendingInterestSatoshis >= 1n;
+
+ // The chain measures the interval from the last payment. Accounts that have
+ // never been paid fall back to when the seconds started accumulating, which
+ // is the earliest point a payout could have been due.
+ const claimReference =
+ parseChainDate(savingsHbdLastInterestPayment) ??
+ parseChainDate(savingsHbdSecondsLastUpdate);
+ const nextClaimDate = claimReference
+ ? claimReference.add(HBD_INTEREST_INTERVAL_DAYS, "day")
+ : null;
+ const isClaimDue = nextClaimDate ? now.isAfter(nextClaimDate) : false;
+
+ return {
+ savingsBalance,
+ pendingInterest,
+ pendingInterestSatoshis: Number(pendingInterestSatoshis),
+ hasSavingsBalance,
+ hasPendingInterest,
+ isEmpty: !hasSavingsBalance && !hasPendingInterest,
+ nextClaimDate,
+ isClaimDue,
+ needsDepositToClaim: hasPendingInterest && !hasSavingsBalance,
+ canClaim: hasPendingInterest && hasSavingsBalance && isClaimDue
+ };
+}
diff --git a/apps/web/src/utils/index.ts b/apps/web/src/utils/index.ts
index 13adaf040d..49fb008cea 100644
--- a/apps/web/src/utils/index.ts
+++ b/apps/web/src/utils/index.ts
@@ -14,6 +14,7 @@ export * from "./rnd";
export * from "./encoder";
export * from "./account-reputation";
export * from "./hive-wallet";
+export * from "./hbd-savings-interest";
export * from "./parse-date";
export * from "./temp-entry";
export * from "./posting";