Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
da061ce
feat: add transaction metadata fields to NormalizedTransaction
esaugomez31 Apr 14, 2026
f8f0917
feat: add tokenBaseUnitsToCents and PaymentDetails mock
esaugomez31 Apr 14, 2026
42d8131
feat: enrich transaction mapper with memo, lnAddress and conversions
esaugomez31 Apr 14, 2026
c3cb73c
feat: add transaction description resolver
esaugomez31 Apr 14, 2026
89b8e96
feat: add transaction fragment mapper for self-custodial
esaugomez31 Apr 14, 2026
4660e54
feat: add pagination and sdk exposure to wallet provider
esaugomez31 Apr 14, 2026
9bf0708
feat: add self-custodial support to transaction history
esaugomez31 Apr 14, 2026
978a2f3
feat: hide Blink internal ID for self-custodial in transaction detail
esaugomez31 Apr 14, 2026
6d1d654
feat: add TransactionDescription i18n keys for self-custodial
esaugomez31 Apr 14, 2026
03612b2
refactor: restructure self-custodial bridge into domain modules
esaugomez31 Apr 14, 2026
1c04adb
feat: add payment detection and stable balance check to SDK lifecycle
esaugomez31 Apr 14, 2026
1d0a643
feat: add self-custodial receive bridge and payment request hook
esaugomez31 Apr 14, 2026
832af7f
feat: integrate self-custodial receive into receive screen
esaugomez31 Apr 14, 2026
7237658
feat: add i18n keys for self-custodial send and deposit flows
esaugomez31 Apr 16, 2026
b5074e1
feat: extend payment types with refund and deposit support
esaugomez31 Apr 16, 2026
fbc7c08
feat: add self-custodial bridge modules for send, deposits, parse and…
esaugomez31 Apr 16, 2026
871089c
feat: add self-custodial payment adapters
esaugomez31 Apr 16, 2026
39ecb2f
feat: add self-custodial payment detail factories and destination wra…
esaugomez31 Apr 16, 2026
7440201
feat: add Spark destination resolver and extend payment destination t…
esaugomez31 Apr 16, 2026
1af22af
feat: add send wallet hooks and onchain fee tier selector
esaugomez31 Apr 16, 2026
8b0e6d6
feat: add unclaimed deposits screen and banner
esaugomez31 Apr 16, 2026
6e18ffc
feat: integrate self-custodial send into send bitcoin screens
esaugomez31 Apr 16, 2026
70b2689
feat: wire self-custodial adapters into usePayments and register routes
esaugomez31 Apr 16, 2026
1522bdf
test: update global SDK mock with new enum values
esaugomez31 Apr 16, 2026
7da743e
test: add self-custodial bridge module tests
esaugomez31 Apr 16, 2026
0f97ac8
test: add self-custodial adapter tests
esaugomez31 Apr 16, 2026
bd44ca6
test: add self-custodial payment detail and destination tests
esaugomez31 Apr 16, 2026
e8dfd29
test: add send wallet hooks and fee tier tests
esaugomez31 Apr 16, 2026
8670e05
test: add usePayments and transaction history tests
esaugomez31 Apr 16, 2026
bd3e801
fix: prevent receive screen from re-triggering paid state on re-entry
esaugomez31 Apr 16, 2026
cebb028
feat: add SDK error classification and user feedback for onchain fee …
esaugomez31 Apr 16, 2026
35efce3
fix: prevent infinite loading by deferring getUserSettings and exposi…
esaugomez31 Apr 16, 2026
a472a56
fix(self-custodial): refresh balance on getInfo and gate invoice gene…
esaugomez31 Apr 17, 2026
c7512a5
refactor(self-custodial): extract SDK event helpers, network validati…
esaugomez31 Apr 17, 2026
398ee40
fix(self-custodial): revert getWalletInfo to ensureSynced:false to pr…
esaugomez31 Apr 17, 2026
7fe536a
feat(self-custodial): add Spark status bridge module wrapping getSpar…
esaugomez31 Apr 17, 2026
035f179
feat(self-custodial): detect connectivity via Spark status and transi…
esaugomez31 Apr 17, 2026
4b598ef
feat(i18n): add self-custodial offline notice translations across 28 …
esaugomez31 Apr 17, 2026
4f66627
feat(self-custodial): gate payment screens with offline notice when w…
esaugomez31 Apr 17, 2026
a6f1205
feat(self-custodial): route send through USDB when USD wallet is sele…
esaugomez31 Apr 22, 2026
76a0032
fix(send): surface loading state for non-Apollo send mutations
esaugomez31 Apr 22, 2026
c5d6dc2
fix(utils): record crashlytics error when toNumber loses BigInt preci…
esaugomez31 Apr 22, 2026
ce579a7
fix(unclaimed-deposits): refetch banner on wallet refresh and screen …
esaugomez31 Apr 22, 2026
c470a52
refactor(send): classify SDK fee errors by SdkError tag instead of me…
esaugomez31 Apr 22, 2026
f62f620
refactor: move format-fee-tier-options from send-bitcoin hooks to sha…
esaugomez31 Apr 22, 2026
f4ebf9a
refactor(self-custodial): use full SelfCustodial prefix consistently …
esaugomez31 Apr 22, 2026
038d904
refactor(self-custodial): replace wrapDestinationForSC if-chain with …
esaugomez31 Apr 22, 2026
703c077
refactor(self-custodial): replace withOfflineGate HOC with OfflineGat…
esaugomez31 Apr 22, 2026
b377f1a
test(self-custodial): extract shared fixture for SelfCustodialWalletP…
esaugomez31 Apr 22, 2026
1782d34
test: add specs for deposit-error-message and use-onchain-resolver
esaugomez31 Apr 22, 2026
e98667f
refactor(self-custodial): drop crashlytics from helper, relocate fee-…
esaugomez31 Apr 27, 2026
989efc7
refactor(self-custodial): extract amounts helpers to utils, drop SC a…
esaugomez31 Apr 27, 2026
c1de1ad
refactor(self-custodial): drop SC abbreviations in receive and transa…
esaugomez31 Apr 27, 2026
c3502f0
fix(utils): emit BIP-21 amount as fixed decimal and percent-encode memo
esaugomez31 May 6, 2026
2f67993
fix(unclaimed-deposits): surface fee fetch error and reject zero refu…
esaugomez31 May 6, 2026
df27052
fix(self-custodial): treat null onchain fees as error instead of zero
esaugomez31 May 6, 2026
c8b8831
test(unclaimed-deposits): cover banner, mempool utils and broader scr…
esaugomez31 May 6, 2026
c056f2d
fix(payments): drop custodial fallback while activeAccount is loading
esaugomez31 May 6, 2026
b84b381
fix(send-bitcoin): skip custodial fee query when sending from self-cu…
esaugomez31 May 6, 2026
532b658
fix(self-custodial): make transaction mapper exhaustive and report un…
esaugomez31 May 6, 2026
eff0c64
fix(self-custodial): track raw page size for hasMore and dedupe trans…
esaugomez31 May 6, 2026
dd4774f
fix(self-custodial): distinguish unknown connectivity from confirmed …
esaugomez31 May 6, 2026
7f3cad3
fix(self-custodial): replace recursive refresh with do/while loop and…
esaugomez31 May 6, 2026
c155e2e
test: add Tier 1 specs for SC bridge, fee selector, navigation wrappe…
esaugomez31 May 6, 2026
f3253f3
test: tighten Tier 2 specs to surface previously hidden SC behaviour
esaugomez31 May 6, 2026
00160b9
fix(self-custodial): expose ParseSparkAddressOutcome to distinguish p…
esaugomez31 May 7, 2026
b2c3e15
fix(self-custodial): validate depositId format and drop fake claim-fe…
esaugomez31 May 7, 2026
648385d
fix(self-custodial): block payment screens on every unhealthy wallet …
esaugomez31 May 7, 2026
efb1122
fix(self-custodial): coordinate concurrent banner fetches via generat…
esaugomez31 May 7, 2026
292ab18
fix(self-custodial): honour requested feeTier on onchain fee quotes a…
esaugomez31 May 7, 2026
217b108
fix(self-custodial): roll back stored mnemonic when restored wallet c…
esaugomez31 May 7, 2026
501a693
fix(self-custodial): drop fragile dust-message matching from claim/re…
esaugomez31 May 7, 2026
8211015
fix(self-custodial): discard stale fee-tier requests, classify only t…
esaugomez31 May 7, 2026
9444247
fix(self-custodial): record send failures via crashlytics and drop re…
esaugomez31 May 7, 2026
e00cafe
fix(self-custodial): align self-custodial PaymentRequestState with cu…
esaugomez31 May 7, 2026
d1d32d7
test(receive-bitcoin): align BIP-21 helper specs with percent-encoded…
esaugomez31 May 7, 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
55 changes: 55 additions & 0 deletions __mocks__/@breeztech/breez-sdk-spark-react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,55 @@ const PaymentDetails_Tags = {
Deposit: "Deposit",
}

const createInstanceOf = (targetTag) => ({
instanceOf: (obj) => obj?.tag === targetTag,
})

const PaymentDetails = {
Lightning: createInstanceOf("Lightning"),
Spark: createInstanceOf("Spark"),
Token: createInstanceOf("Token"),
Deposit: createInstanceOf("Deposit"),
Withdraw: createInstanceOf("Withdraw"),
}

const SdkError_Tags = {
SparkError: "SparkError",
InsufficientFunds: "InsufficientFunds",
InvalidUuid: "InvalidUuid",
InvalidInput: "InvalidInput",
NetworkError: "NetworkError",
StorageError: "StorageError",
ChainServiceError: "ChainServiceError",
MaxDepositClaimFeeExceeded: "MaxDepositClaimFeeExceeded",
MissingUtxo: "MissingUtxo",
LnurlError: "LnurlError",
Signer: "Signer",
Generic: "Generic",
}

const SdkError = {
instanceOf: (obj) =>
Boolean(obj) &&
typeof obj === "object" &&
typeof obj.tag === "string" &&
Object.values(SdkError_Tags).includes(obj.tag),
}

module.exports = {
connect: jest.fn(),
defaultConfig: jest.fn().mockReturnValue({}),
initLogging: jest.fn(),
BitcoinNetwork: { Bitcoin: 0, Testnet3: 1, Testnet4: 2, Signet: 3, Regtest: 4 },
InputType_Tags: { SparkAddress: "SparkAddress", BitcoinAddress: "BitcoinAddress" },
Network: { Mainnet: 0, Regtest: 1 },
OnchainConfirmationSpeed: { Fast: 0, Medium: 1, Slow: 2 },
SendPaymentOptions: {
BitcoinAddress: jest.fn().mockImplementation((args) => ({
tag: "BitcoinAddress",
...args,
})),
},
Seed: { Mnemonic: jest.fn().mockImplementation((args) => args) },
StableBalanceActiveLabel: {
Set: jest.fn().mockImplementation((args) => ({ tag: "Set", inner: args })),
Expand All @@ -40,4 +84,15 @@ module.exports = {
PaymentStatus: { Completed: 0, Pending: 1, Failed: 2 },
PaymentType: { Send: 0, Receive: 1 },
PaymentDetails_Tags,
PaymentDetails,
SdkError,
SdkError_Tags,
ServiceStatus: {
Operational: 0,
Degraded: 1,
Partial: 2,
Unknown: 3,
Major: 4,
},
getSparkStatus: jest.fn().mockResolvedValue({ status: 0, lastUpdated: BigInt(0) }),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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 { SelfCustodialPaymentOfflineNotice } from "@app/components/self-custodial-payment-offline-notice"

const mockRefreshWallets = jest.fn()

jest.mock("@app/self-custodial/providers/wallet-provider", () => ({
useSelfCustodialWallet: () => ({
refreshWallets: mockRefreshWallets,
}),
}))

jest.mock("@app/i18n/i18n-react", () => ({
useI18nContext: () => ({
LL: {
SelfCustodialOffline: {
title: () => "Wallet is offline",
description: () =>
"Your non-custodial wallet can't reach the network right now. Try again when you're back online.",
retry: () => "Try again",
},
},
}),
}))

const renderNotice = () =>
render(
<ThemeProvider theme={theme}>
<SelfCustodialPaymentOfflineNotice />
</ThemeProvider>,
)

describe("SelfCustodialPaymentOfflineNotice", () => {
beforeEach(() => {
jest.clearAllMocks()
})

it("renders the offline title and description", () => {
const { getByText } = renderNotice()

expect(getByText("Wallet is offline")).toBeTruthy()
expect(
getByText(
"Your non-custodial wallet can't reach the network right now. Try again when you're back online.",
),
).toBeTruthy()
})

it("renders the retry button", () => {
const { getByTestId } = renderNotice()

expect(getByTestId("self-custodial-payment-offline-retry")).toBeTruthy()
})

it("calls refreshWallets when the retry button is pressed", () => {
const { getByTestId } = renderNotice()

fireEvent.press(getByTestId("self-custodial-payment-offline-retry"))

expect(mockRefreshWallets).toHaveBeenCalledTimes(1)
})

it("is idempotent: pressing retry multiple times fires a call each time", () => {
const { getByTestId } = renderNotice()

const retryButton = getByTestId("self-custodial-payment-offline-retry")
fireEvent.press(retryButton)
fireEvent.press(retryButton)
fireEvent.press(retryButton)

expect(mockRefreshWallets).toHaveBeenCalledTimes(3)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 { UnclaimedDepositBanner } from "@app/components/unclaimed-deposit-banner/unclaimed-deposit-banner"
import { DepositStatus, type PendingDeposit } from "@app/types/payment.types"
import { WalletCurrency } from "@app/graphql/generated"

const mockNavigate = jest.fn()
const mockListPendingDeposits = jest.fn()
let mockWallets: unknown[] = []
let mockListPendingDepositsImpl: typeof mockListPendingDeposits | undefined =
mockListPendingDeposits

jest.mock("@react-navigation/native", () => ({
useNavigation: () => ({ navigate: mockNavigate }),
useFocusEffect: (cb: () => void) => {
cb()
},
}))

jest.mock("@app/hooks/use-payments", () => ({
usePayments: () => ({ listPendingDeposits: mockListPendingDepositsImpl }),
}))

jest.mock("@app/self-custodial/providers/wallet-provider", () => ({
useSelfCustodialWallet: () => ({ wallets: mockWallets }),
}))

jest.mock("@app/i18n/i18n-react", () => ({
useI18nContext: () => ({
LL: {
UnclaimedDeposit: {
title: ({ count }: { count: number }) => `${count} pending`,
description: ({ sats }: { sats: number }) => `${sats} sats`,
},
},
}),
}))

const buildDeposit = (overrides: Partial<PendingDeposit> = {}): PendingDeposit => ({
id: "deposit-1",
txid: "tx",
vout: 0,
amount: { amount: 1000, currency: WalletCurrency.Btc, currencyCode: "BTC" },
status: DepositStatus.Claimable,
errorReason: null,
...overrides,
})

const renderBanner = () =>
render(
<ThemeProvider theme={theme}>
<UnclaimedDepositBanner />
</ThemeProvider>,
)

describe("UnclaimedDepositBanner", () => {
beforeEach(() => {
jest.clearAllMocks()
mockWallets = []
mockListPendingDepositsImpl = mockListPendingDeposits
mockListPendingDeposits.mockResolvedValue({ deposits: [] })
})

it("renders nothing when there are no pending deposits", async () => {
const { queryByTestId } = renderBanner()

await waitFor(() => expect(mockListPendingDeposits).toHaveBeenCalled())

expect(queryByTestId("unclaimed-deposit-banner")).toBeNull()
})

it("renders count and total sats when deposits are pending", async () => {
mockListPendingDeposits.mockResolvedValue({
deposits: [
buildDeposit({
id: "1",
amount: { amount: 1000, currency: WalletCurrency.Btc, currencyCode: "BTC" },
}),
buildDeposit({
id: "2",
amount: { amount: 2500, currency: WalletCurrency.Btc, currencyCode: "BTC" },
}),
],
})

const { findByText } = renderBanner()

expect(await findByText("2 pending")).toBeTruthy()
expect(await findByText("3500 sats")).toBeTruthy()
})

it("filters out refunded deposits from the count and total", async () => {
mockListPendingDeposits.mockResolvedValue({
deposits: [
buildDeposit({ id: "1", status: DepositStatus.Claimable }),
buildDeposit({
id: "2",
status: DepositStatus.Refunded,
amount: { amount: 9999, currency: WalletCurrency.Btc, currencyCode: "BTC" },
}),
],
})

const { findByText } = renderBanner()

expect(await findByText("1 pending")).toBeTruthy()
expect(await findByText("1000 sats")).toBeTruthy()
})

it("navigates to the unclaimed-deposits screen on press", async () => {
mockListPendingDeposits.mockResolvedValue({
deposits: [buildDeposit()],
})

const { findByTestId } = renderBanner()
const banner = await findByTestId("unclaimed-deposit-banner")

fireEvent.press(banner)

expect(mockNavigate).toHaveBeenCalledWith("unclaimedDepositsScreen")
})

it("does nothing when listPendingDeposits is undefined (custodial / loading)", () => {
mockListPendingDepositsImpl = undefined

const { queryByTestId } = renderBanner()

expect(queryByTestId("unclaimed-deposit-banner")).toBeNull()
expect(mockListPendingDeposits).not.toHaveBeenCalled()
})
})
77 changes: 76 additions & 1 deletion __tests__/hooks/use-payments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,32 @@ import { AccountType } from "@app/types/wallet.types"

import { usePayments } from "@app/hooks/use-payments"

const mockActiveAccount = jest.fn()

jest.mock("@app/hooks/use-account-registry", () => ({
useAccountRegistry: () => ({
activeAccount: { id: "custodial-default", type: "custodial" },
activeAccount: mockActiveAccount(),
}),
}))

const mockSdk = {}
const mockSelfCustodialWallet = jest.fn()

jest.mock("@app/self-custodial/providers/wallet-provider", () => ({
useSelfCustodialWallet: () => mockSelfCustodialWallet(),
}))

jest.mock("@app/self-custodial/bridge", () => ({
createSendPayment: jest.fn().mockReturnValue(jest.fn()),
createGetFee: jest.fn().mockReturnValue(jest.fn()),
createReceiveLightning: jest.fn().mockReturnValue(jest.fn()),
createReceiveOnchain: jest.fn().mockReturnValue(jest.fn()),
createListPendingDeposits: jest.fn().mockReturnValue(jest.fn()),
createClaimDeposit: jest.fn().mockReturnValue({
getClaimFee: jest.fn(),
claimDeposit: jest.fn(),
}),
createConvert: jest.fn().mockReturnValue(jest.fn()),
}))

jest.mock("@app/custodial/adapters/payment-adapter", () => ({
Expand All @@ -20,6 +42,11 @@ jest.mock("@app/custodial/adapters/payment-adapter", () => ({
}))

describe("usePayments", () => {
beforeEach(() => {
mockActiveAccount.mockReturnValue({ id: "custodial-default", type: "custodial" })
mockSelfCustodialWallet.mockReturnValue({ sdk: undefined })
})

it("returns accountType as custodial by default", () => {
const { result } = renderHook(() => usePayments())

Expand Down Expand Up @@ -69,4 +96,52 @@ describe("usePayments", () => {

expect(result.current.receiveOnchain).toBeUndefined()
})

it("returns self-custodial adapters when self-custodial account with SDK", () => {
mockActiveAccount.mockReturnValue({
id: "self-custodial-default",
type: AccountType.SelfCustodial,
})
mockSelfCustodialWallet.mockReturnValue({ sdk: mockSdk })

const { result } = renderHook(() => usePayments())

expect(result.current.accountType).toBe(AccountType.SelfCustodial)
expect(result.current.sendPayment).toBeDefined()
expect(result.current.getFee).toBeDefined()
expect(result.current.receiveLightning).toBeDefined()
expect(result.current.receiveOnchain).toBeDefined()
})

it("returns no adapters while a self-custodial account is loading its SDK (regression Critical #5)", () => {
mockActiveAccount.mockReturnValue({
id: "self-custodial-default",
type: AccountType.SelfCustodial,
})
mockSelfCustodialWallet.mockReturnValue({ sdk: undefined })

const { result } = renderHook(() => usePayments())

expect(result.current.accountType).toBe(AccountType.SelfCustodial)
expect(result.current.sendPayment).toBeUndefined()
expect(result.current.getFee).toBeUndefined()
expect(result.current.receiveLightning).toBeUndefined()
expect(result.current.receiveOnchain).toBeUndefined()
expect(result.current.listPendingDeposits).toBeUndefined()
expect(result.current.claimDeposit).toBeUndefined()
expect(result.current.convert).toBeUndefined()
})

it("returns no adapters and undefined accountType while activeAccount is missing (loading window)", () => {
mockActiveAccount.mockReturnValue(undefined)
mockSelfCustodialWallet.mockReturnValue({ sdk: undefined })

const { result } = renderHook(() => usePayments())

expect(result.current.accountType).toBeUndefined()
expect(result.current.sendPayment).toBeUndefined()
expect(result.current.listPendingDeposits).toBeUndefined()
expect(result.current.claimDeposit).toBeUndefined()
expect(result.current.convert).toBeUndefined()
})
})
Loading
Loading