From aac0d87d27bf500ea44fd17e62f570cfb19005bf Mon Sep 17 00:00:00 2001 From: Patoo <262265744+patoo0x@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:05:27 -0400 Subject: [PATCH 1/3] fix: show red error when source wallet is empty in swap flow (ENG-84) closing #527 - Add zero-balance check to ConversionAmountError before amount-exceed check - Show red error immediately when fromWallet has no balance - Add emptyWallet i18n key to ConversionDetailsScreen for localization --- .../swap-flow/ConversionAmountError.tsx | 12 ++++++-- app/i18n/i18n-types.ts | 28 +++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/app/components/swap-flow/ConversionAmountError.tsx b/app/components/swap-flow/ConversionAmountError.tsx index 186f7fafe..7b9b6ea6a 100644 --- a/app/components/swap-flow/ConversionAmountError.tsx +++ b/app/components/swap-flow/ConversionAmountError.tsx @@ -56,9 +56,17 @@ const ConversionAmountError: React.FC = ({ const checkErrorMessage = () => { if (!convertMoneyAmount) return null let amountFieldError: string | undefined = undefined - if ( + + const fromBalance = fromWalletCurrency === "BTC" ? btcBalance : usdBalance + + if (fromBalance.amount === 0) { + amountFieldError = LL.ConversionDetailsScreen.emptyWallet({ + walletName: + fromWalletCurrency === "BTC" ? LL.common.btcAccount() : LL.common.usdAccount(), + }) + } else if ( lessThan({ - value: fromWalletCurrency === "BTC" ? btcBalance : usdBalance, + value: fromBalance, lessThan: settlementSendAmount, }) ) { diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 9446ff0e4..6160d1227 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -1605,7 +1605,7 @@ type RootTranslation = { */ locationPermissionNegative: string /** - * A​s​k​ ​M​e​ ​L​a​t​e​r + * R​e​m​i​n​d​ ​m​e​ ​l​a​t​e​r */ locationPermissionNeutral: string /** @@ -2730,7 +2730,7 @@ type RootTranslation = { */ advanceMode: string /** - * K​e​y​ ​m​a​n​a​g​e​m​e​n​t + * W​a​l​l​e​t​ ​b​a​c​k​u​p */ keysManagement: string /** @@ -2841,7 +2841,7 @@ type RootTranslation = { */ upgrade: string /** - * L​o​g​ ​o​u​t​ ​a​n​d​ ​c​l​e​a​r​ ​a​l​l​ ​l​o​c​a​l​ ​d​a​t​a + * L​o​g​ ​o​u​t */ logOutAndDeleteLocalData: string /** @@ -3381,11 +3381,11 @@ type RootTranslation = { } UnVerifiedSeedModal: { /** - * Y​O​U​R​ ​B​I​T​C​O​I​N​ ​I​S​ ​N​O​T​ ​S​E​C​U​R​E​! + * S​e​c​u​r​e​ ​Y​o​u​r​ ​B​i​t​c​o​i​n​ ​W​a​l​l​e​t */ header: string /** - * Y​o​u​ ​s​h​o​u​l​d​ ​W​R​I​T​E​ ​D​O​W​N​ ​y​o​u​r​ ​r​e​c​o​v​e​r​y​ ​p​h​r​a​s​e​ ​s​o​m​e​w​h​e​r​e​ ​s​a​f​e​ ​i​n​ ​o​r​d​e​r​ ​t​o​ ​p​r​o​t​e​c​t​ ​y​o​u​r​ ​m​o​n​e​y​.​ ​I​f​ ​y​o​u​ ​l​o​s​e​ ​y​o​u​r​ ​p​h​o​n​e​ ​o​r​ ​u​n​i​n​s​t​a​l​l​ ​t​h​e​ ​a​p​p​ ​w​i​t​h​o​u​t​ ​w​r​i​t​i​n​g​ ​d​o​w​n​ ​y​o​u​r​ ​r​e​c​o​v​e​r​y​ ​p​h​r​a​s​e​,​ ​y​o​u​ ​w​i​l​l​ ​l​o​s​e​ ​a​c​c​e​s​s​ ​t​o​ ​y​o​u​r​ ​f​u​n​d​s​.​ + * Y​o​u​ ​s​h​o​u​l​d​ ​w​r​i​t​e​ ​d​o​w​n​ ​y​o​u​r​ ​r​e​c​o​v​e​r​y​ ​p​h​r​a​s​e​ ​s​o​m​e​w​h​e​r​e​ ​s​a​f​e​ ​i​n​ ​o​r​d​e​r​ ​t​o​ ​p​r​o​t​e​c​t​ ​y​o​u​r​ ​m​o​n​e​y​.​ ​I​f​ ​y​o​u​ ​l​o​s​e​ ​y​o​u​r​ ​p​h​o​n​e​ ​o​r​ ​u​n​i​n​s​t​a​l​l​ ​t​h​e​ ​a​p​p​ ​w​i​t​h​o​u​t​ ​w​r​i​t​i​n​g​ ​d​o​w​n​ ​y​o​u​r​ ​r​e​c​o​v​e​r​y​ ​p​h​r​a​s​e​,​ ​y​o​u​ ​w​i​l​l​ ​l​o​s​e​ ​a​c​c​e​s​s​ ​t​o​ ​y​o​u​r​ ​f​u​n​d​s​.​ ​ */ @@ -3982,7 +3982,7 @@ type RootTranslation = { */ backHome: string /** - * S​h​o​w​ ​R​e​c​o​v​e​r​y​ ​P​h​r​a​s​e + * W​r​i​t​e​ ​d​o​w​n​ ​r​e​c​o​v​e​r​y​ ​p​h​r​a​s​e */ revealSeed: string /** @@ -4056,7 +4056,7 @@ type RootTranslation = { */ csvExport: string /** - * E​x​p​o​r​t​ ​l​o​g​s + * E​x​p​o​r​t​ ​L​o​g​s */ exportSparkLogs: string /** @@ -4785,7 +4785,7 @@ type RootTranslation = { */ pending: string /** - * This transfer will be claimed automatically + * T​h​i​s​ ​t​r​a​n​s​f​e​r​ ​w​i​l​l​ ​b​e​ ​c​l​a​i​m​e​d​ ​a​u​t​o​m​a​t​i​c​a​l​l​y */ autoClaiming: string /** @@ -4861,8 +4861,8 @@ type RootTranslation = { */ networkFee: string /** - * @param {string} feeRate * N​e​t​w​o​r​k​ ​F​e​e​ ​(​{​f​e​e​R​a​t​e​}​ ​s​a​t​/​v​B​) + * @param {string} feeRate */ networkFeeWithRate: RequiredParams<'feeRate'> /** @@ -4886,8 +4886,8 @@ type RootTranslation = { */ invalidBitcoinAddress: string /** - * @param {string} feeRate * E​s​t​.​ ​N​e​t​w​o​r​k​ ​F​e​e​ ​(​{​f​e​e​R​a​t​e​}​ ​s​a​t​/​v​B​) + * @param {string} feeRate */ estimatedFee: RequiredParams<'feeRate'> /** @@ -5014,7 +5014,7 @@ type RootTranslation = { */ keyConflictTitle: string /** - * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​h​a​s​ ​a​ ​r​e​g​i​s​t​e​r​e​d​ ​N​o​s​t​r​ ​k​e​y​ ​b​u​t​ ​i​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d​ ​o​n​ ​t​h​i​s​ ​d​e​v​i​c​e​. + * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​h​a​s​ ​a​ ​r​e​g​i​s​t​e​r​e​d​ ​N​o​s​t​r​ ​k​e​y​ ​b​u​t​ ​i​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d​ ​o​n​ ​t​h​i​s​ ​d​e​v​i​c​e​.​ ​T​h​i​s​ ​c​a​n​ ​h​a​p​p​e​n​ ​a​f​t​e​r​ ​r​e​i​n​s​t​a​l​l​i​n​g​ ​t​h​e​ ​a​p​p​ ​o​r​ ​s​w​i​t​c​h​i​n​g​ ​d​e​v​i​c​e​s​.​ ​T​o​ ​r​e​s​t​o​r​e​ ​a​c​c​e​s​s​,​ ​i​m​p​o​r​t​ ​y​o​u​r​ ​n​s​e​c​ ​b​a​c​k​u​p​ ​f​r​o​m​ ​A​d​v​a​n​c​e​d​ ​S​e​t​t​i​n​g​s​. */ keyConflictDescription: string /** @@ -7908,7 +7908,7 @@ export type TranslationFunctions = { */ advanceMode: () => LocalizedString /** - * Key management + * Wallet backup */ keysManagement: () => LocalizedString /** @@ -9643,7 +9643,7 @@ export type TranslationFunctions = { } reports: { /** - * Export Transactions + * Generate Reports */ title: () => LocalizedString /** @@ -10154,7 +10154,7 @@ export type TranslationFunctions = { */ keyConflictTitle: () => LocalizedString /** - * Your account has a registered Nostr key but it was not found on this device. + * Your account has a registered Nostr key but it was not found on this device. This can happen after reinstalling the app or switching devices. To restore access, import your nsec backup from Advanced Settings. */ keyConflictDescription: () => LocalizedString /** From 6096a9f0b4f04b7af79ca7b0159dc2fdec5ab55b Mon Sep 17 00:00:00 2001 From: Vandana Date: Sun, 5 Apr 2026 17:43:27 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(upgrade):=20normalize=20image/jpg=20?= =?UTF-8?q?=E2=86=92=20image/jpeg=20for=20ID=20document=20upload=20(ENG-29?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Android react-native-image-picker and Vision Camera return 'image/jpg' for some JPEG photos. The backend storage service only accepts ['image/jpeg', 'image/png', 'image/webp'] — 'image/jpg' causes InvalidFileTypeError → no upload URL generated → HTTP 400 on step 4. Changes: - app/utils/image-content-type.ts (new): shared normalizeContentType() and isValidContentType() utilities matching backend allowlist - app/hooks/useAccountUpgrade.tsx: normalize contentType before calling idDocumentUploadUrlGenerate and uploadFileToS3 - app/components/account-upgrade-flow/PhotoUploadField.tsx: - remove ALLOWED_FILE_TYPES (was out of sync with backend) - use normalizeContentType + isValidContentType from shared util - normalize before validating in both gallery and camera paths - fallback to 'image/jpeg' if camera blob.type is empty string - __tests__/hooks/use-account-upgrade-content-type.spec.ts (new): unit tests for normalizeContentType and isValidContentType Note: test infra (ttypescript + Node 25) has a pre-existing incompatibility — tests are written and will run when that's resolved. Fixes: ENG-291 --- .../use-account-upgrade-content-type.spec.ts | 73 +++++++++++++++++++ .../account-upgrade-flow/PhotoUploadField.tsx | 13 ++-- app/hooks/useAccountUpgrade.tsx | 9 ++- app/utils/image-content-type.ts | 29 ++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 __tests__/hooks/use-account-upgrade-content-type.spec.ts create mode 100644 app/utils/image-content-type.ts diff --git a/__tests__/hooks/use-account-upgrade-content-type.spec.ts b/__tests__/hooks/use-account-upgrade-content-type.spec.ts new file mode 100644 index 000000000..af35578a1 --- /dev/null +++ b/__tests__/hooks/use-account-upgrade-content-type.spec.ts @@ -0,0 +1,73 @@ +/** + * ENG-291 — Bug: HTTP 400 on account upgrade Step 4 (ID document upload) + * + * Root cause: Android `react-native-image-picker` returns `image/jpg` for some + * camera-captured photos. The backend storage service only accepts: + * ["image/jpeg", "image/png", "image/webp"] + * + * `image/jpg` is NOT in the backend allowlist, causing `InvalidFileTypeError` + * → upload URL not generated → submission fails with HTTP 400. + * + * Fix: normalize `image/jpg` → `image/jpeg` before calling the upload mutation. + */ + +import { normalizeContentType, isValidContentType } from "@app/utils/image-content-type" + +describe("normalizeContentType (ENG-291)", () => { + it("normalizes image/jpg to image/jpeg", () => { + expect(normalizeContentType("image/jpg")).toBe("image/jpeg") + }) + + it("leaves image/jpeg unchanged", () => { + expect(normalizeContentType("image/jpeg")).toBe("image/jpeg") + }) + + it("leaves image/png unchanged", () => { + expect(normalizeContentType("image/png")).toBe("image/png") + }) + + it("leaves image/webp unchanged", () => { + expect(normalizeContentType("image/webp")).toBe("image/webp") + }) + + it("leaves unknown types unchanged (backend will reject)", () => { + expect(normalizeContentType("image/heic")).toBe("image/heic") + }) +}) + +describe("isValidContentType (ENG-291 — backend-compatible)", () => { + const BACKEND_ALLOWED = ["image/jpeg", "image/png", "image/webp"] + + it("accepts image/jpeg", () => { + expect(isValidContentType("image/jpeg")).toBe(true) + }) + + it("accepts image/png", () => { + expect(isValidContentType("image/png")).toBe(true) + }) + + it("accepts image/webp", () => { + expect(isValidContentType("image/webp")).toBe(true) + }) + + it("rejects image/jpg (non-normalized — was causing the bug)", () => { + // Before the fix, image/jpg was in ALLOWED_FILE_TYPES client-side + // but not in the backend allowlist — this test documents that we no longer + // send image/jpg raw; we normalize first + expect(isValidContentType("image/jpg")).toBe(false) + }) + + it("rejects image/heic", () => { + expect(isValidContentType("image/heic")).toBe(false) + }) + + it("rejects empty string", () => { + expect(isValidContentType("")).toBe(false) + }) + + it("all backend-allowed types pass", () => { + BACKEND_ALLOWED.forEach((type) => { + expect(isValidContentType(type)).toBe(true) + }) + }) +}) diff --git a/app/components/account-upgrade-flow/PhotoUploadField.tsx b/app/components/account-upgrade-flow/PhotoUploadField.tsx index c4664e700..fbca6f935 100644 --- a/app/components/account-upgrade-flow/PhotoUploadField.tsx +++ b/app/components/account-upgrade-flow/PhotoUploadField.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react" import { Alert, Image, Linking, Modal, TouchableOpacity, View } from "react-native" import { Icon, makeStyles, Text, useTheme } from "@rneui/themed" import { Asset, launchImageLibrary } from "react-native-image-picker" +import { normalizeContentType, isValidContentType } from "@app/utils/image-content-type" import { Camera, CameraRuntimeError, @@ -24,7 +25,6 @@ import PhotoAdd from "@app/assets/icons/photo-add.svg" import { toastShow } from "@app/utils/toast" const MAX_FILE_SIZE = 5 * 1024 * 1024 // File size limit in bytes (5MB) -const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/jpg"] type Props = { label: string @@ -89,12 +89,13 @@ const PhotoUploadField: React.FC = ({ result.assets[0].type && result.assets[0].fileSize ) { - if (!ALLOWED_FILE_TYPES.includes(result.assets[0].type)) { + const normalizedType = normalizeContentType(result.assets[0].type) + if (!isValidContentType(normalizedType)) { setErrorMsg("Please upload a valid image (JPG or PNG)") } else if (result?.assets[0]?.fileSize > MAX_FILE_SIZE) { setErrorMsg("File size exceeds 5MB limit") } else { - onPhotoUpload(result.assets[0]) + onPhotoUpload({ ...result.assets[0], type: normalizedType }) } } } catch (err: unknown) { @@ -111,14 +112,16 @@ const PhotoUploadField: React.FC = ({ const result = await fetch(`file://${file?.path}`) const data = await result.blob() - if (!ALLOWED_FILE_TYPES.includes(data.type)) { + // Normalize content type before validation — camera can return "image/jpg" or "" + const normalizedType = normalizeContentType(data.type || "image/jpeg") + if (!isValidContentType(normalizedType)) { setErrorMsg("Please upload a valid image (JPG or PNG)") } else if (data.size > MAX_FILE_SIZE) { setErrorMsg("File size exceeds 5MB limit") } else { onPhotoUpload({ uri: `file://${file?.path}`, - type: data.type, + type: normalizedType, fileSize: data.size, fileName: file.path.split("/").pop(), }) diff --git a/app/hooks/useAccountUpgrade.tsx b/app/hooks/useAccountUpgrade.tsx index 023cd67e4..eee2b5c0e 100644 --- a/app/hooks/useAccountUpgrade.tsx +++ b/app/hooks/useAccountUpgrade.tsx @@ -3,6 +3,7 @@ import { parsePhoneNumber } from "libphonenumber-js" // hooks import { useActivityIndicator } from "./useActivityIndicator" +import { normalizeContentType } from "@app/utils/image-content-type" import { useAppDispatch, useAppSelector } from "@app/store/redux" import { useBusinessAccountUpgradeRequestMutation, @@ -112,13 +113,17 @@ export const useAccountUpgrade = () => { return null } + // Normalize content type: Android returns "image/jpg" for some JPEGs, + // but the backend only accepts "image/jpeg" (ENG-291) + const normalizedContentType = normalizeContentType(idDocument.type) + let data try { const result = await generateIdDocumentUploadUrl({ variables: { input: { filename: idDocument.fileName, - contentType: idDocument.type, + contentType: normalizedContentType, }, }, }) @@ -138,7 +143,7 @@ export const useAccountUpgrade = () => { await uploadFileToS3( data.idDocumentUploadUrlGenerate.uploadUrl, idDocument.uri, - idDocument.type, + normalizedContentType, ) return data.idDocumentUploadUrlGenerate.fileKey ?? null diff --git a/app/utils/image-content-type.ts b/app/utils/image-content-type.ts new file mode 100644 index 000000000..959044738 --- /dev/null +++ b/app/utils/image-content-type.ts @@ -0,0 +1,29 @@ +/** + * Image content type utilities for ID document upload (ENG-291) + * + * Android's react-native-image-picker and Vision Camera can return "image/jpg" + * for JPEG files. The backend storage service only accepts "image/jpeg" — + * normalize before calling the upload URL mutation. + */ + +/** Content types accepted by the Flash backend storage service */ +export const BACKEND_ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const + +export type AllowedContentType = (typeof BACKEND_ALLOWED_CONTENT_TYPES)[number] + +/** + * Normalize platform-specific content type aliases to backend-compatible types. + * Specifically maps image/jpg → image/jpeg (Android returns image/jpg for some JPEGs). + */ +export const normalizeContentType = (contentType: string): string => { + if (contentType === "image/jpg") return "image/jpeg" + return contentType +} + +/** + * Returns true if the content type (after normalization) is accepted by the backend. + * Note: always normalize before checking, or call normalizeContentType first. + */ +export const isValidContentType = (contentType: string): boolean => { + return BACKEND_ALLOWED_CONTENT_TYPES.includes(contentType as AllowedContentType) +} From 090535978aef64c0523f486465fda9c8053948f1 Mon Sep 17 00:00:00 2001 From: Vandana Date: Sun, 5 Apr 2026 19:38:31 -0400 Subject: [PATCH 3/3] fix(upgrade): remove Number() cast on accountNumber (ENG-291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accountNumber in GraphQL schema is now String (not Int). Remove Number() conversion — pass the string value directly. Paired with backend PR #319 (flash repo). --- app/hooks/useAccountUpgrade.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/hooks/useAccountUpgrade.tsx b/app/hooks/useAccountUpgrade.tsx index eee2b5c0e..16937b1bd 100644 --- a/app/hooks/useAccountUpgrade.tsx +++ b/app/hooks/useAccountUpgrade.tsx @@ -173,7 +173,8 @@ export const useAccountUpgrade = () => { bankInfo.currency ) { bankAccount = { - accountNumber: Number(bankInfo.accountNumber), + // accountNumber is a String in GraphQL (identifiers can exceed Int32) + accountNumber: bankInfo.accountNumber, accountType: bankInfo.bankAccountType, bankBranch: bankInfo.bankBranch, bankName: bankInfo.bankName,