diff --git a/.github/workflows/conventional.yaml b/.github/workflows/conventional.yaml index 91b8e12b9..664f73305 100644 --- a/.github/workflows/conventional.yaml +++ b/.github/workflows/conventional.yaml @@ -1,7 +1,7 @@ name: "Conventional commits" on: pull_request: - branches: [main] + branches: [main, feat/fygaro] jobs: conventional: name: "Conventional commits" @@ -14,3 +14,6 @@ jobs: message: this PR needs to be updated to follow conventional commits message body: false issue: false + # Report on invalid PRs instead of closing them (avoids nuking + # long-lived PRs like the feat/fygaro umbrella #621). + close: false diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml index a8c9f9784..492b558e6 100644 --- a/.github/workflows/spelling.yml +++ b/.github/workflows/spelling.yml @@ -1,7 +1,7 @@ name: Spelling on: pull_request: - branches: [main] + branches: [main, feat/fygaro] jobs: spelling: diff --git a/.storybook/views/story-screen.tsx b/.storybook/views/story-screen.tsx index 631f86398..7cd2f0722 100644 --- a/.storybook/views/story-screen.tsx +++ b/.storybook/views/story-screen.tsx @@ -5,11 +5,14 @@ const PersistentStateWrapper: React.FC = ({ children }) {}, resetState: () => {}, diff --git a/.typesafe-i18n.json b/.typesafe-i18n.json index d40474af1..753a37c8d 100644 --- a/.typesafe-i18n.json +++ b/.typesafe-i18n.json @@ -1,5 +1,5 @@ { "adapter": "react", - "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json", + "$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json", "outputPath": "./app/i18n" } \ No newline at end of file diff --git a/__mocks__/@breeztech/breez-sdk-spark-react-native.js b/__mocks__/@breeztech/breez-sdk-spark-react-native.js new file mode 100644 index 000000000..a9acf7505 --- /dev/null +++ b/__mocks__/@breeztech/breez-sdk-spark-react-native.js @@ -0,0 +1,46 @@ +const SdkEvent_Tags = { + PaymentPending: "PaymentPending", + PaymentSucceeded: "PaymentSucceeded", + PaymentFailed: "PaymentFailed", + Synced: "Synced", + Optimization: "Optimization", +} + +const PaymentType = { + Send: "Send", + Receive: "Receive", +} + +const PaymentStatus = { + Pending: "Pending", + Complete: "Complete", + Failed: "Failed", +} + +class Bolt11Invoice { + constructor(args) { + Object.assign(this, args) + } +} + +class BitcoinAddress { + constructor(args) { + Object.assign(this, args) + } +} + +module.exports = { + __esModule: true, + BitcoinAddress, + Bolt11Invoice, + PaymentStatus, + PaymentType, + ReceivePaymentMethod: { + BitcoinAddress, + Bolt11Invoice, + }, + SdkEvent_Tags, + connect: jest.fn(), + defaultConfig: jest.fn(), + disconnect: jest.fn(), +} diff --git a/__mocks__/@env.js b/__mocks__/@env.js new file mode 100644 index 000000000..a94a98dd7 --- /dev/null +++ b/__mocks__/@env.js @@ -0,0 +1,12 @@ +module.exports = { + API_KEY: "test-api-key", + APP_CHECK_ANDROID_DEBUG_TOKEN: "test-android-app-check-token", + APP_CHECK_IOS_DEBUG_TOKEN: "test-ios-app-check-token", + BREEZ_LNURL_DOMAIN: "test.flashapp.me", + GOOGLE_PLACE_API_KEY: "test-google-place-api-key", + GREENLIGHT_PARTNER_CERT: "test-greenlight-partner-cert", + GREENLIGHT_PARTNER_KEY: "test-greenlight-partner-key", + INVITE_CODE: "test-invite-code", + MIGRATION_FEE_LNURL_W: "https://test.flashapp.me/lnurlw", + MNEMONIC_WORDS: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", +} diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.js index e19713d4a..4acc6acdc 100644 --- a/__mocks__/@react-native-async-storage/async-storage.js +++ b/__mocks__/@react-native-async-storage/async-storage.js @@ -1 +1 @@ -export { AsyncStorageMock as default } from "@react-native-async-storage/async-storage/jest/async-storage-mock" +module.exports = require("@react-native-async-storage/async-storage/jest/async-storage-mock") diff --git a/__mocks__/@react-native-community/netinfo.js b/__mocks__/@react-native-community/netinfo.js new file mode 100644 index 000000000..e61651efd --- /dev/null +++ b/__mocks__/@react-native-community/netinfo.js @@ -0,0 +1 @@ +module.exports = require("@react-native-community/netinfo/jest/netinfo-mock") diff --git a/__mocks__/breez-sdk.js b/__mocks__/breez-sdk.js new file mode 100644 index 000000000..b8843aa56 --- /dev/null +++ b/__mocks__/breez-sdk.js @@ -0,0 +1,4 @@ +module.exports = { + receiveOnchainBreez: jest.fn(() => Promise.resolve({ paymentRequest: "" })), + receivePaymentBreez: jest.fn(() => Promise.resolve({ paymentRequest: "" })), +} diff --git a/__mocks__/react-native-fs.js b/__mocks__/react-native-fs.js new file mode 100644 index 000000000..4ed04c796 --- /dev/null +++ b/__mocks__/react-native-fs.js @@ -0,0 +1,19 @@ +const RNFS = { + CachesDirectoryPath: "/tmp", + DocumentDirectoryPath: "/tmp", + DownloadDirectoryPath: "/tmp", + TemporaryDirectoryPath: "/tmp", + appendFile: jest.fn(() => Promise.resolve()), + copyFile: jest.fn(() => Promise.resolve()), + downloadFile: jest.fn(() => ({ + jobId: 1, + promise: Promise.resolve({ statusCode: 200 }), + })), + exists: jest.fn(() => Promise.resolve(false)), + readFile: jest.fn(() => Promise.resolve("")), + unlink: jest.fn(() => Promise.resolve()), + writeFile: jest.fn(() => Promise.resolve()), +} + +module.exports = RNFS +module.exports.default = RNFS diff --git a/__mocks__/react-native-google-places-autocomplete.js b/__mocks__/react-native-google-places-autocomplete.js new file mode 100644 index 000000000..ae93854da --- /dev/null +++ b/__mocks__/react-native-google-places-autocomplete.js @@ -0,0 +1,11 @@ +const React = require("react") +const { View } = require("react-native") + +const GooglePlacesAutocomplete = React.forwardRef((props, ref) => + React.createElement(View, { ...props, ref }), +) + +module.exports = { + __esModule: true, + GooglePlacesAutocomplete, +} diff --git a/__mocks__/react-native-haptic-feedback.js b/__mocks__/react-native-haptic-feedback.js new file mode 100644 index 000000000..f11686a7f --- /dev/null +++ b/__mocks__/react-native-haptic-feedback.js @@ -0,0 +1,7 @@ +const trigger = jest.fn() + +module.exports = { + __esModule: true, + default: { trigger }, + trigger, +} diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.js new file mode 100644 index 000000000..36673002d --- /dev/null +++ b/__mocks__/react-native-image-picker.js @@ -0,0 +1,23 @@ +const emptyResult = { assets: [], didCancel: true } + +const launchImageLibrary = jest.fn((_, callback) => { + if (typeof callback === "function") { + callback(emptyResult) + } + + return Promise.resolve(emptyResult) +}) + +const launchCamera = jest.fn((_, callback) => { + if (typeof callback === "function") { + callback(emptyResult) + } + + return Promise.resolve(emptyResult) +}) + +module.exports = { + __esModule: true, + launchCamera, + launchImageLibrary, +} diff --git a/__mocks__/react-native-keychain.js b/__mocks__/react-native-keychain.js new file mode 100644 index 000000000..6d90f6730 --- /dev/null +++ b/__mocks__/react-native-keychain.js @@ -0,0 +1,26 @@ +const credentialsByServer = new Map() + +const getServer = (serverOrOptions) => + typeof serverOrOptions === "string" ? serverOrOptions : serverOrOptions?.server + +const getInternetCredentials = jest.fn((serverOrOptions) => + Promise.resolve(credentialsByServer.get(getServer(serverOrOptions)) || false), +) + +const setInternetCredentials = jest.fn((server, username, password) => { + credentialsByServer.set(server, { password, server, username }) + return Promise.resolve(true) +}) + +const resetInternetCredentials = jest.fn((serverOrOptions) => { + credentialsByServer.delete(getServer(serverOrOptions)) + return Promise.resolve(true) +}) + +module.exports = { + __esModule: true, + ACCESSIBLE: {}, + getInternetCredentials, + resetInternetCredentials, + setInternetCredentials, +} diff --git a/__mocks__/react-native-nfc-manager.js b/__mocks__/react-native-nfc-manager.js new file mode 100644 index 000000000..3b57fc103 --- /dev/null +++ b/__mocks__/react-native-nfc-manager.js @@ -0,0 +1,21 @@ +const NfcManager = { + cancelTechnologyRequest: jest.fn(), + getTag: jest.fn(), + isEnabled: jest.fn(async () => false), + isSupported: jest.fn(async () => false), + requestTechnology: jest.fn(), + start: jest.fn(), +} + +module.exports = { + __esModule: true, + default: NfcManager, + Ndef: { + text: { + decodePayload: jest.fn(() => ""), + }, + }, + NfcTech: { + Ndef: "Ndef", + }, +} diff --git a/__mocks__/react-native-vision-camera.js b/__mocks__/react-native-vision-camera.js new file mode 100644 index 000000000..138a34e41 --- /dev/null +++ b/__mocks__/react-native-vision-camera.js @@ -0,0 +1,19 @@ +const React = require("react") + +const requestPermission = jest.fn(() => Promise.resolve(true)) + +const Camera = React.forwardRef((props, ref) => + React.createElement("Camera", { ...props, ref }), +) + +module.exports = { + __esModule: true, + Camera, + CameraRuntimeError: Error, + useCameraDevice: jest.fn(() => ({ id: "back" })), + useCameraPermission: jest.fn(() => ({ + hasPermission: true, + requestPermission, + })), + useCodeScanner: jest.fn((config) => config), +} diff --git a/__mocks__/react-native-walkthrough-tooltip.js b/__mocks__/react-native-walkthrough-tooltip.js new file mode 100644 index 000000000..a2c654189 --- /dev/null +++ b/__mocks__/react-native-walkthrough-tooltip.js @@ -0,0 +1,6 @@ +const React = require("react") + +const Tooltip = ({ children }) => React.createElement(React.Fragment, null, children) + +module.exports = Tooltip +module.exports.default = Tooltip diff --git a/__tests__/components/currency-tag.spec.tsx b/__tests__/components/currency-tag.spec.tsx new file mode 100644 index 000000000..3538ef7aa --- /dev/null +++ b/__tests__/components/currency-tag.spec.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import { describe, expect, it } from "@jest/globals" +import { render } from "@testing-library/react-native" +import { ThemeProvider } from "@rneui/themed" + +import { CurrencyTag } from "@app/components/currency-tag" +import { WalletCurrency } from "@app/graphql/generated" +import { displayCurrencyCode } from "@app/utils/currency-display" +import theme from "@app/rne-theme/theme" + +const renderCurrencyTag = (walletCurrency: WalletCurrency) => + render( + + + , + ) + +describe("CurrencyTag", () => { + it.each([WalletCurrency.Btc, WalletCurrency.Usd, WalletCurrency.Usdt])( + "renders a %s tag", + (walletCurrency: WalletCurrency) => { + const { getByText } = renderCurrencyTag(walletCurrency) + + // USDT displays as USD (stablecoin → fiat mapping) + expect(getByText(displayCurrencyCode(walletCurrency))).toBeTruthy() + }, + ) +}) diff --git a/__tests__/config/galoy-instances.spec.ts b/__tests__/config/galoy-instances.spec.ts index 327081a02..df3d0d4b4 100644 --- a/__tests__/config/galoy-instances.spec.ts +++ b/__tests__/config/galoy-instances.spec.ts @@ -22,6 +22,7 @@ it("get a full object with Custom", () => { posUrl: "https://pay.custom.com/", lnAddressHostname: "custom.com", blockExplorer: "https://mempool.space/tx/", + relayUrl: "wss://relay.custom.com", } as const const res = resolveGaloyInstanceOrDefault(CustomInstance) diff --git a/__tests__/hooks/use-display-currency.spec.tsx b/__tests__/hooks/use-display-currency.spec.tsx index f471625b9..4e38cb460 100644 --- a/__tests__/hooks/use-display-currency.spec.tsx +++ b/__tests__/hooks/use-display-currency.spec.tsx @@ -5,7 +5,11 @@ import { MockedProvider } from "@apollo/client/testing" import { PropsWithChildren } from "react" import * as React from "react" import { IsAuthedContextProvider } from "@app/graphql/is-authed-context" -import { CurrencyListDocument, RealtimePriceDocument } from "@app/graphql/generated" +import { + CurrencyListDocument, + RealtimePriceDocument, + WalletCurrency, +} from "@app/graphql/generated" const mocksNgn = [ { @@ -135,12 +139,32 @@ const wrapWithMocks = describe("usePriceConversion", () => { describe("testing moneyAmountToMajorUnitOrSats", () => { + it("formats USDT micros as major units", async () => { + const { result } = renderHook(useDisplayCurrency, { + wrapper: wrapWithMocks([]), + }) + + const moneyAmount = { + amount: 179_554, + currency: WalletCurrency.Usdt, + currencyCode: "USD", + } + + expect(result.current.moneyAmountToMajorUnitOrSats(moneyAmount)).toBe(0.179554) + // USDT displays as USD (stablecoin → fiat display mapping) + expect(result.current.formatMoneyAmount({ moneyAmount })).toBe("$0.179554 USD") + }) + it("with 0 digits", async () => { - const { result, waitForNextUpdate } = renderHook(useDisplayCurrency, { + const { result, waitFor } = renderHook(useDisplayCurrency, { wrapper: wrapWithMocks(mocksJpy), }) - await waitForNextUpdate() + await waitFor( + () => + result.current.displayCurrency === "JPY" && + result.current.fractionDigits === 0, + ) const res = result.current.moneyAmountToMajorUnitOrSats({ amount: 100, @@ -230,4 +254,29 @@ describe("usePriceConversion", () => { displayCurrency: "NGN", }) }) + + it("converts USDT micros to display currency using USD-cent price", async () => { + const { result, waitFor } = renderHook(useDisplayCurrency, { + wrapper: wrapWithMocks(mocksNgn), + }) + + await waitFor( + () => { + return result.current.displayCurrency === "NGN" + }, + { + timeout: 4000, + }, + ) + + const displayAmount = result.current.moneyAmountToDisplayCurrencyString({ + moneyAmount: { + amount: 1_000_000, + currency: WalletCurrency.Usdt, + currencyCode: "USDT", + }, + }) + + expect(displayAmount).toBe("₦100.00") + }) }) diff --git a/__tests__/hooks/use-show-warning-secure-account.spec.tsx b/__tests__/hooks/use-show-warning-secure-account.spec.tsx index c67067ac7..983639490 100644 --- a/__tests__/hooks/use-show-warning-secure-account.spec.tsx +++ b/__tests__/hooks/use-show-warning-secure-account.spec.tsx @@ -92,12 +92,14 @@ const mockLevelZeroLowBalance = [ id: "f79792e3-282b-45d4-85d5-7486d020def5", balance: 100, walletCurrency: "BTC", + isExternal: false, __typename: "BTCWallet", }, { id: "f091c102-6277-4cc6-8d81-87ebf6aaad1b", balance: 100, walletCurrency: "USD", + isExternal: false, __typename: "UsdWallet", }, ], @@ -128,12 +130,14 @@ const mockLevelZeroHighBalance = [ id: "f79792e3-282b-45d4-85d5-7486d020def5", balance: 100, walletCurrency: "BTC", + isExternal: false, __typename: "BTCWallet", }, { id: "f091c102-6277-4cc6-8d81-87ebf6aaad1b", balance: 600, walletCurrency: "USD", + isExternal: false, __typename: "UsdWallet", }, ], @@ -164,12 +168,14 @@ const mockLevelOneHighBalance = [ id: "f79792e3-282b-45d4-85d5-7486d020def5", balance: 100, walletCurrency: "BTC", + isExternal: false, __typename: "BTCWallet", }, { id: "f091c102-6277-4cc6-8d81-87ebf6aaad1b", balance: 600, walletCurrency: "USD", + isExternal: false, __typename: "UsdWallet", }, ], diff --git a/__tests__/payment-destination/intraledger.spec.ts b/__tests__/payment-destination/intraledger.spec.ts index 7be139450..2ebc53041 100644 --- a/__tests__/payment-destination/intraledger.spec.ts +++ b/__tests__/payment-destination/intraledger.spec.ts @@ -27,6 +27,7 @@ describe("resolve intraledger", () => { parsedIntraledgerDestination: { paymentType: "intraledger", handle: "testhandle", + valid: true, } as const, accountDefaultWalletQuery: jest.fn(), myWalletIds: ["testwalletid"], @@ -78,6 +79,7 @@ describe("create intraledger destination", () => { parsedIntraledgerDestination: { paymentType: "intraledger", handle: "testhandle", + valid: true, }, walletId: "testwalletid", } as const diff --git a/__tests__/payment-destination/lnurl.spec.ts b/__tests__/payment-destination/lnurl.spec.ts index 12ceb1708..65ec979bc 100644 --- a/__tests__/payment-destination/lnurl.spec.ts +++ b/__tests__/payment-destination/lnurl.spec.ts @@ -7,23 +7,20 @@ import { } from "@app/screens/send-bitcoin-screen/payment-destination" import { DestinationDirection } from "@app/screens/send-bitcoin-screen/payment-destination/index.types" import { ZeroBtcMoneyAmount } from "@app/types/amounts" -import { PaymentType, fetchLnurlPaymentParams } from "@galoymoney/client" +import { PaymentType } from "@galoymoney/client" import { LNURLPayParams, LNURLResponse, LNURLWithdrawParams, getParams } from "js-lnurl" +import { requestPayServiceParams } from "lnurl-pay" import { LnUrlPayServiceResponse } from "lnurl-pay/dist/types/types" import { defaultPaymentDetailParams } from "./helpers" -jest.mock("@galoymoney/client", () => { - const actualModule = jest.requireActual("@galoymoney/client") - +jest.mock("js-lnurl", () => { return { - ...actualModule, - fetchLnurlPaymentParams: jest.fn(), + getParams: jest.fn(), } }) - -jest.mock("js-lnurl", () => { +jest.mock("lnurl-pay", () => { return { - getParams: jest.fn(), + requestPayServiceParams: jest.fn(), } }) jest.mock("@app/screens/send-bitcoin-screen/payment-details", () => { @@ -32,8 +29,8 @@ jest.mock("@app/screens/send-bitcoin-screen/payment-details", () => { } }) -const mockFetchLnurlPaymentParams = fetchLnurlPaymentParams as jest.MockedFunction< - typeof fetchLnurlPaymentParams +const mockRequestPayServiceParams = requestPayServiceParams as jest.MockedFunction< + typeof requestPayServiceParams > const mockGetParams = getParams as jest.MockedFunction const mockCreateLnurlPaymentDetail = createLnurlPaymentDetails as jest.MockedFunction< @@ -61,7 +58,7 @@ describe("resolve lnurl destination", () => { const lnurlPayParams = createMock({ identifier: lnurlPaymentDestinationParams.parsedLnurlDestination.lnurl, }) - mockFetchLnurlPaymentParams.mockResolvedValue(lnurlPayParams) + mockRequestPayServiceParams.mockResolvedValue(lnurlPayParams) mockGetParams.mockResolvedValue(createMock()) const destination = await resolveLnurlDestination(lnurlPaymentDestinationParams) @@ -96,7 +93,7 @@ describe("resolve lnurl destination", () => { const lnurlPayParams = createMock({ identifier: lnurlPaymentDestinationParams.parsedLnurlDestination.lnurl, }) - mockFetchLnurlPaymentParams.mockResolvedValue(lnurlPayParams) + mockRequestPayServiceParams.mockResolvedValue(lnurlPayParams) mockGetParams.mockResolvedValue(createMock()) const destination = await resolveLnurlDestination(lnurlPaymentDestinationParams) @@ -128,7 +125,7 @@ describe("resolve lnurl destination", () => { } it("creates lnurl withdraw destination", async () => { - mockFetchLnurlPaymentParams.mockImplementation(throwError) + mockRequestPayServiceParams.mockImplementation(throwError) const mockLnurlWithdrawParams = createMock() mockGetParams.mockResolvedValue(mockLnurlWithdrawParams) diff --git a/__tests__/payment-destination/resolve-username-to-lnurl.spec.ts b/__tests__/payment-destination/resolve-username-to-lnurl.spec.ts index 23f729bd6..b9617a729 100644 --- a/__tests__/payment-destination/resolve-username-to-lnurl.spec.ts +++ b/__tests__/payment-destination/resolve-username-to-lnurl.spec.ts @@ -3,10 +3,14 @@ import { WalletCurrency } from "@app/graphql/generated" import { InvalidDestinationReason } from "@app/screens/send-bitcoin-screen/payment-destination/index.types" const mockParsePaymentDestination = jest.fn() -jest.mock("@flash/client", () => ({ - parsePaymentDestination: (...args: unknown[]) => mockParsePaymentDestination(...args), - Network: {}, -})) +jest.mock("@flash/client", () => { + const actual = jest.requireActual("@flash/client") + return { + ...actual, + parsePaymentDestination: (...args: unknown[]) => mockParsePaymentDestination(...args), + Network: actual.Network ?? {}, + } +}) const mockRequestPayServiceParams = jest.fn() jest.mock("lnurl-pay", () => ({ @@ -14,10 +18,16 @@ jest.mock("lnurl-pay", () => ({ })) const mockCreateLnurlPaymentDestination = jest.fn() -jest.mock("@app/screens/send-bitcoin-screen/payment-destination/lnurl", () => ({ - createLnurlPaymentDestination: (...args: unknown[]) => - mockCreateLnurlPaymentDestination(...args), -})) +jest.mock("@app/screens/send-bitcoin-screen/payment-destination/lnurl", () => { + const actual = jest.requireActual( + "@app/screens/send-bitcoin-screen/payment-destination/lnurl", + ) + return { + ...actual, + createLnurlPaymentDestination: (...args: unknown[]) => + mockCreateLnurlPaymentDestination(...args), + } +}) import { maybeResolveManualUsernameToLnurl } from "@app/screens/send-bitcoin-screen/payment-destination/resolve-username-to-lnurl" diff --git a/__tests__/payment-details/lnurl-payment-details.spec.ts b/__tests__/payment-details/lnurl-payment-details.spec.ts index c3a8d017f..b03455483 100644 --- a/__tests__/payment-details/lnurl-payment-details.spec.ts +++ b/__tests__/payment-details/lnurl-payment-details.spec.ts @@ -1,6 +1,6 @@ import { WalletCurrency } from "@app/graphql/generated" import * as PaymentDetails from "@app/screens/send-bitcoin-screen/payment-details/lightning" -import { LnUrlPayServiceResponse } from "lnurl-pay/dist/types/types" +import { LnUrlPayServiceResponse, Satoshis } from "lnurl-pay/dist/types/types" import { createMock } from "ts-auto-mock" import { btcSendingWalletDescriptor, @@ -16,9 +16,11 @@ import { usdSendingWalletDescriptor, } from "./helpers" +const sats = (amount: number) => amount as Satoshis + const defaultParamsWithoutInvoice = { lnurl: "testlnurl", - lnurlParams: createMock({ min: 1, max: 1000 }), + lnurlParams: createMock({ min: sats(1), max: sats(1000) }), convertMoneyAmount: convertMoneyAmountMock, sendingWalletDescriptor: btcSendingWalletDescriptor, unitOfAccountAmount: testAmount, @@ -32,7 +34,10 @@ const defaultParamsWithInvoice = { const defaultParamsWithEqualMinMaxAmount = { ...defaultParamsWithoutInvoice, - lnurlParams: createMock({ min: 100, max: 100 }), + lnurlParams: createMock({ + min: sats(100), + max: sats(100), + }), } const spy = jest.spyOn(PaymentDetails, "createLnurlPaymentDetails") diff --git a/__tests__/payment-request/helpers.ts b/__tests__/payment-request/helpers.ts index 1d498a15c..b28a635d6 100644 --- a/__tests__/payment-request/helpers.ts +++ b/__tests__/payment-request/helpers.ts @@ -14,6 +14,10 @@ export const usdWalletDescriptor = { id: "usd-wallet-id", currency: WalletCurrency.Usd, } +export const usdtWalletDescriptor = { + id: "usdt-wallet-id", + currency: WalletCurrency.Usdt, +} export const convertMoneyAmountFn = ( amount: MoneyAmount, toCurrency: T, diff --git a/__tests__/payment-request/payment-request-creation-data.spec.ts b/__tests__/payment-request/payment-request-creation-data.spec.ts index 44b5e4dfc..5901f7f97 100644 --- a/__tests__/payment-request/payment-request-creation-data.spec.ts +++ b/__tests__/payment-request/payment-request-creation-data.spec.ts @@ -67,7 +67,7 @@ describe("create payment request creation data", () => { expect(prcd.canSetAmount).toBe(false) expect(prcd.canSetMemo).toBe(false) expect(prcd.canSetReceivingWalletDescriptor).toBe(false) - expect(prcd.receivingWalletDescriptor).toBe(btcWalletDescriptor) + expect(prcd.receivingWalletDescriptor).toBe(usdWalletDescriptor) }) it("onchain set", () => { diff --git a/__tests__/payment-request/payment-request.spec.ts b/__tests__/payment-request/payment-request.spec.ts index 50a1a74d6..81c9f1452 100644 --- a/__tests__/payment-request/payment-request.spec.ts +++ b/__tests__/payment-request/payment-request.spec.ts @@ -1,15 +1,26 @@ import { createPaymentRequestCreationData } from "@app/screens/receive-bitcoin-screen/payment/payment-request-creation-data" import { createMock } from "ts-auto-mock" -import { LnInvoice } from "@app/graphql/generated" +import { LnInvoice, WalletCurrency } from "@app/graphql/generated" import { GeneratePaymentRequestMutations, Invoice, PaymentRequestState, } from "@app/screens/receive-bitcoin-screen/payment/index.types" -import { btcWalletDescriptor, defaultParams, usdWalletDescriptor } from "./helpers" +import { + btcWalletDescriptor, + defaultParams, + usdWalletDescriptor, + usdtWalletDescriptor, +} from "./helpers" import { createPaymentRequest } from "@app/screens/receive-bitcoin-screen/payment/payment-request" -import { toUsdMoneyAmount } from "@app/types/amounts" +import { + MoneyAmount, + toUsdMoneyAmount, + USDT_MICROS_PER_USD_CENT, + WalletOrDisplayCurrency, +} from "@app/types/amounts" +import { receiveOnchainBreez, receivePaymentBreez } from "@app/utils/breez-sdk" const usdAmountInvoice = "lnbc49100n1p3l2q6cpp5y8lc3dv7qnplxhc3z9j0sap4n0hu99g39tl3srx6zj0hrqy2snwsdqqcqzpuxqzfvsp5q6t5f3xeruu4k5sk5nlmxx2kzlw2pydmmjk9g4qqmsc9c6ffzldq9qyyssq9lesnumasvvlvwc7yckvuepklttlvwhjqw3539qqqttsyh5s5j246spy9gezng7ng3d40qsrn6dhsrgs7rccaftzulx5auqqd5lz0psqfskeg4" @@ -18,6 +29,10 @@ const noAmountInvoice = const btcAmountInvoice = "lnbc23690n1p3l2qugpp5jeflfqjpxhe0hg3tzttc325j5l6czs9vq9zqx5edpt0yf7k6cypsdqqcqzpuxqyz5vqsp5lteanmnwddszwut839etrgjenfr3dv5tnvz2d2ww2mvggq7zn46q9qyyssqzcz0rvt7r30q7jul79xqqwpr4k2e8mgd23fkjm422sdgpndwql93d4wh3lap9yfwahue9n7ju80ynkqly0lrqqd2978dr8srkrlrjvcq2v5s6k" const mockOnChainAddress = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" +const defaultMemo = "Pay to Flash Wallet User" + +const mockReceiveOnchainBreez = receiveOnchainBreez as jest.Mock +const mockReceivePaymentBreez = receivePaymentBreez as jest.Mock const mockLnInvoice = createMock({ paymentRequest: btcAmountInvoice, @@ -83,9 +98,23 @@ export const clearMocks = () => { mockLnUsdInvoiceCreate.mockClear() mockLnNoAmountInvoiceCreate.mockClear() mockOnChainAddressCurrent.mockClear() + mockReceiveOnchainBreez.mockReset() + mockReceivePaymentBreez.mockReset() } describe("payment request", () => { + beforeEach(() => { + clearMocks() + mockReceiveOnchainBreez.mockResolvedValue({ + paymentRequest: mockOnChainAddress, + }) + mockReceivePaymentBreez.mockImplementation((amount?: number) => + Promise.resolve({ + paymentRequest: amount ? btcAmountInvoice : noAmountInvoice, + }), + ) + }) + it("ln with btc receiving wallet", async () => { const prcd = createPaymentRequestCreationData({ ...defaultParams, @@ -99,7 +128,7 @@ describe("payment request", () => { const prNew = await pr.generateRequest() expect(prNew.info).not.toBeUndefined() - expect(mockLnNoAmountInvoiceCreate).toHaveBeenCalled() + expect(mockReceivePaymentBreez).toHaveBeenCalledWith(undefined, defaultMemo) expect(prNew.state).toBe(PaymentRequestState.Created) expect(prNew.info?.data?.invoiceType).toBe(Invoice.Lightning) expect(prNew.info?.data?.getFullUriFn({})).toBe(noAmountInvoice) @@ -118,10 +147,18 @@ describe("payment request", () => { const prNew = await pr.generateRequest() expect(prNew.info).not.toBeUndefined() - expect(mockLnNoAmountInvoiceCreate).toHaveBeenCalled() + expect(mockLnUsdInvoiceCreate).toHaveBeenCalledWith({ + variables: { + input: { + walletId: usdWalletDescriptor.id, + amount: 0, + memo: defaultMemo, + }, + }, + }) expect(prNew.state).toBe(PaymentRequestState.Created) expect(prNew.info?.data?.invoiceType).toBe(Invoice.Lightning) - expect(prNew.info?.data?.getFullUriFn({})).toBe(noAmountInvoice) + expect(prNew.info?.data?.getFullUriFn({})).toBe(usdAmountInvoice) }) it("ln with btc receiving wallet - set amount", async () => { @@ -138,7 +175,7 @@ describe("payment request", () => { const prNew = await pr.generateRequest() expect(prNew.info).not.toBeUndefined() - expect(mockLnInvoiceCreate).toHaveBeenCalled() + expect(mockReceivePaymentBreez).toHaveBeenCalledWith(1, defaultMemo) expect(prNew.state).toBe(PaymentRequestState.Created) expect(prNew.info?.data?.invoiceType).toBe(Invoice.Lightning) expect(prNew.info?.data?.getFullUriFn({})).toBe(btcAmountInvoice) @@ -164,9 +201,57 @@ describe("payment request", () => { expect(prNew.info?.data?.getFullUriFn({})).toBe(usdAmountInvoice) }) + it("ln with usdt receiving wallet - set amount sends cents, not micros", async () => { + // Regression for the $5.00 -> $50,583 USDT invoice bug. The price + // conversion denominates USDT money amounts in smallest units (micros), + // which are USDT_MICROS_PER_USD_CENT (10,000x) smaller than a cent. The + // lnUsdInvoiceCreate mutation expects USD cents, so the receive flow must + // convert USDT micros back to cents before calling it. Otherwise a $5.00 + // request (500 cents) is sent as 5,000,000 and the backend mints a + // $50,000 invoice. + const convertToUsdtMicros = ( + amount: MoneyAmount, + toCurrency: T, + ): MoneyAmount => { + const converted = + toCurrency === WalletCurrency.Usdt + ? amount.amount * USDT_MICROS_PER_USD_CENT + : amount.amount + return { amount: converted, currency: toCurrency, currencyCode: toCurrency } + } + + const prcd = createPaymentRequestCreationData({ + ...defaultParams, + convertMoneyAmount: convertToUsdtMicros, + receivingWalletDescriptor: usdtWalletDescriptor, + // $5.00 entered as 500 USD cents in the unit of account + unitOfAccountAmount: toUsdMoneyAmount(500), + }) + + // sanity: the settlement amount is in USDT micros (500 cents * 10,000) + expect(prcd.settlementAmount?.currency).toBe(WalletCurrency.Usdt) + expect(prcd.settlementAmount?.amount).toBe(500 * USDT_MICROS_PER_USD_CENT) + + const pr = createPaymentRequest({ creationData: prcd, mutations }) + const prNew = await pr.generateRequest() + + expect(prNew.info).not.toBeUndefined() + expect(mockLnUsdInvoiceCreate).toHaveBeenCalledWith({ + variables: { + input: expect.objectContaining({ + walletId: usdtWalletDescriptor.id, + // must be 500 cents, NOT 5,000,000 micros + amount: 500, + }), + }, + }) + expect(prNew.state).toBe(PaymentRequestState.Created) + }) + it("paycode/lnurl", async () => { const prcd = createPaymentRequestCreationData({ ...defaultParams, + receivingWalletDescriptor: usdWalletDescriptor, type: Invoice.PayCode, username: "username", posUrl: "posUrl", @@ -198,11 +283,14 @@ describe("payment request", () => { const prNew = await pr.generateRequest() expect(prNew.info).not.toBeUndefined() - expect(mockOnChainAddressCurrent).toHaveBeenCalled() + expect(mockReceiveOnchainBreez).toHaveBeenCalled() expect(prNew.state).toBe(PaymentRequestState.Created) expect(prNew.info?.data?.invoiceType).toBe(Invoice.OnChain) - expect( - prNew.info?.data?.getFullUriFn({}).startsWith(`bitcoin:${mockOnChainAddress}`), - ).toBe(true) + expect(prNew.info?.data?.getFullUriFn({})).toBe( + `bitcoin:${mockOnChainAddress}?message=Pay%2520to%2520Flash%2520Wallet%2520User`, + ) + expect(prNew.info?.data?.getFullUriFn({ prefix: false })).toBe( + `${mockOnChainAddress}?message=Pay%2520to%2520Flash%2520Wallet%2520User`, + ) }) }) diff --git a/__tests__/persistent-storage.spec.ts b/__tests__/persistent-storage.spec.ts index 6f8888797..57a3b5597 100644 --- a/__tests__/persistent-storage.spec.ts +++ b/__tests__/persistent-storage.spec.ts @@ -15,6 +15,7 @@ it("migrates persistent state", async () => { }) expect(state).toEqual({ ...defaultPersistentState, + galoyInstance: { id: "Main" }, }) }) @@ -25,20 +26,24 @@ it("returns default when schema is not present", async () => { expect(state).toEqual(defaultPersistentState) }) -it("migration from 5 to 6", async () => { +it("migration from 5 to current", async () => { const state5 = { schemaVersion: 5, galoyInstance: { id: "Main" }, galoyAuthToken: "myToken", } - const state6 = { - schemaVersion: 6, + const currentState = { + schemaVersion: 7, galoyInstance: { id: "Main" }, galoyAuthToken: "myToken", + hasInitializedBreezSDK: false, + helpTriggered: false, + unclaimedDeposits: 0, + closedQuickStartTypes: [], } const res = await migrateAndGetPersistentState(state5) - expect(res).toStrictEqual(state6) + expect(res).toStrictEqual(currentState) }) diff --git a/__tests__/screens/helper.tsx b/__tests__/screens/helper.tsx index fff1ee20e..51b9e405a 100644 --- a/__tests__/screens/helper.tsx +++ b/__tests__/screens/helper.tsx @@ -12,6 +12,8 @@ import mocks from "@app/graphql/mocks" import { createStackNavigator } from "@react-navigation/stack" import TypesafeI18n from "@app/i18n/i18n-react" import { detectDefaultLocale } from "@app/utils/locale-detector" +import { Provider } from "react-redux" +import { store } from "@app/store/redux" const Stack = createStackNavigator() @@ -21,15 +23,17 @@ export const ContextForScreen: React.FC = ({ children }) => ( {() => ( - - - - - {children} - - - - + + + + + + {children} + + + + + )} diff --git a/__tests__/screens/send-confirmation.spec.tsx b/__tests__/screens/send-confirmation.spec.tsx index 7a8cf8aee..533b9df4e 100644 --- a/__tests__/screens/send-confirmation.spec.tsx +++ b/__tests__/screens/send-confirmation.spec.tsx @@ -1,6 +1,6 @@ import React from "react" -import { act, render } from "@testing-library/react-native" +import { act, render, waitFor } from "@testing-library/react-native" import { Intraledger } from "../../app/screens/send-bitcoin-screen/send-bitcoin-confirmation-screen.stories" import { ContextForScreen } from "./helper" @@ -16,6 +16,8 @@ it("SendScreen Confirmation", async () => { await act(async () => {}) await act(async () => {}) - const { children } = await findByLabelText("Successful Fee") - expect(children).toEqual(["₦0 ($0.00)"]) + await waitFor(async () => { + const { children } = await findByLabelText("Successful Fee") + expect(children).toEqual(["₦0.00 ($0.00)"]) + }) }) diff --git a/__tests__/screens/send-destination.spec.tsx b/__tests__/screens/send-destination.spec.tsx index edff26538..76e366b79 100644 --- a/__tests__/screens/send-destination.spec.tsx +++ b/__tests__/screens/send-destination.spec.tsx @@ -16,7 +16,11 @@ const sendBitcoinDestination = { it("SendScreen Destination", async () => { render( - + , ) await act(async () => {}) diff --git a/app/assets/icons/arrow-down-to-bracket.svg b/app/assets/icons/arrow-down-to-bracket.svg new file mode 100644 index 000000000..b2fa9fff6 --- /dev/null +++ b/app/assets/icons/arrow-down-to-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/arrow-up-down.svg b/app/assets/icons/arrow-up-down.svg new file mode 100644 index 000000000..57c0bc041 --- /dev/null +++ b/app/assets/icons/arrow-up-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/arrow-up-from-bracket.svg b/app/assets/icons/arrow-up-from-bracket.svg new file mode 100644 index 000000000..7ce3e68b0 --- /dev/null +++ b/app/assets/icons/arrow-up-from-bracket.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/amount-input-screen/amount-input-screen-ui.tsx b/app/components/amount-input-screen/amount-input-screen-ui.tsx index a8e77ac98..5f95aacd7 100644 --- a/app/components/amount-input-screen/amount-input-screen-ui.tsx +++ b/app/components/amount-input-screen/amount-input-screen-ui.tsx @@ -19,7 +19,7 @@ import Sync from "@app/assets/icons/sync.svg" // utils import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" -import { getUsdWallet } from "@app/graphql/wallets-utils" +import { getCashWallet } from "@app/graphql/wallets-utils" export type AmountInputScreenUIProps = { walletCurrency: WalletCurrency @@ -68,7 +68,7 @@ export const AmountInputScreenUI: React.FC = ({ moneyAmount: toBtcMoneyAmount(btcWallet?.balance ?? 0), }) } - const usdWallet = getUsdWallet(data?.me?.defaultAccount?.wallets) + const usdWallet = getCashWallet(data?.me?.defaultAccount?.wallets) return moneyAmountToDisplayCurrencyString({ moneyAmount: toUsdMoneyAmount(usdWallet?.balance ?? 0), }) diff --git a/app/components/amount-input/amount-input.tsx b/app/components/amount-input/amount-input.tsx index 407b4f50d..a4221ad8b 100644 --- a/app/components/amount-input/amount-input.tsx +++ b/app/components/amount-input/amount-input.tsx @@ -55,8 +55,9 @@ export const AmountInput: React.FC = ({ let formattedSecondaryAmount = undefined if (isNonZeroMoneyAmount(unitOfAccountAmount)) { - const isBtcDenominatedUsdWalletAmount = - walletCurrency === WalletCurrency.Usd && + const isBtcDenominatedCashWalletAmount = + (walletCurrency === WalletCurrency.Usd || + walletCurrency === WalletCurrency.Usdt) && unitOfAccountAmount.currency === WalletCurrency.Btc const primaryAmount = convertMoneyAmount(unitOfAccountAmount, DisplayCurrency) @@ -73,7 +74,7 @@ export const AmountInput: React.FC = ({ formattedPrimaryAmount = formatMoneyAmount({ moneyAmount: primaryAmount, - isApproximate: isBtcDenominatedUsdWalletAmount && !secondaryAmount, + isApproximate: isBtcDenominatedCashWalletAmount && !secondaryAmount, }) formattedSecondaryAmount = @@ -81,8 +82,9 @@ export const AmountInput: React.FC = ({ formatMoneyAmount({ moneyAmount: secondaryAmount, isApproximate: - isBtcDenominatedUsdWalletAmount && - secondaryAmount.currency === WalletCurrency.Usd, + isBtcDenominatedCashWalletAmount && + (secondaryAmount.currency === WalletCurrency.Usd || + secondaryAmount.currency === WalletCurrency.Usdt), }) } diff --git a/app/components/atomic/galoy-primary-button/galoy-primary-button.tsx b/app/components/atomic/galoy-primary-button/galoy-primary-button.tsx index 1eff458a1..f469c0181 100644 --- a/app/components/atomic/galoy-primary-button/galoy-primary-button.tsx +++ b/app/components/atomic/galoy-primary-button/galoy-primary-button.tsx @@ -3,6 +3,9 @@ import { Button, ButtonProps, makeStyles } from "@rneui/themed" import { TouchableHighlight } from "react-native" import { testProps } from "@app/utils/testProps" +const TouchableHighlightComponent = + TouchableHighlight as unknown as ButtonProps["TouchableComponent"] + export const GaloyPrimaryButton: FC> = (props) => { const styles = useStyles() @@ -10,7 +13,7 @@ export const GaloyPrimaryButton: FC> = (props) =>