diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json
index 2f615635..c0e8d142 100644
--- a/dapps/pos-app/app.json
+++ b/dapps/pos-app/app.json
@@ -130,6 +130,12 @@
"iosPermissions": ["Bluetooth"]
}
],
+ [
+ "expo-camera",
+ {
+ "cameraPermission": "WalletConnect Pay uses the camera to scan a Customer API key QR code."
+ }
+ ],
[
"expo-secure-store",
{
diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx
index 382703fc..0b3627e5 100644
--- a/dapps/pos-app/app/_layout.tsx
+++ b/dapps/pos-app/app/_layout.tsx
@@ -233,6 +233,13 @@ export default Sentry.wrap(function RootLayout() {
name="settings"
options={{ headerTitle: SettingsHeaderTitle }}
/>
+
state.setScannedValue,
+ );
+ const [assets] = useAssets([require("@/assets/images/close.png")]);
+
+ // Guards against the scanner firing multiple times before the screen pops.
+ const handledRef = useRef(false);
+
+ // Ask for camera access as soon as the screen mounts.
+ useEffect(() => {
+ if (permission && !permission.granted && permission.canAskAgain) {
+ requestPermission();
+ }
+ }, [permission, requestPermission]);
+
+ const handleBarcodeScanned = (result: BarcodeScanningResult) => {
+ if (handledRef.current) return;
+ const value = result.data?.trim();
+ if (!value) return;
+ handledRef.current = true;
+ setScannedValue(value);
+ router.back();
+ };
+
+ const close = () => router.back();
+
+ const isDenied = permission?.granted === false && !permission.canAskAgain;
+
+ return (
+
+ {isFocused && permission?.granted ? (
+
+ ) : null}
+
+ {/* Dimmed mask with a transparent square window (four strips around it). */}
+
+
+
+
+
+
+
+
+
+
+
+ {isDenied
+ ? "Camera access is off. Enable it in your device settings to scan."
+ : "Point your camera at the API key QR code"}
+
+
+
+
+ {/* Close button, top-left, above the safe-area inset. */}
+
+
+
+
+ {isDenied ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+const MASK_COLOR = "rgba(0, 0, 0, 0.7)";
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "black",
+ },
+ overlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ mask: {
+ flex: 1,
+ backgroundColor: MASK_COLOR,
+ },
+ middleRow: {
+ flexDirection: "row",
+ height: SCAN_AREA_SIZE,
+ },
+ window: {
+ width: SCAN_AREA_SIZE,
+ height: SCAN_AREA_SIZE,
+ },
+ bottomMask: {
+ alignItems: "center",
+ paddingTop: Spacing["spacing-7"],
+ paddingHorizontal: Spacing["spacing-8"],
+ },
+ instruction: {
+ textAlign: "center",
+ },
+ closeButton: {
+ position: "absolute",
+ left: Spacing["spacing-5"],
+ width: 38,
+ height: 38,
+ borderRadius: BorderRadius["3"],
+ borderWidth: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: "rgba(0, 0, 0, 0.3)",
+ },
+ closeIcon: {
+ width: 20,
+ height: 20,
+ },
+ deniedActions: {
+ position: "absolute",
+ left: Spacing["spacing-5"],
+ right: Spacing["spacing-5"],
+ },
+});
diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx
index dd615d32..6ca6cca9 100644
--- a/dapps/pos-app/app/settings.tsx
+++ b/dapps/pos-app/app/settings.tsx
@@ -1,6 +1,7 @@
import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { PinModal } from "@/components/pin-modal";
+import { Pressable } from "@/components/pressable";
import { RadioList, RadioOption } from "@/components/radio-list";
import { SettingsBottomSheet } from "@/components/settings-bottom-sheet";
import { SettingsItem } from "@/components/settings-item";
@@ -10,10 +11,12 @@ import { SetupBanner } from "@/components/setup-banner";
import { ThemedText } from "@/components/themed-text";
import { BorderRadius, Spacing } from "@/constants/spacing";
import { useBiometricAuth } from "@/hooks/use-biometric-auth";
+import { useHasCamera } from "@/hooks/use-has-camera";
import { useMerchantFlow } from "@/hooks/use-merchant-flow";
import { useNfcCapabilities } from "@/hooks/use-nfc-capabilities";
import { useTheme } from "@/hooks/use-theme-color";
import { useLogsStore } from "@/store/useLogsStore";
+import { usePendingApiKeyScanStore } from "@/store/usePendingApiKeyScanStore";
import { useSettingsStore } from "@/store/useSettingsStore";
import { usePosBridgeStore } from "@/store/usePosBridgeStore";
import { isRunningInIframe } from "@/utils/is-running-in-iframe";
@@ -33,7 +36,7 @@ import * as Application from "expo-application";
import Constants from "expo-constants";
import { Image } from "expo-image";
import { router } from "expo-router";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Platform, StyleSheet, TextInput, View } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
@@ -91,6 +94,7 @@ export default function SettingsScreen() {
const isIframeBridgeConfigured = isIframeSession && isBridgeConfigured;
const [activeSheet, setActiveSheet] = useState(null);
+ const hasCamera = useHasCamera();
// Custom hooks for biometrics and merchant flow
const {
@@ -118,6 +122,7 @@ export default function SettingsScreen() {
resetCustomerApiKeyInput,
handleMerchantIdConfirm,
handleCustomerApiKeyConfirm,
+ handleScannedCustomerApiKey,
handlePinVerifyComplete,
handleBiometricPress,
handlePinSetupComplete,
@@ -128,6 +133,19 @@ export default function SettingsScreen() {
biometricLabel,
});
+ // Pick up an API key scanned by the full-screen scan route once it pops back,
+ // then run the auto-save flow (PIN/biometric) here at the settings root.
+ const scannedApiKey = usePendingApiKeyScanStore(
+ (state) => state.scannedValue,
+ );
+ const clearScannedApiKey = usePendingApiKeyScanStore((state) => state.clear);
+
+ useEffect(() => {
+ if (!scannedApiKey) return;
+ handleScannedCustomerApiKey(scannedApiKey);
+ clearScannedApiKey();
+ }, [scannedApiKey, handleScannedCustomerApiKey, clearScannedApiKey]);
+
const currencyOptions: RadioOption[] = useMemo(
() =>
CURRENCIES.map((c) => ({
@@ -174,6 +192,11 @@ export default function SettingsScreen() {
handleCustomerApiKeyConfirm();
};
+ const handleScanApiKeyPress = () => {
+ closeSheet();
+ router.push("/scan-api-key");
+ };
+
const handleTestModeChange = (enabled: boolean) => {
setTestMode(enabled);
if (enabled) {
@@ -488,29 +511,53 @@ export default function SettingsScreen() {
onClose={closeSheet}
>
-
+
+
+ {hasCamera && (
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ corner: {
+ position: "absolute",
+ },
+ topLeft: {
+ top: 0,
+ left: 0,
+ },
+ topRight: {
+ top: 0,
+ right: 0,
+ },
+ bottomLeft: {
+ bottom: 0,
+ left: 0,
+ },
+ bottomRight: {
+ bottom: 0,
+ right: 0,
+ },
+});
diff --git a/dapps/pos-app/hooks/use-has-camera.ts b/dapps/pos-app/hooks/use-has-camera.ts
new file mode 100644
index 00000000..437e7dc5
--- /dev/null
+++ b/dapps/pos-app/hooks/use-has-camera.ts
@@ -0,0 +1,49 @@
+import { CameraView } from "expo-camera";
+import { useEffect, useState } from "react";
+import { Platform } from "react-native";
+import { isCameraPresent } from "react-native-device-info";
+
+// Reports whether the device has a usable camera, so the API-key scan
+// affordance can be hidden on camera-less hardware (e.g. some POS terminals).
+//
+// Detection is per-platform because no single API covers all three:
+// - web: CameraView.isAvailableAsync() enumerates `videoinput` devices.
+// - android: react-native-device-info's isCameraPresent() (the case that
+// matters for camera-less POS terminals).
+// - ios: neither API supports iOS, but every iOS device this app runs on has a
+// camera, so we assume present.
+//
+// Defaults to `true` (optimistic) and only flips to `false` once a check
+// confirms no camera, so the icon doesn't flash out on the common case. Any
+// detection error fails open (keeps the icon; the scan screen still handles the
+// permission/unavailable states).
+export function useHasCamera(): boolean {
+ const [hasCamera, setHasCamera] = useState(true);
+
+ useEffect(() => {
+ let mounted = true;
+
+ async function check() {
+ try {
+ if (Platform.OS === "web") {
+ const available = await CameraView.isAvailableAsync();
+ if (mounted) setHasCamera(available);
+ } else if (Platform.OS === "android") {
+ const present = await isCameraPresent();
+ if (mounted) setHasCamera(present);
+ }
+ // iOS: leave the optimistic default.
+ } catch {
+ // Fail open — keep the affordance visible.
+ }
+ }
+
+ check();
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ return hasCamera;
+}
diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts
index 7ab66407..f1b2ed0c 100644
--- a/dapps/pos-app/hooks/use-merchant-flow.ts
+++ b/dapps/pos-app/hooks/use-merchant-flow.ts
@@ -1,6 +1,7 @@
import { useLogsStore } from "@/store/useLogsStore";
import { useSettingsStore } from "@/store/useSettingsStore";
import { formatCountdown } from "@/utils/misc";
+import { parseApiKeyQr } from "@/utils/parse-api-key-qr";
import { showErrorToast, showSuccessToast } from "@/utils/toast";
import { useCallback, useEffect, useState } from "react";
@@ -173,6 +174,18 @@ export function useMerchantFlow({
initiateSave(trimmedApiKey, "customer-api-key");
}, [state.customerApiKeyInput, isCustomerApiKeySet, initiateSave]);
+ const handleScannedCustomerApiKey = useCallback(
+ (raw: string) => {
+ const parsed = parseApiKeyQr(raw);
+ if (!parsed) {
+ showErrorToast("We couldn't read an API key from that QR code.");
+ return;
+ }
+ initiateSave(parsed, "customer-api-key");
+ },
+ [initiateSave],
+ );
+
const completeSave = useCallback(async () => {
if (state.pendingValue === null || !state.pendingAction) {
return;
@@ -345,6 +358,7 @@ export function useMerchantFlow({
resetCustomerApiKeyInput,
handleMerchantIdConfirm,
handleCustomerApiKeyConfirm,
+ handleScannedCustomerApiKey,
handlePinVerifyComplete,
handleBiometricPress: runBiometricAuth,
handlePinSetupComplete,
diff --git a/dapps/pos-app/package-lock.json b/dapps/pos-app/package-lock.json
index d627fb30..480b16d5 100644
--- a/dapps/pos-app/package-lock.json
+++ b/dapps/pos-app/package-lock.json
@@ -16,6 +16,7 @@
"expo": "56.0.12",
"expo-application": "56.0.3",
"expo-asset": "56.0.17",
+ "expo-camera": "~56.0.8",
"expo-clipboard": "56.0.4",
"expo-constants": "56.0.18",
"expo-crypto": "56.0.4",
@@ -4866,6 +4867,12 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/emscripten": {
+ "version": "1.41.6",
+ "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
+ "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -6430,6 +6437,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/barcode-detector": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz",
+ "integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==",
+ "license": "MIT",
+ "dependencies": {
+ "zxing-wasm": "3.1.3"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -8479,6 +8495,26 @@
"react-native": "*"
}
},
+ "node_modules/expo-camera": {
+ "version": "56.0.8",
+ "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz",
+ "integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "barcode-detector": "^3.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/expo-clipboard": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-56.0.4.tgz",
@@ -15738,6 +15774,18 @@
"url": "https://opencollective.com/synckit"
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/tar": {
"version": "7.5.21",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz",
@@ -17195,6 +17243,34 @@
"optional": true
}
}
+ },
+ "node_modules/zxing-wasm": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz",
+ "integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/emscripten": "^1.41.5",
+ "type-fest": "^5.8.0"
+ },
+ "peerDependencies": {
+ "@types/emscripten": ">=1.39.6"
+ }
+ },
+ "node_modules/zxing-wasm/node_modules/type-fest": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
+ "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
diff --git a/dapps/pos-app/package.json b/dapps/pos-app/package.json
index c600a3e7..1593d998 100644
--- a/dapps/pos-app/package.json
+++ b/dapps/pos-app/package.json
@@ -33,6 +33,7 @@
"expo": "56.0.12",
"expo-application": "56.0.3",
"expo-asset": "56.0.17",
+ "expo-camera": "~56.0.8",
"expo-clipboard": "56.0.4",
"expo-constants": "56.0.18",
"expo-crypto": "56.0.4",
diff --git a/dapps/pos-app/store/usePendingApiKeyScanStore.ts b/dapps/pos-app/store/usePendingApiKeyScanStore.ts
new file mode 100644
index 00000000..2c36dfe5
--- /dev/null
+++ b/dapps/pos-app/store/usePendingApiKeyScanStore.ts
@@ -0,0 +1,20 @@
+import { create } from "zustand";
+
+// Transient (non-persisted) hand-off channel for a scanned Customer API key.
+// The full-screen scan route (`app/scan-api-key.tsx`) writes the decoded value
+// here and pops back; the settings screen consumes it to run the auto-save
+// flow. A navigation param can't update the already-mounted settings screen,
+// so a tiny store is the clean way to pass the value back.
+interface PendingApiKeyScanStore {
+ scannedValue: string | null;
+ setScannedValue: (value: string) => void;
+ clear: () => void;
+}
+
+export const usePendingApiKeyScanStore = create(
+ (set) => ({
+ scannedValue: null,
+ setScannedValue: (value) => set({ scannedValue: value }),
+ clear: () => set({ scannedValue: null }),
+ }),
+);
diff --git a/dapps/pos-app/utils/parse-api-key-qr.ts b/dapps/pos-app/utils/parse-api-key-qr.ts
new file mode 100644
index 00000000..26dc3184
--- /dev/null
+++ b/dapps/pos-app/utils/parse-api-key-qr.ts
@@ -0,0 +1,10 @@
+// Extracts the Customer API key from a scanned QR code payload.
+//
+// The QR format isn't finalized yet, so we currently treat the decoded text as
+// the plain key string. Keep this the single place that knows the payload
+// shape: when the format is settled (e.g. a URL or JSON wrapper), parse it here
+// and the scan flow keeps working unchanged.
+export function parseApiKeyQr(raw: string): string | null {
+ const trimmed = raw.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}