diff --git a/__mocks__/@breeztech/breez-sdk-spark-react-native.js b/__mocks__/@breeztech/breez-sdk-spark-react-native.js index 5e6d8a18fc..709912a066 100644 --- a/__mocks__/@breeztech/breez-sdk-spark-react-native.js +++ b/__mocks__/@breeztech/breez-sdk-spark-react-native.js @@ -70,7 +70,32 @@ module.exports = { Seed: { Mnemonic: jest.fn().mockImplementation((args) => args) }, StableBalanceActiveLabel: { Set: jest.fn().mockImplementation((args) => ({ tag: "Set", inner: args })), - Unset: jest.fn().mockReturnValue({ tag: "Unset" }), + Unset: jest.fn().mockImplementation(() => ({ tag: "Unset" })), + }, + ConversionType: { + FromBitcoin: jest.fn().mockImplementation(() => ({ tag: "FromBitcoin" })), + ToBitcoin: jest + .fn() + .mockImplementation((args) => ({ tag: "ToBitcoin", inner: args })), + }, + AmountAdjustmentReason: { + FlooredToMinLimit: "FlooredToMinLimit", + IncreasedToAvoidDust: "IncreasedToAvoidDust", + }, + PrepareSendPaymentRequest: { create: (p) => p }, + SendPaymentRequest: { create: (p) => p }, + ReceivePaymentRequest: { create: (p) => p }, + ReceivePaymentMethod: { + SparkAddress: jest.fn().mockImplementation(() => ({ tag: "SparkAddress" })), + SparkInvoice: jest + .fn() + .mockImplementation((args) => ({ tag: "SparkInvoice", inner: args })), + Bolt11Invoice: jest + .fn() + .mockImplementation((args) => ({ tag: "Bolt11Invoice", inner: args })), + BitcoinAddress: jest + .fn() + .mockImplementation((args) => ({ tag: "BitcoinAddress", inner: args })), }, SdkEvent_Tags, PaymentMethod: { diff --git a/__tests__/components/balance-header/balance-header.spec.tsx b/__tests__/components/balance-header/balance-header.spec.tsx new file mode 100644 index 0000000000..3d6b605625 --- /dev/null +++ b/__tests__/components/balance-header/balance-header.spec.tsx @@ -0,0 +1,125 @@ +import React from "react" +import { fireEvent, render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { BalanceMode } from "@app/hooks/use-balance-mode" + +import { BalanceHeader } from "@app/components/balance-header/balance-header" + +const mockSwitchMemoryHideAmount = jest.fn() + +jest.mock("@app/graphql/hide-amount-context", () => ({ + useHideAmount: () => ({ + hideAmount: false, + switchMemoryHideAmount: mockSwitchMemoryHideAmount, + }), +})) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + StableBalance: { + balanceLabelBtc: () => "Balance · SATS", + balanceLabelUsd: () => "Balance · USD", + }, + SelfCustodialBalance: { + staleLabel: () => "STALE", + staleRetryHint: () => "Balance may be out of date. Tap to refresh.", + }, + }, + }), +})) + +const renderHeader = (props: Partial> = {}) => + render( + + + , + ) + +describe("BalanceHeader", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the formatted balance", () => { + const { getByText } = renderHeader({ formattedBalance: "$42.00" }) + + expect(getByText("$42.00")).toBeTruthy() + }) + + it("does not render the Stable Balance toggle when showStableBalanceToggle is false", () => { + const { queryByTestId } = renderHeader({ showStableBalanceToggle: false }) + + expect(queryByTestId("balance-mode-toggle")).toBeNull() + }) + + it("renders the SATS label when mode is Btc and toggle is enabled", () => { + const onModeChange = jest.fn() + const { getByText } = renderHeader({ + showStableBalanceToggle: true, + mode: BalanceMode.Btc, + onModeChange, + }) + + expect(getByText("Balance · SATS")).toBeTruthy() + }) + + it("renders the USD label when mode is Usd and toggle is enabled", () => { + const { getByText } = renderHeader({ + showStableBalanceToggle: true, + mode: BalanceMode.Usd, + onModeChange: jest.fn(), + }) + + expect(getByText("Balance · USD")).toBeTruthy() + }) + + it("calls onModeChange when the toggle is pressed", () => { + const onModeChange = jest.fn() + const { getByTestId } = renderHeader({ + showStableBalanceToggle: true, + mode: BalanceMode.Btc, + onModeChange, + }) + + fireEvent.press(getByTestId("balance-mode-toggle")) + + expect(onModeChange).toHaveBeenCalledTimes(1) + }) + + it("hides the toggle when onModeChange is not provided even if the flag is true", () => { + const { queryByTestId } = renderHeader({ + showStableBalanceToggle: true, + mode: BalanceMode.Btc, + onModeChange: undefined, + }) + + expect(queryByTestId("balance-mode-toggle")).toBeNull() + }) + + it("does not render the status badge by default", () => { + const { queryByTestId } = renderHeader() + + expect(queryByTestId("balance-status-badge")).toBeNull() + }) + + it("renders the status badge with the given label and status when provided", () => { + const { getByTestId, getByText } = renderHeader({ + statusBadge: { label: "STALE", status: "warning" }, + }) + + expect(getByTestId("balance-status-badge")).toBeTruthy() + expect(getByText("STALE")).toBeTruthy() + }) + + it("does not render the status badge while loading (avoids flicker during initial load)", () => { + const { queryByTestId } = renderHeader({ + statusBadge: { label: "STALE", status: "warning" }, + loading: true, + }) + + expect(queryByTestId("balance-status-badge")).toBeNull() + }) +}) diff --git a/__tests__/components/stable-balance-first-time-modal.spec.tsx b/__tests__/components/stable-balance-first-time-modal.spec.tsx new file mode 100644 index 0000000000..cdb5d431e9 --- /dev/null +++ b/__tests__/components/stable-balance-first-time-modal.spec.tsx @@ -0,0 +1,61 @@ +import React from "react" +import { fireEvent, render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" + +import { StableBalanceFirstTimeModal } from "@app/components/stable-balance-first-time-modal" + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + StableBalance: { + firstTimeModal: { + title: () => "About Convert", + dualBalance: () => "BTC and USD are two independent balances.", + trustDisclosure: () => "USD mode uses USDB tokens on Spark.", + acknowledge: () => "I understand", + }, + }, + }, + }), +})) + +const renderModal = ( + props: Partial> = {}, +) => + render( + + + , + ) + +describe("StableBalanceFirstTimeModal", () => { + it("renders both explanation paragraphs when visible", () => { + const { getByText } = renderModal() + + expect(getByText("BTC and USD are two independent balances.")).toBeTruthy() + expect(getByText("USD mode uses USDB tokens on Spark.")).toBeTruthy() + }) + + it("renders the acknowledge CTA text", () => { + const { getByText } = renderModal() + + expect(getByText("I understand")).toBeTruthy() + }) + + it("exposes the modal testID for targeting from the Convert flow", () => { + const { getByTestId } = renderModal() + + expect(getByTestId("stable-balance-first-time-modal")).toBeTruthy() + }) + + it("calls onAcknowledge when the CTA is tapped", () => { + const onAcknowledge = jest.fn() + const { getByText } = renderModal({ onAcknowledge }) + + fireEvent.press(getByText("I understand")) + + expect(onAcknowledge).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/components/status-pill.spec.tsx b/__tests__/components/status-pill.spec.tsx new file mode 100644 index 0000000000..cb8203b91f --- /dev/null +++ b/__tests__/components/status-pill.spec.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { StatusPill, type StatusPillVariant } from "@app/components/status-pill" + +const renderPill = (props: React.ComponentProps) => + render( + + + , + ) + +describe("StatusPill", () => { + it("renders the provided label", () => { + const { getByText } = renderPill({ label: "STALE", status: "warning" }) + + expect(getByText("STALE")).toBeTruthy() + }) + + const VARIANTS: StatusPillVariant[] = ["warning", "error", "success", "primary"] + + VARIANTS.forEach((variant) => { + it(`renders without crashing for variant ${variant}`, () => { + const { getByText } = renderPill({ label: "TAG", status: variant }) + + expect(getByText("TAG")).toBeTruthy() + }) + }) + + it("exposes the testID when provided", () => { + const { getByTestId } = renderPill({ + label: "STALE", + status: "warning", + testID: "balance-stale-pill", + }) + + expect(getByTestId("balance-stale-pill")).toBeTruthy() + }) + + it("hides itself from accessibility and ignores the testID when ghost", () => { + const { queryByTestId } = renderPill({ + label: "STALE", + status: "warning", + ghost: true, + testID: "should-not-appear", + }) + + expect(queryByTestId("should-not-appear")).toBeNull() + }) +}) diff --git a/__tests__/custodial/adapters/payment-adapter.spec.ts b/__tests__/custodial/adapters/payment-adapter.spec.ts index 284b1a89a1..3765ef4cfb 100644 --- a/__tests__/custodial/adapters/payment-adapter.spec.ts +++ b/__tests__/custodial/adapters/payment-adapter.spec.ts @@ -174,7 +174,8 @@ describe("createCustodialClaimDeposit", () => { describe("createCustodialConvert", () => { it("returns failed status", async () => { const result = await createCustodialConvert({ - amount: { amount: 1000, currency: WalletCurrency.Btc, currencyCode: "BTC" }, + fromAmount: { amount: 1000, currency: WalletCurrency.Btc, currencyCode: "BTC" }, + toAmount: { amount: 0, currency: WalletCurrency.Usd, currencyCode: "USD" }, direction: ConvertDirection.BtcToUsd, }) diff --git a/__tests__/hooks/use-balance-mode.spec.ts b/__tests__/hooks/use-balance-mode.spec.ts new file mode 100644 index 0000000000..fb990c0e9b --- /dev/null +++ b/__tests__/hooks/use-balance-mode.spec.ts @@ -0,0 +1,90 @@ +import { act, renderHook, waitFor } from "@testing-library/react-native" + +import { BalanceMode, useBalanceMode } from "@app/hooks/use-balance-mode" + +const mockGetItem = jest.fn() +const mockSetItem = jest.fn() + +jest.mock("@react-native-async-storage/async-storage", () => ({ + __esModule: true, + default: { + getItem: (...args: unknown[]) => mockGetItem(...args), + setItem: (...args: unknown[]) => mockSetItem(...args), + }, +})) + +describe("useBalanceMode", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetItem.mockResolvedValue(null) + mockSetItem.mockResolvedValue(undefined) + }) + + it("defaults to BTC when nothing has been persisted", async () => { + const { result } = renderHook(() => useBalanceMode()) + + await waitFor(() => { + expect(result.current.loaded).toBe(true) + }) + expect(result.current.mode).toBe(BalanceMode.Btc) + }) + + it("restores a previously persisted USD mode on mount", async () => { + mockGetItem.mockResolvedValue(BalanceMode.Usd) + + const { result } = renderHook(() => useBalanceMode()) + + await waitFor(() => { + expect(result.current.mode).toBe(BalanceMode.Usd) + }) + }) + + it("ignores corrupted storage values and keeps the default", async () => { + mockGetItem.mockResolvedValue("garbage") + + const { result } = renderHook(() => useBalanceMode()) + + await waitFor(() => { + expect(result.current.loaded).toBe(true) + }) + expect(result.current.mode).toBe(BalanceMode.Btc) + }) + + it("persists a mode change via setMode", async () => { + const { result } = renderHook(() => useBalanceMode()) + await waitFor(() => expect(result.current.loaded).toBe(true)) + + act(() => { + result.current.setMode(BalanceMode.Usd) + }) + + expect(result.current.mode).toBe(BalanceMode.Usd) + expect(mockSetItem).toHaveBeenCalledWith("selfCustodialBalanceMode", "usd") + }) + + it("toggleMode flips BTC → USD → BTC and persists each change", async () => { + const { result } = renderHook(() => useBalanceMode()) + await waitFor(() => expect(result.current.loaded).toBe(true)) + + act(() => { + result.current.toggleMode() + }) + expect(result.current.mode).toBe(BalanceMode.Usd) + expect(mockSetItem).toHaveBeenLastCalledWith("selfCustodialBalanceMode", "usd") + + act(() => { + result.current.toggleMode() + }) + expect(result.current.mode).toBe(BalanceMode.Btc) + expect(mockSetItem).toHaveBeenLastCalledWith("selfCustodialBalanceMode", "btc") + }) + + it("survives an AsyncStorage read failure without crashing", async () => { + mockGetItem.mockRejectedValue(new Error("storage down")) + + const { result } = renderHook(() => useBalanceMode()) + + await waitFor(() => expect(result.current.loaded).toBe(true)) + expect(result.current.mode).toBe(BalanceMode.Btc) + }) +}) diff --git a/__tests__/hooks/use-payments.spec.ts b/__tests__/hooks/use-payments.spec.ts index 5246122034..330d42fe06 100644 --- a/__tests__/hooks/use-payments.spec.ts +++ b/__tests__/hooks/use-payments.spec.ts @@ -29,7 +29,7 @@ jest.mock("@app/self-custodial/bridge", () => ({ getClaimFee: jest.fn(), claimDeposit: jest.fn(), }), - createConvert: jest.fn().mockReturnValue(jest.fn()), + createGetConversionQuote: jest.fn().mockReturnValue(jest.fn()), })) jest.mock("@app/custodial/adapters/payment-adapter", () => ({ @@ -67,12 +67,6 @@ describe("usePayments", () => { expect(result.current.claimDeposit!.claimDeposit).toBeDefined() }) - it("returns convert adapter", () => { - const { result } = renderHook(() => usePayments()) - - expect(result.current.convert).toBeDefined() - }) - it("returns sendPayment as undefined (not wired yet)", () => { const { result } = renderHook(() => usePayments()) @@ -144,4 +138,37 @@ describe("usePayments", () => { expect(result.current.claimDeposit).toBeUndefined() expect(result.current.convert).toBeUndefined() }) + + it("exposes getConversionQuote only on the SC path with an SDK", () => { + mockActiveAccount.mockReturnValue({ + id: "sc-default", + type: AccountType.SelfCustodial, + }) + mockSelfCustodialWallet.mockReturnValue({ sdk: mockSdk }) + + const { result } = renderHook(() => usePayments()) + + expect(result.current.getConversionQuote).toBeDefined() + }) + + it("does not expose getConversionQuote on the custodial path", () => { + mockActiveAccount.mockReturnValue({ id: "custodial-default", type: "custodial" }) + mockSelfCustodialWallet.mockReturnValue({ sdk: undefined }) + + const { result } = renderHook(() => usePayments()) + + expect(result.current.getConversionQuote).toBeUndefined() + }) + + it("does not expose getConversionQuote for a SC account missing its SDK", () => { + mockActiveAccount.mockReturnValue({ + id: "sc-default", + type: AccountType.SelfCustodial, + }) + mockSelfCustodialWallet.mockReturnValue({ sdk: undefined }) + + const { result } = renderHook(() => usePayments()) + + expect(result.current.getConversionQuote).toBeUndefined() + }) }) diff --git a/__tests__/hooks/use-stable-balance-first-time.spec.ts b/__tests__/hooks/use-stable-balance-first-time.spec.ts new file mode 100644 index 0000000000..0d9815a279 --- /dev/null +++ b/__tests__/hooks/use-stable-balance-first-time.spec.ts @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from "@testing-library/react-native" + +import { useStableBalanceFirstTime } from "@app/hooks/use-stable-balance-first-time" + +const mockGetItem = jest.fn() +const mockSetItem = jest.fn() + +jest.mock("@react-native-async-storage/async-storage", () => ({ + __esModule: true, + default: { + getItem: (...args: unknown[]) => mockGetItem(...args), + setItem: (...args: unknown[]) => mockSetItem(...args), + }, +})) + +describe("useStableBalanceFirstTime", () => { + beforeEach(() => { + jest.clearAllMocks() + mockSetItem.mockResolvedValue(undefined) + }) + + it("exposes shouldShow=true when AsyncStorage has no record", async () => { + mockGetItem.mockResolvedValue(null) + const { result } = renderHook(() => useStableBalanceFirstTime()) + + await waitFor(() => expect(result.current.loaded).toBe(true)) + expect(result.current.shouldShow).toBe(true) + }) + + it("exposes shouldShow=false when AsyncStorage has the 'true' flag", async () => { + mockGetItem.mockResolvedValue("true") + const { result } = renderHook(() => useStableBalanceFirstTime()) + + await waitFor(() => expect(result.current.loaded).toBe(true)) + expect(result.current.shouldShow).toBe(false) + }) + + it("flips shouldShow to false after markAsShown and persists the flag", async () => { + mockGetItem.mockResolvedValue(null) + const { result } = renderHook(() => useStableBalanceFirstTime()) + await waitFor(() => expect(result.current.loaded).toBe(true)) + + act(() => { + result.current.markAsShown() + }) + + expect(result.current.shouldShow).toBe(false) + expect(mockSetItem).toHaveBeenCalledWith("stableBalanceExplanationShown", "true") + }) + + it("does not claim shouldShow=true while still loading (race guard)", async () => { + mockGetItem.mockResolvedValue(null) + const { result, unmount } = renderHook(() => useStableBalanceFirstTime()) + + expect(result.current.loaded).toBe(false) + expect(result.current.shouldShow).toBe(false) + + unmount() + }) + + it("survives AsyncStorage read failure without crashing and stays hidden", async () => { + mockGetItem.mockRejectedValue(new Error("storage down")) + const { result } = renderHook(() => useStableBalanceFirstTime()) + + await waitFor(() => expect(result.current.loaded).toBe(true)) + // Fail-closed: if we cannot read storage we keep the modal hidden to avoid + // surprising the user on every launch. + expect(result.current.shouldShow).toBe(false) + }) +}) diff --git a/__tests__/screens/conversion-details-screen.spec.tsx b/__tests__/screens/conversion-details-screen.spec.tsx index e761e0e009..92d64a19c4 100644 --- a/__tests__/screens/conversion-details-screen.spec.tsx +++ b/__tests__/screens/conversion-details-screen.spec.tsx @@ -24,6 +24,7 @@ import { import { APPROXIMATE_PREFIX } from "@app/config" import { IsAuthedContextProvider } from "@app/graphql/is-authed-context" import TypesafeI18n from "@app/i18n/i18n-react" +import { loadLocale } from "@app/i18n/i18n-util.sync" import theme from "@app/rne-theme/theme" import { createCache } from "@app/graphql/cache" import { DisplayCurrency as DisplayCurrencyType } from "@app/types/amounts" @@ -39,6 +40,44 @@ jest.mock("@react-navigation/native", () => ({ }), })) +const mockUseActiveWallet = jest.fn() +const mockUseNonCustodialConversionLimits = jest.fn() + +jest.mock("@app/hooks/use-active-wallet", () => ({ + useActiveWallet: () => mockUseActiveWallet(), +})) + +jest.mock("@app/self-custodial/hooks", () => ({ + useNonCustodialConversionLimits: (...args: unknown[]) => + mockUseNonCustodialConversionLimits(...args), + usePaymentRequest: jest.fn(), +})) + +jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ + useSelfCustodialWallet: () => ({ + wallets: [], + status: "Unavailable", + accountType: "SelfCustodial", + retry: () => {}, + sdk: null, + isStableBalanceActive: false, + isBalanceStale: false, + lastReceivedPaymentId: null, + hasMoreTransactions: false, + loadingMore: false, + loadMore: async () => {}, + refreshWallets: async () => {}, + }), +})) + +jest.mock("@app/hooks/use-stable-balance-first-time", () => ({ + useStableBalanceFirstTime: () => ({ + shouldShow: false, + markAsShown: jest.fn(), + loaded: true, + }), +})) + type CurrencyPillProps = { currency?: WalletCurrency | "ALL" label?: string @@ -547,8 +586,23 @@ afterAll(() => { consoleErrorSpy = null }) +const defaultActiveWallet = { + isSelfCustodial: false, + isReady: false, + needsBackendAuth: true, + wallets: [], + status: "Unavailable", + accountType: "Custodial", +} + +const defaultLimits = { limits: null, loading: false, error: null } + +loadLocale("en") + beforeEach(() => { jest.clearAllMocks() + mockUseActiveWallet.mockReturnValue(defaultActiveWallet) + mockUseNonCustodialConversionLimits.mockReturnValue(defaultLimits) }) describe("Initial render with both wallets having balance", () => { @@ -1897,3 +1951,78 @@ describe("Conversion calculation verification", () => { expect(tolerance).toBeLessThan(0.11) }) }) + +describe("Self-custodial conversion limits gating", () => { + const scActiveWallet = { + isSelfCustodial: true, + isReady: true, + needsBackendAuth: false, + wallets: [ + { + id: "sc-btc-id", + walletCurrency: WalletCurrency.Btc, + balance: { amount: 200000, currency: WalletCurrency.Btc }, + transactions: [], + }, + { + id: "sc-usd-id", + walletCurrency: WalletCurrency.Usd, + balance: { amount: 50000, currency: WalletCurrency.Usd }, + transactions: [], + }, + ], + status: "Ready", + accountType: "SelfCustodial", + } + + const buildMocks = () => createGraphQLMocks({ btcBalance: 200000, usdBalance: 50000 }) + + it("disables Next and surfaces the unavailable message when limits fail to load", async () => { + mockUseActiveWallet.mockReturnValue(scActiveWallet) + mockUseNonCustodialConversionLimits.mockReturnValue({ + limits: null, + loading: false, + error: new Error("limits load failed"), + }) + + const Wrapper = createTestWrapper(buildMocks()) + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("next-button")).toBeTruthy() + }) + + expect(getByTestId("next-button").props.accessibilityState?.disabled).toBe(true) + await waitFor(() => { + expect(getByTestId("amount-field-error").props.children).toContain( + "Conversion is temporarily unavailable", + ) + }) + }) + + it("keeps Next disabled while limits load successfully but amount is below the minimum", async () => { + mockUseActiveWallet.mockReturnValue(scActiveWallet) + mockUseNonCustodialConversionLimits.mockReturnValue({ + limits: { minFromAmount: 1_000_000, minToAmount: null }, + loading: false, + error: null, + }) + + const Wrapper = createTestWrapper(buildMocks()) + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("next-button")).toBeTruthy() + }) + + expect(getByTestId("next-button").props.accessibilityState?.disabled).toBe(true) + }) +}) diff --git a/__tests__/screens/conversion-details-stable-balance-modal.spec.tsx b/__tests__/screens/conversion-details-stable-balance-modal.spec.tsx new file mode 100644 index 0000000000..80d140b562 --- /dev/null +++ b/__tests__/screens/conversion-details-stable-balance-modal.spec.tsx @@ -0,0 +1,388 @@ +import React from "react" +import { + Text, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native" +import { render, waitFor, fireEvent, act } from "@testing-library/react-native" +import { MockedProvider, MockedResponse } from "@apollo/client/testing" +import { NavigationContainer } from "@react-navigation/native" +import { createStackNavigator } from "@react-navigation/stack" +import { ThemeProvider } from "@rn-vui/themed" + +import { ConversionDetailsScreen } from "@app/screens/conversion-flow/conversion-details-screen" +import { + WalletCurrency, + ConversionScreenDocument, + RealtimePriceDocument, + RealtimePriceUnauthedDocument, + DisplayCurrencyDocument, + CurrencyListDocument, +} from "@app/graphql/generated" +import { IsAuthedContextProvider } from "@app/graphql/is-authed-context" +import TypesafeI18n from "@app/i18n/i18n-react" +import theme from "@app/rne-theme/theme" +import { createCache } from "@app/graphql/cache" + +const mockUseActiveWallet = jest.fn() +const mockUseSelfCustodialWallet = jest.fn() +const mockMarkAsShown = jest.fn() +const mockUseStableBalanceFirstTime = jest.fn() + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ navigate: jest.fn() }), +})) + +jest.mock("@app/hooks/use-active-wallet", () => ({ + useActiveWallet: () => mockUseActiveWallet(), +})) + +jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ + useSelfCustodialWallet: () => mockUseSelfCustodialWallet(), +})) + +jest.mock("@app/hooks/use-stable-balance-first-time", () => ({ + useStableBalanceFirstTime: () => mockUseStableBalanceFirstTime(), +})) + +jest.mock("@app/components/stable-balance-first-time-modal", () => ({ + StableBalanceFirstTimeModal: ({ + isVisible, + onAcknowledge, + }: { + isVisible: boolean + onAcknowledge: () => void + }) => { + const ReactMock = jest.requireActual("react") as typeof React + if (!isVisible) return null + return ReactMock.createElement( + "View", + { testID: "stable-balance-first-time-modal" }, + ReactMock.createElement( + "Text", + { testID: "stable-balance-first-time-ack", onPress: onAcknowledge }, + "I understand", + ), + ) + }, +})) + +type CurrencyPillProps = { + currency?: WalletCurrency | "ALL" + label?: string + containerStyle?: StyleProp + onLayout?: (event: LayoutChangeEvent) => void +} + +jest.mock("@app/components/atomic/currency-pill", () => ({ + CurrencyPill: (props: CurrencyPillProps) => {props.label ?? ""}, + useEqualPillWidth: () => ({ + widthStyle: { minWidth: 140 }, + onPillLayout: () => jest.fn(), + }), +})) + +jest.mock("@app/components/atomic/currency-pill/use-equal-pill-width", () => ({ + useEqualPillWidth: () => ({ + widthStyle: { minWidth: 140 }, + onPillLayout: () => jest.fn(), + }), +})) + +const Stack = createStackNavigator() + +const scWallets = [ + { + id: "sc-btc-wallet", + walletCurrency: WalletCurrency.Btc, + balance: { amount: 100000, currency: WalletCurrency.Btc, currencyCode: "BTC" }, + transactions: [], + }, + { + id: "sc-usd-wallet", + walletCurrency: WalletCurrency.Usd, + balance: { amount: 50000, currency: WalletCurrency.Usd, currencyCode: "USD" }, + transactions: [], + }, +] + +const createMocks = (): MockedResponse[] => { + const conversionScreenMock = { + request: { query: ConversionScreenDocument }, + result: { + data: { + __typename: "Query", + me: { + __typename: "User", + id: "user-id", + defaultAccount: { + __typename: "ConsumerAccount", + id: "account-id", + wallets: [ + { + __typename: "BTCWallet", + id: "btc-wallet-id", + balance: 100000, + walletCurrency: WalletCurrency.Btc, + }, + { + __typename: "UsdWallet", + id: "usd-wallet-id", + balance: 50000, + walletCurrency: WalletCurrency.Usd, + }, + ], + }, + }, + }, + }, + } + + const realtimePriceMock = { + request: { query: RealtimePriceDocument }, + result: { + data: { + __typename: "Query", + me: { + __typename: "User", + id: "user-id", + defaultAccount: { + __typename: "ConsumerAccount", + id: "account-id", + realtimePrice: { + __typename: "RealtimePrice", + id: "price-id", + timestamp: Date.now(), + denominatorCurrency: "USD", + btcSatPrice: { + __typename: "PriceOfOneSatInMinorUnit", + base: 2200000000, + offset: 12, + }, + usdCentPrice: { + __typename: "PriceOfOneUsdCentInMinorUnit", + base: 100000000, + offset: 6, + }, + }, + }, + }, + }, + }, + } + + const displayCurrencyMock = { + request: { query: DisplayCurrencyDocument }, + result: { + data: { + __typename: "Query", + me: { + __typename: "User", + id: "user-id", + defaultAccount: { + __typename: "ConsumerAccount", + id: "account-id", + displayCurrency: "USD", + }, + }, + }, + }, + } + + const currencyListMock = { + request: { query: CurrencyListDocument }, + result: { + data: { + __typename: "Query", + currencyList: [ + { + __typename: "Currency", + id: "USD", + flag: "", + name: "US Dollar", + symbol: "$", + fractionDigits: 2, + }, + ], + }, + }, + } + + const realtimePriceUnauthedMock = { + request: { + query: RealtimePriceUnauthedDocument, + variables: { currency: "USD" }, + }, + result: { data: { __typename: "Query", realtimePrice: null } }, + } + + return [ + conversionScreenMock, + realtimePriceMock, + realtimePriceUnauthedMock, + displayCurrencyMock, + currencyListMock, + conversionScreenMock, + realtimePriceMock, + realtimePriceUnauthedMock, + displayCurrencyMock, + currencyListMock, + ] +} + +type TestWrapperProps = { children: React.ReactNode } + +const createTestWrapper = (mocks: MockedResponse[]) => { + const TestWrapper: React.FC = ({ children }) => ( + + + + + {() => ( + + + + {children} + + + + )} + + + + + ) + return TestWrapper +} + +const originalConsoleError = console.error +let consoleErrorSpy: jest.SpyInstance | null = null + +beforeAll(() => { + consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation((message, ...args) => { + const text = String(message) + if (text.includes("not wrapped in act")) return + originalConsoleError(message, ...args) + }) +}) + +afterAll(() => { + if (!consoleErrorSpy) return + consoleErrorSpy.mockRestore() + consoleErrorSpy = null +}) + +beforeEach(() => { + jest.clearAllMocks() + mockUseStableBalanceFirstTime.mockReturnValue({ + shouldShow: true, + markAsShown: mockMarkAsShown, + loaded: true, + }) +}) + +describe("ConversionDetailsScreen — first-time stable balance modal", () => { + it("renders the first-time modal when self-custodial AND stable balance is active", async () => { + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: true, wallets: scWallets }) + mockUseSelfCustodialWallet.mockReturnValue({ isStableBalanceActive: true }) + + const Wrapper = createTestWrapper(createMocks()) + + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("stable-balance-first-time-modal")).toBeTruthy() + }) + }) + + it("does not render the modal for custodial users", async () => { + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: false, wallets: [] }) + mockUseSelfCustodialWallet.mockReturnValue({ isStableBalanceActive: true }) + + const Wrapper = createTestWrapper(createMocks()) + + const { queryByTestId, getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("wallet-toggle-button")).toBeTruthy() + }) + + expect(queryByTestId("stable-balance-first-time-modal")).toBeNull() + }) + + it("does not render the modal when stable balance is not active", async () => { + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: true, wallets: scWallets }) + mockUseSelfCustodialWallet.mockReturnValue({ isStableBalanceActive: false }) + + const Wrapper = createTestWrapper(createMocks()) + + const { queryByTestId, getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("wallet-toggle-button")).toBeTruthy() + }) + + expect(queryByTestId("stable-balance-first-time-modal")).toBeNull() + }) + + it("does not render the modal once the user has already acknowledged it", async () => { + mockUseStableBalanceFirstTime.mockReturnValue({ + shouldShow: false, + markAsShown: mockMarkAsShown, + loaded: true, + }) + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: true, wallets: scWallets }) + mockUseSelfCustodialWallet.mockReturnValue({ isStableBalanceActive: true }) + + const Wrapper = createTestWrapper(createMocks()) + + const { queryByTestId, getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId("wallet-toggle-button")).toBeTruthy() + }) + + expect(queryByTestId("stable-balance-first-time-modal")).toBeNull() + }) + + it("calls markAsShown when the user acknowledges the modal", async () => { + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: true, wallets: scWallets }) + mockUseSelfCustodialWallet.mockReturnValue({ isStableBalanceActive: true }) + + const Wrapper = createTestWrapper(createMocks()) + + const { getByTestId } = render( + + + , + ) + + const ackButton = await waitFor(() => getByTestId("stable-balance-first-time-ack")) + + await act(async () => { + fireEvent.press(ackButton) + }) + + expect(mockMarkAsShown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/screens/conversion-flow/conversion-confirmation.spec.tsx b/__tests__/screens/conversion-flow/conversion-confirmation.spec.tsx index d8d9b1428b..22826d7295 100644 --- a/__tests__/screens/conversion-flow/conversion-confirmation.spec.tsx +++ b/__tests__/screens/conversion-flow/conversion-confirmation.spec.tsx @@ -135,6 +135,21 @@ jest.mock("@app/utils/analytics", () => ({ logConversionResult: jest.fn(), })) +const mockUseActiveWallet: jest.Mock<{ + isSelfCustodial: boolean + wallets: unknown[] +}> = jest.fn(() => ({ isSelfCustodial: false, wallets: [] })) + +jest.mock("@app/hooks/use-active-wallet", () => ({ + useActiveWallet: () => mockUseActiveWallet(), +})) + +const mockNonCustodialConversion = jest.fn() + +jest.mock("@app/screens/conversion-flow/hooks/use-non-custodial-conversion", () => ({ + useNonCustodialConversion: (...args: unknown[]) => mockNonCustodialConversion(...args), +})) + jest.mock("@app/components/atomic/galoy-slider-button/galoy-slider-button", () => { type Props = { onSwipe: () => void; initialText: string } @@ -159,6 +174,15 @@ describe("conversion-confirmation-screen", () => { LL = i18nObject("en") jest.clearAllMocks() ;(useNavigation as jest.Mock).mockReturnValue({ dispatch: dispatchMock }) + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: false, wallets: [] }) + mockNonCustodialConversion.mockReturnValue({ + isQuoting: false, + hasQuoteError: false, + feeText: "", + adjustmentText: null, + canExecute: false, + execute: jest.fn(), + }) }) it("renders BTC to USD texts", async () => { @@ -361,3 +385,160 @@ describe("conversion-confirmation-screen", () => { expect(dispatchMock).toHaveBeenCalled() }) }) + +describe("conversion-confirmation-screen — self-custodial submit path", () => { + let LL: ReturnType + const dispatchMock = jest.fn() + const scWallets = [ + { + id: "sc-btc-wallet", + walletCurrency: WalletCurrency.Btc, + balance: { amount: 100000, currency: WalletCurrency.Btc, currencyCode: "BTC" }, + transactions: [], + }, + { + id: "sc-usd-wallet", + walletCurrency: WalletCurrency.Usd, + balance: { amount: 50000, currency: WalletCurrency.Usd, currencyCode: "USD" }, + transactions: [], + }, + ] + + beforeAll(() => { + loadLocale("en") + }) + + beforeEach(() => { + LL = i18nObject("en") + jest.clearAllMocks() + ;(useNavigation as jest.Mock).mockReturnValue({ dispatch: dispatchMock }) + mockUseActiveWallet.mockReturnValue({ isSelfCustodial: true, wallets: scWallets }) + }) + + it("renders the SC fee row with the feeText returned by useNonCustodialConversion", () => { + mockNonCustodialConversion.mockReturnValue({ + isQuoting: false, + hasQuoteError: false, + feeText: "$0.05", + adjustmentText: null, + canExecute: true, + execute: jest.fn(), + }) + + const route = { + key: "conversionConfirmation", + name: "conversionConfirmation", + params: { + fromWalletCurrency: WalletCurrency.Btc, + moneyAmount: { + amount: 10000, + currency: WalletCurrency.Btc, + currencyCode: WalletCurrency.Btc, + }, + }, + } as const + + render( + + + , + ) + + expect(screen.getByText("$0.05")).toBeTruthy() + }) + + it("invokes nonCustodialConversion.execute and resets navigation to conversionSuccess on success", async () => { + const executeMock = jest.fn().mockResolvedValue({ status: "success" }) + mockNonCustodialConversion.mockReturnValue({ + isQuoting: false, + hasQuoteError: false, + feeText: "$0.05", + adjustmentText: null, + canExecute: true, + execute: executeMock, + }) + + const route = { + key: "conversionConfirmation", + name: "conversionConfirmation", + params: { + fromWalletCurrency: WalletCurrency.Btc, + moneyAmount: { + amount: 10000, + currency: WalletCurrency.Btc, + currencyCode: WalletCurrency.Btc, + }, + }, + } as const + + render( + + + , + ) + + fireEvent.press( + screen.getByText( + LL.ConversionConfirmationScreen.transferButtonText({ + fromWallet: LL.common.bitcoin(), + toWallet: LL.common.dollar(), + }), + ), + ) + + await waitFor(() => { + expect(executeMock).toHaveBeenCalledTimes(1) + }) + expect(dispatchMock).toHaveBeenCalled() + expect(intraLedgerMutationMock).not.toHaveBeenCalled() + expect(intraLedgerUsdMutationMock).not.toHaveBeenCalled() + }) + + it("does not navigate when nonCustodialConversion.execute reports failure", async () => { + const executeMock = jest.fn().mockResolvedValue({ + status: "failed", + message: "SDK rejected", + }) + mockNonCustodialConversion.mockReturnValue({ + isQuoting: false, + hasQuoteError: false, + feeText: "$0.05", + adjustmentText: null, + canExecute: true, + execute: executeMock, + }) + + const route = { + key: "conversionConfirmation", + name: "conversionConfirmation", + params: { + fromWalletCurrency: WalletCurrency.Usd, + moneyAmount: { + amount: 5000, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, + }, + }, + } as const + + render( + + + , + ) + + fireEvent.press( + screen.getByText( + LL.ConversionConfirmationScreen.transferButtonText({ + fromWallet: LL.common.dollar(), + toWallet: LL.common.bitcoin(), + }), + ), + ) + + await waitFor(() => { + expect(executeMock).toHaveBeenCalledTimes(1) + }) + expect(dispatchMock).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/screens/conversion-flow/conversion-fee-row.spec.tsx b/__tests__/screens/conversion-flow/conversion-fee-row.spec.tsx new file mode 100644 index 0000000000..8bceae0f8c --- /dev/null +++ b/__tests__/screens/conversion-flow/conversion-fee-row.spec.tsx @@ -0,0 +1,86 @@ +import React from "react" +import { ActivityIndicator } from "react-native" +import { render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { ConversionFeeRow } from "@app/screens/conversion-flow/conversion-fee-row" + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + ConversionConfirmationScreen: { + feeLabel: () => "Conversion fee", + feeError: () => "Couldn't fetch the conversion fee", + }, + }, + }), +})) + +const renderRow = (props: React.ComponentProps) => + render( + + + , + ) + +describe("ConversionFeeRow", () => { + it("shows the loading spinner while the quote is being fetched", () => { + const rendered = renderRow({ + feeText: "", + adjustmentText: null, + isLoading: true, + hasError: false, + }) + + expect(rendered.UNSAFE_getByType(ActivityIndicator)).toBeTruthy() + expect(rendered.queryByText("Conversion fee")).toBeNull() + }) + + it("shows the fee value when the quote is ready", () => { + const { getByText, queryByText } = renderRow({ + feeText: "$0.05", + adjustmentText: null, + isLoading: false, + hasError: false, + }) + + expect(getByText("Conversion fee")).toBeTruthy() + expect(getByText("$0.05")).toBeTruthy() + expect(queryByText("Couldn't fetch the conversion fee")).toBeNull() + }) + + it("swaps the fee value for an error message when hasError is true", () => { + const { getByText, queryByText } = renderRow({ + feeText: "$0.05", + adjustmentText: null, + isLoading: false, + hasError: true, + }) + + expect(getByText("Couldn't fetch the conversion fee")).toBeTruthy() + expect(queryByText("$0.05")).toBeNull() + }) + + it("renders the adjustment line when provided", () => { + const { getByText } = renderRow({ + feeText: "$0.05", + adjustmentText: "Amount increased to meet the conversion minimum.", + isLoading: false, + hasError: false, + }) + + expect(getByText("Amount increased to meet the conversion minimum.")).toBeTruthy() + }) + + it("omits the adjustment line when null", () => { + const { queryByText } = renderRow({ + feeText: "$0.05", + adjustmentText: null, + isLoading: false, + hasError: false, + }) + + expect(queryByText(/increased/i)).toBeNull() + }) +}) diff --git a/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts b/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts new file mode 100644 index 0000000000..fddfb58965 --- /dev/null +++ b/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts @@ -0,0 +1,180 @@ +import { useMemo } from "react" +import { renderHook, waitFor } from "@testing-library/react-native" + +import { useConversionQuote } from "@app/screens/conversion-flow/hooks/use-conversion-quote" +import { + ConvertAmountAdjustment, + ConvertDirection, + PaymentResultStatus, + type ConvertParams, + type ConvertQuote, +} from "@app/types/payment.types" +import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" + +const mockGetQuote = jest.fn() + +jest.mock("@app/hooks/use-payments", () => ({ + usePayments: () => ({ getConversionQuote: mockGetQuote }), +})) + +jest.mock("@app/hooks/use-display-currency", () => ({ + useDisplayCurrency: () => ({ + formatMoneyAmount: ({ moneyAmount }: { moneyAmount: { amount: number } }) => + `$${(moneyAmount.amount / 100).toFixed(2)}`, + }), +})) + +jest.mock("@app/hooks/use-price-conversion", () => ({ + usePriceConversion: () => ({ + convertMoneyAmount: (amount: { amount: number }) => amount, + }), +})) + +jest.mock("@react-native-firebase/crashlytics", () => { + const recordError = jest.fn() + return { + __esModule: true, + default: () => ({ recordError }), + } +}) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + ConversionConfirmationScreen: { + amountFloored: () => "Amount floored", + amountDustBumped: () => "Amount bumped", + }, + }, + }), +})) + +const buildQuote = (amountAdjustment?: ConvertAmountAdjustment): ConvertQuote => ({ + feeAmount: toUsdMoneyAmount(5), + amountAdjustment, + execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), +}) + +// renderHook passes initialProps by reference, so a single object survives re-renders. +const btcToUsdParams: ConvertParams = { + fromAmount: toBtcMoneyAmount(10_000), + toAmount: toUsdMoneyAmount(500), + direction: ConvertDirection.BtcToUsd, +} + +const usdToBtcParams: ConvertParams = { + fromAmount: toUsdMoneyAmount(500), + toAmount: toBtcMoneyAmount(10_000), + direction: ConvertDirection.UsdToBtc, +} + +// Memoize via useMemo so changing the direction key swaps objects exactly once. +const useHookUnderTest = (direction: ConvertDirection | null) => { + const params = useMemo(() => { + if (direction === ConvertDirection.BtcToUsd) return btcToUsdParams + if (direction === ConvertDirection.UsdToBtc) return usdToBtcParams + return null + }, [direction]) + return useConversionQuote(params) +} + +describe("useConversionQuote", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("stays idle when params are null", async () => { + const { result } = renderHook(() => useHookUnderTest(null)) + + await waitFor(() => expect(result.current.isQuoting).toBe(false)) + expect(result.current.quote).toBeNull() + expect(result.current.feeText).toBe("") + expect(mockGetQuote).not.toHaveBeenCalled() + }) + + it("transitions Loading → Ready and surfaces formattedFee", async () => { + const quote = buildQuote() + mockGetQuote.mockResolvedValue(quote) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.quote).toBe(quote)) + expect(result.current.feeText).toBe("$0.05") + expect(result.current.isQuoting).toBe(false) + expect(result.current.hasQuoteError).toBe(false) + }) + + it("transitions to Error when the quote is null", async () => { + mockGetQuote.mockResolvedValue(null) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + expect(result.current.quote).toBeNull() + expect(result.current.feeText).toBe("") + }) + + it("transitions to Error when getConversionQuote rejects", async () => { + mockGetQuote.mockRejectedValue(new Error("boom")) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + }) + + it("maps FlooredToMin to the correct i18n message", async () => { + mockGetQuote.mockResolvedValue(buildQuote(ConvertAmountAdjustment.FlooredToMin)) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.adjustmentText).toBe("Amount floored")) + }) + + it("maps IncreasedToAvoidDust to the correct i18n message", async () => { + mockGetQuote.mockResolvedValue( + buildQuote(ConvertAmountAdjustment.IncreasedToAvoidDust), + ) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.adjustmentText).toBe("Amount bumped")) + }) + + it("returns null adjustmentText when the SDK reports none", async () => { + mockGetQuote.mockResolvedValue(buildQuote()) + + const { result } = renderHook(() => useHookUnderTest(ConvertDirection.BtcToUsd)) + + await waitFor(() => expect(result.current.quote).not.toBeNull()) + expect(result.current.adjustmentText).toBeNull() + }) + + it("cancels the stale in-flight request when params change", async () => { + let resolveFirst: (value: ConvertQuote) => void = () => {} + mockGetQuote.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + const second = buildQuote() + mockGetQuote.mockResolvedValueOnce(second) + + const initialProps: { direction: ConvertDirection } = { + direction: ConvertDirection.BtcToUsd, + } + const { result, rerender } = renderHook( + ({ direction }: { direction: ConvertDirection }) => useHookUnderTest(direction), + { initialProps }, + ) + + rerender({ direction: ConvertDirection.UsdToBtc }) + + await waitFor(() => expect(result.current.quote).toBe(second)) + + resolveFirst(buildQuote()) + + // Stale resolve should not overwrite newer state. + expect(result.current.quote).toBe(second) + }) +}) diff --git a/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts new file mode 100644 index 0000000000..3b423d665d --- /dev/null +++ b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts @@ -0,0 +1,270 @@ +import { act, renderHook, waitFor } from "@testing-library/react-native" + +import { WalletCurrency } from "@app/graphql/generated" +import { useNonCustodialConversion } from "@app/screens/conversion-flow/hooks/use-non-custodial-conversion" +import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" +import { + ConvertAmountAdjustment, + ConvertDirection, + PaymentResultStatus, +} from "@app/types/payment.types" + +const mockGetQuote = jest.fn() +const mockConvertMoneyAmount = jest.fn() + +jest.mock("@app/hooks/use-payments", () => ({ + usePayments: () => ({ getConversionQuote: mockGetQuote }), +})) + +jest.mock("@app/hooks/use-price-conversion", () => ({ + usePriceConversion: () => ({ convertMoneyAmount: mockConvertMoneyAmount }), +})) + +jest.mock("@app/hooks/use-display-currency", () => ({ + useDisplayCurrency: () => ({ + formatMoneyAmount: ({ moneyAmount }: { moneyAmount: { amount: number } }) => + `$${(moneyAmount.amount / 100).toFixed(2)}`, + }), +})) + +jest.mock("@app/utils/analytics", () => ({ + logConversionAttempt: jest.fn(), +})) + +jest.mock("@react-native-firebase/crashlytics", () => { + const recordError = jest.fn() + return { + __esModule: true, + default: () => ({ recordError }), + } +}) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + ConversionConfirmationScreen: { + amountFloored: () => "Amount increased to meet the conversion minimum.", + amountDustBumped: () => "Amount increased to convert your full balance.", + }, + errors: { + generic: () => "Generic error", + }, + }, + }), +})) + +const defaultParams = { + fromCurrency: WalletCurrency.Btc, + moneyAmount: toUsdMoneyAmount(500), + enabled: true, +} + +const makeQuote = ( + overrides: Partial<{ + amountAdjustment?: ConvertAmountAdjustment + feeAmount: ReturnType + execute: jest.Mock + }> = {}, +) => ({ + feeAmount: overrides.feeAmount ?? toUsdMoneyAmount(5), + amountAdjustment: overrides.amountAdjustment, + execute: + overrides.execute ?? + jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), +}) + +describe("useNonCustodialConversion", () => { + beforeEach(() => { + jest.clearAllMocks() + mockConvertMoneyAmount.mockImplementation( + (amount: { amount: number }, currency: WalletCurrency) => + currency === WalletCurrency.Btc + ? toBtcMoneyAmount(amount.amount * 100) + : toUsdMoneyAmount(amount.amount), + ) + }) + + it("stays idle when enabled is false and does not call getQuote", async () => { + const { result } = renderHook(() => + useNonCustodialConversion({ ...defaultParams, enabled: false }), + ) + + await waitFor(() => { + expect(result.current.isQuoting).toBe(false) + }) + expect(result.current.canExecute).toBe(false) + expect(mockGetQuote).not.toHaveBeenCalled() + }) + + it("transitions Loading → Ready and exposes the formatted fee", async () => { + const quote = makeQuote() + mockGetQuote.mockResolvedValue(quote) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + + await waitFor(() => expect(result.current.canExecute).toBe(true)) + expect(result.current.isQuoting).toBe(false) + expect(result.current.feeText).toBe("$0.05") + expect(result.current.hasQuoteError).toBe(false) + expect(mockGetQuote).toHaveBeenCalledWith({ + fromAmount: expect.objectContaining({ currency: WalletCurrency.Btc }), + toAmount: expect.objectContaining({ currency: WalletCurrency.Usd }), + direction: ConvertDirection.BtcToUsd, + }) + }) + + it("falls into Error when getQuote resolves to null", async () => { + mockGetQuote.mockResolvedValue(null) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + expect(result.current.canExecute).toBe(false) + expect(result.current.feeText).toBe("") + }) + + it("falls into Error when getQuote rejects", async () => { + mockGetQuote.mockRejectedValue(new Error("boom")) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + expect(result.current.canExecute).toBe(false) + }) + + it("maps FlooredToMin adjustment to the correct text", async () => { + mockGetQuote.mockResolvedValue( + makeQuote({ amountAdjustment: ConvertAmountAdjustment.FlooredToMin }), + ) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + + await waitFor(() => + expect(result.current.adjustmentText).toBe( + "Amount increased to meet the conversion minimum.", + ), + ) + }) + + it("maps IncreasedToAvoidDust adjustment to the correct text", async () => { + mockGetQuote.mockResolvedValue( + makeQuote({ amountAdjustment: ConvertAmountAdjustment.IncreasedToAvoidDust }), + ) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + + await waitFor(() => + expect(result.current.adjustmentText).toBe( + "Amount increased to convert your full balance.", + ), + ) + }) + + it("execute() delegates to quote.execute and reports success", async () => { + const execute = jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }) + mockGetQuote.mockResolvedValue(makeQuote({ execute })) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + await waitFor(() => expect(result.current.canExecute).toBe(true)) + + let outcome: Awaited> | undefined + await act(async () => { + outcome = await result.current.execute() + }) + + expect(execute).toHaveBeenCalledTimes(1) + expect(outcome).toEqual({ status: PaymentResultStatus.Success }) + }) + + it("execute() surfaces the SDK error message on failure", async () => { + const execute = jest.fn().mockResolvedValue({ + status: PaymentResultStatus.Failed, + errors: [{ message: "SDK boom" }], + }) + mockGetQuote.mockResolvedValue(makeQuote({ execute })) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + await waitFor(() => expect(result.current.canExecute).toBe(true)) + + let outcome: Awaited> | undefined + await act(async () => { + outcome = await result.current.execute() + }) + + expect(outcome).toEqual({ + status: PaymentResultStatus.Failed, + message: "SDK boom", + }) + }) + + it("execute() refuses to run when no quote is ready", async () => { + mockGetQuote.mockResolvedValue(null) + + const { result } = renderHook(() => useNonCustodialConversion(defaultParams)) + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + + let outcome: Awaited> | undefined + await act(async () => { + outcome = await result.current.execute() + }) + + expect(outcome).toEqual({ + status: PaymentResultStatus.Failed, + message: "Generic error", + }) + }) + + it("snapshots the first Ready quote and stops re-quoting on price ticks", async () => { + const quote = makeQuote() + mockGetQuote.mockResolvedValue(quote) + + const { result, rerender } = renderHook(() => + useNonCustodialConversion(defaultParams), + ) + + await waitFor(() => expect(result.current.canExecute).toBe(true)) + expect(mockGetQuote).toHaveBeenCalledTimes(1) + + // Simulate a realtime-price tick: the price-conversion hook returns a new + // identity for convertMoneyAmount, so liveQuoteParams would change. + mockConvertMoneyAmount.mockImplementation( + (amount: { amount: number }, currency: WalletCurrency) => + currency === WalletCurrency.Btc + ? toBtcMoneyAmount(amount.amount * 101) + : toUsdMoneyAmount(amount.amount), + ) + rerender({}) + + await new Promise((resolve) => { + setTimeout(resolve, 600) + }) + expect(mockGetQuote).toHaveBeenCalledTimes(1) + }) + + it("re-quotes when moneyAmount changes and execute() runs the latest quote", async () => { + const firstQuote = makeQuote({ execute: jest.fn() }) + const secondQuote = makeQuote({ + execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), + }) + mockGetQuote.mockResolvedValueOnce(firstQuote).mockResolvedValueOnce(secondQuote) + + const { result, rerender } = renderHook( + (params: typeof defaultParams) => useNonCustodialConversion(params), + { initialProps: defaultParams }, + ) + + await waitFor(() => expect(result.current.canExecute).toBe(true)) + + rerender({ ...defaultParams, moneyAmount: toUsdMoneyAmount(700) }) + + await waitFor(() => expect(mockGetQuote).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.canExecute).toBe(true)) + + await act(async () => { + await result.current.execute() + }) + + expect(secondQuote.execute).toHaveBeenCalledTimes(1) + expect(firstQuote.execute).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/screens/home.spec.tsx b/__tests__/screens/home.spec.tsx index c3abd655c9..9c87566fe8 100644 --- a/__tests__/screens/home.spec.tsx +++ b/__tests__/screens/home.spec.tsx @@ -14,6 +14,15 @@ import { let currentMocks: MockedResponse[] = [] +jest.mock("@react-native-async-storage/async-storage", () => ({ + __esModule: true, + default: { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), + }, +})) + jest.mock("@app/hooks/use-backup-nudge-state", () => ({ useBackupNudgeState: () => ({ shouldShowBanner: false, @@ -30,6 +39,13 @@ jest.mock("@app/screens/spark-onboarding/trust-model-screen", () => ({ // eslint-disable-next-line prefer-const let mockActiveWalletOverride: Record | null = null +// eslint-disable-next-line prefer-const +let mockFeatureFlagsOverride: Record | null = null +// eslint-disable-next-line prefer-const +let mockSelfCustodialWalletOverride: Record | null = null +const mockToggleBalanceMode = jest.fn() +// eslint-disable-next-line prefer-const +let mockBalanceModeValue: "btc" | "usd" = "usd" jest.mock("@app/hooks/use-active-wallet", () => ({ useActiveWallet: () => @@ -43,6 +59,57 @@ jest.mock("@app/hooks/use-active-wallet", () => ({ }, })) +jest.mock("@app/config/feature-flags-context", () => { + const actual = jest.requireActual("@app/config/feature-flags-context") + return { + ...actual, + useFeatureFlags: () => + mockFeatureFlagsOverride ?? { + nonCustodialEnabled: false, + stableBalanceEnabled: false, + }, + useRemoteConfig: () => ({ + loading: false, + remoteConfigReady: true, + featureFlags: { + nonCustodialEnabled: false, + stableBalanceEnabled: false, + }, + }), + } +}) + +jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ + useSelfCustodialWallet: () => + mockSelfCustodialWalletOverride ?? { + sdk: null, + wallets: [], + status: "unavailable", + isStableBalanceActive: false, + isBalanceStale: false, + lastReceivedPaymentId: null, + hasMoreTransactions: false, + loadingMore: false, + loadMore: jest.fn(), + refreshWallets: jest.fn(), + refreshStableBalanceActive: jest.fn(), + retry: jest.fn(), + }, +})) + +jest.mock("@app/hooks/use-balance-mode", () => { + const BalanceMode = { Btc: "btc", Usd: "usd" } as const + return { + BalanceMode, + useBalanceMode: () => ({ + mode: mockBalanceModeValue, + setMode: jest.fn(), + toggleMode: mockToggleBalanceMode, + loaded: true, + }), + } +}) + jest.mock("@app/utils/helper", () => ({ ...jest.requireActual("@app/utils/helper"), isIos: true, @@ -404,4 +471,123 @@ describe("HomeScreen", () => { mockActiveWalletOverride = null }) + + describe("Stable Balance mode toggle (self-custodial)", () => { + const selfCustodialWallets = [ + { + id: "btc-1", + walletCurrency: "BTC", + balance: { amount: 5517, currency: "BTC", currencyCode: "BTC" }, + transactions: [], + }, + { + id: "usd-1", + walletCurrency: "USD", + balance: { amount: 100, currency: "USD", currencyCode: "USD" }, + transactions: [], + }, + ] + + beforeEach(() => { + mockToggleBalanceMode.mockClear() + mockActiveWalletOverride = { + wallets: selfCustodialWallets, + status: "ready", + accountType: "self-custodial", + isReady: true, + isSelfCustodial: true, + needsBackendAuth: false, + } + mockSelfCustodialWalletOverride = { + sdk: { id: "fake-sdk" }, + wallets: selfCustodialWallets, + status: "ready", + isStableBalanceActive: true, + isBalanceStale: false, + lastReceivedPaymentId: null, + hasMoreTransactions: false, + loadingMore: false, + loadMore: jest.fn(), + refreshWallets: jest.fn(), + refreshStableBalanceActive: jest.fn(), + retry: jest.fn(), + } + }) + + afterEach(() => { + mockActiveWalletOverride = null + mockSelfCustodialWalletOverride = null + mockFeatureFlagsOverride = null + mockBalanceModeValue = "usd" + }) + + it("shows the balance mode toggle when SB is enabled and active", async () => { + mockFeatureFlagsOverride = { + nonCustodialEnabled: true, + stableBalanceEnabled: true, + } + + const { getByTestId } = render( + + + , + ) + + await waitFor(() => expect(getByTestId("balance-mode-toggle")).toBeTruthy()) + }) + + it("hides the toggle when stableBalanceEnabled flag is off", async () => { + mockFeatureFlagsOverride = { + nonCustodialEnabled: true, + stableBalanceEnabled: false, + } + + const { queryByTestId } = render( + + + , + ) + + await act(async () => {}) + expect(queryByTestId("balance-mode-toggle")).toBeNull() + }) + + it("hides the toggle when Stable Balance is inactive even if flag is on", async () => { + mockFeatureFlagsOverride = { + nonCustodialEnabled: true, + stableBalanceEnabled: true, + } + mockSelfCustodialWalletOverride = { + ...(mockSelfCustodialWalletOverride as Record), + isStableBalanceActive: false, + } + + const { queryByTestId } = render( + + + , + ) + + await act(async () => {}) + expect(queryByTestId("balance-mode-toggle")).toBeNull() + }) + + it("invokes toggleMode when the label is pressed", async () => { + mockFeatureFlagsOverride = { + nonCustodialEnabled: true, + stableBalanceEnabled: true, + } + + const { getByTestId } = render( + + + , + ) + + const toggle = await waitFor(() => getByTestId("balance-mode-toggle")) + fireEvent.press(toggle) + + expect(mockToggleBalanceMode).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/__tests__/screens/settings-screen/stable-balance-setting.spec.tsx b/__tests__/screens/settings-screen/stable-balance-setting.spec.tsx new file mode 100644 index 0000000000..0fc213229e --- /dev/null +++ b/__tests__/screens/settings-screen/stable-balance-setting.spec.tsx @@ -0,0 +1,107 @@ +import React from "react" +import { fireEvent, render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { StableBalanceSetting } from "@app/screens/settings-screen/settings/stable-balance" +import { AccountType } from "@app/types/wallet.types" + +const mockNavigate = jest.fn() +const mockUseFeatureFlags = jest.fn() +const mockUseAccountRegistry = jest.fn() + +jest.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})) + +jest.mock("@app/config/feature-flags-context", () => ({ + useFeatureFlags: () => mockUseFeatureFlags(), +})) + +jest.mock("@app/hooks/use-account-registry", () => ({ + useAccountRegistry: () => mockUseAccountRegistry(), +})) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + StableBalance: { + settingsRowTitle: () => "Stable Balance", + }, + }, + }), +})) + +const renderRow = () => + render( + + + , + ) + +describe("StableBalanceSetting", () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseFeatureFlags.mockReturnValue({ + nonCustodialEnabled: true, + stableBalanceEnabled: true, + }) + mockUseAccountRegistry.mockReturnValue({ + activeAccount: { type: AccountType.SelfCustodial }, + }) + }) + + it("renders the row when flags are on and account is self-custodial", () => { + const { getByText } = renderRow() + + expect(getByText("Stable Balance")).toBeTruthy() + }) + + it("navigates to stableBalanceSettings when tapped", () => { + const { getByText } = renderRow() + + fireEvent.press(getByText("Stable Balance")) + + expect(mockNavigate).toHaveBeenCalledWith("stableBalanceSettings") + }) + + it("renders nothing when nonCustodialEnabled is false", () => { + mockUseFeatureFlags.mockReturnValue({ + nonCustodialEnabled: false, + stableBalanceEnabled: true, + }) + + const { queryByText } = renderRow() + + expect(queryByText("Stable Balance")).toBeNull() + }) + + it("renders nothing when stableBalanceEnabled is false", () => { + mockUseFeatureFlags.mockReturnValue({ + nonCustodialEnabled: true, + stableBalanceEnabled: false, + }) + + const { queryByText } = renderRow() + + expect(queryByText("Stable Balance")).toBeNull() + }) + + it("renders nothing when the active account is custodial", () => { + mockUseAccountRegistry.mockReturnValue({ + activeAccount: { type: AccountType.Custodial }, + }) + + const { queryByText } = renderRow() + + expect(queryByText("Stable Balance")).toBeNull() + }) + + it("renders nothing when there is no active account", () => { + mockUseAccountRegistry.mockReturnValue({ activeAccount: undefined }) + + const { queryByText } = renderRow() + + expect(queryByText("Stable Balance")).toBeNull() + }) +}) diff --git a/__tests__/screens/stable-balance-confirm-modal.spec.tsx b/__tests__/screens/stable-balance-confirm-modal.spec.tsx new file mode 100644 index 0000000000..a086955f80 --- /dev/null +++ b/__tests__/screens/stable-balance-confirm-modal.spec.tsx @@ -0,0 +1,115 @@ +import React from "react" +import { fireEvent, render } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { StableBalanceConfirmModal } from "@app/screens/stable-balance-settings-screen/stable-balance-confirm-modal" + +type ModalProps = React.ComponentProps + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + StableBalance: { + toggleModal: { + activateTitle: () => "Activate Stable Balance", + activateBody: () => "Your BTC will be converted to USDB.", + activateConfirm: () => "Activate", + deactivateTitle: () => "Deactivate Stable Balance", + deactivateBody: () => "Your USDB will be converted back to BTC.", + deactivateConfirm: () => "Deactivate", + cancel: () => "Cancel", + }, + }, + ConversionConfirmationScreen: { + feeLabel: () => "Conversion fee", + feeError: () => "Couldn't fetch the conversion fee", + }, + common: { + cancel: () => "Cancel", + }, + }, + }), +})) + +const onConfirm = jest.fn() +const onCancel = jest.fn() + +const baseProps: ModalProps = { + isVisible: true, + isActivating: true, + feeText: "$0.05", + adjustmentText: null, + isLoading: false, + hasError: false, + showFeeRow: true, + isSubmitting: false, + onConfirm, + onCancel, +} + +const renderModal = (overrides: Partial = {}) => + render( + + + , + ) + +describe("StableBalanceConfirmModal", () => { + beforeEach(() => { + onConfirm.mockReset() + onCancel.mockReset() + }) + + it("renders the activation title, body and fee", () => { + const { getByText } = renderModal() + + expect(getByText("Activate Stable Balance")).toBeTruthy() + expect(getByText("Your BTC will be converted to USDB.")).toBeTruthy() + expect(getByText("$0.05")).toBeTruthy() + }) + + it("renders the deactivation title and warning when provided", () => { + const { getByText } = renderModal({ + isActivating: false, + deactivationWarning: "You still have 5.00 USD.", + }) + + expect(getByText("Deactivate Stable Balance")).toBeTruthy() + expect(getByText("You still have 5.00 USD.")).toBeTruthy() + }) + + it("invokes onConfirm when the primary button is pressed", () => { + const { getByText } = renderModal() + + fireEvent.press(getByText("Activate")) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + + it("does not invoke onConfirm when the fee preview has an error", () => { + const { getByText } = renderModal({ hasError: true }) + + fireEvent.press(getByText("Activate")) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it("does not invoke onConfirm while loading", () => { + const { getByText } = renderModal({ isLoading: true }) + + fireEvent.press(getByText("Activate")) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it("invokes onCancel when the secondary button is pressed", () => { + const { getByText } = renderModal() + + fireEvent.press(getByText("Cancel")) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it("hides the fee row when showFeeRow is false", () => { + const { queryByText } = renderModal({ showFeeRow: false }) + + expect(queryByText("$0.05")).toBeNull() + }) +}) diff --git a/__tests__/screens/stable-balance-settings-screen.spec.tsx b/__tests__/screens/stable-balance-settings-screen.spec.tsx new file mode 100644 index 0000000000..547f987685 --- /dev/null +++ b/__tests__/screens/stable-balance-settings-screen.spec.tsx @@ -0,0 +1,349 @@ +import React from "react" +import { fireEvent, render, waitFor } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import theme from "@app/rne-theme/theme" +import { WalletCurrency } from "@app/graphql/generated" + +import { StableBalanceSettingsScreen } from "@app/screens/stable-balance-settings-screen" + +jest.mock("react-native-reanimated", () => { + const RNView = jest.requireActual("react-native").View + return { + __esModule: true, + default: { + View: RNView, + createAnimatedComponent: (component: React.ComponentType) => component, + }, + useSharedValue: (initial: number) => ({ value: initial }), + useAnimatedStyle: () => ({}), + withTiming: (value: number) => value, + interpolateColor: () => "transparent", + View: RNView, + } +}) + +const mockActivate = jest.fn() +const mockDeactivate = jest.fn() +const mockRefresh = jest.fn() +const mockRefreshStableBalanceActive = jest.fn() +const mockWallet = jest.fn() +const mockToggleQuote = jest.fn() +const mockRecordError = jest.fn() +const mockToastShow = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError }), +})) + +jest.mock("@app/utils/toast", () => ({ + toastShow: (...args: unknown[]) => mockToastShow(...args), +})) + +jest.mock("@app/self-custodial/bridge", () => ({ + activateStableBalance: (...args: unknown[]) => mockActivate(...args), + deactivateStableBalance: (...args: unknown[]) => mockDeactivate(...args), +})) + +jest.mock("@app/hooks/use-display-currency", () => ({ + useDisplayCurrency: () => ({ + formatMoneyAmount: ({ moneyAmount }: { moneyAmount: { amount: number } }) => + `$${(moneyAmount.amount / 100).toFixed(2)}`, + }), +})) + +jest.mock("@app/hooks/use-price-conversion", () => ({ + usePriceConversion: () => ({ + convertMoneyAmount: (amount: { amount: number }) => amount, + }), +})) + +jest.mock("@app/self-custodial/config", () => ({ + SparkToken: { Label: "USDB", Ticker: "USDB" }, +})) + +jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ + useSelfCustodialWallet: () => mockWallet(), +})) + +jest.mock( + "@app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote", + () => ({ + useStableBalanceToggleQuote: (...args: unknown[]) => mockToggleQuote(...args), + }), +) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + StableBalance: { + settingsTitle: () => "Stable Balance", + settingsDescription: () => "Stable Balance description.", + activationLabel: () => "Active", + activeHint: () => "Holding USD", + inactiveHint: () => "Holding BTC only", + deactivateWarningBody: ({ amount }: { amount: string }) => + `You still have ${amount}.`, + toggleFailedToast: () => "Could not update Stable Balance. Please try again.", + toggleModal: { + activateTitle: () => "Activate Stable Balance", + activateBody: () => "Your BTC will be converted to USDB.", + activateConfirm: () => "Activate", + deactivateTitle: () => "Deactivate Stable Balance", + deactivateBody: () => "Your USDB will be converted back to BTC.", + deactivateConfirm: () => "Deactivate", + cancel: () => "Cancel", + }, + }, + ConversionConfirmationScreen: { + feeLabel: () => "Conversion fee", + feeError: () => "Couldn't fetch the conversion fee", + }, + common: { + cancel: () => "Cancel", + switch: () => "Switch", + }, + }, + }), +})) + +const renderScreen = () => + render( + + + , + ) + +const baseContext = { + sdk: { updateUserSettings: jest.fn() }, + isStableBalanceActive: false, + wallets: [ + { + walletCurrency: WalletCurrency.Btc, + balance: { amount: 0 }, + }, + { + walletCurrency: WalletCurrency.Usd, + balance: { amount: 0 }, + }, + ], + refreshWallets: mockRefresh, + refreshStableBalanceActive: mockRefreshStableBalanceActive, +} + +const readyQuote = { + isQuoting: false, + hasQuoteError: false, + feeText: "$0.05", + adjustmentText: null, +} + +describe("StableBalanceSettingsScreen", () => { + beforeEach(() => { + jest.clearAllMocks() + mockActivate.mockResolvedValue(undefined) + mockDeactivate.mockResolvedValue(undefined) + mockRefresh.mockResolvedValue(undefined) + mockRefreshStableBalanceActive.mockResolvedValue(undefined) + mockWallet.mockReturnValue(baseContext) + mockToggleQuote.mockReturnValue(readyQuote) + }) + + it("renders the settings title and description", () => { + const { getByText } = renderScreen() + + expect(getByText("Stable Balance")).toBeTruthy() + expect(getByText("Stable Balance description.")).toBeTruthy() + }) + + it("shows inactive hint when Stable Balance is off", () => { + const { getByText } = renderScreen() + + expect(getByText("Holding BTC only")).toBeTruthy() + }) + + it("shows active hint when Stable Balance is on", () => { + mockWallet.mockReturnValue({ ...baseContext, isStableBalanceActive: true }) + const { getByText } = renderScreen() + + expect(getByText("Holding USD")).toBeTruthy() + }) + + it("activates directly when BTC balance is zero (no conversion needed)", async () => { + const { getByTestId, queryByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await waitFor(() => { + expect(mockActivate).toHaveBeenCalledWith(baseContext.sdk, "USDB") + }) + expect(queryByTestId("stable-balance-confirm-modal")).toBeNull() + }) + + it("deactivates directly when USD balance is zero (no conversion needed)", async () => { + mockWallet.mockReturnValue({ ...baseContext, isStableBalanceActive: true }) + const { getByTestId, queryByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await waitFor(() => { + expect(mockDeactivate).toHaveBeenCalledWith(baseContext.sdk) + }) + expect(queryByTestId("stable-balance-confirm-modal")).toBeNull() + }) + + it("shows confirm modal with fee on activate when BTC balance > 0", () => { + mockWallet.mockReturnValue({ + ...baseContext, + wallets: [ + { walletCurrency: WalletCurrency.Btc, balance: { amount: 5000 } }, + { walletCurrency: WalletCurrency.Usd, balance: { amount: 0 } }, + ], + }) + const { getByTestId, getByText } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + expect(getByTestId("stable-balance-confirm-modal")).toBeTruthy() + expect(getByText("Activate Stable Balance")).toBeTruthy() + expect(getByText("$0.05")).toBeTruthy() + expect(mockActivate).not.toHaveBeenCalled() + }) + + it("shows confirm modal with fee on deactivate when USD balance > 0", () => { + mockWallet.mockReturnValue({ + ...baseContext, + isStableBalanceActive: true, + wallets: [ + { walletCurrency: WalletCurrency.Btc, balance: { amount: 1000 } }, + { walletCurrency: WalletCurrency.Usd, balance: { amount: 500 } }, + ], + }) + const { getByTestId, getByText } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + expect(getByTestId("stable-balance-confirm-modal")).toBeTruthy() + expect(getByText("Deactivate Stable Balance")).toBeTruthy() + expect(getByText("You still have $5.00.")).toBeTruthy() + expect(getByText("$0.05")).toBeTruthy() + expect(mockDeactivate).not.toHaveBeenCalled() + }) + + it("runs activation when the user confirms on the modal", async () => { + mockWallet.mockReturnValue({ + ...baseContext, + wallets: [ + { walletCurrency: WalletCurrency.Btc, balance: { amount: 5000 } }, + { walletCurrency: WalletCurrency.Usd, balance: { amount: 0 } }, + ], + }) + const { getByTestId, getByText } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + fireEvent.press(getByText("Activate")) + + await waitFor(() => { + expect(mockActivate).toHaveBeenCalledWith(baseContext.sdk, "USDB") + }) + }) + + it("runs deactivation when the user confirms on the modal", async () => { + mockWallet.mockReturnValue({ + ...baseContext, + isStableBalanceActive: true, + wallets: [ + { walletCurrency: WalletCurrency.Btc, balance: { amount: 1000 } }, + { walletCurrency: WalletCurrency.Usd, balance: { amount: 500 } }, + ], + }) + const { getByTestId, getAllByText } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + // Two "Deactivate" strings: hint text and modal button — press the last (button) + const deactivateButtons = getAllByText("Deactivate") + fireEvent.press(deactivateButtons[deactivateButtons.length - 1]) + + await waitFor(() => { + expect(mockDeactivate).toHaveBeenCalledWith(baseContext.sdk) + }) + }) + + it("cancels the toggle without invoking the SDK when the user dismisses the modal", () => { + mockWallet.mockReturnValue({ + ...baseContext, + wallets: [ + { walletCurrency: WalletCurrency.Btc, balance: { amount: 5000 } }, + { walletCurrency: WalletCurrency.Usd, balance: { amount: 0 } }, + ], + }) + const { getByTestId, getByText } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + fireEvent.press(getByText("Cancel")) + + expect(mockActivate).not.toHaveBeenCalled() + expect(mockDeactivate).not.toHaveBeenCalled() + }) + + it("does not invoke the SDK when it is null (inactive wallet)", async () => { + mockWallet.mockReturnValue({ ...baseContext, sdk: null }) + + const { getByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + expect(mockActivate).not.toHaveBeenCalled() + expect(mockDeactivate).not.toHaveBeenCalled() + }) + + it("calls refreshStableBalanceActive before refreshWallets after activating", async () => { + const { getByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await waitFor(() => { + expect(mockRefreshStableBalanceActive).toHaveBeenCalledTimes(1) + expect(mockRefresh).toHaveBeenCalledTimes(1) + }) + const refreshActiveOrder = mockRefreshStableBalanceActive.mock.invocationCallOrder[0] + const refreshWalletsOrder = mockRefresh.mock.invocationCallOrder[0] + expect(refreshActiveOrder).toBeLessThan(refreshWalletsOrder) + }) + + it("records to crashlytics and shows error toast when activation rejects", async () => { + const failure = new Error("update failed") + mockActivate.mockRejectedValueOnce(failure) + const { getByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await waitFor(() => { + expect(mockRecordError).toHaveBeenCalledWith(failure) + expect(mockToastShow).toHaveBeenCalledTimes(1) + }) + expect(mockToastShow.mock.calls[0][0].type).toBe("error") + expect(mockRefresh).not.toHaveBeenCalled() + expect(mockRefreshStableBalanceActive).not.toHaveBeenCalled() + }) + + it("records to crashlytics and shows error toast when deactivation rejects", async () => { + const failure = new Error("deactivate failed") + mockDeactivate.mockRejectedValueOnce(failure) + mockWallet.mockReturnValue({ ...baseContext, isStableBalanceActive: true }) + const { getByTestId } = renderScreen() + + fireEvent(getByTestId("stable-balance-switch"), "pressIn") + + await waitFor(() => { + expect(mockRecordError).toHaveBeenCalledWith(failure) + expect(mockToastShow).toHaveBeenCalledTimes(1) + }) + expect(mockRefresh).not.toHaveBeenCalled() + expect(mockRefreshStableBalanceActive).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/screens/stable-balance-settings-screen/use-stable-balance-toggle-quote.spec.ts b/__tests__/screens/stable-balance-settings-screen/use-stable-balance-toggle-quote.spec.ts new file mode 100644 index 0000000000..2eac9ab900 --- /dev/null +++ b/__tests__/screens/stable-balance-settings-screen/use-stable-balance-toggle-quote.spec.ts @@ -0,0 +1,202 @@ +import { renderHook, waitFor } from "@testing-library/react-native" + +import { WalletCurrency } from "@app/graphql/generated" +import { useStableBalanceToggleQuote } from "@app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote" +import { DisplayCurrency, toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" +import { + ConvertAmountAdjustment, + ConvertDirection, + PaymentResultStatus, +} from "@app/types/payment.types" + +const mockGetQuote = jest.fn() +const mockConvertMoneyAmount = jest.fn() + +jest.mock("@app/hooks/use-payments", () => ({ + usePayments: () => ({ getConversionQuote: mockGetQuote }), +})) + +jest.mock("@app/hooks/use-price-conversion", () => ({ + usePriceConversion: () => ({ convertMoneyAmount: mockConvertMoneyAmount }), +})) + +jest.mock("@app/hooks/use-display-currency", () => ({ + useDisplayCurrency: () => ({ + formatMoneyAmount: ({ moneyAmount }: { moneyAmount: { amount: number } }) => + `$${(moneyAmount.amount / 100).toFixed(2)}`, + }), +})) + +jest.mock("@react-native-firebase/crashlytics", () => { + const recordError = jest.fn() + return { + __esModule: true, + default: () => ({ recordError }), + } +}) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + ConversionConfirmationScreen: { + amountFloored: () => "Amount increased to meet the conversion minimum.", + amountDustBumped: () => "Amount increased to convert your full balance.", + }, + }, + }), +})) + +const makeQuote = (amountAdjustment?: ConvertAmountAdjustment) => ({ + feeAmount: toUsdMoneyAmount(5), + amountAdjustment, + execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), +}) + +describe("useStableBalanceToggleQuote", () => { + beforeEach(() => { + jest.clearAllMocks() + mockConvertMoneyAmount.mockImplementation( + (amount: { amount: number }, currency: WalletCurrency | typeof DisplayCurrency) => { + if (currency === WalletCurrency.Btc) return toBtcMoneyAmount(amount.amount * 100) + if (currency === DisplayCurrency) { + return { amount: amount.amount, currency: DisplayCurrency, currencyCode: "USD" } + } + return toUsdMoneyAmount(Math.round(amount.amount / 100)) + }, + ) + }) + + it("stays idle when enabled is false", async () => { + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: false, + }), + ) + + await waitFor(() => expect(result.current.isQuoting).toBe(false)) + expect(mockGetQuote).not.toHaveBeenCalled() + }) + + it("stays idle when sourceBalance is zero (no conversion to simulate)", async () => { + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 0, + enabled: true, + }), + ) + + await waitFor(() => expect(result.current.isQuoting).toBe(false)) + expect(mockGetQuote).not.toHaveBeenCalled() + }) + + it("transitions to Ready and surfaces the formatted fee for BTC→USD", async () => { + mockGetQuote.mockResolvedValue(makeQuote()) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: true, + }), + ) + + await waitFor(() => expect(result.current.feeText).toBe("$0.05")) + expect(result.current.isQuoting).toBe(false) + expect(result.current.hasQuoteError).toBe(false) + expect(mockGetQuote).toHaveBeenCalledWith({ + fromAmount: expect.objectContaining({ + currency: WalletCurrency.Btc, + amount: 5_000, + }), + toAmount: expect.objectContaining({ currency: WalletCurrency.Usd }), + direction: ConvertDirection.BtcToUsd, + }) + }) + + it("passes USD→BTC direction when deactivating with a USD balance", async () => { + mockGetQuote.mockResolvedValue(makeQuote()) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Usd, + sourceBalance: 500, + enabled: true, + }), + ) + + await waitFor(() => expect(result.current.feeText).toBe("$0.05")) + expect(mockGetQuote).toHaveBeenCalledWith( + expect.objectContaining({ direction: ConvertDirection.UsdToBtc }), + ) + }) + + it("surfaces an error when the quote is null", async () => { + mockGetQuote.mockResolvedValue(null) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: true, + }), + ) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + expect(result.current.feeText).toBe("") + }) + + it("surfaces an error when the quote call rejects", async () => { + mockGetQuote.mockRejectedValue(new Error("network down")) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: true, + }), + ) + + await waitFor(() => expect(result.current.hasQuoteError).toBe(true)) + }) + + it("maps FlooredToMin adjustment to the correct text", async () => { + mockGetQuote.mockResolvedValue(makeQuote(ConvertAmountAdjustment.FlooredToMin)) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: true, + }), + ) + + await waitFor(() => + expect(result.current.adjustmentText).toBe( + "Amount increased to meet the conversion minimum.", + ), + ) + }) + + it("maps IncreasedToAvoidDust adjustment to the correct text", async () => { + mockGetQuote.mockResolvedValue( + makeQuote(ConvertAmountAdjustment.IncreasedToAvoidDust), + ) + + const { result } = renderHook(() => + useStableBalanceToggleQuote({ + fromCurrency: WalletCurrency.Btc, + sourceBalance: 5_000, + enabled: true, + }), + ) + + await waitFor(() => + expect(result.current.adjustmentText).toBe( + "Amount increased to convert your full balance.", + ), + ) + }) +}) diff --git a/__tests__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index a1c3f9b642..638bcaea73 100644 --- a/__tests__/self-custodial/adapters/payment-adapter.spec.ts +++ b/__tests__/self-custodial/adapters/payment-adapter.spec.ts @@ -3,11 +3,7 @@ import { createSendPayment, createGetFee, } from "@app/self-custodial/adapters/payment-adapter" -import { - createReceiveLightning, - createReceiveOnchain, - createConvert, -} from "@app/self-custodial/bridge" +import { createReceiveLightning, createReceiveOnchain } from "@app/self-custodial/bridge" jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ BitcoinNetwork: { Bitcoin: 0, Regtest: 4 }, @@ -40,6 +36,15 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ ReceivePaymentMethod: { Bolt11Invoice: jest.fn((args: unknown) => ({ tag: "Bolt11Invoice", inner: args })), BitcoinAddress: jest.fn((args: unknown) => ({ tag: "BitcoinAddress", inner: args })), + SparkInvoice: jest.fn((args: unknown) => ({ tag: "SparkInvoice", inner: args })), + }, + ConversionType: { + FromBitcoin: jest.fn(() => ({ tag: "FromBitcoin" })), + ToBitcoin: jest.fn((args: unknown) => ({ tag: "ToBitcoin", inner: args })), + }, + AmountAdjustmentReason: { + FlooredToMinLimit: "FlooredToMinLimit", + IncreasedToAvoidDust: "IncreasedToAvoidDust", }, ListUnclaimedDepositsRequest: { create: (args: unknown) => args, @@ -50,16 +55,28 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ })) jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "test-token-id" }, - SparkToken: { Label: "USDB", Ticker: "USDB", DefaultDecimals: 6 }, + SparkConfig: { maxSlippageBps: 50 }, + requireSparkTokenIdentifier: () => "test-token-id", + SparkToken: { Label: "USDB", DefaultDecimals: 6 }, })) +jest.mock("@app/self-custodial/bridge/limits", () => { + const actual = jest.requireActual("@app/self-custodial/bridge/limits") + return { + ...actual, + fetchConversionLimits: jest + .fn() + .mockResolvedValue({ minFromAmount: null, minToAmount: null }), + } +}) + const createMockSdk = () => ({ prepareSendPayment: jest.fn(), sendPayment: jest.fn(), - receivePayment: jest.fn(), + receivePayment: jest.fn().mockResolvedValue({ paymentRequest: "sp1own" }), listUnclaimedDeposits: jest.fn(), claimDeposit: jest.fn(), + getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), }) describe("self-custodial payment adapters", () => { @@ -361,36 +378,4 @@ describe("self-custodial payment adapters", () => { expect(result.errors?.[0].message).toBe("no address") }) }) - - describe("createConvert", () => { - it("prepares and sends conversion", async () => { - const sdk = createMockSdk() - sdk.prepareSendPayment.mockResolvedValue({ amount: BigInt(100) }) - sdk.sendPayment.mockResolvedValue({}) - - const convert = createConvert(sdk as never) - const result = await convert({ - amount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, - direction: "btc_to_usd", - }) - - expect(result.status).toBe("success") - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ tokenIdentifier: "test-token-id" }), - ) - }) - - it("returns failed on error", async () => { - const sdk = createMockSdk() - sdk.prepareSendPayment.mockRejectedValue(new Error("slippage")) - - const convert = createConvert(sdk as never) - const result = await convert({ - amount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, - direction: "btc_to_usd", - }) - - expect(result.status).toBe("failed") - }) - }) }) diff --git a/__tests__/self-custodial/bridge/convert.spec.ts b/__tests__/self-custodial/bridge/convert.spec.ts index 7aaac05434..ee8eae3f10 100644 --- a/__tests__/self-custodial/bridge/convert.spec.ts +++ b/__tests__/self-custodial/bridge/convert.spec.ts @@ -1,144 +1,250 @@ -jest.mock("react-native-config", () => ({ - SPARK_TOKEN_IDENTIFIER: "test-token-id", - BREEZ_API_KEY: "test-api-key", - BREEZ_NETWORK: "regtest", -})) +import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" +import { ConvertDirection, ConvertErrorCode } from "@app/types/payment.types" +import { WalletCurrency } from "@app/graphql/generated" -jest.mock("react-native-fs", () => ({ - DocumentDirectoryPath: "/test/documents", -})) +import { createGetConversionQuote } from "@app/self-custodial/bridge/convert" -jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ - Network: { Mainnet: 0, Regtest: 1 }, - PrepareSendPaymentRequest: { - create: jest.fn((args: Record) => args), - }, - SendPaymentRequest: { - create: jest.fn((args: Record) => args), - }, +const mockFetchLimits = jest.fn() +const mockRequireTokenId = jest.fn(() => "usdb-token-id") +const mockRecordError = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), })) -import { WalletCurrency } from "@app/graphql/generated" -import { ConvertDirection, PaymentResultStatus } from "@app/types/payment.types" +jest.mock("@app/self-custodial/bridge/limits", () => { + const actual = jest.requireActual("@app/self-custodial/bridge/limits") + return { + ...actual, + fetchConversionLimits: (...args: unknown[]) => mockFetchLimits(...args), + } +}) -import { createConvert } from "@app/self-custodial/bridge/convert" +jest.mock("@app/self-custodial/config", () => ({ + SparkConfig: { maxSlippageBps: 50 }, + requireSparkTokenIdentifier: () => mockRequireTokenId(), + SparkToken: { Label: "USDB", DefaultDecimals: 6 }, +})) -const buildSdk = () => ({ - prepareSendPayment: jest.fn(), - sendPayment: jest.fn(), +const createSdk = () => ({ + prepareSendPayment: jest.fn().mockResolvedValue({ + paymentMethod: {}, + conversionEstimate: { + fee: BigInt(50000), + amountAdjustment: undefined, + }, + }), + sendPayment: jest.fn().mockResolvedValue(undefined), + receivePayment: jest.fn().mockResolvedValue({ paymentRequest: "sp1own-spark-address" }), + getInfo: jest.fn().mockResolvedValue({ + tokenBalances: { + "usdb-token-id": { + tokenMetadata: { identifier: "usdb-token-id", decimals: 6 }, + }, + }, + }), }) -const buildAmount = (currency: WalletCurrency, amount = 1000) => ({ - amount, - currency, - currencyCode: currency === WalletCurrency.Btc ? "BTC" : "USD", -}) +describe("createGetConversionQuote — BTC → USD", () => { + beforeEach(() => { + jest.clearAllMocks() + }) -describe("createConvert", () => { - it("returns Success when prepareSendPayment + sendPayment both resolve (BTC → USD)", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockResolvedValue(undefined) + it("prepares a payment to own spark address with FromBitcoin conversion and USDB destination amount in token base units", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 1000, minToAmount: 0 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe(PaymentResultStatus.Success) + expect(quote).not.toBeNull() + expect(sdk.receivePayment).toHaveBeenCalled() + const prepArg = sdk.prepareSendPayment.mock.calls[0][0] + expect(prepArg.paymentRequest).toBe("sp1own-spark-address") + expect(prepArg.amount).toBe(BigInt(1_370_000)) + expect(prepArg.tokenIdentifier).toBe("usdb-token-id") + expect(prepArg.conversionOptions.conversionType).toEqual({ tag: "FromBitcoin" }) + expect(prepArg.conversionOptions.maxSlippageBps).toBe(50) }) - it("forwards the configured tokenIdentifier on BTC → USD conversions", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockResolvedValue(undefined) + it("execute() sends the prepared payment and returns success", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 1000, minToAmount: 0 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Btc), + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ tokenIdentifier: "test-token-id" }), - ) + const result = await quote!.execute() + + expect(result.status).toBe("success") + expect(sdk.sendPayment).toHaveBeenCalled() }) - it("omits tokenIdentifier on USD → BTC conversions", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockResolvedValue(undefined) + it("exposes the estimated fee converted to a USD MoneyAmount in cents", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 0, minToAmount: 0 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Usd), - direction: ConvertDirection.UsdToBtc, + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, }) - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ tokenIdentifier: undefined }), - ) + expect(quote!.feeAmount.currency).toBe(WalletCurrency.Usd) + expect(quote!.feeAmount.amount).toBe(5) }) - it("returns Failed with the SDK error message when prepare rejects", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockRejectedValue(new Error("prepare boom")) + it("throws BelowMinimum and records to crashlytics when fromAmount is under the SDK minimum", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 10000, minToAmount: 0 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), - direction: ConvertDirection.BtcToUsd, - }) + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toMatchObject({ code: ConvertErrorCode.BelowMinimum }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toBe("prepare boom") - expect(sdk.sendPayment).not.toHaveBeenCalled() + expect(mockRecordError).toHaveBeenCalled() + expect(sdk.prepareSendPayment).not.toHaveBeenCalled() + }) + + it("throws LimitsUnavailable and records to crashlytics when fetchConversionLimits throws", async () => { + mockFetchLimits.mockRejectedValue(new Error("limits unavailable")) + const sdk = createSdk() + + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toMatchObject({ code: ConvertErrorCode.LimitsUnavailable }) + + expect(mockRecordError).toHaveBeenCalled() + expect(sdk.prepareSendPayment).not.toHaveBeenCalled() }) - it("returns Failed when sendPayment rejects after a successful prepare", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockRejectedValue(new Error("send boom")) + it("skips minimum check when minFromAmount is null (no limit)", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: null, minToAmount: null }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(100), + toAmount: toUsdMoneyAmount(7), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toBe("send boom") + expect(quote).not.toBeNull() }) +}) - it("wraps non-Error throws into a Conversion-failed message", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockRejectedValue("string thrown") +describe("createGetConversionQuote — USD → BTC", () => { + beforeEach(() => { + jest.clearAllMocks() + }) - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), - direction: ConvertDirection.BtcToUsd, + it("prepares a payment with ToBitcoin conversion and sat destination amount", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 10, minToAmount: 500 }) + const sdk = createSdk() + + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toUsdMoneyAmount(100), + toAmount: toBtcMoneyAmount(1300), + direction: ConvertDirection.UsdToBtc, }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toContain("Conversion failed") - expect(result.errors?.[0].message).toContain("string thrown") + expect(quote).not.toBeNull() + const arg = sdk.prepareSendPayment.mock.calls[0][0] + expect(arg.paymentRequest).toBe("sp1own-spark-address") + expect(arg.amount).toBe(BigInt(1300)) + expect(arg.tokenIdentifier).toBeUndefined() + expect(arg.conversionOptions.conversionType).toEqual({ + tag: "ToBitcoin", + inner: { fromTokenIdentifier: "usdb-token-id" }, + }) + expect(arg.conversionOptions.maxSlippageBps).toBe(50) }) +}) - it("forwards the requested amount as a BigInt", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({}) - sdk.sendPayment.mockResolvedValue(undefined) +describe("createGetConversionQuote — error handling", () => { + beforeEach(() => { + jest.clearAllMocks() + mockFetchLimits.mockResolvedValue({ minFromAmount: null, minToAmount: null }) + }) + + it("re-throws and records to crashlytics when prepareSendPayment fails", async () => { + const sdk = { + prepareSendPayment: jest.fn().mockRejectedValue(new Error("prepare failed")), + sendPayment: jest.fn(), + receivePayment: jest + .fn() + .mockResolvedValue({ paymentRequest: "sp1own-spark-address" }), + getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), + } + + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toThrow("prepare failed") + + expect(mockRecordError).toHaveBeenCalled() + expect(sdk.sendPayment).not.toHaveBeenCalled() + }) - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Btc, 12345), + it("execute() records to crashlytics and returns failed when sendPayment throws", async () => { + const sdk = { + prepareSendPayment: jest.fn().mockResolvedValue({ + paymentMethod: {}, + conversionEstimate: { fee: BigInt(0), amountAdjustment: undefined }, + }), + sendPayment: jest.fn().mockRejectedValue(new Error("send failed")), + receivePayment: jest + .fn() + .mockResolvedValue({ paymentRequest: "sp1own-spark-address" }), + getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), + } + + const quote = await createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) + const result = await quote!.execute() - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ amount: BigInt(12345) }), - ) + expect(result.status).toBe("failed") + expect(result.errors?.[0].message).toBe("send failed") + expect(mockRecordError).toHaveBeenCalled() + }) + + it("propagates the configuration error when SPARK_TOKEN_IDENTIFIER is missing", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 0, minToAmount: 0 }) + mockRequireTokenId.mockImplementationOnce(() => { + throw new Error("SPARK_TOKEN_IDENTIFIER is not configured for this build") + }) + const sdk = createSdk() + + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toThrow("SPARK_TOKEN_IDENTIFIER is not configured for this build") + + expect(mockRecordError).toHaveBeenCalled() + expect(sdk.prepareSendPayment).not.toHaveBeenCalled() + expect(sdk.sendPayment).not.toHaveBeenCalled() }) }) diff --git a/__tests__/self-custodial/bridge/limits.spec.ts b/__tests__/self-custodial/bridge/limits.spec.ts new file mode 100644 index 0000000000..cee0dcd340 --- /dev/null +++ b/__tests__/self-custodial/bridge/limits.spec.ts @@ -0,0 +1,123 @@ +import { fetchConversionLimits } from "@app/self-custodial/bridge/limits" +import { ConvertDirection } from "@app/types/payment.types" + +const mockRequireTokenId = jest.fn(() => "usdb-token-id") + +jest.mock("@app/self-custodial/config", () => ({ + SparkConfig: {}, + requireSparkTokenIdentifier: () => mockRequireTokenId(), +})) + +const mockGetInfo = jest.fn().mockResolvedValue({ + tokenBalances: { + usdb: { + balance: BigInt(0), + tokenMetadata: { + identifier: "usdb-token-id", + ticker: "USDB", + decimals: 6, + }, + }, + }, +}) + +describe("fetchConversionLimits", () => { + it("calls sdk.fetchConversionLimits with FromBitcoin and leaves sat-denominated minFromAmount unchanged", async () => { + const fetchConversionLimitsFn = jest.fn().mockResolvedValue({ + minFromAmount: BigInt(1000), + minToAmount: BigInt(500000), + }) + + const result = await fetchConversionLimits( + { + fetchConversionLimits: fetchConversionLimitsFn, + getInfo: mockGetInfo, + } as never, + ConvertDirection.BtcToUsd, + ) + + expect(fetchConversionLimitsFn).toHaveBeenCalledWith({ + conversionType: { tag: "FromBitcoin" }, + tokenIdentifier: "usdb-token-id", + }) + // minFromAmount stays as sats; minToAmount converts token base units → cents. + expect(result).toEqual({ minFromAmount: 1000, minToAmount: 50 }) + }) + + it("calls sdk.fetchConversionLimits with ToBitcoin and normalizes token-denominated minFromAmount to cents", async () => { + const fetchConversionLimitsFn = jest.fn().mockResolvedValue({ + minFromAmount: BigInt(500000), + minToAmount: BigInt(800), + }) + + const result = await fetchConversionLimits( + { + fetchConversionLimits: fetchConversionLimitsFn, + getInfo: mockGetInfo, + } as never, + ConvertDirection.UsdToBtc, + ) + + expect(fetchConversionLimitsFn).toHaveBeenCalledWith({ + conversionType: { + tag: "ToBitcoin", + inner: { fromTokenIdentifier: "usdb-token-id" }, + }, + tokenIdentifier: undefined, + }) + // minFromAmount is USDB (6 decimals) → cents; minToAmount stays as sats. + expect(result).toEqual({ minFromAmount: 50, minToAmount: 800 }) + }) + + it("ceils sub-cent residues so the UI never accepts an amount the SDK will reject", async () => { + const fetchConversionLimitsFn = jest.fn().mockResolvedValue({ + minFromAmount: BigInt(1_000_001), + minToAmount: BigInt(0), + }) + + const result = await fetchConversionLimits( + { + fetchConversionLimits: fetchConversionLimitsFn, + getInfo: mockGetInfo, + } as never, + ConvertDirection.UsdToBtc, + ) + + expect(result.minFromAmount).toBe(101) + }) + + it("returns null fields when the SDK returns undefined limits", async () => { + const fetchConversionLimitsFn = jest.fn().mockResolvedValue({ + minFromAmount: undefined, + minToAmount: undefined, + }) + + const result = await fetchConversionLimits( + { + fetchConversionLimits: fetchConversionLimitsFn, + getInfo: mockGetInfo, + } as never, + ConvertDirection.BtcToUsd, + ) + + expect(result).toEqual({ minFromAmount: null, minToAmount: null }) + }) + + it("propagates the configuration error when SPARK_TOKEN_IDENTIFIER is missing", async () => { + mockRequireTokenId.mockImplementationOnce(() => { + throw new Error("SPARK_TOKEN_IDENTIFIER is not configured for this build") + }) + const fetchConversionLimitsFn = jest.fn() + + await expect( + fetchConversionLimits( + { + fetchConversionLimits: fetchConversionLimitsFn, + getInfo: mockGetInfo, + } as never, + ConvertDirection.BtcToUsd, + ), + ).rejects.toThrow("SPARK_TOKEN_IDENTIFIER is not configured for this build") + expect(fetchConversionLimitsFn).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/self-custodial/bridge/send.spec.ts b/__tests__/self-custodial/bridge/send.spec.ts index defa3823a0..30c2b821cb 100644 --- a/__tests__/self-custodial/bridge/send.spec.ts +++ b/__tests__/self-custodial/bridge/send.spec.ts @@ -25,7 +25,8 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ })) jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "usdb-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "usdb-token-id", SparkToken: { Label: "USDB", Ticker: "USDB", DefaultDecimals: 6 }, })) diff --git a/__tests__/self-custodial/bridge/stable-balance.spec.ts b/__tests__/self-custodial/bridge/stable-balance.spec.ts new file mode 100644 index 0000000000..cd50556f91 --- /dev/null +++ b/__tests__/self-custodial/bridge/stable-balance.spec.ts @@ -0,0 +1,71 @@ +import { + activateStableBalance, + deactivateStableBalance, +} from "@app/self-custodial/bridge/stable-balance" + +const mockSet = jest.fn() +const mockUnset = jest.fn() + +jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ + StableBalanceActiveLabel: { + Set: class SetClass { + constructor(args: { label: string }) { + mockSet(args) + } + }, + Unset: class UnsetClass { + constructor() { + mockUnset() + } + }, + }, +})) + +describe("activateStableBalance", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateUserSettings with StableBalanceActiveLabel.Set", async () => { + const updateUserSettings = jest.fn().mockResolvedValue(undefined) + + await activateStableBalance({ updateUserSettings } as never, "USDB") + + expect(mockSet).toHaveBeenCalledWith({ label: "USDB" }) + expect(updateUserSettings).toHaveBeenCalledTimes(1) + const arg = updateUserSettings.mock.calls[0][0] + expect(arg.sparkPrivateModeEnabled).toBeUndefined() + expect(arg.stableBalanceActiveLabel).toBeInstanceOf(Object) + }) + + it("propagates errors from the SDK", async () => { + const updateUserSettings = jest.fn().mockRejectedValue(new Error("sdk unavailable")) + + await expect( + activateStableBalance({ updateUserSettings } as never, "USDB"), + ).rejects.toThrow("sdk unavailable") + }) +}) + +describe("deactivateStableBalance", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateUserSettings with StableBalanceActiveLabel.Unset", async () => { + const updateUserSettings = jest.fn().mockResolvedValue(undefined) + + await deactivateStableBalance({ updateUserSettings } as never) + + expect(mockUnset).toHaveBeenCalledTimes(1) + expect(updateUserSettings).toHaveBeenCalledTimes(1) + }) + + it("propagates errors from the SDK", async () => { + const updateUserSettings = jest.fn().mockRejectedValue(new Error("boom")) + + await expect( + deactivateStableBalance({ updateUserSettings } as never), + ).rejects.toThrow("boom") + }) +}) diff --git a/__tests__/self-custodial/bridge/token-balance.spec.ts b/__tests__/self-custodial/bridge/token-balance.spec.ts new file mode 100644 index 0000000000..7a3523dab4 --- /dev/null +++ b/__tests__/self-custodial/bridge/token-balance.spec.ts @@ -0,0 +1,166 @@ +import { + findUsdbToken, + fetchUsdbDecimals, +} from "@app/self-custodial/bridge/token-balance" + +const mockRecordError = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) + +jest.mock("@app/self-custodial/config", () => ({ + SparkConfig: {}, + requireSparkTokenIdentifier: () => "test-token-id", + SparkToken: { DefaultDecimals: 6, Label: "USDB", Ticker: "USDB" }, +})) + +const loadFreshModule = () => { + let mod: typeof import("@app/self-custodial/bridge/token-balance") | undefined + jest.isolateModules(() => { + mod = require("@app/self-custodial/bridge/token-balance") + }) + return mod! +} + +const usdbToken = { + balance: BigInt(500_000), + tokenMetadata: { + identifier: "test-token-id", + decimals: 6, + ticker: "USDB", + }, +} + +const otherToken = { + balance: BigInt(100), + tokenMetadata: { + identifier: "other-token", + decimals: 8, + ticker: "OTHER", + }, +} + +describe("findUsdbToken", () => { + it("returns the USDB token when tokenBalances is an object keyed by identifier", () => { + const info = { + tokenBalances: { + "test-token-id": usdbToken, + "other-token": otherToken, + }, + } as never + + expect(findUsdbToken(info)).toBe(usdbToken) + }) + + it("returns the USDB token when tokenBalances is a Map", () => { + const info = { + tokenBalances: new Map([ + ["test-token-id", usdbToken], + ["other-token", otherToken], + ]), + } as never + + expect(findUsdbToken(info)).toBe(usdbToken) + }) + + it("returns undefined when the SDK has no token balances", () => { + const info = { tokenBalances: {} } as never + + expect(findUsdbToken(info)).toBeUndefined() + }) + + it("returns undefined when tokenBalances is missing", () => { + const info = {} as never + + expect(findUsdbToken(info)).toBeUndefined() + }) + + it("returns undefined when only unrelated tokens are present", () => { + const info = { tokenBalances: { "other-token": otherToken } } as never + + expect(findUsdbToken(info)).toBeUndefined() + }) +}) + +describe("fetchUsdbDecimals", () => { + it("returns the USDB token's decimals when present", async () => { + const sdk = { + getInfo: jest.fn().mockResolvedValue({ + tokenBalances: { "test-token-id": usdbToken }, + }), + } as never + + await expect(fetchUsdbDecimals(sdk)).resolves.toBe(6) + }) + + it("falls back to SparkToken.DefaultDecimals when the USDB token is missing", async () => { + const sdk = { + getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), + } as never + + await expect(fetchUsdbDecimals(sdk)).resolves.toBe(6) + }) + + it("falls back when tokenMetadata is missing on the found token", async () => { + const sdk = { + getInfo: jest.fn().mockResolvedValue({ + tokenBalances: { + "test-token-id": { + balance: BigInt(0), + tokenMetadata: { identifier: "test-token-id" }, + }, + }, + }), + } as never + + await expect(fetchUsdbDecimals(sdk)).resolves.toBe(6) + }) + + it("calls sdk.getInfo with ensureSynced: false to avoid blocking on sync", async () => { + const getInfo = jest.fn().mockResolvedValue({ tokenBalances: {} }) + const sdk = { getInfo } as never + + await fetchUsdbDecimals(sdk) + + expect(getInfo).toHaveBeenCalledWith({ ensureSynced: false }) + }) +}) + +describe("token-balance crashlytics reporting", () => { + beforeEach(() => { + mockRecordError.mockClear() + }) + + it("records to crashlytics once when the expected token is missing (dedupes within session)", () => { + const fresh = loadFreshModule() + const info = { tokenBalances: { "other-token": otherToken } } as never + + fresh.findUsdbToken(info) + fresh.findUsdbToken(info) + fresh.findUsdbToken(info) + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toContain("test-token-id") + }) + + it("records to crashlytics once when the token is present but lacks decimals metadata", async () => { + const fresh = loadFreshModule() + const tokenWithoutDecimals = { + balance: BigInt(0), + tokenMetadata: { identifier: "test-token-id" }, + } + const sdk = { + getInfo: jest.fn().mockResolvedValue({ + tokenBalances: { "test-token-id": tokenWithoutDecimals }, + }), + } as never + + await fresh.fetchUsdbDecimals(sdk) + await fresh.fetchUsdbDecimals(sdk) + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toContain("decimals") + }) +}) diff --git a/__tests__/self-custodial/config.spec.ts b/__tests__/self-custodial/config.spec.ts index eb26980d27..dd088b2d62 100644 --- a/__tests__/self-custodial/config.spec.ts +++ b/__tests__/self-custodial/config.spec.ts @@ -56,14 +56,26 @@ describe("SparkConfig", () => { expect(SparkConfig.storageDir).toBe("/test/documents/breez-sdk-spark-regtest") }) - it("reads apiKey and tokenIdentifier from env", () => { - const { SparkConfig } = loadConfig({ - BREEZ_API_KEY: "my-key", + it("reads apiKey from env", () => { + const { SparkConfig } = loadConfig({ BREEZ_API_KEY: "my-key" }) + + expect(SparkConfig.apiKey).toBe("my-key") + }) + + it("requireSparkTokenIdentifier returns the configured identifier", () => { + const { requireSparkTokenIdentifier } = loadConfig({ SPARK_TOKEN_IDENTIFIER: "my-token", }) - expect(SparkConfig.apiKey).toBe("my-key") - expect(SparkConfig.tokenIdentifier).toBe("my-token") + expect(requireSparkTokenIdentifier()).toBe("my-token") + }) + + it("requireSparkTokenIdentifier throws a clear error when env is missing", () => { + const { requireSparkTokenIdentifier } = loadConfig({ SPARK_TOKEN_IDENTIFIER: "" }) + + expect(() => requireSparkTokenIdentifier()).toThrow( + "SPARK_TOKEN_IDENTIFIER is not configured for this build", + ) }) it("exports SparkNetworkLabel as 'mainnet' for mainnet", () => { diff --git a/__tests__/self-custodial/hooks/use-non-custodial-conversion-limits.spec.ts b/__tests__/self-custodial/hooks/use-non-custodial-conversion-limits.spec.ts new file mode 100644 index 0000000000..f1ddd7354a --- /dev/null +++ b/__tests__/self-custodial/hooks/use-non-custodial-conversion-limits.spec.ts @@ -0,0 +1,104 @@ +import { renderHook, waitFor } from "@testing-library/react-native" + +import { ConvertDirection } from "@app/types/payment.types" + +import { useNonCustodialConversionLimits } from "@app/self-custodial/hooks/use-non-custodial-conversion-limits" + +const mockFetchConversionLimits = jest.fn() +const mockUseSelfCustodialWallet = jest.fn() + +jest.mock("@app/self-custodial/bridge", () => ({ + fetchConversionLimits: (...args: unknown[]) => mockFetchConversionLimits(...args), +})) + +jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ + useSelfCustodialWallet: () => mockUseSelfCustodialWallet(), +})) + +const fakeSdk = { id: "fake-sdk" } + +describe("useNonCustodialConversionLimits", () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseSelfCustodialWallet.mockReturnValue({ sdk: fakeSdk }) + }) + + it("returns limits after successful fetch and forwards direction to the bridge", async () => { + mockFetchConversionLimits.mockResolvedValue({ + minFromAmount: 1000, + minToAmount: 500, + }) + + const { result } = renderHook(() => + useNonCustodialConversionLimits(ConvertDirection.BtcToUsd), + ) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.limits).toEqual({ minFromAmount: 1000, minToAmount: 500 }) + expect(result.current.error).toBeNull() + expect(mockFetchConversionLimits).toHaveBeenCalledWith( + fakeSdk, + ConvertDirection.BtcToUsd, + ) + }) + + it("surfaces the error when fetchConversionLimits throws and clears limits", async () => { + mockFetchConversionLimits.mockRejectedValue(new Error("network unreachable")) + + const { result } = renderHook(() => + useNonCustodialConversionLimits(ConvertDirection.UsdToBtc), + ) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.limits).toBeNull() + expect(result.current.error?.message).toBe("network unreachable") + }) + + it("does not call the bridge when sdk is null", () => { + mockUseSelfCustodialWallet.mockReturnValue({ sdk: null }) + + const { result } = renderHook(() => + useNonCustodialConversionLimits(ConvertDirection.BtcToUsd), + ) + + expect(mockFetchConversionLimits).not.toHaveBeenCalled() + expect(result.current.limits).toBeNull() + expect(result.current.loading).toBe(false) + }) + + it("refetches when direction changes", async () => { + mockFetchConversionLimits.mockResolvedValue({ + minFromAmount: 1, + minToAmount: 1, + }) + + const { rerender } = renderHook( + (direction: ConvertDirection) => useNonCustodialConversionLimits(direction), + { initialProps: ConvertDirection.BtcToUsd as ConvertDirection }, + ) + + await waitFor(() => { + expect(mockFetchConversionLimits).toHaveBeenCalledWith( + fakeSdk, + ConvertDirection.BtcToUsd, + ) + }) + + rerender(ConvertDirection.UsdToBtc) + + await waitFor(() => { + expect(mockFetchConversionLimits).toHaveBeenCalledWith( + fakeSdk, + ConvertDirection.UsdToBtc, + ) + }) + + expect(mockFetchConversionLimits).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts b/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts index 25b4958066..6876581ce1 100644 --- a/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts +++ b/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts @@ -201,23 +201,22 @@ describe("toTransactionFragment", () => { fractionDigits: 2, }) - // First call converts the settlement amount in USD cents - expect(convertMoneyAmount).toHaveBeenNthCalledWith( - 1, + // The BTC fee is converted via convertMoneyAmount with currency: BTC — never + // mixed in raw with the USD settlement amount. That's the mixed-unit guard. + expect(convertMoneyAmount).toHaveBeenCalledWith( expect.objectContaining({ - amount: 512, // |signedAmount| = settlement (500) + fee (12) for a Send - currency: WalletCurrency.Usd, - currencyCode: WalletCurrency.Usd, + amount: 12, + currency: WalletCurrency.Btc, + currencyCode: WalletCurrency.Btc, }), "USD", ) - // Second call converts the fee in BTC sats — NOT mixed in with the USD amount - expect(convertMoneyAmount).toHaveBeenNthCalledWith( - 2, + // The settlement amount stays in USD cents for the display conversion. + expect(convertMoneyAmount).toHaveBeenCalledWith( expect.objectContaining({ - amount: 12, - currency: WalletCurrency.Btc, - currencyCode: WalletCurrency.Btc, + amount: 501, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, }), "USD", ) @@ -233,4 +232,36 @@ describe("toTransactionFragments", () => { expect(results[0].id).toBe("tx-1") expect(results[1].id).toBe("tx-2") }) + + it("keeps a USD fee in raw cents when settlementCurrency is USD instead of converting it through BTC pricing", () => { + const tx = createTx({ + direction: TransactionDirection.Send, + paymentType: PaymentType.Lightning, + amount: { + amount: 500, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, + }, + fee: { + amount: 7, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, + }, + }) + + const convertMoneyAmount = jest.fn(({ amount }) => ({ + amount, + currency: "USD", + currencyCode: "USD", + })) + + const result = toTransactionFragment(tx, { + displayCurrency: "USD", + convertMoneyAmount: convertMoneyAmount as never, + fractionDigits: 2, + }) + + expect(result.settlementFee).toBe(7) + expect(result.settlementCurrency).toBe(WalletCurrency.Usd) + }) }) diff --git a/__tests__/self-custodial/mappers/transaction-mapper.spec.ts b/__tests__/self-custodial/mappers/transaction-mapper.spec.ts index 83dacf516b..b0f83d1de0 100644 --- a/__tests__/self-custodial/mappers/transaction-mapper.spec.ts +++ b/__tests__/self-custodial/mappers/transaction-mapper.spec.ts @@ -134,10 +134,11 @@ describe("mapSelfCustodialTransaction", () => { expect(result.sourceAccountType).toBe(AccountType.SelfCustodial) }) - it("fees always use BTC currency", () => { + it("scales token payment fees from base units to USD cents and tags them as USD", () => { const result = mapSelfCustodialTransaction( createPayment({ method: 2, + fees: BigInt(1_500_000), details: { tag: "Token", inner: { metadata: { ticker: "USDB", decimals: 6, identifier: "test" } }, @@ -145,7 +146,21 @@ describe("mapSelfCustodialTransaction", () => { }), ) + expect(result.fee?.currency).toBe(WalletCurrency.Usd) + expect(result.fee?.amount).toBe(150) + }) + + it("keeps BTC payment fees in sats and tags them as BTC", () => { + const result = mapSelfCustodialTransaction( + createPayment({ + method: 0, + fees: BigInt(42), + details: { tag: "Lightning", inner: {} }, + }), + ) + expect(result.fee?.currency).toBe(WalletCurrency.Btc) + expect(result.fee?.amount).toBe(42) }) }) diff --git a/__tests__/self-custodial/payment-details/lightning.spec.ts b/__tests__/self-custodial/payment-details/lightning.spec.ts index a44f54d53a..79cf12f92b 100644 --- a/__tests__/self-custodial/payment-details/lightning.spec.ts +++ b/__tests__/self-custodial/payment-details/lightning.spec.ts @@ -43,7 +43,8 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ /* eslint-enable camelcase */ jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "usdb-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "usdb-token-id", SparkToken: { Label: "USDB", Ticker: "USDB", DefaultDecimals: 6 }, })) diff --git a/__tests__/self-custodial/payment-details/spark.spec.ts b/__tests__/self-custodial/payment-details/spark.spec.ts index ea8343265a..b8383ed6ba 100644 --- a/__tests__/self-custodial/payment-details/spark.spec.ts +++ b/__tests__/self-custodial/payment-details/spark.spec.ts @@ -16,7 +16,8 @@ jest.mock("@app/self-custodial/payment-details/send-helpers", () => { }) jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "usdb-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "usdb-token-id", SparkToken: { Label: "USDB", Ticker: "USDB", DefaultDecimals: 6 }, })) diff --git a/__tests__/self-custodial/providers/detect-balance-stale.spec.ts b/__tests__/self-custodial/providers/detect-balance-stale.spec.ts new file mode 100644 index 0000000000..1beee0d2a4 --- /dev/null +++ b/__tests__/self-custodial/providers/detect-balance-stale.spec.ts @@ -0,0 +1,100 @@ +import { WalletCurrency } from "@app/graphql/generated" +import { detectBalanceStale } from "@app/self-custodial/providers/detect-balance-stale" +import { + PaymentType, + TransactionDirection, + TransactionStatus, + type NormalizedTransaction, +} from "@app/types/transaction.types" +import { toWalletId, type WalletState } from "@app/types/wallet.types" + +const buildTx = ( + overrides: Partial = {}, +): NormalizedTransaction => ({ + id: "tx1", + amount: { amount: 100, currency: WalletCurrency.Btc, currencyCode: WalletCurrency.Btc }, + direction: TransactionDirection.Receive, + status: TransactionStatus.Completed, + timestamp: 0, + paymentType: PaymentType.Lightning, + ...overrides, +}) + +const buildWallet = (overrides: Partial = {}): WalletState => ({ + id: toWalletId("btc"), + walletCurrency: WalletCurrency.Btc, + balance: { amount: 0, currency: WalletCurrency.Btc, currencyCode: WalletCurrency.Btc }, + transactions: [], + ...overrides, +}) + +describe("detectBalanceStale", () => { + it("returns false for an empty wallet list (no data yet)", () => { + expect(detectBalanceStale([])).toBe(false) + }) + + it("returns true when balance=0 and at least one completed incoming tx exists", () => { + const wallet = buildWallet({ transactions: [buildTx()] }) + expect(detectBalanceStale([wallet])).toBe(true) + }) + + it("returns false when balance is non-zero (even with incoming history)", () => { + const wallet = buildWallet({ + balance: { + amount: 500, + currency: WalletCurrency.Btc, + currencyCode: WalletCurrency.Btc, + }, + transactions: [buildTx()], + }) + expect(detectBalanceStale([wallet])).toBe(false) + }) + + it("returns false when balance=0 and history is empty (truly empty wallet)", () => { + const wallet = buildWallet() + expect(detectBalanceStale([wallet])).toBe(false) + }) + + it("returns false when balance=0 but history only has outgoing txs (user spent everything)", () => { + const wallet = buildWallet({ + transactions: [buildTx({ direction: TransactionDirection.Send })], + }) + expect(detectBalanceStale([wallet])).toBe(false) + }) + + it("ignores pending incoming txs (only completed ones count)", () => { + const wallet = buildWallet({ + transactions: [buildTx({ status: TransactionStatus.Pending })], + }) + expect(detectBalanceStale([wallet])).toBe(false) + }) + + it("checks across multiple wallets — any one matching triggers stale", () => { + const empty = buildWallet({ id: toWalletId("btc") }) + const usdBtWithRx = buildWallet({ + id: toWalletId("usd"), + walletCurrency: WalletCurrency.Usd, + balance: { + amount: 0, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, + }, + transactions: [buildTx()], + }) + expect(detectBalanceStale([empty, usdBtWithRx])).toBe(true) + }) + + it("returns false when combined balance across wallets is non-zero", () => { + const btc = buildWallet({ transactions: [buildTx()] }) + const usd = buildWallet({ + id: toWalletId("usd"), + walletCurrency: WalletCurrency.Usd, + balance: { + amount: 1000, + currency: WalletCurrency.Usd, + currencyCode: WalletCurrency.Usd, + }, + }) + expect(detectBalanceStale([btc, usd])).toBe(false) + }) +}) diff --git a/__tests__/self-custodial/providers/is-online.spec.ts b/__tests__/self-custodial/providers/is-online.spec.ts index a8ea4ba3a8..112037d9fb 100644 --- a/__tests__/self-custodial/providers/is-online.spec.ts +++ b/__tests__/self-custodial/providers/is-online.spec.ts @@ -1,55 +1,106 @@ import { ServiceStatus } from "@breeztech/breez-sdk-spark-react-native" -import { getOnlineState, isOnline } from "@app/self-custodial/providers/is-online" +import { + getOnlineState, + getServiceStatus, + isOnline, + isOnlineStatus, +} from "@app/self-custodial/providers/is-online" const mockGetSparkStatus = jest.fn() +const mockRecordError = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) jest.mock("@app/self-custodial/bridge", () => ({ getSparkStatus: () => mockGetSparkStatus(), })) -describe("isOnline (boolean wrapper, backward-compat)", () => { +const loadFreshIsOnlineModule = () => { + let mod: typeof import("@app/self-custodial/providers/is-online") | undefined + jest.isolateModules(() => { + mod = require("@app/self-custodial/providers/is-online") + }) + return mod! +} + +describe("getServiceStatus", () => { beforeEach(() => { jest.clearAllMocks() }) - it("returns true when Spark status is Operational", async () => { - mockGetSparkStatus.mockResolvedValue({ - status: ServiceStatus.Operational, - lastUpdated: BigInt(0), + const ALL_STATUSES: ReadonlyArray<{ label: string; status: ServiceStatus }> = [ + { label: "Operational", status: ServiceStatus.Operational }, + { label: "Degraded", status: ServiceStatus.Degraded }, + { label: "Partial", status: ServiceStatus.Partial }, + { label: "Unknown", status: ServiceStatus.Unknown }, + { label: "Major", status: ServiceStatus.Major }, + ] + + ALL_STATUSES.forEach(({ label, status }) => { + it(`returns ${label} when the SDK reports it`, async () => { + mockGetSparkStatus.mockResolvedValue({ status, lastUpdated: BigInt(0) }) + + expect(await getServiceStatus()).toBe(status) }) + }) - expect(await isOnline()).toBe(true) + it("falls back to Major when getSparkStatus throws (device offline / API down)", async () => { + mockGetSparkStatus.mockRejectedValue(new Error("network down")) + + expect(await getServiceStatus()).toBe(ServiceStatus.Major) }) +}) - it("returns true when Spark status is Degraded (payments still possible)", async () => { - mockGetSparkStatus.mockResolvedValue({ - status: ServiceStatus.Degraded, - lastUpdated: BigInt(0), +describe("isOnlineStatus", () => { + it("returns true for Operational", () => { + expect(isOnlineStatus(ServiceStatus.Operational)).toBe(true) + }) + + it("returns true for Degraded (payments still possible)", () => { + expect(isOnlineStatus(ServiceStatus.Degraded)).toBe(true) + }) + + const OFFLINE_STATUSES: ReadonlyArray<{ label: string; status: ServiceStatus }> = [ + { label: "Partial", status: ServiceStatus.Partial }, + { label: "Unknown", status: ServiceStatus.Unknown }, + { label: "Major", status: ServiceStatus.Major }, + ] + + OFFLINE_STATUSES.forEach(({ label, status }) => { + it(`returns false for ${label}`, () => { + expect(isOnlineStatus(status)).toBe(false) }) + }) +}) - expect(await isOnline()).toBe(true) +describe("isOnline", () => { + beforeEach(() => { + jest.clearAllMocks() }) - it("returns false when Spark status is Partial", async () => { + it("returns true when status is Operational", async () => { mockGetSparkStatus.mockResolvedValue({ - status: ServiceStatus.Partial, + status: ServiceStatus.Operational, lastUpdated: BigInt(0), }) - expect(await isOnline()).toBe(false) + expect(await isOnline()).toBe(true) }) - it("returns false when Spark status is Unknown", async () => { + it("returns true when status is Degraded", async () => { mockGetSparkStatus.mockResolvedValue({ - status: ServiceStatus.Unknown, + status: ServiceStatus.Degraded, lastUpdated: BigInt(0), }) - expect(await isOnline()).toBe(false) + expect(await isOnline()).toBe(true) }) - it("returns false when Spark status is Major outage", async () => { + it("returns false when status is Major", async () => { mockGetSparkStatus.mockResolvedValue({ status: ServiceStatus.Major, lastUpdated: BigInt(0), @@ -58,8 +109,8 @@ describe("isOnline (boolean wrapper, backward-compat)", () => { expect(await isOnline()).toBe(false) }) - it("returns false when getSparkStatus throws (device offline / status API unreachable)", async () => { - mockGetSparkStatus.mockRejectedValue(new Error("Network request failed")) + it("returns false when getSparkStatus throws", async () => { + mockGetSparkStatus.mockRejectedValue(new Error("net down")) expect(await isOnline()).toBe(false) }) @@ -105,3 +156,45 @@ describe("getOnlineState (3-state, Critical #4)", () => { expect(await getOnlineState()).toBe("unknown") }) }) + +describe("crashlytics reporting on Spark status failures (I4)", () => { + beforeEach(() => { + mockRecordError.mockClear() + }) + + it("records to crashlytics once per session when getServiceStatus catches the SDK error", async () => { + const fresh = loadFreshIsOnlineModule() + mockGetSparkStatus.mockRejectedValue(new Error("network down")) + + await fresh.getServiceStatus() + await fresh.getServiceStatus() + await fresh.getServiceStatus() + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toBe("network down") + }) + + it("records to crashlytics once per session when getOnlineState catches the SDK error", async () => { + const fresh = loadFreshIsOnlineModule() + mockGetSparkStatus.mockRejectedValue(new Error("auth failed")) + + await fresh.getOnlineState() + await fresh.getOnlineState() + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toBe("auth failed") + }) + + it("does not record the failure when the SDK eventually returns a status", async () => { + const fresh = loadFreshIsOnlineModule() + mockGetSparkStatus.mockResolvedValue({ + status: ServiceStatus.Operational, + lastUpdated: BigInt(0), + }) + + await fresh.getServiceStatus() + await fresh.getOnlineState() + + expect(mockRecordError).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/self-custodial/providers/wallet-provider.fixtures.ts b/__tests__/self-custodial/providers/wallet-provider.fixtures.ts index 21a1ccb5f9..8b8fa31fb3 100644 --- a/__tests__/self-custodial/providers/wallet-provider.fixtures.ts +++ b/__tests__/self-custodial/providers/wallet-provider.fixtures.ts @@ -3,7 +3,11 @@ // but these helpers remove the repeated mock-access and lifecycle wiring from // every test body. -type WalletSnapshot = { wallets: unknown[]; hasMore: boolean } +type WalletSnapshot = { + wallets: unknown[] + hasMore: boolean + rawTransactionCount?: number +} type SdkEventListener = (event: { tag: string; inner?: unknown }) => Promise type CapturedListenerRef = { current: SdkEventListener | null } diff --git a/__tests__/self-custodial/providers/wallet-provider.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index 415ddb13f0..751e02b5f8 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -23,6 +23,13 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ PaymentFailed: "PaymentFailed", Optimization: "Optimization", }, + ServiceStatus: { + Operational: 0, + Degraded: 1, + Partial: 2, + Unknown: 3, + Major: 4, + }, initLogging: jest.fn(), })) @@ -31,6 +38,8 @@ const mockGetMnemonicNetwork = jest.fn() const mockInitSdk = jest.fn() const mockDisconnectSdk = jest.fn() const mockAddSdkEventListener = jest.fn() +const mockToastShow = jest.fn() +const mockGetUserSettings = jest.fn() jest.mock("@app/utils/storage/secureStorage", () => ({ __esModule: true, @@ -44,10 +53,11 @@ jest.mock("@app/self-custodial/bridge", () => ({ initSdk: (...args: unknown[]) => mockInitSdk(...args), disconnectSdk: (...args: unknown[]) => mockDisconnectSdk(...args), addSdkEventListener: (...args: unknown[]) => mockAddSdkEventListener(...args), - getUserSettings: jest.fn().mockResolvedValue({ - stableBalanceActiveLabel: undefined, - sparkPrivateModeEnabled: false, - }), + getUserSettings: (...args: unknown[]) => mockGetUserSettings(...args), +})) + +jest.mock("@app/utils/toast", () => ({ + toastShow: (...args: unknown[]) => mockToastShow(...args), })) jest.mock("@app/self-custodial/logging", () => ({ @@ -71,11 +81,21 @@ jest.mock("@app/self-custodial/providers/validate-network", () => ({ validateStoredNetwork: jest.fn().mockResolvedValue(true), })) -jest.mock("@app/self-custodial/providers/is-online", () => ({ - ...jest.requireActual("@app/self-custodial/providers/is-online"), - isOnline: jest.fn().mockResolvedValue(true), - getOnlineState: jest.fn().mockResolvedValue("online"), -})) +jest.mock("@app/self-custodial/providers/is-online", () => { + const Operational = 0 + const Degraded = 1 + return { + OnlineState: { + Online: "online", + Offline: "offline", + Unknown: "unknown", + }, + getOnlineState: jest.fn().mockResolvedValue("online"), + getServiceStatus: jest.fn().mockResolvedValue(Operational), + isOnlineStatus: (s: number) => s === Operational || s === Degraded, + isOnline: jest.fn().mockResolvedValue(true), + } +}) jest.mock("@app/self-custodial/providers/wallet-snapshot", () => ({ getSelfCustodialWalletSnapshot: jest.fn().mockResolvedValue([]), @@ -95,6 +115,10 @@ describe("SelfCustodialWalletProvider", () => { mockInitSdk.mockRejectedValue(new Error("SDK not available in test")) mockDisconnectSdk.mockResolvedValue(undefined) mockAddSdkEventListener.mockResolvedValue("listener-id") + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: undefined, + sparkPrivateModeEnabled: false, + }) jest .requireMock("@app/self-custodial/providers/is-online") .getOnlineState.mockResolvedValue("online") @@ -303,14 +327,11 @@ describe("SelfCustodialWalletProvider", () => { expect(mockAddSdkEventListener).toHaveBeenCalled() }) - // Fire event while first refresh is in-flight listener.current?.({ tag: "Synced" }) - // Resolve first refresh resolveFirst!() await waitFor(() => { - // Initial refresh + event-triggered coalesced refresh expect(getSelfCustodialWalletSnapshot).toHaveBeenCalledTimes(2) }) }) @@ -463,8 +484,7 @@ describe("SelfCustodialWalletProvider", () => { initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }) - const bridge = jest.requireMock("@app/self-custodial/bridge") - bridge.getUserSettings.mockRejectedValue(new Error("settings boom")) + mockGetUserSettings.mockRejectedValue(new Error("settings boom")) renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -483,10 +503,10 @@ describe("SelfCustodialWalletProvider", () => { initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValueOnce("online").mockResolvedValueOnce("unknown") + getOnlineStateMock.mockResolvedValueOnce("online").mockResolvedValueOnce("unknown") const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -498,7 +518,6 @@ describe("SelfCustodialWalletProvider", () => { await listener.current?.({ tag: "Synced" }) }) - // Unknown must NOT transition Ready → Offline. expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) @@ -512,9 +531,9 @@ describe("SelfCustodialWalletProvider", () => { "@app/self-custodial/providers/is-online", ).getOnlineState getOnlineStateMock - .mockResolvedValueOnce("online") // initial refresh → Ready - .mockResolvedValueOnce("offline") // Synced event → Offline - .mockResolvedValueOnce("online") // manual refresh → Ready + .mockResolvedValueOnce("online") + .mockResolvedValueOnce("offline") + .mockResolvedValueOnce("online") const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -538,6 +557,27 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) }) +}) + +describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetMnemonic.mockResolvedValue(null) + mockGetMnemonicNetwork.mockResolvedValue("regtest") + mockInitSdk.mockRejectedValue(new Error("SDK not available in test")) + mockDisconnectSdk.mockResolvedValue(undefined) + mockAddSdkEventListener.mockResolvedValue("listener-id") + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: undefined, + sparkPrivateModeEnabled: false, + }) + jest + .requireMock("@app/self-custodial/providers/is-online") + .getOnlineState.mockResolvedValue("online") + jest + .requireMock("@app/self-custodial/providers/is-online") + .isOnline.mockResolvedValue(true) + }) it("loadMore calls loadMoreTransactions and appends via appendTransactions", async () => { setupConnectedWallet( @@ -569,6 +609,246 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.hasMoreTransactions).toBe(false) }) + it("disconnects the SDK and skips listener registration when the provider unmounts before initSdk resolves (I11)", async () => { + let resolveInit: (sdk: unknown) => void = () => {} + mockInitSdk.mockImplementation( + () => + new Promise((resolve) => { + resolveInit = resolve + }), + ) + mockGetMnemonicForAccount.mockResolvedValue("word1 word2 word3") + mockListSelfCustodialAccounts.mockResolvedValue([ + { id: "test-sc-uuid", lightningAddress: null }, + ]) + mockState.activeAccountId = "test-sc-uuid" + const fakeSdk = { id: "fake-sdk" } + + const { unmount } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(mockInitSdk).toHaveBeenCalled() + }) + + unmount() + + await act(async () => { + resolveInit(fakeSdk) + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + }) + + expect(mockDisconnectSdk).toHaveBeenCalledWith(fakeSdk) + expect(mockAddSdkEventListener).not.toHaveBeenCalled() + }) + + it("preserves the loadMore cursor across refresh by passing the current raw offset to the snapshot (Critical #8)", async () => { + const { listener } = setupConnectedWallet( + { + getMnemonicForAccount: mockGetMnemonicForAccount, + listSelfCustodialAccounts: mockListSelfCustodialAccounts, + setActiveAccountId: (id: string) => { + mockState.activeAccountId = id + }, + initSdk: mockInitSdk, + addSdkEventListener: mockAddSdkEventListener, + }, + { wallets: [], hasMore: true, rawTransactionCount: 20 }, + ) + const snapshot = getWalletSnapshotMocks() + snapshot.loadMoreTransactions.mockResolvedValue({ + transactions: [{ id: "tx-loadmore" }], + rawCount: 20, + hasMore: true, + }) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.status).toBe(ActiveWalletStatus.Ready) + }) + + await act(async () => { + await result.current.loadMore() + }) + + snapshot.getSelfCustodialWalletSnapshot.mockClear() + await act(async () => { + await listener.current?.({ tag: "Synced" }) + }) + + expect(snapshot.getSelfCustodialWalletSnapshot).toHaveBeenCalledWith( + expect.anything(), + 40, + ) + }) + + const buildStaleSnapshot = () => ({ + wallets: [ + { + id: "btc", + walletCurrency: "BTC", + balance: { amount: 0, currency: "BTC", currencyCode: "BTC" }, + transactions: [ + { + id: "tx1", + amount: { amount: 100, currency: "BTC", currencyCode: "BTC" }, + direction: "receive", + status: "completed", + timestamp: 0, + paymentType: "lightning", + }, + ], + }, + ], + hasMore: false, + }) + + const buildFreshSnapshot = () => ({ + wallets: [ + { + id: "btc", + walletCurrency: "BTC", + balance: { amount: 100, currency: "BTC", currencyCode: "BTC" }, + transactions: [], + }, + ], + hasMore: false, + }) + + it("exposes isBalanceStale=true when balance=0 but history has completed incoming txs", async () => { + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue(buildStaleSnapshot()) + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.status).toBe(ActiveWalletStatus.Ready) + }) + await waitFor(() => { + expect(result.current.isBalanceStale).toBe(true) + }) + }) + + it("exposes isBalanceStale=false when balance is non-zero", async () => { + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue(buildFreshSnapshot()) + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.status).toBe(ActiveWalletStatus.Ready) + }) + + expect(result.current.isBalanceStale).toBe(false) + }) + + it("shows the balance-stale toast only on the false→true transition (not on every poll)", async () => { + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue(buildStaleSnapshot()) + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + let capturedListener: (event: { tag: string }) => Promise + mockAddSdkEventListener.mockImplementation( + (_sdk: unknown, onEvent: (event: { tag: string }) => Promise) => { + capturedListener = onEvent + return Promise.resolve("id") + }, + ) + + renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(mockToastShow).toHaveBeenCalledTimes(1) + }) + + await act(async () => { + await capturedListener!({ tag: "Synced" }) + }) + + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + expect(mockToastShow).toHaveBeenCalledTimes(1) + }) + + it("keeps isBalanceStale=true when status transitions to Offline (sticky, only a fresh snapshot clears it)", async () => { + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue(buildStaleSnapshot()) + + const getOnlineStateMock = jest.requireMock( + "@app/self-custodial/providers/is-online", + ).getOnlineState + getOnlineStateMock.mockResolvedValueOnce("online").mockResolvedValueOnce("offline") + + let capturedListener: (event: { tag: string }) => Promise + mockAddSdkEventListener.mockImplementation( + (_sdk: unknown, onEvent: (event: { tag: string }) => Promise) => { + capturedListener = onEvent + return Promise.resolve("id") + }, + ) + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.isBalanceStale).toBe(true) + }) + + await act(async () => { + await capturedListener!({ tag: "Synced" }) + }) + + await waitFor(() => { + expect(result.current.status).toBe(ActiveWalletStatus.Offline) + }) + expect(result.current.isBalanceStale).toBe(true) + }) + + it("clears isBalanceStale when a subsequent snapshot reports a non-zero balance", async () => { + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot + .mockResolvedValueOnce(buildStaleSnapshot()) + .mockResolvedValue(buildFreshSnapshot()) + + let capturedListener: (event: { tag: string }) => Promise + mockAddSdkEventListener.mockImplementation( + (_sdk: unknown, onEvent: (event: { tag: string }) => Promise) => { + capturedListener = onEvent + return Promise.resolve("id") + }, + ) + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.isBalanceStale).toBe(true) + }) + + await act(async () => { + await capturedListener!({ tag: "Synced" }) + }) + + await waitFor(() => { + expect(result.current.isBalanceStale).toBe(false) + }) + }) + it("preserves Error status when isOnline=false (does not downgrade to Offline)", async () => { const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ @@ -576,14 +856,10 @@ describe("SelfCustodialWalletProvider", () => { hasMore: false, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - // Initial refresh online so we reach Ready, then we simulate an Error - // status from elsewhere (e.g. init failure scenario) and check offline - // ticks do not overwrite it. Since the direct path from Ready cannot - // become Error, we validate through the network validation branch. - isOnlineMock.mockResolvedValue("offline") + getOnlineStateMock.mockResolvedValue("offline") const mockValidate = jest.requireMock( "@app/self-custodial/providers/validate-network", @@ -597,7 +873,6 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Error) }) - // Trigger a manual refresh while offline — Error must stay Error await act(async () => { await result.current.refreshWallets() }) @@ -606,10 +881,10 @@ describe("SelfCustodialWalletProvider", () => { }) it("preserves Unavailable status when isOnline=false (no mnemonic case)", async () => { - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValue("offline") + getOnlineStateMock.mockResolvedValue("offline") mockGetMnemonic.mockResolvedValue(null) @@ -619,9 +894,6 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Unavailable) }) - // refreshWallets returns early when sdkRef.current is null, so status - // never transitions here. This confirms the Unavailable path is untouched - // by offline detection. await act(async () => { await result.current.refreshWallets() }) @@ -636,10 +908,10 @@ describe("SelfCustodialWalletProvider", () => { hasMore: false, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValue("offline") + getOnlineStateMock.mockResolvedValue("offline") mockGetMnemonic.mockResolvedValue("word1 word2 word3") mockInitSdk.mockResolvedValue({}) @@ -650,52 +922,98 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Offline) }) - // Wallets should not be populated because refresh returned early expect(snapshot.getSelfCustodialWalletSnapshot).not.toHaveBeenCalled() }) it("polls refreshWallets every 10s while mounted", async () => { jest.useFakeTimers() + const { AppState } = jest.requireActual("react-native") + Object.defineProperty(AppState, "currentState", { + value: "active", + configurable: true, + }) const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ wallets: [], hasMore: false, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValue("online") + getOnlineStateMock.mockResolvedValue("online") + + const prevAppState = AppState.currentState + AppState.currentState = "active" mockGetMnemonic.mockResolvedValue("word1 word2 word3") mockInitSdk.mockResolvedValue({}) renderHook(() => useSelfCustodialWallet(), { wrapper }) - // Flush pending async init await act(async () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() }) - const initialIsOnlineCalls = isOnlineMock.mock.calls.length + const initialCalls = getOnlineStateMock.mock.calls.length - // Advance 10 seconds: one more poll tick await act(async () => { jest.advanceTimersByTime(10000) await Promise.resolve() }) - expect(isOnlineMock.mock.calls.length).toBeGreaterThan(initialIsOnlineCalls) + expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(initialCalls) - // Advance another 10 seconds: another tick - const afterFirstTick = isOnlineMock.mock.calls.length + const afterFirstTick = getOnlineStateMock.mock.calls.length await act(async () => { jest.advanceTimersByTime(10000) await Promise.resolve() }) - expect(isOnlineMock.mock.calls.length).toBeGreaterThan(afterFirstTick) + expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(afterFirstTick) + AppState.currentState = prevAppState + jest.useRealTimers() + }) + + it("skips the 10s poll tick when AppState is not 'active'", async () => { + jest.useFakeTimers() + const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ + wallets: [], + hasMore: false, + }) + const { ServiceStatus } = jest.requireMock("@breeztech/breez-sdk-spark-react-native") + const getServiceStatusMock = jest.requireMock( + "@app/self-custodial/providers/is-online", + ).getServiceStatus + getServiceStatusMock.mockResolvedValue(ServiceStatus.Operational) + + const { AppState } = jest.requireActual("react-native") + const prevAppState = AppState.currentState + AppState.currentState = "background" + + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({}) + + renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + const initialCalls = getServiceStatusMock.mock.calls.length + + await act(async () => { + jest.advanceTimersByTime(10000) + await Promise.resolve() + }) + + expect(getServiceStatusMock.mock.calls).toHaveLength(initialCalls) + + AppState.currentState = prevAppState jest.useRealTimers() }) @@ -707,10 +1025,10 @@ describe("SelfCustodialWalletProvider", () => { hasMore: false, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValue("online") + getOnlineStateMock.mockResolvedValue("online") mockGetMnemonic.mockResolvedValue("word1 word2 word3") mockInitSdk.mockResolvedValue({}) @@ -724,15 +1042,14 @@ describe("SelfCustodialWalletProvider", () => { }) unmount() - const afterUnmount = isOnlineMock.mock.calls.length + const afterUnmount = getOnlineStateMock.mock.calls.length - // Advance several intervals after unmount — should not trigger more calls await act(async () => { jest.advanceTimersByTime(60000) await Promise.resolve() }) - expect(isOnlineMock.mock.calls).toHaveLength(afterUnmount) + expect(getOnlineStateMock.mock.calls).toHaveLength(afterUnmount) jest.useRealTimers() }) @@ -745,10 +1062,10 @@ describe("SelfCustodialWalletProvider", () => { hasMore: false, }) - const isOnlineMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - isOnlineMock.mockResolvedValue("online") + getOnlineStateMock.mockResolvedValue("online") const listeners: Array<(state: string) => void> = [] const addEventListenerSpy = jest @@ -767,24 +1084,119 @@ describe("SelfCustodialWalletProvider", () => { expect(addEventListenerSpy).toHaveBeenCalled() }) - const callsBefore = isOnlineMock.mock.calls.length + const callsBefore = getOnlineStateMock.mock.calls.length await act(async () => { listeners.forEach((fn) => fn("active")) await Promise.resolve() }) - expect(isOnlineMock.mock.calls.length).toBeGreaterThan(callsBefore) + expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(callsBefore) - const callsAfterActive = isOnlineMock.mock.calls.length + const callsAfterActive = getOnlineStateMock.mock.calls.length await act(async () => { listeners.forEach((fn) => fn("background")) await Promise.resolve() }) - // Background transition should NOT trigger a refresh - expect(isOnlineMock.mock.calls).toHaveLength(callsAfterActive) + expect(getOnlineStateMock.mock.calls).toHaveLength(callsAfterActive) addEventListenerSpy.mockRestore() }) + + describe("isStableBalanceActive state and refreshStableBalanceActive()", () => { + it("defaults to false when getUserSettings returns no active label", async () => { + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({ id: "sdk" }) + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: undefined, + sparkPrivateModeEnabled: false, + }) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => expect(result.current.sdk).toBeTruthy()) + await waitFor(() => expect(mockGetUserSettings).toHaveBeenCalled()) + expect(result.current.isStableBalanceActive).toBe(false) + }) + + it("reports true when getUserSettings returns an active label", async () => { + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({ id: "sdk" }) + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: { label: "USDB" }, + sparkPrivateModeEnabled: false, + }) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => expect(result.current.sdk).toBeTruthy()) + await waitFor(() => expect(result.current.isStableBalanceActive).toBe(true)) + }) + + it("refreshStableBalanceActive() re-reads the SDK and flips the flag on change", async () => { + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({ id: "sdk" }) + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: undefined, + sparkPrivateModeEnabled: false, + }) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => expect(result.current.sdk).toBeTruthy()) + await waitFor(() => expect(result.current.isStableBalanceActive).toBe(false)) + + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: { label: "USDB" }, + sparkPrivateModeEnabled: false, + }) + + await act(async () => { + await result.current.refreshStableBalanceActive() + }) + + expect(result.current.isStableBalanceActive).toBe(true) + }) + + it("refreshStableBalanceActive() is a no-op when the SDK is not connected", async () => { + mockGetMnemonic.mockResolvedValue(null) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => + expect(result.current.status).toBe(ActiveWalletStatus.Unavailable), + ) + + const callsBefore = mockGetUserSettings.mock.calls.length + + await act(async () => { + await result.current.refreshStableBalanceActive() + }) + + expect(mockGetUserSettings.mock.calls).toHaveLength(callsBefore) + }) + + it("refreshStableBalanceActive() swallows errors and keeps the flag stable", async () => { + mockGetMnemonic.mockResolvedValue("word1 word2 word3") + mockInitSdk.mockResolvedValue({ id: "sdk" }) + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: { label: "USDB" }, + sparkPrivateModeEnabled: false, + }) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => expect(result.current.sdk).toBeTruthy()) + await waitFor(() => expect(result.current.isStableBalanceActive).toBe(true)) + + mockGetUserSettings.mockRejectedValueOnce(new Error("boom")) + + await act(async () => { + await result.current.refreshStableBalanceActive() + }) + + expect(result.current.isStableBalanceActive).toBe(true) + }) + }) }) diff --git a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index ffafd820d3..7d9250655b 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -9,11 +9,27 @@ import { loadMoreTransactions, } from "@app/self-custodial/providers/wallet-snapshot" +const mockRecordError = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) + jest.mock("@app/self-custodial/config", () => ({ SparkToken: { Label: "USDB", Ticker: "USDB" }, - SparkConfig: { tokenIdentifier: "test-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "test-token-id", })) +const loadFreshSnapshotModule = () => { + let mod: typeof import("@app/self-custodial/providers/wallet-snapshot") | undefined + jest.isolateModules(() => { + mod = require("@app/self-custodial/providers/wallet-snapshot") + }) + return mod! +} + const createMockSdk = (overrides = {}) => ({ getInfo: jest.fn().mockResolvedValue({ identityPubkey: "pubkey123", @@ -21,7 +37,11 @@ const createMockSdk = (overrides = {}) => ({ tokenBalances: { token1: { balance: 150000, - tokenMetadata: { ticker: "USDB", decimals: 6 }, + tokenMetadata: { + identifier: "test-token-id", + ticker: "USDB", + decimals: 6, + }, }, }, ...overrides, @@ -171,6 +191,112 @@ describe("loadMoreTransactions", () => { expect(page.hasMore).toBe(true) expect(page.transactions).toHaveLength(12) }) + + it("rawCount counts every payment from the SDK, not just the ones that survived filtering", async () => { + const sdk = createMockSdk() + sdk.listPayments.mockResolvedValue({ + payments: [ + ...Array.from({ length: 12 }, (_, i) => buildKnownPayment(`k-${i}`)), + ...Array.from({ length: 8 }, (_, i) => buildUnknownTokenPayment(`o-${i}`)), + ], + }) + + const page = await loadMoreTransactions(sdk as never, 20) + + expect(page.rawCount).toBe(20) + expect(page.transactions).toHaveLength(12) + }) + + it("a caller advancing the cursor by rawCount keeps the next loadMore aligned with the SDK page size, not the filtered count", async () => { + const sdk = createMockSdk() + sdk.listPayments + .mockResolvedValueOnce({ + payments: [ + ...Array.from({ length: 12 }, (_, i) => buildKnownPayment(`k0-${i}`)), + ...Array.from({ length: 8 }, (_, i) => buildUnknownTokenPayment(`o0-${i}`)), + ], + }) + .mockResolvedValueOnce({ + payments: Array.from({ length: 5 }, (_, i) => buildKnownPayment(`k1-${i}`)), + }) + + const first = await loadMoreTransactions(sdk as never, 0) + expect(first.rawCount).toBe(20) + expect(first.transactions).toHaveLength(12) + + await loadMoreTransactions(sdk as never, first.rawCount) + + expect(sdk.listPayments).toHaveBeenLastCalledWith( + expect.objectContaining({ offset: 20, limit: 20 }), + ) + }) +}) + +describe("getSelfCustodialWalletSnapshot pagination preservation (Critical #8)", () => { + it("fetches a single page when no targetRawCount is provided", async () => { + const sdk = createMockSdk() + sdk.listPayments.mockResolvedValue({ + payments: Array.from({ length: 20 }, (_, i) => buildKnownPayment(`p-${i}`)), + }) + + const snapshot = await getSelfCustodialWalletSnapshot(sdk as never) + + expect(sdk.listPayments).toHaveBeenCalledTimes(1) + expect(sdk.listPayments).toHaveBeenCalledWith( + expect.objectContaining({ offset: 0, limit: 20 }), + ) + expect(snapshot.rawTransactionCount).toBe(20) + expect(snapshot.hasMore).toBe(true) + }) + + it("re-fetches every page up to the target raw count so loadMore cursor is preserved across refresh", async () => { + const sdk = createMockSdk() + sdk.listPayments + .mockResolvedValueOnce({ + payments: Array.from({ length: 20 }, (_, i) => buildKnownPayment(`page0-${i}`)), + }) + .mockResolvedValueOnce({ + payments: Array.from({ length: 20 }, (_, i) => buildKnownPayment(`page1-${i}`)), + }) + .mockResolvedValueOnce({ + payments: Array.from({ length: 20 }, (_, i) => buildKnownPayment(`page2-${i}`)), + }) + + const snapshot = await getSelfCustodialWalletSnapshot(sdk as never, 60) + + expect(sdk.listPayments).toHaveBeenCalledTimes(3) + expect(sdk.listPayments).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ offset: 0, limit: 20 }), + ) + expect(sdk.listPayments).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ offset: 20, limit: 20 }), + ) + expect(sdk.listPayments).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ offset: 40, limit: 20 }), + ) + expect(snapshot.rawTransactionCount).toBe(60) + expect(snapshot.hasMore).toBe(true) + }) + + it("stops paginating early when the SDK returns fewer than a full page (no more transactions)", async () => { + const sdk = createMockSdk() + sdk.listPayments + .mockResolvedValueOnce({ + payments: Array.from({ length: 20 }, (_, i) => buildKnownPayment(`page0-${i}`)), + }) + .mockResolvedValueOnce({ + payments: Array.from({ length: 5 }, (_, i) => buildKnownPayment(`page1-${i}`)), + }) + + const snapshot = await getSelfCustodialWalletSnapshot(sdk as never, 60) + + expect(sdk.listPayments).toHaveBeenCalledTimes(2) + expect(snapshot.rawTransactionCount).toBe(25) + expect(snapshot.hasMore).toBe(false) + }) }) const buildTx = (id: string, currency: WalletCurrency): NormalizedTransaction => ({ @@ -264,3 +390,47 @@ describe("appendTransactions", () => { expect(result[0].transactions.map((t) => t.id)).toEqual(["a", "b"]) }) }) + +describe("isKnownPayment crashlytics reporting (Critical #10)", () => { + beforeEach(() => { + mockRecordError.mockClear() + }) + + it("records to crashlytics once per unknown token identifier and drops the payment", async () => { + const fresh = loadFreshSnapshotModule() + const unknownTokenPayment = { + id: "unknown-1", + method: 2, + paymentType: 0, + status: 0, + amount: 100, + fees: 0, + timestamp: 0, + details: { + tag: "Token", + inner: { + metadata: { identifier: "rogue-token-id", decimals: 6, ticker: "ROGUE" }, + }, + }, + } + const sdk = { + getInfo: jest.fn().mockResolvedValue({ + identityPubkey: "pk", + balanceSats: 0, + tokenBalances: {}, + }), + listPayments: jest.fn().mockResolvedValue({ + payments: [unknownTokenPayment, unknownTokenPayment], + }), + } as never + + await fresh.getSelfCustodialWalletSnapshot(sdk) + + const reportedErrors = mockRecordError.mock.calls.filter((args) => { + const message = args[0]?.message ?? "" + return message.includes("rogue-token-id") + }) + expect(reportedErrors).toHaveLength(1) + expect(reportedErrors[0][0].message).toContain("expected=test-token-id") + }) +}) diff --git a/__tests__/utils/amounts.spec.ts b/__tests__/utils/amounts.spec.ts index 0a623f11cb..ebbe50d6c5 100644 --- a/__tests__/utils/amounts.spec.ts +++ b/__tests__/utils/amounts.spec.ts @@ -1,5 +1,11 @@ import { WalletCurrency } from "@app/graphql/generated" -import { toSatsAmount } from "@app/utils/amounts" +import { + centsToTokenBaseUnits, + toSatsAmount, + tokenBaseUnitsToCents, + tokenBaseUnitsToCentsCeil, + tokenBaseUnitsToCentsExact, +} from "@app/utils/amounts" const mockConvert = jest.fn() @@ -32,3 +38,67 @@ describe("toSatsAmount", () => { expect(mockConvert).toHaveBeenCalledWith(usdAmount, WalletCurrency.Btc) }) }) + +describe("tokenBaseUnitsToCentsExact", () => { + it("preserves sub-cent precision when token decimals exceed display decimals", () => { + // 1.000001 USDB (6 decimals) = 1.0000001 cents (lossless) + expect(tokenBaseUnitsToCentsExact(1_000_001, 6)).toBe(100.0001) + expect(tokenBaseUnitsToCentsExact(1_500_000, 6)).toBe(150) + }) + + it("returns the raw amount when token decimals match display decimals", () => { + expect(tokenBaseUnitsToCentsExact(150, 2)).toBe(150) + }) + + it("returns the raw amount when token has fewer decimals than display", () => { + expect(tokenBaseUnitsToCentsExact(150, 1)).toBe(150) + }) +}) + +describe("tokenBaseUnitsToCents (round)", () => { + it("rounds to the nearest cent", () => { + expect(tokenBaseUnitsToCents(1_499_999, 6)).toBe(150) + expect(tokenBaseUnitsToCents(1_500_001, 6)).toBe(150) + }) + + it("rounds 0.5 ¢ residue up to the next cent (banker's rounding off)", () => { + expect(tokenBaseUnitsToCents(1_005_000, 6)).toBe(101) + }) +}) + +describe("tokenBaseUnitsToCentsCeil — for SDK minimums", () => { + it("rounds up when there is any sub-cent residue", () => { + expect(tokenBaseUnitsToCentsCeil(1_000_001, 6)).toBe(101) + }) + + it("does not change values that are already on a whole-cent boundary", () => { + expect(tokenBaseUnitsToCentsCeil(1_500_000, 6)).toBe(150) + }) + + it("rounds up tiny fractional residues to 1 cent", () => { + expect(tokenBaseUnitsToCentsCeil(1, 6)).toBe(1) + }) +}) + +describe("centsToTokenBaseUnits", () => { + it("scales cents into token base units when token has more decimals", () => { + expect(centsToTokenBaseUnits(150, 6)).toBe(1_500_000) + }) + + it("returns the input untouched when decimals match display", () => { + expect(centsToTokenBaseUnits(150, 2)).toBe(150) + }) + + it("rounds the scaled product so non-integer cents survive without drift", () => { + expect(centsToTokenBaseUnits(0.5, 6)).toBe(5000) + }) +}) + +describe("token base-unit round-trip", () => { + it("survives cents -> base units -> cents", () => { + const cents = 137 + const tokenDecimals = 6 + const baseUnits = centsToTokenBaseUnits(cents, tokenDecimals) + expect(tokenBaseUnitsToCents(baseUnits, tokenDecimals)).toBe(cents) + }) +}) diff --git a/app/components/balance-header/balance-header.tsx b/app/components/balance-header/balance-header.tsx index ec9052f0e7..2e6c259000 100644 --- a/app/components/balance-header/balance-header.tsx +++ b/app/components/balance-header/balance-header.tsx @@ -1,10 +1,13 @@ import * as React from "react" import ContentLoader, { Rect } from "react-content-loader/native" -import { TouchableOpacity, View, Text } from "react-native" +import { Pressable, TouchableOpacity, View, Text } from "react-native" import { makeStyles } from "@rn-vui/themed" +import { StatusPill, type StatusPillVariant } from "@app/components/status-pill" import { useHideAmount } from "@app/graphql/hide-amount-context" +import { useI18nContext } from "@app/i18n/i18n-react" +import { BalanceMode } from "@app/hooks/use-balance-mode" import { testProps } from "@app/utils/testProps" const Loader = () => { @@ -23,18 +26,41 @@ const Loader = () => { ) } +export type StatusBadge = { + label: string + status: StatusPillVariant +} + type Props = { loading: boolean formattedBalance?: string + showStableBalanceToggle?: boolean + mode?: BalanceMode + onModeChange?: () => void + statusBadge?: StatusBadge } -export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => { +export const BalanceHeader: React.FC = ({ + loading, + formattedBalance, + showStableBalanceToggle, + mode, + onModeChange, + statusBadge, +}) => { const styles = useStyles() + const { LL } = useI18nContext() const { hideAmount, switchMemoryHideAmount } = useHideAmount() + const currentMode = mode ?? BalanceMode.Btc + + const modeLabel = + currentMode === BalanceMode.Btc + ? LL.StableBalance.balanceLabelBtc() + : LL.StableBalance.balanceLabelUsd() + + const showBadge = Boolean(statusBadge) && !loading && !hideAmount - // TODO: use suspense for this component with the apollo suspense hook (in beta) - // so there is no need to pass loading from parent? return ( {hideAmount ? ( @@ -43,7 +69,15 @@ export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => ) : ( - + + {showBadge && statusBadge ? ( + + ) : null} {loading ? ( ) : ( @@ -55,9 +89,27 @@ export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => {formattedBalance} )} + {showBadge && statusBadge ? ( + + ) : null} )} + {showStableBalanceToggle && onModeChange ? ( + + {modeLabel} + + ) : null} ) } @@ -67,6 +119,11 @@ const useStyles = makeStyles(({ colors }) => ({ alignItems: "center", textAlign: "center", }, + amountWrapper: { + flexDirection: "row", + alignItems: "flex-start", + alignSelf: "center", + }, primaryBalanceText: { fontSize: 32, color: colors.black, @@ -82,4 +139,23 @@ const useStyles = makeStyles(({ colors }) => ({ fontWeight: "bold", color: colors.black, }, + modeToggle: { + marginTop: 4, + paddingVertical: 2, + paddingHorizontal: 8, + }, + modeToggleText: { + fontSize: 11, + fontWeight: "600", + color: colors.grey2, + letterSpacing: 0.6, + }, + statusPill: { + marginLeft: 6, + marginTop: 2, + }, + statusPillGhost: { + marginRight: 6, + marginTop: 2, + }, })) diff --git a/app/components/stable-balance-first-time-modal/index.ts b/app/components/stable-balance-first-time-modal/index.ts new file mode 100644 index 0000000000..efae36872d --- /dev/null +++ b/app/components/stable-balance-first-time-modal/index.ts @@ -0,0 +1 @@ +export { StableBalanceFirstTimeModal } from "./stable-balance-first-time-modal" diff --git a/app/components/stable-balance-first-time-modal/stable-balance-first-time-modal.tsx b/app/components/stable-balance-first-time-modal/stable-balance-first-time-modal.tsx new file mode 100644 index 0000000000..db06ea602f --- /dev/null +++ b/app/components/stable-balance-first-time-modal/stable-balance-first-time-modal.tsx @@ -0,0 +1,45 @@ +import React from "react" +import { View } from "react-native" + +import { makeStyles, Text } from "@rn-vui/themed" + +import CustomModal from "@app/components/custom-modal/custom-modal" +import { useI18nContext } from "@app/i18n/i18n-react" +import { testProps } from "@app/utils/testProps" + +type Props = { + isVisible: boolean + onAcknowledge: () => void +} + +export const StableBalanceFirstTimeModal: React.FC = ({ + isVisible, + onAcknowledge, +}) => { + const { LL } = useI18nContext() + const styles = useStyles() + + return ( + + + {LL.StableBalance.firstTimeModal.dualBalance()} + + {LL.StableBalance.firstTimeModal.trustDisclosure()} + + } + primaryButtonTitle={LL.StableBalance.firstTimeModal.acknowledge()} + primaryButtonOnPress={onAcknowledge} + /> + ) +} + +const useStyles = makeStyles(() => ({ + dualBalanceText: { + marginBottom: 12, + }, +})) diff --git a/app/components/status-pill/index.ts b/app/components/status-pill/index.ts new file mode 100644 index 0000000000..f2bee86fcc --- /dev/null +++ b/app/components/status-pill/index.ts @@ -0,0 +1,2 @@ +export { StatusPill } from "./status-pill" +export type { StatusPillVariant } from "./status-pill" diff --git a/app/components/status-pill/status-pill.tsx b/app/components/status-pill/status-pill.tsx new file mode 100644 index 0000000000..63b1d623e1 --- /dev/null +++ b/app/components/status-pill/status-pill.tsx @@ -0,0 +1,62 @@ +import React from "react" +import { StyleProp, View, ViewStyle } from "react-native" + +import { makeStyles, Text } from "@rn-vui/themed" + +import { testProps } from "@app/utils/testProps" + +export type StatusPillVariant = "warning" | "error" | "success" | "primary" + +type Props = { + label: string + status: StatusPillVariant + ghost?: boolean + testID?: string + style?: StyleProp +} + +export const StatusPill: React.FC = ({ label, status, ghost, testID, style }) => { + const styles = useStyles({ status }) + + const body = {label} + + if (ghost) { + return ( + + {body} + + ) + } + + return ( + + {body} + + ) +} + +const useStyles = makeStyles(({ colors }, { status }: { status: StatusPillVariant }) => ({ + pill: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 10, + backgroundColor: colors[status], + }, + ghost: { + opacity: 0, + }, + label: { + fontSize: 9, + fontWeight: "700", + color: colors.black, + letterSpacing: 0.4, + includeFontPadding: false, + }, +})) diff --git a/app/hooks/use-balance-mode.ts b/app/hooks/use-balance-mode.ts new file mode 100644 index 0000000000..46d41c828f --- /dev/null +++ b/app/hooks/use-balance-mode.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react" + +import AsyncStorage from "@react-native-async-storage/async-storage" +import crashlytics from "@react-native-firebase/crashlytics" + +export const BalanceMode = { + Btc: "btc", + Usd: "usd", +} as const + +export type BalanceMode = (typeof BalanceMode)[keyof typeof BalanceMode] + +const STORAGE_KEY = "selfCustodialBalanceMode" + +const isBalanceMode = (value: string | null): value is BalanceMode => + value === BalanceMode.Btc || value === BalanceMode.Usd + +type UseBalanceModeResult = { + mode: BalanceMode + setMode: (next: BalanceMode) => void + toggleMode: () => void + loaded: boolean +} + +const persistMode = (next: BalanceMode) => { + AsyncStorage.setItem(STORAGE_KEY, next).catch((err) => { + if (err instanceof Error) crashlytics().recordError(err) + }) +} + +export const useBalanceMode = (): UseBalanceModeResult => { + const [mode, setModeState] = useState(BalanceMode.Btc) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY) + .then((raw) => { + if (isBalanceMode(raw)) setModeState(raw) + }) + .catch((err) => { + if (err instanceof Error) crashlytics().recordError(err) + }) + .finally(() => { + setLoaded(true) + }) + }, []) + + const setMode = useCallback((next: BalanceMode) => { + setModeState(next) + persistMode(next) + }, []) + + const toggleMode = useCallback(() => { + setModeState((prev) => { + const next = prev === BalanceMode.Btc ? BalanceMode.Usd : BalanceMode.Btc + persistMode(next) + return next + }) + }, []) + + return { mode, setMode, toggleMode, loaded } +} diff --git a/app/hooks/use-payments.ts b/app/hooks/use-payments.ts index a539271539..36a1c2e38c 100644 --- a/app/hooks/use-payments.ts +++ b/app/hooks/use-payments.ts @@ -14,7 +14,7 @@ import { createListPendingDeposits, } from "@app/self-custodial/adapters/deposit-adapter" import { - createConvert, + createGetConversionQuote, createReceiveLightning, createReceiveOnchain, } from "@app/self-custodial/bridge" @@ -22,6 +22,7 @@ import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-pro import { type ClaimDepositAdapter, type ConvertAdapter, + type GetConversionQuoteAdapter, type GetFeeAdapter, type ListPendingDepositsAdapter, type ReceiveLightningAdapter, @@ -40,6 +41,7 @@ type PaymentsResult = { listPendingDeposits?: ListPendingDepositsAdapter claimDeposit?: ClaimDepositAdapter convert?: ConvertAdapter + getConversionQuote?: GetConversionQuoteAdapter accountType?: AccountType } @@ -57,7 +59,7 @@ export const usePayments = (): PaymentsResult => { receiveOnchain: createReceiveOnchain(sdk), listPendingDeposits: createListPendingDeposits(sdk), claimDeposit: createClaimDeposit(sdk), - convert: createConvert(sdk), + getConversionQuote: createGetConversionQuote(sdk), accountType, } } diff --git a/app/hooks/use-stable-balance-first-time.ts b/app/hooks/use-stable-balance-first-time.ts new file mode 100644 index 0000000000..ea003c22b8 --- /dev/null +++ b/app/hooks/use-stable-balance-first-time.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react" + +import AsyncStorage from "@react-native-async-storage/async-storage" +import crashlytics from "@react-native-firebase/crashlytics" + +const STORAGE_KEY = "stableBalanceExplanationShown" + +type UseStableBalanceFirstTimeResult = { + shouldShow: boolean + markAsShown: () => void + loaded: boolean +} + +export const useStableBalanceFirstTime = (): UseStableBalanceFirstTimeResult => { + const [shown, setShown] = useState(true) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY) + .then((raw) => { + setShown(raw === "true") + }) + .catch((err) => { + if (err instanceof Error) crashlytics().recordError(err) + }) + .finally(() => { + setLoaded(true) + }) + }, []) + + const markAsShown = useCallback(() => { + setShown(true) + AsyncStorage.setItem(STORAGE_KEY, "true").catch((err) => { + if (err instanceof Error) crashlytics().recordError(err) + }) + }, []) + + return { + shouldShow: loaded && !shown, + markAsShown, + loaded, + } +} diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index c0fdcad254..f706f71c9b 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -81,6 +81,10 @@ const en: BaseTranslation = { infoBitcoin: "Bitcoin amount is only approximate. It can vary by a small amount.", infoDollar: "Dollar amount is only approximate. It can vary by a small amount.", transferButtonText: "Transfer {fromWallet} to {toWallet}", + feeLabel: "Conversion fee", + feeError: "Couldn't fetch the conversion fee", + amountFloored: "Amount increased to meet the conversion minimum.", + amountDustBumped: "Amount increased to convert your full balance.", }, ConversionSuccessScreen: { message: "Transfer successful", @@ -3726,6 +3730,10 @@ const en: BaseTranslation = { "Your non-custodial wallet can't reach the network right now. Try again when you're back online.", retry: "Try again", }, + SelfCustodialBalance: { + staleLabel: "STALE", + syncFailedToast: "Balance sync failed. Your balance may be out of date.", + }, UnclaimedDeposit: { screenTitle: "Unclaimed Deposits", cardTitle: "Claim {sats} sats", @@ -3759,6 +3767,39 @@ const en: BaseTranslation = { StableBalance: { title: "Stable Balance", description: "Hold a USD-denominated balance powered by USDB on Spark.", + balanceLabelBtc: "Balance · SATS", + balanceLabelUsd: "Balance · USD", + settingsRowTitle: "Stable Balance", + settingsTitle: "Stable Balance", + settingsDescription: + "Keep part of your wallet in USD. Convert between BTC and USD manually anytime using the Convert action.", + activationLabel: "Active", + activeHint: "Your wallet is holding USD via USDB.", + inactiveHint: "Your wallet is holding BTC only.", + deactivateWarningBody: + "You still have {amount:string}. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + toggleFailedToast: "Could not update Stable Balance. Please try again.", + toggleModal: { + activateTitle: "Activate Stable Balance", + activateBody: + "Your BTC balance will be converted to USDB. This is the estimated conversion fee.", + activateConfirm: "Activate", + deactivateTitle: "Deactivate Stable Balance", + deactivateBody: + "Your USDB balance will be converted back to BTC. This is the estimated conversion fee.", + deactivateConfirm: "Deactivate", + cancel: "Cancel", + }, + firstTimeModal: { + title: "About Convert", + dualBalance: + "BTC and USD are two independent balances in your wallet. Use Convert any time to move funds between them.", + trustDisclosure: + "USD mode uses USDB tokens on Spark. The trust assumptions are different from holding BTC directly. USDB relies on Spark's token issuer.", + acknowledge: "I understand", + }, + minimumConversion: "Minimum conversion: {amount:string}", + conversionUnavailable: "Conversion is temporarily unavailable. Please try again.", }, SparkWalletCreationScreen: { creating: "Creating your wallet...", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 31281c842b..cc12b1ab02 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -286,6 +286,22 @@ type RootTranslation = { * @param {unknown} toWallet */ transferButtonText: RequiredParams<'fromWallet' | 'toWallet'> + /** + * C​o​n​v​e​r​s​i​o​n​ ​f​e​e + */ + feeLabel: string + /** + * C​o​u​l​d​n​'​t​ ​f​e​t​c​h​ ​t​h​e​ ​c​o​n​v​e​r​s​i​o​n​ ​f​e​e + */ + feeError: string + /** + * A​m​o​u​n​t​ ​i​n​c​r​e​a​s​e​d​ ​t​o​ ​m​e​e​t​ ​t​h​e​ ​c​o​n​v​e​r​s​i​o​n​ ​m​i​n​i​m​u​m​. + */ + amountFloored: string + /** + * A​m​o​u​n​t​ ​i​n​c​r​e​a​s​e​d​ ​t​o​ ​c​o​n​v​e​r​t​ ​y​o​u​r​ ​f​u​l​l​ ​b​a​l​a​n​c​e​. + */ + amountDustBumped: string } ConversionSuccessScreen: { /** @@ -11806,6 +11822,16 @@ type RootTranslation = { */ retry: string } + SelfCustodialBalance: { + /** + * S​T​A​L​E + */ + staleLabel: string + /** + * B​a​l​a​n​c​e​ ​s​y​n​c​ ​f​a​i​l​e​d​.​ ​Y​o​u​r​ ​b​a​l​a​n​c​e​ ​m​a​y​ ​b​e​ ​o​u​t​ ​o​f​ ​d​a​t​e​. + */ + syncFailedToast: string + } UnclaimedDeposit: { /** * U​n​c​l​a​i​m​e​d​ ​D​e​p​o​s​i​t​s @@ -11937,6 +11963,104 @@ type RootTranslation = { * H​o​l​d​ ​a​ ​U​S​D​-​d​e​n​o​m​i​n​a​t​e​d​ ​b​a​l​a​n​c​e​ ​p​o​w​e​r​e​d​ ​b​y​ ​U​S​D​B​ ​o​n​ ​S​p​a​r​k​. */ description: string + /** + * B​a​l​a​n​c​e​ ​·​ ​S​A​T​S + */ + balanceLabelBtc: string + /** + * B​a​l​a​n​c​e​ ​·​ ​U​S​D + */ + balanceLabelUsd: string + /** + * S​t​a​b​l​e​ ​B​a​l​a​n​c​e + */ + settingsRowTitle: string + /** + * S​t​a​b​l​e​ ​B​a​l​a​n​c​e + */ + settingsTitle: string + /** + * K​e​e​p​ ​p​a​r​t​ ​o​f​ ​y​o​u​r​ ​w​a​l​l​e​t​ ​i​n​ ​U​S​D​.​ ​C​o​n​v​e​r​t​ ​b​e​t​w​e​e​n​ ​B​T​C​ ​a​n​d​ ​U​S​D​ ​m​a​n​u​a​l​l​y​ ​a​n​y​t​i​m​e​ ​u​s​i​n​g​ ​t​h​e​ ​C​o​n​v​e​r​t​ ​a​c​t​i​o​n​. + */ + settingsDescription: string + /** + * A​c​t​i​v​e + */ + activationLabel: string + /** + * Y​o​u​r​ ​w​a​l​l​e​t​ ​i​s​ ​h​o​l​d​i​n​g​ ​U​S​D​ ​v​i​a​ ​U​S​D​B​. + */ + activeHint: string + /** + * Y​o​u​r​ ​w​a​l​l​e​t​ ​i​s​ ​h​o​l​d​i​n​g​ ​B​T​C​ ​o​n​l​y​. + */ + inactiveHint: string + /** + * Y​o​u​ ​s​t​i​l​l​ ​h​a​v​e​ ​{​a​m​o​u​n​t​}​.​ ​C​o​n​v​e​r​t​ ​t​o​ ​B​T​C​ ​f​i​r​s​t​,​ ​o​r​ ​y​o​u​r​ ​U​S​D​ ​b​a​l​a​n​c​e​ ​w​i​l​l​ ​b​e​ ​h​i​d​d​e​n​ ​u​n​t​i​l​ ​y​o​u​ ​r​e​a​c​t​i​v​a​t​e​ ​S​t​a​b​l​e​ ​B​a​l​a​n​c​e​. + * @param {string} amount + */ + deactivateWarningBody: RequiredParams<'amount'> + /** + * C​o​u​l​d​ ​n​o​t​ ​u​p​d​a​t​e​ ​S​t​a​b​l​e​ ​B​a​l​a​n​c​e​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + toggleFailedToast: string + toggleModal: { + /** + * A​c​t​i​v​a​t​e​ ​S​t​a​b​l​e​ ​B​a​l​a​n​c​e + */ + activateTitle: string + /** + * Y​o​u​r​ ​B​T​C​ ​b​a​l​a​n​c​e​ ​w​i​l​l​ ​b​e​ ​c​o​n​v​e​r​t​e​d​ ​t​o​ ​U​S​D​B​.​ ​T​h​i​s​ ​i​s​ ​t​h​e​ ​e​s​t​i​m​a​t​e​d​ ​c​o​n​v​e​r​s​i​o​n​ ​f​e​e​. + */ + activateBody: string + /** + * A​c​t​i​v​a​t​e + */ + activateConfirm: string + /** + * D​e​a​c​t​i​v​a​t​e​ ​S​t​a​b​l​e​ ​B​a​l​a​n​c​e + */ + deactivateTitle: string + /** + * Y​o​u​r​ ​U​S​D​B​ ​b​a​l​a​n​c​e​ ​w​i​l​l​ ​b​e​ ​c​o​n​v​e​r​t​e​d​ ​b​a​c​k​ ​t​o​ ​B​T​C​.​ ​T​h​i​s​ ​i​s​ ​t​h​e​ ​e​s​t​i​m​a​t​e​d​ ​c​o​n​v​e​r​s​i​o​n​ ​f​e​e​. + */ + deactivateBody: string + /** + * D​e​a​c​t​i​v​a​t​e + */ + deactivateConfirm: string + /** + * C​a​n​c​e​l + */ + cancel: string + } + firstTimeModal: { + /** + * A​b​o​u​t​ ​C​o​n​v​e​r​t + */ + title: string + /** + * B​T​C​ ​a​n​d​ ​U​S​D​ ​a​r​e​ ​t​w​o​ ​i​n​d​e​p​e​n​d​e​n​t​ ​b​a​l​a​n​c​e​s​ ​i​n​ ​y​o​u​r​ ​w​a​l​l​e​t​.​ ​U​s​e​ ​C​o​n​v​e​r​t​ ​a​n​y​ ​t​i​m​e​ ​t​o​ ​m​o​v​e​ ​f​u​n​d​s​ ​b​e​t​w​e​e​n​ ​t​h​e​m​. + */ + dualBalance: string + /** + * U​S​D​ ​m​o​d​e​ ​u​s​e​s​ ​U​S​D​B​ ​t​o​k​e​n​s​ ​o​n​ ​S​p​a​r​k​.​ ​T​h​e​ ​t​r​u​s​t​ ​a​s​s​u​m​p​t​i​o​n​s​ ​a​r​e​ ​d​i​f​f​e​r​e​n​t​ ​f​r​o​m​ ​h​o​l​d​i​n​g​ ​B​T​C​ ​d​i​r​e​c​t​l​y​.​ ​U​S​D​B​ ​r​e​l​i​e​s​ ​o​n​ ​S​p​a​r​k​'​s​ ​t​o​k​e​n​ ​i​s​s​u​e​r​. + */ + trustDisclosure: string + /** + * I​ ​u​n​d​e​r​s​t​a​n​d + */ + acknowledge: string + } + /** + * M​i​n​i​m​u​m​ ​c​o​n​v​e​r​s​i​o​n​:​ ​{​a​m​o​u​n​t​} + * @param {string} amount + */ + minimumConversion: RequiredParams<'amount'> + /** + * C​o​n​v​e​r​s​i​o​n​ ​i​s​ ​t​e​m​p​o​r​a​r​i​l​y​ ​u​n​a​v​a​i​l​a​b​l​e​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + conversionUnavailable: string } SparkWalletCreationScreen: { /** @@ -12210,6 +12334,22 @@ export type TranslationFunctions = { * Transfer {fromWallet} to {toWallet} */ transferButtonText: (arg: { fromWallet: unknown, toWallet: unknown }) => LocalizedString + /** + * Conversion fee + */ + feeLabel: () => LocalizedString + /** + * Couldn't fetch the conversion fee + */ + feeError: () => LocalizedString + /** + * Amount increased to meet the conversion minimum. + */ + amountFloored: () => LocalizedString + /** + * Amount increased to convert your full balance. + */ + amountDustBumped: () => LocalizedString } ConversionSuccessScreen: { /** @@ -23618,6 +23758,16 @@ export type TranslationFunctions = { */ retry: () => LocalizedString } + SelfCustodialBalance: { + /** + * STALE + */ + staleLabel: () => LocalizedString + /** + * Balance sync failed. Your balance may be out of date. + */ + syncFailedToast: () => LocalizedString + } UnclaimedDeposit: { /** * Unclaimed Deposits @@ -23737,6 +23887,102 @@ export type TranslationFunctions = { * Hold a USD-denominated balance powered by USDB on Spark. */ description: () => LocalizedString + /** + * Balance · SATS + */ + balanceLabelBtc: () => LocalizedString + /** + * Balance · USD + */ + balanceLabelUsd: () => LocalizedString + /** + * Stable Balance + */ + settingsRowTitle: () => LocalizedString + /** + * Stable Balance + */ + settingsTitle: () => LocalizedString + /** + * Keep part of your wallet in USD. Convert between BTC and USD manually anytime using the Convert action. + */ + settingsDescription: () => LocalizedString + /** + * Active + */ + activationLabel: () => LocalizedString + /** + * Your wallet is holding USD via USDB. + */ + activeHint: () => LocalizedString + /** + * Your wallet is holding BTC only. + */ + inactiveHint: () => LocalizedString + /** + * You still have {amount}. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance. + */ + deactivateWarningBody: (arg: { amount: string }) => LocalizedString + /** + * Could not update Stable Balance. Please try again. + */ + toggleFailedToast: () => LocalizedString + toggleModal: { + /** + * Activate Stable Balance + */ + activateTitle: () => LocalizedString + /** + * Your BTC balance will be converted to USDB. This is the estimated conversion fee. + */ + activateBody: () => LocalizedString + /** + * Activate + */ + activateConfirm: () => LocalizedString + /** + * Deactivate Stable Balance + */ + deactivateTitle: () => LocalizedString + /** + * Your USDB balance will be converted back to BTC. This is the estimated conversion fee. + */ + deactivateBody: () => LocalizedString + /** + * Deactivate + */ + deactivateConfirm: () => LocalizedString + /** + * Cancel + */ + cancel: () => LocalizedString + } + firstTimeModal: { + /** + * About Convert + */ + title: () => LocalizedString + /** + * BTC and USD are two independent balances in your wallet. Use Convert any time to move funds between them. + */ + dualBalance: () => LocalizedString + /** + * USD mode uses USDB tokens on Spark. The trust assumptions are different from holding BTC directly. USDB relies on Spark's token issuer. + */ + trustDisclosure: () => LocalizedString + /** + * I understand + */ + acknowledge: () => LocalizedString + } + /** + * Minimum conversion: {amount} + */ + minimumConversion: (arg: { amount: string }) => LocalizedString + /** + * Conversion is temporarily unavailable. Please try again. + */ + conversionUnavailable: () => LocalizedString } SparkWalletCreationScreen: { /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 118f6f7a84..be0ada78ea 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -70,7 +70,11 @@ "receivingAccount": "Receiving account", "infoBitcoin": "Bitcoin amount is only approximate. It can vary by a small amount.", "infoDollar": "Dollar amount is only approximate. It can vary by a small amount.", - "transferButtonText": "Transfer {fromWallet} to {toWallet}" + "transferButtonText": "Transfer {fromWallet} to {toWallet}", + "feeLabel": "Conversion fee", + "feeError": "Couldn't fetch the conversion fee", + "amountFloored": "Amount increased to meet the conversion minimum.", + "amountDustBumped": "Amount increased to convert your full balance." }, "ConversionSuccessScreen": { "message": "Transfer successful" @@ -3569,6 +3573,10 @@ "description": "Your non-custodial wallet can't reach the network right now. Try again when you're back online.", "retry": "Try again" }, + "SelfCustodialBalance": { + "staleLabel": "STALE", + "syncFailedToast": "Balance sync failed. Your balance may be out of date." + }, "UnclaimedDeposit": { "screenTitle": "Unclaimed Deposits", "cardTitle": "Claim {sats} sats", @@ -3600,7 +3608,34 @@ }, "StableBalance": { "title": "Stable Balance", - "description": "Hold a USD-denominated balance powered by USDB on Spark." + "description": "Hold a USD-denominated balance powered by USDB on Spark.", + "balanceLabelBtc": "Balance · SATS", + "balanceLabelUsd": "Balance · USD", + "settingsRowTitle": "Stable Balance", + "settingsTitle": "Stable Balance", + "settingsDescription": "Keep part of your wallet in USD. Convert between BTC and USD manually anytime using the Convert action.", + "activationLabel": "Active", + "activeHint": "Your wallet is holding USD via USDB.", + "inactiveHint": "Your wallet is holding BTC only.", + "deactivateWarningBody": "You still have {amount:string}. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + "toggleFailedToast": "Could not update Stable Balance. Please try again.", + "toggleModal": { + "activateTitle": "Activate Stable Balance", + "activateBody": "Your BTC balance will be converted to USDB. This is the estimated conversion fee.", + "activateConfirm": "Activate", + "deactivateTitle": "Deactivate Stable Balance", + "deactivateBody": "Your USDB balance will be converted back to BTC. This is the estimated conversion fee.", + "deactivateConfirm": "Deactivate", + "cancel": "Cancel" + }, + "firstTimeModal": { + "title": "About Convert", + "dualBalance": "BTC and USD are two independent balances in your wallet. Use Convert any time to move funds between them.", + "trustDisclosure": "USD mode uses USDB tokens on Spark. The trust assumptions are different from holding BTC directly. USDB relies on Spark's token issuer.", + "acknowledge": "I understand" + }, + "minimumConversion": "Minimum conversion: {amount:string}", + "conversionUnavailable": "Conversion is temporarily unavailable. Please try again." }, "SparkWalletCreationScreen": { "creating": "Creating your wallet...", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index cad7169f20..2014d92476 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -72,7 +72,11 @@ "receivingAccount": "Ontvangsrekening", "infoBitcoin": "Bitcoin-bedrag is slegs 'n skatting. Dit kan met 'n klein bedrag wissel.", "infoDollar": "Dollar-bedrag is slegs 'n skatting. Dit kan met 'n klein bedrag wissel.", - "transferButtonText": "Dra {fromWallet} oor na {toWallet}" + "transferButtonText": "Dra {fromWallet} oor na {toWallet}", + "feeLabel": "Omskakelingsfooi", + "feeError": "Kon nie die omskakelingsfooi haal nie", + "amountFloored": "Bedrag verhoog om die omskakelingsminimum te haal.", + "amountDustBumped": "Bedrag verhoog om jou volle saldo om te skakel." }, "ConversionSuccessScreen": { "message": "Omskakeling suksesvol", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Stabiele balans", - "description": "Hou 'n USD-balans aangedryf deur USDB op Spark." + "description": "Hou 'n USD-balans aangedryf deur USDB op Spark.", + "balanceLabelBtc": "Balans · SATS", + "balanceLabelUsd": "Balans · USD", + "settingsRowTitle": "Stabiele Balans", + "settingsTitle": "Stabiele Balans", + "settingsDescription": "Hou 'n deel van jou beursie in USD. Skakel BTC en USD enige tyd handmatig om met die Skakel-aksie.", + "activationLabel": "Aktief", + "activeHint": "Jou beursie hou USD via USDB.", + "inactiveHint": "Jou beursie hou net BTC.", + "deactivateWarningBody": "Jy het nog {amount:string}. Skakel eers om na BTC, anders word jou USD-saldo versteek totdat jy Stabiele Balans heractiveer.", + "toggleFailedToast": "Kon nie Stabiele Balans bywerk nie. Probeer asseblief weer.", + "toggleModal": { + "activateTitle": "Aktiveer Stabiele Saldo", + "activateBody": "Jou BTC-saldo sal na USDB omgeskakel word. Dit is die geskatte omskakelingsfooi.", + "activateConfirm": "Aktiveer", + "deactivateTitle": "Deaktiveer Stabiele Saldo", + "deactivateBody": "Jou USDB-saldo sal terug na BTC omgeskakel word. Dit is die geskatte omskakelingsfooi.", + "deactivateConfirm": "Deaktiveer", + "cancel": "Kanselleer" + }, + "firstTimeModal": { + "title": "Oor Omskakeling", + "dualBalance": "BTC en USD is twee onafhanklike saldo's in jou beursie. Gebruik Convert enige tyd om fondse tussen hulle te skuif.", + "trustDisclosure": "USD-modus gebruik USDB-tokens op Spark. Die vertroue-aannames is anders as om BTC direk te hou. USDB steun op Spark se token-uitreiker.", + "acknowledge": "Ek verstaan" + }, + "minimumConversion": "Minimum omskakeling: {amount:string}", + "conversionUnavailable": "Omskakeling is tydelik onbeskikbaar. Probeer asseblief weer." }, "BackendFeatureGate": { "title": "Funksie nie beskikbaar nie", @@ -3634,6 +3665,10 @@ "description": "Jou nie-bewaarderbeursie kan nie die netwerk nou bereik nie. Probeer weer wanneer jy aanlyn is.", "retry": "Probeer weer" }, + "SelfCustodialBalance": { + "staleLabel": "VEROUDERD", + "syncFailedToast": "Balans sinkronisasie het misluk. Jou balans is dalk verouderd." + }, "UnclaimedDeposit": { "title": "Jy het {count} onopgeëiste deposito('s)", "description": "Totaal: {sats} sats beskikbaar om te eis", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index 2f737a2d6d..07c269bfce 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -72,7 +72,11 @@ "receivingAccount": "حساب الاستلام", "infoBitcoin": "مبلغ Bitcoin تقريبي فقط. قد يختلف بمقدار صغير.", "infoDollar": "مبلغ الدولار تقريبي فقط. قد يختلف بمقدار صغير.", - "transferButtonText": "تحويل من {fromWallet} إلى {toWallet}" + "transferButtonText": "تحويل من {fromWallet} إلى {toWallet}", + "feeLabel": "رسوم التحويل", + "feeError": "تعذر جلب رسوم التحويل", + "amountFloored": "تم زيادة المبلغ للوصول إلى الحد الأدنى للتحويل.", + "amountDustBumped": "تم زيادة المبلغ لتحويل رصيدك بالكامل." }, "ConversionSuccessScreen": { "message": "تم التحويل بنجاح", @@ -3601,7 +3605,34 @@ }, "StableBalance": { "title": "الرصيد المستقر", - "description": "احتفظ برصيد مقوّم بالدولار مدعوم بـ USDB على Spark." + "description": "احتفظ برصيد مقوّم بالدولار مدعوم بـ USDB على Spark.", + "balanceLabelBtc": "الرصيد · ساتس", + "balanceLabelUsd": "الرصيد · دولار", + "settingsRowTitle": "الرصيد المستقر", + "settingsTitle": "الرصيد المستقر", + "settingsDescription": "احتفظ بجزء من محفظتك بالدولار الأمريكي. قم بالتحويل بين BTC والدولار الأمريكي يدويًا في أي وقت باستخدام إجراء التحويل.", + "activationLabel": "مفعل", + "activeHint": "محفظتك تحتفظ بالدولار عبر USDB.", + "inactiveHint": "محفظتك تحتفظ بالبيتكوين فقط.", + "deactivateWarningBody": "لا يزال لديك {amount:string}. قم بالتحويل إلى بيتكوين أولاً، وإلا سيتم إخفاء رصيد الدولار حتى تقوم بإعادة تفعيل الرصيد المستقر.", + "toggleFailedToast": "تعذر تحديث الرصيد المستقر. يرجى المحاولة مرة أخرى.", + "toggleModal": { + "activateTitle": "تفعيل الرصيد الثابت", + "activateBody": "سيتم تحويل رصيد البيتكوين الخاص بك إلى USDB. هذه هي رسوم التحويل التقديرية.", + "activateConfirm": "تفعيل", + "deactivateTitle": "إلغاء تفعيل الرصيد الثابت", + "deactivateBody": "سيتم تحويل رصيد USDB الخاص بك إلى البيتكوين. هذه هي رسوم التحويل التقديرية.", + "deactivateConfirm": "إلغاء التفعيل", + "cancel": "إلغاء" + }, + "firstTimeModal": { + "title": "عن التحويل", + "dualBalance": "BTC و USD رصيدان مستقلان في محفظتك. استخدم Convert في أي وقت لنقل الأموال بينهما.", + "trustDisclosure": "يستخدم وضع USD رموز USDB على Spark. افتراضات الثقة تختلف عن الاحتفاظ بالبيتكوين مباشرة. USDB يعتمد على مُصدر الرموز في Spark.", + "acknowledge": "فهمت" + }, + "minimumConversion": "الحد الأدنى للتحويل: {amount:string}", + "conversionUnavailable": "التحويل غير متاح مؤقتًا. يرجى المحاولة مرة أخرى." }, "BackendFeatureGate": { "title": "الميزة غير متاحة", @@ -3631,6 +3662,10 @@ "description": "محفظتك غير المحتجزة لا يمكنها الوصول إلى الشبكة الآن. حاول مرة أخرى عندما تكون متصلاً بالإنترنت.", "retry": "حاول مرة أخرى" }, + "SelfCustodialBalance": { + "staleLabel": "قديم", + "syncFailedToast": "فشل تزامن الرصيد. قد يكون رصيدك قديماً." + }, "UnclaimedDeposit": { "title": "لديك {count} إيداع(ات) غير مطالب بها", "description": "الإجمالي: {sats} سات متاحة للمطالبة", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index 6ac799afc5..8c3a2a567c 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -70,7 +70,11 @@ "receivingAccount": "Compte receptor", "infoBitcoin": "La quantitat de Bitcoin és només aproximada. Pot variar lleugerament.", "infoDollar": "La quantitat en dòlars és només aproximada. Pot variar lleugerament.", - "transferButtonText": "Transferir {fromWallet} a {toWallet}" + "transferButtonText": "Transferir {fromWallet} a {toWallet}", + "feeLabel": "Comissió de conversió", + "feeError": "No s'ha pogut obtenir la comissió de conversió", + "amountFloored": "Import augmentat per complir el mínim de conversió.", + "amountDustBumped": "Import augmentat per convertir tot el teu saldo." }, "ConversionSuccessScreen": { "message": "Conversió efectuada amb èxit", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Balanç estable", - "description": "Mantén un balanç denominat en USD amb USDB a Spark." + "description": "Mantén un balanç denominat en USD amb USDB a Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Estable", + "settingsTitle": "Saldo Estable", + "settingsDescription": "Mantén part de la teva cartera en USD. Converteix entre BTC i USD manualment en qualsevol moment amb l'acció Converteix.", + "activationLabel": "Actiu", + "activeHint": "La teva cartera manté USD via USDB.", + "inactiveHint": "La teva cartera manté només BTC.", + "deactivateWarningBody": "Encara tens {amount:string}. Converteix a BTC primer, o el teu saldo en USD quedarà ocult fins que reactivis el Saldo Estable.", + "toggleFailedToast": "No s'ha pogut actualitzar el saldo estable. Torna-ho a provar.", + "toggleModal": { + "activateTitle": "Activa el saldo estable", + "activateBody": "El teu saldo en BTC es convertirà a USDB. Aquesta és la comissió de conversió estimada.", + "activateConfirm": "Activar", + "deactivateTitle": "Desactiva el saldo estable", + "deactivateBody": "El teu saldo en USDB es tornarà a convertir a BTC. Aquesta és la comissió de conversió estimada.", + "deactivateConfirm": "Desactivar", + "cancel": "Cancel·la" + }, + "firstTimeModal": { + "title": "Sobre Convertir", + "dualBalance": "BTC i USD són dos saldos independents a la teva cartera. Utilitza Convert en qualsevol moment per moure fons entre ells.", + "trustDisclosure": "El mode USD utilitza tokens USDB a Spark. Les suposicions de confiança són diferents a guardar BTC directament. USDB depèn de l'emissor de tokens de Spark.", + "acknowledge": "Ho entenc" + }, + "minimumConversion": "Conversió mínima: {amount:string}", + "conversionUnavailable": "La conversió no està disponible temporalment. Torna-ho a provar." }, "BackendFeatureGate": { "title": "Funció no disponible", @@ -3593,6 +3624,10 @@ "description": "La teva cartera no custodial no pot arribar a la xarxa ara mateix. Torna-ho a provar quan estiguis connectat.", "retry": "Torna-ho a provar" }, + "SelfCustodialBalance": { + "staleLabel": "OBSOLET", + "syncFailedToast": "No s'ha pogut sincronitzar el saldo. El teu saldo pot estar desactualitzat." + }, "UnclaimedDeposit": { "title": "Tens {count} dipòsit(s) no reclamat(s)", "description": "Total: {sats} sats disponibles per reclamar", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index f0127e98b1..9e0397d643 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -72,7 +72,11 @@ "receivingAccount": "Přijímací účet", "infoBitcoin": "Částka v Bitcoinu je pouze přibližná. Může se mírně lišit.", "infoDollar": "Částka v dolarech je pouze přibližná. Může se mírně lišit.", - "transferButtonText": "Převést {fromWallet} na {toWallet}" + "transferButtonText": "Převést {fromWallet} na {toWallet}", + "feeLabel": "Poplatek za konverzi", + "feeError": "Nepodařilo se načíst poplatek za konverzi", + "amountFloored": "Částka zvýšena na minimum konverze.", + "amountDustBumped": "Částka zvýšena pro konverzi celého zůstatku." }, "ConversionSuccessScreen": { "message": "Úspěšný převod", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Stabilní zůstatek", - "description": "Držte zůstatek v USD poháněný USDB na Spark." + "description": "Držte zůstatek v USD poháněný USDB na Spark.", + "balanceLabelBtc": "Zůstatek · SATS", + "balanceLabelUsd": "Zůstatek · USD", + "settingsRowTitle": "Stabilní zůstatek", + "settingsTitle": "Stabilní zůstatek", + "settingsDescription": "Část své peněženky uchovávejte v USD. BTC a USD převádějte kdykoli ručně pomocí akce Převést.", + "activationLabel": "Aktivní", + "activeHint": "Peněženka drží USD přes USDB.", + "inactiveHint": "Peněženka drží pouze BTC.", + "deactivateWarningBody": "Máš ještě {amount:string}. Nejdřív převeď na BTC, jinak bude zůstatek v USD skrytý, dokud Stabilní zůstatek znovu neaktivuješ.", + "toggleFailedToast": "Stabilní zůstatek se nepodařilo aktualizovat. Zkuste to znovu.", + "toggleModal": { + "activateTitle": "Aktivovat stabilní zůstatek", + "activateBody": "Váš zůstatek v BTC bude převeden na USDB. Toto je odhadovaný poplatek za převod.", + "activateConfirm": "Aktivovat", + "deactivateTitle": "Deaktivovat stabilní zůstatek", + "deactivateBody": "Váš zůstatek v USDB bude převeden zpět na BTC. Toto je odhadovaný poplatek za převod.", + "deactivateConfirm": "Deaktivovat", + "cancel": "Zrušit" + }, + "firstTimeModal": { + "title": "O převodu", + "dualBalance": "BTC a USD jsou dva nezávislé zůstatky ve vaší peněžence. Kdykoli použijte Convert k přesunu prostředků mezi nimi.", + "trustDisclosure": "Režim USD používá tokeny USDB na Sparku. Předpoklady důvěry jsou jiné než držení BTC přímo. USDB spoléhá na emitenta tokenů Sparku.", + "acknowledge": "Rozumím" + }, + "minimumConversion": "Minimální převod: {amount:string}", + "conversionUnavailable": "Konverze je dočasně nedostupná. Zkuste to znovu." }, "BackendFeatureGate": { "title": "Funkce nedostupná", @@ -3634,6 +3665,10 @@ "description": "Vaše nesvěřenská peněženka momentálně nemůže dosáhnout sítě. Zkuste to znovu, až budete online.", "retry": "Zkusit znovu" }, + "SelfCustodialBalance": { + "staleLabel": "ZASTARALÝ", + "syncFailedToast": "Synchronizace zůstatku se nezdařila. Váš zůstatek může být neaktuální." + }, "UnclaimedDeposit": { "title": "Máte {count} nevyzvednutý(ch) vklad(ů)", "description": "Celkem: {sats} satů k vyzvednutí", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index bf9dfa5ca5..e4d41d871e 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -72,7 +72,11 @@ "receivingAccount": "Modtager konto", "infoBitcoin": "Bitcoin-beløbet er kun omtrentligt. Det kan variere med et lille beløb.", "infoDollar": "Dollar-beløbet er kun omtrentligt. Det kan variere med et lille beløb.", - "transferButtonText": "Overfør {fromWallet} til {toWallet}" + "transferButtonText": "Overfør {fromWallet} til {toWallet}", + "feeLabel": "Konverteringsgebyr", + "feeError": "Kunne ikke hente konverteringsgebyret", + "amountFloored": "Beløb forøget for at opfylde konverteringsminimum.", + "amountDustBumped": "Beløb forøget for at konvertere hele din saldo." }, "ConversionSuccessScreen": { "message": "Konvertering vel afsluttet", @@ -3581,7 +3585,34 @@ }, "StableBalance": { "title": "Stabil saldo", - "description": "Hold en USD-saldo drevet af USDB på Spark." + "description": "Hold en USD-saldo drevet af USDB på Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Stabil Saldo", + "settingsTitle": "Stabil Saldo", + "settingsDescription": "Hold en del af din pung i USD. Konverter manuelt mellem BTC og USD når som helst med Konverter-handlingen.", + "activationLabel": "Aktiv", + "activeHint": "Din pung holder USD via USDB.", + "inactiveHint": "Din pung holder kun BTC.", + "deactivateWarningBody": "Du har stadig {amount:string}. Konverter til BTC først, ellers skjules din USD-saldo, indtil du genaktiverer Stable Balance.", + "toggleFailedToast": "Kunne ikke opdatere Stable Balance. Prøv igen.", + "toggleModal": { + "activateTitle": "Aktivér stabil saldo", + "activateBody": "Din BTC-saldo vil blive konverteret til USDB. Dette er det anslåede konverteringsgebyr.", + "activateConfirm": "Aktivér", + "deactivateTitle": "Deaktivér stabil saldo", + "deactivateBody": "Din USDB-saldo vil blive konverteret tilbage til BTC. Dette er det anslåede konverteringsgebyr.", + "deactivateConfirm": "Deaktivér", + "cancel": "Annullér" + }, + "firstTimeModal": { + "title": "Om Konvertering", + "dualBalance": "BTC og USD er to uafhængige saldi i din tegnebog. Brug Convert når som helst til at flytte midler mellem dem.", + "trustDisclosure": "USD-tilstand bruger USDB-tokens på Spark. Tillidsantagelserne er anderledes end at holde BTC direkte. USDB er afhængig af Sparks tokenudsteder.", + "acknowledge": "Jeg forstår" + }, + "minimumConversion": "Minimum konvertering: {amount:string}", + "conversionUnavailable": "Konvertering er midlertidigt utilgængelig. Prøv igen." }, "BackendFeatureGate": { "title": "Funktion ikke tilgængelig", @@ -3611,6 +3642,10 @@ "description": "Din ikke-deponerede pung kan ikke nå netværket lige nu. Prøv igen, når du er online.", "retry": "Prøv igen" }, + "SelfCustodialBalance": { + "staleLabel": "FORÆLDET", + "syncFailedToast": "Synkronisering af saldo mislykkedes. Din saldo kan være forældet." + }, "UnclaimedDeposit": { "title": "Du har {count} uafhentede indbetalinger", "description": "Total: {sats} sats tilgængelige at gøre krav på", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index 1b04dddc0c..4cdc498602 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -70,7 +70,11 @@ "receivingAccount": "Empfangendes Konto", "infoBitcoin": "Der Bitcoin-Betrag ist nur ungefähr. Er kann geringfügig abweichen.", "infoDollar": "Der Dollar-Betrag ist nur ungefähr. Er kann geringfügig abweichen.", - "transferButtonText": "{fromWallet} zu {toWallet} transferieren" + "transferButtonText": "{fromWallet} zu {toWallet} transferieren", + "feeLabel": "Konvertierungsgebühr", + "feeError": "Konvertierungsgebühr konnte nicht abgerufen werden", + "amountFloored": "Betrag erhöht, um das Konvertierungsminimum zu erreichen.", + "amountDustBumped": "Betrag erhöht, um dein gesamtes Guthaben umzuwandeln." }, "ConversionSuccessScreen": { "message": "Das hat geklappt!", @@ -3551,7 +3555,34 @@ }, "StableBalance": { "title": "Stabiler Saldo", - "description": "Halte einen USD-Saldo betrieben durch USDB auf Spark." + "description": "Halte einen USD-Saldo betrieben durch USDB auf Spark.", + "balanceLabelBtc": "Guthaben · SATS", + "balanceLabelUsd": "Guthaben · USD", + "settingsRowTitle": "Stabiler Saldo", + "settingsTitle": "Stabiler Saldo", + "settingsDescription": "Halte einen Teil deiner Wallet in USD. Wandle BTC und USD jederzeit manuell mit der Konvertieren-Aktion um.", + "activationLabel": "Aktiv", + "activeHint": "Deine Wallet hält USD über USDB.", + "inactiveHint": "Deine Wallet hält nur BTC.", + "deactivateWarningBody": "Du hast noch {amount:string}. Wandle zuerst in BTC um, sonst wird dein USD-Guthaben ausgeblendet, bis du Stable Balance erneut aktivierst.", + "toggleFailedToast": "Stable Balance konnte nicht aktualisiert werden. Bitte versuche es erneut.", + "toggleModal": { + "activateTitle": "Stabiles Guthaben aktivieren", + "activateBody": "Dein BTC-Guthaben wird in USDB umgewandelt. Dies ist die geschätzte Umrechnungsgebühr.", + "activateConfirm": "Aktivieren", + "deactivateTitle": "Stabiles Guthaben deaktivieren", + "deactivateBody": "Dein USDB-Guthaben wird zurück in BTC umgewandelt. Dies ist die geschätzte Umrechnungsgebühr.", + "deactivateConfirm": "Deaktivieren", + "cancel": "Abbrechen" + }, + "firstTimeModal": { + "title": "Über Umwandlung", + "dualBalance": "BTC und USD sind zwei unabhängige Guthaben in deiner Wallet. Nutze Convert jederzeit, um Gelder zwischen ihnen zu bewegen.", + "trustDisclosure": "Der USD-Modus nutzt USDB-Tokens auf Spark. Die Vertrauensannahmen unterscheiden sich vom direkten Halten von BTC. USDB stützt sich auf den Token-Emittenten von Spark.", + "acknowledge": "Ich verstehe" + }, + "minimumConversion": "Mindestumwandlung: {amount:string}", + "conversionUnavailable": "Die Umwandlung ist vorübergehend nicht verfügbar. Bitte versuche es erneut." }, "BackendFeatureGate": { "title": "Funktion nicht verfügbar", @@ -3581,6 +3612,10 @@ "description": "Deine nicht-verwahrte Wallet kann das Netzwerk gerade nicht erreichen. Versuche es erneut, wenn du wieder online bist.", "retry": "Erneut versuchen" }, + "SelfCustodialBalance": { + "staleLabel": "VERALTET", + "syncFailedToast": "Saldo-Synchronisierung fehlgeschlagen. Dein Saldo könnte veraltet sein." + }, "UnclaimedDeposit": { "title": "Du hast {count} nicht beanspruchte Einzahlung(en)", "description": "Gesamt: {sats} Sats zum Beanspruchen verfügbar", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index ee1a81955b..1ef9a2556e 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -70,7 +70,11 @@ "receivingAccount": "Λογαριασμός λήψης", "infoBitcoin": "Το ποσό σε Bitcoin είναι κατά προσέγγιση. Μπορεί να διαφέρει ελαφρώς.", "infoDollar": "Το ποσό σε δολάρια είναι κατά προσέγγιση. Μπορεί να διαφέρει ελαφρώς.", - "transferButtonText": "Μεταφορά {fromWallet} σε {toWallet}" + "transferButtonText": "Μεταφορά {fromWallet} σε {toWallet}", + "feeLabel": "Χρέωση μετατροπής", + "feeError": "Δεν ήταν δυνατή η ανάκτηση της χρέωσης μετατροπής", + "amountFloored": "Το ποσό αυξήθηκε για να ικανοποιήσει το ελάχιστο μετατροπής.", + "amountDustBumped": "Το ποσό αυξήθηκε για να μετατραπεί όλο το υπόλοιπό σου." }, "ConversionSuccessScreen": { "message": "Επιτυχής μετατροπή", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Σταθερό υπόλοιπο", - "description": "Διατηρήστε υπόλοιπο σε USD με USDB στο Spark." + "description": "Διατηρήστε υπόλοιπο σε USD με USDB στο Spark.", + "balanceLabelBtc": "Υπόλοιπο · SATS", + "balanceLabelUsd": "Υπόλοιπο · USD", + "settingsRowTitle": "Σταθερό Υπόλοιπο", + "settingsTitle": "Σταθερό Υπόλοιπο", + "settingsDescription": "Διατηρήστε μέρος του πορτοφολιού σας σε USD. Μετατρέψτε χειροκίνητα μεταξύ BTC και USD ανά πάσα στιγμή χρησιμοποιώντας την ενέργεια Μετατροπή.", + "activationLabel": "Ενεργό", + "activeHint": "Το πορτοφόλι διατηρεί USD μέσω USDB.", + "inactiveHint": "Το πορτοφόλι διατηρεί μόνο BTC.", + "deactivateWarningBody": "Έχεις ακόμη {amount:string}. Μετατρέψτε πρώτα σε BTC, αλλιώς το υπόλοιπο USD θα είναι κρυφό μέχρι να επανενεργοποιήσετε το Stable Balance.", + "toggleFailedToast": "Δεν ήταν δυνατή η ενημέρωση του Stable Balance. Δοκιμάστε ξανά.", + "toggleModal": { + "activateTitle": "Ενεργοποίηση σταθερού υπολοίπου", + "activateBody": "Το υπόλοιπό σας σε BTC θα μετατραπεί σε USDB. Αυτή είναι η εκτιμώμενη χρέωση μετατροπής.", + "activateConfirm": "Ενεργοποίηση", + "deactivateTitle": "Απενεργοποίηση σταθερού υπολοίπου", + "deactivateBody": "Το υπόλοιπό σας σε USDB θα μετατραπεί ξανά σε BTC. Αυτή είναι η εκτιμώμενη χρέωση μετατροπής.", + "deactivateConfirm": "Απενεργοποίηση", + "cancel": "Ακύρωση" + }, + "firstTimeModal": { + "title": "Σχετικά με τη Μετατροπή", + "dualBalance": "Το BTC και το USD είναι δύο ανεξάρτητα υπόλοιπα στο πορτοφόλι σας. Χρησιμοποιήστε το Convert οποιαδήποτε στιγμή για να μεταφέρετε χρήματα μεταξύ τους.", + "trustDisclosure": "Η λειτουργία USD χρησιμοποιεί tokens USDB στο Spark. Οι παραδοχές εμπιστοσύνης είναι διαφορετικές από το να κρατάτε BTC απευθείας. το USDB βασίζεται στον εκδότη tokens του Spark.", + "acknowledge": "Καταλαβαίνω" + }, + "minimumConversion": "Ελάχιστη μετατροπή: {amount:string}", + "conversionUnavailable": "Η μετατροπή δεν είναι διαθέσιμη προσωρινά. Δοκιμάστε ξανά." }, "BackendFeatureGate": { "title": "Λειτουργία μη διαθέσιμη", @@ -3593,6 +3624,10 @@ "description": "Το μη θεματοφυλακικό πορτοφόλι σας δεν μπορεί να φτάσει στο δίκτυο αυτή τη στιγμή. Δοκιμάστε ξανά όταν είστε συνδεδεμένοι.", "retry": "Δοκιμάστε ξανά" }, + "SelfCustodialBalance": { + "staleLabel": "ΠΑΡΩΧΗΜΕΝΟ", + "syncFailedToast": "Η συγχρονισμός υπολοίπου απέτυχε. Το υπόλοιπό σας ενδέχεται να μην είναι ενημερωμένο." + }, "UnclaimedDeposit": { "title": "Έχετε {count} μη διεκδικημένη(-ες) κατάθεση(-εις)", "description": "Σύνολο: {sats} sats διαθέσιμα για διεκδίκηση", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 133737ee19..3d0a5edaac 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -70,7 +70,11 @@ "receivingAccount": "Cuenta destino", "infoBitcoin": "La cantidad de Bitcoin es solo aproximada. Puede variar en una pequeña cantidad.", "infoDollar": "La cantidad de Dólares es solo aproximada. Puede variar en una pequeña cantidad.", - "transferButtonText": "Transferir {fromWallet} a {toWallet}" + "transferButtonText": "Transferir {fromWallet} a {toWallet}", + "feeLabel": "Comisión de conversión", + "feeError": "No se pudo obtener la comisión de conversión", + "amountFloored": "Monto aumentado para cumplir el mínimo de conversión.", + "amountDustBumped": "Monto aumentado para convertir todo tu saldo." }, "ConversionSuccessScreen": { "message": "Transferencia exitosa" @@ -3551,7 +3555,34 @@ }, "StableBalance": { "title": "Balance estable", - "description": "Mantén un balance en USD impulsado por USDB en Spark." + "description": "Mantén un balance en USD impulsado por USDB en Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Estable", + "settingsTitle": "Saldo Estable", + "settingsDescription": "Mantén parte de tu billetera en USD. Convierte entre BTC y USD manualmente cuando quieras con la acción Convertir.", + "activationLabel": "Activo", + "activeHint": "Tu billetera mantiene USD vía USDB.", + "inactiveHint": "Tu billetera solo mantiene BTC.", + "deactivateWarningBody": "Todavía tienes {amount:string}. Conviértelos a BTC primero, o tu saldo en USD quedará oculto hasta que reactives Saldo Estable.", + "toggleFailedToast": "No se pudo actualizar Stable Balance. Inténtalo de nuevo.", + "toggleModal": { + "activateTitle": "Activar saldo estable", + "activateBody": "Tu saldo en BTC se convertirá a USDB. Esta es la comisión de conversión estimada.", + "activateConfirm": "Activar", + "deactivateTitle": "Desactivar saldo estable", + "deactivateBody": "Tu saldo en USDB se convertirá de nuevo a BTC. Esta es la comisión de conversión estimada.", + "deactivateConfirm": "Desactivar", + "cancel": "Cancelar" + }, + "firstTimeModal": { + "title": "Sobre Convertir", + "dualBalance": "BTC y USD son dos saldos independientes en tu billetera. Usa Convert en cualquier momento para mover fondos entre ellos.", + "trustDisclosure": "El modo USD usa tokens USDB en Spark. Las suposiciones de confianza son distintas a mantener BTC directamente. USDB depende del emisor de tokens de Spark.", + "acknowledge": "Entiendo" + }, + "minimumConversion": "Conversión mínima: {amount:string}", + "conversionUnavailable": "La conversión no está disponible temporalmente. Inténtalo de nuevo." }, "BackendFeatureGate": { "title": "Función no disponible", @@ -3581,6 +3612,10 @@ "description": "Tu billetera no custodial no puede alcanzar la red en este momento. Inténtalo de nuevo cuando vuelvas a estar en línea.", "retry": "Intentar de nuevo" }, + "SelfCustodialBalance": { + "staleLabel": "DESACTUALIZADO", + "syncFailedToast": "Error al sincronizar el balance. Tu balance puede estar desactualizado." + }, "UnclaimedDeposit": { "title": "Tienes {count} depósito(s) sin reclamar", "description": "Total: {sats} sats disponibles para reclamar", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index f90bf18d51..dc33bcd111 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -72,7 +72,11 @@ "receivingAccount": "Compte destinataire", "infoBitcoin": "Le montant en Bitcoin est approximatif. Il peut varier légèrement.", "infoDollar": "Le montant en dollars est approximatif. Il peut varier légèrement.", - "transferButtonText": "Transférer de {fromWallet} vers {toWallet}" + "transferButtonText": "Transférer de {fromWallet} vers {toWallet}", + "feeLabel": "Frais de conversion", + "feeError": "Impossible de récupérer les frais de conversion", + "amountFloored": "Montant augmenté pour atteindre le minimum de conversion.", + "amountDustBumped": "Montant augmenté pour convertir la totalité de votre solde." }, "ConversionSuccessScreen": { "message": "Conversion réussie", @@ -3592,7 +3596,34 @@ }, "StableBalance": { "title": "Solde stable", - "description": "Conservez un solde en USD propulsé par USDB sur Spark." + "description": "Conservez un solde en USD propulsé par USDB sur Spark.", + "balanceLabelBtc": "Solde · SATS", + "balanceLabelUsd": "Solde · USD", + "settingsRowTitle": "Solde Stable", + "settingsTitle": "Solde Stable", + "settingsDescription": "Gardez une partie de votre portefeuille en USD. Convertissez entre BTC et USD manuellement à tout moment avec l'action Convertir.", + "activationLabel": "Actif", + "activeHint": "Votre portefeuille détient des USD via USDB.", + "inactiveHint": "Votre portefeuille ne détient que des BTC.", + "deactivateWarningBody": "Il vous reste {amount:string}. Convertissez-les d'abord en BTC, sinon votre solde USD sera masqué jusqu'à ce que vous réactiviez Stable Balance.", + "toggleFailedToast": "Impossible de mettre à jour Stable Balance. Veuillez réessayer.", + "toggleModal": { + "activateTitle": "Activer le solde stable", + "activateBody": "Votre solde en BTC sera converti en USDB. Voici les frais de conversion estimés.", + "activateConfirm": "Activer", + "deactivateTitle": "Désactiver le solde stable", + "deactivateBody": "Votre solde en USDB sera reconverti en BTC. Voici les frais de conversion estimés.", + "deactivateConfirm": "Désactiver", + "cancel": "Annuler" + }, + "firstTimeModal": { + "title": "À propos de la Conversion", + "dualBalance": "BTC et USD sont deux soldes indépendants dans votre portefeuille. Utilisez Convert à tout moment pour déplacer des fonds entre eux.", + "trustDisclosure": "Le mode USD utilise des jetons USDB sur Spark. Les hypothèses de confiance diffèrent de la détention directe de BTC. USDB repose sur l'émetteur de jetons de Spark.", + "acknowledge": "Je comprends" + }, + "minimumConversion": "Conversion minimale : {amount:string}", + "conversionUnavailable": "La conversion est temporairement indisponible. Veuillez réessayer." }, "BackendFeatureGate": { "title": "Fonctionnalité indisponible", @@ -3622,6 +3653,10 @@ "description": "Votre portefeuille non-custodial ne peut pas atteindre le réseau pour le moment. Réessayez lorsque vous serez de nouveau en ligne.", "retry": "Réessayer" }, + "SelfCustodialBalance": { + "staleLabel": "OBSOLÈTE", + "syncFailedToast": "Échec de la synchronisation du solde. Votre solde peut être obsolète." + }, "UnclaimedDeposit": { "title": "Vous avez {count} dépôt(s) non réclamé(s)", "description": "Total : {sats} sats disponibles à réclamer", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index 67a389f4d4..f5c11a8e5f 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -72,7 +72,11 @@ "receivingAccount": "Račun za primanje", "infoBitcoin": "Bitcoin iznos je samo približan. Može varirati za mali iznos.", "infoDollar": "Dolarski iznos je samo približan. Može varirati za mali iznos.", - "transferButtonText": "Prenesi {fromWallet} u {toWallet}" + "transferButtonText": "Prenesi {fromWallet} u {toWallet}", + "feeLabel": "Naknada za konverziju", + "feeError": "Nije bilo moguće dohvatiti naknadu za konverziju", + "amountFloored": "Iznos povećan kako bi se zadovoljio minimum konverzije.", + "amountDustBumped": "Iznos povećan radi konverzije cijelog salda." }, "ConversionSuccessScreen": { "message": "Konverzija uspješna", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Stabilan saldo", - "description": "Držite saldo u USD pokretan USDB na Sparku." + "description": "Držite saldo u USD pokretan USDB na Sparku.", + "balanceLabelBtc": "Stanje · SATS", + "balanceLabelUsd": "Stanje · USD", + "settingsRowTitle": "Stabilno stanje", + "settingsTitle": "Stabilno stanje", + "settingsDescription": "Dio novčanika držite u USD. BTC i USD pretvarajte ručno u bilo kojem trenutku pomoću radnje Pretvori.", + "activationLabel": "Aktivno", + "activeHint": "Tvoj novčanik drži USD putem USDB.", + "inactiveHint": "Tvoj novčanik drži samo BTC.", + "deactivateWarningBody": "Još imaš {amount:string}. Pretvori u BTC prvo, inače će tvoje USD stanje biti skriveno dok ponovno ne aktiviraš Stable Balance.", + "toggleFailedToast": "Nije moguće ažurirati Stable Balance. Pokušajte ponovno.", + "toggleModal": { + "activateTitle": "Aktiviraj stabilni saldo", + "activateBody": "Vaš BTC saldo bit će pretvoren u USDB. Ovo je procijenjena naknada za konverziju.", + "activateConfirm": "Aktiviraj", + "deactivateTitle": "Deaktiviraj stabilni saldo", + "deactivateBody": "Vaš USDB saldo bit će pretvoren natrag u BTC. Ovo je procijenjena naknada za konverziju.", + "deactivateConfirm": "Deaktiviraj", + "cancel": "Odustani" + }, + "firstTimeModal": { + "title": "O pretvorbi", + "dualBalance": "BTC i USD su dva neovisna salda u vašem novčaniku. Koristite Convert u bilo kojem trenutku za premještanje sredstava između njih.", + "trustDisclosure": "USD način koristi USDB tokene na Sparku. Pretpostavke povjerenja razlikuju se od izravnog držanja BTC-a. USDB se oslanja na Sparkovog izdavatelja tokena.", + "acknowledge": "Razumijem" + }, + "minimumConversion": "Minimalna pretvorba: {amount:string}", + "conversionUnavailable": "Konverzija je privremeno nedostupna. Pokušajte ponovno." }, "BackendFeatureGate": { "title": "Značajka nedostupna", @@ -3634,6 +3665,10 @@ "description": "Vaš ne-skrbnički novčanik trenutno ne može pristupiti mreži. Pokušajte ponovno kada se vratite na mrežu.", "retry": "Pokušaj ponovno" }, + "SelfCustodialBalance": { + "staleLabel": "ZASTARJELO", + "syncFailedToast": "Sinkronizacija stanja nije uspjela. Vaše stanje možda nije ažurno." + }, "UnclaimedDeposit": { "title": "Imate {count} nezatraženi(h) polog(a)", "description": "Ukupno: {sats} satova dostupno za potraživanje", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index a06c14a0c8..ac0d751eec 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -70,7 +70,11 @@ "receivingAccount": "Fogadó számla", "infoBitcoin": "A Bitcoin összeg csak hozzávetőleges. Kis mértékben változhat.", "infoDollar": "A dollár összeg csak hozzávetőleges. Kis mértékben változhat.", - "transferButtonText": "Átutalás {fromWallet}-ból/ből {toWallet}-ba/be" + "transferButtonText": "Átutalás {fromWallet}-ból/ből {toWallet}-ba/be", + "feeLabel": "Átváltási díj", + "feeError": "Nem sikerült lekérni az átváltási díjat", + "amountFloored": "Az összeget megemeltük az átváltási minimum eléréséhez.", + "amountDustBumped": "Az összeget megemeltük a teljes egyenleg átváltásához." }, "ConversionSuccessScreen": { "message": "Sikeres átváltás", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Stabil egyenleg", - "description": "Tartson USD egyenleget USDB-vel a Sparkon." + "description": "Tartson USD egyenleget USDB-vel a Sparkon.", + "balanceLabelBtc": "Egyenleg · SATS", + "balanceLabelUsd": "Egyenleg · USD", + "settingsRowTitle": "Stabil egyenleg", + "settingsTitle": "Stabil egyenleg", + "settingsDescription": "Tartsa pénztárcája egy részét USD-ben. BTC és USD között manuálisan válthat bármikor a Konvertálás művelettel.", + "activationLabel": "Aktív", + "activeHint": "A pénztárcád USD-t tart USDB-n keresztül.", + "inactiveHint": "A pénztárcád csak BTC-t tart.", + "deactivateWarningBody": "Még van {amount:string}-od. Először konvertáld BTC-re, különben az USD egyenleged rejtett marad, amíg újra nem aktiválod a Stable Balance-t.", + "toggleFailedToast": "A Stable Balance frissítése nem sikerült. Próbáld újra.", + "toggleModal": { + "activateTitle": "Stabil egyenleg aktiválása", + "activateBody": "A BTC egyenlegét USDB-re váltjuk át. Ez a becsült átváltási díj.", + "activateConfirm": "Aktiválás", + "deactivateTitle": "Stabil egyenleg kikapcsolása", + "deactivateBody": "Az USDB egyenlegét visszaváltjuk BTC-re. Ez a becsült átváltási díj.", + "deactivateConfirm": "Kikapcsolás", + "cancel": "Mégse" + }, + "firstTimeModal": { + "title": "A Konverzióról", + "dualBalance": "A BTC és az USD két független egyenleg a pénztárcádban. Használd a Convert funkciót bármikor, hogy pénzt mozgass közöttük.", + "trustDisclosure": "Az USD mód USDB tokeneket használ a Sparkon. A bizalmi feltételezések eltérnek a közvetlen BTC tartástól. Az USDB a Spark tokenkibocsátójára támaszkodik.", + "acknowledge": "Megértettem" + }, + "minimumConversion": "Minimum konverzió: {amount:string}", + "conversionUnavailable": "A konverzió ideiglenesen nem érhető el. Próbáld újra." }, "BackendFeatureGate": { "title": "Funkció nem elérhető", @@ -3593,6 +3624,10 @@ "description": "A nem letétbe helyezett pénztárcája jelenleg nem tudja elérni a hálózatot. Próbálja újra, amikor ismét online lesz.", "retry": "Próbáld újra" }, + "SelfCustodialBalance": { + "staleLabel": "ELAVULT", + "syncFailedToast": "Az egyenleg szinkronizálása sikertelen. Az egyenleged elavult lehet." + }, "UnclaimedDeposit": { "title": "{count} nem igényelt befizetésed van", "description": "Összesen: {sats} sat elérhető igénylésre", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index 993c952031..c2eca7e079 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -72,7 +72,11 @@ "receivingAccount": "Ստացող հաշիվ ", "infoBitcoin": "Բիթքոյն-ի գումարը մոտավորապես է։ Այն կարող է փոքր-ինչ տարբերվել։", "infoDollar": "Դոլարի գումարը մոտավորապես է։ Այն կարող է փոքր-ինչ տարբերվել։", - "transferButtonText": "Փոխանցել {fromWallet}-ից {toWallet}" + "transferButtonText": "Փոխանցել {fromWallet}-ից {toWallet}", + "feeLabel": "Փոխարկման վճար", + "feeError": "Չհաջողվեց ստանալ փոխարկման վճարը", + "amountFloored": "Գումարն ավելացվեց փոխարկման նվազագույնին հասնելու համար։", + "amountDustBumped": "Գումարն ավելացվեց ամբողջ մնացորդդ փոխարկելու համար։" }, "ConversionSuccessScreen": { "message": "Փոխակերպումը հաջողությամբ է կատարվել", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Կայուն մնացորդ", - "description": "Պահեք ԱՄՆ դոլարով արտահայտված մնացորդ, որը աշխատում է Spark-ում USDB-ի միջոցով։" + "description": "Պահեք ԱՄՆ դոլարով արտահայտված մնացորդ, որը աշխատում է Spark-ում USDB-ի միջոցով։", + "balanceLabelBtc": "Մնացորդ · SATS", + "balanceLabelUsd": "Մնացորդ · USD", + "settingsRowTitle": "Կայուն մնացորդ", + "settingsTitle": "Կայուն մնացորդ", + "settingsDescription": "Ձեր դրամապանակի մի մասը պահեք USD-ով: Փոխարկեք BTC-ի և USD-ի միջև ձեռքով ցանկացած պահի՝ օգտագործելով Փոխարկել գործողությունը:", + "activationLabel": "Ակտիվ", + "activeHint": "Ձեր դրամապանակը USD է պահում USDB-ի միջոցով։", + "inactiveHint": "Ձեր դրամապանակը պահում է միայն BTC։", + "deactivateWarningBody": "Դուք դեռ ունեք {amount:string}։ Նախ փոխարկեք BTC, այլապես ձեր USD մնացորդը թաքցվելու է, մինչև նորից ակտիվացնեք Stable Balance-ը։", + "toggleFailedToast": "Չհաջողվեց թարմացնել Stable Balance-ը։ Խնդրում ենք փորձել կրկին։", + "toggleModal": { + "activateTitle": "Ակտիվացնել կայուն մնացորդը", + "activateBody": "Ձեր BTC մնացորդը կփոխարկվի USDB-ի։ Սա փոխարկման գնահատված վճարն է։", + "activateConfirm": "Ակտիվացնել", + "deactivateTitle": "Ապաակտիվացնել կայուն մնացորդը", + "deactivateBody": "Ձեր USDB մնացորդը նորից կփոխարկվի BTC-ի։ Սա փոխարկման գնահատված վճարն է։", + "deactivateConfirm": "Ապաակտիվացնել", + "cancel": "Չեղարկել" + }, + "firstTimeModal": { + "title": "Փոխարկման մասին", + "dualBalance": "BTC-ն և USD-ն ձեր դրամապանակում երկու անկախ մնացորդներ են։ Ցանկացած պահի օգտագործեք Convert-ը՝ դրանց միջև միջոցներ տեղափոխելու համար։", + "trustDisclosure": "USD ռեժիմը օգտագործում է USDB թոքեններ Spark-ում։ Վստահության ենթադրությունները տարբերվում են BTC-ի ուղղակի պահելուց. USDB-ն հիմնված է Spark-ի թոքեն թողարկողի վրա։", + "acknowledge": "Ես հասկանում եմ" + }, + "minimumConversion": "Նվազագույն փոխարկում. {amount:string}", + "conversionUnavailable": "Փոխարկումը ժամանակավորապես անհասանելի է։ Խնդրում ենք փորձել կրկին։" }, "BackendFeatureGate": { "title": "Հնարավորությունը հասանելի չէ", @@ -3634,6 +3665,10 @@ "description": "Ձեր ոչ պահպանող դրամապանակը ներկայումս չի կարող հասնել ցանցին։ Փորձեք կրկին, երբ միացեք։", "retry": "Փորձել կրկին" }, + "SelfCustodialBalance": { + "staleLabel": "ՀՆԱՑԱԾ", + "syncFailedToast": "Մնացորդի համաժամացումը ձախողվեց։ Ձեր մնացորդը կարող է հնացած լինել։" + }, "UnclaimedDeposit": { "title": "Դուք ունեք {count} չպահանջված ավանդ(ներ)", "description": "Ընդամենը՝ {sats} sats, հասանելի պահանջման համար", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 532970a6ea..716832b1a5 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -70,7 +70,11 @@ "receivingAccount": "Akun penerima", "infoBitcoin": "Jumlah Bitcoin hanya perkiraan. Bisa sedikit berbeda.", "infoDollar": "Jumlah Dolar hanya perkiraan. Bisa sedikit berbeda.", - "transferButtonText": "Transfer {fromWallet} ke {toWallet}" + "transferButtonText": "Transfer {fromWallet} ke {toWallet}", + "feeLabel": "Biaya konversi", + "feeError": "Tidak dapat mengambil biaya konversi", + "amountFloored": "Jumlah dinaikkan untuk memenuhi minimum konversi.", + "amountDustBumped": "Jumlah dinaikkan untuk mengonversi seluruh saldo Anda." }, "ConversionSuccessScreen": { "message": "Konversi Berhasil", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Saldo stabil", - "description": "Simpan saldo dalam USD didukung USDB di Spark." + "description": "Simpan saldo dalam USD didukung USDB di Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Stabil", + "settingsTitle": "Saldo Stabil", + "settingsDescription": "Simpan sebagian dompet Anda dalam USD. Konversi antara BTC dan USD secara manual kapan saja menggunakan tindakan Konversi.", + "activationLabel": "Aktif", + "activeHint": "Dompet Anda menyimpan USD melalui USDB.", + "inactiveHint": "Dompet Anda hanya menyimpan BTC.", + "deactivateWarningBody": "Anda masih memiliki {amount:string}. Konversikan ke BTC terlebih dahulu, atau saldo USD Anda akan disembunyikan hingga Anda mengaktifkan kembali Stable Balance.", + "toggleFailedToast": "Tidak dapat memperbarui Stable Balance. Silakan coba lagi.", + "toggleModal": { + "activateTitle": "Aktifkan Saldo Stabil", + "activateBody": "Saldo BTC Anda akan dikonversi ke USDB. Ini adalah perkiraan biaya konversi.", + "activateConfirm": "Aktifkan", + "deactivateTitle": "Nonaktifkan Saldo Stabil", + "deactivateBody": "Saldo USDB Anda akan dikonversi kembali ke BTC. Ini adalah perkiraan biaya konversi.", + "deactivateConfirm": "Nonaktifkan", + "cancel": "Batal" + }, + "firstTimeModal": { + "title": "Tentang Konversi", + "dualBalance": "BTC dan USD adalah dua saldo independen di dompet Anda. Gunakan Convert kapan saja untuk memindahkan dana di antara keduanya.", + "trustDisclosure": "Mode USD menggunakan token USDB di Spark. Asumsi kepercayaan berbeda dari memegang BTC langsung. USDB bergantung pada penerbit token Spark.", + "acknowledge": "Saya mengerti" + }, + "minimumConversion": "Konversi minimum: {amount:string}", + "conversionUnavailable": "Konversi sementara tidak tersedia. Silakan coba lagi." }, "BackendFeatureGate": { "title": "Fitur tidak tersedia", @@ -3593,6 +3624,10 @@ "description": "Dompet non-kustodian Anda tidak dapat mencapai jaringan saat ini. Coba lagi ketika Anda kembali online.", "retry": "Coba lagi" }, + "SelfCustodialBalance": { + "staleLabel": "USANG", + "syncFailedToast": "Sinkronisasi saldo gagal. Saldo Anda mungkin sudah tidak terbaru." + }, "UnclaimedDeposit": { "title": "Anda memiliki {count} deposit yang belum diklaim", "description": "Total: {sats} sats tersedia untuk diklaim", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index f82046ca8a..8f01e6f43c 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -70,7 +70,11 @@ "receivingAccount": "Conto deposito", "infoBitcoin": "L'importo in Bitcoin è solo approssimativo. Può variare leggermente.", "infoDollar": "L'importo in dollari è solo approssimativo. Può variare leggermente.", - "transferButtonText": "Trasferisci {fromWallet} a {toWallet}" + "transferButtonText": "Trasferisci {fromWallet} a {toWallet}", + "feeLabel": "Commissione di conversione", + "feeError": "Impossibile recuperare la commissione di conversione", + "amountFloored": "Importo aumentato per raggiungere il minimo di conversione.", + "amountDustBumped": "Importo aumentato per convertire l'intero saldo." }, "ConversionSuccessScreen": { "message": "Conversione riuscita", @@ -3551,7 +3555,34 @@ }, "StableBalance": { "title": "Saldo stabile", - "description": "Mantieni un saldo in USD alimentato da USDB su Spark." + "description": "Mantieni un saldo in USD alimentato da USDB su Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Stabile", + "settingsTitle": "Saldo Stabile", + "settingsDescription": "Mantieni parte del tuo portafoglio in USD. Converti tra BTC e USD manualmente in qualsiasi momento con l'azione Converti.", + "activationLabel": "Attivo", + "activeHint": "Il tuo portafoglio detiene USD tramite USDB.", + "inactiveHint": "Il tuo portafoglio detiene solo BTC.", + "deactivateWarningBody": "Hai ancora {amount:string}. Converti prima in BTC, altrimenti il tuo saldo in USD sarà nascosto fino a quando non riattiverai Stable Balance.", + "toggleFailedToast": "Impossibile aggiornare Stable Balance. Riprova.", + "toggleModal": { + "activateTitle": "Attiva saldo stabile", + "activateBody": "Il tuo saldo in BTC sarà convertito in USDB. Questa è la commissione di conversione stimata.", + "activateConfirm": "Attiva", + "deactivateTitle": "Disattiva saldo stabile", + "deactivateBody": "Il tuo saldo in USDB sarà riconvertito in BTC. Questa è la commissione di conversione stimata.", + "deactivateConfirm": "Disattiva", + "cancel": "Annulla" + }, + "firstTimeModal": { + "title": "Informazioni sulla Conversione", + "dualBalance": "BTC e USD sono due saldi indipendenti nel tuo portafoglio. Usa Convert in qualsiasi momento per spostare fondi tra di essi.", + "trustDisclosure": "La modalità USD utilizza token USDB su Spark. I presupposti di fiducia sono diversi dal detenere BTC direttamente. USDB si affida all'emittente di token di Spark.", + "acknowledge": "Ho capito" + }, + "minimumConversion": "Conversione minima: {amount:string}", + "conversionUnavailable": "La conversione è temporaneamente non disponibile. Riprova." }, "BackendFeatureGate": { "title": "Funzione non disponibile", @@ -3581,6 +3612,10 @@ "description": "Il tuo portafoglio non custodito non può raggiungere la rete in questo momento. Riprova quando sarai di nuovo online.", "retry": "Riprova" }, + "SelfCustodialBalance": { + "staleLabel": "OBSOLETO", + "syncFailedToast": "Sincronizzazione del saldo non riuscita. Il tuo saldo potrebbe essere obsoleto." + }, "UnclaimedDeposit": { "title": "Hai {count} deposito/i non reclamato/i", "description": "Totale: {sats} sats disponibili da reclamare", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index 5885382850..aaecb3fd87 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -72,7 +72,11 @@ "receivingAccount": "受金ウォレット", "infoBitcoin": "Bitcoin金額は概算です。少額の変動が生じる場合があります。", "infoDollar": "ドル金額は概算です。少額の変動が生じる場合があります。", - "transferButtonText": "{fromWallet}から{toWallet}へ振替" + "transferButtonText": "{fromWallet}から{toWallet}へ振替", + "feeLabel": "変換手数料", + "feeError": "変換手数料を取得できませんでした", + "amountFloored": "変換の最小額に達するよう金額を引き上げました。", + "amountDustBumped": "全残高を変換するため金額を引き上げました。" }, "ConversionSuccessScreen": { "message": "両替が完了しました", @@ -3592,7 +3596,34 @@ }, "StableBalance": { "title": "ステーブルバランス", - "description": "Spark上のUSDBによるUSD残高を保持します。" + "description": "Spark上のUSDBによるUSD残高を保持します。", + "balanceLabelBtc": "残高 · SATS", + "balanceLabelUsd": "残高 · USD", + "settingsRowTitle": "安定残高", + "settingsTitle": "安定残高", + "settingsDescription": "ウォレットの一部をUSDで保持します。Convertアクションを使用して、いつでも手動でBTCとUSDの間で変換できます。", + "activationLabel": "有効", + "activeHint": "ウォレットはUSDB経由でUSDを保持しています。", + "inactiveHint": "ウォレットはBTCのみを保持しています。", + "deactivateWarningBody": "まだ {amount:string} あります。先にBTCに変換してください。そうしないと、Stable Balanceを再度有効にするまでUSD残高は非表示になります。", + "toggleFailedToast": "Stable Balanceを更新できませんでした。もう一度お試しください。", + "toggleModal": { + "activateTitle": "安定残高を有効化", + "activateBody": "BTC残高はUSDBに変換されます。これは推定される変換手数料です。", + "activateConfirm": "有効化", + "deactivateTitle": "安定残高を無効化", + "deactivateBody": "USDB残高はBTCに戻されます。これは推定される変換手数料です。", + "deactivateConfirm": "無効化", + "cancel": "キャンセル" + }, + "firstTimeModal": { + "title": "変換について", + "dualBalance": "BTCとUSDは、ウォレット内の独立した2つの残高です。いつでもConvertを使って、それらの間で資金を移動できます。", + "trustDisclosure": "USDモードはSpark上のUSDBトークンを使用します。信頼の前提はBTCを直接保有することとは異なります. USDBはSparkのトークン発行者に依存しています。", + "acknowledge": "理解しました" + }, + "minimumConversion": "最小変換額: {amount:string}", + "conversionUnavailable": "変換は一時的に利用できません。もう一度お試しください。" }, "BackendFeatureGate": { "title": "機能が利用できません", @@ -3622,6 +3653,10 @@ "description": "非保管型ウォレットは現在ネットワークに接続できません。オンラインに戻ったら再試行してください。", "retry": "再試行" }, + "SelfCustodialBalance": { + "staleLabel": "古い", + "syncFailedToast": "残高の同期に失敗しました。残高が最新でない可能性があります。" + }, "UnclaimedDeposit": { "title": "{count}件の未請求デポジットがあります", "description": "合計: {sats} sats請求可能", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index e03d9a7c3f..a5ced0a325 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -70,7 +70,11 @@ "receivingAccount": "Akawunti eweebwa", "infoBitcoin": "Omuwendo gwa Bitcoin gwa mugerageranyo gwokka. Guyinza okukyuka katono.", "infoDollar": "Omuwendo gwa Doola gwa mugerageranyo gwokka. Guyinza okukyuka katono.", - "transferButtonText": "Weereza {fromWallet} eri {toWallet}" + "transferButtonText": "Weereza {fromWallet} eri {toWallet}", + "feeLabel": "Ssente z'okukyusa", + "feeError": "Tekisoboka kuggya ssente z'okukyusa", + "amountFloored": "Omuwendo gulinnyisiddwa okutuuka ku minimum y'okukyusa.", + "amountDustBumped": "Omuwendo gulinnyisiddwa okukyusa akasente ko kwonna." }, "ConversionSuccessScreen": { "message": "Okukyusa kutuuse kubuwanguzi", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Bbaalansi ennywevu", - "description": "Kuuma bbaalansi mu USD n'obuyambi bwa USDB ku Spark." + "description": "Kuuma bbaalansi mu USD n'obuyambi bwa USDB ku Spark.", + "balanceLabelBtc": "Balansi · SATS", + "balanceLabelUsd": "Balansi · USD", + "settingsRowTitle": "Balansi Enywevu", + "settingsTitle": "Balansi Enywevu", + "settingsDescription": "Kuuma ku n'yamu ku yalopu yo mu doola za America. Enkyusa wakati wonna gy'oyagala nga okozesa akatuli ka Kyusa.", + "activationLabel": "Kisaliddwa", + "activeHint": "Valet yo ekwata USD nga eyita mu USDB.", + "inactiveHint": "Valet yo ekwata BTC kyokka.", + "deactivateWarningBody": "Okyasigalawo {amount:string}. Sooka ofuule BTC, oba balansi yo eya USD eyiinzira nga tokyalonge Stable Balance.", + "toggleFailedToast": "Tetwasobozeswa kufuna Stable Balance. Gezaako nate.", + "toggleModal": { + "activateTitle": "Kola Ssente Ezinywedde", + "activateBody": "BTC yo ejja kukyusibwa mu USDB. Lino lye sente ez'okukyusa ezisuubiddwa.", + "activateConfirm": "Kola", + "deactivateTitle": "Zikiriza Ssente Ezinywedde Kusalako", + "deactivateBody": "USDB yo ejja kuddizibwa mu BTC. Lino lye sente ez'okukyusa ezisuubiddwa.", + "deactivateConfirm": "Kusalako", + "cancel": "Sazaamu" + }, + "firstTimeModal": { + "title": "Ebikwata ku Kukyusa", + "dualBalance": "BTC ne USD bye ssente ebbiri ezeewaddeko mu wolede yo. Kozesa Convert buli kiseera okusengula ssente wakati waabyo.", + "trustDisclosure": "Endowooza ya USD ekozesa tokens za USDB ku Spark. Ebiteebereezebwa eby'obwesige bya njawulo okukuuma BTC butereevu. USDB yeesigamye ku muwa tokens wa Spark.", + "acknowledge": "Ntegedde" + }, + "minimumConversion": "Ekitono ekisinga okukyusa: {amount:string}", + "conversionUnavailable": "Okukyusa tekikolaako kati. Gezaako nate." }, "BackendFeatureGate": { "title": "Ekikola tekiriwo", @@ -3593,6 +3624,10 @@ "description": "Envalopu yo etesigibwamu tesobola kutuuka ku yintaneeti kati. Gezaako nate ng'oddayo ku yintaneeti.", "retry": "Gezaako nate" }, + "SelfCustodialBalance": { + "staleLabel": "KIKADDE", + "syncFailedToast": "Okugoberera akawungeezi tekusobose. Akawungeezi ko ka yinza okuba nga kakadde." + }, "UnclaimedDeposit": { "title": "Olina {count} ebitateekeddwa", "description": "Omutindo: {sats} sats ezikubirwa", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index ab5a2381d7..15a11777bb 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -72,7 +72,11 @@ "receivingAccount": "Akaun penerimaan", "infoBitcoin": "Jumlah Bitcoin hanyalah anggaran. Ia mungkin berbeza sedikit.", "infoDollar": "Jumlah Dolar hanyalah anggaran. Ia mungkin berbeza sedikit.", - "transferButtonText": "Pindahkan {fromWallet} ke {toWallet}" + "transferButtonText": "Pindahkan {fromWallet} ke {toWallet}", + "feeLabel": "Yuran pertukaran", + "feeError": "Tidak dapat mendapatkan yuran pertukaran", + "amountFloored": "Jumlah dinaikkan untuk memenuhi minimum pertukaran.", + "amountDustBumped": "Jumlah dinaikkan untuk menukar keseluruhan baki anda." }, "ConversionSuccessScreen": { "message": "Penukaran telah berjaya", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Baki stabil", - "description": "Simpan baki dalam USD dikuasakan USDB di Spark." + "description": "Simpan baki dalam USD dikuasakan USDB di Spark.", + "balanceLabelBtc": "Baki · SATS", + "balanceLabelUsd": "Baki · USD", + "settingsRowTitle": "Baki Stabil", + "settingsTitle": "Baki Stabil", + "settingsDescription": "Simpan sebahagian dompet anda dalam USD. Tukar antara BTC dan USD secara manual pada bila-bila masa menggunakan tindakan Tukar.", + "activationLabel": "Aktif", + "activeHint": "Dompet anda menyimpan USD melalui USDB.", + "inactiveHint": "Dompet anda hanya menyimpan BTC.", + "deactivateWarningBody": "Anda masih mempunyai {amount:string}. Tukarkan ke BTC terlebih dahulu, atau baki USD anda akan disembunyikan sehingga anda mengaktifkan semula Stable Balance.", + "toggleFailedToast": "Tidak dapat mengemas kini Stable Balance. Sila cuba lagi.", + "toggleModal": { + "activateTitle": "Aktifkan Baki Stabil", + "activateBody": "Baki BTC anda akan ditukar kepada USDB. Ini adalah anggaran yuran penukaran.", + "activateConfirm": "Aktifkan", + "deactivateTitle": "Nyahaktifkan Baki Stabil", + "deactivateBody": "Baki USDB anda akan ditukar semula kepada BTC. Ini adalah anggaran yuran penukaran.", + "deactivateConfirm": "Nyahaktifkan", + "cancel": "Batal" + }, + "firstTimeModal": { + "title": "Mengenai Penukaran", + "dualBalance": "BTC dan USD ialah dua baki bebas dalam dompet anda. Gunakan Convert pada bila-bila masa untuk memindahkan dana antara keduanya.", + "trustDisclosure": "Mod USD menggunakan token USDB di Spark. Andaian kepercayaan berbeza daripada memegang BTC secara langsung. USDB bergantung pada penerbit token Spark.", + "acknowledge": "Saya faham" + }, + "minimumConversion": "Penukaran minimum: {amount:string}", + "conversionUnavailable": "Penukaran tidak tersedia buat sementara waktu. Sila cuba lagi." }, "BackendFeatureGate": { "title": "Ciri tidak tersedia", @@ -3634,6 +3665,10 @@ "description": "Dompet bukan kustodian anda tidak dapat mencapai rangkaian sekarang. Cuba lagi apabila anda dalam talian semula.", "retry": "Cuba lagi" }, + "SelfCustodialBalance": { + "staleLabel": "LAPUK", + "syncFailedToast": "Penyegerakan baki gagal. Baki anda mungkin sudah lapuk." + }, "UnclaimedDeposit": { "title": "Anda mempunyai {count} deposit yang belum dituntut", "description": "Jumlah: {sats} sats tersedia untuk dituntut", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index 7502c55e6a..a0a18baffe 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -72,7 +72,11 @@ "receivingAccount": "Ontvanger", "infoBitcoin": "Het Bitcoin-bedrag is slechts een schatting. Het kan enigszins afwijken.", "infoDollar": "Het dollarbedrag is slechts een schatting. Het kan enigszins afwijken.", - "transferButtonText": "Overschrijven van {fromWallet} naar {toWallet}" + "transferButtonText": "Overschrijven van {fromWallet} naar {toWallet}", + "feeLabel": "Conversiekosten", + "feeError": "Kon de conversiekosten niet ophalen", + "amountFloored": "Bedrag verhoogd om aan het conversieminimum te voldoen.", + "amountDustBumped": "Bedrag verhoogd om je volledige saldo te converteren." }, "ConversionSuccessScreen": { "message": "Wissel geslaagd", @@ -3604,7 +3608,34 @@ }, "StableBalance": { "title": "Stabiel saldo", - "description": "Houd een USD-saldo aangedreven door USDB op Spark." + "description": "Houd een USD-saldo aangedreven door USDB op Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Stabiel Saldo", + "settingsTitle": "Stabiel Saldo", + "settingsDescription": "Houd een deel van je wallet in USD. Converteer handmatig tussen BTC en USD wanneer je maar wilt via de Converteer-actie.", + "activationLabel": "Actief", + "activeHint": "Je wallet houdt USD via USDB.", + "inactiveHint": "Je wallet houdt alleen BTC.", + "deactivateWarningBody": "Je hebt nog {amount:string}. Zet eerst om naar BTC, anders wordt je USD-saldo verborgen totdat je Stable Balance opnieuw activeert.", + "toggleFailedToast": "Stable Balance kon niet worden bijgewerkt. Probeer het opnieuw.", + "toggleModal": { + "activateTitle": "Stabiel saldo activeren", + "activateBody": "Je BTC-saldo wordt omgezet naar USDB. Dit zijn de geschatte conversiekosten.", + "activateConfirm": "Activeren", + "deactivateTitle": "Stabiel saldo deactiveren", + "deactivateBody": "Je USDB-saldo wordt terug omgezet naar BTC. Dit zijn de geschatte conversiekosten.", + "deactivateConfirm": "Deactiveren", + "cancel": "Annuleren" + }, + "firstTimeModal": { + "title": "Over Converteren", + "dualBalance": "BTC en USD zijn twee onafhankelijke saldi in je portemonnee. Gebruik Convert op elk moment om geld tussen beide te verplaatsen.", + "trustDisclosure": "De USD-modus gebruikt USDB-tokens op Spark. De vertrouwensaannames verschillen van het direct aanhouden van BTC. USDB steunt op de token-uitgever van Spark.", + "acknowledge": "Ik begrijp het" + }, + "minimumConversion": "Minimale conversie: {amount:string}", + "conversionUnavailable": "Conversie is tijdelijk niet beschikbaar. Probeer het opnieuw." }, "BackendFeatureGate": { "title": "Functie niet beschikbaar", @@ -3634,6 +3665,10 @@ "description": "Je niet-bewaarde wallet kan het netwerk momenteel niet bereiken. Probeer het opnieuw wanneer je weer online bent.", "retry": "Opnieuw proberen" }, + "SelfCustodialBalance": { + "staleLabel": "VEROUDERD", + "syncFailedToast": "Synchroniseren van saldo mislukt. Je saldo is mogelijk verouderd." + }, "UnclaimedDeposit": { "title": "Je hebt {count} niet-opgeëiste storting(en)", "description": "Totaal: {sats} sats beschikbaar om op te eisen", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index cd5801e23d..717c298b42 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -70,7 +70,11 @@ "receivingAccount": "Conta de Recebimento", "infoBitcoin": "O valor em Bitcoin é apenas aproximado. Ele pode variar uma pequena quantia.", "infoDollar": "O valor em Dólar é apenas aproximado. Ele pode variar uma pequena quantia.", - "transferButtonText": "Transferir {fromWallet} para {toWallet}" + "transferButtonText": "Transferir {fromWallet} para {toWallet}", + "feeLabel": "Taxa de conversão", + "feeError": "Não foi possível obter a taxa de conversão", + "amountFloored": "Valor aumentado para cumprir o mínimo de conversão.", + "amountDustBumped": "Valor aumentado para converter todo o seu saldo." }, "ConversionSuccessScreen": { "message": "Conversão bem sucedida", @@ -3551,7 +3555,34 @@ }, "StableBalance": { "title": "Saldo estável", - "description": "Mantenha um saldo em USD alimentado por USDB no Spark." + "description": "Mantenha um saldo em USD alimentado por USDB no Spark.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Estável", + "settingsTitle": "Saldo Estável", + "settingsDescription": "Mantenha parte da sua carteira em USD. Converta entre BTC e USD manualmente a qualquer momento com a ação Converter.", + "activationLabel": "Ativo", + "activeHint": "A tua carteira mantém USD via USDB.", + "inactiveHint": "A tua carteira só mantém BTC.", + "deactivateWarningBody": "Ainda tens {amount:string}. Converte primeiro para BTC, ou o teu saldo em USD ficará oculto até reativares o Stable Balance.", + "toggleFailedToast": "Não foi possível atualizar o Stable Balance. Tenta novamente.", + "toggleModal": { + "activateTitle": "Ativar saldo estável", + "activateBody": "Seu saldo em BTC será convertido em USDB. Esta é a taxa de conversão estimada.", + "activateConfirm": "Ativar", + "deactivateTitle": "Desativar saldo estável", + "deactivateBody": "Seu saldo em USDB será convertido de volta em BTC. Esta é a taxa de conversão estimada.", + "deactivateConfirm": "Desativar", + "cancel": "Cancelar" + }, + "firstTimeModal": { + "title": "Sobre Converter", + "dualBalance": "BTC e USD são dois saldos independentes em sua carteira. Use Convert a qualquer momento para mover fundos entre eles.", + "trustDisclosure": "O modo USD usa tokens USDB no Spark. As suposições de confiança são diferentes de deter BTC diretamente. O USDB depende do emissor de tokens do Spark.", + "acknowledge": "Compreendo" + }, + "minimumConversion": "Conversão mínima: {amount:string}", + "conversionUnavailable": "A conversão está temporariamente indisponível. Tenta novamente." }, "BackendFeatureGate": { "title": "Recurso indisponível", @@ -3581,6 +3612,10 @@ "description": "A sua carteira não custodial não consegue alcançar a rede neste momento. Tente novamente quando voltar a estar online.", "retry": "Tentar novamente" }, + "SelfCustodialBalance": { + "staleLabel": "DESATUALIZADO", + "syncFailedToast": "Falha ao sincronizar saldo. Seu saldo pode estar desatualizado." + }, "UnclaimedDeposit": { "title": "Você tem {count} depósito(s) não reclamado(s)", "description": "Total: {sats} sats disponíveis para reivindicar", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index 446fe23769..d5f69358a6 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -72,7 +72,11 @@ "receivingAccount": "Yanapa yupaychana", "infoBitcoin": "Bitcoin yupaynin asllata t'aqwirisqa kachkan. Asllata tikrakuyta atinmi.", "infoDollar": "Dólar yupaynin asllata t'aqwirisqa kachkan. Asllata tikrakuyta atinmi.", - "transferButtonText": "{fromWallet} manta {toWallet} man apachimuq" + "transferButtonText": "{fromWallet} manta {toWallet} man apachimuq", + "feeLabel": "Conversionpa pagon", + "feeError": "Conversionpa pagon mana apaykachay atinchu", + "amountFloored": "Qullqi yapayqa conversion minimumman chayanapaq.", + "amountDustBumped": "Qullqi yapayqa llapan qullqikipi conversion ruwanapaq." }, "ConversionSuccessScreen": { "message": "Unanchayninmi kamarichik", @@ -3601,7 +3605,34 @@ }, "StableBalance": { "title": "Saldo sinchi", - "description": "USD saldota USDB Spark nisqawan jap'iy." + "description": "USD saldota USDB Spark nisqawan jap'iy.", + "balanceLabelBtc": "Saldo · SATS", + "balanceLabelUsd": "Saldo · USD", + "settingsRowTitle": "Saldo Takyasqa", + "settingsTitle": "Saldo Takyasqa", + "settingsDescription": "Wallet-niyki t'aqata USD-pi waqaychay. BTC-manta USD-man mayqinpaqpas t'ikranki Convert ruwanamanta.", + "activationLabel": "Kawsachiy", + "activeHint": "Walletniyki USD-ta USDB-nintakama waqaychan.", + "inactiveHint": "Walletniyki BTC-llata waqaychan.", + "deactivateWarningBody": "Qanqa kanraqmi {amount:string}. Ñawpaqta BTC-man tikray, manaña chayqa USD saldoyki pakakunqa Stable Balance kutimanta llamkanchasqaykikama.", + "toggleFailedToast": "Stable Balance manam hukmanchayta atikurqachu. Yapamanta intentay.", + "toggleModal": { + "activateTitle": "Sayaq Qullqita Kachaykuy", + "activateBody": "BTC qullqiyki USDB-man tikrakunqa. Kaymi tikrachiypa chaninchasqa chaninchayninmi.", + "activateConfirm": "Kachaykuy", + "deactivateTitle": "Sayaq Qullqita Wañuchiy", + "deactivateBody": "USDB qullqiyki BTC-man kutichikunqa. Kaymi tikrachiypa chaninchasqa chaninchayninmi.", + "deactivateConfirm": "Wañuchiy", + "cancel": "T'akuy" + }, + "firstTimeModal": { + "title": "Tikrana hawalla", + "dualBalance": "BTC-wan USD-wan qillqasapaykipi iskay saqisqa qullqikunam. Haykaqpas Convert-ta ruray paykunapura qullqita astanapaq.", + "trustDisclosure": "USD modoqa Sparkpi USDB tokenkunata apaykachan. Iñiy suposikuykunaqa wakjina kan BTC-ta direkto hapishaptikimanta. USDB Sparkpa token tuqyachisqanman tiyan.", + "acknowledge": "Entiendechkani" + }, + "minimumConversion": "Aswan aslla tikray: {amount:string}", + "conversionUnavailable": "Tikrachiy mana atikuyniyuqchu kachkan. Yapamanta intentay." }, "BackendFeatureGate": { "title": "Kay mana kanchu", @@ -3631,6 +3662,10 @@ "description": "Non-custodial walletniyki kunanqa manan networkman chayayta atinchu. Conexionniyuq kutipiwanki.", "retry": "Yapamanta rurana" }, + "SelfCustodialBalance": { + "staleLabel": "MACHURAYASQA", + "syncFailedToast": "Qullqi kaqllachiy mana atikurqachu. Qullqiyki mana hunt'aqchu kanman." + }, "UnclaimedDeposit": { "title": "{count} mana mañakusqa churana(kuna) kapusunki", "description": "Tukuy: {sats} sats mañakunapaq", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index 1522543828..d374e80d2d 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -70,7 +70,11 @@ "receivingAccount": "Cont de primire", "infoBitcoin": "Suma în Bitcoin este doar aproximativă. Poate varia cu o mică diferență.", "infoDollar": "Suma în dolari este doar aproximativă. Poate varia cu o mică diferență.", - "transferButtonText": "Transferă {fromWallet} către {toWallet}" + "transferButtonText": "Transferă {fromWallet} către {toWallet}", + "feeLabel": "Comision de conversie", + "feeError": "Nu s-a putut obține comisionul de conversie", + "amountFloored": "Suma a fost mărită pentru a atinge minimul de conversie.", + "amountDustBumped": "Suma a fost mărită pentru a converti întregul sold." }, "ConversionSuccessScreen": { "message": "Conversie reușită", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Sold stabil", - "description": "Păstrați un sold în USD alimentat de USDB pe Spark." + "description": "Păstrați un sold în USD alimentat de USDB pe Spark.", + "balanceLabelBtc": "Sold · SATS", + "balanceLabelUsd": "Sold · USD", + "settingsRowTitle": "Sold Stabil", + "settingsTitle": "Sold Stabil", + "settingsDescription": "Păstrează o parte din portofelul tău în USD. Convertește între BTC și USD manual oricând folosind acțiunea Convertire.", + "activationLabel": "Activ", + "activeHint": "Portofelul tău păstrează USD prin USDB.", + "inactiveHint": "Portofelul tău păstrează doar BTC.", + "deactivateWarningBody": "Mai ai {amount:string}. Convertește mai întâi în BTC, altfel soldul în USD va fi ascuns până reactivezi Stable Balance.", + "toggleFailedToast": "Nu am putut actualiza Stable Balance. Încearcă din nou.", + "toggleModal": { + "activateTitle": "Activează soldul stabil", + "activateBody": "Soldul tău în BTC va fi convertit în USDB. Acesta este comisionul de conversie estimat.", + "activateConfirm": "Activează", + "deactivateTitle": "Dezactivează soldul stabil", + "deactivateBody": "Soldul tău în USDB va fi convertit înapoi în BTC. Acesta este comisionul de conversie estimat.", + "deactivateConfirm": "Dezactivează", + "cancel": "Anulează" + }, + "firstTimeModal": { + "title": "Despre Conversie", + "dualBalance": "BTC și USD sunt două solduri independente în portofelul tău. Folosește Convert oricând pentru a muta fonduri între ele.", + "trustDisclosure": "Modul USD folosește tokenuri USDB pe Spark. Presupunerile de încredere diferă de păstrarea BTC direct. USDB se bazează pe emitentul de tokenuri Spark.", + "acknowledge": "Am înțeles" + }, + "minimumConversion": "Conversie minimă: {amount:string}", + "conversionUnavailable": "Conversia este temporar indisponibilă. Încearcă din nou." }, "BackendFeatureGate": { "title": "Funcție indisponibilă", @@ -3593,6 +3624,10 @@ "description": "Portofelul tău non-custodial nu poate ajunge la rețea în acest moment. Încearcă din nou când ești online.", "retry": "Încearcă din nou" }, + "SelfCustodialBalance": { + "staleLabel": "ÎNVECHIT", + "syncFailedToast": "Sincronizarea soldului a eșuat. Soldul tău poate fi învechit." + }, "UnclaimedDeposit": { "title": "Ai {count} depozit(e) nerevendicate", "description": "Total: {sats} sats disponibili pentru revendicare", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 2d36ef428e..85dc02eac1 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -70,7 +70,11 @@ "receivingAccount": "Prijímajúci účet", "infoBitcoin": "Suma v Bitcoin je len približná. Môže sa mierne líšiť.", "infoDollar": "Suma v dolároch je len približná. Môže sa mierne líšiť.", - "transferButtonText": "Previesť {fromWallet} na {toWallet}" + "transferButtonText": "Previesť {fromWallet} na {toWallet}", + "feeLabel": "Poplatok za konverziu", + "feeError": "Nepodarilo sa načítať poplatok za konverziu", + "amountFloored": "Suma zvýšená na minimum konverzie.", + "amountDustBumped": "Suma zvýšená na konverziu celého zostatku." }, "ConversionSuccessScreen": { "message": "Úspešná konverzia", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Stabilný zostatok", - "description": "Udržiavajte zostatok v USD poháňaný USDB na Spark." + "description": "Udržiavajte zostatok v USD poháňaný USDB na Spark.", + "balanceLabelBtc": "Zostatok · SATS", + "balanceLabelUsd": "Zostatok · USD", + "settingsRowTitle": "Stabilný zostatok", + "settingsTitle": "Stabilný zostatok", + "settingsDescription": "Časť peňaženky si udržujte v USD. BTC a USD si kedykoľvek ručne konvertujte pomocou akcie Konvertovať.", + "activationLabel": "Aktívny", + "activeHint": "Peňaženka drží USD cez USDB.", + "inactiveHint": "Peňaženka drží iba BTC.", + "deactivateWarningBody": "Máš ešte {amount:string}. Najprv previesť na BTC, inak bude USD zostatok skrytý, kým znovu neaktivuješ Stable Balance.", + "toggleFailedToast": "Stabilný zostatok sa nepodarilo aktualizovať. Skúste to znova.", + "toggleModal": { + "activateTitle": "Aktivovať stabilný zostatok", + "activateBody": "Váš zostatok v BTC bude prevedený na USDB. Toto je odhadovaný poplatok za prevod.", + "activateConfirm": "Aktivovať", + "deactivateTitle": "Deaktivovať stabilný zostatok", + "deactivateBody": "Váš zostatok v USDB bude prevedený späť na BTC. Toto je odhadovaný poplatok za prevod.", + "deactivateConfirm": "Deaktivovať", + "cancel": "Zrušiť" + }, + "firstTimeModal": { + "title": "O konverzii", + "dualBalance": "BTC a USD sú dva nezávislé zostatky vo vašej peňaženke. Kedykoľvek použite Convert na presun prostriedkov medzi nimi.", + "trustDisclosure": "Režim USD používa tokeny USDB na Sparku. Predpoklady dôvery sa líšia od priameho držania BTC. USDB sa spolieha na Sparkovho emitenta tokenov.", + "acknowledge": "Rozumiem" + }, + "minimumConversion": "Minimálna konverzia: {amount:string}", + "conversionUnavailable": "Konverzia je dočasne nedostupná. Skúste to znova." }, "BackendFeatureGate": { "title": "Funkcia nedostupná", @@ -3593,6 +3624,10 @@ "description": "Vaša nekustodiálna peňaženka momentálne nemôže dosiahnuť sieť. Skúste to znova, keď budete online.", "retry": "Skúsiť znova" }, + "SelfCustodialBalance": { + "staleLabel": "ZASTARALÝ", + "syncFailedToast": "Synchronizácia zostatku zlyhala. Váš zostatok môže byť neaktuálny." + }, "UnclaimedDeposit": { "title": "Máte {count} nevyzdvihnutý(ch) vklad(ov)", "description": "Celkom: {sats} satov na vyzdvihnutie", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index 4c88b8c6f9..9f454693a7 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -72,7 +72,11 @@ "receivingAccount": "Рачун прималац", "infoBitcoin": "Биткоин износ је само приближан. Може варирати за мали износ.", "infoDollar": "Доларски износ је само приближан. Може варирати за мали износ.", - "transferButtonText": "Пренеси {fromWallet} на {toWallet}" + "transferButtonText": "Пренеси {fromWallet} на {toWallet}", + "feeLabel": "Накнада за конверзију", + "feeError": "Није било могуће преузети накнаду за конверзију", + "amountFloored": "Износ увећан да би се испунио минимум за конверзију.", + "amountDustBumped": "Износ увећан да се конвертује цео салдо." }, "ConversionSuccessScreen": { "message": "Конверзија успешна", @@ -3601,7 +3605,34 @@ }, "StableBalance": { "title": "Стабилан салдо", - "description": "Држите салдо у USD покретан USDB на Spark." + "description": "Држите салдо у USD покретан USDB на Spark.", + "balanceLabelBtc": "Стање · SATS", + "balanceLabelUsd": "Стање · USD", + "settingsRowTitle": "Стабилно стање", + "settingsTitle": "Стабилно стање", + "settingsDescription": "Задржите део свог новчаника у USD. Конвертујте BTC и USD ручно у било које време користећи радњу Конвертуј.", + "activationLabel": "Активно", + "activeHint": "Твој новчаник држи USD преко USDB.", + "inactiveHint": "Твој новчаник држи само BTC.", + "deactivateWarningBody": "Још имаш {amount:string}. Прво пребаци у BTC, иначе ће твој USD биланс бити сакривен док поново не активираш Stable Balance.", + "toggleFailedToast": "Није могуће ажурирати Stable Balance. Покушајте поново.", + "toggleModal": { + "activateTitle": "Активирај стабилни салдо", + "activateBody": "Ваш BTC салдо ће бити конвертован у USDB. Ово је процењена накнада за конверзију.", + "activateConfirm": "Активирај", + "deactivateTitle": "Деактивирај стабилни салдо", + "deactivateBody": "Ваш USDB салдо ће бити враћен у BTC. Ово је процењена накнада за конверзију.", + "deactivateConfirm": "Деактивирај", + "cancel": "Откажи" + }, + "firstTimeModal": { + "title": "О конверзији", + "dualBalance": "BTC и USD су два независна салда у вашем новчанику. Користите Convert било кад за премештање средстава између њих.", + "trustDisclosure": "USD режим користи USDB токене на Sparku. Претпоставке поверења разликују се од директног држања BTC-а. USDB се ослања на Sparkovog издаваоца токена.", + "acknowledge": "Разумем" + }, + "minimumConversion": "Минимална конверзија: {amount:string}", + "conversionUnavailable": "Конверзија тренутно није доступна. Покушајте поново." }, "BackendFeatureGate": { "title": "Функција недоступна", @@ -3631,6 +3662,10 @@ "description": "Ваш нестаратељски новчаник тренутно не може да приступи мрежи. Покушајте поново када се вратите на мрежу.", "retry": "Покушај поново" }, + "SelfCustodialBalance": { + "staleLabel": "ЗАСТАРЕЛО", + "syncFailedToast": "Синхронизација стања није успела. Ваше стање можда није ажурно." + }, "UnclaimedDeposit": { "title": "Имате {count} незатражен(их) депозит(а)", "description": "Укупно: {sats} сатова доступно за потраживање", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index a3c58dc03f..d0cc77f2a6 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -70,7 +70,11 @@ "receivingAccount": "Akaunti inayopokea", "infoBitcoin": "Kiasi cha Bitcoin ni takriban tu. Kinaweza kubadilika kwa kiasi kidogo.", "infoDollar": "Kiasi cha Dola ni takriban tu. Kinaweza kubadilika kwa kiasi kidogo.", - "transferButtonText": "Hamisha {fromWallet} kwenda {toWallet}" + "transferButtonText": "Hamisha {fromWallet} kwenda {toWallet}", + "feeLabel": "Ada ya ubadilishaji", + "feeError": "Imeshindikana kupata ada ya ubadilishaji", + "amountFloored": "Kiasi kimeongezwa ili kufikia kima cha chini cha ubadilishaji.", + "amountDustBumped": "Kiasi kimeongezwa ili kubadilisha salio lako lote." }, "ConversionSuccessScreen": { "message": "Ubadilishaji umekamilika", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Salio thabiti", - "description": "Hifadhi salio la USD linaloendeleshwa na USDB kwenye Spark." + "description": "Hifadhi salio la USD linaloendeleshwa na USDB kwenye Spark.", + "balanceLabelBtc": "Salio · SATS", + "balanceLabelUsd": "Salio · USD", + "settingsRowTitle": "Salio Thabiti", + "settingsTitle": "Salio Thabiti", + "settingsDescription": "Weka sehemu ya pochi yako kwa USD. Badilisha kati ya BTC na USD kwa mkono wakati wowote kwa kutumia kitendo cha Badilisha.", + "activationLabel": "Hai", + "activeHint": "Pochi yako inashikilia USD kupitia USDB.", + "inactiveHint": "Pochi yako inashikilia BTC tu.", + "deactivateWarningBody": "Bado una {amount:string}. Badilisha kuwa BTC kwanza, ama salio lako la USD litafichwa hadi uwashe upya Stable Balance.", + "toggleFailedToast": "Imeshindikana kusasisha Stable Balance. Tafadhali jaribu tena.", + "toggleModal": { + "activateTitle": "Washa Salio Thabiti", + "activateBody": "Salio lako la BTC litabadilishwa kuwa USDB. Hii ni ada ya makadirio ya ubadilishaji.", + "activateConfirm": "Washa", + "deactivateTitle": "Zima Salio Thabiti", + "deactivateBody": "Salio lako la USDB litarudishwa kwa BTC. Hii ni ada ya makadirio ya ubadilishaji.", + "deactivateConfirm": "Zima", + "cancel": "Futa" + }, + "firstTimeModal": { + "title": "Kuhusu Ubadilishaji", + "dualBalance": "BTC na USD ni salio mbili huru katika mkoba wako. Tumia Convert wakati wowote kuhamisha fedha kati yao.", + "trustDisclosure": "Modi ya USD hutumia tokeni za USDB kwenye Spark. Mawazo ya kuaminika ni tofauti na kushikilia BTC moja kwa moja. USDB inategemea mtoaji wa tokeni wa Spark.", + "acknowledge": "Naelewa" + }, + "minimumConversion": "Ubadilishaji mdogo zaidi: {amount:string}", + "conversionUnavailable": "Ubadilishaji haupatikani kwa muda. Tafadhali jaribu tena." }, "BackendFeatureGate": { "title": "Kipengele hakipatikani", @@ -3593,6 +3624,10 @@ "description": "Pochi yako isiyo ya ulinzi haiwezi kufikia mtandao kwa sasa. Jaribu tena utakaporudi mtandaoni.", "retry": "Jaribu tena" }, + "SelfCustodialBalance": { + "staleLabel": "CHAKALA", + "syncFailedToast": "Kusawazisha salio kumeshindwa. Salio lako linaweza kuwa la zamani." + }, "UnclaimedDeposit": { "title": "Una amana {count} ambazo hazijaombwa", "description": "Jumla: {sats} sats zinazopatikana kudai", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 9e2d97b2b7..48ee6c4e60 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -72,7 +72,11 @@ "receivingAccount": "บัญชีรับเงิน", "infoBitcoin": "จำนวน Bitcoin เป็นเพียงค่าประมาณ อาจเปลี่ยนแปลงเล็กน้อยได้", "infoDollar": "จำนวนดอลลาร์เป็นเพียงค่าประมาณ อาจเปลี่ยนแปลงเล็กน้อยได้", - "transferButtonText": "โอนจาก {fromWallet} ไป {toWallet}" + "transferButtonText": "โอนจาก {fromWallet} ไป {toWallet}", + "feeLabel": "ค่าธรรมเนียมการแปลง", + "feeError": "ไม่สามารถดึงค่าธรรมเนียมการแปลงได้", + "amountFloored": "จำนวนเงินเพิ่มขึ้นเพื่อให้ถึงขั้นต่ำในการแปลง", + "amountDustBumped": "จำนวนเงินเพิ่มขึ้นเพื่อแปลงยอดคงเหลือทั้งหมดของคุณ" }, "ConversionSuccessScreen": { "message": "การแลกเปลี่ยนสำเร็จแล้ว", @@ -3601,7 +3605,34 @@ }, "StableBalance": { "title": "ยอดคงเหลือมั่นคง", - "description": "ถือยอดคงเหลือเป็น USD ขับเคลื่อนโดย USDB บน Spark" + "description": "ถือยอดคงเหลือเป็น USD ขับเคลื่อนโดย USDB บน Spark", + "balanceLabelBtc": "ยอดเงิน · SATS", + "balanceLabelUsd": "ยอดเงิน · USD", + "settingsRowTitle": "ยอดเงินคงที่", + "settingsTitle": "ยอดเงินคงที่", + "settingsDescription": "เก็บส่วนหนึ่งของกระเป๋าของคุณเป็น USD แปลงระหว่าง BTC และ USD ด้วยตนเองได้ตลอดเวลาโดยใช้การกระทำ แปลง", + "activationLabel": "เปิดใช้งาน", + "activeHint": "กระเป๋าเงินของคุณถือ USD ผ่าน USDB", + "inactiveHint": "กระเป๋าเงินของคุณถือ BTC เท่านั้น", + "deactivateWarningBody": "คุณยังมี {amount:string} โปรดแปลงเป็น BTC ก่อน มิฉะนั้นยอด USD จะถูกซ่อนไว้จนกว่าคุณจะเปิด Stable Balance อีกครั้ง", + "toggleFailedToast": "ไม่สามารถอัปเดต Stable Balance ได้ โปรดลองอีกครั้ง", + "toggleModal": { + "activateTitle": "เปิดใช้ยอดคงเหลือแบบเสถียร", + "activateBody": "ยอดคงเหลือ BTC ของคุณจะถูกแปลงเป็น USDB นี่คือค่าธรรมเนียมการแปลงโดยประมาณ", + "activateConfirm": "เปิดใช้", + "deactivateTitle": "ปิดใช้ยอดคงเหลือแบบเสถียร", + "deactivateBody": "ยอดคงเหลือ USDB ของคุณจะถูกแปลงกลับเป็น BTC นี่คือค่าธรรมเนียมการแปลงโดยประมาณ", + "deactivateConfirm": "ปิดใช้", + "cancel": "ยกเลิก" + }, + "firstTimeModal": { + "title": "เกี่ยวกับการแปลง", + "dualBalance": "BTC และ USD เป็นยอดคงเหลือที่เป็นอิสระสองรายการในกระเป๋าของคุณ ใช้ Convert เมื่อใดก็ได้เพื่อย้ายเงินระหว่างกัน", + "trustDisclosure": "โหมด USD ใช้โทเค็น USDB บน Spark ข้อสันนิษฐานด้านความเชื่อถือต่างจากการถือ BTC โดยตรง. USDB พึ่งพาผู้ออกโทเค็นของ Spark", + "acknowledge": "เข้าใจแล้ว" + }, + "minimumConversion": "จำนวนขั้นต่ำที่แปลงได้: {amount:string}", + "conversionUnavailable": "ขณะนี้ไม่สามารถแปลงได้ โปรดลองอีกครั้ง" }, "BackendFeatureGate": { "title": "ฟีเจอร์ไม่พร้อมใช้งาน", @@ -3631,6 +3662,10 @@ "description": "กระเป๋าเงินที่ไม่ใช่การรับฝากของคุณไม่สามารถเข้าถึงเครือข่ายได้ในขณะนี้ ลองอีกครั้งเมื่อคุณกลับมาออนไลน์", "retry": "ลองอีกครั้ง" }, + "SelfCustodialBalance": { + "staleLabel": "ล้าสมัย", + "syncFailedToast": "การซิงค์ยอดคงเหลือล้มเหลว ยอดคงเหลือของคุณอาจไม่เป็นปัจจุบัน" + }, "UnclaimedDeposit": { "title": "คุณมี {count} รายการฝากที่ยังไม่ได้เรียกร้อง", "description": "รวม: {sats} sats พร้อมเรียกร้อง", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index c43a458897..35b1c7e9c8 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -70,7 +70,11 @@ "receivingAccount": "Alınacak miktar", "infoBitcoin": "Bitcoin miktarı yalnızca yaklaşık bir değerdir. Küçük bir miktar farklılık gösterebilir.", "infoDollar": "Dolar miktarı yalnızca yaklaşık bir değerdir. Küçük bir miktar farklılık gösterebilir.", - "transferButtonText": "{fromWallet} hesabından {toWallet} hesabına transfer et" + "transferButtonText": "{fromWallet} hesabından {toWallet} hesabına transfer et", + "feeLabel": "Dönüştürme ücreti", + "feeError": "Dönüştürme ücreti alınamadı", + "amountFloored": "Tutar, dönüştürme minimumunu karşılamak için artırıldı.", + "amountDustBumped": "Tutar, tüm bakiyenizi dönüştürmek için artırıldı." }, "ConversionSuccessScreen": { "message": "Çevirme başarılı", @@ -3563,7 +3567,34 @@ }, "StableBalance": { "title": "Sabit bakiye", - "description": "Spark üzerinde USDB tarafından desteklenen USD bakiyesi tutun." + "description": "Spark üzerinde USDB tarafından desteklenen USD bakiyesi tutun.", + "balanceLabelBtc": "Bakiye · SATS", + "balanceLabelUsd": "Bakiye · USD", + "settingsRowTitle": "Stabil Bakiye", + "settingsTitle": "Stabil Bakiye", + "settingsDescription": "Cüzdanınızın bir kısmını USD olarak tutun. Dönüştür eylemini kullanarak BTC ile USD arasında istediğiniz zaman manuel olarak dönüşüm yapın.", + "activationLabel": "Aktif", + "activeHint": "Cüzdanınız USDB üzerinden USD tutuyor.", + "inactiveHint": "Cüzdanınız sadece BTC tutuyor.", + "deactivateWarningBody": "Hâlâ {amount:string} bakiyeniz var. Önce BTC'ye dönüştürün; aksi halde Stable Balance'ı tekrar etkinleştirene kadar USD bakiyeniz gizlenecek.", + "toggleFailedToast": "Stable Balance güncellenemedi. Lütfen tekrar deneyin.", + "toggleModal": { + "activateTitle": "Sabit Bakiyeyi Etkinleştir", + "activateBody": "BTC bakiyeniz USDB'ye dönüştürülecek. Bu, tahmini dönüşüm ücretidir.", + "activateConfirm": "Etkinleştir", + "deactivateTitle": "Sabit Bakiyeyi Devre Dışı Bırak", + "deactivateBody": "USDB bakiyeniz tekrar BTC'ye dönüştürülecek. Bu, tahmini dönüşüm ücretidir.", + "deactivateConfirm": "Devre dışı bırak", + "cancel": "İptal" + }, + "firstTimeModal": { + "title": "Dönüşüm Hakkında", + "dualBalance": "BTC ve USD, cüzdanınızdaki iki bağımsız bakiyedir. Aralarında fon taşımak için istediğiniz zaman Convert'i kullanın.", + "trustDisclosure": "USD modu Spark üzerindeki USDB tokenlarını kullanır. Güven varsayımları doğrudan BTC tutmaktan farklıdır. USDB Spark'ın token çıkaranına dayanır.", + "acknowledge": "Anlıyorum" + }, + "minimumConversion": "Minimum dönüşüm: {amount:string}", + "conversionUnavailable": "Dönüşüm geçici olarak kullanılamıyor. Lütfen tekrar deneyin." }, "BackendFeatureGate": { "title": "Özellik kullanılamıyor", @@ -3593,6 +3624,10 @@ "description": "Gözetimsiz cüzdanınız şu anda ağa ulaşamıyor. Tekrar çevrimiçi olduğunuzda tekrar deneyin.", "retry": "Tekrar dene" }, + "SelfCustodialBalance": { + "staleLabel": "ESKİMİŞ", + "syncFailedToast": "Bakiye senkronizasyonu başarısız. Bakiyeniz güncel olmayabilir." + }, "UnclaimedDeposit": { "title": "{count} talep edilmemiş yatırmanız var", "description": "Toplam: {sats} sats talep edilebilir", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index 3d3b209437..6d333500fa 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -72,7 +72,11 @@ "receivingAccount": "Tài khoản nhận", "infoBitcoin": "Số lượng Bitcoin chỉ là gần đúng. Nó có thể thay đổi một lượng nhỏ.", "infoDollar": "Số tiền Đô la chỉ là gần đúng. Nó có thể thay đổi một lượng nhỏ.", - "transferButtonText": "Chuyển {fromWallet} sang {toWallet}" + "transferButtonText": "Chuyển {fromWallet} sang {toWallet}", + "feeLabel": "Phí chuyển đổi", + "feeError": "Không thể lấy phí chuyển đổi", + "amountFloored": "Số tiền đã được tăng để đạt mức tối thiểu chuyển đổi.", + "amountDustBumped": "Số tiền đã được tăng để chuyển đổi toàn bộ số dư của bạn." }, "ConversionSuccessScreen": { "message": "Chuyễn đổi đã thành công", @@ -3601,7 +3605,34 @@ }, "StableBalance": { "title": "Số dư ổn định", - "description": "Giữ số dư tính bằng USD được hỗ trợ bởi USDB trên Spark." + "description": "Giữ số dư tính bằng USD được hỗ trợ bởi USDB trên Spark.", + "balanceLabelBtc": "Số dư · SATS", + "balanceLabelUsd": "Số dư · USD", + "settingsRowTitle": "Số Dư Ổn Định", + "settingsTitle": "Số Dư Ổn Định", + "settingsDescription": "Giữ một phần ví của bạn bằng USD. Chuyển đổi giữa BTC và USD thủ công bất cứ lúc nào bằng hành động Chuyển đổi.", + "activationLabel": "Hoạt động", + "activeHint": "Ví của bạn đang giữ USD qua USDB.", + "inactiveHint": "Ví của bạn chỉ giữ BTC.", + "deactivateWarningBody": "Bạn vẫn còn {amount:string}. Hãy chuyển sang BTC trước, nếu không số dư USD của bạn sẽ bị ẩn cho đến khi bạn kích hoạt lại Stable Balance.", + "toggleFailedToast": "Không thể cập nhật Stable Balance. Vui lòng thử lại.", + "toggleModal": { + "activateTitle": "Bật số dư ổn định", + "activateBody": "Số dư BTC của bạn sẽ được chuyển đổi sang USDB. Đây là phí chuyển đổi ước tính.", + "activateConfirm": "Bật", + "deactivateTitle": "Tắt số dư ổn định", + "deactivateBody": "Số dư USDB của bạn sẽ được chuyển đổi lại thành BTC. Đây là phí chuyển đổi ước tính.", + "deactivateConfirm": "Tắt", + "cancel": "Hủy" + }, + "firstTimeModal": { + "title": "Về Chuyển đổi", + "dualBalance": "BTC và USD là hai số dư độc lập trong ví của bạn. Sử dụng Convert bất cứ lúc nào để chuyển tiền giữa chúng.", + "trustDisclosure": "Chế độ USD sử dụng token USDB trên Spark. Các giả định tin cậy khác với việc giữ BTC trực tiếp. USDB phụ thuộc vào nhà phát hành token của Spark.", + "acknowledge": "Tôi hiểu" + }, + "minimumConversion": "Chuyển đổi tối thiểu: {amount:string}", + "conversionUnavailable": "Chuyển đổi tạm thời không khả dụng. Vui lòng thử lại." }, "BackendFeatureGate": { "title": "Tính năng không khả dụng", @@ -3631,6 +3662,10 @@ "description": "Ví không lưu ký của bạn hiện không thể kết nối với mạng. Vui lòng thử lại khi bạn kết nối trở lại.", "retry": "Thử lại" }, + "SelfCustodialBalance": { + "staleLabel": "CŨ", + "syncFailedToast": "Đồng bộ số dư thất bại. Số dư của bạn có thể không còn cập nhật." + }, "UnclaimedDeposit": { "title": "Bạn có {count} khoản gửi chưa được yêu cầu", "description": "Tổng: {sats} sats có thể yêu cầu", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index b0e2596904..077b71d145 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -72,7 +72,11 @@ "receivingAccount": "Akhawunti yokufumana", "infoBitcoin": "Isixa se-Bitcoin siqikelelo kuphela. Singatshintsha ngesixa esincinci.", "infoDollar": "Isixa seDola siqikelelo kuphela. Singatshintsha ngesixa esincinci.", - "transferButtonText": "Dlulisela {fromWallet} kuya ku {toWallet}" + "transferButtonText": "Dlulisela {fromWallet} kuya ku {toWallet}", + "feeLabel": "Imali yotshintsho", + "feeError": "Ayikwazanga ukufumana imali yotshintsho", + "amountFloored": "Isixa sonyuswe ukuze sifike kumngcele osezantsi wotshintsho.", + "amountDustBumped": "Isixa sonyuswe ukuze kutshintshwe ibhalansi yakho iphela." }, "ConversionSuccessScreen": { "message": "Uguqulo luphumelele", @@ -3610,7 +3614,34 @@ }, "StableBalance": { "title": "Ibhalansi ezinzileyo", - "description": "Gcina ibhalansi ye-USD exhaswe yi-USDB kwi-Spark." + "description": "Gcina ibhalansi ye-USD exhaswe yi-USDB kwi-Spark.", + "balanceLabelBtc": "Imali · SATS", + "balanceLabelUsd": "Imali · USD", + "settingsRowTitle": "Ibhalansi Ezinzileyo", + "settingsTitle": "Ibhalansi Ezinzileyo", + "settingsDescription": "Gcina inxalenye yengxowa-mali yakho kwi-USD. Guqula phakathi kwe-BTC ne-USD ngesandla nangaliphi na ixesha usebenzisa isenzo soGuquko.", + "activationLabel": "Iyasebenza", + "activeHint": "Isipaji sakho sigcina i-USD ngeUSDB.", + "inactiveHint": "Isipaji sakho sigcina i-BTC kuphela.", + "deactivateWarningBody": "Usenayo {amount:string}. Yitshintshele kwi-BTC kuqala, okanye ibhalansi yakho yeUSD iza kufihlakala de uphinde uvulele iStable Balance.", + "toggleFailedToast": "Akukwazekanga ukuhlaziya iStable Balance. Nceda uzame kwakhona.", + "toggleModal": { + "activateTitle": "Vulela iBhalansi ezinzileyo", + "activateBody": "Ibhalansi yakho ye-BTC iya kuguqulelwa kwi-USDB. Le yimali yoguqulelo elinqakraziweyo.", + "activateConfirm": "Vulela", + "deactivateTitle": "Yicima iBhalansi ezinzileyo", + "deactivateBody": "Ibhalansi yakho ye-USDB iya kuguqulelwa kwakhona kwi-BTC. Le yimali yoguqulelo elinqakraziweyo.", + "deactivateConfirm": "Yicima", + "cancel": "Rhoxisa" + }, + "firstTimeModal": { + "title": "Malunga noGuqulo", + "dualBalance": "I-BTC ne-USD zibhalansi ezimbini ezizimeleyo kwi-wallet yakho. Sebenzisa u-Convert nangaliphi na ixesha ukuhambisa imali phakathi kwayo.", + "trustDisclosure": "Imowudi yeUSD isebenzisa iitokheni zeUSDB kwiSpark. Izicwangciso zokuthembela zahlukile kunokubamba iBTC ngokuthe ngqo. IUSDB ixhomekeke kumkhuphi weetokheni weSpark.", + "acknowledge": "Ndiyavumelana" + }, + "minimumConversion": "Inguqulo encinci: {amount:string}", + "conversionUnavailable": "Uguqulo aluxhanyiwe okwexeshana. Nceda uzame kwakhona." }, "BackendFeatureGate": { "title": "Isici asifumaneki", @@ -3640,6 +3671,10 @@ "description": "Ingxowa-mali yakho engekho kugcino ayinakufikelela kwinethiwekhi ngoku. Zama kwakhona xa ubuyela kwi-intanethi.", "retry": "Zama kwakhona" }, + "SelfCustodialBalance": { + "staleLabel": "ENDALA", + "syncFailedToast": "Ukuhambelanisa ibhalansi kuhlulekile. Ibhalansi yakho inokuba ayihambi nexesha." + }, "UnclaimedDeposit": { "title": "Unama-{count} edipozithi engabangwayo", "description": "Iyonke: {sats} sats ezifumanekayo ukufuna", diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 09e2b8b201..836931796c 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -41,6 +41,7 @@ import SendBitcoinConfirmationScreen from "@app/screens/send-bitcoin-screen/send import SendBitcoinDestinationScreen from "@app/screens/send-bitcoin-screen/send-bitcoin-destination-screen" import SendBitcoinDetailsScreen from "@app/screens/send-bitcoin-screen/send-bitcoin-details-screen" import { OfflineGate } from "@app/self-custodial/components" +import { StableBalanceSettingsScreen } from "@app/screens/stable-balance-settings-screen" import { SetLightningAddressScreen } from "@app/screens/lightning-address-screen/set-lightning-address-screen" import { AccountScreen, SwitchAccount } from "@app/screens/settings-screen/account" import { DefaultWalletScreen } from "@app/screens/settings-screen/default-wallet" @@ -784,6 +785,11 @@ export const RootStack = () => { component={SparkWalletCreationScreen} options={{ title: "" }} /> + = ({ route }) => { const { fromWalletCurrency, moneyAmount } = route.params const [errorMessage, setErrorMessage] = useState() + const [scConverting, setScConverting] = useState(false) const isAuthed = useIsAuthed() + const { isSelfCustodial, wallets: activeWallets } = useActiveWallet() const [intraLedgerPaymentSend, { loading: intraLedgerPaymentSendLoading }] = useIntraLedgerPaymentSendMutation() const [intraLedgerUsdPaymentSend, { loading: intraLedgerUsdPaymentSendLoading }] = useIntraLedgerUsdPaymentSendMutation() - const isLoading = intraLedgerPaymentSendLoading || intraLedgerUsdPaymentSendLoading + const isLoading = + intraLedgerPaymentSendLoading || intraLedgerUsdPaymentSendLoading || scConverting const { LL } = useI18nContext() const { widthStyle: pillWidthStyle, onPillLayout } = useEqualPillWidth() const { data } = useConversionScreenQuery({ fetchPolicy: "cache-first", - skip: !isAuthed, + skip: !isAuthed || isSelfCustodial, }) - const btcWallet = getBtcWallet(data?.me?.defaultAccount?.wallets) - const usdWallet = getUsdWallet(data?.me?.defaultAccount?.wallets) + const scBtcWallet = activeWallets.find((w) => w.walletCurrency === WalletCurrency.Btc) + const scUsdWallet = activeWallets.find((w) => w.walletCurrency === WalletCurrency.Usd) + const btcWallet = isSelfCustodial + ? scBtcWallet && { + id: scBtcWallet.id, + balance: scBtcWallet.balance.amount, + walletCurrency: scBtcWallet.walletCurrency, + } + : getBtcWallet(data?.me?.defaultAccount?.wallets) + const usdWallet = isSelfCustodial + ? scUsdWallet && { + id: scUsdWallet.id, + balance: scUsdWallet.balance.amount, + walletCurrency: scUsdWallet.walletCurrency, + } + : getUsdWallet(data?.me?.defaultAccount?.wallets) const btcToUsdRate = useMemo(() => { if (!convertMoneyAmount) return null @@ -85,7 +107,18 @@ export const ConversionConfirmationScreen: React.FC = ({ route }) => { }) }, [convertMoneyAmount, formatMoneyAmount]) - if (!data?.me || !usdWallet || !btcWallet || !convertMoneyAmount) { + const nonCustodialConversion = useNonCustodialConversion({ + fromCurrency: fromWalletCurrency, + moneyAmount, + enabled: isSelfCustodial, + }) + + if ( + (!isSelfCustodial && !data?.me) || + !usdWallet || + !btcWallet || + !convertMoneyAmount + ) { // TODO: handle errors and or provide some loading state return null } @@ -165,7 +198,54 @@ export const ConversionConfirmationScreen: React.FC = ({ route }) => { toastShow({ message: error.message, LL }) } + const paySelfCustodial = async () => { + setScConverting(true) + try { + const outcome = await nonCustodialConversion.execute() + logConversionResult({ + sendingWallet: fromWalletCurrency, + receivingWallet: + fromWalletCurrency === WalletCurrency.Btc + ? WalletCurrency.Usd + : WalletCurrency.Btc, + paymentStatus: + outcome.status === PaymentResultStatus.Success + ? PaymentSendResult.Success + : PaymentSendResult.Failure, + }) + if (outcome.status === PaymentResultStatus.Success) { + navigation.dispatch((state) => { + const routes = [{ name: "Primary" }, { name: "conversionSuccess" }] + return CommonActions.reset({ + ...state, + routes, + index: routes.length - 1, + }) + }) + ReactNativeHapticFeedback.trigger("notificationSuccess", { + ignoreAndroidSystemSettings: true, + }) + return + } + setErrorMessage(outcome.message) + ReactNativeHapticFeedback.trigger("notificationError", { + ignoreAndroidSystemSettings: true, + }) + } catch (err) { + if (err instanceof Error) { + crashlytics().recordError(err) + handlePaymentError(err) + } + } finally { + setScConverting(false) + } + } + const payWallet = async () => { + if (isSelfCustodial) { + await paySelfCustodial() + return + } if (fromWallet.currency === WalletCurrency.Btc) { try { logConversionAttempt({ @@ -295,6 +375,14 @@ export const ConversionConfirmationScreen: React.FC = ({ route }) => { + {isSelfCustodial && ( + + )} {toWallet.currency === WalletCurrency.Btc @@ -318,7 +406,9 @@ export const ConversionConfirmationScreen: React.FC = ({ route }) => { })} loadingText={LL.SendBitcoinConfirmationScreen.slideConfirming()} onSwipe={payWallet} - disabled={isLoading} + disabled={ + isLoading || (isSelfCustodial && !nonCustodialConversion.canExecute) + } /> @@ -345,9 +435,6 @@ const useStyles = makeStyles(({ colors }) => ({ conversionRateText: { color: colors.grey0, }, - conversionInfoField: { - marginBottom: 20, - }, conversionInfoFieldTitle: { color: colors.grey1, lineHeight: 25, fontWeight: "400" }, conversionInfoFieldValue: { color: colors.grey1, @@ -359,7 +446,6 @@ const useStyles = makeStyles(({ colors }) => ({ fontSize: 14, fontWeight: "normal", }, - buttonContainer: { marginHorizontal: 20, marginBottom: 20 }, errorContainer: { marginBottom: 10, }, @@ -367,13 +453,6 @@ const useStyles = makeStyles(({ colors }) => ({ color: colors.error, textAlign: "center", }, - walletSelectorContainer: { - flexDirection: "column", - backgroundColor: colors.grey5, - borderRadius: 10, - padding: 15, - marginBottom: 15, - }, fromFieldContainer: { flexDirection: "row", marginBottom: 15, diff --git a/app/screens/conversion-flow/conversion-details-screen.tsx b/app/screens/conversion-flow/conversion-details-screen.tsx index ad79fc52f3..7b5c32b564 100644 --- a/app/screens/conversion-flow/conversion-details-screen.tsx +++ b/app/screens/conversion-flow/conversion-details-screen.tsx @@ -26,6 +26,7 @@ import { toDisplayAmount, toUsdMoneyAmount, toWalletAmount, + toWalletMoneyAmount, WalletOrDisplayCurrency, } from "@app/types/amounts" @@ -40,6 +41,13 @@ import { AmountInputScreen, ConvertInputType, } from "@app/components/transfer-amount-input" +import { StableBalanceFirstTimeModal } from "@app/components/stable-balance-first-time-modal" + +import { useActiveWallet } from "@app/hooks/use-active-wallet" +import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" +import { useNonCustodialConversionLimits } from "@app/self-custodial/hooks" +import { useStableBalanceFirstTime } from "@app/hooks/use-stable-balance-first-time" +import { convertDirectionFromCurrency } from "@app/types/payment.types" import { useConversionFormatting, @@ -80,9 +88,13 @@ export const ConversionDetailsScreen = () => { useRealtimePriceQuery({ fetchPolicy: "network-only" }) + const { isSelfCustodial, wallets: activeWallets } = useActiveWallet() + const { isStableBalanceActive } = useSelfCustodialWallet() + const { data } = useConversionScreenQuery({ fetchPolicy: "cache-and-network", returnPartialData: true, + skip: isSelfCustodial, }) const { LL } = useI18nContext() @@ -94,8 +106,36 @@ export const ConversionDetailsScreen = () => { } = useDisplayCurrency() const styles = useStyles(displayCurrency !== WalletCurrency.Usd) - const btcWallet = getBtcWallet(data?.me?.defaultAccount?.wallets) - const usdWallet = getUsdWallet(data?.me?.defaultAccount?.wallets) + const { + shouldShow: shouldShowStableBalanceFirstTime, + markAsShown: markStableBalanceFirstTimeShown, + } = useStableBalanceFirstTime() + const showStableBalanceFirstTimeModal = + shouldShowStableBalanceFirstTime && isSelfCustodial && isStableBalanceActive + + const scWalletsForConvert = useMemo(() => { + if (!isSelfCustodial) return null + const scBtc = activeWallets.find((w) => w.walletCurrency === WalletCurrency.Btc) + const scUsd = activeWallets.find((w) => w.walletCurrency === WalletCurrency.Usd) + if (!scBtc || !scUsd) return null + return { + btc: { + id: scBtc.id, + balance: scBtc.balance.amount, + walletCurrency: scBtc.walletCurrency, + }, + usd: { + id: scUsd.id, + balance: scUsd.balance.amount, + walletCurrency: scUsd.walletCurrency, + }, + } + }, [isSelfCustodial, activeWallets]) + + const btcWallet = + scWalletsForConvert?.btc ?? getBtcWallet(data?.me?.defaultAccount?.wallets) + const usdWallet = + scWalletsForConvert?.usd ?? getUsdWallet(data?.me?.defaultAccount?.wallets) const { fromWallet, @@ -114,6 +154,17 @@ export const ConversionDetailsScreen = () => { : undefined, ) + const convertDirection = + isSelfCustodial && fromWallet + ? convertDirectionFromCurrency(fromWallet.walletCurrency) + : undefined + const { limits: scConversionLimits, error: scLimitsError } = + useNonCustodialConversionLimits(convertDirection) + const scMinFromAmount = isSelfCustodial + ? scConversionLimits?.minFromAmount ?? null + : null + const scLimitsUnavailable = isSelfCustodial && scLimitsError !== null + const [focusedInputValues, setFocusedInputValues] = useState(null) const [initialAmount, setInitialAmount] = useState>() @@ -305,7 +356,7 @@ export const ConversionDetailsScreen = () => { } }, [displayCurrency, renderValue]) - if (!data?.me?.defaultAccount || !fromWallet) return <> + if ((!isSelfCustodial && !data?.me?.defaultAccount) || !fromWallet) return <> const toggleInputs = () => { if (uiLocked) return @@ -425,18 +476,34 @@ export const ConversionDetailsScreen = () => { ? null : moneyAmountToDisplayCurrencyString({ moneyAmount: toWalletBalance }) - let amountFieldError: string | undefined = undefined + const exceedsBalance = lessThan({ + value: fromWalletBalance, + lessThan: settlementSendAmount, + }) + + const belowMinimum = + isSelfCustodial && + scMinFromAmount !== null && + settlementSendAmount.amount > 0 && + settlementSendAmount.amount < scMinFromAmount - if ( - lessThan({ - value: fromWalletBalance, - lessThan: settlementSendAmount, - }) - ) { - amountFieldError = LL.SendBitcoinScreen.amountExceed({ - balance: fromWalletBalanceFormatted, - }) - } + const amountFieldError: string | undefined = (() => { + if (exceedsBalance) { + return LL.SendBitcoinScreen.amountExceed({ balance: fromWalletBalanceFormatted }) + } + if (scLimitsUnavailable) { + return LL.StableBalance.conversionUnavailable() + } + if (belowMinimum && scMinFromAmount !== null) { + const minMoneyAmount = toWalletMoneyAmount( + scMinFromAmount, + fromWallet.walletCurrency, + ) + return LL.StableBalance.minimumConversion({ + amount: formatMoneyAmount({ moneyAmount: minMoneyAmount }), + }) + } + })() const hasError = Boolean(amountFieldError) @@ -462,6 +529,10 @@ export const ConversionDetailsScreen = () => { return ( + { size={14} color={hasError ? colors.error : "transparent"} /> - + {amountFieldError || " "} @@ -669,7 +744,9 @@ export const ConversionDetailsScreen = () => { uiLocked || toggleInitiated.current || isTyping || - Boolean(loadingPercent) + Boolean(loadingPercent) || + belowMinimum || + scLimitsUnavailable } onPress={moveToNextScreen} testID="next-button" diff --git a/app/screens/conversion-flow/conversion-fee-row.tsx b/app/screens/conversion-flow/conversion-fee-row.tsx new file mode 100644 index 0000000000..edb679530f --- /dev/null +++ b/app/screens/conversion-flow/conversion-fee-row.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { ActivityIndicator, View } from "react-native" + +import { makeStyles, Text } from "@rn-vui/themed" + +import { useI18nContext } from "@app/i18n/i18n-react" + +type Props = { + feeText: string + adjustmentText: string | null + isLoading: boolean + hasError: boolean +} + +export const ConversionFeeRow: React.FC = ({ + feeText, + adjustmentText, + isLoading, + hasError, +}) => { + const styles = useStyles() + const { LL } = useI18nContext() + + const valueText = hasError ? LL.ConversionConfirmationScreen.feeError() : feeText + + return ( + + {isLoading ? ( + + ) : ( + + {LL.ConversionConfirmationScreen.feeLabel()} + {valueText} + + )} + {adjustmentText ? ( + + {adjustmentText} + + ) : null} + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + card: { + marginHorizontal: 20, + marginBottom: 10, + backgroundColor: colors.grey5, + borderRadius: 13, + padding: 20, + gap: 6, + }, + row: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + label: { + color: colors.grey2, + fontSize: 14, + fontWeight: "600", + }, + value: { + color: colors.grey1, + fontSize: 14, + fontWeight: "700", + }, + errorValue: { + color: colors.error, + }, + adjustment: { + color: colors.warning, + fontSize: 12, + }, +})) diff --git a/app/screens/conversion-flow/hooks/index.ts b/app/screens/conversion-flow/hooks/index.ts index ea0aa449c3..c8926a15ee 100644 --- a/app/screens/conversion-flow/hooks/index.ts +++ b/app/screens/conversion-flow/hooks/index.ts @@ -1,3 +1,5 @@ export * from "./use-conversion-formatting" export * from "./use-conversion-overlay-focus" +export * from "./use-conversion-quote" +export * from "./use-non-custodial-conversion" export * from "./use-synced-input-values" diff --git a/app/screens/conversion-flow/hooks/use-conversion-quote.ts b/app/screens/conversion-flow/hooks/use-conversion-quote.ts new file mode 100644 index 0000000000..268ca49946 --- /dev/null +++ b/app/screens/conversion-flow/hooks/use-conversion-quote.ts @@ -0,0 +1,99 @@ +import { useEffect, useMemo, useState } from "react" + +import { useDisplayCurrency } from "@app/hooks/use-display-currency" +import { usePayments } from "@app/hooks/use-payments" +import { usePriceConversion } from "@app/hooks/use-price-conversion" +import { useI18nContext } from "@app/i18n/i18n-react" +import { + ConvertAmountAdjustment, + type ConvertParams, + type ConvertQuote, +} from "@app/types/payment.types" +import { formatUsdInDisplay } from "@app/utils/amounts" + +const QuoteStatus = { + Idle: "idle", + Loading: "loading", + Ready: "ready", + Error: "error", +} as const + +type QuoteStatus = (typeof QuoteStatus)[keyof typeof QuoteStatus] + +export type ConversionQuoteState = { + isQuoting: boolean + hasQuoteError: boolean + quote: ConvertQuote | null + feeText: string + adjustmentText: string | null +} + +export const useConversionQuote = ( + quoteParams: ConvertParams | null, +): ConversionQuoteState => { + const { getConversionQuote } = usePayments() + const { LL } = useI18nContext() + const { formatMoneyAmount } = useDisplayCurrency() + const { convertMoneyAmount } = usePriceConversion() + + const [state, setState] = useState<{ + status: QuoteStatus + quote: ConvertQuote | null + }>({ status: QuoteStatus.Idle, quote: null }) + + useEffect(() => { + if (!getConversionQuote || !quoteParams) { + setState({ status: QuoteStatus.Idle, quote: null }) + return + } + let cancelled = false + setState({ status: QuoteStatus.Loading, quote: null }) + getConversionQuote(quoteParams) + .then((quote) => { + if (cancelled) return + if (!quote) { + return setState({ status: QuoteStatus.Error, quote: null }) + } + setState({ status: QuoteStatus.Ready, quote }) + }) + .catch(() => { + if (cancelled) return + // Bridge already records to crashlytics with breadcrumbs + // (`createGetConversionQuote` in `app/self-custodial/bridge/convert.ts`), + // so the hook only needs to surface the Error state to the UI. + setState({ status: QuoteStatus.Error, quote: null }) + }) + return () => { + cancelled = true + } + }, [getConversionQuote, quoteParams]) + + const { quote } = state + + const feeText = useMemo(() => { + if (!quote) return "" + return formatUsdInDisplay(quote.feeAmount.amount, { + formatMoneyAmount, + convertMoneyAmount, + }) + }, [quote, formatMoneyAmount, convertMoneyAmount]) + + const adjustmentText = useMemo(() => { + if (!quote) return null + if (quote.amountAdjustment === ConvertAmountAdjustment.FlooredToMin) { + return LL.ConversionConfirmationScreen.amountFloored() + } + if (quote.amountAdjustment === ConvertAmountAdjustment.IncreasedToAvoidDust) { + return LL.ConversionConfirmationScreen.amountDustBumped() + } + return null + }, [quote, LL]) + + return { + isQuoting: state.status === QuoteStatus.Loading, + hasQuoteError: state.status === QuoteStatus.Error, + quote: state.quote, + feeText, + adjustmentText, + } +} diff --git a/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts new file mode 100644 index 0000000000..94db93d43c --- /dev/null +++ b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +import { WalletCurrency } from "@app/graphql/generated" +import { usePriceConversion } from "@app/hooks/use-price-conversion" +import { useI18nContext } from "@app/i18n/i18n-react" +import { type MoneyAmount, type WalletOrDisplayCurrency } from "@app/types/amounts" +import { + convertDirectionFromCurrency, + oppositeWalletCurrency, + PaymentResultStatus, + type ConvertParams, +} from "@app/types/payment.types" +import { logConversionAttempt } from "@app/utils/analytics" + +import { useConversionQuote } from "./use-conversion-quote" + +type Params = { + fromCurrency: WalletCurrency + moneyAmount: MoneyAmount + enabled: boolean +} + +export type NonCustodialConversionOutcome = + | { status: typeof PaymentResultStatus.Success } + | { status: typeof PaymentResultStatus.Failed; message: string } + +export type NonCustodialConversionFlow = { + isQuoting: boolean + hasQuoteError: boolean + feeText: string + adjustmentText: string | null + canExecute: boolean + execute: () => Promise +} + +export const useNonCustodialConversion = ({ + fromCurrency, + moneyAmount, + enabled, +}: Params): NonCustodialConversionFlow => { + const { convertMoneyAmount } = usePriceConversion() + const { LL } = useI18nContext() + + const liveQuoteParams = useMemo(() => { + if (!enabled || !convertMoneyAmount) return null + const toCurrency = oppositeWalletCurrency(fromCurrency) + return { + fromAmount: convertMoneyAmount(moneyAmount, fromCurrency), + toAmount: convertMoneyAmount(moneyAmount, toCurrency), + direction: convertDirectionFromCurrency(fromCurrency), + } + }, [enabled, convertMoneyAmount, moneyAmount, fromCurrency]) + + const [snapshotParams, setSnapshotParams] = useState(null) + + useEffect(() => { + setSnapshotParams(null) + }, [enabled, fromCurrency, moneyAmount.amount, moneyAmount.currencyCode]) + + const quoteParams = snapshotParams ?? liveQuoteParams + + const { isQuoting, hasQuoteError, quote, feeText, adjustmentText } = + useConversionQuote(quoteParams) + + useEffect(() => { + if (quote && !snapshotParams && liveQuoteParams) { + setSnapshotParams(liveQuoteParams) + } + }, [quote, snapshotParams, liveQuoteParams]) + + const execute = useCallback(async (): Promise => { + if (!quote) { + return { status: PaymentResultStatus.Failed, message: LL.errors.generic() } + } + logConversionAttempt({ + sendingWallet: fromCurrency, + receivingWallet: oppositeWalletCurrency(fromCurrency), + }) + const result = await quote.execute() + if (result.status === PaymentResultStatus.Success) { + return { status: PaymentResultStatus.Success } + } + return { + status: PaymentResultStatus.Failed, + message: result.errors?.[0]?.message ?? LL.errors.generic(), + } + }, [quote, fromCurrency, LL]) + + return { + isQuoting, + hasQuoteError, + feeText, + adjustmentText, + canExecute: quote !== null, + execute, + } +} diff --git a/app/screens/conversion-flow/use-convert-money-details.ts b/app/screens/conversion-flow/use-convert-money-details.ts index a66f4919a3..c98107a9bf 100644 --- a/app/screens/conversion-flow/use-convert-money-details.ts +++ b/app/screens/conversion-flow/use-convert-money-details.ts @@ -1,4 +1,4 @@ -import React from "react" +import React, { useCallback } from "react" import { Wallet } from "@app/graphql/generated" import { useDisplayCurrency } from "@app/hooks/use-display-currency" @@ -58,12 +58,12 @@ export const useConvertMoneyDetails = (params?: UseConvertMoneyDetailsParams) => const [moneyAmount, setMoneyAmount] = React.useState>(zeroDisplayAmount) - const setWallets = (wallets: { - fromWallet: WalletFragment - toWallet: WalletFragment - }) => { - _setWallets(wallets) - } + const setWallets = useCallback( + (wallets: { fromWallet: WalletFragment; toWallet: WalletFragment }) => { + _setWallets(wallets) + }, + [], + ) if (!wallets || !convertMoneyAmount || !convertMoneyAmountWithRounding) { return { diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 7335db767a..1118550d0d 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -17,7 +17,14 @@ import { BulletinsCard } from "@app/components/notifications/bulletins" import { SetDefaultAccountModal } from "@app/components/set-default-account-modal" import { StableSatsModal } from "@app/components/stablesats-modal" import WalletOverview from "@app/components/wallet-overview/wallet-overview" -import { BalanceHeader, useTotalBalance } from "@app/components/balance-header" +import { + BalanceHeader, + useTotalBalance, + type StatusBadge, +} from "@app/components/balance-header" +import { BalanceMode, useBalanceMode } from "@app/hooks/use-balance-mode" +import { useDisplayCurrency } from "@app/hooks/use-display-currency" +import { toBtcMoneyAmount } from "@app/types/amounts" import { TrialAccountLimitsModal } from "@app/components/upgrade-account-modal" import SlideUpHandle from "@app/components/slide-up-handle" import { Screen } from "@app/components/screen" @@ -29,7 +36,7 @@ import { } from "@app/components/unseen-tx-amount-badge" import { RootStackParamList } from "@app/navigation/stack-param-lists" -import { useRemoteConfig } from "@app/config/feature-flags-context" +import { useFeatureFlags, useRemoteConfig } from "@app/config/feature-flags-context" import { BackupNudgeBanner } from "@app/components/backup-nudge-banner" import { BackupNudgeModal } from "@app/components/backup-nudge-modal" import { useIsAuthed } from "@app/graphql/is-authed-context" @@ -172,7 +179,13 @@ export const HomeScreen: React.FC = () => { const isAuthed = useIsAuthed() const activeWallet = useActiveWallet() const { isSelfCustodial } = activeWallet - const { refreshWallets: refreshSelfCustodialWallets } = useSelfCustodialWallet() + const { + refreshWallets: refreshSelfCustodialWallets, + isStableBalanceActive, + isBalanceStale: selfCustodialIsBalanceStale, + } = useSelfCustodialWallet() + const { stableBalanceEnabled } = useFeatureFlags() + const { mode: balanceMode, toggleMode: toggleBalanceMode } = useBalanceMode() const { shouldShowBanner, shouldShowModal, dismissBanner } = useBackupNudgeState() const { seen: trustModelSeen, @@ -255,7 +268,23 @@ export const HomeScreen: React.FC = () => { walletCurrency: w.walletCurrency, })) : dataAuthed?.me?.defaultAccount?.wallets - const { formattedBalance, satsBalance } = useTotalBalance(wallets) + const { formattedBalance: defaultFormattedBalance, satsBalance } = + useTotalBalance(wallets) + + const showStableBalanceToggle = + stableBalanceEnabled && isSelfCustodial && isStableBalanceActive + + const { formatMoneyAmount } = useDisplayCurrency() + + const formattedBalance = + showStableBalanceToggle && balanceMode === BalanceMode.Btc + ? formatMoneyAmount({ moneyAmount: toBtcMoneyAmount(satsBalance) }) + : defaultFormattedBalance + + const balanceStatusBadge = useMemo(() => { + if (!isSelfCustodial || !selfCustodialIsBalanceStale) return undefined + return { label: LL.SelfCustodialBalance.staleLabel(), status: "warning" } + }, [isSelfCustodial, selfCustodialIsBalanceStale, LL]) const accountId = dataAuthed?.me?.defaultAccount?.id const levelAccount = dataAuthed?.me?.defaultAccount.level @@ -539,7 +568,14 @@ export const HomeScreen: React.FC = () => { /> - + { CurrencySetting, LanguageSetting, ThemeSetting, + StableBalanceSetting, ], securityAndPrivacy: [TotpSetting, OnDeviceSecuritySetting, ViewBackupPhraseSetting], advanced: [ExportCsvSetting, ApiAccessSetting], diff --git a/app/screens/settings-screen/settings/stable-balance.tsx b/app/screens/settings-screen/settings/stable-balance.tsx new file mode 100644 index 0000000000..d0eafd8f46 --- /dev/null +++ b/app/screens/settings-screen/settings/stable-balance.tsx @@ -0,0 +1,30 @@ +import React from "react" + +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" + +import { useFeatureFlags } from "@app/config/feature-flags-context" +import { useAccountRegistry } from "@app/hooks/use-account-registry" +import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { AccountType } from "@app/types/wallet.types" + +import { SettingsRow } from "../row" + +export const StableBalanceSetting: React.FC = () => { + const { LL } = useI18nContext() + const { navigate } = useNavigation>() + const { activeAccount } = useAccountRegistry() + const { nonCustodialEnabled, stableBalanceEnabled } = useFeatureFlags() + + if (!nonCustodialEnabled || !stableBalanceEnabled) return null + if (activeAccount?.type !== AccountType.SelfCustodial) return null + + return ( + navigate("stableBalanceSettings")} + /> + ) +} diff --git a/app/screens/stable-balance-settings-screen/hooks/index.ts b/app/screens/stable-balance-settings-screen/hooks/index.ts new file mode 100644 index 0000000000..a34ddd00bd --- /dev/null +++ b/app/screens/stable-balance-settings-screen/hooks/index.ts @@ -0,0 +1,4 @@ +export { useStableBalanceToggle } from "./use-stable-balance-toggle" +export type { StableBalanceToggleControls } from "./use-stable-balance-toggle" +export { useStableBalanceToggleQuote } from "./use-stable-balance-toggle-quote" +export type { StableBalanceToggleQuote } from "./use-stable-balance-toggle-quote" diff --git a/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote.ts b/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote.ts new file mode 100644 index 0000000000..83b0248584 --- /dev/null +++ b/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote.ts @@ -0,0 +1,50 @@ +import { useMemo } from "react" + +import { WalletCurrency } from "@app/graphql/generated" +import { usePriceConversion } from "@app/hooks/use-price-conversion" +import { useConversionQuote } from "@app/screens/conversion-flow/hooks/use-conversion-quote" +import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" +import { + convertDirectionFromCurrency, + oppositeWalletCurrency, +} from "@app/types/payment.types" + +type Params = { + fromCurrency: WalletCurrency + sourceBalance: number + enabled: boolean +} + +export type StableBalanceToggleQuote = { + isQuoting: boolean + hasQuoteError: boolean + feeText: string + adjustmentText: string | null +} + +export const useStableBalanceToggleQuote = ({ + fromCurrency, + sourceBalance, + enabled, +}: Params): StableBalanceToggleQuote => { + const { convertMoneyAmount } = usePriceConversion() + + const quoteParams = useMemo(() => { + if (!enabled || !convertMoneyAmount || sourceBalance <= 0) return null + const fromAmount = + fromCurrency === WalletCurrency.Btc + ? toBtcMoneyAmount(sourceBalance) + : toUsdMoneyAmount(sourceBalance) + const toCurrency = oppositeWalletCurrency(fromCurrency) + return { + fromAmount, + toAmount: convertMoneyAmount(fromAmount, toCurrency), + direction: convertDirectionFromCurrency(fromCurrency), + } + }, [enabled, convertMoneyAmount, sourceBalance, fromCurrency]) + + const { isQuoting, hasQuoteError, feeText, adjustmentText } = + useConversionQuote(quoteParams) + + return { isQuoting, hasQuoteError, feeText, adjustmentText } +} diff --git a/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle.ts b/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle.ts new file mode 100644 index 0000000000..6673bd3108 --- /dev/null +++ b/app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle.ts @@ -0,0 +1,81 @@ +import { useCallback, useState } from "react" + +import type { BreezSdkInterface } from "@breeztech/breez-sdk-spark-react-native" +import crashlytics from "@react-native-firebase/crashlytics" + +import { + activateStableBalance, + deactivateStableBalance, +} from "@app/self-custodial/bridge" +import { SparkToken } from "@app/self-custodial/config" +import type { TranslationFunctions } from "@app/i18n/i18n-types" +import { toastShow } from "@app/utils/toast" + +type Params = { + sdk: BreezSdkInterface | null + isStableBalanceActive: boolean + refreshWallets: () => Promise + refreshStableBalanceActive: () => Promise + LL: TranslationFunctions +} + +export type StableBalanceToggleControls = { + busy: boolean + displayValue: boolean + switchKey: number + apply: (activate: boolean) => Promise + resyncSwitch: () => void +} + +export const useStableBalanceToggle = ({ + sdk, + isStableBalanceActive, + refreshWallets, + refreshStableBalanceActive, + LL, +}: Params): StableBalanceToggleControls => { + const [busy, setBusy] = useState(false) + const [pendingValue, setPendingValue] = useState(null) + const [switchKey, setSwitchKey] = useState(0) + + const resyncSwitch = useCallback(() => setSwitchKey((k) => k + 1), []) + + const apply = useCallback( + async (activate: boolean) => { + if (!sdk || busy) return + setBusy(true) + setPendingValue(activate) + try { + if (activate) { + await activateStableBalance(sdk, SparkToken.Label) + } else { + await deactivateStableBalance(sdk) + } + await refreshStableBalanceActive() + await refreshWallets() + } catch (err) { + crashlytics().recordError( + err instanceof Error ? err : new Error(`Stable Balance toggle failed: ${err}`), + ) + toastShow({ + message: (tr) => tr.StableBalance.toggleFailedToast(), + LL, + type: "error", + }) + resyncSwitch() + } finally { + setBusy(false) + setPendingValue(null) + } + }, + [sdk, busy, refreshStableBalanceActive, refreshWallets, LL, resyncSwitch], + ) + + return { + busy, + displayValue: pendingValue ?? isStableBalanceActive, + switchKey, + apply, + resyncSwitch, + } +} diff --git a/app/screens/stable-balance-settings-screen/index.ts b/app/screens/stable-balance-settings-screen/index.ts new file mode 100644 index 0000000000..7caca2e824 --- /dev/null +++ b/app/screens/stable-balance-settings-screen/index.ts @@ -0,0 +1 @@ +export { StableBalanceSettingsScreen } from "./stable-balance-settings-screen" diff --git a/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx b/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx new file mode 100644 index 0000000000..38f32f0bd9 --- /dev/null +++ b/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx @@ -0,0 +1,101 @@ +import React from "react" +import { View } from "react-native" + +import { makeStyles, Text } from "@rn-vui/themed" + +import CustomModal from "@app/components/custom-modal/custom-modal" +import { useI18nContext } from "@app/i18n/i18n-react" +import { ConversionFeeRow } from "@app/screens/conversion-flow/conversion-fee-row" +import { testProps } from "@app/utils/testProps" + +type Props = { + isVisible: boolean + isActivating: boolean + feeText: string + adjustmentText: string | null + isLoading: boolean + hasError: boolean + showFeeRow: boolean + deactivationWarning?: string + isSubmitting: boolean + onConfirm: () => void + onCancel: () => void +} + +export const StableBalanceConfirmModal: React.FC = ({ + isVisible, + isActivating, + feeText, + adjustmentText, + isLoading, + hasError, + showFeeRow, + deactivationWarning, + isSubmitting, + onConfirm, + onCancel, +}) => { + const { LL } = useI18nContext() + const styles = useStyles() + + const title = isActivating + ? LL.StableBalance.toggleModal.activateTitle() + : LL.StableBalance.toggleModal.deactivateTitle() + + const body = isActivating + ? LL.StableBalance.toggleModal.activateBody() + : LL.StableBalance.toggleModal.deactivateBody() + + const confirmTitle = isActivating + ? LL.StableBalance.toggleModal.activateConfirm() + : LL.StableBalance.toggleModal.deactivateConfirm() + + return ( + + + {body} + + {deactivationWarning ? ( + + {deactivationWarning} + + ) : null} + {showFeeRow ? ( + + + + ) : null} + + } + primaryButtonTitle={confirmTitle} + primaryButtonOnPress={onConfirm} + primaryButtonLoading={isSubmitting} + primaryButtonDisabled={isSubmitting || isLoading || hasError} + secondaryButtonTitle={LL.StableBalance.toggleModal.cancel()} + secondaryButtonOnPress={onCancel} + /> + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + body: { + marginBottom: 12, + }, + warning: { + color: colors.warning, + marginBottom: 12, + }, + feeRowWrapper: { + marginHorizontal: -20, + }, +})) diff --git a/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx new file mode 100644 index 0000000000..b91a1c934c --- /dev/null +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -0,0 +1,184 @@ +import React, { useState } from "react" +import { ActivityIndicator, View } from "react-native" + +import { makeStyles, Text } from "@rn-vui/themed" + +import { Screen } from "@app/components/screen" +import { Switch } from "@app/components/atomic/switch" +import { useDisplayCurrency } from "@app/hooks/use-display-currency" +import { usePriceConversion } from "@app/hooks/use-price-conversion" +import { useI18nContext } from "@app/i18n/i18n-react" +import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" +import { WalletCurrency } from "@app/graphql/generated" +import { formatUsdInDisplay } from "@app/utils/amounts" +import { testProps } from "@app/utils/testProps" + +import { StableBalanceConfirmModal } from "./stable-balance-confirm-modal" +import { useStableBalanceToggle, useStableBalanceToggleQuote } from "./hooks" + +const ToggleDirection = { + Activate: "activate", + Deactivate: "deactivate", +} as const +type ToggleDirection = (typeof ToggleDirection)[keyof typeof ToggleDirection] + +const SCREEN_TEST_ID = "stable-balance-settings-screen" +const SWITCH_TEST_ID = "stable-balance-switch" + +export const StableBalanceSettingsScreen: React.FC = () => { + const styles = useStyles() + const { LL } = useI18nContext() + const { formatMoneyAmount } = useDisplayCurrency() + const { convertMoneyAmount } = usePriceConversion() + const { + sdk, + isStableBalanceActive, + wallets, + refreshWallets, + refreshStableBalanceActive, + } = useSelfCustodialWallet() + const [pendingDirection, setPendingDirection] = useState(null) + + const { busy, displayValue, switchKey, apply, resyncSwitch } = useStableBalanceToggle({ + sdk, + isStableBalanceActive, + refreshWallets, + refreshStableBalanceActive, + LL, + }) + + const btcBalanceAmount = + wallets.find((w) => w.walletCurrency === WalletCurrency.Btc)?.balance.amount ?? 0 + const usdBalanceAmount = + wallets.find((w) => w.walletCurrency === WalletCurrency.Usd)?.balance.amount ?? 0 + const hasUsdBalance = usdBalanceAmount > 0 + const hasBtcBalance = btcBalanceAmount > 0 + + const isActivating = pendingDirection === ToggleDirection.Activate + const sourceBalance = isActivating ? btcBalanceAmount : usdBalanceAmount + const fromCurrency = isActivating ? WalletCurrency.Btc : WalletCurrency.Usd + + const toggleQuote = useStableBalanceToggleQuote({ + fromCurrency, + sourceBalance, + enabled: pendingDirection !== null, + }) + + const handleToggle = (next: boolean) => { + if (next && !hasBtcBalance) { + apply(true) + return + } + if (!next && !hasUsdBalance) { + apply(false) + return + } + setPendingDirection(next ? ToggleDirection.Activate : ToggleDirection.Deactivate) + } + + const closeModal = () => { + setPendingDirection(null) + resyncSwitch() + } + + const handleConfirmModal = async () => { + const activate = pendingDirection === ToggleDirection.Activate + setPendingDirection(null) + await apply(activate) + } + + const showFeeRow = sourceBalance > 0 + + return ( + + + + {LL.StableBalance.settingsTitle()} + + + {LL.StableBalance.settingsDescription()} + + + + + {LL.StableBalance.activationLabel()} + + + {isStableBalanceActive + ? LL.StableBalance.activeHint() + : LL.StableBalance.inactiveHint()} + + + {busy ? : null} + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + flex: 1, + paddingHorizontal: 20, + paddingVertical: 16, + gap: 16, + }, + title: { + fontWeight: "600", + }, + description: { + color: colors.grey2, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 12, + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: colors.grey4, + }, + rowLabel: { + flex: 1, + gap: 2, + }, + rowTitle: { + fontWeight: "600", + }, + rowHint: { + color: colors.grey2, + }, + spinner: { + marginRight: 4, + }, +})) diff --git a/app/self-custodial/bridge/convert.ts b/app/self-custodial/bridge/convert.ts index 480975e7c3..3408fd09d3 100644 --- a/app/self-custodial/bridge/convert.ts +++ b/app/self-custodial/bridge/convert.ts @@ -1,42 +1,186 @@ import { + AmountAdjustmentReason, PrepareSendPaymentRequest, + PrepareSendPaymentResponse, + ReceivePaymentMethod, + ReceivePaymentRequest, SendPaymentRequest, type BreezSdkInterface, } from "@breeztech/breez-sdk-spark-react-native" +import crashlytics from "@react-native-firebase/crashlytics" +import { toUsdMoneyAmount } from "@app/types/amounts" import { + ConvertAmountAdjustment, ConvertDirection, + ConvertErrorCode, PaymentResultStatus, - type ConvertAdapter, + type ConvertParams, + type ConvertQuote, + type GetConversionQuoteAdapter, type PaymentAdapterResult, } from "@app/types/payment.types" +import { centsToTokenBaseUnits, tokenBaseUnitsToCents } from "@app/utils/amounts" +import { toNumber } from "@app/utils/helper" -import { SparkConfig } from "../config" +import { requireSparkTokenIdentifier, SparkConfig } from "../config" -const failed = (message: string): PaymentAdapterResult => ({ +import { buildConversionType, fetchConversionLimits } from "./limits" +import { fetchUsdbDecimals } from "./token-balance" + +const failed = (message: string, code?: string): PaymentAdapterResult => ({ status: PaymentResultStatus.Failed, - errors: [{ message }], + errors: [{ message, code }], }) -export const createConvert = (sdk: BreezSdkInterface): ConvertAdapter => { - return async ({ amount, direction }) => { - try { - const isBtcToUsd = direction === ConvertDirection.BtcToUsd - const tokenIdentifier = isBtcToUsd ? SparkConfig.tokenIdentifier : undefined +class ConvertError extends Error { + constructor( + readonly code: ConvertErrorCode, + message: string, + ) { + super(message) + } +} + +const mapAmountAdjustment = ( + reason: AmountAdjustmentReason | undefined, +): ConvertAmountAdjustment | undefined => { + if (reason === AmountAdjustmentReason.FlooredToMinLimit) { + return ConvertAmountAdjustment.FlooredToMin + } + if (reason === AmountAdjustmentReason.IncreasedToAvoidDust) { + return ConvertAmountAdjustment.IncreasedToAvoidDust + } + return undefined +} - const prepared = await sdk.prepareSendPayment( - PrepareSendPaymentRequest.create({ - paymentRequest: "", - amount: BigInt(amount.amount), - tokenIdentifier, - }), - ) +const createOwnSparkInvoice = async ( + sdk: BreezSdkInterface, + amount: bigint, + tokenIdentifier: string | undefined, +): Promise => { + const response = await sdk.receivePayment( + ReceivePaymentRequest.create({ + paymentMethod: new ReceivePaymentMethod.SparkInvoice({ + amount, + tokenIdentifier, + expiryTime: undefined, + description: undefined, + senderPublicKey: undefined, + }), + }), + ) + return response.paymentRequest +} + +const buildConversionOptions = (direction: ConvertDirection) => ({ + conversionType: buildConversionType(direction), + maxSlippageBps: SparkConfig.maxSlippageBps, + completionTimeoutSecs: undefined, +}) + +type PreparedConversion = { + prepared: PrepareSendPaymentResponse + tokenDecimals: number +} - await sdk.sendPayment(SendPaymentRequest.create({ prepareResponse: prepared })) +const recordConvertError = (err: unknown, params: ConvertParams, where: string): void => { + crashlytics().log( + `[Convert] ${where} failed (direction=${params.direction}, fromAmount=${params.fromAmount.amount}, toAmount=${params.toAmount.amount})`, + ) + crashlytics().recordError( + err instanceof Error ? err : new Error(`${where} failed: ${err}`), + ) +} - return { status: PaymentResultStatus.Success } +const prepareConversion = async ( + sdk: BreezSdkInterface, + { fromAmount, toAmount, direction }: ConvertParams, +): Promise => { + const tokenDecimals = await fetchUsdbDecimals(sdk) + const limits = await fetchConversionLimits(sdk, direction, tokenDecimals).catch( + () => null, + ) + if (!limits) { + throw new ConvertError( + ConvertErrorCode.LimitsUnavailable, + "Conversion limits unavailable", + ) + } + if (limits.minFromAmount !== null && fromAmount.amount < limits.minFromAmount) { + throw new ConvertError( + ConvertErrorCode.BelowMinimum, + "Amount is below the conversion minimum", + ) + } + if (limits.minToAmount !== null && toAmount.amount < limits.minToAmount) { + throw new ConvertError( + ConvertErrorCode.BelowMinimum, + "Destination amount is below the conversion minimum", + ) + } + + const isBtcToUsd = direction === ConvertDirection.BtcToUsd + const tokenIdentifier = isBtcToUsd ? requireSparkTokenIdentifier() : undefined + const destinationAmount = isBtcToUsd + ? BigInt(centsToTokenBaseUnits(toAmount.amount, tokenDecimals)) + : BigInt(toAmount.amount) + + const paymentRequest = await createOwnSparkInvoice( + sdk, + destinationAmount, + tokenIdentifier, + ) + + const prepared = await sdk.prepareSendPayment( + PrepareSendPaymentRequest.create({ + paymentRequest, + amount: destinationAmount, + tokenIdentifier, + conversionOptions: buildConversionOptions(direction), + }), + ) + + return { prepared, tokenDecimals } +} + +const executePrepared = async ( + sdk: BreezSdkInterface, + prepared: PrepareSendPaymentResponse, + params: ConvertParams, +): Promise => { + try { + await sdk.sendPayment(SendPaymentRequest.create({ prepareResponse: prepared })) + return { status: PaymentResultStatus.Success } + } catch (err) { + recordConvertError(err, params, "executePrepared") + return failed(err instanceof Error ? err.message : `Conversion failed: ${err}`) + } +} + +const toConvertQuote = ( + sdk: BreezSdkInterface, + { prepared, tokenDecimals }: PreparedConversion, + params: ConvertParams, +): ConvertQuote | null => { + const estimate = prepared.conversionEstimate + if (!estimate) return null + const feeCents = tokenBaseUnitsToCents(toNumber(estimate.fee), tokenDecimals) + return { + feeAmount: toUsdMoneyAmount(feeCents), + amountAdjustment: mapAmountAdjustment(estimate.amountAdjustment), + execute: () => executePrepared(sdk, prepared, params), + } +} + +export const createGetConversionQuote = + (sdk: BreezSdkInterface): GetConversionQuoteAdapter => + async (params) => { + try { + const context = await prepareConversion(sdk, params) + return toConvertQuote(sdk, context, params) } catch (err) { - return failed(err instanceof Error ? err.message : `Conversion failed: ${err}`) + recordConvertError(err, params, "getConversionQuote") + throw err } } -} diff --git a/app/self-custodial/bridge/index.ts b/app/self-custodial/bridge/index.ts index bb8df054d9..c603ac59a7 100644 --- a/app/self-custodial/bridge/index.ts +++ b/app/self-custodial/bridge/index.ts @@ -8,6 +8,7 @@ export { } from "./lifecycle" export { getWalletInfo, listPayments, getUserSettings } from "./wallet" export { getSparkStatus } from "./status" +export { activateStableBalance, deactivateStableBalance } from "./stable-balance" export { createReceiveLightning, createReceiveOnchain } from "./receive" export { prepareSend, @@ -20,6 +21,8 @@ export { export type { OnchainFeeTiers, PrepareSendOptions } from "./send" export { listDeposits, claimDeposit, refundDeposit, getRecommendedFees } from "./deposits" export type { MappedDeposit, NetworkFeeRates } from "./deposits" -export { createConvert } from "./convert" +export { createGetConversionQuote } from "./convert" +export { fetchConversionLimits } from "./limits" export { parseSparkAddress } from "./parse" export type { ParsedSparkAddress } from "./parse" +export { findUsdbToken, fetchUsdbDecimals } from "./token-balance" diff --git a/app/self-custodial/bridge/lifecycle.ts b/app/self-custodial/bridge/lifecycle.ts index 2a40e80469..d750957a93 100644 --- a/app/self-custodial/bridge/lifecycle.ts +++ b/app/self-custodial/bridge/lifecycle.ts @@ -14,7 +14,12 @@ import Crypto from "react-native-quick-crypto" import KeyStoreWrapper from "@app/utils/storage/secureStorage" -import { SparkConfig, SparkNetworkLabel, SparkToken } from "../config" +import { + requireSparkTokenIdentifier, + SparkConfig, + SparkNetworkLabel, + SparkToken, +} from "../config" import { createSdkLogListener } from "../logging" const initializeLogging = (() => { @@ -34,13 +39,11 @@ const createSdkConfig = () => { const config = defaultConfig(SparkConfig.network) config.apiKey = SparkConfig.apiKey - if (SparkConfig.tokenIdentifier) { - config.stableBalanceConfig = { - tokens: [{ label: SparkToken.Label, tokenIdentifier: SparkConfig.tokenIdentifier }], - defaultActiveLabel: undefined, - thresholdSats: undefined, - maxSlippageBps: SparkConfig.maxSlippageBps, - } + config.stableBalanceConfig = { + tokens: [{ label: SparkToken.Label, tokenIdentifier: requireSparkTokenIdentifier() }], + defaultActiveLabel: undefined, + thresholdSats: undefined, + maxSlippageBps: SparkConfig.maxSlippageBps, } return config diff --git a/app/self-custodial/bridge/limits.ts b/app/self-custodial/bridge/limits.ts new file mode 100644 index 0000000000..4fe3a62b85 --- /dev/null +++ b/app/self-custodial/bridge/limits.ts @@ -0,0 +1,49 @@ +import { + ConversionType, + type BreezSdkInterface, +} from "@breeztech/breez-sdk-spark-react-native" + +import { ConvertDirection, type ConversionLimits } from "@app/types/payment.types" +import { tokenBaseUnitsToCentsCeil } from "@app/utils/amounts" +import { toNumber } from "@app/utils/helper" + +import { requireSparkTokenIdentifier } from "../config" + +import { fetchUsdbDecimals } from "./token-balance" + +export const buildConversionType = (direction: ConvertDirection) => + direction === ConvertDirection.BtcToUsd + ? new ConversionType.FromBitcoin() + : new ConversionType.ToBitcoin({ + fromTokenIdentifier: requireSparkTokenIdentifier(), + }) + +const toWalletUnit = ( + raw: bigint | null | undefined, + assetIsToken: boolean, + tokenDecimals: number, +): number | null => { + if (raw === null || raw === undefined) return null + const value = toNumber(raw) + if (!assetIsToken) return value + return tokenBaseUnitsToCentsCeil(value, tokenDecimals) +} + +export const fetchConversionLimits = async ( + sdk: BreezSdkInterface, + direction: ConvertDirection, + tokenDecimalsHint?: number, +): Promise => { + const isBtcToUsd = direction === ConvertDirection.BtcToUsd + const response = await sdk.fetchConversionLimits({ + conversionType: buildConversionType(direction), + tokenIdentifier: isBtcToUsd ? requireSparkTokenIdentifier() : undefined, + }) + + const tokenDecimals = tokenDecimalsHint ?? (await fetchUsdbDecimals(sdk)) + + return { + minFromAmount: toWalletUnit(response.minFromAmount, !isBtcToUsd, tokenDecimals), + minToAmount: toWalletUnit(response.minToAmount, isBtcToUsd, tokenDecimals), + } +} diff --git a/app/self-custodial/bridge/send.ts b/app/self-custodial/bridge/send.ts index a2572d680d..83cdb3c781 100644 --- a/app/self-custodial/bridge/send.ts +++ b/app/self-custodial/bridge/send.ts @@ -13,7 +13,7 @@ import { WalletCurrency } from "@app/graphql/generated" import { centsToTokenBaseUnits } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" -import { SparkConfig, SparkToken } from "../config" +import { requireSparkTokenIdentifier, SparkToken } from "../config" const speedFeeTotal = (quote: SendOnchainSpeedFeeQuote): number => toNumber(quote.userFeeSat) + toNumber(quote.l1BroadcastFeeSat) @@ -85,7 +85,7 @@ export const toSdkSendAmount = ( export const resolveSendTokenIdentifier = ( currency: WalletCurrency, ): string | undefined => - currency === WalletCurrency.Usd ? SparkConfig.tokenIdentifier || undefined : undefined + currency === WalletCurrency.Usd ? requireSparkTokenIdentifier() : undefined export const executeSend = ( sdk: BreezSdkInterface, diff --git a/app/self-custodial/bridge/stable-balance.ts b/app/self-custodial/bridge/stable-balance.ts new file mode 100644 index 0000000000..51b8f62a68 --- /dev/null +++ b/app/self-custodial/bridge/stable-balance.ts @@ -0,0 +1,19 @@ +import { + StableBalanceActiveLabel, + type BreezSdkInterface, +} from "@breeztech/breez-sdk-spark-react-native" + +export const activateStableBalance = ( + sdk: BreezSdkInterface, + label: string, +): Promise => + sdk.updateUserSettings({ + sparkPrivateModeEnabled: undefined, + stableBalanceActiveLabel: new StableBalanceActiveLabel.Set({ label }), + }) + +export const deactivateStableBalance = (sdk: BreezSdkInterface): Promise => + sdk.updateUserSettings({ + sparkPrivateModeEnabled: undefined, + stableBalanceActiveLabel: new StableBalanceActiveLabel.Unset(), + }) diff --git a/app/self-custodial/bridge/token-balance.ts b/app/self-custodial/bridge/token-balance.ts new file mode 100644 index 0000000000..4972090ddb --- /dev/null +++ b/app/self-custodial/bridge/token-balance.ts @@ -0,0 +1,44 @@ +import { + type BreezSdkInterface, + type GetInfoResponse, + type TokenBalance, +} from "@breeztech/breez-sdk-spark-react-native" + +import { requireSparkTokenIdentifier, SparkToken } from "../config" +import { recordErrorOnce } from "../logging" + +const listTokenBalances = (info: GetInfoResponse): TokenBalance[] => + info.tokenBalances instanceof Map + ? [...info.tokenBalances.values()] + : Object.values(info.tokenBalances ?? {}) + +export const findUsdbToken = (info: GetInfoResponse): TokenBalance | undefined => { + const expectedIdentifier = requireSparkTokenIdentifier() + const match = listTokenBalances(info).find( + (token) => token.tokenMetadata?.identifier === expectedIdentifier, + ) + if (!match) { + recordErrorOnce( + `spark-token-not-found:${expectedIdentifier}`, + new Error( + `Spark token ${expectedIdentifier} not present in tokenBalances response`, + ), + ) + } + return match +} + +export const fetchUsdbDecimals = async (sdk: BreezSdkInterface): Promise => { + const info = await sdk.getInfo({ ensureSynced: false }) + const decimals = findUsdbToken(info)?.tokenMetadata?.decimals + if (decimals === undefined) { + recordErrorOnce( + "spark-token-decimals-missing", + new Error( + `Spark token decimals unavailable; falling back to ${SparkToken.DefaultDecimals}`, + ), + ) + return SparkToken.DefaultDecimals + } + return decimals +} diff --git a/app/self-custodial/config.ts b/app/self-custodial/config.ts index 4dab1ebfdf..33cc67d3ce 100644 --- a/app/self-custodial/config.ts +++ b/app/self-custodial/config.ts @@ -4,7 +4,6 @@ import { DocumentDirectoryPath } from "react-native-fs" export const SparkToken = { Label: "USDB", - Ticker: "USDB", DefaultDecimals: 6, } as const @@ -33,6 +32,22 @@ export const SparkConfig = { network: SparkNetwork, storageDir: `${DocumentDirectoryPath}/breez-sdk-spark-${SparkNetworkLabel}`, maxSlippageBps: 50, - tokenIdentifier: Config.SPARK_TOKEN_IDENTIFIER ?? "", apiKey: Config.BREEZ_API_KEY ?? "", } as const + +let cachedTokenIdentifier: string | null = null + +// Validates SPARK_TOKEN_IDENTIFIER once per session. The first call (typically +// from `lifecycle.createSdkConfig` at SDK init) performs the env lookup and +// throws on a misconfigured build; downstream callers in hot paths (mappers, +// snapshot loops, conversion entry points) read the cached value without +// re-validating. +export const requireSparkTokenIdentifier = (): string => { + if (cachedTokenIdentifier !== null) return cachedTokenIdentifier + const id = Config.SPARK_TOKEN_IDENTIFIER + if (!id) { + throw new Error("SPARK_TOKEN_IDENTIFIER is not configured for this build") + } + cachedTokenIdentifier = id + return id +} diff --git a/app/self-custodial/hooks/index.ts b/app/self-custodial/hooks/index.ts index 1a5f5b08f1..16fd2d0a9f 100644 --- a/app/self-custodial/hooks/index.ts +++ b/app/self-custodial/hooks/index.ts @@ -1,2 +1,3 @@ +export { useNonCustodialConversionLimits } from "./use-non-custodial-conversion-limits" export { usePaymentRequest } from "./use-payment-request" export type { SelfCustodialPaymentRequestState, InvoiceData } from "./types" diff --git a/app/self-custodial/hooks/use-non-custodial-conversion-limits.ts b/app/self-custodial/hooks/use-non-custodial-conversion-limits.ts new file mode 100644 index 0000000000..a72a5879d8 --- /dev/null +++ b/app/self-custodial/hooks/use-non-custodial-conversion-limits.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react" + +import { fetchConversionLimits } from "@app/self-custodial/bridge" +import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" +import { type ConversionLimits, type ConvertDirection } from "@app/types/payment.types" + +type Result = { + limits: ConversionLimits | null + loading: boolean + error: Error | null +} + +export const useNonCustodialConversionLimits = ( + direction: ConvertDirection | undefined, +): Result => { + const { sdk } = useSelfCustodialWallet() + const [limits, setLimits] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!sdk || !direction) { + setLimits(null) + return + } + + let cancelled = false + setLoading(true) + setError(null) + + fetchConversionLimits(sdk, direction) + .then((result) => { + if (!cancelled) setLimits(result) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err : new Error(String(err))) + setLimits(null) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [sdk, direction]) + + return { limits, loading, error } +} diff --git a/app/self-custodial/logging.ts b/app/self-custodial/logging.ts index 9114a3996c..e0056ebe4f 100644 --- a/app/self-custodial/logging.ts +++ b/app/self-custodial/logging.ts @@ -1,5 +1,20 @@ import crashlytics from "@react-native-firebase/crashlytics" +const reportedDedupKeys = new Set() + +export const recordErrorOnce = (dedupKey: string, error: Error): void => { + if (reportedDedupKeys.has(dedupKey)) return + reportedDedupKeys.add(dedupKey) + crashlytics().recordError(error) +} + +// Test-only escape hatch so specs that mount/unmount the SC stack repeatedly +// (or swap envs across `jest.isolateModules` boundaries) don't see one test's +// dedup state poisoning the next. +export const __resetRecordedErrorsForTests = (): void => { + reportedDedupKeys.clear() +} + export const SdkLogLevel = { Debug: "debug", Info: "info", diff --git a/app/self-custodial/mappers/to-transaction-fragment.ts b/app/self-custodial/mappers/to-transaction-fragment.ts index 67254761b5..3bf4905ce9 100644 --- a/app/self-custodial/mappers/to-transaction-fragment.ts +++ b/app/self-custodial/mappers/to-transaction-fragment.ts @@ -23,13 +23,77 @@ type DisplayInfo = { fractionDigits: number } +type DescriptionResolver = (tx: NormalizedTransaction) => string + +type DisplayValues = { + displayAmount: string + displayCurrency: string + displayFee: string +} + +type ComputeDisplayInput = { + signedAmount: number + currency: WalletCurrency + feeAmount: number + feeCurrency: WalletCurrency + display?: DisplayInfo +} + +const STATUS_MAP: Record = { + [TransactionStatus.Completed]: TxStatus.Success, + [TransactionStatus.Pending]: TxStatus.Pending, + [TransactionStatus.Failed]: TxStatus.Failure, +} + const mapDirection = (direction: TransactionDirection): TxDirection => direction === TransactionDirection.Send ? TxDirection.Send : TxDirection.Receive -const mapStatus = (status: TransactionStatus): TxStatus => { - if (status === TransactionStatus.Completed) return TxStatus.Success - if (status === TransactionStatus.Pending) return TxStatus.Pending - return TxStatus.Failure +const mapStatus = (status: TransactionStatus): TxStatus => + STATUS_MAP[status] ?? TxStatus.Failure + +const wrapMoneyAmount = ( + amount: number, + currency: WalletCurrency, +): MoneyAmount => ({ + amount, + currency, + currencyCode: currency, +}) + +const formatInDisplayCurrency = ( + amount: MoneyAmount, + display: DisplayInfo, +): string => { + const converted = display.convertMoneyAmount( + amount, + display.displayCurrency as WalletOrDisplayCurrency, + ) + const majorUnits = converted.amount / 10 ** display.fractionDigits + return majorUnits.toFixed(display.fractionDigits) +} + +const computeDisplay = ({ + signedAmount, + currency, + feeAmount, + feeCurrency, + display, +}: ComputeDisplayInput): DisplayValues => { + if (!display) { + return { + displayAmount: `${Math.abs(signedAmount)}`, + displayCurrency: currency, + displayFee: `${feeAmount}`, + } + } + return { + displayAmount: formatInDisplayCurrency( + wrapMoneyAmount(Math.abs(signedAmount), currency), + display, + ), + displayCurrency: display.displayCurrency, + displayFee: formatInDisplayCurrency(wrapMoneyAmount(feeAmount, feeCurrency), display), + } } const createInitiationVia = ( @@ -38,11 +102,7 @@ const createInitiationVia = ( if (tx.paymentType === PaymentType.Onchain) { return { __typename: "InitiationViaOnChain", address: "" } } - return { - __typename: "InitiationViaLn", - paymentHash: tx.id, - paymentRequest: "", - } + return { __typename: "InitiationViaLn", paymentHash: tx.id, paymentRequest: "" } } const createSettlementVia = ( @@ -58,77 +118,53 @@ const createSettlementVia = ( return { __typename: "SettlementViaLn", preImage: null } } -type ComputeDisplayInput = { - signedAmount: number - currency: WalletCurrency - feeAmount: number - display?: DisplayInfo +type FeeConversionInput = { + rawAmount: number + rawCurrency: WalletCurrency + settlementCurrency: WalletCurrency + display: DisplayInfo | undefined } -const computeDisplay = ({ - signedAmount, - currency, - feeAmount, +const feeInSettlementCurrency = ({ + rawAmount, + rawCurrency, + settlementCurrency, display, -}: ComputeDisplayInput): { - displayAmount: string - displayCurrency: string - displayFee: string -} => { - if (!display) { - return { - displayAmount: `${Math.abs(signedAmount)}`, - displayCurrency: currency, - displayFee: `${feeAmount}`, - } - } - - const settlementInWalletCurrency: MoneyAmount = { - amount: Math.abs(signedAmount), - currency, - currencyCode: currency, - } - - const converted = display.convertMoneyAmount( - settlementInWalletCurrency, - display.displayCurrency as WalletOrDisplayCurrency, - ) - const majorUnits = converted.amount / 10 ** display.fractionDigits - const displayAmount = majorUnits.toFixed(display.fractionDigits) - - const feeInWalletCurrency: MoneyAmount = { - amount: feeAmount, - currency: WalletCurrency.Btc, - currencyCode: WalletCurrency.Btc, - } - const convertedFee = display.convertMoneyAmount( - feeInWalletCurrency, - display.displayCurrency as WalletOrDisplayCurrency, - ) - const feeMajor = convertedFee.amount / 10 ** display.fractionDigits - const displayFee = feeMajor.toFixed(display.fractionDigits) - - return { displayAmount, displayCurrency: display.displayCurrency, displayFee } +}: FeeConversionInput): number => { + if (rawCurrency === settlementCurrency) return rawAmount + if (!display) return 0 + return display.convertMoneyAmount( + wrapMoneyAmount(rawAmount, rawCurrency), + settlementCurrency, + ).amount } -type DescriptionResolver = (tx: NormalizedTransaction) => string - export const toTransactionFragment = ( tx: NormalizedTransaction, display?: DisplayInfo, resolveDescription?: DescriptionResolver, ): TransactionFragment => { const direction = mapDirection(tx.direction) - const feeAmount = tx.fee?.amount ?? 0 + const rawFeeAmount = tx.fee?.amount ?? 0 + const rawFeeCurrency = (tx.fee?.currency ?? WalletCurrency.Btc) as WalletCurrency + const currency = tx.amount.currency as WalletCurrency + + const settlementFee = feeInSettlementCurrency({ + rawAmount: rawFeeAmount, + rawCurrency: rawFeeCurrency, + settlementCurrency: currency, + display, + }) + const totalAmount = - direction === TxDirection.Send ? tx.amount.amount + feeAmount : tx.amount.amount + direction === TxDirection.Send ? tx.amount.amount + settlementFee : tx.amount.amount const signedAmount = direction === TxDirection.Send ? -totalAmount : totalAmount - const currency = tx.amount.currency as WalletCurrency const { displayAmount, displayCurrency, displayFee } = computeDisplay({ signedAmount, currency, - feeAmount, + feeAmount: rawFeeAmount, + feeCurrency: rawFeeCurrency, display, }) @@ -140,7 +176,7 @@ export const toTransactionFragment = ( memo: resolveDescription ? resolveDescription(tx) : tx.memo ?? null, createdAt: tx.timestamp, settlementAmount: signedAmount, - settlementFee: feeAmount, + settlementFee, settlementDisplayFee: displayFee, settlementCurrency: currency, settlementDisplayAmount: displayAmount, diff --git a/app/self-custodial/mappers/transaction-mapper.ts b/app/self-custodial/mappers/transaction-mapper.ts index 85f00d131a..10ae651cde 100644 --- a/app/self-custodial/mappers/transaction-mapper.ts +++ b/app/self-custodial/mappers/transaction-mapper.ts @@ -102,10 +102,10 @@ const toDisplayAmount = ( rawAmount: number, currency: WalletCurrency, tokenDecimals: number, -): number => { - if (currency === WalletCurrency.Btc) return rawAmount - return tokenBaseUnitsToCents(rawAmount, tokenDecimals) -} +): number => + currency === WalletCurrency.Btc + ? rawAmount + : tokenBaseUnitsToCents(rawAmount, tokenDecimals) const extractMemo = (payment: Payment): string | undefined => { if (!payment.details) return undefined @@ -139,20 +139,20 @@ const extractTokenTicker = (payment: Payment): string | undefined => { return payment.details.inner.metadata.ticker } -const hasConversion = (payment: Payment): boolean => { - if (payment.conversionDetails) return true - if (!payment.details) return false - +const conversionInfoOf = (payment: Payment) => { + if (!payment.details) return undefined if (PaymentDetails.Spark.instanceOf(payment.details)) { - return Boolean(payment.details.inner.conversionInfo) + return payment.details.inner.conversionInfo } if (PaymentDetails.Token.instanceOf(payment.details)) { - return Boolean(payment.details.inner.conversionInfo) + return payment.details.inner.conversionInfo } - - return false + return undefined } +const hasConversion = (payment: Payment): boolean => + Boolean(payment.conversionDetails) || Boolean(conversionInfoOf(payment)) + export const mapSelfCustodialTransaction = (payment: Payment): NormalizedTransaction => { const currency = mapCurrency(payment.details) const tokenDecimals = getTokenDecimals(payment.details) @@ -171,7 +171,10 @@ export const mapSelfCustodialTransaction = (payment: Payment): NormalizedTransac status: mapStatus(payment.status), timestamp: toNumber(payment.timestamp), paymentType: mapPaymentMethod(payment.method, payment.details), - fee: toWalletMoneyAmount(Math.abs(toNumber(payment.fees)), WalletCurrency.Btc), + fee: toWalletMoneyAmount( + toDisplayAmount(Math.abs(toNumber(payment.fees)), currency, tokenDecimals), + currency, + ), sourceAccountType: AccountType.SelfCustodial, } } diff --git a/app/self-custodial/providers/detect-balance-stale.ts b/app/self-custodial/providers/detect-balance-stale.ts new file mode 100644 index 0000000000..794da1fada --- /dev/null +++ b/app/self-custodial/providers/detect-balance-stale.ts @@ -0,0 +1,24 @@ +import { TransactionDirection, TransactionStatus } from "@app/types/transaction.types" +import type { WalletState } from "@app/types/wallet.types" + +/** + * Heuristic stale-balance detection: if balance=0 but the wallet has completed + * incoming txs, the Spark tree sync likely failed (DNS/operator unreachable). + */ +export const detectBalanceStale = (wallets: WalletState[] | undefined): boolean => { + if (!wallets || !wallets.length) return false + + const totalBalance = wallets.reduce( + (sum, wallet) => sum + Number(wallet.balance.amount), + 0, + ) + if (totalBalance) return false + + return wallets.some((wallet) => + wallet.transactions.some( + (tx) => + tx.direction === TransactionDirection.Receive && + tx.status === TransactionStatus.Completed, + ), + ) +} diff --git a/app/self-custodial/providers/is-online.ts b/app/self-custodial/providers/is-online.ts index f9907915db..9049bf5875 100644 --- a/app/self-custodial/providers/is-online.ts +++ b/app/self-custodial/providers/is-online.ts @@ -1,16 +1,36 @@ import { ServiceStatus } from "@breeztech/breez-sdk-spark-react-native" import { getSparkStatus } from "../bridge" +import { recordErrorOnce } from "../logging" -export const isOnline = async (): Promise => { +const ONLINE_STATUSES: readonly ServiceStatus[] = [ + ServiceStatus.Operational, + ServiceStatus.Degraded, +] + +const reportSparkStatusFailure = (err: unknown): void => { + recordErrorOnce( + "spark-status-fetch-failed", + err instanceof Error ? err : new Error(`Spark status fetch failed: ${err}`), + ) +} + +export const getServiceStatus = async (): Promise => { try { const { status } = await getSparkStatus() - return status === ServiceStatus.Operational || status === ServiceStatus.Degraded - } catch { - return false + return status + } catch (err) { + reportSparkStatusFailure(err) + return ServiceStatus.Major } } +export const isOnlineStatus = (status: ServiceStatus): boolean => + ONLINE_STATUSES.includes(status) + +export const isOnline = async (): Promise => + isOnlineStatus(await getServiceStatus()) + export const OnlineState = { Online: "online", Offline: "offline", @@ -22,11 +42,9 @@ export type OnlineState = (typeof OnlineState)[keyof typeof OnlineState] export const getOnlineState = async (): Promise => { try { const { status } = await getSparkStatus() - if (status === ServiceStatus.Operational || status === ServiceStatus.Degraded) { - return OnlineState.Online - } - return OnlineState.Offline - } catch { + return isOnlineStatus(status) ? OnlineState.Online : OnlineState.Offline + } catch (err) { + reportSparkStatusFailure(err) return OnlineState.Unknown } } diff --git a/app/self-custodial/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 492b62cf6a..99f0f55dd0 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -4,12 +4,15 @@ import { AppState } from "react-native" import type { BreezSdkInterface } from "@breeztech/breez-sdk-spark-react-native" import crashlytics from "@react-native-firebase/crashlytics" +import { useI18nContext } from "@app/i18n/i18n-react" import { ActiveWalletStatus, type WalletState } from "@app/types/wallet.types" import KeyStoreWrapper from "@app/utils/storage/secureStorage" +import { toastShow } from "@app/utils/toast" import { addSdkEventListener, disconnectSdk, getUserSettings, initSdk } from "../bridge" import { logSdkEvent, SdkLogLevel } from "../logging" +import { detectBalanceStale } from "./detect-balance-stale" import { extractPaymentId, PAYMENT_RECEIVED_EVENTS, REFRESH_EVENTS } from "./sdk-events" import { validateStoredNetwork } from "./validate-network" import { getOnlineState, OnlineState } from "./is-online" @@ -24,11 +27,13 @@ type SdkLifecycleState = { status: ActiveWalletStatus sdk: BreezSdkInterface | null isStableBalanceActive: boolean + isBalanceStale: boolean lastReceivedPaymentId: string | null hasMoreTransactions: boolean loadingMore: boolean loadMore: () => Promise refreshWallets: () => Promise + refreshStableBalanceActive: () => Promise } const OFFLINE_EXEMPT_STATUSES: readonly ActiveWalletStatus[] = [ @@ -37,9 +42,12 @@ const OFFLINE_EXEMPT_STATUSES: readonly ActiveWalletStatus[] = [ ] export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { + const { LL } = useI18nContext() + const [wallets, setWallets] = useState([]) const [status, setStatus] = useState(ActiveWalletStatus.Unavailable) const [isStableBalanceActive, setIsStableBalanceActive] = useState(false) + const [isBalanceStale, setIsBalanceStale] = useState(false) const [lastReceivedPaymentId, setLastReceivedPaymentId] = useState(null) const [hasMoreTransactions, setHasMoreTransactions] = useState(false) const [loadingMore, setLoadingMore] = useState(false) @@ -47,7 +55,29 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const sdkRef = useRef(null) const refreshingRef = useRef(false) const pendingRefreshRef = useRef(false) + const isBalanceStaleRef = useRef(false) + const rawTxOffsetRef = useRef(0) + const llRef = useRef(LL) + llRef.current = LL + + const updateBalanceStale = useCallback((nextStale: boolean) => { + const prevStale = isBalanceStaleRef.current + isBalanceStaleRef.current = nextStale + setIsBalanceStale(nextStale) + if (nextStale && !prevStale) { + toastShow({ + message: (tr) => tr.SelfCustodialBalance.syncFailedToast(), + LL: llRef.current, + type: "warning", + }) + } + }, []) + // `refreshingRef` linearizes concurrent refreshes (10s poll, AppState change, + // SDK events): only one runOnce executes at a time, and any overlapping call + // sets `pendingRefreshRef` so the in-flight loop reruns once it returns. The + // two require-atomic-updates disables below (post-await ref writes) are safe + // under that invariant. const refreshWallets = useCallback(async () => { const sdk = sdkRef.current if (!sdk) return @@ -73,10 +103,13 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { return } - const snapshot = await getSelfCustodialWalletSnapshot(sdk) + const snapshot = await getSelfCustodialWalletSnapshot(sdk, rawTxOffsetRef.current) setWallets(snapshot.wallets) setHasMoreTransactions(snapshot.hasMore) + rawTxOffsetRef.current = snapshot.rawTransactionCount // eslint-disable-line require-atomic-updates setStatus(ActiveWalletStatus.Ready) + + updateBalanceStale(detectBalanceStale(snapshot.wallets)) } catch (err) { logSdkEvent(SdkLogLevel.Error, `Failed to refresh wallets: ${err}`) crashlytics().recordError( @@ -99,7 +132,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } finally { refreshingRef.current = false // eslint-disable-line require-atomic-updates } - }, []) + }, [updateBalanceStale]) useEffect(() => { let mounted = true @@ -124,6 +157,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } await refreshWallets() }) + if (!mounted) return refreshWallets() @@ -176,7 +210,12 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { useEffect(() => { const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") refreshWallets() + if (state !== "active") return + refreshWallets().catch((err) => { + crashlytics().recordError( + err instanceof Error ? err : new Error(`AppState refresh failed: ${err}`), + ) + }) }) return () => subscription.remove() }, [refreshWallets]) @@ -185,7 +224,12 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const CONNECTIVITY_POLL_MS = 10000 const interval = setInterval(() => { if (!sdkRef.current) return - refreshWallets() + if (AppState.currentState !== "active") return + refreshWallets().catch((err) => { + crashlytics().recordError( + err instanceof Error ? err : new Error(`Polling refresh failed: ${err}`), + ) + }) }, CONNECTIVITY_POLL_MS) return () => clearInterval(interval) }, [refreshWallets]) @@ -194,8 +238,8 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { if (!sdkRef.current || loadingMore || !hasMoreTransactions) return setLoadingMore(true) try { - const currentCount = wallets.reduce((sum, w) => sum + w.transactions.length, 0) - const result = await loadMoreTransactions(sdkRef.current, currentCount) + const result = await loadMoreTransactions(sdkRef.current, rawTxOffsetRef.current) + rawTxOffsetRef.current += result.rawCount setHasMoreTransactions(result.hasMore) setWallets((prev) => appendTransactions(prev, result.transactions)) } catch (err) { @@ -203,17 +247,32 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } finally { setLoadingMore(false) } - }, [loadingMore, hasMoreTransactions, wallets]) + }, [loadingMore, hasMoreTransactions]) + + const refreshStableBalanceActive = useCallback(async () => { + if (!sdkRef.current) return + try { + const settings = await getUserSettings(sdkRef.current) + setIsStableBalanceActive(settings.stableBalanceActiveLabel !== undefined) + } catch (err) { + logSdkEvent(SdkLogLevel.Error, `Failed to refresh user settings: ${err}`) + crashlytics().recordError( + err instanceof Error ? err : new Error(`Refresh user settings failed: ${err}`), + ) + } + }, []) return { wallets, status, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, } } diff --git a/app/self-custodial/providers/wallet-provider.tsx b/app/self-custodial/providers/wallet-provider.tsx index 48b5ef88ef..d4971ceed6 100644 --- a/app/self-custodial/providers/wallet-provider.tsx +++ b/app/self-custodial/providers/wallet-provider.tsx @@ -14,11 +14,13 @@ type SelfCustodialWalletContextValue = ActiveWalletState & { retry: () => void sdk: BreezSdkInterface | null isStableBalanceActive: boolean + isBalanceStale: boolean lastReceivedPaymentId: string | null hasMoreTransactions: boolean loadingMore: boolean loadMore: () => Promise refreshWallets: () => Promise + refreshStableBalanceActive: () => Promise } const noop = async () => {} @@ -30,11 +32,13 @@ const defaultState: SelfCustodialWalletContextValue = { retry: () => {}, sdk: null, isStableBalanceActive: false, + isBalanceStale: false, lastReceivedPaymentId: null, hasMoreTransactions: false, loadingMore: false, loadMore: noop, refreshWallets: noop, + refreshStableBalanceActive: noop, } const SelfCustodialWalletContext = @@ -49,11 +53,13 @@ export const SelfCustodialWalletProvider: React.FC = ({ status, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, } = useSdkLifecycle(retryCount) const retry = useCallback(() => { @@ -68,11 +74,13 @@ export const SelfCustodialWalletProvider: React.FC = ({ retry, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, }), [ wallets, @@ -80,11 +88,13 @@ export const SelfCustodialWalletProvider: React.FC = ({ retry, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, ], ) diff --git a/app/self-custodial/providers/wallet-snapshot.ts b/app/self-custodial/providers/wallet-snapshot.ts index 557bc207e2..a5239acc79 100644 --- a/app/self-custodial/providers/wallet-snapshot.ts +++ b/app/self-custodial/providers/wallet-snapshot.ts @@ -2,8 +2,8 @@ import { PaymentDetails, PaymentMethod, type BreezSdkInterface, + type GetInfoResponse, type Payment, - type TokenBalance, } from "@breeztech/breez-sdk-spark-react-native" import { WalletCurrency } from "@app/graphql/generated" @@ -12,37 +12,38 @@ import { toWalletMoneyAmount } from "@app/types/amounts" import { type NormalizedTransaction } from "@app/types/transaction.types" import { toWalletId, type WalletState } from "@app/types/wallet.types" -import { getWalletInfo, listPayments } from "../bridge" -import { SparkConfig, SparkToken } from "../config" +import { findUsdbToken, getWalletInfo, listPayments } from "../bridge" +import { requireSparkTokenIdentifier } from "../config" +import { recordErrorOnce } from "../logging" import { mapSelfCustodialTransactions } from "../mappers/transaction-mapper" const TRANSACTIONS_PER_PAGE = 20 -const getStableBalance = ( - tokenBalances: Map | Record, -): number => { - const entries: [string, TokenBalance][] = - tokenBalances instanceof Map - ? [...tokenBalances.entries()] - : Object.entries(tokenBalances) - - const match = entries.find( - ([, token]) => token.tokenMetadata?.ticker === SparkToken.Ticker, - ) - if (!match) return 0 - - const decimals = match[1].tokenMetadata?.decimals ?? 0 - return tokenBaseUnitsToCents(Number(match[1].balance), decimals) +const getStableBalance = (info: GetInfoResponse): number => { + const token = findUsdbToken(info) + if (!token) return 0 + const decimals = token.tokenMetadata?.decimals ?? 0 + return tokenBaseUnitsToCents(Number(token.balance), decimals) } const isKnownPayment = (payment: Payment): boolean => { if (payment.method !== PaymentMethod.Token) return true if (!payment.details || !PaymentDetails.Token.instanceOf(payment.details)) return false - return payment.details.inner.metadata.identifier === SparkConfig.tokenIdentifier + const expectedIdentifier = requireSparkTokenIdentifier() + const observedIdentifier = payment.details.inner.metadata.identifier + if (observedIdentifier === expectedIdentifier) return true + recordErrorOnce( + `spark-unknown-token-payment:${observedIdentifier}`, + new Error( + `Unknown token payment dropped: id=${observedIdentifier} expected=${expectedIdentifier}`, + ), + ) + return false } type PaymentsPage = { transactions: NormalizedTransaction[] + rawCount: number hasMore: boolean } @@ -51,8 +52,12 @@ const fetchAndMapPayments = async ( offset: number, ): Promise => { const response = await listPayments(sdk, offset, TRANSACTIONS_PER_PAGE) + const transactions = mapSelfCustodialTransactions( + response.payments.filter(isKnownPayment), + ) return { - transactions: mapSelfCustodialTransactions(response.payments.filter(isKnownPayment)), + transactions, + rawCount: response.payments.length, hasMore: response.payments.length >= TRANSACTIONS_PER_PAGE, } } @@ -84,24 +89,42 @@ const buildWallets = ( export type WalletSnapshot = { wallets: WalletState[] hasMore: boolean + rawTransactionCount: number } export const getSelfCustodialWalletSnapshot = async ( sdk: BreezSdkInterface, + targetRawCount: number = TRANSACTIONS_PER_PAGE, ): Promise => { const info = await getWalletInfo(sdk) - const page = await fetchAndMapPayments(sdk, 0) + const minRawCount = Math.max(targetRawCount, TRANSACTIONS_PER_PAGE) + + const transactions: NormalizedTransaction[] = [] + let rawTransactionCount = 0 + let hasMore = false + + while (rawTransactionCount < minRawCount) { + const page = await fetchAndMapPayments(sdk, rawTransactionCount) + if (page.rawCount === 0) break + + transactions.push(...page.transactions) + rawTransactionCount += page.rawCount + hasMore = page.hasMore + + if (!hasMore) break + } return { wallets: buildWallets( { identityPubkey: info.identityPubkey, btcBalance: Number(info.balanceSats), - stableBalance: getStableBalance(info.tokenBalances), + stableBalance: getStableBalance(info), }, - page.transactions, + transactions, ), - hasMore: page.hasMore, + hasMore, + rawTransactionCount, } } @@ -117,5 +140,5 @@ export const appendTransactions = ( export const loadMoreTransactions = async ( sdk: BreezSdkInterface, - currentCount: number, -): Promise => fetchAndMapPayments(sdk, currentCount) + rawOffset: number, +): Promise => fetchAndMapPayments(sdk, rawOffset) diff --git a/app/types/payment.types.ts b/app/types/payment.types.ts index 8f83452837..0eb5c6b089 100644 --- a/app/types/payment.types.ts +++ b/app/types/payment.types.ts @@ -136,11 +136,34 @@ export const ConvertDirection = { export type ConvertDirection = (typeof ConvertDirection)[keyof typeof ConvertDirection] +export const convertDirectionFromCurrency = ( + fromCurrency: WalletCurrency, +): ConvertDirection => + fromCurrency === WalletCurrency.Btc + ? ConvertDirection.BtcToUsd + : ConvertDirection.UsdToBtc + +export const oppositeWalletCurrency = (currency: WalletCurrency): WalletCurrency => + currency === WalletCurrency.Btc ? WalletCurrency.Usd : WalletCurrency.Btc + export type ConvertParams = { - amount: MoneyAmount + fromAmount: MoneyAmount + toAmount: MoneyAmount direction: ConvertDirection } +export type ConversionLimits = { + minFromAmount: number | null + minToAmount: number | null +} + +export const ConvertErrorCode = { + BelowMinimum: "below_minimum", + LimitsUnavailable: "limits_unavailable", +} as const + +export type ConvertErrorCode = (typeof ConvertErrorCode)[keyof typeof ConvertErrorCode] + export type SendPaymentAdapter = ( params: SendPaymentParams, ) => Promise @@ -169,3 +192,21 @@ export type ClaimDepositAdapter = { } export type ConvertAdapter = (params: ConvertParams) => Promise + +export const ConvertAmountAdjustment = { + FlooredToMin: "floored_to_min", + IncreasedToAvoidDust: "increased_to_avoid_dust", +} as const + +export type ConvertAmountAdjustment = + (typeof ConvertAmountAdjustment)[keyof typeof ConvertAmountAdjustment] + +export type ConvertQuote = { + feeAmount: MoneyAmount + amountAdjustment?: ConvertAmountAdjustment + execute: () => Promise +} + +export type GetConversionQuoteAdapter = ( + params: ConvertParams, +) => Promise diff --git a/app/utils/amounts.ts b/app/utils/amounts.ts index 66172973dc..c02b257a66 100644 --- a/app/utils/amounts.ts +++ b/app/utils/amounts.ts @@ -1,5 +1,35 @@ import { WalletCurrency } from "@app/graphql/generated" -import { type MoneyAmount, type WalletOrDisplayCurrency } from "@app/types/amounts" +import { + DisplayCurrency, + toUsdMoneyAmount, + type DisplayCurrency as DisplayCurrencyType, + type MoneyAmount, + type WalletOrDisplayCurrency, +} from "@app/types/amounts" + +type FormatUsdInDisplayDeps = { + formatMoneyAmount: (args: { + moneyAmount: MoneyAmount + }) => string + convertMoneyAmount?: ( + moneyAmount: MoneyAmount, + target: WalletCurrency | DisplayCurrencyType, + ) => MoneyAmount +} + +// Formats a USD-cents amount in the user's display currency. When the +// price-conversion is not yet available, falls back to formatting the raw +// USD amount so the UI never blocks on price loading. +export const formatUsdInDisplay = ( + usdCents: number, + { formatMoneyAmount, convertMoneyAmount }: FormatUsdInDisplayDeps, +): string => { + const usdAmount = toUsdMoneyAmount(usdCents) + if (!convertMoneyAmount) return formatMoneyAmount({ moneyAmount: usdAmount }) + return formatMoneyAmount({ + moneyAmount: convertMoneyAmount(usdAmount, DisplayCurrency), + }) +} export const toSatsAmount = ( amount: MoneyAmount, @@ -9,15 +39,29 @@ export const toSatsAmount = ( ) => MoneyAmount, ): number => convert(amount, WalletCurrency.Btc).amount -export const tokenBaseUnitsToCents = ( +export const tokenBaseUnitsToCentsExact = ( rawAmount: number, tokenDecimals: number, displayDecimals = 2, ): number => { const excessDecimals = Math.max(tokenDecimals - displayDecimals, 0) - return Math.round(rawAmount / 10 ** excessDecimals) + return rawAmount / 10 ** excessDecimals } +export const tokenBaseUnitsToCents = ( + rawAmount: number, + tokenDecimals: number, + displayDecimals = 2, +): number => + Math.round(tokenBaseUnitsToCentsExact(rawAmount, tokenDecimals, displayDecimals)) + +export const tokenBaseUnitsToCentsCeil = ( + rawAmount: number, + tokenDecimals: number, + displayDecimals = 2, +): number => + Math.ceil(tokenBaseUnitsToCentsExact(rawAmount, tokenDecimals, displayDecimals)) + export const centsToTokenBaseUnits = ( cents: number, tokenDecimals: number, diff --git a/package.json b/package.json index e992e99f46..105fc4bfd4 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@apollo/client": "^3.9.11", "@bitcoinerlab/secp256k1": "^1.1.1", "@blinkbitcoin/blink-client": "0.5.3", - "@breeztech/breez-sdk-spark-react-native": "^0.12.2-dev3", + "@breeztech/breez-sdk-spark-react-native": "0.13.2-dev3", "@expo/react-native-action-sheet": "^4.0.1", "@formatjs/intl-getcanonicallocales": "^2.3.0", "@formatjs/intl-locale": "^3.4.5", diff --git a/yarn.lock b/yarn.lock index 9ce93d598f..0be73d202a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2386,10 +2386,10 @@ resolved "https://registry.yarnpkg.com/@blinkbitcoin/blink-client/-/blink-client-0.5.3.tgz#7ef372f264e1fb94f7638c35e1eb5ec8dd3601f5" integrity sha512-jdd9qJEnMvceZzzI2iLVf4ArHqNC2RcusL9qYP1F2O4AK5XEkh7HMW1KB3t0MXS4DqjYmMeIFW169o1WEIahCA== -"@breeztech/breez-sdk-spark-react-native@^0.12.2-dev3": - version "0.12.2-dev3" - resolved "https://registry.yarnpkg.com/@breeztech/breez-sdk-spark-react-native/-/breez-sdk-spark-react-native-0.12.2-dev3.tgz#319dc7c749e16984746163471510e67fd650173f" - integrity sha512-ZEaSdghXdGlRk2775Mghjgb0md+M8iqsnOjlM8rz0aoEu6xBY/Cx68rjgsx0hnHWSXtBpQdmNq27oNmXxLpazQ== +"@breeztech/breez-sdk-spark-react-native@0.13.2-dev3": + version "0.13.2-dev3" + resolved "https://registry.yarnpkg.com/@breeztech/breez-sdk-spark-react-native/-/breez-sdk-spark-react-native-0.13.2-dev3.tgz#1f5237a0dfa4ff2090bbc159417896c202d0ef39" + integrity sha512-ahlvq/qr0E7K0M52dt8V5jjLTzA4hvVGEmdrO80jcE+9c29rnSscfRXZvvo7vNcnZZ5rj547ePGEyvml0rslwQ== dependencies: uniffi-bindgen-react-native "^0.29.3-1"