From 8eeb3932c2841c8e7036cbd083272019489acbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 17 Apr 2026 19:07:02 -0600 Subject: [PATCH 01/71] feat(self-custodial): detect stale balance and surface STALE pill + toast --- .../providers/detect-balance-stale.spec.ts | 100 ++++++ .../providers/is-online.spec.ts | 79 +++-- .../providers/wallet-provider.spec.tsx | 286 +++++++++++++++--- .../balance-header/balance-header.tsx | 46 ++- app/components/status-pill/index.ts | 2 + app/components/status-pill/status-pill.tsx | 62 ++++ app/i18n/en/index.ts | 4 + app/i18n/i18n-types.ts | 20 ++ app/i18n/raw-i18n/source/en.json | 4 + app/i18n/raw-i18n/translations/af.json | 4 + app/i18n/raw-i18n/translations/ar.json | 4 + app/i18n/raw-i18n/translations/ca.json | 4 + app/i18n/raw-i18n/translations/cs.json | 4 + app/i18n/raw-i18n/translations/da.json | 4 + app/i18n/raw-i18n/translations/de.json | 4 + app/i18n/raw-i18n/translations/el.json | 4 + app/i18n/raw-i18n/translations/es.json | 4 + app/i18n/raw-i18n/translations/fr.json | 4 + app/i18n/raw-i18n/translations/hr.json | 4 + app/i18n/raw-i18n/translations/hu.json | 4 + app/i18n/raw-i18n/translations/hy.json | 4 + app/i18n/raw-i18n/translations/id.json | 4 + app/i18n/raw-i18n/translations/it.json | 4 + app/i18n/raw-i18n/translations/ja.json | 4 + app/i18n/raw-i18n/translations/lg.json | 4 + app/i18n/raw-i18n/translations/ms.json | 4 + app/i18n/raw-i18n/translations/nl.json | 4 + app/i18n/raw-i18n/translations/pt.json | 4 + app/i18n/raw-i18n/translations/qu.json | 4 + app/i18n/raw-i18n/translations/ro.json | 4 + app/i18n/raw-i18n/translations/sk.json | 4 + app/i18n/raw-i18n/translations/sr.json | 4 + app/i18n/raw-i18n/translations/sw.json | 4 + app/i18n/raw-i18n/translations/th.json | 4 + app/i18n/raw-i18n/translations/tr.json | 4 + app/i18n/raw-i18n/translations/vi.json | 4 + app/i18n/raw-i18n/translations/xh.json | 4 + app/screens/home-screen/home-screen.tsx | 22 +- .../providers/detect-balance-stale.ts | 24 ++ app/self-custodial/providers/is-online.ts | 22 +- .../providers/use-sdk-lifecycle.ts | 28 +- .../providers/wallet-provider.tsx | 5 + 42 files changed, 732 insertions(+), 84 deletions(-) create mode 100644 __tests__/self-custodial/providers/detect-balance-stale.spec.ts create mode 100644 app/components/status-pill/index.ts create mode 100644 app/components/status-pill/status-pill.tsx create mode 100644 app/self-custodial/providers/detect-balance-stale.ts 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..9190fce633 100644 --- a/__tests__/self-custodial/providers/is-online.spec.ts +++ b/__tests__/self-custodial/providers/is-online.spec.ts @@ -1,6 +1,11 @@ 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() @@ -8,48 +13,80 @@ jest.mock("@app/self-custodial/bridge", () => ({ getSparkStatus: () => mockGetSparkStatus(), })) -describe("isOnline (boolean wrapper, backward-compat)", () => { +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 +95,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) }) diff --git a/__tests__/self-custodial/providers/wallet-provider.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index 415ddb13f0..4cdfa166aa 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,7 @@ const mockGetMnemonicNetwork = jest.fn() const mockInitSdk = jest.fn() const mockDisconnectSdk = jest.fn() const mockAddSdkEventListener = jest.fn() +const mockToastShow = jest.fn() jest.mock("@app/utils/storage/secureStorage", () => ({ __esModule: true, @@ -50,6 +58,10 @@ jest.mock("@app/self-custodial/bridge", () => ({ }), })) +jest.mock("@app/utils/toast", () => ({ + toastShow: (...args: unknown[]) => mockToastShow(...args), +})) + jest.mock("@app/self-custodial/logging", () => ({ logSdkEvent: jest.fn(), SdkLogLevel: { Error: "error" }, @@ -71,11 +83,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([]), @@ -303,14 +325,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) }) }) @@ -483,10 +502,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 +517,6 @@ describe("SelfCustodialWalletProvider", () => { await listener.current?.({ tag: "Synced" }) }) - // Unknown must NOT transition Ready → Offline. expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) @@ -512,9 +530,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 +556,23 @@ 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") + 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 +604,171 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.hasMoreTransactions).toBe(false) }) + 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 +776,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 +793,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 +801,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 +814,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 +828,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,7 +842,6 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Offline) }) - // Wallets should not be populated because refresh returned early expect(snapshot.getSelfCustodialWalletSnapshot).not.toHaveBeenCalled() }) @@ -662,39 +853,36 @@ 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({}) 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) jest.useRealTimers() }) @@ -707,10 +895,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 +912,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 +932,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,23 +954,22 @@ 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() }) diff --git a/app/components/balance-header/balance-header.tsx b/app/components/balance-header/balance-header.tsx index ec9052f0e7..1a169f17cf 100644 --- a/app/components/balance-header/balance-header.tsx +++ b/app/components/balance-header/balance-header.tsx @@ -4,6 +4,7 @@ import { 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 { testProps } from "@app/utils/testProps" @@ -23,16 +24,28 @@ const Loader = () => { ) } +export type StatusBadge = { + label: string + status: StatusPillVariant +} + type Props = { loading: boolean formattedBalance?: string + statusBadge?: StatusBadge } -export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => { +export const BalanceHeader: React.FC = ({ + loading, + formattedBalance, + statusBadge, +}) => { const styles = useStyles() const { hideAmount, switchMemoryHideAmount } = useHideAmount() + 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 ( @@ -43,7 +56,15 @@ export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => ) : ( - + + {showBadge && statusBadge ? ( + + ) : null} {loading ? ( ) : ( @@ -55,6 +76,14 @@ export const BalanceHeader: React.FC = ({ loading, formattedBalance }) => {formattedBalance} )} + {showBadge && statusBadge ? ( + + ) : null} )} @@ -67,6 +96,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 +116,12 @@ const useStyles = makeStyles(({ colors }) => ({ fontWeight: "bold", color: colors.black, }, + statusPill: { + marginLeft: 6, + marginTop: 2, + }, + statusPillGhost: { + marginRight: 6, + marginTop: 2, + }, })) 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/i18n/en/index.ts b/app/i18n/en/index.ts index c0fdcad254..e6994e93a4 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -3726,6 +3726,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", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 31281c842b..66121d75e8 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -11806,6 +11806,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 @@ -23618,6 +23628,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 diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 118f6f7a84..312ea39afa 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -3569,6 +3569,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", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index cad7169f20..0e2b375257 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -3634,6 +3634,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..f54df9141b 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -3631,6 +3631,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..47e361561d 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -3593,6 +3593,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..b498b3c953 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -3634,6 +3634,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..7b41ed3557 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -3611,6 +3611,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..cb1bd25201 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -3581,6 +3581,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..56ab7342e7 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -3593,6 +3593,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..8505bebd39 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -3581,6 +3581,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..c482c4b0e8 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -3622,6 +3622,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..2b45672c23 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -3634,6 +3634,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..e284843a19 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -3593,6 +3593,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..79272b45aa 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -3634,6 +3634,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..0a61ef1d9a 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -3593,6 +3593,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..bc6424455b 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -3581,6 +3581,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..a81b54bce7 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -3622,6 +3622,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..e0574abd36 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -3593,6 +3593,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..c18e0b6cf5 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -3634,6 +3634,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..d8b20d3baa 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -3634,6 +3634,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..314aab02b4 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -3581,6 +3581,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..c451628b14 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -3631,6 +3631,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..93bd7d8189 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -3593,6 +3593,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..1c24eb2fc3 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -3593,6 +3593,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..95d4b99cfd 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -3631,6 +3631,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..74efbf27cb 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -3593,6 +3593,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..243e1062fa 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -3631,6 +3631,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..617ce5b313 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -3593,6 +3593,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..655010ff31 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -3631,6 +3631,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..685dab7eba 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -3640,6 +3640,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/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 7335db767a..742c0a2ed7 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -17,7 +17,11 @@ 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 { TrialAccountLimitsModal } from "@app/components/upgrade-account-modal" import SlideUpHandle from "@app/components/slide-up-handle" import { Screen } from "@app/components/screen" @@ -172,7 +176,10 @@ export const HomeScreen: React.FC = () => { const isAuthed = useIsAuthed() const activeWallet = useActiveWallet() const { isSelfCustodial } = activeWallet - const { refreshWallets: refreshSelfCustodialWallets } = useSelfCustodialWallet() + const { + refreshWallets: refreshSelfCustodialWallets, + isBalanceStale: selfCustodialIsBalanceStale, + } = useSelfCustodialWallet() const { shouldShowBanner, shouldShowModal, dismissBanner } = useBackupNudgeState() const { seen: trustModelSeen, @@ -257,6 +264,11 @@ export const HomeScreen: React.FC = () => { : dataAuthed?.me?.defaultAccount?.wallets const { formattedBalance, satsBalance } = useTotalBalance(wallets) + 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 const pendingIncomingTransactions = @@ -539,7 +551,11 @@ export const HomeScreen: React.FC = () => { /> - + { + 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..07ca63473e 100644 --- a/app/self-custodial/providers/is-online.ts +++ b/app/self-custodial/providers/is-online.ts @@ -2,15 +2,26 @@ import { ServiceStatus } from "@breeztech/breez-sdk-spark-react-native" import { getSparkStatus } from "../bridge" -export const isOnline = async (): Promise => { +const ONLINE_STATUSES: readonly ServiceStatus[] = [ + ServiceStatus.Operational, + ServiceStatus.Degraded, +] + +export const getServiceStatus = async (): Promise => { try { const { status } = await getSparkStatus() - return status === ServiceStatus.Operational || status === ServiceStatus.Degraded + return status } catch { - return false + 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,10 +33,7 @@ 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 + return isOnlineStatus(status) ? OnlineState.Online : OnlineState.Offline } catch { 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..5b9072de0b 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,6 +27,7 @@ type SdkLifecycleState = { status: ActiveWalletStatus sdk: BreezSdkInterface | null isStableBalanceActive: boolean + isBalanceStale: boolean lastReceivedPaymentId: string | null hasMoreTransactions: boolean loadingMore: boolean @@ -37,9 +41,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,6 +54,22 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const sdkRef = useRef(null) const refreshingRef = useRef(false) const pendingRefreshRef = useRef(false) + const isBalanceStaleRef = useRef(false) + 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", + }) + } + }, []) const refreshWallets = useCallback(async () => { const sdk = sdkRef.current @@ -77,6 +100,8 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { setWallets(snapshot.wallets) setHasMoreTransactions(snapshot.hasMore) setStatus(ActiveWalletStatus.Ready) + + updateBalanceStale(detectBalanceStale(snapshot.wallets)) } catch (err) { logSdkEvent(SdkLogLevel.Error, `Failed to refresh wallets: ${err}`) crashlytics().recordError( @@ -99,7 +124,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } finally { refreshingRef.current = false // eslint-disable-line require-atomic-updates } - }, []) + }, [updateBalanceStale]) useEffect(() => { let mounted = true @@ -210,6 +235,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { status, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, diff --git a/app/self-custodial/providers/wallet-provider.tsx b/app/self-custodial/providers/wallet-provider.tsx index 48b5ef88ef..4e0ecf70c3 100644 --- a/app/self-custodial/providers/wallet-provider.tsx +++ b/app/self-custodial/providers/wallet-provider.tsx @@ -14,6 +14,7 @@ type SelfCustodialWalletContextValue = ActiveWalletState & { retry: () => void sdk: BreezSdkInterface | null isStableBalanceActive: boolean + isBalanceStale: boolean lastReceivedPaymentId: string | null hasMoreTransactions: boolean loadingMore: boolean @@ -30,6 +31,7 @@ const defaultState: SelfCustodialWalletContextValue = { retry: () => {}, sdk: null, isStableBalanceActive: false, + isBalanceStale: false, lastReceivedPaymentId: null, hasMoreTransactions: false, loadingMore: false, @@ -49,6 +51,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ status, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, @@ -68,6 +71,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ retry, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, @@ -80,6 +84,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ retry, sdk, isStableBalanceActive, + isBalanceStale, lastReceivedPaymentId, hasMoreTransactions, loadingMore, From 48020c5eab0f70c1fc05ec9c5c7bd67f891b3e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:15:15 -0600 Subject: [PATCH 02/71] chore(spark): bump breez-sdk-spark-react-native to 0.13.2-dev3 --- .../breez-sdk-spark-react-native.js | 23 ++++++++++++++++++- package.json | 2 +- yarn.lock | 8 +++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/__mocks__/@breeztech/breez-sdk-spark-react-native.js b/__mocks__/@breeztech/breez-sdk-spark-react-native.js index 5e6d8a18fc..098026c0e9 100644 --- a/__mocks__/@breeztech/breez-sdk-spark-react-native.js +++ b/__mocks__/@breeztech/breez-sdk-spark-react-native.js @@ -70,7 +70,28 @@ 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 })), + }, + 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/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" From 6344ec42a5a3a234b73c0aad3443cbe9ab140ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:15:20 -0600 Subject: [PATCH 03/71] feat(self-custodial): extend amounts helpers with token base unit conversions --- app/utils/amounts.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/utils/amounts.ts b/app/utils/amounts.ts index 66172973dc..9fe570b7d9 100644 --- a/app/utils/amounts.ts +++ b/app/utils/amounts.ts @@ -9,15 +9,22 @@ 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 centsToTokenBaseUnits = ( cents: number, tokenDecimals: number, From 34bfc391dcb3f9684bb7b7ec3f6783c4246a6e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:15:25 -0600 Subject: [PATCH 04/71] feat(self-custodial): add SparkToken.DefaultDecimals constant --- app/self-custodial/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/self-custodial/config.ts b/app/self-custodial/config.ts index 4dab1ebfdf..f7c82cae61 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 From b14bfe3018aa99eb953b6e243d51a8fb31e4e9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:15:58 -0600 Subject: [PATCH 05/71] feat(payments): model ConvertParams, ConversionLimits, ConvertQuote and error codes --- app/types/payment.types.ts | 43 +++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/types/payment.types.ts b/app/types/payment.types.ts index 8f83452837..f0c97a4241 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 = { + formattedFee: string + amountAdjustment?: ConvertAmountAdjustment + execute: () => Promise +} + +export type GetConversionQuoteAdapter = ( + params: ConvertParams, +) => Promise From f8d3499605a1e557ad3121f0e953a332bc602863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:04 -0600 Subject: [PATCH 06/71] feat(self-custodial): add token-balance bridge helpers for USDB metadata --- app/self-custodial/bridge/token-balance.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 app/self-custodial/bridge/token-balance.ts diff --git a/app/self-custodial/bridge/token-balance.ts b/app/self-custodial/bridge/token-balance.ts new file mode 100644 index 0000000000..88e66ba9e4 --- /dev/null +++ b/app/self-custodial/bridge/token-balance.ts @@ -0,0 +1,22 @@ +import { + type BreezSdkInterface, + type GetInfoResponse, + type TokenBalance, +} from "@breeztech/breez-sdk-spark-react-native" + +import { SparkConfig, SparkToken } from "../config" + +const listTokenBalances = (info: GetInfoResponse): TokenBalance[] => + info.tokenBalances instanceof Map + ? [...info.tokenBalances.values()] + : Object.values(info.tokenBalances ?? {}) + +export const findUsdbToken = (info: GetInfoResponse): TokenBalance | undefined => + listTokenBalances(info).find( + (token) => token.tokenMetadata?.identifier === SparkConfig.tokenIdentifier, + ) + +export const fetchUsdbDecimals = async (sdk: BreezSdkInterface): Promise => { + const info = await sdk.getInfo({ ensureSynced: false }) + return findUsdbToken(info)?.tokenMetadata?.decimals ?? SparkToken.DefaultDecimals +} From c5fa99cf4e165d6303d919cc724b1baa0fbecf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:09 -0600 Subject: [PATCH 07/71] feat(self-custodial): add fetchConversionLimits and buildConversionType in limits bridge --- app/self-custodial/bridge/limits.ts | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 app/self-custodial/bridge/limits.ts diff --git a/app/self-custodial/bridge/limits.ts b/app/self-custodial/bridge/limits.ts new file mode 100644 index 0000000000..52f179ad2a --- /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 { tokenBaseUnitsToCents } from "@app/types/amounts" +import { ConvertDirection, type ConversionLimits } from "@app/types/payment.types" +import { toNumber } from "@app/utils/helper" + +import { SparkConfig } from "../config" + +import { fetchUsdbDecimals } from "./token-balance" + +export const buildConversionType = (direction: ConvertDirection) => + direction === ConvertDirection.BtcToUsd + ? new ConversionType.FromBitcoin() + : new ConversionType.ToBitcoin({ + fromTokenIdentifier: SparkConfig.tokenIdentifier, + }) + +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 tokenBaseUnitsToCents(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 ? SparkConfig.tokenIdentifier : undefined, + }) + + const tokenDecimals = tokenDecimalsHint ?? (await fetchUsdbDecimals(sdk)) + + return { + minFromAmount: toWalletUnit(response.minFromAmount, !isBtcToUsd, tokenDecimals), + minToAmount: toWalletUnit(response.minToAmount, isBtcToUsd, tokenDecimals), + } +} From a2fac8e8314c65510beae68567417b965200a392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:13 -0600 Subject: [PATCH 08/71] feat(self-custodial): add activateStableBalance and deactivateStableBalance bridge --- app/self-custodial/bridge/stable-balance.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/self-custodial/bridge/stable-balance.ts 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(), + }) From 32f93fbb8bcb463b09f9ef4a6b6b7e39e29f3401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:17 -0600 Subject: [PATCH 09/71] feat(self-custodial): implement BTC and USDB conversion adapter with fee preview --- app/self-custodial/bridge/convert.ts | 196 ++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 18 deletions(-) diff --git a/app/self-custodial/bridge/convert.ts b/app/self-custodial/bridge/convert.ts index 480975e7c3..4eb373d582 100644 --- a/app/self-custodial/bridge/convert.ts +++ b/app/self-custodial/bridge/convert.ts @@ -1,42 +1,202 @@ import { + AmountAdjustmentReason, PrepareSendPaymentRequest, + PrepareSendPaymentResponse, + ReceivePaymentMethod, + ReceivePaymentRequest, SendPaymentRequest, type BreezSdkInterface, } from "@breeztech/breez-sdk-spark-react-native" +import { centsToTokenBaseUnits } 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 { toNumber } from "@app/utils/helper" import { SparkConfig } from "../config" -const failed = (message: string): PaymentAdapterResult => ({ +import { buildConversionType, fetchConversionLimits } from "./limits" +import { fetchUsdbDecimals } from "./token-balance" + +const MIN_USD_FRACTION_DIGITS = 2 + +const formatUsdFromBaseUnits = (rawAmount: number, decimals: number): string => { + const divisor = 10 ** decimals + const whole = Math.floor(rawAmount / divisor) + const fractional = rawAmount % divisor + const padded = String(fractional).padStart(decimals, "0") + const trimmed = padded.replace(/0+$/, "") + const fractionalStr = + trimmed.length < MIN_USD_FRACTION_DIGITS + ? trimmed.padEnd(MIN_USD_FRACTION_DIGITS, "0") + : trimmed + return `$${whole}.${fractionalStr}` +} + +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 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 +} + +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 destinationAmount = isBtcToUsd + ? BigInt(centsToTokenBaseUnits(toAmount.amount, tokenDecimals)) + : BigInt(toAmount.amount) + + const paymentRequest = await createOwnSparkInvoice( + sdk, + destinationAmount, + isBtcToUsd ? SparkConfig.tokenIdentifier : undefined, + ) + + const prepared = await sdk.prepareSendPayment( + PrepareSendPaymentRequest.create({ + paymentRequest, + amount: destinationAmount, + tokenIdentifier: isBtcToUsd ? SparkConfig.tokenIdentifier : undefined, + conversionOptions: buildConversionOptions(direction), + }), + ) - const prepared = await sdk.prepareSendPayment( - PrepareSendPaymentRequest.create({ - paymentRequest: "", - amount: BigInt(amount.amount), - tokenIdentifier, - }), - ) + return { prepared, tokenDecimals } +} - await sdk.sendPayment(SendPaymentRequest.create({ prepareResponse: prepared })) +const executePrepared = async ( + sdk: BreezSdkInterface, + prepared: PrepareSendPaymentResponse, +): Promise => { + try { + await sdk.sendPayment(SendPaymentRequest.create({ prepareResponse: prepared })) + return { status: PaymentResultStatus.Success } + } catch (err) { + return failed(err instanceof Error ? err.message : `Conversion failed: ${err}`) + } +} - return { status: PaymentResultStatus.Success } +const toConvertQuote = ( + sdk: BreezSdkInterface, + { prepared, tokenDecimals }: PreparedConversion, +): ConvertQuote | null => { + const estimate = prepared.conversionEstimate + if (!estimate) return null + return { + formattedFee: formatUsdFromBaseUnits(toNumber(estimate.fee), tokenDecimals), + amountAdjustment: mapAmountAdjustment(estimate.amountAdjustment), + execute: () => executePrepared(sdk, prepared), + } +} + +const toFailedResult = (err: unknown): PaymentAdapterResult => { + if (err instanceof ConvertError) return failed(err.message, err.code) + if (err instanceof Error) return failed(err.message) + return failed(`Conversion failed: ${err}`) +} + +export const createGetConversionQuote = + (sdk: BreezSdkInterface): GetConversionQuoteAdapter => + async (params) => { + try { + const context = await prepareConversion(sdk, params) + return toConvertQuote(sdk, context) + } catch { + return null + } + } + +export const createConvert = + (sdk: BreezSdkInterface): ConvertAdapter => + async (params) => { + try { + const context = await prepareConversion(sdk, params) + return await executePrepared(sdk, context.prepared) } catch (err) { - return failed(err instanceof Error ? err.message : `Conversion failed: ${err}`) + return toFailedResult(err) } } -} From d0688217e66a62b3ad28e03515b76cd909e4397f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:21 -0600 Subject: [PATCH 10/71] chore(self-custodial): expose new bridge modules from barrel --- app/self-custodial/bridge/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/self-custodial/bridge/index.ts b/app/self-custodial/bridge/index.ts index bb8df054d9..8c80cbe39b 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 { createConvert, createGetConversionQuote } from "./convert" +export { fetchConversionLimits } from "./limits" export { parseSparkAddress } from "./parse" export type { ParsedSparkAddress } from "./parse" +export { findUsdbToken, fetchUsdbDecimals } from "./token-balance" From 06aa1dd1a461450ee61a8ffec3db8dad1d6a5662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:25 -0600 Subject: [PATCH 11/71] feat(self-custodial): surface Stable Balance state and refresh in SDK lifecycle --- .../providers/use-sdk-lifecycle.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/self-custodial/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 5b9072de0b..2783c501a7 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -33,6 +33,7 @@ type SdkLifecycleState = { loadingMore: boolean loadMore: () => Promise refreshWallets: () => Promise + refreshStableBalanceActive: () => Promise } const OFFLINE_EXEMPT_STATUSES: readonly ActiveWalletStatus[] = [ @@ -55,6 +56,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const refreshingRef = useRef(false) const pendingRefreshRef = useRef(false) const isBalanceStaleRef = useRef(false) + const rawTxOffsetRef = useRef(0) const llRef = useRef(LL) llRef.current = LL @@ -99,6 +101,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const snapshot = await getSelfCustodialWalletSnapshot(sdk) setWallets(snapshot.wallets) setHasMoreTransactions(snapshot.hasMore) + rawTxOffsetRef.current = snapshot.rawTransactionCount setStatus(ActiveWalletStatus.Ready) updateBalanceStale(detectBalanceStale(snapshot.wallets)) @@ -210,6 +213,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const CONNECTIVITY_POLL_MS = 10000 const interval = setInterval(() => { if (!sdkRef.current) return + if (AppState.currentState !== "active") return refreshWallets() }, CONNECTIVITY_POLL_MS) return () => clearInterval(interval) @@ -219,8 +223,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) { @@ -228,7 +232,17 @@ 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}`) + } + }, []) return { wallets, @@ -241,5 +255,6 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, } } From c84422119a390389bda88db67417158cc7df34aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:30 -0600 Subject: [PATCH 12/71] feat(self-custodial): propagate Stable Balance state via wallet provider --- app/self-custodial/providers/wallet-provider.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/self-custodial/providers/wallet-provider.tsx b/app/self-custodial/providers/wallet-provider.tsx index 4e0ecf70c3..d4971ceed6 100644 --- a/app/self-custodial/providers/wallet-provider.tsx +++ b/app/self-custodial/providers/wallet-provider.tsx @@ -20,6 +20,7 @@ type SelfCustodialWalletContextValue = ActiveWalletState & { loadingMore: boolean loadMore: () => Promise refreshWallets: () => Promise + refreshStableBalanceActive: () => Promise } const noop = async () => {} @@ -37,6 +38,7 @@ const defaultState: SelfCustodialWalletContextValue = { loadingMore: false, loadMore: noop, refreshWallets: noop, + refreshStableBalanceActive: noop, } const SelfCustodialWalletContext = @@ -57,6 +59,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, } = useSdkLifecycle(retryCount) const retry = useCallback(() => { @@ -77,6 +80,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, }), [ wallets, @@ -90,6 +94,7 @@ export const SelfCustodialWalletProvider: React.FC = ({ loadingMore, loadMore, refreshWallets, + refreshStableBalanceActive, ], ) From 7a141035d958233b315fc238209ddcef3dcadbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:35 -0600 Subject: [PATCH 13/71] feat(self-custodial): filter unknown tokens and track raw pagination offset in wallet snapshot --- .../providers/wallet-snapshot.ts | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/app/self-custodial/providers/wallet-snapshot.ts b/app/self-custodial/providers/wallet-snapshot.ts index 557bc207e2..99b64f099b 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,27 +12,17 @@ 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 { SparkConfig } from "../config" 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 => { @@ -43,6 +33,7 @@ const isKnownPayment = (payment: Payment): boolean => { type PaymentsPage = { transactions: NormalizedTransaction[] + rawCount: number hasMore: boolean } @@ -51,8 +42,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,6 +79,7 @@ const buildWallets = ( export type WalletSnapshot = { wallets: WalletState[] hasMore: boolean + rawTransactionCount: number } export const getSelfCustodialWalletSnapshot = async ( @@ -97,11 +93,12 @@ export const getSelfCustodialWalletSnapshot = async ( { identityPubkey: info.identityPubkey, btcBalance: Number(info.balanceSats), - stableBalance: getStableBalance(info.tokenBalances), + stableBalance: getStableBalance(info), }, page.transactions, ), hasMore: page.hasMore, + rawTransactionCount: page.rawCount, } } @@ -117,5 +114,5 @@ export const appendTransactions = ( export const loadMoreTransactions = async ( sdk: BreezSdkInterface, - currentCount: number, -): Promise => fetchAndMapPayments(sdk, currentCount) + rawOffset: number, +): Promise => fetchAndMapPayments(sdk, rawOffset) From 7711a5eae07d98a4edc5d812a9d7b69b276df056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:38 -0600 Subject: [PATCH 14/71] feat(self-custodial): map token payments and conversion metadata in transaction mapper --- .../mappers/transaction-mapper.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/self-custodial/mappers/transaction-mapper.ts b/app/self-custodial/mappers/transaction-mapper.ts index 85f00d131a..f2e842d255 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) From 794b11f8e95f166d98bc49cef162f133c62eb099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:16:56 -0600 Subject: [PATCH 15/71] feat(self-custodial): convert fee to settlement currency in transaction fragment --- .../mappers/to-transaction-fragment.spec.ts | 23 ++- .../mappers/to-transaction-fragment.ts | 164 +++++++++++------- 2 files changed, 111 insertions(+), 76 deletions(-) diff --git a/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts b/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts index 25b4958066..b7f06b1c44 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", ) 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, From 1d714367aef24e988676de2e06defdf7afdc51be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:02 -0600 Subject: [PATCH 16/71] feat(payments): wire getConversionQuote in usePayments --- app/hooks/use-payments.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/hooks/use-payments.ts b/app/hooks/use-payments.ts index a539271539..04552f5e3d 100644 --- a/app/hooks/use-payments.ts +++ b/app/hooks/use-payments.ts @@ -15,6 +15,7 @@ import { } from "@app/self-custodial/adapters/deposit-adapter" import { createConvert, + createGetConversionQuote, createReceiveLightning, createReceiveOnchain, } from "@app/self-custodial/bridge" @@ -22,6 +23,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 +42,7 @@ type PaymentsResult = { listPendingDeposits?: ListPendingDepositsAdapter claimDeposit?: ClaimDepositAdapter convert?: ConvertAdapter + getConversionQuote?: GetConversionQuoteAdapter accountType?: AccountType } @@ -58,6 +61,7 @@ export const usePayments = (): PaymentsResult => { listPendingDeposits: createListPendingDeposits(sdk), claimDeposit: createClaimDeposit(sdk), convert: createConvert(sdk), + getConversionQuote: createGetConversionQuote(sdk), accountType, } } From 082fc9eb965cafce34a161f06c7b212f8c059160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:09 -0600 Subject: [PATCH 17/71] feat(self-custodial): add useNonCustodialConversionLimits hook --- app/self-custodial/hooks/index.ts | 1 + .../use-non-custodial-conversion-limits.ts | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 app/self-custodial/hooks/use-non-custodial-conversion-limits.ts 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 } +} From e19646509295d73b7e43d9864349e2438b250a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:12 -0600 Subject: [PATCH 18/71] feat(home): add useBalanceMode hook for BTC and USD display toggle --- app/hooks/use-balance-mode.ts | 62 +++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 app/hooks/use-balance-mode.ts 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 } +} From e224d9c8eafd59f2919a102d2a30ccf64b6bc660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:17 -0600 Subject: [PATCH 19/71] feat(stable-balance): add useStableBalanceFirstTime acknowledgment hook --- app/hooks/use-stable-balance-first-time.ts | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/hooks/use-stable-balance-first-time.ts 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, + } +} From 3aa1cb76c6880f7445a5204c5a7ba550ee489029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:24 -0600 Subject: [PATCH 20/71] feat(i18n): add Stable Balance and conversion copy across 28 locales --- app/i18n/en/index.ts | 35 +++++ app/i18n/i18n-types.ts | 210 +++++++++++++++++++++++++ app/i18n/raw-i18n/source/en.json | 33 +++- app/i18n/raw-i18n/translations/af.json | 33 +++- app/i18n/raw-i18n/translations/ar.json | 33 +++- app/i18n/raw-i18n/translations/ca.json | 33 +++- app/i18n/raw-i18n/translations/cs.json | 33 +++- app/i18n/raw-i18n/translations/da.json | 33 +++- app/i18n/raw-i18n/translations/de.json | 33 +++- app/i18n/raw-i18n/translations/el.json | 33 +++- app/i18n/raw-i18n/translations/es.json | 33 +++- app/i18n/raw-i18n/translations/fr.json | 33 +++- app/i18n/raw-i18n/translations/hr.json | 33 +++- app/i18n/raw-i18n/translations/hu.json | 33 +++- app/i18n/raw-i18n/translations/hy.json | 33 +++- app/i18n/raw-i18n/translations/id.json | 33 +++- app/i18n/raw-i18n/translations/it.json | 33 +++- app/i18n/raw-i18n/translations/ja.json | 33 +++- app/i18n/raw-i18n/translations/lg.json | 33 +++- app/i18n/raw-i18n/translations/ms.json | 33 +++- app/i18n/raw-i18n/translations/nl.json | 33 +++- app/i18n/raw-i18n/translations/pt.json | 33 +++- app/i18n/raw-i18n/translations/qu.json | 33 +++- app/i18n/raw-i18n/translations/ro.json | 33 +++- app/i18n/raw-i18n/translations/sk.json | 33 +++- app/i18n/raw-i18n/translations/sr.json | 33 +++- app/i18n/raw-i18n/translations/sw.json | 33 +++- app/i18n/raw-i18n/translations/th.json | 33 +++- app/i18n/raw-i18n/translations/tr.json | 33 +++- app/i18n/raw-i18n/translations/vi.json | 33 +++- app/i18n/raw-i18n/translations/xh.json | 33 +++- 31 files changed, 1144 insertions(+), 58 deletions(-) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index e6994e93a4..0b7c3dbb12 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", @@ -3763,6 +3767,37 @@ 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} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + 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}", }, SparkWalletCreationScreen: { creating: "Creating your wallet...", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 66121d75e8..d522d1e94b 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: { /** @@ -11947,6 +11963,96 @@ 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​}​ ​U​S​D​.​ ​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'> + 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'> } SparkWalletCreationScreen: { /** @@ -12220,6 +12326,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: { /** @@ -23757,6 +23879,94 @@ 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} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance. + */ + deactivateWarningBody: (arg: { amount: string }) => 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 } SparkWalletCreationScreen: { /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 312ea39afa..08b4af2ce8 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" @@ -3604,7 +3608,32 @@ }, "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} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + "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}" }, "SparkWalletCreationScreen": { "creating": "Creating your wallet...", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index 0e2b375257..9531176459 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,32 @@ }, "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} USD. Skakel eers om na BTC, anders word jou USD-saldo versteek totdat jy Stabiele Balans heraktiveer.", + "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}" }, "BackendFeatureGate": { "title": "Funksie nie beskikbaar nie", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index f54df9141b..fa5451cb22 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,32 @@ }, "StableBalance": { "title": "الرصيد المستقر", - "description": "احتفظ برصيد مقوّم بالدولار مدعوم بـ USDB على Spark." + "description": "احتفظ برصيد مقوّم بالدولار مدعوم بـ USDB على Spark.", + "balanceLabelBtc": "الرصيد · ساتس", + "balanceLabelUsd": "الرصيد · دولار", + "settingsRowTitle": "الرصيد المستقر", + "settingsTitle": "الرصيد المستقر", + "settingsDescription": "احتفظ بجزء من محفظتك بالدولار الأمريكي. قم بالتحويل بين BTC والدولار الأمريكي يدويًا في أي وقت باستخدام إجراء التحويل.", + "activationLabel": "مفعل", + "activeHint": "محفظتك تحتفظ بالدولار عبر USDB.", + "inactiveHint": "محفظتك تحتفظ بالبيتكوين فقط.", + "deactivateWarningBody": "لا يزال لديك {amount:string} دولار. قم بالتحويل إلى بيتكوين أولاً، وإلا سيتم إخفاء رصيد الدولار حتى تُعيد تفعيل الرصيد المستقر.", + "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}" }, "BackendFeatureGate": { "title": "الميزة غير متاحة", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index 47e361561d..7acab330cd 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,32 @@ }, "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} USD. Converteix a BTC primer, o el teu saldo en USD quedarà ocult fins que reactivis Saldo Estable.", + "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}" }, "BackendFeatureGate": { "title": "Funció no disponible", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index b498b3c953..562b352f4d 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,32 @@ }, "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} USD. Nejdřív převeď na BTC, jinak bude zůstatek v USD skrytý, dokud Stabilní zůstatek znovu neaktivuješ.", + "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}" }, "BackendFeatureGate": { "title": "Funkce nedostupná", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index 7b41ed3557..cd9dfd6cfb 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,32 @@ }, "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} USD. Konverter til BTC først, ellers skjules din USD-saldo, indtil du genaktiverer Stabil Saldo.", + "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}" }, "BackendFeatureGate": { "title": "Funktion ikke tilgængelig", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index cb1bd25201..c06f327854 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,32 @@ }, "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} USD. Wandle zuerst in BTC um, sonst wird dein USD-Guthaben ausgeblendet, bis du Stabiler Saldo wieder aktivierst.", + "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}" }, "BackendFeatureGate": { "title": "Funktion nicht verfügbar", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index 56ab7342e7..1d8840944e 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,32 @@ }, "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} USD. Μετατρέψτε πρώτα σε BTC, αλλιώς το υπόλοιπο USD θα είναι κρυφό μέχρι να ενεργοποιήσετε ξανά το Σταθερό Υπόλοιπο.", + "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}" }, "BackendFeatureGate": { "title": "Λειτουργία μη διαθέσιμη", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 8505bebd39..284e3c07c3 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,32 @@ }, "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} USD. Conviértelos a BTC primero, o tu saldo en USD quedará oculto hasta que reactives Saldo Estable.", + "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}" }, "BackendFeatureGate": { "title": "Función no disponible", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index c482c4b0e8..352f65edce 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,32 @@ }, "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} USD. Convertissez-les d'abord en BTC, sinon votre solde USD sera masqué jusqu'à la réactivation du Solde Stable.", + "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}" }, "BackendFeatureGate": { "title": "Fonctionnalité indisponible", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index 2b45672c23..48750e1433 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,32 @@ }, "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} USD. Pretvori u BTC prvo, inače će tvoje USD stanje biti skriveno dok ponovno ne aktiviraš Stabilno stanje.", + "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}" }, "BackendFeatureGate": { "title": "Značajka nedostupna", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index e284843a19..5d261e754b 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,32 @@ }, "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} USD-d. Először konvertáld BTC-re, különben az USD egyenleged rejtett marad, amíg újra nem aktiválod a Stabil egyenleget.", + "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}" }, "BackendFeatureGate": { "title": "Funkció nem elérhető", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index 79272b45aa..59ff215063 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,32 @@ }, "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} USD։ Նախ փոխարկեք BTC, այլապես ձեր USD մնացորդը թաքցվելու է, մինչև նորից չակտիվացնեք Կայուն մնացորդը։", + "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}" }, "BackendFeatureGate": { "title": "Հնարավորությունը հասանելի չէ", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 0a61ef1d9a..4cc9f5837a 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,32 @@ }, "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} USD. Konversikan ke BTC terlebih dahulu, atau saldo USD Anda akan disembunyikan hingga Anda mengaktifkan kembali Saldo Stabil.", + "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}" }, "BackendFeatureGate": { "title": "Fitur tidak tersedia", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index bc6424455b..d6ef59f9d2 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,32 @@ }, "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} USD. Converti prima in BTC, altrimenti il tuo saldo in USD sarà nascosto finché non riattiverai Saldo Stabile.", + "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}" }, "BackendFeatureGate": { "title": "Funzione non disponibile", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index a81b54bce7..91cb4956db 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,32 @@ }, "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} USDあります。先にBTCに変換してください。そうしないと、安定残高を再度有効にするまでUSD残高は非表示になります。", + "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}" }, "BackendFeatureGate": { "title": "機能が利用できません", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index e0574abd36..def09c867f 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,32 @@ }, "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} USD. Sooka ofuule BTC, oba balansi yo eya USD eyiinzira nga tokyalongedde Balansi Enywevu.", + "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}" }, "BackendFeatureGate": { "title": "Ekikola tekiriwo", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index c18e0b6cf5..859bc0dd47 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,32 @@ }, "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} USD. Tukarkan ke BTC terlebih dahulu, atau baki USD anda akan disembunyikan sehingga anda mengaktifkan semula Baki Stabil.", + "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}" }, "BackendFeatureGate": { "title": "Ciri tidak tersedia", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index d8b20d3baa..14e867613b 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,32 @@ }, "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} USD. Zet eerst om naar BTC, anders wordt je USD-saldo verborgen totdat je Stabiel Saldo weer inschakelt.", + "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}" }, "BackendFeatureGate": { "title": "Functie niet beschikbaar", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 314aab02b4..6e0ca55d03 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,32 @@ }, "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} USD. Converte primeiro para BTC, ou o teu saldo em USD ficará oculto até reativares o Saldo Estável.", + "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}" }, "BackendFeatureGate": { "title": "Recurso indisponível", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index c451628b14..b0803b0daf 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,32 @@ }, "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} USD. Ñawpaqta BTC-man tikray, manaña chayqa USD saldoyki pakakunqa Saldo Takyasqa kutiy kawsachisqa kanan kama.", + "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}" }, "BackendFeatureGate": { "title": "Kay mana kanchu", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index 93bd7d8189..5a24687958 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,32 @@ }, "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} USD. Convertește mai întâi în BTC, altfel soldul în USD va fi ascuns până reactivezi Sold Stabil.", + "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}" }, "BackendFeatureGate": { "title": "Funcție indisponibilă", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 1c24eb2fc3..55eafb9899 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,32 @@ }, "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} USD. Najprv previedť na BTC, inak bude USD zostatok skrytý, kým znovu neaktivuješ Stabilný zostatok.", + "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}" }, "BackendFeatureGate": { "title": "Funkcia nedostupná", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index 95d4b99cfd..e141a89aa8 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,32 @@ }, "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} USD. Прво пребаци у BTC, иначе ће твој USD биланс бити сакривен док поново не активираш Стабилно стање.", + "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}" }, "BackendFeatureGate": { "title": "Функција недоступна", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index 74efbf27cb..d19ba4def2 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,32 @@ }, "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} USD. Badilisha kuwa BTC kwanza, ama salio lako la USD litafichwa hadi uwashe tena Salio Thabiti.", + "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}" }, "BackendFeatureGate": { "title": "Kipengele hakipatikani", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 243e1062fa..077c651c0f 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,32 @@ }, "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} USD โปรดแปลงเป็น BTC ก่อน มิฉะนั้นยอด USD จะถูกซ่อนไว้จนกว่าคุณจะเปิดยอดเงินคงที่อีกครั้ง", + "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}" }, "BackendFeatureGate": { "title": "ฟีเจอร์ไม่พร้อมใช้งาน", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index 617ce5b313..2eb1aedf6d 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,32 @@ }, "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} USD'niz var. Önce BTC'ye dönüştürün; aksi halde Stabil Bakiye'yi tekrar etkinleştirene kadar USD bakiyeniz gizlenir.", + "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}" }, "BackendFeatureGate": { "title": "Özellik kullanılamıyor", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index 655010ff31..9a116bae43 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,32 @@ }, "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} USD. 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 Số Dư Ổn Định.", + "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}" }, "BackendFeatureGate": { "title": "Tính năng không khả dụng", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 685dab7eba..6cef929cfb 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,32 @@ }, "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} USD. Yitshintshele kwi-BTC kuqala, okanye ibhalansi yakho yeUSD iza kufihlakala de uyivuselele iBhalansi Ezinzileyo.", + "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}" }, "BackendFeatureGate": { "title": "Isici asifumaneki", From e03989fb8e4f4509d1a907323a7fce0619451671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:33 -0600 Subject: [PATCH 21/71] feat(balance-header): add Stable Balance mode toggle support --- .../balance-header/balance-header.tsx | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/app/components/balance-header/balance-header.tsx b/app/components/balance-header/balance-header.tsx index 1a169f17cf..2e6c259000 100644 --- a/app/components/balance-header/balance-header.tsx +++ b/app/components/balance-header/balance-header.tsx @@ -1,11 +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 = () => { @@ -32,22 +34,33 @@ export type StatusBadge = { type Props = { loading: boolean formattedBalance?: string + showStableBalanceToggle?: boolean + mode?: BalanceMode + onModeChange?: () => void statusBadge?: StatusBadge } 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 ? ( @@ -87,6 +100,16 @@ export const BalanceHeader: React.FC = ({ )} + {showStableBalanceToggle && onModeChange ? ( + + {modeLabel} + + ) : null} ) } @@ -116,6 +139,17 @@ 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, From 5f49671adaa89cf7bc3a340078b425dea3297a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:37 -0600 Subject: [PATCH 22/71] feat(stable-balance): add first-time explanation modal --- .../stable-balance-first-time-modal/index.ts | 1 + .../stable-balance-first-time-modal.tsx | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 app/components/stable-balance-first-time-modal/index.ts create mode 100644 app/components/stable-balance-first-time-modal/stable-balance-first-time-modal.tsx 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, + }, +})) From 5782aa34fbe36ddb9f8a2a88ec7a8cca51715ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:42 -0600 Subject: [PATCH 23/71] feat(convert): add ConversionFeeRow presentational component --- .../conversion-flow/conversion-fee-row.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 app/screens/conversion-flow/conversion-fee-row.tsx 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, + }, +})) From 6024b3dc7b44cd1f3d585131f977af1f9342557e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:47 -0600 Subject: [PATCH 24/71] feat(convert): add useConversionQuote common hook --- .../hooks/use-conversion-quote.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 app/screens/conversion-flow/hooks/use-conversion-quote.ts 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..fda4e73764 --- /dev/null +++ b/app/screens/conversion-flow/hooks/use-conversion-quote.ts @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from "react" + +import crashlytics from "@react-native-firebase/crashlytics" + +import { usePayments } from "@app/hooks/use-payments" +import { useI18nContext } from "@app/i18n/i18n-react" +import { + ConvertAmountAdjustment, + type ConvertParams, + type ConvertQuote, +} from "@app/types/payment.types" + +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 [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((err) => { + if (cancelled) return + if (err instanceof Error) crashlytics().recordError(err) + setState({ status: QuoteStatus.Error, quote: null }) + }) + return () => { + cancelled = true + } + }, [getConversionQuote, quoteParams]) + + const feeText = + state.status === QuoteStatus.Ready && state.quote ? state.quote.formattedFee : "" + + const adjustmentText = useMemo(() => { + if (state.status !== QuoteStatus.Ready || !state.quote) return null + const adjustment = state.quote.amountAdjustment + if (adjustment === ConvertAmountAdjustment.FlooredToMin) { + return LL.ConversionConfirmationScreen.amountFloored() + } + if (adjustment === ConvertAmountAdjustment.IncreasedToAvoidDust) { + return LL.ConversionConfirmationScreen.amountDustBumped() + } + return null + }, [state, LL]) + + return { + isQuoting: state.status === QuoteStatus.Loading, + hasQuoteError: state.status === QuoteStatus.Error, + quote: state.quote, + feeText, + adjustmentText, + } +} From 6b9b94340f01e14943c0f2146f30be36ffcce4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:51 -0600 Subject: [PATCH 25/71] feat(convert): add useNonCustodialConversion flow hook --- app/screens/conversion-flow/hooks/index.ts | 2 + .../hooks/use-non-custodial-conversion.ts | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts 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-non-custodial-conversion.ts b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts new file mode 100644 index 0000000000..0c91b7b9d7 --- /dev/null +++ b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts @@ -0,0 +1,82 @@ +import { useCallback, useMemo } 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, +} 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 quoteParams = 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 { isQuoting, hasQuoteError, quote, feeText, adjustmentText } = + useConversionQuote(quoteParams) + + 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, + } +} From be302ad2623fbd78dd450d7b612e51bcf67cf4eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:17:56 -0600 Subject: [PATCH 26/71] feat(convert): support self-custodial conversion in details and confirmation screens --- .../conversion-confirmation-screen.tsx | 113 +++++++++++++++--- .../conversion-details-screen.tsx | 97 ++++++++++++--- .../use-convert-money-details.ts | 14 +-- 3 files changed, 185 insertions(+), 39 deletions(-) diff --git a/app/screens/conversion-flow/conversion-confirmation-screen.tsx b/app/screens/conversion-flow/conversion-confirmation-screen.tsx index e01052399e..d8102a4b9b 100644 --- a/app/screens/conversion-flow/conversion-confirmation-screen.tsx +++ b/app/screens/conversion-flow/conversion-confirmation-screen.tsx @@ -24,14 +24,19 @@ import { useIsAuthed } from "@app/graphql/is-authed-context" import { getErrorMessages } from "@app/graphql/utils" import { getBtcWallet, getUsdWallet } from "@app/graphql/wallets-utils" import { SATS_PER_BTC, usePriceConversion } from "@app/hooks" +import { useActiveWallet } from "@app/hooks/use-active-wallet" import { useDisplayCurrency } from "@app/hooks/use-display-currency" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { toBtcMoneyAmount } from "@app/types/amounts" +import { PaymentResultStatus } from "@app/types/payment.types" import { WalletDescriptor } from "@app/types/wallets" import { logConversionAttempt, logConversionResult } from "@app/utils/analytics" import { toastShow } from "@app/utils/toast" +import { ConversionFeeRow } from "./conversion-fee-row" +import { useNonCustodialConversion } from "./hooks" + import { Screen } from "@app/components/screen" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { CurrencyPill, useEqualPillWidth } from "@app/components/atomic/currency-pill" @@ -55,23 +60,40 @@ export const ConversionConfirmationScreen: React.FC = ({ 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..22cbd65348 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,15 @@ export const ConversionDetailsScreen = () => { : undefined, ) + const convertDirection = + isSelfCustodial && fromWallet + ? convertDirectionFromCurrency(fromWallet.walletCurrency) + : undefined + const { limits: scConversionLimits } = useNonCustodialConversionLimits(convertDirection) + const scMinFromAmount = isSelfCustodial + ? scConversionLimits?.minFromAmount ?? null + : null + const [focusedInputValues, setFocusedInputValues] = useState(null) const [initialAmount, setInitialAmount] = useState>() @@ -305,7 +354,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 +474,31 @@ export const ConversionDetailsScreen = () => { ? null : moneyAmountToDisplayCurrencyString({ moneyAmount: toWalletBalance }) - let amountFieldError: string | undefined = undefined + const exceedsBalance = lessThan({ + value: fromWalletBalance, + lessThan: settlementSendAmount, + }) - if ( - lessThan({ - value: fromWalletBalance, - lessThan: settlementSendAmount, - }) - ) { - amountFieldError = LL.SendBitcoinScreen.amountExceed({ - balance: fromWalletBalanceFormatted, - }) - } + const belowMinimum = + isSelfCustodial && + scMinFromAmount !== null && + settlementSendAmount.amount > 0 && + settlementSendAmount.amount < scMinFromAmount + + const amountFieldError: string | undefined = (() => { + if (exceedsBalance) { + return LL.SendBitcoinScreen.amountExceed({ balance: fromWalletBalanceFormatted }) + } + if (belowMinimum && scMinFromAmount !== null) { + const minMoneyAmount = toWalletMoneyAmount( + scMinFromAmount, + fromWallet.walletCurrency, + ) + return LL.StableBalance.minimumConversion({ + amount: formatMoneyAmount({ moneyAmount: minMoneyAmount }), + }) + } + })() const hasError = Boolean(amountFieldError) @@ -462,6 +524,10 @@ export const ConversionDetailsScreen = () => { return ( + { uiLocked || toggleInitiated.current || isTyping || - Boolean(loadingPercent) + Boolean(loadingPercent) || + belowMinimum } onPress={moveToNextScreen} testID="next-button" 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 { From 324f0f0b8e3c5b7291485c85cd0fe22c9a25f93e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:18:02 -0600 Subject: [PATCH 27/71] feat(home): wire balance mode toggle and Stable Balance display --- .../adapters/payment-adapter.spec.ts | 3 +- .../adapters/payment-adapter.spec.ts | 38 +------------------ .../providers/wallet-provider.spec.tsx | 5 +++ .../providers/wallet-snapshot.spec.ts | 2 +- app/screens/home-screen/home-screen.tsx | 24 +++++++++++- app/self-custodial/bridge/convert.ts | 2 +- app/self-custodial/bridge/limits.ts | 2 +- 7 files changed, 33 insertions(+), 43 deletions(-) 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__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index a1c3f9b642..941ac6e0f8 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 }, @@ -361,36 +357,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/providers/wallet-provider.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index 4cdfa166aa..777cad6243 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -847,6 +847,11 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () 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: [], diff --git a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index ffafd820d3..c53d26b6fc 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -21,7 +21,7 @@ const createMockSdk = (overrides = {}) => ({ tokenBalances: { token1: { balance: 150000, - tokenMetadata: { ticker: "USDB", decimals: 6 }, + tokenMetadata: { identifier: "test-token-id", ticker: "USDB", decimals: 6 }, }, }, ...overrides, diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 742c0a2ed7..1118550d0d 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -22,6 +22,9 @@ import { 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" @@ -33,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" @@ -178,8 +181,11 @@ export const HomeScreen: React.FC = () => { const { isSelfCustodial } = activeWallet const { refreshWallets: refreshSelfCustodialWallets, + isStableBalanceActive, isBalanceStale: selfCustodialIsBalanceStale, } = useSelfCustodialWallet() + const { stableBalanceEnabled } = useFeatureFlags() + const { mode: balanceMode, toggleMode: toggleBalanceMode } = useBalanceMode() const { shouldShowBanner, shouldShowModal, dismissBanner } = useBackupNudgeState() const { seen: trustModelSeen, @@ -262,7 +268,18 @@ 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 @@ -554,6 +571,9 @@ export const HomeScreen: React.FC = () => { diff --git a/app/self-custodial/bridge/convert.ts b/app/self-custodial/bridge/convert.ts index 4eb373d582..bd42a3e1ee 100644 --- a/app/self-custodial/bridge/convert.ts +++ b/app/self-custodial/bridge/convert.ts @@ -8,7 +8,6 @@ import { type BreezSdkInterface, } from "@breeztech/breez-sdk-spark-react-native" -import { centsToTokenBaseUnits } from "@app/types/amounts" import { ConvertAmountAdjustment, ConvertDirection, @@ -20,6 +19,7 @@ import { type GetConversionQuoteAdapter, type PaymentAdapterResult, } from "@app/types/payment.types" +import { centsToTokenBaseUnits } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" import { SparkConfig } from "../config" diff --git a/app/self-custodial/bridge/limits.ts b/app/self-custodial/bridge/limits.ts index 52f179ad2a..578ab528df 100644 --- a/app/self-custodial/bridge/limits.ts +++ b/app/self-custodial/bridge/limits.ts @@ -3,8 +3,8 @@ import { type BreezSdkInterface, } from "@breeztech/breez-sdk-spark-react-native" -import { tokenBaseUnitsToCents } from "@app/types/amounts" import { ConvertDirection, type ConversionLimits } from "@app/types/payment.types" +import { tokenBaseUnitsToCents } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" import { SparkConfig } from "../config" From e25b48c57c0374d7842d0b37154173d84357361e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:18:08 -0600 Subject: [PATCH 28/71] feat(stable-balance): add Stable Balance settings screen with toggle confirm modal --- .../hooks/index.ts | 2 + .../hooks/use-stable-balance-toggle-quote.ts | 50 +++++ .../stable-balance-settings-screen/index.ts | 1 + .../stable-balance-confirm-modal.tsx | 101 +++++++++ .../stable-balance-settings-screen.tsx | 203 ++++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 app/screens/stable-balance-settings-screen/hooks/index.ts create mode 100644 app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle-quote.ts create mode 100644 app/screens/stable-balance-settings-screen/index.ts create mode 100644 app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx create mode 100644 app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx 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..efaf95952a --- /dev/null +++ b/app/screens/stable-balance-settings-screen/hooks/index.ts @@ -0,0 +1,2 @@ +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/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..ae8427feb0 --- /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} + 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..43db667fcf --- /dev/null +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -0,0 +1,203 @@ +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 { useI18nContext } from "@app/i18n/i18n-react" +import { + activateStableBalance, + deactivateStableBalance, +} from "@app/self-custodial/bridge" +import { SparkToken } from "@app/self-custodial/config" +import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" +import { WalletCurrency } from "@app/graphql/generated" +import { testProps } from "@app/utils/testProps" + +import { StableBalanceConfirmModal } from "./stable-balance-confirm-modal" +import { useStableBalanceToggleQuote } from "./hooks" + +const ToggleDirection = { + Activate: "activate", + Deactivate: "deactivate", +} as const +type ToggleDirection = (typeof ToggleDirection)[keyof typeof ToggleDirection] + +const USD_CENTS_PER_DOLLAR = 100 +const USD_FRACTION_DIGITS = 2 + +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 { + sdk, + isStableBalanceActive, + wallets, + refreshWallets, + refreshStableBalanceActive, + } = useSelfCustodialWallet() + const [busy, setBusy] = useState(false) + const [pendingValue, setPendingValue] = useState(null) + const [switchKey, setSwitchKey] = useState(0) + const [pendingDirection, setPendingDirection] = useState(null) + + const resyncSwitch = () => setSwitchKey((k) => k + 1) + + 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 apply = 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() + } finally { + setBusy(false) + setPendingValue(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 + setPendingValue(activate) + setPendingDirection(null) + await apply(activate) + } + + const displayValue = pendingValue ?? isStableBalanceActive + 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, + }, +})) From 63f3ef92929046813c1f43b4ec0cc4401e1dc139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:18:50 -0600 Subject: [PATCH 29/71] feat(settings): add Stable Balance entry row --- .../settings-screen/settings-screen.tsx | 2 ++ .../settings/stable-balance.tsx | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/screens/settings-screen/settings/stable-balance.tsx diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index 3c9e161136..7af70c42ec 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -40,6 +40,7 @@ import { TotpSetting } from "./totp" import { AccountStaticQR } from "./settings/account-static-qr" import { MoveToNonCustodialSetting } from "./settings/account-move-to-noncustodial" import { SwitchAccountSetting } from "./settings/multi-account" +import { StableBalanceSetting } from "./settings/stable-balance" import { ViewBackupPhraseSetting } from "./settings/view-backup-phrase" // All queries in settings have to be set here so that the server is not hit with @@ -103,6 +104,7 @@ export const SettingsScreen: 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")} + /> + ) +} From 84c0777ac063ae2e7911e81531596186386223a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:19:14 -0600 Subject: [PATCH 30/71] feat(navigation): register stableBalanceSettings route --- app/navigation/root-navigator.tsx | 6 ++++++ app/navigation/stack-param-lists.ts | 1 + 2 files changed, 7 insertions(+) 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: "" }} /> + Date: Tue, 21 Apr 2026 23:19:42 -0600 Subject: [PATCH 31/71] test(hooks): cover getConversionQuote wiring in usePayments spec --- __tests__/hooks/use-payments.spec.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/__tests__/hooks/use-payments.spec.ts b/__tests__/hooks/use-payments.spec.ts index 5246122034..cd749e5ede 100644 --- a/__tests__/hooks/use-payments.spec.ts +++ b/__tests__/hooks/use-payments.spec.ts @@ -30,6 +30,7 @@ jest.mock("@app/self-custodial/bridge", () => ({ claimDeposit: jest.fn(), }), createConvert: jest.fn().mockReturnValue(jest.fn()), + createGetConversionQuote: jest.fn().mockReturnValue(jest.fn()), })) jest.mock("@app/custodial/adapters/payment-adapter", () => ({ @@ -144,4 +145,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() + }) }) From a1a188f01e11d0ecb13b70b3e7b409126a8fc310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:19:46 -0600 Subject: [PATCH 32/71] test(convert): update conversion-details spec for self-custodial wiring --- .../conversion-details-screen.spec.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/__tests__/screens/conversion-details-screen.spec.tsx b/__tests__/screens/conversion-details-screen.spec.tsx index e761e0e009..7b3a3e51f8 100644 --- a/__tests__/screens/conversion-details-screen.spec.tsx +++ b/__tests__/screens/conversion-details-screen.spec.tsx @@ -39,6 +39,42 @@ jest.mock("@react-navigation/native", () => ({ }), })) +jest.mock("@app/hooks/use-active-wallet", () => ({ + useActiveWallet: () => ({ + isSelfCustodial: false, + isReady: false, + needsBackendAuth: true, + wallets: [], + status: "Unavailable", + accountType: "Custodial", + }), +})) + +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 From 13abfcee9d45265959362c1084ac24f88a9960a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:19:50 -0600 Subject: [PATCH 33/71] test(home): cover Stable Balance toggle rendering in home spec --- __tests__/screens/home.spec.tsx | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) 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) + }) + }) }) From 070a9eb5302693299202518800abffbe48331be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:19:54 -0600 Subject: [PATCH 34/71] test(self-custodial): update payment-adapter spec for new SDK mocks --- .../adapters/payment-adapter.spec.ts | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/__tests__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index 941ac6e0f8..dcf81e3071 100644 --- a/__tests__/self-custodial/adapters/payment-adapter.spec.ts +++ b/__tests__/self-custodial/adapters/payment-adapter.spec.ts @@ -3,7 +3,11 @@ import { createSendPayment, createGetFee, } from "@app/self-custodial/adapters/payment-adapter" -import { createReceiveLightning, createReceiveOnchain } from "@app/self-custodial/bridge" +import { + createReceiveLightning, + createReceiveOnchain, + createConvert, +} from "@app/self-custodial/bridge" jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ BitcoinNetwork: { Bitcoin: 0, Regtest: 4 }, @@ -36,6 +40,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, @@ -46,16 +59,27 @@ 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: { tokenIdentifier: "test-token-id", maxSlippageBps: 50 }, + 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", () => { @@ -357,4 +381,38 @@ 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({ + fromAmount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, + toAmount: { amount: 100, currency: "USD", currencyCode: "USD" }, + 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({ + fromAmount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, + toAmount: { amount: 100, currency: "USD", currencyCode: "USD" }, + direction: "btc_to_usd", + }) + + expect(result.status).toBe("failed") + }) + }) }) From 24ec7417decd5765ed40a2169ca5e71274618ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:19:59 -0600 Subject: [PATCH 35/71] test(self-custodial): cover refreshStableBalanceActive and AppState poll gate in wallet-provider spec --- .../providers/wallet-provider.spec.tsx | 158 +++++++++++++++++- 1 file changed, 152 insertions(+), 6 deletions(-) diff --git a/__tests__/self-custodial/providers/wallet-provider.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index 777cad6243..612ab760a2 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -39,6 +39,7 @@ 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, @@ -52,10 +53,7 @@ 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", () => ({ @@ -117,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") @@ -482,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 }) @@ -566,6 +567,10 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () 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") @@ -863,6 +868,9 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () ).getOnlineState getOnlineStateMock.mockResolvedValue("online") + const prevAppState = AppState.currentState + AppState.currentState = "active" + mockGetMnemonic.mockResolvedValue("word1 word2 word3") mockInitSdk.mockResolvedValue({}) @@ -889,6 +897,48 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () }) 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() }) @@ -978,4 +1028,100 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () 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) + }) + }) }) From 37759e49ae8b91895033c1663d1facecaeb17a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:06 -0600 Subject: [PATCH 36/71] test(self-custodial): update wallet-snapshot spec for token filter and raw pagination --- __tests__/self-custodial/providers/wallet-snapshot.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index c53d26b6fc..9eedab8fae 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -21,7 +21,11 @@ const createMockSdk = (overrides = {}) => ({ tokenBalances: { token1: { balance: 150000, - tokenMetadata: { identifier: "test-token-id", ticker: "USDB", decimals: 6 }, + tokenMetadata: { + identifier: "test-token-id", + ticker: "USDB", + decimals: 6, + }, }, }, ...overrides, From c881d02b2e86358251ada3f8e9b20167601369cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:13 -0600 Subject: [PATCH 37/71] test(balance-header): add BalanceHeader component spec --- .../balance-header/balance-header.spec.tsx | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 __tests__/components/balance-header/balance-header.spec.tsx 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() + }) +}) From feac156eef27cafbdfa93b37227cdd9800e9bd06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:17 -0600 Subject: [PATCH 38/71] test(stable-balance): add first-time modal spec --- .../stable-balance-first-time-modal.spec.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 __tests__/components/stable-balance-first-time-modal.spec.tsx 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) + }) +}) From 81e44bcc5b3c3a0426fdef52d20d5b251394b6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:23 -0600 Subject: [PATCH 39/71] test(hooks): add useBalanceMode spec --- __tests__/hooks/use-balance-mode.spec.ts | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 __tests__/hooks/use-balance-mode.spec.ts 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) + }) +}) From 1437025e9170b588c6e353e65117724df5461e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:27 -0600 Subject: [PATCH 40/71] test(hooks): add useStableBalanceFirstTime spec --- .../use-stable-balance-first-time.spec.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 __tests__/hooks/use-stable-balance-first-time.spec.ts 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) + }) +}) From 5228775cc09be7c02da2800c0914005230c182e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:34 -0600 Subject: [PATCH 41/71] test(convert): add conversion-details first-time modal spec --- ...sion-details-stable-balance-modal.spec.tsx | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 __tests__/screens/conversion-details-stable-balance-modal.spec.tsx 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) + }) +}) From 34cfd86ac79469d1de9a764e7e80110117bd99a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:38 -0600 Subject: [PATCH 42/71] test(convert): add ConversionFeeRow spec --- .../conversion-fee-row.spec.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 __tests__/screens/conversion-flow/conversion-fee-row.spec.tsx 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() + }) +}) From dd0197fc3bd6160e078d370d083ce855c1f8fec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:41 -0600 Subject: [PATCH 43/71] test(convert): add useConversionQuote spec --- .../use-conversion-quote.spec.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 __tests__/screens/conversion-flow/use-conversion-quote.spec.ts 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..38a95a3a2a --- /dev/null +++ b/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts @@ -0,0 +1,167 @@ +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("@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 => ({ + formattedFee: "$0.05", + 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) + }) +}) From 66839ad8e70c128c5d9d36974821213373a35300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:45 -0600 Subject: [PATCH 44/71] test(convert): add useNonCustodialConversion spec --- .../use-non-custodial-conversion.spec.ts | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 __tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts 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..c8d15dded7 --- /dev/null +++ b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts @@ -0,0 +1,209 @@ +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/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 + formattedFee: string + execute: jest.Mock + }> = {}, +) => ({ + formattedFee: overrides.formattedFee ?? "$0.05", + 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", + }) + }) +}) From 9c1d73308333e5688ff8df1413ee985d249997b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:52 -0600 Subject: [PATCH 45/71] test(settings): add StableBalanceSetting row spec --- .../stable-balance-setting.spec.tsx | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 __tests__/screens/settings-screen/stable-balance-setting.spec.tsx 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() + }) +}) From 0768cfbd619134d0c84dcbd2af1518c69618c27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:20:56 -0600 Subject: [PATCH 46/71] test(stable-balance): add settings screen spec --- .../stable-balance-settings-screen.spec.tsx | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 __tests__/screens/stable-balance-settings-screen.spec.tsx 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..7e5a3f69ea --- /dev/null +++ b/__tests__/screens/stable-balance-settings-screen.spec.tsx @@ -0,0 +1,278 @@ +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() + +jest.mock("@app/self-custodial/bridge", () => ({ + activateStableBalance: (...args: unknown[]) => mockActivate(...args), + deactivateStableBalance: (...args: unknown[]) => mockDeactivate(...args), +})) + +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} USD.`, + 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 USD.")).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() + }) +}) From 661d98936cc75a0a1cb0a8d3a8a23f0e932d200d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:00 -0600 Subject: [PATCH 47/71] test(stable-balance): add useStableBalanceToggleQuote spec --- .../use-stable-balance-toggle-quote.spec.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 __tests__/screens/stable-balance-settings-screen/use-stable-balance-toggle-quote.spec.ts 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..2d2c56fcd5 --- /dev/null +++ b/__tests__/screens/stable-balance-settings-screen/use-stable-balance-toggle-quote.spec.ts @@ -0,0 +1,192 @@ +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 { 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("@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) => ({ + formattedFee: "$0.05", + amountAdjustment, + execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), +}) + +describe("useStableBalanceToggleQuote", () => { + beforeEach(() => { + jest.clearAllMocks() + mockConvertMoneyAmount.mockImplementation( + (amount: { amount: number }, currency: WalletCurrency) => + currency === WalletCurrency.Btc + ? toBtcMoneyAmount(amount.amount * 100) + : 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.", + ), + ) + }) +}) From a0359c41efe0b58543ed7c2ed388b9a7c891fe45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:04 -0600 Subject: [PATCH 48/71] test(self-custodial): add bridge convert spec --- .../self-custodial/bridge/convert.spec.ts | 231 ++++++++++-------- 1 file changed, 134 insertions(+), 97 deletions(-) diff --git a/__tests__/self-custodial/bridge/convert.spec.ts b/__tests__/self-custodial/bridge/convert.spec.ts index 7aaac05434..4f0cd49727 100644 --- a/__tests__/self-custodial/bridge/convert.spec.ts +++ b/__tests__/self-custodial/bridge/convert.spec.ts @@ -1,144 +1,181 @@ -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" -jest.mock("react-native-fs", () => ({ - DocumentDirectoryPath: "/test/documents", -})) +import { createConvert } 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() -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: { tokenIdentifier: "usdb-token-id", maxSlippageBps: 50 }, + SparkToken: { Label: "USDB", DefaultDecimals: 6 }, +})) -const buildSdk = () => ({ - prepareSendPayment: jest.fn(), - sendPayment: jest.fn(), +const createSdk = () => ({ + prepareSendPayment: jest.fn().mockResolvedValue({ paymentMethod: {} }), + 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("createConvert — 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("sends to own spark address with FromBitcoin conversion and USDB amount as destination 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 result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe(PaymentResultStatus.Success) + expect(result.status).toBe("success") + 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) + expect(sdk.sendPayment).toHaveBeenCalled() }) - it("forwards the configured tokenIdentifier on BTC → USD conversions", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockResolvedValue(undefined) + it("rejects with BelowMinimum error when fromAmount is under the SDK minimum", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 10000, minToAmount: 0 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Btc), + const result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ tokenIdentifier: "test-token-id" }), - ) + expect(result.status).toBe("failed") + expect(result.errors?.[0].code).toBe(ConvertErrorCode.BelowMinimum) + expect(sdk.prepareSendPayment).not.toHaveBeenCalled() + expect(sdk.sendPayment).not.toHaveBeenCalled() }) - it("omits tokenIdentifier on USD → BTC conversions", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({ id: "prepared" }) - sdk.sendPayment.mockResolvedValue(undefined) + it("returns LimitsUnavailable and does not execute when fetchConversionLimits throws", async () => { + mockFetchLimits.mockRejectedValue(new Error("limits unavailable")) + const sdk = createSdk() - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Usd), - direction: ConvertDirection.UsdToBtc, + const result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, }) - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ tokenIdentifier: undefined }), - ) + expect(result.status).toBe("failed") + expect(result.errors?.[0].code).toBe(ConvertErrorCode.LimitsUnavailable) + expect(sdk.prepareSendPayment).not.toHaveBeenCalled() + expect(sdk.sendPayment).not.toHaveBeenCalled() }) - it("returns Failed with the SDK error message when prepare rejects", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockRejectedValue(new Error("prepare 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 result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(100), + toAmount: toUsdMoneyAmount(7), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toBe("prepare boom") - expect(sdk.sendPayment).not.toHaveBeenCalled() + expect(result.status).toBe("success") + }) +}) + +describe("createConvert — USD → BTC", () => { + beforeEach(() => { + jest.clearAllMocks() }) - 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("sends to own spark address with ToBitcoin conversion and sat amount as destination", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 10, minToAmount: 500 }) + const sdk = createSdk() - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), - direction: ConvertDirection.BtcToUsd, + const result = await createConvert(sdk as never)({ + fromAmount: toUsdMoneyAmount(100), + toAmount: toBtcMoneyAmount(1300), + direction: ConvertDirection.UsdToBtc, }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toBe("send boom") + expect(result.status).toBe("success") + 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("wraps non-Error throws into a Conversion-failed message", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockRejectedValue("string thrown") +describe("createConvert — error handling", () => { + beforeEach(() => { + jest.clearAllMocks() + mockFetchLimits.mockResolvedValue({ minFromAmount: null, minToAmount: null }) + }) - const convert = createConvert(sdk as never) - const result = await convert({ - amount: buildAmount(WalletCurrency.Btc), + it("returns failed with the SDK error message when prepareSendPayment throws", 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: {} }), + } + + const result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe(PaymentResultStatus.Failed) - expect(result.errors?.[0].message).toContain("Conversion failed") - expect(result.errors?.[0].message).toContain("string thrown") + expect(result.status).toBe("failed") + expect(result.errors?.[0].message).toBe("prepare failed") + expect(result.errors?.[0].code).toBeUndefined() + expect(sdk.sendPayment).not.toHaveBeenCalled() }) - it("forwards the requested amount as a BigInt", async () => { - const sdk = buildSdk() - sdk.prepareSendPayment.mockResolvedValue({}) - sdk.sendPayment.mockResolvedValue(undefined) - - const convert = createConvert(sdk as never) - await convert({ - amount: buildAmount(WalletCurrency.Btc, 12345), + it("returns failed when sendPayment throws", async () => { + const sdk = { + prepareSendPayment: jest.fn().mockResolvedValue({}), + sendPayment: jest.fn().mockRejectedValue(new Error("send failed")), + receivePayment: jest + .fn() + .mockResolvedValue({ paymentRequest: "sp1own-spark-address" }), + getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), + } + + const result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(sdk.prepareSendPayment).toHaveBeenCalledWith( - expect.objectContaining({ amount: BigInt(12345) }), - ) + expect(result.status).toBe("failed") + expect(result.errors?.[0].message).toBe("send failed") }) }) From 82c9b582c68d1e2c346ff7b2766cd489c357792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:09 -0600 Subject: [PATCH 49/71] test(self-custodial): add bridge limits spec --- .../self-custodial/bridge/limits.spec.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 __tests__/self-custodial/bridge/limits.spec.ts diff --git a/__tests__/self-custodial/bridge/limits.spec.ts b/__tests__/self-custodial/bridge/limits.spec.ts new file mode 100644 index 0000000000..cdac65a4e5 --- /dev/null +++ b/__tests__/self-custodial/bridge/limits.spec.ts @@ -0,0 +1,85 @@ +import { fetchConversionLimits } from "@app/self-custodial/bridge/limits" +import { ConvertDirection } from "@app/types/payment.types" + +jest.mock("@app/self-custodial/config", () => ({ + SparkConfig: { tokenIdentifier: "usdb-token-id" }, +})) + +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("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 }) + }) +}) From 1fa96b02237a93fcd1ec5209fed9d3454d6e35c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:16 -0600 Subject: [PATCH 50/71] test(self-custodial): add bridge stable-balance spec --- .../bridge/stable-balance.spec.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 __tests__/self-custodial/bridge/stable-balance.spec.ts 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") + }) +}) From 43ea2074ea7c8ab8c2fac2975634ef48238a807b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:20 -0600 Subject: [PATCH 51/71] test(self-custodial): add bridge token-balance spec --- .../bridge/token-balance.spec.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 __tests__/self-custodial/bridge/token-balance.spec.ts 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..1f55c277bb --- /dev/null +++ b/__tests__/self-custodial/bridge/token-balance.spec.ts @@ -0,0 +1,113 @@ +import { + findUsdbToken, + fetchUsdbDecimals, +} from "@app/self-custodial/bridge/token-balance" + +jest.mock("@app/self-custodial/config", () => ({ + SparkConfig: { tokenIdentifier: "test-token-id" }, + SparkToken: { DefaultDecimals: 6, Label: "USDB", Ticker: "USDB" }, +})) + +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 }) + }) +}) From fd4debc5eae8f694d313e545bc078999d8c30b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Tue, 21 Apr 2026 23:21:24 -0600 Subject: [PATCH 52/71] test(self-custodial): add useNonCustodialConversionLimits spec --- ...se-non-custodial-conversion-limits.spec.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 __tests__/self-custodial/hooks/use-non-custodial-conversion-limits.spec.ts 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) + }) +}) From 50f4d8df385935bc88eb282bad00622081b8e317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 10:35:22 -0600 Subject: [PATCH 53/71] feat(i18n): add StableBalance.toggleFailedToast key --- app/i18n/en/index.ts | 1 + app/i18n/i18n-types.ts | 8 ++++++++ app/i18n/raw-i18n/source/en.json | 1 + app/i18n/raw-i18n/translations/af.json | 1 + app/i18n/raw-i18n/translations/ar.json | 1 + app/i18n/raw-i18n/translations/ca.json | 1 + app/i18n/raw-i18n/translations/cs.json | 1 + app/i18n/raw-i18n/translations/da.json | 1 + app/i18n/raw-i18n/translations/de.json | 1 + app/i18n/raw-i18n/translations/el.json | 1 + app/i18n/raw-i18n/translations/es.json | 1 + app/i18n/raw-i18n/translations/fr.json | 1 + app/i18n/raw-i18n/translations/hr.json | 1 + app/i18n/raw-i18n/translations/hu.json | 1 + app/i18n/raw-i18n/translations/hy.json | 1 + app/i18n/raw-i18n/translations/id.json | 1 + app/i18n/raw-i18n/translations/it.json | 1 + app/i18n/raw-i18n/translations/ja.json | 1 + app/i18n/raw-i18n/translations/lg.json | 1 + app/i18n/raw-i18n/translations/ms.json | 1 + app/i18n/raw-i18n/translations/nl.json | 1 + app/i18n/raw-i18n/translations/pt.json | 1 + app/i18n/raw-i18n/translations/qu.json | 1 + app/i18n/raw-i18n/translations/ro.json | 1 + app/i18n/raw-i18n/translations/sk.json | 1 + app/i18n/raw-i18n/translations/sr.json | 1 + app/i18n/raw-i18n/translations/sw.json | 1 + app/i18n/raw-i18n/translations/th.json | 1 + app/i18n/raw-i18n/translations/tr.json | 1 + app/i18n/raw-i18n/translations/vi.json | 1 + app/i18n/raw-i18n/translations/xh.json | 1 + 31 files changed, 38 insertions(+) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index 0b7c3dbb12..b8800117b4 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -3778,6 +3778,7 @@ const en: BaseTranslation = { inactiveHint: "Your wallet is holding BTC only.", deactivateWarningBody: "You still have {amount:string} USD. 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: diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index d522d1e94b..6c56da6484 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -12000,6 +12000,10 @@ type RootTranslation = { * @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 @@ -23915,6 +23919,10 @@ export type TranslationFunctions = { * You still have {amount} USD. 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 diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 08b4af2ce8..5534f98e7e 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -3618,6 +3618,7 @@ "activeHint": "Your wallet is holding USD via USDB.", "inactiveHint": "Your wallet is holding BTC only.", "deactivateWarningBody": "You still have {amount:string} USD. 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.", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index 9531176459..1655fef6d0 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -3618,6 +3618,7 @@ "activeHint": "Jou beursie hou USD via USDB.", "inactiveHint": "Jou beursie hou net BTC.", "deactivateWarningBody": "Jy het nog $ {amount:string} USD. Skakel eers om na BTC, anders word jou USD-saldo versteek totdat jy Stabiele Balans heraktiveer.", + "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.", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index fa5451cb22..cb0fa146ba 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -3615,6 +3615,7 @@ "activeHint": "محفظتك تحتفظ بالدولار عبر USDB.", "inactiveHint": "محفظتك تحتفظ بالبيتكوين فقط.", "deactivateWarningBody": "لا يزال لديك {amount:string} دولار. قم بالتحويل إلى بيتكوين أولاً، وإلا سيتم إخفاء رصيد الدولار حتى تُعيد تفعيل الرصيد المستقر.", + "toggleFailedToast": "تعذر تحديث الرصيد المستقر. يرجى المحاولة مرة أخرى.", "toggleModal": { "activateTitle": "تفعيل الرصيد الثابت", "activateBody": "سيتم تحويل رصيد البيتكوين الخاص بك إلى USDB. هذه هي رسوم التحويل التقديرية.", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index 7acab330cd..264d4ff05e 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -3577,6 +3577,7 @@ "activeHint": "La teva cartera manté USD via USDB.", "inactiveHint": "La teva cartera manté només BTC.", "deactivateWarningBody": "Encara tens {amount:string} USD. Converteix a BTC primer, o el teu saldo en USD quedarà ocult fins que reactivis 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.", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index 562b352f4d..18c3b44cf7 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -3618,6 +3618,7 @@ "activeHint": "Peněženka drží USD přes USDB.", "inactiveHint": "Peněženka drží pouze BTC.", "deactivateWarningBody": "Máš ještě {amount:string} USD. 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.", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index cd9dfd6cfb..d81b73eaa4 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -3595,6 +3595,7 @@ "activeHint": "Din pung holder USD via USDB.", "inactiveHint": "Din pung holder kun BTC.", "deactivateWarningBody": "Du har stadig {amount:string} USD. Konverter til BTC først, ellers skjules din USD-saldo, indtil du genaktiverer Stabil Saldo.", + "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.", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index c06f327854..dc0b624a3b 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -3565,6 +3565,7 @@ "activeHint": "Deine Wallet hält USD über USDB.", "inactiveHint": "Deine Wallet hält nur BTC.", "deactivateWarningBody": "Du hast noch {amount:string} USD. Wandle zuerst in BTC um, sonst wird dein USD-Guthaben ausgeblendet, bis du Stabiler Saldo wieder 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.", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index 1d8840944e..f52b72733e 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -3577,6 +3577,7 @@ "activeHint": "Το πορτοφόλι διατηρεί USD μέσω USDB.", "inactiveHint": "Το πορτοφόλι διατηρεί μόνο BTC.", "deactivateWarningBody": "Έχεις ακόμη {amount:string} USD. Μετατρέψτε πρώτα σε BTC, αλλιώς το υπόλοιπο USD θα είναι κρυφό μέχρι να ενεργοποιήσετε ξανά το Σταθερό Υπόλοιπο.", + "toggleFailedToast": "Δεν ήταν δυνατή η ενημέρωση του Stable Balance. Δοκιμάστε ξανά.", "toggleModal": { "activateTitle": "Ενεργοποίηση σταθερού υπολοίπου", "activateBody": "Το υπόλοιπό σας σε BTC θα μετατραπεί σε USDB. Αυτή είναι η εκτιμώμενη χρέωση μετατροπής.", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 284e3c07c3..97447cd4af 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -3565,6 +3565,7 @@ "activeHint": "Tu billetera mantiene USD vía USDB.", "inactiveHint": "Tu billetera solo mantiene BTC.", "deactivateWarningBody": "Todavía tienes {amount:string} USD. 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.", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index 352f65edce..a8b6e3b7b6 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -3606,6 +3606,7 @@ "activeHint": "Votre portefeuille détient des USD via USDB.", "inactiveHint": "Votre portefeuille ne détient que des BTC.", "deactivateWarningBody": "Il vous reste {amount:string} USD. Convertissez-les d'abord en BTC, sinon votre solde USD sera masqué jusqu'à la réactivation du Solde Stable.", + "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.", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index 48750e1433..ca287e7b5b 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -3618,6 +3618,7 @@ "activeHint": "Tvoj novčanik drži USD putem USDB.", "inactiveHint": "Tvoj novčanik drži samo BTC.", "deactivateWarningBody": "Još imaš {amount:string} USD. Pretvori u BTC prvo, inače će tvoje USD stanje biti skriveno dok ponovno ne aktiviraš Stabilno stanje.", + "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.", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index 5d261e754b..b3af8a83f5 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -3577,6 +3577,7 @@ "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} USD-d. Először konvertáld BTC-re, különben az USD egyenleged rejtett marad, amíg újra nem aktiválod a Stabil egyenleget.", + "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.", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index 59ff215063..53e745b76a 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -3618,6 +3618,7 @@ "activeHint": "Ձեր դրամապանակը USD է պահում USDB-ի միջոցով։", "inactiveHint": "Ձեր դրամապանակը պահում է միայն BTC։", "deactivateWarningBody": "Դուք դեռ ունեք {amount:string} USD։ Նախ փոխարկեք BTC, այլապես ձեր USD մնացորդը թաքցվելու է, մինչև նորից չակտիվացնեք Կայուն մնացորդը։", + "toggleFailedToast": "Չհաջողվեց թարմացնել Stable Balance-ը։ Խնդրում ենք փորձել կրկին։", "toggleModal": { "activateTitle": "Ակտիվացնել կայուն մնացորդը", "activateBody": "Ձեր BTC մնացորդը կփոխարկվի USDB-ի։ Սա փոխարկման գնահատված վճարն է։", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 4cc9f5837a..7d68d41dd5 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -3577,6 +3577,7 @@ "activeHint": "Dompet Anda menyimpan USD melalui USDB.", "inactiveHint": "Dompet Anda hanya menyimpan BTC.", "deactivateWarningBody": "Anda masih memiliki {amount:string} USD. Konversikan ke BTC terlebih dahulu, atau saldo USD Anda akan disembunyikan hingga Anda mengaktifkan kembali Saldo Stabil.", + "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.", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index d6ef59f9d2..ecba090c63 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -3565,6 +3565,7 @@ "activeHint": "Il tuo portafoglio detiene USD tramite USDB.", "inactiveHint": "Il tuo portafoglio detiene solo BTC.", "deactivateWarningBody": "Hai ancora {amount:string} USD. Converti prima in BTC, altrimenti il tuo saldo in USD sarà nascosto finché non riattiverai Saldo Stabile.", + "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.", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index 91cb4956db..a9b3d5777b 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -3606,6 +3606,7 @@ "activeHint": "ウォレットはUSDB経由でUSDを保持しています。", "inactiveHint": "ウォレットはBTCのみを保持しています。", "deactivateWarningBody": "まだ {amount:string} USDあります。先にBTCに変換してください。そうしないと、安定残高を再度有効にするまでUSD残高は非表示になります。", + "toggleFailedToast": "Stable Balanceを更新できませんでした。もう一度お試しください。", "toggleModal": { "activateTitle": "安定残高を有効化", "activateBody": "BTC残高はUSDBに変換されます。これは推定される変換手数料です。", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index def09c867f..3e75c33569 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -3577,6 +3577,7 @@ "activeHint": "Valet yo ekwata USD nga eyita mu USDB.", "inactiveHint": "Valet yo ekwata BTC kyokka.", "deactivateWarningBody": "Okyasigalawo {amount:string} USD. Sooka ofuule BTC, oba balansi yo eya USD eyiinzira nga tokyalongedde Balansi Enywevu.", + "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.", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 859bc0dd47..3d6b66baa3 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -3618,6 +3618,7 @@ "activeHint": "Dompet anda menyimpan USD melalui USDB.", "inactiveHint": "Dompet anda hanya menyimpan BTC.", "deactivateWarningBody": "Anda masih mempunyai {amount:string} USD. Tukarkan ke BTC terlebih dahulu, atau baki USD anda akan disembunyikan sehingga anda mengaktifkan semula Baki Stabil.", + "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.", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index 14e867613b..3761ba43ed 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -3618,6 +3618,7 @@ "activeHint": "Je wallet houdt USD via USDB.", "inactiveHint": "Je wallet houdt alleen BTC.", "deactivateWarningBody": "Je hebt nog {amount:string} USD. Zet eerst om naar BTC, anders wordt je USD-saldo verborgen totdat je Stabiel Saldo weer inschakelt.", + "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.", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 6e0ca55d03..ff4675328b 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -3565,6 +3565,7 @@ "activeHint": "A tua carteira mantém USD via USDB.", "inactiveHint": "A tua carteira só mantém BTC.", "deactivateWarningBody": "Ainda tens {amount:string} USD. Converte primeiro para BTC, ou o teu saldo em USD ficará oculto até reativares o Saldo Estável.", + "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.", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index b0803b0daf..59554fce5b 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -3615,6 +3615,7 @@ "activeHint": "Walletniyki USD-ta USDB-nintakama waqaychan.", "inactiveHint": "Walletniyki BTC-llata waqaychan.", "deactivateWarningBody": "Qanqa kanraqmi {amount:string} USD. Ñawpaqta BTC-man tikray, manaña chayqa USD saldoyki pakakunqa Saldo Takyasqa kutiy kawsachisqa kanan kama.", + "toggleFailedToast": "Stable Balance manam hukmanchayta atikurqachu. Yapamanta intentay.", "toggleModal": { "activateTitle": "Sayaq Qullqita Kachaykuy", "activateBody": "BTC qullqiyki USDB-man tikrakunqa. Kaymi tikrachiypa chaninchasqa chaninchayninmi.", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index 5a24687958..35adc161ba 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -3577,6 +3577,7 @@ "activeHint": "Portofelul tău păstrează USD prin USDB.", "inactiveHint": "Portofelul tău păstrează doar BTC.", "deactivateWarningBody": "Mai ai {amount:string} USD. Convertește mai întâi în BTC, altfel soldul în USD va fi ascuns până reactivezi Sold Stabil.", + "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.", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 55eafb9899..25f9514fd4 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -3577,6 +3577,7 @@ "activeHint": "Peňaženka drží USD cez USDB.", "inactiveHint": "Peňaženka drží iba BTC.", "deactivateWarningBody": "Máš ešte {amount:string} USD. Najprv previedť na BTC, inak bude USD zostatok skrytý, kým znovu neaktivuješ Stabilný zostatok.", + "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.", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index e141a89aa8..1ac3441622 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -3615,6 +3615,7 @@ "activeHint": "Твој новчаник држи USD преко USDB.", "inactiveHint": "Твој новчаник држи само BTC.", "deactivateWarningBody": "Још имаш {amount:string} USD. Прво пребаци у BTC, иначе ће твој USD биланс бити сакривен док поново не активираш Стабилно стање.", + "toggleFailedToast": "Није могуће ажурирати Stable Balance. Покушајте поново.", "toggleModal": { "activateTitle": "Активирај стабилни салдо", "activateBody": "Ваш BTC салдо ће бити конвертован у USDB. Ово је процењена накнада за конверзију.", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index d19ba4def2..31f8bae9e5 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -3577,6 +3577,7 @@ "activeHint": "Pochi yako inashikilia USD kupitia USDB.", "inactiveHint": "Pochi yako inashikilia BTC tu.", "deactivateWarningBody": "Bado una {amount:string} USD. Badilisha kuwa BTC kwanza, ama salio lako la USD litafichwa hadi uwashe tena Salio Thabiti.", + "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.", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 077c651c0f..ebb6f7eef0 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -3615,6 +3615,7 @@ "activeHint": "กระเป๋าเงินของคุณถือ USD ผ่าน USDB", "inactiveHint": "กระเป๋าเงินของคุณถือ BTC เท่านั้น", "deactivateWarningBody": "คุณยังมี {amount:string} USD โปรดแปลงเป็น BTC ก่อน มิฉะนั้นยอด USD จะถูกซ่อนไว้จนกว่าคุณจะเปิดยอดเงินคงที่อีกครั้ง", + "toggleFailedToast": "ไม่สามารถอัปเดต Stable Balance ได้ โปรดลองอีกครั้ง", "toggleModal": { "activateTitle": "เปิดใช้ยอดคงเหลือแบบเสถียร", "activateBody": "ยอดคงเหลือ BTC ของคุณจะถูกแปลงเป็น USDB นี่คือค่าธรรมเนียมการแปลงโดยประมาณ", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index 2eb1aedf6d..d6099581e7 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -3577,6 +3577,7 @@ "activeHint": "Cüzdanınız USDB üzerinden USD tutuyor.", "inactiveHint": "Cüzdanınız sadece BTC tutuyor.", "deactivateWarningBody": "Hâlâ {amount:string} USD'niz var. Önce BTC'ye dönüştürün; aksi halde Stabil Bakiye'yi tekrar etkinleştirene kadar USD bakiyeniz gizlenir.", + "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.", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index 9a116bae43..bea694bfc9 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -3615,6 +3615,7 @@ "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} USD. 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 Số Dư Ổn Định.", + "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.", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 6cef929cfb..ccd6ff36b4 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -3624,6 +3624,7 @@ "activeHint": "Isipaji sakho sigcina i-USD ngeUSDB.", "inactiveHint": "Isipaji sakho sigcina i-BTC kuphela.", "deactivateWarningBody": "Usenayo {amount:string} USD. Yitshintshele kwi-BTC kuqala, okanye ibhalansi yakho yeUSD iza kufihlakala de uyivuselele iBhalansi Ezinzileyo.", + "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.", From d1f939ff6c05ff5db3722e51286503dea5013a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 10:35:29 -0600 Subject: [PATCH 54/71] fix(stable-balance): catch toggle errors and disable confirm on fee preview failure --- .../stable-balance-confirm-modal.spec.tsx | 115 ++++++++++++++++++ .../stable-balance-settings-screen.spec.tsx | 58 +++++++++ .../stable-balance-confirm-modal.tsx | 2 +- .../stable-balance-settings-screen.tsx | 12 ++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 __tests__/screens/stable-balance-confirm-modal.spec.tsx 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 index 7e5a3f69ea..18af5995d8 100644 --- a/__tests__/screens/stable-balance-settings-screen.spec.tsx +++ b/__tests__/screens/stable-balance-settings-screen.spec.tsx @@ -29,6 +29,17 @@ 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), @@ -61,6 +72,7 @@ jest.mock("@app/i18n/i18n-react", () => ({ inactiveHint: () => "Holding BTC only", deactivateWarningBody: ({ amount }: { amount: string }) => `You still have ${amount} USD.`, + toggleFailedToast: () => "Could not update Stable Balance. Please try again.", toggleModal: { activateTitle: () => "Activate Stable Balance", activateBody: () => "Your BTC will be converted to USDB.", @@ -275,4 +287,50 @@ describe("StableBalanceSettingsScreen", () => { 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/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx b/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx index ae8427feb0..38f32f0bd9 100644 --- a/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx +++ b/app/screens/stable-balance-settings-screen/stable-balance-confirm-modal.tsx @@ -80,7 +80,7 @@ export const StableBalanceConfirmModal: React.FC = ({ primaryButtonTitle={confirmTitle} primaryButtonOnPress={onConfirm} primaryButtonLoading={isSubmitting} - primaryButtonDisabled={isSubmitting || isLoading} + primaryButtonDisabled={isSubmitting || isLoading || hasError} secondaryButtonTitle={LL.StableBalance.toggleModal.cancel()} secondaryButtonOnPress={onCancel} /> 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 index 43db667fcf..cde8dedcdd 100644 --- a/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react" import { ActivityIndicator, View } from "react-native" import { makeStyles, Text } from "@rn-vui/themed" +import crashlytics from "@react-native-firebase/crashlytics" import { Screen } from "@app/components/screen" import { Switch } from "@app/components/atomic/switch" @@ -14,6 +15,7 @@ import { SparkToken } from "@app/self-custodial/config" import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" import { WalletCurrency } from "@app/graphql/generated" import { testProps } from "@app/utils/testProps" +import { toastShow } from "@app/utils/toast" import { StableBalanceConfirmModal } from "./stable-balance-confirm-modal" import { useStableBalanceToggleQuote } from "./hooks" @@ -76,6 +78,16 @@ export const StableBalanceSettingsScreen: React.FC = () => { } 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) From b9988efc0a4d20103f9bb54af143570306444685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 10:35:33 -0600 Subject: [PATCH 55/71] fix(self-custodial): record stable-balance refresh failures to crashlytics --- app/self-custodial/providers/use-sdk-lifecycle.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/self-custodial/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 2783c501a7..2443a7f07e 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -241,6 +241,9 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { 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}`), + ) } }, []) From 35c81d143de89f74e96256e199dc235c06714c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 11:09:51 -0600 Subject: [PATCH 56/71] fix(self-custodial): require SPARK_TOKEN_IDENTIFIER and fail fast when missing --- .../adapters/payment-adapter.spec.ts | 3 ++- .../self-custodial/bridge/convert.spec.ts | 25 ++++++++++++++++++- .../self-custodial/bridge/limits.spec.ts | 23 ++++++++++++++++- __tests__/self-custodial/bridge/send.spec.ts | 3 ++- .../bridge/token-balance.spec.ts | 3 ++- __tests__/self-custodial/config.spec.ts | 22 ++++++++++++---- .../payment-details/lightning.spec.ts | 3 ++- .../payment-details/spark.spec.ts | 3 ++- .../providers/wallet-snapshot.spec.ts | 3 ++- app/self-custodial/bridge/convert.ts | 7 +++--- app/self-custodial/bridge/lifecycle.ts | 19 ++++++++------ app/self-custodial/bridge/limits.ts | 6 ++--- app/self-custodial/bridge/send.ts | 4 +-- app/self-custodial/bridge/token-balance.ts | 10 +++++--- app/self-custodial/config.ts | 9 ++++++- .../providers/wallet-snapshot.ts | 4 +-- 16 files changed, 111 insertions(+), 36 deletions(-) diff --git a/__tests__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index dcf81e3071..167aacd55f 100644 --- a/__tests__/self-custodial/adapters/payment-adapter.spec.ts +++ b/__tests__/self-custodial/adapters/payment-adapter.spec.ts @@ -59,7 +59,8 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ })) jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "test-token-id", maxSlippageBps: 50 }, + SparkConfig: { maxSlippageBps: 50 }, + requireSparkTokenIdentifier: () => "test-token-id", SparkToken: { Label: "USDB", DefaultDecimals: 6 }, })) diff --git a/__tests__/self-custodial/bridge/convert.spec.ts b/__tests__/self-custodial/bridge/convert.spec.ts index 4f0cd49727..c9497af32f 100644 --- a/__tests__/self-custodial/bridge/convert.spec.ts +++ b/__tests__/self-custodial/bridge/convert.spec.ts @@ -4,6 +4,7 @@ import { ConvertDirection, ConvertErrorCode } from "@app/types/payment.types" import { createConvert } from "@app/self-custodial/bridge/convert" const mockFetchLimits = jest.fn() +const mockRequireTokenId = jest.fn(() => "usdb-token-id") jest.mock("@app/self-custodial/bridge/limits", () => { const actual = jest.requireActual("@app/self-custodial/bridge/limits") @@ -14,7 +15,8 @@ jest.mock("@app/self-custodial/bridge/limits", () => { }) jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "usdb-token-id", maxSlippageBps: 50 }, + SparkConfig: { maxSlippageBps: 50 }, + requireSparkTokenIdentifier: () => mockRequireTokenId(), SparkToken: { Label: "USDB", DefaultDecimals: 6 }, })) @@ -178,4 +180,25 @@ describe("createConvert — error handling", () => { expect(result.status).toBe("failed") expect(result.errors?.[0].message).toBe("send failed") }) + + 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() + + const result = await createConvert(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }) + + expect(result.status).toBe("failed") + expect(result.errors?.[0].message).toBe( + "SPARK_TOKEN_IDENTIFIER is not configured for this build", + ) + 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 index cdac65a4e5..54e810242c 100644 --- a/__tests__/self-custodial/bridge/limits.spec.ts +++ b/__tests__/self-custodial/bridge/limits.spec.ts @@ -1,8 +1,11 @@ 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: { tokenIdentifier: "usdb-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => mockRequireTokenId(), })) const mockGetInfo = jest.fn().mockResolvedValue({ @@ -82,4 +85,22 @@ describe("fetchConversionLimits", () => { 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/token-balance.spec.ts b/__tests__/self-custodial/bridge/token-balance.spec.ts index 1f55c277bb..ad0a1fa788 100644 --- a/__tests__/self-custodial/bridge/token-balance.spec.ts +++ b/__tests__/self-custodial/bridge/token-balance.spec.ts @@ -4,7 +4,8 @@ import { } from "@app/self-custodial/bridge/token-balance" jest.mock("@app/self-custodial/config", () => ({ - SparkConfig: { tokenIdentifier: "test-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "test-token-id", SparkToken: { DefaultDecimals: 6, Label: "USDB", Ticker: "USDB" }, })) 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/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/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index 9eedab8fae..22e56c3f93 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -11,7 +11,8 @@ import { jest.mock("@app/self-custodial/config", () => ({ SparkToken: { Label: "USDB", Ticker: "USDB" }, - SparkConfig: { tokenIdentifier: "test-token-id" }, + SparkConfig: {}, + requireSparkTokenIdentifier: () => "test-token-id", })) const createMockSdk = (overrides = {}) => ({ diff --git a/app/self-custodial/bridge/convert.ts b/app/self-custodial/bridge/convert.ts index bd42a3e1ee..258dddfebd 100644 --- a/app/self-custodial/bridge/convert.ts +++ b/app/self-custodial/bridge/convert.ts @@ -22,7 +22,7 @@ import { import { centsToTokenBaseUnits } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" -import { SparkConfig } from "../config" +import { requireSparkTokenIdentifier, SparkConfig } from "../config" import { buildConversionType, fetchConversionLimits } from "./limits" import { fetchUsdbDecimals } from "./token-balance" @@ -126,6 +126,7 @@ const prepareConversion = async ( } const isBtcToUsd = direction === ConvertDirection.BtcToUsd + const tokenIdentifier = isBtcToUsd ? requireSparkTokenIdentifier() : undefined const destinationAmount = isBtcToUsd ? BigInt(centsToTokenBaseUnits(toAmount.amount, tokenDecimals)) : BigInt(toAmount.amount) @@ -133,14 +134,14 @@ const prepareConversion = async ( const paymentRequest = await createOwnSparkInvoice( sdk, destinationAmount, - isBtcToUsd ? SparkConfig.tokenIdentifier : undefined, + tokenIdentifier, ) const prepared = await sdk.prepareSendPayment( PrepareSendPaymentRequest.create({ paymentRequest, amount: destinationAmount, - tokenIdentifier: isBtcToUsd ? SparkConfig.tokenIdentifier : undefined, + tokenIdentifier, conversionOptions: buildConversionOptions(direction), }), ) 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 index 578ab528df..e45d22e4fb 100644 --- a/app/self-custodial/bridge/limits.ts +++ b/app/self-custodial/bridge/limits.ts @@ -7,7 +7,7 @@ import { ConvertDirection, type ConversionLimits } from "@app/types/payment.type import { tokenBaseUnitsToCents } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" -import { SparkConfig } from "../config" +import { requireSparkTokenIdentifier } from "../config" import { fetchUsdbDecimals } from "./token-balance" @@ -15,7 +15,7 @@ export const buildConversionType = (direction: ConvertDirection) => direction === ConvertDirection.BtcToUsd ? new ConversionType.FromBitcoin() : new ConversionType.ToBitcoin({ - fromTokenIdentifier: SparkConfig.tokenIdentifier, + fromTokenIdentifier: requireSparkTokenIdentifier(), }) const toWalletUnit = ( @@ -37,7 +37,7 @@ export const fetchConversionLimits = async ( const isBtcToUsd = direction === ConvertDirection.BtcToUsd const response = await sdk.fetchConversionLimits({ conversionType: buildConversionType(direction), - tokenIdentifier: isBtcToUsd ? SparkConfig.tokenIdentifier : undefined, + tokenIdentifier: isBtcToUsd ? requireSparkTokenIdentifier() : undefined, }) const tokenDecimals = tokenDecimalsHint ?? (await fetchUsdbDecimals(sdk)) 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/token-balance.ts b/app/self-custodial/bridge/token-balance.ts index 88e66ba9e4..572127606d 100644 --- a/app/self-custodial/bridge/token-balance.ts +++ b/app/self-custodial/bridge/token-balance.ts @@ -4,17 +4,19 @@ import { type TokenBalance, } from "@breeztech/breez-sdk-spark-react-native" -import { SparkConfig, SparkToken } from "../config" +import { requireSparkTokenIdentifier, SparkToken } from "../config" const listTokenBalances = (info: GetInfoResponse): TokenBalance[] => info.tokenBalances instanceof Map ? [...info.tokenBalances.values()] : Object.values(info.tokenBalances ?? {}) -export const findUsdbToken = (info: GetInfoResponse): TokenBalance | undefined => - listTokenBalances(info).find( - (token) => token.tokenMetadata?.identifier === SparkConfig.tokenIdentifier, +export const findUsdbToken = (info: GetInfoResponse): TokenBalance | undefined => { + const expectedIdentifier = requireSparkTokenIdentifier() + return listTokenBalances(info).find( + (token) => token.tokenMetadata?.identifier === expectedIdentifier, ) +} export const fetchUsdbDecimals = async (sdk: BreezSdkInterface): Promise => { const info = await sdk.getInfo({ ensureSynced: false }) diff --git a/app/self-custodial/config.ts b/app/self-custodial/config.ts index f7c82cae61..7739f1b2aa 100644 --- a/app/self-custodial/config.ts +++ b/app/self-custodial/config.ts @@ -32,6 +32,13 @@ 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 + +export const requireSparkTokenIdentifier = (): string => { + const id = Config.SPARK_TOKEN_IDENTIFIER + if (!id) { + throw new Error("SPARK_TOKEN_IDENTIFIER is not configured for this build") + } + return id +} diff --git a/app/self-custodial/providers/wallet-snapshot.ts b/app/self-custodial/providers/wallet-snapshot.ts index 99b64f099b..ae67762622 100644 --- a/app/self-custodial/providers/wallet-snapshot.ts +++ b/app/self-custodial/providers/wallet-snapshot.ts @@ -13,7 +13,7 @@ import { type NormalizedTransaction } from "@app/types/transaction.types" import { toWalletId, type WalletState } from "@app/types/wallet.types" import { findUsdbToken, getWalletInfo, listPayments } from "../bridge" -import { SparkConfig } from "../config" +import { requireSparkTokenIdentifier } from "../config" import { mapSelfCustodialTransactions } from "../mappers/transaction-mapper" const TRANSACTIONS_PER_PAGE = 20 @@ -28,7 +28,7 @@ const getStableBalance = (info: GetInfoResponse): number => { 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 + return payment.details.inner.metadata.identifier === requireSparkTokenIdentifier() } type PaymentsPage = { From 1b3bc7a59d84965eb9eb53df093b5671e070c69c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 11:39:08 -0600 Subject: [PATCH 57/71] feat(i18n): add StableBalance.conversionUnavailable key --- app/i18n/en/index.ts | 1 + app/i18n/i18n-types.ts | 8 ++++++++ app/i18n/raw-i18n/source/en.json | 3 ++- app/i18n/raw-i18n/translations/af.json | 3 ++- app/i18n/raw-i18n/translations/ar.json | 3 ++- app/i18n/raw-i18n/translations/ca.json | 3 ++- app/i18n/raw-i18n/translations/cs.json | 3 ++- app/i18n/raw-i18n/translations/da.json | 3 ++- app/i18n/raw-i18n/translations/de.json | 3 ++- app/i18n/raw-i18n/translations/el.json | 3 ++- app/i18n/raw-i18n/translations/es.json | 3 ++- app/i18n/raw-i18n/translations/fr.json | 3 ++- app/i18n/raw-i18n/translations/hr.json | 3 ++- app/i18n/raw-i18n/translations/hu.json | 3 ++- app/i18n/raw-i18n/translations/hy.json | 3 ++- app/i18n/raw-i18n/translations/id.json | 3 ++- app/i18n/raw-i18n/translations/it.json | 3 ++- app/i18n/raw-i18n/translations/ja.json | 3 ++- app/i18n/raw-i18n/translations/lg.json | 3 ++- app/i18n/raw-i18n/translations/ms.json | 3 ++- app/i18n/raw-i18n/translations/nl.json | 3 ++- app/i18n/raw-i18n/translations/pt.json | 3 ++- app/i18n/raw-i18n/translations/qu.json | 3 ++- app/i18n/raw-i18n/translations/ro.json | 3 ++- app/i18n/raw-i18n/translations/sk.json | 3 ++- app/i18n/raw-i18n/translations/sr.json | 3 ++- app/i18n/raw-i18n/translations/sw.json | 3 ++- app/i18n/raw-i18n/translations/th.json | 3 ++- app/i18n/raw-i18n/translations/tr.json | 3 ++- app/i18n/raw-i18n/translations/vi.json | 3 ++- app/i18n/raw-i18n/translations/xh.json | 3 ++- 31 files changed, 67 insertions(+), 29 deletions(-) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index b8800117b4..d0113b81ad 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -3799,6 +3799,7 @@ const en: BaseTranslation = { 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 6c56da6484..dc232ef14a 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -12057,6 +12057,10 @@ type RootTranslation = { * @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: { /** @@ -23975,6 +23979,10 @@ export type TranslationFunctions = { * 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 5534f98e7e..571405dd1c 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -3634,7 +3634,8 @@ "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}" + "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 1655fef6d0..e8ed11fd77 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -3634,7 +3634,8 @@ "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}" + "minimumConversion": "Minimum omskakeling: {amount:string}", + "conversionUnavailable": "Omskakeling is tydelik onbeskikbaar. Probeer asseblief weer." }, "BackendFeatureGate": { "title": "Funksie nie beskikbaar nie", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index cb0fa146ba..ccb819f545 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -3631,7 +3631,8 @@ "trustDisclosure": "يستخدم وضع USD رموز USDB على Spark. افتراضات الثقة تختلف عن الاحتفاظ بالبيتكوين مباشرة. USDB يعتمد على مُصدر الرموز في Spark.", "acknowledge": "فهمت" }, - "minimumConversion": "الحد الأدنى للتحويل: {amount:string}" + "minimumConversion": "الحد الأدنى للتحويل: {amount:string}", + "conversionUnavailable": "التحويل غير متاح مؤقتًا. يرجى المحاولة مرة أخرى." }, "BackendFeatureGate": { "title": "الميزة غير متاحة", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index 264d4ff05e..ce58bcb197 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Conversió mínima: {amount:string}", + "conversionUnavailable": "La conversió no està disponible temporalment. Torna-ho a provar." }, "BackendFeatureGate": { "title": "Funció no disponible", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index 18c3b44cf7..d90aea9326 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -3634,7 +3634,8 @@ "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}" + "minimumConversion": "Minimální převod: {amount:string}", + "conversionUnavailable": "Konverze je dočasně nedostupná. Zkuste to znovu." }, "BackendFeatureGate": { "title": "Funkce nedostupná", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index d81b73eaa4..b14a222947 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -3611,7 +3611,8 @@ "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}" + "minimumConversion": "Minimum konvertering: {amount:string}", + "conversionUnavailable": "Konvertering er midlertidigt utilgængelig. Prøv igen." }, "BackendFeatureGate": { "title": "Funktion ikke tilgængelig", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index dc0b624a3b..6dabcda083 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -3581,7 +3581,8 @@ "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}" + "minimumConversion": "Mindestumwandlung: {amount:string}", + "conversionUnavailable": "Die Umwandlung ist vorübergehend nicht verfügbar. Bitte versuche es erneut." }, "BackendFeatureGate": { "title": "Funktion nicht verfügbar", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index f52b72733e..c4b1ac6a46 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -3593,7 +3593,8 @@ "trustDisclosure": "Η λειτουργία USD χρησιμοποιεί tokens USDB στο Spark. Οι παραδοχές εμπιστοσύνης είναι διαφορετικές από το να κρατάτε BTC απευθείας. το USDB βασίζεται στον εκδότη tokens του Spark.", "acknowledge": "Καταλαβαίνω" }, - "minimumConversion": "Ελάχιστη μετατροπή: {amount:string}" + "minimumConversion": "Ελάχιστη μετατροπή: {amount:string}", + "conversionUnavailable": "Η μετατροπή δεν είναι διαθέσιμη προσωρινά. Δοκιμάστε ξανά." }, "BackendFeatureGate": { "title": "Λειτουργία μη διαθέσιμη", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 97447cd4af..5b9cdea08d 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -3581,7 +3581,8 @@ "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}" + "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", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index a8b6e3b7b6..194d022d97 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -3622,7 +3622,8 @@ "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}" + "minimumConversion": "Conversion minimale : {amount:string}", + "conversionUnavailable": "La conversion est temporairement indisponible. Veuillez réessayer." }, "BackendFeatureGate": { "title": "Fonctionnalité indisponible", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index ca287e7b5b..450fc3652f 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -3634,7 +3634,8 @@ "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}" + "minimumConversion": "Minimalna pretvorba: {amount:string}", + "conversionUnavailable": "Konverzija je privremeno nedostupna. Pokušajte ponovno." }, "BackendFeatureGate": { "title": "Značajka nedostupna", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index b3af8a83f5..f591fd5274 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Minimum konverzió: {amount:string}", + "conversionUnavailable": "A konverzió ideiglenesen nem érhető el. Próbáld újra." }, "BackendFeatureGate": { "title": "Funkció nem elérhető", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index 53e745b76a..f4be1f5732 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -3634,7 +3634,8 @@ "trustDisclosure": "USD ռեժիմը օգտագործում է USDB թոքեններ Spark-ում։ Վստահության ենթադրությունները տարբերվում են BTC-ի ուղղակի պահելուց. USDB-ն հիմնված է Spark-ի թոքեն թողարկողի վրա։", "acknowledge": "Ես հասկանում եմ" }, - "minimumConversion": "Նվազագույն փոխարկում. {amount:string}" + "minimumConversion": "Նվազագույն փոխարկում. {amount:string}", + "conversionUnavailable": "Փոխարկումը ժամանակավորապես անհասանելի է։ Խնդրում ենք փորձել կրկին։" }, "BackendFeatureGate": { "title": "Հնարավորությունը հասանելի չէ", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 7d68d41dd5..b0bdbbd6bd 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Konversi minimum: {amount:string}", + "conversionUnavailable": "Konversi sementara tidak tersedia. Silakan coba lagi." }, "BackendFeatureGate": { "title": "Fitur tidak tersedia", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index ecba090c63..13ec862782 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -3581,7 +3581,8 @@ "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}" + "minimumConversion": "Conversione minima: {amount:string}", + "conversionUnavailable": "La conversione è temporaneamente non disponibile. Riprova." }, "BackendFeatureGate": { "title": "Funzione non disponibile", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index a9b3d5777b..33304f7b66 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -3622,7 +3622,8 @@ "trustDisclosure": "USDモードはSpark上のUSDBトークンを使用します。信頼の前提はBTCを直接保有することとは異なります. USDBはSparkのトークン発行者に依存しています。", "acknowledge": "理解しました" }, - "minimumConversion": "最小変換額: {amount:string}" + "minimumConversion": "最小変換額: {amount:string}", + "conversionUnavailable": "変換は一時的に利用できません。もう一度お試しください。" }, "BackendFeatureGate": { "title": "機能が利用できません", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index 3e75c33569..b8fef42264 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Ekitono ekisinga okukyusa: {amount:string}", + "conversionUnavailable": "Okukyusa tekikolaako kati. Gezaako nate." }, "BackendFeatureGate": { "title": "Ekikola tekiriwo", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 3d6b66baa3..155fa61109 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -3634,7 +3634,8 @@ "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}" + "minimumConversion": "Penukaran minimum: {amount:string}", + "conversionUnavailable": "Penukaran tidak tersedia buat sementara waktu. Sila cuba lagi." }, "BackendFeatureGate": { "title": "Ciri tidak tersedia", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index 3761ba43ed..a5b65c331c 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -3634,7 +3634,8 @@ "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}" + "minimumConversion": "Minimale conversie: {amount:string}", + "conversionUnavailable": "Conversie is tijdelijk niet beschikbaar. Probeer het opnieuw." }, "BackendFeatureGate": { "title": "Functie niet beschikbaar", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index ff4675328b..9be49180eb 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -3581,7 +3581,8 @@ "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}" + "minimumConversion": "Conversão mínima: {amount:string}", + "conversionUnavailable": "A conversão está temporariamente indisponível. Tenta novamente." }, "BackendFeatureGate": { "title": "Recurso indisponível", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index 59554fce5b..a4a81c98ec 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -3631,7 +3631,8 @@ "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}" + "minimumConversion": "Aswan aslla tikray: {amount:string}", + "conversionUnavailable": "Tikrachiy mana atikuyniyuqchu kachkan. Yapamanta intentay." }, "BackendFeatureGate": { "title": "Kay mana kanchu", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index 35adc161ba..fd5f615735 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Conversie minimă: {amount:string}", + "conversionUnavailable": "Conversia este temporar indisponibilă. Încearcă din nou." }, "BackendFeatureGate": { "title": "Funcție indisponibilă", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 25f9514fd4..9f3cb83d32 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Minimálna konverzia: {amount:string}", + "conversionUnavailable": "Konverzia je dočasne nedostupná. Skúste to znova." }, "BackendFeatureGate": { "title": "Funkcia nedostupná", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index 1ac3441622..bdbb090be4 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -3631,7 +3631,8 @@ "trustDisclosure": "USD режим користи USDB токене на Sparku. Претпоставке поверења разликују се од директног држања BTC-а. USDB се ослања на Sparkovog издаваоца токена.", "acknowledge": "Разумем" }, - "minimumConversion": "Минимална конверзија: {amount:string}" + "minimumConversion": "Минимална конверзија: {amount:string}", + "conversionUnavailable": "Конверзија тренутно није доступна. Покушајте поново." }, "BackendFeatureGate": { "title": "Функција недоступна", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index 31f8bae9e5..41fe25d939 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -3593,7 +3593,8 @@ "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}" + "minimumConversion": "Ubadilishaji mdogo zaidi: {amount:string}", + "conversionUnavailable": "Ubadilishaji haupatikani kwa muda. Tafadhali jaribu tena." }, "BackendFeatureGate": { "title": "Kipengele hakipatikani", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index ebb6f7eef0..f12e0bce74 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -3631,7 +3631,8 @@ "trustDisclosure": "โหมด USD ใช้โทเค็น USDB บน Spark ข้อสันนิษฐานด้านความเชื่อถือต่างจากการถือ BTC โดยตรง. USDB พึ่งพาผู้ออกโทเค็นของ Spark", "acknowledge": "เข้าใจแล้ว" }, - "minimumConversion": "จำนวนขั้นต่ำที่แปลงได้: {amount:string}" + "minimumConversion": "จำนวนขั้นต่ำที่แปลงได้: {amount:string}", + "conversionUnavailable": "ขณะนี้ไม่สามารถแปลงได้ โปรดลองอีกครั้ง" }, "BackendFeatureGate": { "title": "ฟีเจอร์ไม่พร้อมใช้งาน", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index d6099581e7..3f002a6b32 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -3593,7 +3593,8 @@ "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}" + "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", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index bea694bfc9..94cca5e032 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -3631,7 +3631,8 @@ "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}" + "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", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index ccd6ff36b4..83e3b121e7 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -3640,7 +3640,8 @@ "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}" + "minimumConversion": "Inguqulo encinci: {amount:string}", + "conversionUnavailable": "Uguqulo aluxhanyiwe okwexeshana. Nceda uzame kwakhona." }, "BackendFeatureGate": { "title": "Isici asifumaneki", From cf7b340da0efd82021bff52d448c6ac0efe08209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 11:39:13 -0600 Subject: [PATCH 58/71] fix(self-custodial): ceil token-denominated conversion minimums to keep UI gate above SDK floor --- .../self-custodial/bridge/limits.spec.ts | 17 +++++ __tests__/utils/amounts.spec.ts | 72 ++++++++++++++++++- app/self-custodial/bridge/limits.ts | 4 +- app/utils/amounts.ts | 7 ++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/__tests__/self-custodial/bridge/limits.spec.ts b/__tests__/self-custodial/bridge/limits.spec.ts index 54e810242c..cee0dcd340 100644 --- a/__tests__/self-custodial/bridge/limits.spec.ts +++ b/__tests__/self-custodial/bridge/limits.spec.ts @@ -69,6 +69,23 @@ describe("fetchConversionLimits", () => { 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, 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/self-custodial/bridge/limits.ts b/app/self-custodial/bridge/limits.ts index e45d22e4fb..4fe3a62b85 100644 --- a/app/self-custodial/bridge/limits.ts +++ b/app/self-custodial/bridge/limits.ts @@ -4,7 +4,7 @@ import { } from "@breeztech/breez-sdk-spark-react-native" import { ConvertDirection, type ConversionLimits } from "@app/types/payment.types" -import { tokenBaseUnitsToCents } from "@app/utils/amounts" +import { tokenBaseUnitsToCentsCeil } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" import { requireSparkTokenIdentifier } from "../config" @@ -26,7 +26,7 @@ const toWalletUnit = ( if (raw === null || raw === undefined) return null const value = toNumber(raw) if (!assetIsToken) return value - return tokenBaseUnitsToCents(value, tokenDecimals) + return tokenBaseUnitsToCentsCeil(value, tokenDecimals) } export const fetchConversionLimits = async ( diff --git a/app/utils/amounts.ts b/app/utils/amounts.ts index 9fe570b7d9..6aa424468d 100644 --- a/app/utils/amounts.ts +++ b/app/utils/amounts.ts @@ -25,6 +25,13 @@ export const tokenBaseUnitsToCents = ( ): 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, From 7505e4d47c8a053bb156412c0439fe3a5683e15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 11:39:17 -0600 Subject: [PATCH 59/71] fix(conversion): surface limits-load failures in conversion details screen --- .../conversion-details-screen.spec.tsx | 109 ++++++++++++++++-- .../conversion-details-screen.tsx | 16 ++- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/__tests__/screens/conversion-details-screen.spec.tsx b/__tests__/screens/conversion-details-screen.spec.tsx index 7b3a3e51f8..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,15 +40,17 @@ jest.mock("@react-navigation/native", () => ({ }), })) +const mockUseActiveWallet = jest.fn() +const mockUseNonCustodialConversionLimits = jest.fn() + jest.mock("@app/hooks/use-active-wallet", () => ({ - useActiveWallet: () => ({ - isSelfCustodial: false, - isReady: false, - needsBackendAuth: true, - wallets: [], - status: "Unavailable", - accountType: "Custodial", - }), + useActiveWallet: () => mockUseActiveWallet(), +})) + +jest.mock("@app/self-custodial/hooks", () => ({ + useNonCustodialConversionLimits: (...args: unknown[]) => + mockUseNonCustodialConversionLimits(...args), + usePaymentRequest: jest.fn(), })) jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ @@ -583,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", () => { @@ -1933,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/app/screens/conversion-flow/conversion-details-screen.tsx b/app/screens/conversion-flow/conversion-details-screen.tsx index 22cbd65348..7b5c32b564 100644 --- a/app/screens/conversion-flow/conversion-details-screen.tsx +++ b/app/screens/conversion-flow/conversion-details-screen.tsx @@ -158,10 +158,12 @@ export const ConversionDetailsScreen = () => { isSelfCustodial && fromWallet ? convertDirectionFromCurrency(fromWallet.walletCurrency) : undefined - const { limits: scConversionLimits } = useNonCustodialConversionLimits(convertDirection) + 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] = @@ -489,6 +491,9 @@ export const ConversionDetailsScreen = () => { if (exceedsBalance) { return LL.SendBitcoinScreen.amountExceed({ balance: fromWalletBalanceFormatted }) } + if (scLimitsUnavailable) { + return LL.StableBalance.conversionUnavailable() + } if (belowMinimum && scMinFromAmount !== null) { const minMoneyAmount = toWalletMoneyAmount( scMinFromAmount, @@ -675,7 +680,11 @@ export const ConversionDetailsScreen = () => { size={14} color={hasError ? colors.error : "transparent"} /> - + {amountFieldError || " "} @@ -736,7 +745,8 @@ export const ConversionDetailsScreen = () => { toggleInitiated.current || isTyping || Boolean(loadingPercent) || - belowMinimum + belowMinimum || + scLimitsUnavailable } onPress={moveToNextScreen} testID="next-button" From 82e8e365676e76868610ab8058616323eaf94b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 11:56:32 -0600 Subject: [PATCH 60/71] fix(convert): record SDK errors to crashlytics, drop dead createConvert adapter, and expose fee as MoneyAmount --- .../breez-sdk-spark-react-native.js | 4 + __tests__/hooks/use-payments.spec.ts | 7 - .../use-conversion-quote.spec.ts | 15 +- .../use-non-custodial-conversion.spec.ts | 11 +- .../use-stable-balance-toggle-quote.spec.ts | 22 ++- .../adapters/payment-adapter.spec.ts | 40 +---- .../self-custodial/bridge/convert.spec.ts | 142 ++++++++++++------ app/hooks/use-payments.ts | 2 - .../hooks/use-conversion-quote.ts | 13 +- app/self-custodial/bridge/convert.ts | 59 +++----- app/self-custodial/bridge/index.ts | 2 +- app/types/payment.types.ts | 2 +- 12 files changed, 172 insertions(+), 147 deletions(-) diff --git a/__mocks__/@breeztech/breez-sdk-spark-react-native.js b/__mocks__/@breeztech/breez-sdk-spark-react-native.js index 098026c0e9..709912a066 100644 --- a/__mocks__/@breeztech/breez-sdk-spark-react-native.js +++ b/__mocks__/@breeztech/breez-sdk-spark-react-native.js @@ -78,6 +78,10 @@ module.exports = { .fn() .mockImplementation((args) => ({ tag: "ToBitcoin", inner: args })), }, + AmountAdjustmentReason: { + FlooredToMinLimit: "FlooredToMinLimit", + IncreasedToAvoidDust: "IncreasedToAvoidDust", + }, PrepareSendPaymentRequest: { create: (p) => p }, SendPaymentRequest: { create: (p) => p }, ReceivePaymentRequest: { create: (p) => p }, diff --git a/__tests__/hooks/use-payments.spec.ts b/__tests__/hooks/use-payments.spec.ts index cd749e5ede..330d42fe06 100644 --- a/__tests__/hooks/use-payments.spec.ts +++ b/__tests__/hooks/use-payments.spec.ts @@ -29,7 +29,6 @@ jest.mock("@app/self-custodial/bridge", () => ({ getClaimFee: jest.fn(), claimDeposit: jest.fn(), }), - createConvert: jest.fn().mockReturnValue(jest.fn()), createGetConversionQuote: jest.fn().mockReturnValue(jest.fn()), })) @@ -68,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()) diff --git a/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts b/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts index 38a95a3a2a..fddfb58965 100644 --- a/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts +++ b/__tests__/screens/conversion-flow/use-conversion-quote.spec.ts @@ -17,6 +17,19 @@ 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 { @@ -37,7 +50,7 @@ jest.mock("@app/i18n/i18n-react", () => ({ })) const buildQuote = (amountAdjustment?: ConvertAmountAdjustment): ConvertQuote => ({ - formattedFee: "$0.05", + feeAmount: toUsdMoneyAmount(5), amountAdjustment, execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), }) diff --git a/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts index c8d15dded7..1ad18d2e7c 100644 --- a/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts +++ b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts @@ -20,6 +20,13 @@ 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(), })) @@ -55,11 +62,11 @@ const defaultParams = { const makeQuote = ( overrides: Partial<{ amountAdjustment?: ConvertAmountAdjustment - formattedFee: string + feeAmount: ReturnType execute: jest.Mock }> = {}, ) => ({ - formattedFee: overrides.formattedFee ?? "$0.05", + feeAmount: overrides.feeAmount ?? toUsdMoneyAmount(5), amountAdjustment: overrides.amountAdjustment, execute: overrides.execute ?? 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 index 2d2c56fcd5..2eac9ab900 100644 --- 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 @@ -2,7 +2,7 @@ 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 { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" +import { DisplayCurrency, toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" import { ConvertAmountAdjustment, ConvertDirection, @@ -20,6 +20,13 @@ 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 { @@ -40,7 +47,7 @@ jest.mock("@app/i18n/i18n-react", () => ({ })) const makeQuote = (amountAdjustment?: ConvertAmountAdjustment) => ({ - formattedFee: "$0.05", + feeAmount: toUsdMoneyAmount(5), amountAdjustment, execute: jest.fn().mockResolvedValue({ status: PaymentResultStatus.Success }), }) @@ -49,10 +56,13 @@ describe("useStableBalanceToggleQuote", () => { beforeEach(() => { jest.clearAllMocks() mockConvertMoneyAmount.mockImplementation( - (amount: { amount: number }, currency: WalletCurrency) => - currency === WalletCurrency.Btc - ? toBtcMoneyAmount(amount.amount * 100) - : toUsdMoneyAmount(Math.round(amount.amount / 100)), + (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)) + }, ) }) diff --git a/__tests__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index 167aacd55f..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 }, @@ -382,38 +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({ - fromAmount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, - toAmount: { amount: 100, currency: "USD", currencyCode: "USD" }, - 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({ - fromAmount: { amount: 1000, currency: "BTC", currencyCode: "BTC" }, - toAmount: { amount: 100, currency: "USD", currencyCode: "USD" }, - 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 c9497af32f..ee8eae3f10 100644 --- a/__tests__/self-custodial/bridge/convert.spec.ts +++ b/__tests__/self-custodial/bridge/convert.spec.ts @@ -1,10 +1,17 @@ import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" import { ConvertDirection, ConvertErrorCode } from "@app/types/payment.types" +import { WalletCurrency } from "@app/graphql/generated" -import { createConvert } from "@app/self-custodial/bridge/convert" +import { createGetConversionQuote } from "@app/self-custodial/bridge/convert" 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() }), +})) jest.mock("@app/self-custodial/bridge/limits", () => { const actual = jest.requireActual("@app/self-custodial/bridge/limits") @@ -21,7 +28,13 @@ jest.mock("@app/self-custodial/config", () => ({ })) const createSdk = () => ({ - prepareSendPayment: jest.fn().mockResolvedValue({ paymentMethod: {} }), + 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({ @@ -33,22 +46,22 @@ const createSdk = () => ({ }), }) -describe("createConvert — BTC → USD", () => { +describe("createGetConversionQuote — BTC → USD", () => { beforeEach(() => { jest.clearAllMocks() }) - it("sends to own spark address with FromBitcoin conversion and USDB amount as destination in token base units", async () => { + 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 result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toBtcMoneyAmount(5000), toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe("success") + expect(quote).not.toBeNull() expect(sdk.receivePayment).toHaveBeenCalled() const prepArg = sdk.prepareSendPayment.mock.calls[0][0] expect(prepArg.paymentRequest).toBe("sp1own-spark-address") @@ -56,71 +69,100 @@ describe("createConvert — BTC → USD", () => { expect(prepArg.tokenIdentifier).toBe("usdb-token-id") expect(prepArg.conversionOptions.conversionType).toEqual({ tag: "FromBitcoin" }) expect(prepArg.conversionOptions.maxSlippageBps).toBe(50) - expect(sdk.sendPayment).toHaveBeenCalled() }) - it("rejects with BelowMinimum error when fromAmount is under the SDK minimum", async () => { - mockFetchLimits.mockResolvedValue({ minFromAmount: 10000, minToAmount: 0 }) + it("execute() sends the prepared payment and returns success", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 1000, minToAmount: 0 }) const sdk = createSdk() - const result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toBtcMoneyAmount(5000), toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe("failed") - expect(result.errors?.[0].code).toBe(ConvertErrorCode.BelowMinimum) - expect(sdk.prepareSendPayment).not.toHaveBeenCalled() - expect(sdk.sendPayment).not.toHaveBeenCalled() + const result = await quote!.execute() + + expect(result.status).toBe("success") + expect(sdk.sendPayment).toHaveBeenCalled() }) - it("returns LimitsUnavailable and does not execute when fetchConversionLimits throws", async () => { - mockFetchLimits.mockRejectedValue(new Error("limits unavailable")) + it("exposes the estimated fee converted to a USD MoneyAmount in cents", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 0, minToAmount: 0 }) const sdk = createSdk() - const result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toBtcMoneyAmount(5000), toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe("failed") - expect(result.errors?.[0].code).toBe(ConvertErrorCode.LimitsUnavailable) + expect(quote!.feeAmount.currency).toBe(WalletCurrency.Usd) + expect(quote!.feeAmount.amount).toBe(5) + }) + + it("throws BelowMinimum and records to crashlytics when fromAmount is under the SDK minimum", async () => { + mockFetchLimits.mockResolvedValue({ minFromAmount: 10000, minToAmount: 0 }) + const sdk = createSdk() + + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toMatchObject({ code: ConvertErrorCode.BelowMinimum }) + + 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() - expect(sdk.sendPayment).not.toHaveBeenCalled() }) it("skips minimum check when minFromAmount is null (no limit)", async () => { mockFetchLimits.mockResolvedValue({ minFromAmount: null, minToAmount: null }) const sdk = createSdk() - const result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toBtcMoneyAmount(100), toAmount: toUsdMoneyAmount(7), direction: ConvertDirection.BtcToUsd, }) - expect(result.status).toBe("success") + expect(quote).not.toBeNull() }) }) -describe("createConvert — USD → BTC", () => { +describe("createGetConversionQuote — USD → BTC", () => { beforeEach(() => { jest.clearAllMocks() }) - it("sends to own spark address with ToBitcoin conversion and sat amount as destination", async () => { + it("prepares a payment with ToBitcoin conversion and sat destination amount", async () => { mockFetchLimits.mockResolvedValue({ minFromAmount: 10, minToAmount: 500 }) const sdk = createSdk() - const result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toUsdMoneyAmount(100), toAmount: toBtcMoneyAmount(1300), direction: ConvertDirection.UsdToBtc, }) - expect(result.status).toBe("success") + 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)) @@ -133,13 +175,13 @@ describe("createConvert — USD → BTC", () => { }) }) -describe("createConvert — error handling", () => { +describe("createGetConversionQuote — error handling", () => { beforeEach(() => { jest.clearAllMocks() mockFetchLimits.mockResolvedValue({ minFromAmount: null, minToAmount: null }) }) - it("returns failed with the SDK error message when prepareSendPayment throws", async () => { + it("re-throws and records to crashlytics when prepareSendPayment fails", async () => { const sdk = { prepareSendPayment: jest.fn().mockRejectedValue(new Error("prepare failed")), sendPayment: jest.fn(), @@ -149,21 +191,24 @@ describe("createConvert — error handling", () => { getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), } - const result = await createConvert(sdk as never)({ - fromAmount: toBtcMoneyAmount(5000), - toAmount: toUsdMoneyAmount(137), - direction: ConvertDirection.BtcToUsd, - }) + await expect( + createGetConversionQuote(sdk as never)({ + fromAmount: toBtcMoneyAmount(5000), + toAmount: toUsdMoneyAmount(137), + direction: ConvertDirection.BtcToUsd, + }), + ).rejects.toThrow("prepare failed") - expect(result.status).toBe("failed") - expect(result.errors?.[0].message).toBe("prepare failed") - expect(result.errors?.[0].code).toBeUndefined() + expect(mockRecordError).toHaveBeenCalled() expect(sdk.sendPayment).not.toHaveBeenCalled() }) - it("returns failed when sendPayment throws", async () => { + it("execute() records to crashlytics and returns failed when sendPayment throws", async () => { const sdk = { - prepareSendPayment: jest.fn().mockResolvedValue({}), + prepareSendPayment: jest.fn().mockResolvedValue({ + paymentMethod: {}, + conversionEstimate: { fee: BigInt(0), amountAdjustment: undefined }, + }), sendPayment: jest.fn().mockRejectedValue(new Error("send failed")), receivePayment: jest .fn() @@ -171,14 +216,16 @@ describe("createConvert — error handling", () => { getInfo: jest.fn().mockResolvedValue({ tokenBalances: {} }), } - const result = await createConvert(sdk as never)({ + const quote = await createGetConversionQuote(sdk as never)({ fromAmount: toBtcMoneyAmount(5000), toAmount: toUsdMoneyAmount(137), direction: ConvertDirection.BtcToUsd, }) + const result = await quote!.execute() 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 () => { @@ -188,16 +235,15 @@ describe("createConvert — error handling", () => { }) const sdk = createSdk() - const result = await createConvert(sdk as never)({ - fromAmount: toBtcMoneyAmount(5000), - toAmount: toUsdMoneyAmount(137), - direction: ConvertDirection.BtcToUsd, - }) + 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(result.status).toBe("failed") - expect(result.errors?.[0].message).toBe( - "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/app/hooks/use-payments.ts b/app/hooks/use-payments.ts index 04552f5e3d..36a1c2e38c 100644 --- a/app/hooks/use-payments.ts +++ b/app/hooks/use-payments.ts @@ -14,7 +14,6 @@ import { createListPendingDeposits, } from "@app/self-custodial/adapters/deposit-adapter" import { - createConvert, createGetConversionQuote, createReceiveLightning, createReceiveOnchain, @@ -60,7 +59,6 @@ export const usePayments = (): PaymentsResult => { receiveOnchain: createReceiveOnchain(sdk), listPendingDeposits: createListPendingDeposits(sdk), claimDeposit: createClaimDeposit(sdk), - convert: createConvert(sdk), getConversionQuote: createGetConversionQuote(sdk), accountType, } diff --git a/app/screens/conversion-flow/hooks/use-conversion-quote.ts b/app/screens/conversion-flow/hooks/use-conversion-quote.ts index fda4e73764..34536c9c6a 100644 --- a/app/screens/conversion-flow/hooks/use-conversion-quote.ts +++ b/app/screens/conversion-flow/hooks/use-conversion-quote.ts @@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from "react" import crashlytics from "@react-native-firebase/crashlytics" +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 { DisplayCurrency } from "@app/types/amounts" import { ConvertAmountAdjustment, type ConvertParams, @@ -32,6 +35,8 @@ export const useConversionQuote = ( ): ConversionQuoteState => { const { getConversionQuote } = usePayments() const { LL } = useI18nContext() + const { formatMoneyAmount } = useDisplayCurrency() + const { convertMoneyAmount } = usePriceConversion() const [state, setState] = useState<{ status: QuoteStatus @@ -63,8 +68,12 @@ export const useConversionQuote = ( } }, [getConversionQuote, quoteParams]) - const feeText = - state.status === QuoteStatus.Ready && state.quote ? state.quote.formattedFee : "" + const feeText = useMemo(() => { + if (state.status !== QuoteStatus.Ready || !state.quote) return "" + if (!convertMoneyAmount) return "" + const feeInDisplay = convertMoneyAmount(state.quote.feeAmount, DisplayCurrency) + return formatMoneyAmount({ moneyAmount: feeInDisplay }) + }, [state, formatMoneyAmount, convertMoneyAmount]) const adjustmentText = useMemo(() => { if (state.status !== QuoteStatus.Ready || !state.quote) return null diff --git a/app/self-custodial/bridge/convert.ts b/app/self-custodial/bridge/convert.ts index 258dddfebd..3408fd09d3 100644 --- a/app/self-custodial/bridge/convert.ts +++ b/app/self-custodial/bridge/convert.ts @@ -7,19 +7,20 @@ import { 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 } from "@app/utils/amounts" +import { centsToTokenBaseUnits, tokenBaseUnitsToCents } from "@app/utils/amounts" import { toNumber } from "@app/utils/helper" import { requireSparkTokenIdentifier, SparkConfig } from "../config" @@ -27,21 +28,6 @@ import { requireSparkTokenIdentifier, SparkConfig } from "../config" import { buildConversionType, fetchConversionLimits } from "./limits" import { fetchUsdbDecimals } from "./token-balance" -const MIN_USD_FRACTION_DIGITS = 2 - -const formatUsdFromBaseUnits = (rawAmount: number, decimals: number): string => { - const divisor = 10 ** decimals - const whole = Math.floor(rawAmount / divisor) - const fractional = rawAmount % divisor - const padded = String(fractional).padStart(decimals, "0") - const trimmed = padded.replace(/0+$/, "") - const fractionalStr = - trimmed.length < MIN_USD_FRACTION_DIGITS - ? trimmed.padEnd(MIN_USD_FRACTION_DIGITS, "0") - : trimmed - return `$${whole}.${fractionalStr}` -} - const failed = (message: string, code?: string): PaymentAdapterResult => ({ status: PaymentResultStatus.Failed, errors: [{ message, code }], @@ -98,6 +84,15 @@ type PreparedConversion = { tokenDecimals: number } +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}`), + ) +} + const prepareConversion = async ( sdk: BreezSdkInterface, { fromAmount, toAmount, direction }: ConvertParams, @@ -152,11 +147,13 @@ const prepareConversion = async ( 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}`) } } @@ -164,40 +161,26 @@ const executePrepared = async ( 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 { - formattedFee: formatUsdFromBaseUnits(toNumber(estimate.fee), tokenDecimals), + feeAmount: toUsdMoneyAmount(feeCents), amountAdjustment: mapAmountAdjustment(estimate.amountAdjustment), - execute: () => executePrepared(sdk, prepared), + execute: () => executePrepared(sdk, prepared, params), } } -const toFailedResult = (err: unknown): PaymentAdapterResult => { - if (err instanceof ConvertError) return failed(err.message, err.code) - if (err instanceof Error) return failed(err.message) - return failed(`Conversion failed: ${err}`) -} - export const createGetConversionQuote = (sdk: BreezSdkInterface): GetConversionQuoteAdapter => async (params) => { try { const context = await prepareConversion(sdk, params) - return toConvertQuote(sdk, context) - } catch { - return null - } - } - -export const createConvert = - (sdk: BreezSdkInterface): ConvertAdapter => - async (params) => { - try { - const context = await prepareConversion(sdk, params) - return await executePrepared(sdk, context.prepared) + return toConvertQuote(sdk, context, params) } catch (err) { - return toFailedResult(err) + recordConvertError(err, params, "getConversionQuote") + throw err } } diff --git a/app/self-custodial/bridge/index.ts b/app/self-custodial/bridge/index.ts index 8c80cbe39b..c603ac59a7 100644 --- a/app/self-custodial/bridge/index.ts +++ b/app/self-custodial/bridge/index.ts @@ -21,7 +21,7 @@ export { export type { OnchainFeeTiers, PrepareSendOptions } from "./send" export { listDeposits, claimDeposit, refundDeposit, getRecommendedFees } from "./deposits" export type { MappedDeposit, NetworkFeeRates } from "./deposits" -export { createConvert, createGetConversionQuote } from "./convert" +export { createGetConversionQuote } from "./convert" export { fetchConversionLimits } from "./limits" export { parseSparkAddress } from "./parse" export type { ParsedSparkAddress } from "./parse" diff --git a/app/types/payment.types.ts b/app/types/payment.types.ts index f0c97a4241..0eb5c6b089 100644 --- a/app/types/payment.types.ts +++ b/app/types/payment.types.ts @@ -202,7 +202,7 @@ export type ConvertAmountAdjustment = (typeof ConvertAmountAdjustment)[keyof typeof ConvertAmountAdjustment] export type ConvertQuote = { - formattedFee: string + feeAmount: MoneyAmount amountAdjustment?: ConvertAmountAdjustment execute: () => Promise } From 6aae236cad5115978b4a7931bcaddc8e685b1377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 12:18:36 -0600 Subject: [PATCH 61/71] fix(conversion): snapshot the first Ready quote on confirmation to keep fee preview in sync with execute --- .../use-non-custodial-conversion.spec.ts | 54 +++++++++++++++++++ .../hooks/use-non-custodial-conversion.ts | 19 ++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts index 1ad18d2e7c..3b423d665d 100644 --- a/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts +++ b/__tests__/screens/conversion-flow/use-non-custodial-conversion.spec.ts @@ -213,4 +213,58 @@ describe("useNonCustodialConversion", () => { 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/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts index 0c91b7b9d7..94db93d43c 100644 --- a/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts +++ b/app/screens/conversion-flow/hooks/use-non-custodial-conversion.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { WalletCurrency } from "@app/graphql/generated" import { usePriceConversion } from "@app/hooks/use-price-conversion" @@ -8,6 +8,7 @@ import { convertDirectionFromCurrency, oppositeWalletCurrency, PaymentResultStatus, + type ConvertParams, } from "@app/types/payment.types" import { logConversionAttempt } from "@app/utils/analytics" @@ -40,7 +41,7 @@ export const useNonCustodialConversion = ({ const { convertMoneyAmount } = usePriceConversion() const { LL } = useI18nContext() - const quoteParams = useMemo(() => { + const liveQuoteParams = useMemo(() => { if (!enabled || !convertMoneyAmount) return null const toCurrency = oppositeWalletCurrency(fromCurrency) return { @@ -50,9 +51,23 @@ export const useNonCustodialConversion = ({ } }, [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() } From 0c983a79f299c8569090138485c22171549ad2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 13:39:10 -0600 Subject: [PATCH 62/71] fix(self-custodial): scale token-payment fees to USD and dedupe crashlytics on token mismatches --- .../bridge/token-balance.spec.ts | 52 ++++++++++++++++ .../mappers/to-transaction-fragment.spec.ts | 32 ++++++++++ .../mappers/transaction-mapper.spec.ts | 17 +++++- .../providers/wallet-snapshot.spec.ts | 59 +++++++++++++++++++ app/self-custodial/bridge/token-balance.ts | 24 +++++++- app/self-custodial/logging.ts | 8 +++ .../mappers/transaction-mapper.ts | 5 +- .../providers/wallet-snapshot.ts | 12 +++- 8 files changed, 204 insertions(+), 5 deletions(-) diff --git a/__tests__/self-custodial/bridge/token-balance.spec.ts b/__tests__/self-custodial/bridge/token-balance.spec.ts index ad0a1fa788..7a3523dab4 100644 --- a/__tests__/self-custodial/bridge/token-balance.spec.ts +++ b/__tests__/self-custodial/bridge/token-balance.spec.ts @@ -3,12 +3,27 @@ import { 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: { @@ -112,3 +127,40 @@ describe("fetchUsdbDecimals", () => { 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/mappers/to-transaction-fragment.spec.ts b/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts index b7f06b1c44..6876581ce1 100644 --- a/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts +++ b/__tests__/self-custodial/mappers/to-transaction-fragment.spec.ts @@ -232,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/providers/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index 22e56c3f93..2c1d7901a0 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -9,12 +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: {}, 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", @@ -269,3 +284,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/app/self-custodial/bridge/token-balance.ts b/app/self-custodial/bridge/token-balance.ts index 572127606d..4972090ddb 100644 --- a/app/self-custodial/bridge/token-balance.ts +++ b/app/self-custodial/bridge/token-balance.ts @@ -5,6 +5,7 @@ import { } 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 @@ -13,12 +14,31 @@ const listTokenBalances = (info: GetInfoResponse): TokenBalance[] => export const findUsdbToken = (info: GetInfoResponse): TokenBalance | undefined => { const expectedIdentifier = requireSparkTokenIdentifier() - return listTokenBalances(info).find( + 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 }) - return findUsdbToken(info)?.tokenMetadata?.decimals ?? SparkToken.DefaultDecimals + 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/logging.ts b/app/self-custodial/logging.ts index 9114a3996c..c763c6b43a 100644 --- a/app/self-custodial/logging.ts +++ b/app/self-custodial/logging.ts @@ -1,5 +1,13 @@ 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) +} + export const SdkLogLevel = { Debug: "debug", Info: "info", diff --git a/app/self-custodial/mappers/transaction-mapper.ts b/app/self-custodial/mappers/transaction-mapper.ts index f2e842d255..10ae651cde 100644 --- a/app/self-custodial/mappers/transaction-mapper.ts +++ b/app/self-custodial/mappers/transaction-mapper.ts @@ -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/wallet-snapshot.ts b/app/self-custodial/providers/wallet-snapshot.ts index ae67762622..88b02098eb 100644 --- a/app/self-custodial/providers/wallet-snapshot.ts +++ b/app/self-custodial/providers/wallet-snapshot.ts @@ -14,6 +14,7 @@ import { toWalletId, type WalletState } from "@app/types/wallet.types" 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 @@ -28,7 +29,16 @@ const getStableBalance = (info: GetInfoResponse): number => { 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 === requireSparkTokenIdentifier() + 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 = { From 36ff5b8963a97dfc3473eb53f5102752a793b796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 14:12:34 -0600 Subject: [PATCH 63/71] fix(self-custodial): preserve loadMore cursor across refresh by re-fetching every page up to the active offset --- .../providers/wallet-provider.fixtures.ts | 6 +- .../providers/wallet-provider.spec.tsx | 41 +++++++ .../providers/wallet-snapshot.spec.ts | 106 ++++++++++++++++++ .../providers/use-sdk-lifecycle.ts | 4 +- .../providers/wallet-snapshot.ts | 24 +++- 5 files changed, 174 insertions(+), 7 deletions(-) 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 612ab760a2..c72ed664aa 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -609,6 +609,47 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(result.current.hasMoreTransactions).toBe(false) }) + 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: [ { diff --git a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts index 2c1d7901a0..7d9250655b 100644 --- a/__tests__/self-custodial/providers/wallet-snapshot.spec.ts +++ b/__tests__/self-custodial/providers/wallet-snapshot.spec.ts @@ -191,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 => ({ diff --git a/app/self-custodial/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 2443a7f07e..57660b6985 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -98,10 +98,10 @@ 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 + rawTxOffsetRef.current = snapshot.rawTransactionCount // eslint-disable-line require-atomic-updates setStatus(ActiveWalletStatus.Ready) updateBalanceStale(detectBalanceStale(snapshot.wallets)) diff --git a/app/self-custodial/providers/wallet-snapshot.ts b/app/self-custodial/providers/wallet-snapshot.ts index 88b02098eb..a5239acc79 100644 --- a/app/self-custodial/providers/wallet-snapshot.ts +++ b/app/self-custodial/providers/wallet-snapshot.ts @@ -94,9 +94,25 @@ export type WalletSnapshot = { 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( @@ -105,10 +121,10 @@ export const getSelfCustodialWalletSnapshot = async ( btcBalance: Number(info.balanceSats), stableBalance: getStableBalance(info), }, - page.transactions, + transactions, ), - hasMore: page.hasMore, - rawTransactionCount: page.rawCount, + hasMore, + rawTransactionCount, } } From c04f78069d2be146bb113e461f6c5644604844c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 14:35:20 -0600 Subject: [PATCH 64/71] fix(self-custodial): record polling/AppState refresh failures, dedupe spark status errors, and guard against unmount mid-init --- .../providers/is-online.spec.ts | 56 +++++++++++++++++++ .../providers/wallet-provider.spec.tsx | 34 +++++++++++ app/self-custodial/providers/is-online.ts | 14 ++++- .../providers/use-sdk-lifecycle.ts | 14 ++++- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/__tests__/self-custodial/providers/is-online.spec.ts b/__tests__/self-custodial/providers/is-online.spec.ts index 9190fce633..112037d9fb 100644 --- a/__tests__/self-custodial/providers/is-online.spec.ts +++ b/__tests__/self-custodial/providers/is-online.spec.ts @@ -8,11 +8,25 @@ import { } 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(), })) +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() @@ -142,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.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index c72ed664aa..751e02b5f8 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -609,6 +609,40 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () 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( { diff --git a/app/self-custodial/providers/is-online.ts b/app/self-custodial/providers/is-online.ts index 07ca63473e..9049bf5875 100644 --- a/app/self-custodial/providers/is-online.ts +++ b/app/self-custodial/providers/is-online.ts @@ -1,17 +1,26 @@ import { ServiceStatus } from "@breeztech/breez-sdk-spark-react-native" import { getSparkStatus } from "../bridge" +import { recordErrorOnce } from "../logging" 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 - } catch { + } catch (err) { + reportSparkStatusFailure(err) return ServiceStatus.Major } } @@ -34,7 +43,8 @@ export const getOnlineState = async (): Promise => { try { const { status } = await getSparkStatus() return isOnlineStatus(status) ? OnlineState.Online : OnlineState.Offline - } catch { + } 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 57660b6985..0c16d47f7e 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -152,6 +152,7 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } await refreshWallets() }) + if (!mounted) return refreshWallets() @@ -204,7 +205,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]) @@ -214,7 +220,11 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { const interval = setInterval(() => { if (!sdkRef.current) return if (AppState.currentState !== "active") return - refreshWallets() + refreshWallets().catch((err) => { + crashlytics().recordError( + err instanceof Error ? err : new Error(`Polling refresh failed: ${err}`), + ) + }) }, CONNECTIVITY_POLL_MS) return () => clearInterval(interval) }, [refreshWallets]) From 9bd4aa85983695c185113b0f8e1e9b6719a2fb2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:03:58 -0600 Subject: [PATCH 65/71] fix(stable-balance): format deactivation warning amount through display currency --- .../stable-balance-settings-screen.spec.tsx | 17 ++++++++++++++-- app/i18n/en/index.ts | 2 +- app/i18n/i18n-types.ts | 4 ++-- app/i18n/raw-i18n/source/en.json | 2 +- app/i18n/raw-i18n/translations/af.json | 2 +- app/i18n/raw-i18n/translations/ar.json | 2 +- app/i18n/raw-i18n/translations/ca.json | 2 +- app/i18n/raw-i18n/translations/cs.json | 2 +- app/i18n/raw-i18n/translations/da.json | 2 +- app/i18n/raw-i18n/translations/de.json | 2 +- app/i18n/raw-i18n/translations/el.json | 2 +- app/i18n/raw-i18n/translations/es.json | 2 +- app/i18n/raw-i18n/translations/fr.json | 2 +- app/i18n/raw-i18n/translations/hr.json | 2 +- app/i18n/raw-i18n/translations/hu.json | 2 +- app/i18n/raw-i18n/translations/hy.json | 2 +- app/i18n/raw-i18n/translations/id.json | 2 +- app/i18n/raw-i18n/translations/it.json | 2 +- app/i18n/raw-i18n/translations/ja.json | 2 +- app/i18n/raw-i18n/translations/lg.json | 2 +- app/i18n/raw-i18n/translations/ms.json | 2 +- app/i18n/raw-i18n/translations/nl.json | 2 +- app/i18n/raw-i18n/translations/pt.json | 2 +- app/i18n/raw-i18n/translations/qu.json | 2 +- app/i18n/raw-i18n/translations/ro.json | 2 +- app/i18n/raw-i18n/translations/sk.json | 2 +- app/i18n/raw-i18n/translations/sr.json | 2 +- app/i18n/raw-i18n/translations/sw.json | 2 +- app/i18n/raw-i18n/translations/th.json | 2 +- app/i18n/raw-i18n/translations/tr.json | 2 +- app/i18n/raw-i18n/translations/vi.json | 2 +- app/i18n/raw-i18n/translations/xh.json | 2 +- .../stable-balance-settings-screen.tsx | 20 +++++++++++++------ 33 files changed, 61 insertions(+), 40 deletions(-) diff --git a/__tests__/screens/stable-balance-settings-screen.spec.tsx b/__tests__/screens/stable-balance-settings-screen.spec.tsx index 18af5995d8..547f987685 100644 --- a/__tests__/screens/stable-balance-settings-screen.spec.tsx +++ b/__tests__/screens/stable-balance-settings-screen.spec.tsx @@ -46,6 +46,19 @@ jest.mock("@app/self-custodial/bridge", () => ({ 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" }, })) @@ -71,7 +84,7 @@ jest.mock("@app/i18n/i18n-react", () => ({ activeHint: () => "Holding USD", inactiveHint: () => "Holding BTC only", deactivateWarningBody: ({ amount }: { amount: string }) => - `You still have ${amount} USD.`, + `You still have ${amount}.`, toggleFailedToast: () => "Could not update Stable Balance. Please try again.", toggleModal: { activateTitle: () => "Activate Stable Balance", @@ -213,7 +226,7 @@ describe("StableBalanceSettingsScreen", () => { expect(getByTestId("stable-balance-confirm-modal")).toBeTruthy() expect(getByText("Deactivate Stable Balance")).toBeTruthy() - expect(getByText("You still have 5.00 USD.")).toBeTruthy() + expect(getByText("You still have $5.00.")).toBeTruthy() expect(getByText("$0.05")).toBeTruthy() expect(mockDeactivate).not.toHaveBeenCalled() }) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index d0113b81ad..f706f71c9b 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -3777,7 +3777,7 @@ const en: BaseTranslation = { activeHint: "Your wallet is holding USD via USDB.", inactiveHint: "Your wallet is holding BTC only.", deactivateWarningBody: - "You still have {amount:string} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + "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", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index dc232ef14a..cc12b1ab02 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -11996,7 +11996,7 @@ type RootTranslation = { */ inactiveHint: string /** - * Y​o​u​ ​s​t​i​l​l​ ​h​a​v​e​ ​{​a​m​o​u​n​t​}​ ​U​S​D​.​ ​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​. + * 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'> @@ -23920,7 +23920,7 @@ export type TranslationFunctions = { */ inactiveHint: () => LocalizedString /** - * You still have {amount} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance. + * 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 /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 571405dd1c..be0ada78ea 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -3617,7 +3617,7 @@ "activationLabel": "Active", "activeHint": "Your wallet is holding USD via USDB.", "inactiveHint": "Your wallet is holding BTC only.", - "deactivateWarningBody": "You still have {amount:string} USD. Convert to BTC first, or your USD balance will be hidden until you reactivate Stable Balance.", + "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", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index e8ed11fd77..2014d92476 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -3617,7 +3617,7 @@ "activationLabel": "Aktief", "activeHint": "Jou beursie hou USD via USDB.", "inactiveHint": "Jou beursie hou net BTC.", - "deactivateWarningBody": "Jy het nog $ {amount:string} USD. Skakel eers om na BTC, anders word jou USD-saldo versteek totdat jy Stabiele Balans heraktiveer.", + "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", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index ccb819f545..07c269bfce 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -3614,7 +3614,7 @@ "activationLabel": "مفعل", "activeHint": "محفظتك تحتفظ بالدولار عبر USDB.", "inactiveHint": "محفظتك تحتفظ بالبيتكوين فقط.", - "deactivateWarningBody": "لا يزال لديك {amount:string} دولار. قم بالتحويل إلى بيتكوين أولاً، وإلا سيتم إخفاء رصيد الدولار حتى تُعيد تفعيل الرصيد المستقر.", + "deactivateWarningBody": "لا يزال لديك {amount:string}. قم بالتحويل إلى بيتكوين أولاً، وإلا سيتم إخفاء رصيد الدولار حتى تقوم بإعادة تفعيل الرصيد المستقر.", "toggleFailedToast": "تعذر تحديث الرصيد المستقر. يرجى المحاولة مرة أخرى.", "toggleModal": { "activateTitle": "تفعيل الرصيد الثابت", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index ce58bcb197..8c3a2a567c 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -3576,7 +3576,7 @@ "activationLabel": "Actiu", "activeHint": "La teva cartera manté USD via USDB.", "inactiveHint": "La teva cartera manté només BTC.", - "deactivateWarningBody": "Encara tens {amount:string} USD. Converteix a BTC primer, o el teu saldo en USD quedarà ocult fins que reactivis Saldo Estable.", + "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", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index d90aea9326..9e0397d643 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -3617,7 +3617,7 @@ "activationLabel": "Aktivní", "activeHint": "Peněženka drží USD přes USDB.", "inactiveHint": "Peněženka drží pouze BTC.", - "deactivateWarningBody": "Máš ještě {amount:string} USD. Nejdřív převeď na BTC, jinak bude zůstatek v USD skrytý, dokud Stabilní zůstatek znovu neaktivuješ.", + "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", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index b14a222947..e4d41d871e 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -3594,7 +3594,7 @@ "activationLabel": "Aktiv", "activeHint": "Din pung holder USD via USDB.", "inactiveHint": "Din pung holder kun BTC.", - "deactivateWarningBody": "Du har stadig {amount:string} USD. Konverter til BTC først, ellers skjules din USD-saldo, indtil du genaktiverer Stabil Saldo.", + "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", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index 6dabcda083..4cdc498602 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -3564,7 +3564,7 @@ "activationLabel": "Aktiv", "activeHint": "Deine Wallet hält USD über USDB.", "inactiveHint": "Deine Wallet hält nur BTC.", - "deactivateWarningBody": "Du hast noch {amount:string} USD. Wandle zuerst in BTC um, sonst wird dein USD-Guthaben ausgeblendet, bis du Stabiler Saldo wieder aktivierst.", + "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", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index c4b1ac6a46..1ef9a2556e 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -3576,7 +3576,7 @@ "activationLabel": "Ενεργό", "activeHint": "Το πορτοφόλι διατηρεί USD μέσω USDB.", "inactiveHint": "Το πορτοφόλι διατηρεί μόνο BTC.", - "deactivateWarningBody": "Έχεις ακόμη {amount:string} USD. Μετατρέψτε πρώτα σε BTC, αλλιώς το υπόλοιπο USD θα είναι κρυφό μέχρι να ενεργοποιήσετε ξανά το Σταθερό Υπόλοιπο.", + "deactivateWarningBody": "Έχεις ακόμη {amount:string}. Μετατρέψτε πρώτα σε BTC, αλλιώς το υπόλοιπο USD θα είναι κρυφό μέχρι να επανενεργοποιήσετε το Stable Balance.", "toggleFailedToast": "Δεν ήταν δυνατή η ενημέρωση του Stable Balance. Δοκιμάστε ξανά.", "toggleModal": { "activateTitle": "Ενεργοποίηση σταθερού υπολοίπου", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 5b9cdea08d..3d0a5edaac 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -3564,7 +3564,7 @@ "activationLabel": "Activo", "activeHint": "Tu billetera mantiene USD vía USDB.", "inactiveHint": "Tu billetera solo mantiene BTC.", - "deactivateWarningBody": "Todavía tienes {amount:string} USD. Conviértelos a BTC primero, o tu saldo en USD quedará oculto hasta que reactives Saldo Estable.", + "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", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index 194d022d97..dc33bcd111 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -3605,7 +3605,7 @@ "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} USD. Convertissez-les d'abord en BTC, sinon votre solde USD sera masqué jusqu'à la réactivation du Solde Stable.", + "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", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index 450fc3652f..f5c11a8e5f 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -3617,7 +3617,7 @@ "activationLabel": "Aktivno", "activeHint": "Tvoj novčanik drži USD putem USDB.", "inactiveHint": "Tvoj novčanik drži samo BTC.", - "deactivateWarningBody": "Još imaš {amount:string} USD. Pretvori u BTC prvo, inače će tvoje USD stanje biti skriveno dok ponovno ne aktiviraš Stabilno stanje.", + "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", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index f591fd5274..ac0d751eec 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -3576,7 +3576,7 @@ "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} USD-d. Először konvertáld BTC-re, különben az USD egyenleged rejtett marad, amíg újra nem aktiválod a Stabil egyenleget.", + "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", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index f4be1f5732..c2eca7e079 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -3617,7 +3617,7 @@ "activationLabel": "Ակտիվ", "activeHint": "Ձեր դրամապանակը USD է պահում USDB-ի միջոցով։", "inactiveHint": "Ձեր դրամապանակը պահում է միայն BTC։", - "deactivateWarningBody": "Դուք դեռ ունեք {amount:string} USD։ Նախ փոխարկեք BTC, այլապես ձեր USD մնացորդը թաքցվելու է, մինչև նորից չակտիվացնեք Կայուն մնացորդը։", + "deactivateWarningBody": "Դուք դեռ ունեք {amount:string}։ Նախ փոխարկեք BTC, այլապես ձեր USD մնացորդը թաքցվելու է, մինչև նորից ակտիվացնեք Stable Balance-ը։", "toggleFailedToast": "Չհաջողվեց թարմացնել Stable Balance-ը։ Խնդրում ենք փորձել կրկին։", "toggleModal": { "activateTitle": "Ակտիվացնել կայուն մնացորդը", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index b0bdbbd6bd..716832b1a5 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -3576,7 +3576,7 @@ "activationLabel": "Aktif", "activeHint": "Dompet Anda menyimpan USD melalui USDB.", "inactiveHint": "Dompet Anda hanya menyimpan BTC.", - "deactivateWarningBody": "Anda masih memiliki {amount:string} USD. Konversikan ke BTC terlebih dahulu, atau saldo USD Anda akan disembunyikan hingga Anda mengaktifkan kembali Saldo Stabil.", + "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", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index 13ec862782..8f01e6f43c 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -3564,7 +3564,7 @@ "activationLabel": "Attivo", "activeHint": "Il tuo portafoglio detiene USD tramite USDB.", "inactiveHint": "Il tuo portafoglio detiene solo BTC.", - "deactivateWarningBody": "Hai ancora {amount:string} USD. Converti prima in BTC, altrimenti il tuo saldo in USD sarà nascosto finché non riattiverai Saldo Stabile.", + "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", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index 33304f7b66..aaecb3fd87 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -3605,7 +3605,7 @@ "activationLabel": "有効", "activeHint": "ウォレットはUSDB経由でUSDを保持しています。", "inactiveHint": "ウォレットはBTCのみを保持しています。", - "deactivateWarningBody": "まだ {amount:string} USDあります。先にBTCに変換してください。そうしないと、安定残高を再度有効にするまでUSD残高は非表示になります。", + "deactivateWarningBody": "まだ {amount:string} あります。先にBTCに変換してください。そうしないと、Stable Balanceを再度有効にするまでUSD残高は非表示になります。", "toggleFailedToast": "Stable Balanceを更新できませんでした。もう一度お試しください。", "toggleModal": { "activateTitle": "安定残高を有効化", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index b8fef42264..a5ced0a325 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -3576,7 +3576,7 @@ "activationLabel": "Kisaliddwa", "activeHint": "Valet yo ekwata USD nga eyita mu USDB.", "inactiveHint": "Valet yo ekwata BTC kyokka.", - "deactivateWarningBody": "Okyasigalawo {amount:string} USD. Sooka ofuule BTC, oba balansi yo eya USD eyiinzira nga tokyalongedde Balansi Enywevu.", + "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", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 155fa61109..15a11777bb 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -3617,7 +3617,7 @@ "activationLabel": "Aktif", "activeHint": "Dompet anda menyimpan USD melalui USDB.", "inactiveHint": "Dompet anda hanya menyimpan BTC.", - "deactivateWarningBody": "Anda masih mempunyai {amount:string} USD. Tukarkan ke BTC terlebih dahulu, atau baki USD anda akan disembunyikan sehingga anda mengaktifkan semula Baki Stabil.", + "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", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index a5b65c331c..a0a18baffe 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -3617,7 +3617,7 @@ "activationLabel": "Actief", "activeHint": "Je wallet houdt USD via USDB.", "inactiveHint": "Je wallet houdt alleen BTC.", - "deactivateWarningBody": "Je hebt nog {amount:string} USD. Zet eerst om naar BTC, anders wordt je USD-saldo verborgen totdat je Stabiel Saldo weer inschakelt.", + "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", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 9be49180eb..717c298b42 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -3564,7 +3564,7 @@ "activationLabel": "Ativo", "activeHint": "A tua carteira mantém USD via USDB.", "inactiveHint": "A tua carteira só mantém BTC.", - "deactivateWarningBody": "Ainda tens {amount:string} USD. Converte primeiro para BTC, ou o teu saldo em USD ficará oculto até reativares o Saldo Estável.", + "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", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index a4a81c98ec..d5f69358a6 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -3614,7 +3614,7 @@ "activationLabel": "Kawsachiy", "activeHint": "Walletniyki USD-ta USDB-nintakama waqaychan.", "inactiveHint": "Walletniyki BTC-llata waqaychan.", - "deactivateWarningBody": "Qanqa kanraqmi {amount:string} USD. Ñawpaqta BTC-man tikray, manaña chayqa USD saldoyki pakakunqa Saldo Takyasqa kutiy kawsachisqa kanan kama.", + "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", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index fd5f615735..d374e80d2d 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -3576,7 +3576,7 @@ "activationLabel": "Activ", "activeHint": "Portofelul tău păstrează USD prin USDB.", "inactiveHint": "Portofelul tău păstrează doar BTC.", - "deactivateWarningBody": "Mai ai {amount:string} USD. Convertește mai întâi în BTC, altfel soldul în USD va fi ascuns până reactivezi Sold Stabil.", + "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", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 9f3cb83d32..85dc02eac1 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -3576,7 +3576,7 @@ "activationLabel": "Aktívny", "activeHint": "Peňaženka drží USD cez USDB.", "inactiveHint": "Peňaženka drží iba BTC.", - "deactivateWarningBody": "Máš ešte {amount:string} USD. Najprv previedť na BTC, inak bude USD zostatok skrytý, kým znovu neaktivuješ Stabilný zostatok.", + "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", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index bdbb090be4..9f454693a7 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -3614,7 +3614,7 @@ "activationLabel": "Активно", "activeHint": "Твој новчаник држи USD преко USDB.", "inactiveHint": "Твој новчаник држи само BTC.", - "deactivateWarningBody": "Још имаш {amount:string} USD. Прво пребаци у BTC, иначе ће твој USD биланс бити сакривен док поново не активираш Стабилно стање.", + "deactivateWarningBody": "Још имаш {amount:string}. Прво пребаци у BTC, иначе ће твој USD биланс бити сакривен док поново не активираш Stable Balance.", "toggleFailedToast": "Није могуће ажурирати Stable Balance. Покушајте поново.", "toggleModal": { "activateTitle": "Активирај стабилни салдо", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index 41fe25d939..d0cc77f2a6 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -3576,7 +3576,7 @@ "activationLabel": "Hai", "activeHint": "Pochi yako inashikilia USD kupitia USDB.", "inactiveHint": "Pochi yako inashikilia BTC tu.", - "deactivateWarningBody": "Bado una {amount:string} USD. Badilisha kuwa BTC kwanza, ama salio lako la USD litafichwa hadi uwashe tena Salio Thabiti.", + "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", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index f12e0bce74..48ee6c4e60 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -3614,7 +3614,7 @@ "activationLabel": "เปิดใช้งาน", "activeHint": "กระเป๋าเงินของคุณถือ USD ผ่าน USDB", "inactiveHint": "กระเป๋าเงินของคุณถือ BTC เท่านั้น", - "deactivateWarningBody": "คุณยังมี {amount:string} USD โปรดแปลงเป็น BTC ก่อน มิฉะนั้นยอด USD จะถูกซ่อนไว้จนกว่าคุณจะเปิดยอดเงินคงที่อีกครั้ง", + "deactivateWarningBody": "คุณยังมี {amount:string} โปรดแปลงเป็น BTC ก่อน มิฉะนั้นยอด USD จะถูกซ่อนไว้จนกว่าคุณจะเปิด Stable Balance อีกครั้ง", "toggleFailedToast": "ไม่สามารถอัปเดต Stable Balance ได้ โปรดลองอีกครั้ง", "toggleModal": { "activateTitle": "เปิดใช้ยอดคงเหลือแบบเสถียร", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index 3f002a6b32..35b1c7e9c8 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -3576,7 +3576,7 @@ "activationLabel": "Aktif", "activeHint": "Cüzdanınız USDB üzerinden USD tutuyor.", "inactiveHint": "Cüzdanınız sadece BTC tutuyor.", - "deactivateWarningBody": "Hâlâ {amount:string} USD'niz var. Önce BTC'ye dönüştürün; aksi halde Stabil Bakiye'yi tekrar etkinleştirene kadar USD bakiyeniz gizlenir.", + "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", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index 94cca5e032..6d333500fa 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -3614,7 +3614,7 @@ "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} USD. 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 Số Dư Ổn Định.", + "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", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 83e3b121e7..077b71d145 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -3623,7 +3623,7 @@ "activationLabel": "Iyasebenza", "activeHint": "Isipaji sakho sigcina i-USD ngeUSDB.", "inactiveHint": "Isipaji sakho sigcina i-BTC kuphela.", - "deactivateWarningBody": "Usenayo {amount:string} USD. Yitshintshele kwi-BTC kuqala, okanye ibhalansi yakho yeUSD iza kufihlakala de uyivuselele iBhalansi Ezinzileyo.", + "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", 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 index cde8dedcdd..d046ebcee4 100644 --- a/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -6,6 +6,8 @@ import crashlytics from "@react-native-firebase/crashlytics" 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 { activateStableBalance, @@ -13,6 +15,7 @@ import { } from "@app/self-custodial/bridge" import { SparkToken } from "@app/self-custodial/config" import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" +import { DisplayCurrency, toUsdMoneyAmount } from "@app/types/amounts" import { WalletCurrency } from "@app/graphql/generated" import { testProps } from "@app/utils/testProps" import { toastShow } from "@app/utils/toast" @@ -26,15 +29,14 @@ const ToggleDirection = { } as const type ToggleDirection = (typeof ToggleDirection)[keyof typeof ToggleDirection] -const USD_CENTS_PER_DOLLAR = 100 -const USD_FRACTION_DIGITS = 2 - 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, @@ -56,6 +58,14 @@ export const StableBalanceSettingsScreen: React.FC = () => { const hasUsdBalance = usdBalanceAmount > 0 const hasBtcBalance = btcBalanceAmount > 0 + const formatUsdBalanceForDisplay = (cents: number): string => { + const usdAmount = toUsdMoneyAmount(cents) + if (!convertMoneyAmount) return formatMoneyAmount({ moneyAmount: usdAmount }) + return formatMoneyAmount({ + moneyAmount: convertMoneyAmount(usdAmount, DisplayCurrency), + }) + } + const isActivating = pendingDirection === ToggleDirection.Activate const sourceBalance = isActivating ? btcBalanceAmount : usdBalanceAmount const fromCurrency = isActivating ? WalletCurrency.Btc : WalletCurrency.Usd @@ -163,9 +173,7 @@ export const StableBalanceSettingsScreen: React.FC = () => { deactivationWarning={ pendingDirection === ToggleDirection.Deactivate && hasUsdBalance ? LL.StableBalance.deactivateWarningBody({ - amount: (usdBalanceAmount / USD_CENTS_PER_DOLLAR).toFixed( - USD_FRACTION_DIGITS, - ), + amount: formatUsdBalanceForDisplay(usdBalanceAmount), }) : undefined } From 4ffc30fcf2007f2c1a512ad9c04371a429f68e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:12:00 -0600 Subject: [PATCH 66/71] test(stable-balance,conversion-confirmation): add status-pill spec and SC submit describe --- __tests__/components/status-pill.spec.tsx | 52 +++++ .../conversion-confirmation.spec.tsx | 181 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 __tests__/components/status-pill.spec.tsx 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__/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() + }) +}) From 4a90eeb6f82024b7884eb33ad8c6b0f5b7fd4f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:21:06 -0600 Subject: [PATCH 67/71] refactor(self-custodial): cache validated SPARK_TOKEN_IDENTIFIER so hot paths read instead of re-throwing --- app/self-custodial/config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/self-custodial/config.ts b/app/self-custodial/config.ts index 7739f1b2aa..33cc67d3ce 100644 --- a/app/self-custodial/config.ts +++ b/app/self-custodial/config.ts @@ -35,10 +35,19 @@ export const SparkConfig = { 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 } From 39edc9f45cbce4606ae87086faae9d32ea79d5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:22:18 -0600 Subject: [PATCH 68/71] fix(conversion): drop duplicate crashlytics report in useConversionQuote (bridge already records with breadcrumbs) --- app/screens/conversion-flow/hooks/use-conversion-quote.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/screens/conversion-flow/hooks/use-conversion-quote.ts b/app/screens/conversion-flow/hooks/use-conversion-quote.ts index 34536c9c6a..62c01911b1 100644 --- a/app/screens/conversion-flow/hooks/use-conversion-quote.ts +++ b/app/screens/conversion-flow/hooks/use-conversion-quote.ts @@ -1,7 +1,5 @@ import { useEffect, useMemo, useState } from "react" -import crashlytics from "@react-native-firebase/crashlytics" - import { useDisplayCurrency } from "@app/hooks/use-display-currency" import { usePayments } from "@app/hooks/use-payments" import { usePriceConversion } from "@app/hooks/use-price-conversion" @@ -58,9 +56,11 @@ export const useConversionQuote = ( } setState({ status: QuoteStatus.Ready, quote }) }) - .catch((err) => { + .catch(() => { if (cancelled) return - if (err instanceof Error) crashlytics().recordError(err) + // 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 () => { From f6194b64d953754f0e24f109749831c4094b34a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:24:50 -0600 Subject: [PATCH 69/71] refactor(amounts): extract formatUsdInDisplay helper and tighten use-conversion-quote memo deps --- .../hooks/use-conversion-quote.ts | 24 +++++++------- .../stable-balance-settings-screen.tsx | 15 +++------ app/utils/amounts.ts | 32 ++++++++++++++++++- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/app/screens/conversion-flow/hooks/use-conversion-quote.ts b/app/screens/conversion-flow/hooks/use-conversion-quote.ts index 62c01911b1..268ca49946 100644 --- a/app/screens/conversion-flow/hooks/use-conversion-quote.ts +++ b/app/screens/conversion-flow/hooks/use-conversion-quote.ts @@ -4,12 +4,12 @@ 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 { DisplayCurrency } from "@app/types/amounts" import { ConvertAmountAdjustment, type ConvertParams, type ConvertQuote, } from "@app/types/payment.types" +import { formatUsdInDisplay } from "@app/utils/amounts" const QuoteStatus = { Idle: "idle", @@ -68,24 +68,26 @@ export const useConversionQuote = ( } }, [getConversionQuote, quoteParams]) + const { quote } = state + const feeText = useMemo(() => { - if (state.status !== QuoteStatus.Ready || !state.quote) return "" - if (!convertMoneyAmount) return "" - const feeInDisplay = convertMoneyAmount(state.quote.feeAmount, DisplayCurrency) - return formatMoneyAmount({ moneyAmount: feeInDisplay }) - }, [state, formatMoneyAmount, convertMoneyAmount]) + if (!quote) return "" + return formatUsdInDisplay(quote.feeAmount.amount, { + formatMoneyAmount, + convertMoneyAmount, + }) + }, [quote, formatMoneyAmount, convertMoneyAmount]) const adjustmentText = useMemo(() => { - if (state.status !== QuoteStatus.Ready || !state.quote) return null - const adjustment = state.quote.amountAdjustment - if (adjustment === ConvertAmountAdjustment.FlooredToMin) { + if (!quote) return null + if (quote.amountAdjustment === ConvertAmountAdjustment.FlooredToMin) { return LL.ConversionConfirmationScreen.amountFloored() } - if (adjustment === ConvertAmountAdjustment.IncreasedToAvoidDust) { + if (quote.amountAdjustment === ConvertAmountAdjustment.IncreasedToAvoidDust) { return LL.ConversionConfirmationScreen.amountDustBumped() } return null - }, [state, LL]) + }, [quote, LL]) return { isQuoting: state.status === QuoteStatus.Loading, 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 index d046ebcee4..323381f01c 100644 --- a/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -15,8 +15,8 @@ import { } from "@app/self-custodial/bridge" import { SparkToken } from "@app/self-custodial/config" import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" -import { DisplayCurrency, toUsdMoneyAmount } from "@app/types/amounts" import { WalletCurrency } from "@app/graphql/generated" +import { formatUsdInDisplay } from "@app/utils/amounts" import { testProps } from "@app/utils/testProps" import { toastShow } from "@app/utils/toast" @@ -58,14 +58,6 @@ export const StableBalanceSettingsScreen: React.FC = () => { const hasUsdBalance = usdBalanceAmount > 0 const hasBtcBalance = btcBalanceAmount > 0 - const formatUsdBalanceForDisplay = (cents: number): string => { - const usdAmount = toUsdMoneyAmount(cents) - if (!convertMoneyAmount) return formatMoneyAmount({ moneyAmount: usdAmount }) - return formatMoneyAmount({ - moneyAmount: convertMoneyAmount(usdAmount, DisplayCurrency), - }) - } - const isActivating = pendingDirection === ToggleDirection.Activate const sourceBalance = isActivating ? btcBalanceAmount : usdBalanceAmount const fromCurrency = isActivating ? WalletCurrency.Btc : WalletCurrency.Usd @@ -173,7 +165,10 @@ export const StableBalanceSettingsScreen: React.FC = () => { deactivationWarning={ pendingDirection === ToggleDirection.Deactivate && hasUsdBalance ? LL.StableBalance.deactivateWarningBody({ - amount: formatUsdBalanceForDisplay(usdBalanceAmount), + amount: formatUsdInDisplay(usdBalanceAmount, { + formatMoneyAmount, + convertMoneyAmount, + }), }) : undefined } diff --git a/app/utils/amounts.ts b/app/utils/amounts.ts index 6aa424468d..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, From addda9a9bb72d1bd698952342fff93f99b5a06e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:27:12 -0600 Subject: [PATCH 70/71] refactor(stable-balance): extract useStableBalanceToggle hook from settings screen --- .../hooks/index.ts | 2 + .../hooks/use-stable-balance-toggle.ts | 81 +++++++++++++++++++ .../stable-balance-settings-screen.tsx | 50 ++---------- 3 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 app/screens/stable-balance-settings-screen/hooks/use-stable-balance-toggle.ts diff --git a/app/screens/stable-balance-settings-screen/hooks/index.ts b/app/screens/stable-balance-settings-screen/hooks/index.ts index efaf95952a..a34ddd00bd 100644 --- a/app/screens/stable-balance-settings-screen/hooks/index.ts +++ b/app/screens/stable-balance-settings-screen/hooks/index.ts @@ -1,2 +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.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/stable-balance-settings-screen.tsx b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx index 323381f01c..b91a1c934c 100644 --- a/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx +++ b/app/screens/stable-balance-settings-screen/stable-balance-settings-screen.tsx @@ -2,26 +2,19 @@ import React, { useState } from "react" import { ActivityIndicator, View } from "react-native" import { makeStyles, Text } from "@rn-vui/themed" -import crashlytics from "@react-native-firebase/crashlytics" 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 { - activateStableBalance, - deactivateStableBalance, -} from "@app/self-custodial/bridge" -import { SparkToken } from "@app/self-custodial/config" 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 { toastShow } from "@app/utils/toast" import { StableBalanceConfirmModal } from "./stable-balance-confirm-modal" -import { useStableBalanceToggleQuote } from "./hooks" +import { useStableBalanceToggle, useStableBalanceToggleQuote } from "./hooks" const ToggleDirection = { Activate: "activate", @@ -44,12 +37,15 @@ export const StableBalanceSettingsScreen: React.FC = () => { refreshWallets, refreshStableBalanceActive, } = useSelfCustodialWallet() - const [busy, setBusy] = useState(false) - const [pendingValue, setPendingValue] = useState(null) - const [switchKey, setSwitchKey] = useState(0) const [pendingDirection, setPendingDirection] = useState(null) - const resyncSwitch = () => setSwitchKey((k) => k + 1) + const { busy, displayValue, switchKey, apply, resyncSwitch } = useStableBalanceToggle({ + sdk, + isStableBalanceActive, + refreshWallets, + refreshStableBalanceActive, + LL, + }) const btcBalanceAmount = wallets.find((w) => w.walletCurrency === WalletCurrency.Btc)?.balance.amount ?? 0 @@ -68,34 +64,6 @@ export const StableBalanceSettingsScreen: React.FC = () => { enabled: pendingDirection !== null, }) - const apply = 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) - } - } - const handleToggle = (next: boolean) => { if (next && !hasBtcBalance) { apply(true) @@ -115,12 +83,10 @@ export const StableBalanceSettingsScreen: React.FC = () => { const handleConfirmModal = async () => { const activate = pendingDirection === ToggleDirection.Activate - setPendingValue(activate) setPendingDirection(null) await apply(activate) } - const displayValue = pendingValue ?? isStableBalanceActive const showFeeRow = sourceBalance > 0 return ( From 1af9609171eb8def0c4c699e129cf1e193a3cdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez=29?= Date: Fri, 8 May 2026 15:29:39 -0600 Subject: [PATCH 71/71] chore(self-custodial): expose recordErrorOnce reset for tests and document use-sdk-lifecycle race-condition disables --- app/self-custodial/logging.ts | 7 +++++++ app/self-custodial/providers/use-sdk-lifecycle.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/app/self-custodial/logging.ts b/app/self-custodial/logging.ts index c763c6b43a..e0056ebe4f 100644 --- a/app/self-custodial/logging.ts +++ b/app/self-custodial/logging.ts @@ -8,6 +8,13 @@ export const recordErrorOnce = (dedupKey: string, error: Error): void => { 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/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 0c16d47f7e..99f0f55dd0 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -73,6 +73,11 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } }, []) + // `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