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";