Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
8eeb393
feat(self-custodial): detect stale balance and surface STALE pill + t…
esaugomez31 Apr 18, 2026
48020c5
chore(spark): bump breez-sdk-spark-react-native to 0.13.2-dev3
esaugomez31 Apr 22, 2026
6344ec4
feat(self-custodial): extend amounts helpers with token base unit con…
esaugomez31 Apr 22, 2026
34bfc39
feat(self-custodial): add SparkToken.DefaultDecimals constant
esaugomez31 Apr 22, 2026
b14bfe3
feat(payments): model ConvertParams, ConversionLimits, ConvertQuote a…
esaugomez31 Apr 22, 2026
f8d3499
feat(self-custodial): add token-balance bridge helpers for USDB metadata
esaugomez31 Apr 22, 2026
c5fa99c
feat(self-custodial): add fetchConversionLimits and buildConversionTy…
esaugomez31 Apr 22, 2026
a2fac8e
feat(self-custodial): add activateStableBalance and deactivateStableB…
esaugomez31 Apr 22, 2026
32f93fb
feat(self-custodial): implement BTC and USDB conversion adapter with …
esaugomez31 Apr 22, 2026
d068821
chore(self-custodial): expose new bridge modules from barrel
esaugomez31 Apr 22, 2026
06aa1dd
feat(self-custodial): surface Stable Balance state and refresh in SDK…
esaugomez31 Apr 22, 2026
c844221
feat(self-custodial): propagate Stable Balance state via wallet provider
esaugomez31 Apr 22, 2026
7a14103
feat(self-custodial): filter unknown tokens and track raw pagination …
esaugomez31 Apr 22, 2026
7711a5e
feat(self-custodial): map token payments and conversion metadata in t…
esaugomez31 Apr 22, 2026
794b11f
feat(self-custodial): convert fee to settlement currency in transacti…
esaugomez31 Apr 22, 2026
1d71436
feat(payments): wire getConversionQuote in usePayments
esaugomez31 Apr 22, 2026
082fc9e
feat(self-custodial): add useNonCustodialConversionLimits hook
esaugomez31 Apr 22, 2026
e196465
feat(home): add useBalanceMode hook for BTC and USD display toggle
esaugomez31 Apr 22, 2026
e224d9c
feat(stable-balance): add useStableBalanceFirstTime acknowledgment hook
esaugomez31 Apr 22, 2026
3aa1cb7
feat(i18n): add Stable Balance and conversion copy across 28 locales
esaugomez31 Apr 22, 2026
e03989f
feat(balance-header): add Stable Balance mode toggle support
esaugomez31 Apr 22, 2026
5f49671
feat(stable-balance): add first-time explanation modal
esaugomez31 Apr 22, 2026
5782aa3
feat(convert): add ConversionFeeRow presentational component
esaugomez31 Apr 22, 2026
6024b3d
feat(convert): add useConversionQuote common hook
esaugomez31 Apr 22, 2026
6b9b943
feat(convert): add useNonCustodialConversion flow hook
esaugomez31 Apr 22, 2026
be302ad
feat(convert): support self-custodial conversion in details and confi…
esaugomez31 Apr 22, 2026
324f0f0
feat(home): wire balance mode toggle and Stable Balance display
esaugomez31 Apr 22, 2026
e25b48c
feat(stable-balance): add Stable Balance settings screen with toggle …
esaugomez31 Apr 22, 2026
63f3ef9
feat(settings): add Stable Balance entry row
esaugomez31 Apr 22, 2026
84c0777
feat(navigation): register stableBalanceSettings route
esaugomez31 Apr 22, 2026
ded3ac3
test(hooks): cover getConversionQuote wiring in usePayments spec
esaugomez31 Apr 22, 2026
a1a188f
test(convert): update conversion-details spec for self-custodial wiring
esaugomez31 Apr 22, 2026
13abfce
test(home): cover Stable Balance toggle rendering in home spec
esaugomez31 Apr 22, 2026
070a9eb
test(self-custodial): update payment-adapter spec for new SDK mocks
esaugomez31 Apr 22, 2026
24ec741
test(self-custodial): cover refreshStableBalanceActive and AppState p…
esaugomez31 Apr 22, 2026
37759e4
test(self-custodial): update wallet-snapshot spec for token filter an…
esaugomez31 Apr 22, 2026
c881d02
test(balance-header): add BalanceHeader component spec
esaugomez31 Apr 22, 2026
feac156
test(stable-balance): add first-time modal spec
esaugomez31 Apr 22, 2026
81e44bc
test(hooks): add useBalanceMode spec
esaugomez31 Apr 22, 2026
1437025
test(hooks): add useStableBalanceFirstTime spec
esaugomez31 Apr 22, 2026
5228775
test(convert): add conversion-details first-time modal spec
esaugomez31 Apr 22, 2026
34cfd86
test(convert): add ConversionFeeRow spec
esaugomez31 Apr 22, 2026
dd0197f
test(convert): add useConversionQuote spec
esaugomez31 Apr 22, 2026
66839ad
test(convert): add useNonCustodialConversion spec
esaugomez31 Apr 22, 2026
9c1d733
test(settings): add StableBalanceSetting row spec
esaugomez31 Apr 22, 2026
0768cfb
test(stable-balance): add settings screen spec
esaugomez31 Apr 22, 2026
661d989
test(stable-balance): add useStableBalanceToggleQuote spec
esaugomez31 Apr 22, 2026
a0359c4
test(self-custodial): add bridge convert spec
esaugomez31 Apr 22, 2026
82c9b58
test(self-custodial): add bridge limits spec
esaugomez31 Apr 22, 2026
1fa96b0
test(self-custodial): add bridge stable-balance spec
esaugomez31 Apr 22, 2026
43ea207
test(self-custodial): add bridge token-balance spec
esaugomez31 Apr 22, 2026
fd4debc
test(self-custodial): add useNonCustodialConversionLimits spec
esaugomez31 Apr 22, 2026
50f4d8d
feat(i18n): add StableBalance.toggleFailedToast key
esaugomez31 May 8, 2026
d1f939f
fix(stable-balance): catch toggle errors and disable confirm on fee p…
esaugomez31 May 8, 2026
b9988ef
fix(self-custodial): record stable-balance refresh failures to crashl…
esaugomez31 May 8, 2026
35c81d1
fix(self-custodial): require SPARK_TOKEN_IDENTIFIER and fail fast whe…
esaugomez31 May 8, 2026
1b3bc7a
feat(i18n): add StableBalance.conversionUnavailable key
esaugomez31 May 8, 2026
cf7b340
fix(self-custodial): ceil token-denominated conversion minimums to ke…
esaugomez31 May 8, 2026
7505e4d
fix(conversion): surface limits-load failures in conversion details s…
esaugomez31 May 8, 2026
82e8e36
fix(convert): record SDK errors to crashlytics, drop dead createConve…
esaugomez31 May 8, 2026
6aae236
fix(conversion): snapshot the first Ready quote on confirmation to ke…
esaugomez31 May 8, 2026
0c983a7
fix(self-custodial): scale token-payment fees to USD and dedupe crash…
esaugomez31 May 8, 2026
36ff5b8
fix(self-custodial): preserve loadMore cursor across refresh by re-fe…
esaugomez31 May 8, 2026
c04f780
fix(self-custodial): record polling/AppState refresh failures, dedupe…
esaugomez31 May 8, 2026
9bd4aa8
fix(stable-balance): format deactivation warning amount through displ…
esaugomez31 May 8, 2026
4ffc30f
test(stable-balance,conversion-confirmation): add status-pill spec an…
esaugomez31 May 8, 2026
4a90eeb
refactor(self-custodial): cache validated SPARK_TOKEN_IDENTIFIER so h…
esaugomez31 May 8, 2026
39edc9f
fix(conversion): drop duplicate crashlytics report in useConversionQu…
esaugomez31 May 8, 2026
f6194b6
refactor(amounts): extract formatUsdInDisplay helper and tighten use-…
esaugomez31 May 8, 2026
addda9a
refactor(stable-balance): extract useStableBalanceToggle hook from se…
esaugomez31 May 8, 2026
1af9609
chore(self-custodial): expose recordErrorOnce reset for tests and doc…
esaugomez31 May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion __mocks__/@breeztech/breez-sdk-spark-react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,32 @@ module.exports = {
Seed: { Mnemonic: jest.fn().mockImplementation((args) => args) },
StableBalanceActiveLabel: {
Set: jest.fn().mockImplementation((args) => ({ tag: "Set", inner: args })),
Unset: jest.fn().mockReturnValue({ tag: "Unset" }),
Unset: jest.fn().mockImplementation(() => ({ tag: "Unset" })),
},
ConversionType: {
FromBitcoin: jest.fn().mockImplementation(() => ({ tag: "FromBitcoin" })),
ToBitcoin: jest
.fn()
.mockImplementation((args) => ({ tag: "ToBitcoin", inner: args })),
},
AmountAdjustmentReason: {
FlooredToMinLimit: "FlooredToMinLimit",
IncreasedToAvoidDust: "IncreasedToAvoidDust",
},
PrepareSendPaymentRequest: { create: (p) => p },
SendPaymentRequest: { create: (p) => p },
ReceivePaymentRequest: { create: (p) => p },
ReceivePaymentMethod: {
SparkAddress: jest.fn().mockImplementation(() => ({ tag: "SparkAddress" })),
SparkInvoice: jest
.fn()
.mockImplementation((args) => ({ tag: "SparkInvoice", inner: args })),
Bolt11Invoice: jest
.fn()
.mockImplementation((args) => ({ tag: "Bolt11Invoice", inner: args })),
BitcoinAddress: jest
.fn()
.mockImplementation((args) => ({ tag: "BitcoinAddress", inner: args })),
},
SdkEvent_Tags,
PaymentMethod: {
Expand Down
125 changes: 125 additions & 0 deletions __tests__/components/balance-header/balance-header.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof BalanceHeader>> = {}) =>
render(
<ThemeProvider theme={theme}>
<BalanceHeader loading={false} formattedBalance="$10" {...props} />
</ThemeProvider>,
)

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()
})
})
61 changes: 61 additions & 0 deletions __tests__/components/stable-balance-first-time-modal.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof StableBalanceFirstTimeModal>> = {},
) =>
render(
<ThemeProvider theme={theme}>
<StableBalanceFirstTimeModal isVisible onAcknowledge={jest.fn()} {...props} />
</ThemeProvider>,
)

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)
})
})
52 changes: 52 additions & 0 deletions __tests__/components/status-pill.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof StatusPill>) =>
render(
<ThemeProvider theme={theme}>
<StatusPill {...props} />
</ThemeProvider>,
)

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()
})
})
3 changes: 2 additions & 1 deletion __tests__/custodial/adapters/payment-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
90 changes: 90 additions & 0 deletions __tests__/hooks/use-balance-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading