diff --git a/README.md b/README.md index c1e07c7..4991b18 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,40 @@ -# Welcome to your Expo app πŸ‘‹ - # Ding Payments β€” Mobile Client MVP -This repository contains the core React Native application built with Expo Router and the Soroban Smart Contract SDK for the Ding Payments network. +Peer-to-peer contactless (NFC) payments on Stellar with a self-custodial wallet and passkey authentication. -## πŸ›  Prerequisites +## Prerequisites -- **Node.js**: v18 or later -- **Package Manager**: `npm` -- **Development Target**: Physical iOS or Android device (required for NFC, passkeys, and SecureStore biometrics); simulator/emulator for UI-only work +- **Node.js** v20+ +- **npm** +- **Xcode** (iOS) or **Android Studio** (Android) +- Physical NFC devices for end-to-end NFC validation -> ⚠️ Native Framework Limitation: This application leverages advanced hardware integrations including NFC capabilities and Passkey WebAuthn modules. These features cannot execute inside standard Expo Go. You must use an Expo Development Build (EAS Development Build) to validate NFC, passkeys, and SecureStore functionality on physical devices. +> **Expo Go is not supported.** NFC, passkeys, and SecureStore require a **development build** (`npx expo run:ios` or `npx expo run:android`, or EAS dev build). -## πŸš€ Local Development Setup +## Setup -1. **Clone the Repository & Fetch Dependencies** +1. Clone and install dependencies: ```bash npm install + cp .env.example .env ``` -2. **Build requirements for native features** - - NFC and passkey research require an Expo development build or custom native runtime. +2. Build requirements for native features: + - Run `npx expo prebuild` and `npx expo run:android` / `npx expo run:ios` for device validation. - Use `npm run dev-client` to launch a dev-client session after native dependencies are installed. ## EAS Development Build -1. **Install and authenticate EAS CLI** +1. Install and authenticate EAS CLI: ```bash npm install -g eas-cli eas login ``` - On first setup, link the project with `eas init`. Build profiles live in `eas.json` (`development`, `preview`, `production`). - -2. **Create a development build** +2. Create a development build: ```bash npm run dev:build:android @@ -44,59 +42,25 @@ This repository contains the core React Native application built with Expo Route npm run dev:build:ios ``` - Install the resulting build on a **physical device** (`.apk` on Android; iOS via internal distribution or TestFlight). - -3. **Start the dev client** +3. Start the dev client: ```bash npm run dev-client ``` - This runs `expo start --dev-client` and connects the installed development build to Metro. - -4. **Native rebuild required** - Rebuild and reinstall the development build after changes to: - - `app.config.ts` plugins, permissions, or entitlements - - native dependencies (for example `expo-dev-client`, `expo-secure-store`, `react-native-nfc-manager`, `react-native-passkey`) - - JavaScript-only changes do not require a native rebuild. - -5. **Expo Go limitations** - Do not use Expo Go to validate NFC, passkeys, or SecureStore with biometric authentication. These flows require a development build with `expo-dev-client`. +4. **Native rebuild required** after changes to `app.config.ts` plugins, permissions, or native dependencies (`expo-dev-client`, `expo-secure-store`, `react-native-nfc-manager`, `react-native-passkey`). -6. **Simulator vs physical device** +## Quality checks (CI) - | Feature | Simulator / emulator | Physical device | - | ------------------------ | -------------------- | --------------- | - | General UI / routing | Yes | Yes | - | NFC | No | Yes (required) | - | Passkeys | Limited / unreliable | Yes (required) | - | SecureStore + biometrics | Limited | Yes (required) | - - Use a physical device for native capability smoke tests, including the `/c05` spike page. - -## βœ… Quality checks (CI) - -CI (`.github/workflows/ci-client.yml`) runs the exact same npm scripts you run locally, so a green local run means a green pipeline. Run all three before opening a PR: +Run before opening a PR: ```bash -npm run build # tsc --noEmit β€” type-checks the project -npm run lint # expo lint (ESLint flat config + Prettier rules) -npm run format # prettier --write . β€” auto-formats the repo +npm run build +npm run lint +npm run test +npm run format:check ``` -Helper scripts: - -| Script | Purpose | -| ------------------------------------- | ---------------------------------------------- | -| `npm run build` / `npm run typecheck` | TypeScript type-check (`tsc --noEmit`) | -| `npm run lint` | Report lint problems (`expo lint`) | -| `npm run lint:fix` | Auto-fix lint problems | -| `npm run format` | Format all files with Prettier | -| `npm run format:check` | Verify formatting without writing (used by CI) | - -Tooling config lives at the repo root: [`eslint.config.mjs`](eslint.config.mjs) (Expo flat config + Prettier) and [`.prettierrc`](.prettierrc) (`singleQuote`, `trailingComma: es5`). Editors with the ESLint and Prettier extensions pick these up automatically. - ## C05 Spike documentation - Passkey ADR: [`docs/adr-passkey-library.md`](docs/adr-passkey-library.md) @@ -104,59 +68,55 @@ Tooling config lives at the repo root: [`eslint.config.mjs`](eslint.config.mjs) - NFC ADR: [`docs/adr-nfc-library.md`](docs/adr-nfc-library.md) - Spike PoC page: open `/c05` in the app after starting the dev-client. -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). - -## Get started +## NFC development (C10) -1. Install dependencies +The NFC core stack lives under `src/features/nfc/`: - ```bash - npm install - ``` - -2. Start the app - - ```bash - npx expo start - ``` +| Module | Purpose | +| ---------------------------------------- | ----------------------------------------------------- | +| `services/NfcService.*` | Native abstraction (support checks, sessions) | +| `schemas/paymentRequest.ts` | Zod schema for `payment_request.v1` payloads | +| `services/NfcPayloadCodec.ts` | Compact JSON encode/decode with size guard | +| `services/NfcWriter.ts` / `NfcReader.ts` | Writer (receiver) and reader (payer) sessions | +| `state/nfcSessionStore.ts` | Session state machine + `nfcActive` lock coordination | +| `services/nfc-spike.ts` | Manual PoC helpers for device verification | - For NFC, passkeys, and SecureStore testing, use `npm run dev-client` (`expo start --dev-client`) with an installed development buildβ€”not Expo Go. - -In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo - -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). - -## Get a fresh project - -When you're ready, run: +### Rebuild after native NFC changes ```bash -npm run reset-project +npx expo prebuild --clean +npx expo run:ios +# or +npx expo run:android ``` -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. +### Smoke test on device + +```typescript +import { nfcSpikeCheckSupport } from '@/features/nfc/services/nfc-spike'; -### Other setup steps +const { supported, enabled } = await nfcSpikeCheckSupport(); +``` -- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/) -- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/) -- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/) +See [docs/adr-nfc-library.md](docs/adr-nfc-library.md) for platform constraints and payload limits (880 bytes max). -## Learn more +## Scripts -To learn more about developing your project with Expo, look at the following resources: +| Command | Description | +| ------------------------------------- | -------------------------- | +| `npm start` | Start Expo dev server | +| `npm run dev-client` | Start Expo with dev-client | +| `npm run build` / `npm run typecheck` | TypeScript check | +| `npm test` | Run unit tests | +| `npm run lint` | ESLint via Expo | +| `npm run format:check` | Prettier check (CI) | -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +## Documentation -## Join the community +- [Product flows & system definition](docs/ding-payments.md) +- [Client MVP build plan](docs/build-plan-client-mvp.md) +- [NFC library ADR](docs/adr-nfc-library.md) -Join our community of developers creating universal apps. +## License -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +MIT diff --git a/app.config.ts b/app.config.ts index a6810cf..912c568 100644 --- a/app.config.ts +++ b/app.config.ts @@ -19,7 +19,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ infoPlist: { ...(config.ios?.infoPlist ?? {}), NFCReaderUsageDescription: - 'Use NFC to read and write payment requests securely for Ding Payments.', + 'Ding Payments uses NFC to share and receive payment requests between devices.', NSFaceIDUsageDescription: 'Use Face ID to authenticate passkey operations safely.', }, }, @@ -31,7 +31,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ backgroundImage: './assets/images/android-icon-background.png', monochromeImage: './assets/images/android-icon-monochrome.png', }, - permissions: ['NFC'], + permissions: ['android.permission.NFC'], predictiveBackGestureEnabled: false, }, web: { @@ -59,6 +59,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ configureAndroidBackup: true, }, ], + [ + 'react-native-nfc-manager', + { + nfcPermission: + 'Ding Payments uses NFC to share and receive payment requests between devices.', + includeNdefEntitlement: true, + }, + ], ], experiments: { typedRoutes: true, diff --git a/docs/adr-nfc-library.md b/docs/adr-nfc-library.md index bf784c2..4c6a6bf 100644 --- a/docs/adr-nfc-library.md +++ b/docs/adr-nfc-library.md @@ -1,66 +1,95 @@ -# ADR: NFC library selection for Expo mobile +# ADR: NFC Library Selection β€” react-native-nfc-manager -- Status: Accepted -- Date: 2026-06-19 -- Related: C05, CLI-045 +- **Status:** Accepted +- **Date:** 2026-06-19 +- **Related:** C05 (CLI-045), C10 (CLI-046–052) ## Context -The app needs NFC NDEF read/write support for payment request payloads on physical devices. Expo Go cannot be used for this verification, so the runtime must be validated in a dev-client or custom native build. +Ding Payments requires peer-to-peer NFC transport for payment-request payloads on iOS and Android. Expo Go does not expose native NFC APIs; a development build is mandatory. ## Decision -We will use `react-native-nfc-manager` for NFC integration. +Use **react-native-nfc-manager** (v3.17+) with the official Expo config plugin. ### Why this library? -- It is the most mature React Native NFC library with both Android and iOS support. -- It supports NDEF scanning and writing flows needed for payment request roundtrips. -- It is compatible with Expo native builds and supports the required native permissions. +- Mature NDEF read/write APIs on Android and Core NFC on iOS +- Official Expo config plugin for permissions and entitlements +- Active maintenance and broad community usage +- Fits the abstraction layer (`NfcService`) without leaking native details to product code ### Alternatives considered -- `react-native-hce` or similar NFC libraries: considered weaker in NDEF support for both platforms. -- Custom native Objective-C/Java modules: higher maintenance risk and slower validation. -- NFC-only web alternatives: rejected because the client must validate native NFC behavior on devices. +| Option | Rejected because | +|--------|------------------| +| `react-native-hce` or similar | Weaker NDEF support across both platforms | +| Custom native modules | Higher maintenance; slower validation | +| Expo Go only | No NFC access | +| QR-only | Out of scope for C10; planned as fallback | ## Version pinning -- `react-native-nfc-manager@^3.4.0` +- `react-native-nfc-manager@^3.17.2` -## Compatibility matrix +## Platform constraints -- iOS: Core NFC supported on devices with NFC hardware and iOS 13+; iOS only supports NDEF tag discovery for this spike. -- Android: NFC requires `android.permission.NFC`; supported devices must have NFC hardware enabled. -- Expo Go: unsupported for NFC runtime validation. +| Platform | Capability | Notes | +|----------|------------|-------| +| Android | NDEF push + tag reader mode | Primary P2P path via `setNdefPushMessage` | +| iOS | Core NFC reader / NDEF write to tags | P2P limited; validate on physical hardware | +| Web | Not supported | Stub returns `isSupported: false` | +| Expo Go | Not supported | Requires dev build rebuild after native changes | -## Implementation notes +## Payload limits -- App configuration is updated in `app.config.ts` to declare Android NFC permission and iOS NFC usage description. -- The spike implementation is isolated at `src/features/nfc/services/nfc-spike.ts`. -- The roundtrip payload for JSON NDEF should be capped to a safe practical limit, typically under 880 bytes. -- The ADR is validated through the PoC page at `/c05` after running the Expo dev-client. +- **Max NDEF payload:** 880 bytes (conservative; typical Type 2 tag usable ~888 bytes minus overhead) +- **Encoding:** UTF-8 compact JSON (`application/json` MIME NDEF record) +- **Schema:** `payment_request.v1` β€” see `src/features/nfc/schemas/paymentRequest.ts` + +## Permissions + +### iOS + +- `NFCReaderUsageDescription` in Info.plist (via config plugin) +- NDEF entitlement: `com.apple.developer.nfc.readersession.formats` (via `includeNdefEntitlement: true`) + +### Android + +- `android.permission.NFC` in AndroidManifest (via config plugin) +- Minimum SDK enforced by plugin (API 31+) + +## Session semantics (C10) + +| Session | Timeout | Policy | +|---------|---------|--------| +| Writer (receiver) | 60s | Auto-cancel + resource cleanup | +| Reader (payer) | 45s | Single-read per session; ignore duplicates | ## Validation matrix -- Android physical device: NFC initialization and NDEF write/read roundtrip works. -- iOS physical device: NFC initialization and NDEF read/write behave as expected under Core NFC constraints. -- Payload size validation: JSON payload remains below 880 bytes and roundtrip is successful on both test devices. +- Android physical device: NFC initialization and NDEF write/read roundtrip works +- iOS physical device: NFC read/write under Core NFC constraints +- Payload size validation: JSON payload remains below 880 bytes +- PoC page: `/c05` in dev-client for spike flows; C10 services in `src/features/nfc/` + +## Rebuild requirement -## Manual validation note +Any change to `app.config.ts` NFC plugin settings requires: -Use the `/c05` test page in the dev-client to execute NFC write/read flows and capture the exact read/write behavior in the ADR appendix. +```bash +npx expo prebuild --clean +npx expo run:ios # or run:android +``` ## Rollback plan If `react-native-nfc-manager` is incompatible with the Expo dev-client: -1. Re-evaluate with a custom `expo prebuild` workflow and explicit native module linking. -2. If the library cannot be used, isolate NFC support behind a modular adapter and retain the ability to switch to a different NFC package or a pure native module. +1. Re-evaluate with explicit native module linking via `expo prebuild` +2. Isolate NFC behind `NfcService` adapter to swap library without UI changes -## Known limitations +## References -- Payload size: JSON roundtrip payloads must be kept small to avoid tag write/read failures. -- Platform differences: iOS and Android may behave differently, so the ADR must capture exact device compatibility notes. -- Native build required: NFC validation is only reliable on an Expo dev-client or prebuilt binary. -- iOS Core NFC only supports certain tag types and cannot run on simulator hardware. +- [react-native-nfc-manager Expo wiki](https://github.com/revtel/react-native-nfc-manager/wiki/Expo-Go) +- Product spec: `docs/ding-payments.md` β€” Proposed Payment Payload Structure diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..929da3c --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,4 @@ +process.env.EXPO_PUBLIC_STELLAR_NETWORK = 'testnet'; +process.env.EXPO_PUBLIC_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +process.env.EXPO_PUBLIC_RPC_URL = 'https://soroban-testnet.stellar.org'; +process.env.EXPO_PUBLIC_USDC_ISSUER = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; diff --git a/package-lock.json b/package-lock.json index 30011c1..062d060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,17 +33,21 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-get-random-values": "~1.11.0", - "react-native-nfc-manager": "^3.4.0", + "react-native-nfc-manager": "^3.17.2", "react-native-passkey": "^3.5.0", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-web": "~0.21.0", "react-native-worklets": "0.8.3", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { + "@react-native/jest-preset": "^0.85.3", "@types/jest": "^29.5.14", + "@types/node": "^22.10.0", "@types/react": "~19.2.2", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^9.39.4", @@ -3407,7 +3411,6 @@ "integrity": "sha512-ALPSrM0q2fU+5AXcOXzDKx7rxVKPMvygAZfsTWLdrGRVWIqf/HEfM0R8euQqIKUqmEuQ1TxMWN+px3h6gc4vow==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.85.3", @@ -3818,12 +3821,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~6.21.0" } }, "node_modules/@types/react": { @@ -8289,6 +8292,15 @@ } } }, + "node_modules/expo/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8647,6 +8659,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -15516,9 +15529,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -16204,9 +16217,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -16224,6 +16237,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 879df51..17e8b5c 100644 --- a/package.json +++ b/package.json @@ -28,17 +28,21 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-get-random-values": "~1.11.0", - "react-native-nfc-manager": "^3.4.0", + "react-native-nfc-manager": "^3.17.2", "react-native-passkey": "^3.5.0", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", "react-native-web": "~0.21.0", "react-native-worklets": "0.8.3", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { + "@react-native/jest-preset": "^0.85.3", "@types/jest": "^29.5.14", + "@types/node": "^22.10.0", "@types/react": "~19.2.2", "babel-plugin-module-resolver": "^5.0.0", "eslint": "^9.39.4", @@ -61,7 +65,7 @@ "web": "expo start --web", "build": "tsc --noEmit", "typecheck": "tsc --noEmit", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", "lint": "expo lint", "lint:fix": "expo lint --fix", @@ -70,15 +74,19 @@ }, "jest": { "preset": "jest-expo", + "setupFiles": [ + "/jest.setup.ts" + ], "testMatch": [ "**/__tests__/**/*.test.ts", - "**/__tests__/**/*.test.tsx" + "**/__tests__/**/*.test.tsx", + "**/*.test.ts" ], "moduleNameMapper": { "^@/(.*)$": "/src/$1" }, "transformIgnorePatterns": [ - "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-passkey)" + "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-native-passkey|zustand)" ] }, "private": true diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index c1bbe7c..082d0b1 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,5 +1,4 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router'; -import { Stack } from 'expo-router'; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; import { useColorScheme } from 'react-native'; import { AnimatedSplashOverlay } from '@/components/animated-icon'; diff --git a/src/constants/session.ts b/src/constants/session.ts index c082187..53226a2 100644 --- a/src/constants/session.ts +++ b/src/constants/session.ts @@ -2,38 +2,25 @@ * Session policy constants β€” CLI-026 * * Controls inactivity lock and background lock windows. - * NFC-active lock exemption hook point is present for future coordination - * via nfcActive flag in the session policy hook. + * NFC-active lock exemption is coordinated via nfcSessionStore.nfcActive (CLI-052). */ export const SESSION = { - /** - * Lock after this many milliseconds of inactivity (no user interaction). - * 15 minutes. - */ + /** Lock after 15 minutes of foreground idle. */ IDLE_LOCK_MS: 15 * 60 * 1000, - /** - * Lock after the app has been backgrounded for this long. - * 5 minutes. - */ + /** Lock after 5 minutes in background. */ BACKGROUND_LOCK_MS: 5 * 60 * 1000, - /** - * Grace period before full lock β€” app was backgrounded less than this, - * so we can skip the re-auth prompt. 30 seconds. - */ + /** Skip re-auth if background was shorter than 30 seconds. */ BACKGROUND_GRACE_MS: 30 * 1000, - /** - * Relying party ID used for all passkey operations. - * Must match the app's associated domain / asset links. - * Placeholder β€” replace with production domain in app.config.ts. - */ RP_ID: 'dingpayments.app', - - /** - * Human-readable relying party name shown in passkey dialogs. - */ RP_NAME: 'Ding Payments', } as const; + +/** @deprecated Use SESSION.BACKGROUND_LOCK_MS */ +export const BACKGROUND_LOCK_MS = SESSION.BACKGROUND_LOCK_MS; + +/** @deprecated Use SESSION.IDLE_LOCK_MS */ +export const FOREGROUND_IDLE_LOCK_MS = SESSION.IDLE_LOCK_MS; diff --git a/src/features/auth/components/ReAuthModal.tsx b/src/features/auth/components/ReAuthModal.tsx index ff9c89c..1f8253e 100644 --- a/src/features/auth/components/ReAuthModal.tsx +++ b/src/features/auth/components/ReAuthModal.tsx @@ -12,12 +12,7 @@ */ import React, { useCallback } from 'react'; -import { - Modal, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; +import { Modal, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Button } from '@/components/ui/Button'; import { ThemedText } from '@/components/themed-text'; diff --git a/src/features/auth/components/SessionPolicyMount.tsx b/src/features/auth/components/SessionPolicyMount.tsx index 53bc077..382300e 100644 --- a/src/features/auth/components/SessionPolicyMount.tsx +++ b/src/features/auth/components/SessionPolicyMount.tsx @@ -4,14 +4,11 @@ * Thin component that mounts the session policy hook inside the * AuthProvider tree. React hooks cannot be called in the root layout * directly because it renders before the provider subtree. - * - * Mount this as a sibling of the navigation stack, inside . */ import { useSessionPolicy } from '../hooks/useSessionPolicy'; export function SessionPolicyMount() { - // nfcActive: wire from NFC feature state when available - useSessionPolicy({ nfcActive: false }); + useSessionPolicy(); return null; } diff --git a/src/features/auth/components/SettingsAuthSection.tsx b/src/features/auth/components/SettingsAuthSection.tsx index f14141b..46c85e7 100644 --- a/src/features/auth/components/SettingsAuthSection.tsx +++ b/src/features/auth/components/SettingsAuthSection.tsx @@ -11,12 +11,7 @@ */ import React, { useCallback, useState } from 'react'; -import { - Alert, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; +import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Button } from '@/components/ui/Button'; import { ThemedText } from '@/components/themed-text'; @@ -88,7 +83,12 @@ export function SettingsAuthSection() { Clave pΓΊblica - + {pubkeyCopied ? 'Β‘Copiada!' : publicKey} diff --git a/src/features/auth/hooks/useAuth.tsx b/src/features/auth/hooks/useAuth.tsx index 968af74..4390cfe 100644 --- a/src/features/auth/hooks/useAuth.tsx +++ b/src/features/auth/hooks/useAuth.tsx @@ -9,13 +9,7 @@ * Access auth state and actions via useAuth() in any component. */ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useReducer, -} from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react'; import { SecureKeyStore } from '@/lib/SecureKeyStore'; import { SECURE_KEYS } from '@/lib/SecureKeyStore.types'; @@ -108,11 +102,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); // Persist session timestamp - await SecureKeyStore.set( - SECURE_KEYS.SESSION_LAST_ACTIVE, - new Date().toISOString(), - { requireAuthentication: false } - ).catch(() => null); + await SecureKeyStore.set(SECURE_KEYS.SESSION_LAST_ACTIVE, new Date().toISOString(), { + requireAuthentication: false, + }).catch(() => null); return true; }, []); @@ -140,11 +132,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { credentialId: result.result.credentialId, }); - await SecureKeyStore.set( - SECURE_KEYS.SESSION_LAST_ACTIVE, - new Date().toISOString(), - { requireAuthentication: false } - ).catch(() => null); + await SecureKeyStore.set(SECURE_KEYS.SESSION_LAST_ACTIVE, new Date().toISOString(), { + requireAuthentication: false, + }).catch(() => null); return true; }, []); diff --git a/src/features/auth/hooks/useSessionPolicy.ts b/src/features/auth/hooks/useSessionPolicy.ts index 6a5c993..df9b9c1 100644 --- a/src/features/auth/hooks/useSessionPolicy.ts +++ b/src/features/auth/hooks/useSessionPolicy.ts @@ -2,83 +2,86 @@ * CLI-026 β€” useSessionPolicy * * Monitors AppState transitions and activity timestamps to enforce - * session lock policy: - * - * - Background lock: app backgrounded > BACKGROUND_LOCK_MS β†’ lock - * - Idle lock: no user activity for > IDLE_LOCK_MS β†’ lock - * - * NFC-active lock exemption: when nfcActive is true, background lock - * is skipped. This is the hook point for future NFC coordination. - * - * Usage: - * Mount once at the root layout inside . - * Pass nfcActive from NFC feature state when available. + * session lock policy. Skips lock while NFC sessions are active (CLI-052). */ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; import { SESSION } from '@/constants/session'; -import { useAuth } from './useAuth'; +import { useAuth } from '@/features/auth/hooks/useAuth'; +import { selectNfcActive, useNfcSessionStore } from '@/features/nfc/state/nfcSessionStore'; interface UseSessionPolicyOptions { - /** - * NFC-active exemption: when true, background lock is suppressed. - * Hook point for future NFC coordination β€” wire via nfcActive state. - */ + /** Override NFC-active detection (defaults to nfcSessionStore). */ nfcActive?: boolean; } -export function useSessionPolicy({ nfcActive = false }: UseSessionPolicyOptions = {}) { +export function useSessionPolicy({ nfcActive: nfcActiveOverride }: UseSessionPolicyOptions = {}) { + const nfcActiveFromStore = useNfcSessionStore(selectNfcActive); + const nfcActive = nfcActiveOverride ?? nfcActiveFromStore; + const { state, lock } = useAuth(); const backgroundedAt = useRef(null); const idleTimerRef = useRef | null>(null); - // ── Idle lock timer ───────────────────────────────────────────────────────── - const resetIdleTimer = () => { - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); - if (state.status !== 'READY') return; + const resetIdleTimer = useCallback(() => { + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + } + + if (state.status !== 'READY' || nfcActive) { + return; + } idleTimerRef.current = setTimeout(() => { lock(); }, SESSION.IDLE_LOCK_MS); - }; + }, [state.status, nfcActive, lock]); - // ── AppState background/foreground transitions ────────────────────────────── useEffect(() => { - if (state.status !== 'READY') return; - - const subscription = AppState.addEventListener( - 'change', - (nextState: AppStateStatus) => { - if (nextState === 'background' || nextState === 'inactive') { - backgroundedAt.current = Date.now(); - } else if (nextState === 'active') { - const elapsed = backgroundedAt.current - ? Date.now() - backgroundedAt.current - : 0; - backgroundedAt.current = null; - - // Skip lock if NFC is active (exemption hook point) - if (nfcActive) return; - - // Grace period: very short background (e.g. notification tray) β†’ skip - if (elapsed > SESSION.BACKGROUND_GRACE_MS && elapsed > SESSION.BACKGROUND_LOCK_MS) { - lock(); - } - } + if (state.status !== 'READY') { + return; + } + + const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { + if (nextState === 'background' || nextState === 'inactive') { + backgroundedAt.current = Date.now(); + return; } - ); + + if (nextState !== 'active') { + return; + } + + const elapsed = backgroundedAt.current ? Date.now() - backgroundedAt.current : 0; + backgroundedAt.current = null; + + if (nfcActive) { + resetIdleTimer(); + return; + } + + if (elapsed > SESSION.BACKGROUND_GRACE_MS && elapsed > SESSION.BACKGROUND_LOCK_MS) { + lock(); + } + + resetIdleTimer(); + }); return () => subscription.remove(); - }, [state.status, nfcActive, lock]); + }, [state.status, nfcActive, lock, resetIdleTimer]); - // ── Start/reset idle timer whenever auth status changes ────────────────────── useEffect(() => { resetIdleTimer(); return () => { - if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.status, state.lastAuthAt]); + }, [state.status, state.lastAuthAt, nfcActive, resetIdleTimer]); +} + +export function useNfcActive(): boolean { + return useNfcSessionStore(selectNfcActive); } diff --git a/src/features/auth/schemas/authApi.ts b/src/features/auth/schemas/authApi.ts index 39af466..136de56 100644 --- a/src/features/auth/schemas/authApi.ts +++ b/src/features/auth/schemas/authApi.ts @@ -32,20 +32,14 @@ export function validateRegisterChallengeResponse( return hasStringKeys(data, ['challenge', 'rpId', 'userId', 'expiresAt']); } -export function validateRegisterVerifyResponse( - data: unknown -): data is RegisterVerifyResponse { +export function validateRegisterVerifyResponse(data: unknown): data is RegisterVerifyResponse { return hasStringKeys(data, ['publicKey', 'sessionToken', 'expiresAt']); } -export function validateAuthChallengeResponse( - data: unknown -): data is AuthChallengeResponse { +export function validateAuthChallengeResponse(data: unknown): data is AuthChallengeResponse { return hasStringKeys(data, ['challenge', 'rpId', 'expiresAt']); } -export function validateAuthVerifyResponse( - data: unknown -): data is AuthVerifyResponse { +export function validateAuthVerifyResponse(data: unknown): data is AuthVerifyResponse { return hasStringKeys(data, ['sessionToken', 'publicKey', 'expiresAt']); } diff --git a/src/features/auth/services/AuthApiClient.ts b/src/features/auth/services/AuthApiClient.ts index 3a6074d..29490e6 100644 --- a/src/features/auth/services/AuthApiClient.ts +++ b/src/features/auth/services/AuthApiClient.ts @@ -12,10 +12,7 @@ * S19 (observability/telemetry conventions). */ -import type { - PasskeyAuthResult, - PasskeyCredentialInfo, -} from './types'; +import type { PasskeyAuthResult, PasskeyCredentialInfo } from './types'; // ─── Request / Response DTOs ────────────────────────────────────────────────── @@ -106,9 +103,7 @@ export const AuthApiClient = { * [STUB] Verify the registration credential with the server. * Replace with: POST /v1/auth/register/verify */ - async verifyRegistration( - request: RegisterVerifyRequest - ): Promise { + async verifyRegistration(request: RegisterVerifyRequest): Promise { // Stub: return credential ID as placeholder public key return { publicKey: request.credential.credentialId, @@ -121,9 +116,7 @@ export const AuthApiClient = { * [STUB] Request an authentication challenge from the server. * Replace with: POST /v1/auth/challenge */ - async getAuthChallenge( - _request: AuthChallengeRequest - ): Promise { + async getAuthChallenge(_request: AuthChallengeRequest): Promise { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); const challenge = Buffer.from(bytes).toString('base64url'); @@ -138,9 +131,7 @@ export const AuthApiClient = { * [STUB] Verify the authentication assertion with the server. * Replace with: POST /v1/auth/verify */ - async verifyAuthentication( - request: AuthVerifyRequest - ): Promise { + async verifyAuthentication(request: AuthVerifyRequest): Promise { return { sessionToken: `stub_token_${Date.now()}`, publicKey: request.result.credentialId, diff --git a/src/features/auth/services/__tests__/PasskeyService.test.ts b/src/features/auth/services/__tests__/PasskeyService.test.ts index c363399..7f5ba63 100644 --- a/src/features/auth/services/__tests__/PasskeyService.test.ts +++ b/src/features/auth/services/__tests__/PasskeyService.test.ts @@ -9,17 +9,21 @@ * - Session state transitions via authReducer */ +import { Buffer } from 'buffer'; + +import { SecureKeyStore } from '@/lib/SecureKeyStore'; +import { Passkey } from 'react-native-passkey'; + import { authReducer, INITIAL_AUTH_STATE } from '../../state/authStore'; import { - AuthErrorCode, - createAuthError, - mapNativePasskeyError, - sanitizeAuthError, + AuthErrorCode, + createAuthError, + mapNativePasskeyError, + sanitizeAuthError, } from '../authErrors'; +import { PasskeyService } from '../PasskeyService'; -// ─── Mock dependencies ──────────────────────────────────────────────────────── - -jest.mock('react-native-passkey', () => require('../__mocks__/passkeyNative')); +jest.mock('react-native-passkey', () => jest.requireActual('../__mocks__/passkeyNative')); jest.mock('@/lib/SecureKeyStore', () => ({ SecureKeyStore: { set: jest.fn().mockResolvedValue(undefined), @@ -45,12 +49,7 @@ Object.defineProperty(global, 'crypto', { }, }); -// Polyfill Buffer in test environment -global.Buffer = require('buffer').Buffer; - -import { SecureKeyStore } from '@/lib/SecureKeyStore'; -import { Passkey } from 'react-native-passkey'; -import { PasskeyService } from '../PasskeyService'; +global.Buffer = Buffer; // ─── authErrors.ts ──────────────────────────────────────────────────────────── diff --git a/src/features/auth/services/authErrors.ts b/src/features/auth/services/authErrors.ts index 3d58d05..0669bc7 100644 --- a/src/features/auth/services/authErrors.ts +++ b/src/features/auth/services/authErrors.ts @@ -36,12 +36,12 @@ export const AuthErrorCode = { UNKNOWN: 'UNKNOWN', } as const; -export type AuthErrorCode = (typeof AuthErrorCode)[keyof typeof AuthErrorCode]; +export type AuthErrorCodeValue = (typeof AuthErrorCode)[keyof typeof AuthErrorCode]; // ─── Typed auth error ──────────────────────────────────────────────────────── export interface AuthError { - code: AuthErrorCode; + code: AuthErrorCodeValue; /** User-safe Spanish message β€” safe to show in toasts/UI */ message: string; /** Original error for internal logging only β€” never expose to UI */ @@ -50,32 +50,25 @@ export interface AuthError { // ─── User-safe Spanish messages ────────────────────────────────────────────── -const AUTH_ERROR_MESSAGES: Record = { +const AUTH_ERROR_MESSAGES: Record = { USER_CANCELLED: 'AutenticaciΓ³n cancelada. IntΓ©ntalo de nuevo cuando estΓ©s listo.', NOT_SUPPORTED: 'Este dispositivo no es compatible con llaves de acceso. Actualiza tu sistema operativo.', - LOCKOUT: - 'Demasiados intentos fallidos. Espera un momento antes de intentarlo de nuevo.', - NO_CREDENTIAL: - 'No se encontrΓ³ ninguna llave de acceso en este dispositivo. RegΓ­strala primero.', - CREDENTIAL_EXISTS: - 'Ya existe una llave de acceso registrada para esta cuenta.', + LOCKOUT: 'Demasiados intentos fallidos. Espera un momento antes de intentarlo de nuevo.', + NO_CREDENTIAL: 'No se encontrΓ³ ninguna llave de acceso en este dispositivo. RegΓ­strala primero.', + CREDENTIAL_EXISTS: 'Ya existe una llave de acceso registrada para esta cuenta.', INVALID_REQUEST: 'La solicitud de autenticaciΓ³n no es vΓ‘lida. Contacta al soporte si el error persiste.', - TIMEOUT: - 'La solicitud tardΓ³ demasiado. Verifica tu conexiΓ³n e intΓ©ntalo de nuevo.', - INTERRUPTED: - 'La autenticaciΓ³n fue interrumpida. Por favor intΓ©ntalo de nuevo.', - STORE_ERROR: - 'No se pudo acceder al almacenamiento seguro. Verifica los permisos biomΓ©tricos.', - UNKNOWN: - 'OcurriΓ³ un error inesperado. Por favor intΓ©ntalo de nuevo.', + TIMEOUT: 'La solicitud tardΓ³ demasiado. Verifica tu conexiΓ³n e intΓ©ntalo de nuevo.', + INTERRUPTED: 'La autenticaciΓ³n fue interrumpida. Por favor intΓ©ntalo de nuevo.', + STORE_ERROR: 'No se pudo acceder al almacenamiento seguro. Verifica los permisos biomΓ©tricos.', + UNKNOWN: 'OcurriΓ³ un error inesperado. Por favor intΓ©ntalo de nuevo.', }; // ─── Native error-code β†’ AuthErrorCode mapping ────────────────────────────── // Matches error strings from react-native-passkey PasskeyError constants. -const NATIVE_ERROR_MAP: Record = { +const NATIVE_ERROR_MAP: Record = { UserCancelled: AuthErrorCode.USER_CANCELLED, UserCancelledError: AuthErrorCode.USER_CANCELLED, NotSupported: AuthErrorCode.NOT_SUPPORTED, @@ -102,7 +95,7 @@ const NATIVE_ERROR_MAP: Record = { /** * Creates a typed AuthError with user-safe Spanish message. */ -export function createAuthError(code: AuthErrorCode, cause?: unknown): AuthError { +export function createAuthError(code: AuthErrorCodeValue, cause?: unknown): AuthError { return { code, message: AUTH_ERROR_MESSAGES[code], @@ -134,6 +127,9 @@ export function mapNativePasskeyError(nativeError: unknown): AuthError { * Sanitizes an AuthError for safe logging/analytics. * Strips `cause` so no raw native errors are emitted externally. */ -export function sanitizeAuthError(err: AuthError): { code: AuthErrorCode; message: string } { +export function sanitizeAuthError(err: AuthError): { + code: AuthErrorCodeValue; + message: string; +} { return { code: err.code, message: err.message }; } diff --git a/src/features/auth/services/types.ts b/src/features/auth/services/types.ts index cb6ec5a..a5c00c2 100644 --- a/src/features/auth/services/types.ts +++ b/src/features/auth/services/types.ts @@ -69,9 +69,7 @@ export type AuthenticateResult = // ─── Revocation ─────────────────────────────────────────────────────────────── -export type RevokeResult = - | { success: true } - | { success: false; error: AuthError }; +export type RevokeResult = { success: true } | { success: false; error: AuthError }; // ─── Support check ──────────────────────────────────────────────────────────── diff --git a/src/features/auth/state/authStore.ts b/src/features/auth/state/authStore.ts index 275fcc9..5f74307 100644 --- a/src/features/auth/state/authStore.ts +++ b/src/features/auth/state/authStore.ts @@ -15,12 +15,7 @@ * - LOCKED : Session timed out β€” passkey re-auth required */ -export type AuthStatus = - | 'LOADING' - | 'UNAUTHENTICATED' - | 'ONBOARDING' - | 'READY' - | 'LOCKED'; +export type AuthStatus = 'LOADING' | 'UNAUTHENTICATED' | 'ONBOARDING' | 'READY' | 'LOCKED'; export interface AuthState { status: AuthStatus; diff --git a/src/features/auth/views/CreatePasskeyView.tsx b/src/features/auth/views/CreatePasskeyView.tsx index 053fb99..4eb4b03 100644 --- a/src/features/auth/views/CreatePasskeyView.tsx +++ b/src/features/auth/views/CreatePasskeyView.tsx @@ -42,7 +42,9 @@ export function CreatePasskeyView() { router.replace('/(tabs)/receive'); }, 1200); } else { - setErrorMessage(state.lastError ?? 'No se pudo crear la llave de acceso. IntΓ©ntalo de nuevo.'); + setErrorMessage( + state.lastError ?? 'No se pudo crear la llave de acceso. IntΓ©ntalo de nuevo.' + ); setViewState('error'); } }, [registerPasskey, router, state.lastError]); @@ -59,8 +61,8 @@ export function CreatePasskeyView() { Crear llave de acceso - Tu llave de acceso se almacena de forma segura en este dispositivo. - Se usarΓ‘ para proteger tu billetera y confirmar pagos. + Tu llave de acceso se almacena de forma segura en este dispositivo. Se usarΓ‘ para + proteger tu billetera y confirmar pagos. diff --git a/src/features/nfc/constants/index.ts b/src/features/nfc/constants/index.ts new file mode 100644 index 0000000..24c290c --- /dev/null +++ b/src/features/nfc/constants/index.ts @@ -0,0 +1,6 @@ +export { + MAX_NDEF_PAYLOAD_BYTES, + NFC_PAYMENT_MIME_TYPE, + NFC_READER_TIMEOUT_MS, + NFC_WRITER_TIMEOUT_MS, +} from '@/features/nfc/constants/nfcConstants'; diff --git a/src/features/nfc/constants/nfcConstants.ts b/src/features/nfc/constants/nfcConstants.ts new file mode 100644 index 0000000..1ea3449 --- /dev/null +++ b/src/features/nfc/constants/nfcConstants.ts @@ -0,0 +1,11 @@ +/** Maximum NDEF payload size in bytes (see docs/adr-nfc-library.md). */ +export const MAX_NDEF_PAYLOAD_BYTES = 880; + +/** MIME type for payment-request NDEF records. */ +export const NFC_PAYMENT_MIME_TYPE = 'application/json'; + +/** Writer session timeout (receiver broadcasting request). */ +export const NFC_WRITER_TIMEOUT_MS = 60_000; + +/** Reader session timeout (payer scanning for request). */ +export const NFC_READER_TIMEOUT_MS = 45_000; diff --git a/src/features/nfc/hooks/useNfcReader.ts b/src/features/nfc/hooks/useNfcReader.ts new file mode 100644 index 0000000..0d8a0fd --- /dev/null +++ b/src/features/nfc/hooks/useNfcReader.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import type { PaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { startReaderSession, type NfcReaderSession } from '@/features/nfc/services/NfcReader'; +import { NfcError } from '@/features/nfc/services/NfcService.types'; +import { useNfcSessionStore } from '@/features/nfc/state/nfcSessionStore'; + +export interface UseNfcReaderResult { + status: ReturnType['status']; + error: NfcError | null; + lastRequest: PaymentRequest | null; + startReading: () => Promise; + cancel: () => Promise; + reset: () => void; +} + +export function useNfcReader(): UseNfcReaderResult { + const status = useNfcSessionStore((state) => state.status); + const error = useNfcSessionStore((state) => state.error); + const lastRequest = useNfcSessionStore((state) => state.lastRequest); + const beginScanning = useNfcSessionStore((state) => state.beginScanning); + const setSuccess = useNfcSessionStore((state) => state.setSuccess); + const setError = useNfcSessionStore((state) => state.setError); + const reset = useNfcSessionStore((state) => state.reset); + + const sessionRef = useRef(null); + + const cancel = useCallback(async () => { + await sessionRef.current?.cancel(); + sessionRef.current = null; + reset(); + }, [reset]); + + const startReading = useCallback(async () => { + await cancel(); + beginScanning(); + + try { + sessionRef.current = await startReaderSession({ + onRequest: (request) => { + setSuccess(request); + sessionRef.current = null; + }, + onError: (readerError) => { + if (readerError.code === 'SESSION_CANCELLED') { + reset(); + } else { + setError(readerError); + } + sessionRef.current = null; + }, + }); + } catch (readerError) { + setError( + readerError instanceof NfcError + ? readerError + : new NfcError('NATIVE_ERROR', String(readerError)) + ); + sessionRef.current = null; + } + }, [beginScanning, cancel, reset, setError, setSuccess]); + + useEffect(() => { + return () => { + void sessionRef.current?.cancel(); + }; + }, []); + + return { status, error, lastRequest, startReading, cancel, reset }; +} diff --git a/src/features/nfc/hooks/useNfcWriter.ts b/src/features/nfc/hooks/useNfcWriter.ts new file mode 100644 index 0000000..4d2a14e --- /dev/null +++ b/src/features/nfc/hooks/useNfcWriter.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import type { PaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { startWriterSession, type NfcWriterSession } from '@/features/nfc/services/NfcWriter'; +import { NfcError } from '@/features/nfc/services/NfcService.types'; +import { useNfcSessionStore } from '@/features/nfc/state/nfcSessionStore'; + +export interface UseNfcWriterResult { + status: ReturnType['status']; + error: NfcError | null; + startWriting: (request: PaymentRequest) => Promise; + cancel: () => Promise; +} + +export function useNfcWriter(): UseNfcWriterResult { + const status = useNfcSessionStore((state) => state.status); + const error = useNfcSessionStore((state) => state.error); + const beginWriting = useNfcSessionStore((state) => state.beginWriting); + const setSuccess = useNfcSessionStore((state) => state.setSuccess); + const setError = useNfcSessionStore((state) => state.setError); + const reset = useNfcSessionStore((state) => state.reset); + + const sessionRef = useRef(null); + + const cancel = useCallback(async () => { + await sessionRef.current?.cancel(); + sessionRef.current = null; + reset(); + }, [reset]); + + const startWriting = useCallback( + async (request: PaymentRequest) => { + await cancel(); + beginWriting(); + + try { + sessionRef.current = await startWriterSession(request, { + onWritten: () => { + setSuccess(request); + sessionRef.current = null; + }, + onError: (writerError) => { + if (writerError.code === 'SESSION_CANCELLED') { + reset(); + } else { + setError(writerError); + } + sessionRef.current = null; + }, + }); + } catch (writerError) { + setError( + writerError instanceof NfcError + ? writerError + : new NfcError('NATIVE_ERROR', String(writerError)) + ); + sessionRef.current = null; + } + }, + [beginWriting, cancel, reset, setError, setSuccess] + ); + + useEffect(() => { + return () => { + void sessionRef.current?.cancel(); + }; + }, []); + + return { status, error, startWriting, cancel }; +} diff --git a/src/features/nfc/schemas/paymentRequest.test.ts b/src/features/nfc/schemas/paymentRequest.test.ts new file mode 100644 index 0000000..909e17e --- /dev/null +++ b/src/features/nfc/schemas/paymentRequest.test.ts @@ -0,0 +1,59 @@ +import { + createPaymentRequest, + parsePaymentRequest, + parsePaymentRequestFresh, + validateExpiry, +} from '@/features/nfc/schemas/paymentRequest'; + +const VALID_RECIPIENT = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; + +describe('paymentRequest schema', () => { + const basePayload = { + type: 'payment_request' as const, + recipient: VALID_RECIPIENT, + asset: 'USDC' as const, + amount: '25.00', + timestamp: 1_740_000_000, + expiresAt: 1_740_000_030, + }; + + it('accepts a valid payment request', () => { + expect(parsePaymentRequest(basePayload)).toEqual(basePayload); + }); + + it('rejects invalid Stellar public keys', () => { + expect(() => parsePaymentRequest({ ...basePayload, recipient: 'INVALID' })).toThrow(); + }); + + it('rejects unsupported assets', () => { + expect(() => parsePaymentRequest({ ...basePayload, asset: 'BTC' })).toThrow(); + }); + + it('rejects non-decimal amount strings', () => { + expect(() => parsePaymentRequest({ ...basePayload, amount: '25,00' })).toThrow(); + }); + + it('rejects expiresAt before timestamp', () => { + expect(() => + parsePaymentRequest({ ...basePayload, expiresAt: basePayload.timestamp }) + ).toThrow(); + }); + + it('validateExpiry returns false for past requests', () => { + const request = createPaymentRequest({ + recipient: VALID_RECIPIENT, + asset: 'USDC', + amount: '1.00', + timestamp: 1, + expiresAt: 2, + }); + + expect(validateExpiry(request, 3_000)).toBe(false); + }); + + it('parsePaymentRequestFresh rejects expired payloads', () => { + expect(() => parsePaymentRequestFresh(basePayload, basePayload.expiresAt * 1000 + 1)).toThrow( + /expired/i + ); + }); +}); diff --git a/src/features/nfc/schemas/paymentRequest.ts b/src/features/nfc/schemas/paymentRequest.ts new file mode 100644 index 0000000..8363fa8 --- /dev/null +++ b/src/features/nfc/schemas/paymentRequest.ts @@ -0,0 +1,88 @@ +/** + * Canonical payment-request payload for NFC transport (payment_request.v1). + * + * @see docs/ding-payments.md β€” Proposed Payment Payload Structure + * @see docs/adr-nfc-library.md β€” payload size and encoding constraints + */ +import { z } from 'zod'; + +import { isSupportedAssetCode } from '@/features/wallet/constants/assets'; + +/** Stellar StrKey public key (G + 55 base32 chars). */ +const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; + +/** Decimal amount string (up to 7 fractional digits). */ +const AMOUNT_REGEX = /^\d+(\.\d{1,7})?$/; + +export const paymentRequestSchema = z + .object({ + type: z.literal('payment_request'), + recipient: z.string().regex(STELLAR_PUBLIC_KEY_REGEX, 'Invalid Stellar public key'), + asset: z.string().refine(isSupportedAssetCode, 'Unsupported asset code'), + amount: z + .string() + .regex(AMOUNT_REGEX, 'Amount must be a positive decimal string') + .refine((value) => parseFloat(value) > 0, 'Amount must be greater than zero'), + timestamp: z.number().int().positive(), + expiresAt: z.number().int().positive(), + }) + .refine((data) => data.expiresAt > data.timestamp, { + message: 'expiresAt must be after timestamp', + path: ['expiresAt'], + }); + +export type PaymentRequest = z.infer; + +export class PaymentRequestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'PaymentRequestValidationError'; + } +} + +export function parsePaymentRequest(input: unknown): PaymentRequest { + const result = paymentRequestSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues.map((issue) => issue.message).join('; '); + throw new PaymentRequestValidationError(message); + } + + return result.data; +} + +/** Returns true when the request is still valid at `nowMs` (defaults to Date.now()). */ +export function validateExpiry(request: PaymentRequest, nowMs = Date.now()): boolean { + return request.expiresAt * 1000 > nowMs; +} + +/** Parses and rejects expired requests. */ +export function parsePaymentRequestFresh(input: unknown, nowMs = Date.now()): PaymentRequest { + const request = parsePaymentRequest(input); + + if (!validateExpiry(request, nowMs)) { + throw new PaymentRequestValidationError('Payment request has expired'); + } + + return request; +} + +export function createPaymentRequest( + partial: Omit & { + timestamp?: number; + expiresAt?: number; + ttlSeconds?: number; + } +): PaymentRequest { + const timestamp = partial.timestamp ?? Math.floor(Date.now() / 1000); + const ttlSeconds = partial.ttlSeconds ?? 30; + const expiresAt = partial.expiresAt ?? timestamp + ttlSeconds; + + return parsePaymentRequest({ + type: 'payment_request', + recipient: partial.recipient, + asset: partial.asset, + amount: partial.amount, + timestamp, + expiresAt, + }); +} diff --git a/src/features/nfc/services/NfcPayloadCodec.test.ts b/src/features/nfc/services/NfcPayloadCodec.test.ts new file mode 100644 index 0000000..52aecd9 --- /dev/null +++ b/src/features/nfc/services/NfcPayloadCodec.test.ts @@ -0,0 +1,51 @@ +import { MAX_NDEF_PAYLOAD_BYTES } from '@/features/nfc/constants/nfcConstants'; +import { createPaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { + decodePaymentRequest, + encodePaymentRequest, +} from '@/features/nfc/services/NfcPayloadCodec'; +import { NfcError } from '@/features/nfc/services/NfcService.types'; + +const VALID_RECIPIENT = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; + +describe('NfcPayloadCodec', () => { + const sampleRequest = createPaymentRequest({ + recipient: VALID_RECIPIENT, + asset: 'USDC', + amount: '10.50', + timestamp: 1_740_000_000, + expiresAt: 1_740_000_060, + }); + + it('roundtrips valid payment requests', () => { + const encoded = encodePaymentRequest(sampleRequest); + const decoded = decodePaymentRequest(encoded); + + expect(decoded).toEqual(sampleRequest); + }); + + it('rejects malformed JSON payloads', () => { + const bytes = new TextEncoder().encode('{not-json'); + + expect(() => decodePaymentRequest(bytes)).toThrow(NfcError); + expect(() => decodePaymentRequest(bytes)).toThrow(/valid JSON/i); + }); + + it('rejects oversize payloads', () => { + const oversized = new Uint8Array(MAX_NDEF_PAYLOAD_BYTES + 1); + + expect(() => decodePaymentRequest(oversized)).toThrow(NfcError); + expect(() => decodePaymentRequest(oversized)).toThrow(/exceeds/i); + }); + + it('rejects expired payloads when rejectExpired is enabled', () => { + const encoded = encodePaymentRequest(sampleRequest); + + expect(() => + decodePaymentRequest(encoded, { + rejectExpired: true, + nowMs: sampleRequest.expiresAt * 1000 + 1, + }) + ).toThrow(/expired/i); + }); +}); diff --git a/src/features/nfc/services/NfcPayloadCodec.ts b/src/features/nfc/services/NfcPayloadCodec.ts new file mode 100644 index 0000000..0a9c084 --- /dev/null +++ b/src/features/nfc/services/NfcPayloadCodec.ts @@ -0,0 +1,63 @@ +import { MAX_NDEF_PAYLOAD_BYTES } from '@/features/nfc/constants/nfcConstants'; +import { + parsePaymentRequest, + parsePaymentRequestFresh, + type PaymentRequest, + PaymentRequestValidationError, +} from '@/features/nfc/schemas/paymentRequest'; +import { NfcError } from '@/features/nfc/services/NfcService.types'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export function encodePaymentRequest(request: PaymentRequest): Uint8Array { + const json = JSON.stringify(request); + const bytes = textEncoder.encode(json); + + assertMaxPayloadSize(bytes); + return bytes; +} + +export function decodePaymentRequest( + bytes: Uint8Array, + options?: { rejectExpired?: boolean; nowMs?: number } +): PaymentRequest { + assertMaxPayloadSize(bytes); + + let parsed: unknown; + try { + parsed = JSON.parse(textDecoder.decode(bytes)); + } catch { + throw new NfcError('PAYLOAD_MALFORMED', 'NFC payload is not valid JSON'); + } + + try { + if (options?.rejectExpired) { + return parsePaymentRequestFresh(parsed, options.nowMs); + } + + return parsePaymentRequest(parsed); + } catch (error) { + if (error instanceof PaymentRequestValidationError) { + const code = error.message.includes('expired') ? 'PAYLOAD_EXPIRED' : 'PAYLOAD_INVALID'; + throw new NfcError(code, error.message); + } + + throw error; + } +} + +export function assertMaxPayloadSize(bytes: Uint8Array): void { + if (bytes.byteLength > MAX_NDEF_PAYLOAD_BYTES) { + throw new NfcError( + 'PAYLOAD_OVERSIZE', + `NFC payload exceeds ${MAX_NDEF_PAYLOAD_BYTES} byte limit (${bytes.byteLength} bytes)` + ); + } +} + +export const nfcPayloadCodec = { + encode: encodePaymentRequest, + decode: decodePaymentRequest, + assertMaxPayloadSize, +}; diff --git a/src/features/nfc/services/NfcReader.ts b/src/features/nfc/services/NfcReader.ts new file mode 100644 index 0000000..3894b69 --- /dev/null +++ b/src/features/nfc/services/NfcReader.ts @@ -0,0 +1,104 @@ +import { NFC_READER_TIMEOUT_MS } from '@/features/nfc/constants/nfcConstants'; +import { decodePaymentRequest } from '@/features/nfc/services/NfcPayloadCodec'; +import type { PaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { NfcError, toNfcError } from '@/features/nfc/services/NfcService.types'; +import { nfcService } from '@/features/nfc/services/nfcServiceImpl'; + +export interface NfcReaderSession { + cancel: () => Promise; +} + +export interface StartReaderSessionOptions { + timeoutMs?: number; + onRequest: (request: PaymentRequest) => void; + onError?: (error: NfcError) => void; + alertMessage?: string; +} + +export async function startReaderSession( + options: StartReaderSessionOptions +): Promise { + const timeoutMs = options.timeoutMs ?? NFC_READER_TIMEOUT_MS; + let cancelled = false; + let delivered = false; + let timeoutId: ReturnType | null = null; + + const cleanup = async () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + await nfcService.cancelSession().catch(() => undefined); + }; + + const supported = await nfcService.isSupported(); + if (!supported) { + throw new NfcError('UNSUPPORTED', 'NFC is not supported on this device'); + } + + const enabled = await nfcService.isEnabled(); + if (!enabled) { + throw new NfcError('DISABLED', 'NFC is disabled. Enable it in system settings.'); + } + + timeoutId = setTimeout(async () => { + if (cancelled || delivered) { + return; + } + + cancelled = true; + await cleanup(); + options.onError?.(new NfcError('SESSION_TIMEOUT', 'NFC reader session timed out')); + }, timeoutMs); + + try { + await nfcService.startReaderSession({ + alertMessage: options.alertMessage, + onPayload: (payload) => { + if (cancelled || delivered) { + return; + } + + try { + const request = decodePaymentRequest(payload, { rejectExpired: true }); + delivered = true; + cancelled = true; + void cleanup(); + options.onRequest(request); + } catch (error) { + if (delivered || cancelled) { + return; + } + + const nfcError = toNfcError(error); + options.onError?.(nfcError); + } + }, + onError: (error) => { + if (cancelled || delivered) { + return; + } + + cancelled = true; + void cleanup(); + options.onError?.(error); + }, + }); + } catch (error) { + cancelled = true; + await cleanup(); + throw toNfcError(error); + } + + return { + cancel: async () => { + if (cancelled) { + return; + } + + cancelled = true; + await cleanup(); + options.onError?.(new NfcError('SESSION_CANCELLED', 'NFC reader session cancelled')); + }, + }; +} diff --git a/src/features/nfc/services/NfcService.native.ts b/src/features/nfc/services/NfcService.native.ts new file mode 100644 index 0000000..3a0a72d --- /dev/null +++ b/src/features/nfc/services/NfcService.native.ts @@ -0,0 +1,151 @@ +import { Platform } from 'react-native'; +import NfcManager, { NfcEvents, NfcTech, Ndef, type TagEvent } from 'react-native-nfc-manager'; + +import { NFC_PAYMENT_MIME_TYPE } from '@/features/nfc/constants/nfcConstants'; +import { + NfcError, + type NfcReaderSessionOptions, + type NfcService, + type NfcWriterSessionOptions, + toNfcError, +} from '@/features/nfc/services/NfcService.types'; + +type AndroidNfcManager = typeof NfcManager & { + setNdefPushMessage?: (bytes: number[] | null) => Promise; +}; + +const nfcManager = NfcManager as AndroidNfcManager; + +let sessionKind: 'reader' | 'writer' | null = null; + +function payloadFromTag(tag: TagEvent): Uint8Array | null { + for (const record of tag.ndefMessage ?? []) { + if (Ndef.isType(record, Ndef.TNF_MIME_MEDIA, NFC_PAYMENT_MIME_TYPE)) { + return new Uint8Array(record.payload); + } + + if (Ndef.isType(record, Ndef.TNF_WELL_KNOWN, Ndef.RTD_TEXT)) { + const text = Ndef.text.decodePayload(new Uint8Array(record.payload)); + return new TextEncoder().encode(text); + } + } + + return null; +} + +function buildNdefMessage(payload: Uint8Array): number[] { + const record = Ndef.record(Ndef.TNF_MIME_MEDIA, NFC_PAYMENT_MIME_TYPE, [], Array.from(payload)); + + return Ndef.encodeMessage([record]); +} + +async function cleanupSession(): Promise { + NfcManager.setEventListener(NfcEvents.DiscoverTag, null); + NfcManager.setEventListener(NfcEvents.SessionClosed, null); + + await NfcManager.unregisterTagEvent().catch(() => undefined); + await NfcManager.cancelTechnologyRequest({ throwOnError: false }).catch(() => undefined); + + if (Platform.OS === 'android') { + await nfcManager.setNdefPushMessage?.(null).catch(() => undefined); + } + + sessionKind = null; +} + +export const nfcService: NfcService = { + async isSupported() { + try { + return await NfcManager.isSupported(); + } catch { + return false; + } + }, + + async isEnabled() { + try { + return await NfcManager.isEnabled(); + } catch { + return false; + } + }, + + async start() { + await NfcManager.start(); + }, + + async stop() { + await cleanupSession(); + }, + + async cancelSession() { + await cleanupSession(); + }, + + async startReaderSession(options: NfcReaderSessionOptions) { + if (sessionKind) { + throw new NfcError('SESSION_ACTIVE', 'An NFC session is already active'); + } + + sessionKind = 'reader'; + await NfcManager.start(); + + NfcManager.setEventListener(NfcEvents.DiscoverTag, async (tag: TagEvent) => { + try { + const payload = payloadFromTag(tag); + if (!payload) { + options.onError?.(new NfcError('EMPTY_NDEF', 'No payment payload found in NDEF message')); + return; + } + + options.onPayload(payload); + } catch (error) { + options.onError?.(toNfcError(error)); + } + }); + + await NfcManager.registerTagEvent({ + alertMessage: options.alertMessage ?? 'Hold your phone near the receiver device', + invalidateAfterFirstRead: true, + isReaderModeEnabled: Platform.OS === 'android', + }); + }, + + async startWriterSession(payload: Uint8Array, options: NfcWriterSessionOptions = {}) { + if (sessionKind) { + throw new NfcError('SESSION_ACTIVE', 'An NFC session is already active'); + } + + sessionKind = 'writer'; + await NfcManager.start(); + + const bytes = buildNdefMessage(payload); + + if (Platform.OS === 'android') { + await nfcManager.setNdefPushMessage?.(bytes); + options.onSuccess?.(); + return; + } + + NfcManager.setEventListener(NfcEvents.DiscoverTag, async () => { + try { + await NfcManager.requestTechnology(NfcTech.Ndef, { + alertMessage: options.alertMessage ?? 'Hold your phone near the payer device', + }); + await NfcManager.ndefHandler.writeNdefMessage(bytes); + options.onSuccess?.(); + } catch (error) { + options.onError?.(toNfcError(error)); + } finally { + await NfcManager.cancelTechnologyRequest({ throwOnError: false }).catch(() => undefined); + } + }); + + await NfcManager.registerTagEvent({ + alertMessage: options.alertMessage ?? 'Ready to share payment request', + invalidateAfterFirstRead: false, + }); + }, +}; + +export default nfcService; diff --git a/src/features/nfc/services/NfcService.ts b/src/features/nfc/services/NfcService.ts new file mode 100644 index 0000000..17594fd --- /dev/null +++ b/src/features/nfc/services/NfcService.ts @@ -0,0 +1 @@ +export * from './NfcService.types'; diff --git a/src/features/nfc/services/NfcService.types.ts b/src/features/nfc/services/NfcService.types.ts new file mode 100644 index 0000000..83117d4 --- /dev/null +++ b/src/features/nfc/services/NfcService.types.ts @@ -0,0 +1,66 @@ +export type NfcErrorCode = + | 'UNSUPPORTED' + | 'DISABLED' + | 'SESSION_ACTIVE' + | 'SESSION_CANCELLED' + | 'SESSION_TIMEOUT' + | 'EMPTY_NDEF' + | 'PAYLOAD_MALFORMED' + | 'PAYLOAD_OVERSIZE' + | 'PAYLOAD_INVALID' + | 'PAYLOAD_EXPIRED' + | 'NATIVE_ERROR'; + +export class NfcError extends Error { + readonly code: NfcErrorCode; + + constructor(code: NfcErrorCode, message: string) { + super(message); + this.name = 'NfcError'; + this.code = code; + } +} + +export function toNfcError(error: unknown, fallbackCode: NfcErrorCode = 'NATIVE_ERROR'): NfcError { + if (error instanceof NfcError) { + return error; + } + + const message = error instanceof Error ? error.message : 'Unknown NFC error'; + return new NfcError(fallbackCode, message); +} + +export interface NfcReaderSessionOptions { + onPayload: (payload: Uint8Array) => void; + onError?: (error: NfcError) => void; + alertMessage?: string; +} + +export interface NfcWriterSessionOptions { + onSuccess?: () => void; + onError?: (error: NfcError) => void; + alertMessage?: string; +} + +export interface NfcService { + isSupported(): Promise; + isEnabled(): Promise; + start(): Promise; + stop(): Promise; + cancelSession(): Promise; + startReaderSession(options: NfcReaderSessionOptions): Promise; + startWriterSession(payload: Uint8Array, options?: NfcWriterSessionOptions): Promise; +} + +export function createMockNfcService(overrides: Partial = {}): NfcService { + return { + isSupported: async () => true, + isEnabled: async () => true, + start: async () => undefined, + stop: async () => undefined, + cancelSession: async () => undefined, + startReaderSession: async () => undefined, + startWriterSession: async () => undefined, + ...overrides, + }; +} diff --git a/src/features/nfc/services/NfcService.web.ts b/src/features/nfc/services/NfcService.web.ts new file mode 100644 index 0000000..a8cb302 --- /dev/null +++ b/src/features/nfc/services/NfcService.web.ts @@ -0,0 +1,8 @@ +import { createMockNfcService, type NfcService } from '@/features/nfc/services/NfcService.types'; + +export const nfcService: NfcService = createMockNfcService({ + isSupported: async () => false, + isEnabled: async () => false, +}); + +export default nfcService; diff --git a/src/features/nfc/services/NfcWriter.ts b/src/features/nfc/services/NfcWriter.ts new file mode 100644 index 0000000..4e94d22 --- /dev/null +++ b/src/features/nfc/services/NfcWriter.ts @@ -0,0 +1,95 @@ +import { NFC_WRITER_TIMEOUT_MS } from '@/features/nfc/constants/nfcConstants'; +import { encodePaymentRequest } from '@/features/nfc/services/NfcPayloadCodec'; +import type { PaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { NfcError, toNfcError } from '@/features/nfc/services/NfcService.types'; +import { nfcService } from '@/features/nfc/services/nfcServiceImpl'; + +export interface NfcWriterSession { + cancel: () => Promise; +} + +export interface StartWriterSessionOptions { + timeoutMs?: number; + onWritten?: () => void; + onError?: (error: NfcError) => void; + alertMessage?: string; +} + +export async function startWriterSession( + request: PaymentRequest, + options: StartWriterSessionOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? NFC_WRITER_TIMEOUT_MS; + let cancelled = false; + let timeoutId: ReturnType | null = null; + + const cleanup = async () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + await nfcService.cancelSession().catch(() => undefined); + }; + + const supported = await nfcService.isSupported(); + if (!supported) { + throw new NfcError('UNSUPPORTED', 'NFC is not supported on this device'); + } + + const enabled = await nfcService.isEnabled(); + if (!enabled) { + throw new NfcError('DISABLED', 'NFC is disabled. Enable it in system settings.'); + } + + const payload = encodePaymentRequest(request); + + timeoutId = setTimeout(async () => { + if (cancelled) { + return; + } + + cancelled = true; + await cleanup(); + options.onError?.(new NfcError('SESSION_TIMEOUT', 'NFC writer session timed out')); + }, timeoutMs); + + try { + await nfcService.startWriterSession(payload, { + alertMessage: options.alertMessage, + onSuccess: () => { + if (cancelled) { + return; + } + + cancelled = true; + void cleanup(); + options.onWritten?.(); + }, + onError: (error) => { + if (cancelled) { + return; + } + + cancelled = true; + void cleanup(); + options.onError?.(error); + }, + }); + } catch (error) { + cancelled = true; + await cleanup(); + throw toNfcError(error); + } + + return { + cancel: async () => { + if (cancelled) { + return; + } + + cancelled = true; + await cleanup(); + options.onError?.(new NfcError('SESSION_CANCELLED', 'NFC writer session cancelled')); + }, + }; +} diff --git a/src/features/nfc/services/index.ts b/src/features/nfc/services/index.ts new file mode 100644 index 0000000..08e1d6f --- /dev/null +++ b/src/features/nfc/services/index.ts @@ -0,0 +1,2 @@ +export * from './NfcService.types'; +export { nfcService } from './nfcServiceImpl'; diff --git a/src/features/nfc/services/nfc-spike.ts b/src/features/nfc/services/nfc-spike.ts index 633cddc..02827be 100644 --- a/src/features/nfc/services/nfc-spike.ts +++ b/src/features/nfc/services/nfc-spike.ts @@ -1,12 +1,17 @@ import { Buffer } from 'buffer'; import NfcManager, { Ndef, NfcTech } from 'react-native-nfc-manager'; +import { createPaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { encodePaymentRequest } from '@/features/nfc/services/NfcPayloadCodec'; +import { nfcService } from '@/features/nfc/services/nfcServiceImpl'; + export type NfcSpikeResult = | { success: true; message: string } | { success: false; reason: string }; const JSON_TAG_TYPE = 'application/vnd.ding-payments.v1+json'; +/** C05 spike: check NFC runtime support in dev build. */ export async function initializeNfcSpike(): Promise<{ supported: boolean; details: string }> { const supported = await NfcManager.isSupported(); return { @@ -17,6 +22,7 @@ export async function initializeNfcSpike(): Promise<{ supported: boolean; detail }; } +/** C05 spike: write raw JSON NDEF payload for PoC roundtrip on `/c05`. */ export async function writeNdefJsonPayload( payload: Record ): Promise { @@ -31,19 +37,18 @@ export async function writeNdefJsonPayload( [], Array.from(Buffer.from(jsonString, 'utf8')) ); - - // nfc-manager v3 writes an encoded NDEF byte message through the Ndef tech - // handler (the top-level NfcManager.writeNdefMessage was removed). const bytes = Ndef.encodeMessage([record]); await NfcManager.ndefHandler.writeNdefMessage(bytes); await NfcManager.cancelTechnologyRequest(); return { success: true, message: `Wrote ${jsonString.length} bytes payload` }; - } catch (error: any) { - return { success: false, reason: error?.message ?? 'NFC write failed.' }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'NFC write failed.'; + return { success: false, reason: message }; } } +/** C05 spike: read raw JSON NDEF payload for PoC roundtrip on `/c05`. */ export async function readNdefJsonPayload(): Promise { try { await NfcManager.start(); @@ -53,17 +58,47 @@ export async function readNdefJsonPayload(): Promise { await NfcManager.cancelTechnologyRequest(); const ndefMessage = tag?.ndefMessage; - if (!ndefMessage || !ndefMessage.length) { + if (!ndefMessage?.length) { return { success: false, reason: 'No NDEF message found on tag.' }; } const record = ndefMessage[0]; - const payload = record.payload; - const text = Buffer.from(payload).toString('utf8'); + const text = Buffer.from(record.payload).toString('utf8'); JSON.parse(text); return { success: true, message: `Read JSON payload (${text.length} bytes)` }; - } catch (error: any) { - return { success: false, reason: error?.message ?? 'NFC read failed.' }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'NFC read failed.'; + return { success: false, reason: message }; } } + +/** C10 helper: check support via NfcService abstraction. */ +export async function nfcSpikeCheckSupport(): Promise<{ supported: boolean; enabled: boolean }> { + const supported = await nfcService.isSupported(); + const enabled = supported ? await nfcService.isEnabled() : false; + return { supported, enabled }; +} + +export function nfcSpikeSamplePayload(recipient: string) { + return createPaymentRequest({ + recipient, + asset: 'USDC', + amount: '1.00', + ttlSeconds: 60, + }); +} + +/** C10 helper: broadcast sample payment request via writer session. */ +export async function nfcSpikeWriteSample(recipient: string): Promise { + const request = nfcSpikeSamplePayload(recipient); + const bytes = encodePaymentRequest(request); + + await nfcService.startWriterSession(bytes, { + alertMessage: 'NFC spike: ready to broadcast sample payment request', + }); +} + +export async function nfcSpikeCancel(): Promise { + await nfcService.cancelSession(); +} diff --git a/src/features/nfc/services/nfcServiceImpl.native.ts b/src/features/nfc/services/nfcServiceImpl.native.ts new file mode 100644 index 0000000..1ce9eed --- /dev/null +++ b/src/features/nfc/services/nfcServiceImpl.native.ts @@ -0,0 +1 @@ +export { nfcService } from './NfcService.native'; diff --git a/src/features/nfc/services/nfcServiceImpl.ts b/src/features/nfc/services/nfcServiceImpl.ts new file mode 100644 index 0000000..3b8ba83 --- /dev/null +++ b/src/features/nfc/services/nfcServiceImpl.ts @@ -0,0 +1 @@ +export { nfcService } from './NfcService.web'; diff --git a/src/features/nfc/state/nfcSessionStore.test.ts b/src/features/nfc/state/nfcSessionStore.test.ts new file mode 100644 index 0000000..e50adad --- /dev/null +++ b/src/features/nfc/state/nfcSessionStore.test.ts @@ -0,0 +1,47 @@ +import { NfcError } from '@/features/nfc/services/NfcService.types'; +import { useNfcSessionStore } from '@/features/nfc/state/nfcSessionStore'; + +describe('nfcSessionStore', () => { + beforeEach(() => { + useNfcSessionStore.setState({ + status: 'idle', + error: null, + lastRequest: null, + nfcActive: false, + }); + }); + + it('transitions idle β†’ scanning with nfcActive', () => { + useNfcSessionStore.getState().beginScanning(); + + const state = useNfcSessionStore.getState(); + expect(state.status).toBe('scanning'); + expect(state.nfcActive).toBe(true); + }); + + it('transitions scanning β†’ success and clears nfcActive', () => { + useNfcSessionStore.getState().beginScanning(); + useNfcSessionStore.getState().setSuccess(); + + const state = useNfcSessionStore.getState(); + expect(state.status).toBe('success'); + expect(state.nfcActive).toBe(false); + }); + + it('enforces one active session at a time', () => { + useNfcSessionStore.getState().beginWriting(); + + expect(() => useNfcSessionStore.getState().beginScanning()).toThrow(NfcError); + }); + + it('reset returns to idle', () => { + useNfcSessionStore.getState().beginScanning(); + useNfcSessionStore.getState().setError(new NfcError('SESSION_TIMEOUT', 'timeout')); + useNfcSessionStore.getState().reset(); + + const state = useNfcSessionStore.getState(); + expect(state.status).toBe('idle'); + expect(state.nfcActive).toBe(false); + expect(state.error).toBeNull(); + }); +}); diff --git a/src/features/nfc/state/nfcSessionStore.ts b/src/features/nfc/state/nfcSessionStore.ts new file mode 100644 index 0000000..cec4969 --- /dev/null +++ b/src/features/nfc/state/nfcSessionStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; + +import type { PaymentRequest } from '@/features/nfc/schemas/paymentRequest'; +import { NfcError } from '@/features/nfc/services/NfcService.types'; + +export type NfcSessionStatus = 'idle' | 'scanning' | 'writing' | 'success' | 'error'; + +interface NfcSessionState { + status: NfcSessionStatus; + error: NfcError | null; + lastRequest: PaymentRequest | null; + /** True while scanning or writing β€” suppresses auth lock (CLI-026 / CLI-052). */ + nfcActive: boolean; + beginScanning: () => void; + beginWriting: () => void; + setSuccess: (request?: PaymentRequest) => void; + setError: (error: NfcError) => void; + reset: () => void; +} + +const ACTIVE_STATUSES: NfcSessionStatus[] = ['scanning', 'writing']; + +function assertCanStart(status: NfcSessionStatus): void { + if (ACTIVE_STATUSES.includes(status)) { + throw new NfcError('SESSION_ACTIVE', 'Only one NFC session can be active at a time'); + } +} + +export const useNfcSessionStore = create((set, get) => ({ + status: 'idle', + error: null, + lastRequest: null, + nfcActive: false, + + beginScanning: () => { + assertCanStart(get().status); + set({ status: 'scanning', error: null, nfcActive: true }); + }, + + beginWriting: () => { + assertCanStart(get().status); + set({ status: 'writing', error: null, nfcActive: true }); + }, + + setSuccess: (request) => { + set({ + status: 'success', + error: null, + lastRequest: request ?? get().lastRequest, + nfcActive: false, + }); + }, + + setError: (error) => { + set({ status: 'error', error, nfcActive: false }); + }, + + reset: () => { + set({ status: 'idle', error: null, nfcActive: false }); + }, +})); + +export const selectNfcActive = (state: NfcSessionState) => state.nfcActive; +export const selectNfcStatus = (state: NfcSessionState) => state.status; diff --git a/src/features/wallet/constants/assets.ts b/src/features/wallet/constants/assets.ts new file mode 100644 index 0000000..ea26a1e --- /dev/null +++ b/src/features/wallet/constants/assets.ts @@ -0,0 +1,30 @@ +import { env } from '@/lib/env'; + +export interface StellarAsset { + code: string; + issuer?: string; + displayName: string; + decimals: number; +} + +export const STELLAR_ASSETS = { + XLM: { + code: 'XLM', + displayName: 'Stellar Lumens', + decimals: 7, + }, + USDC: { + code: 'USDC', + issuer: env.usdcIssuer, + displayName: 'USDC', + decimals: 7, + }, +} as const satisfies Record; + +export type SupportedAssetCode = keyof typeof STELLAR_ASSETS; + +export const SUPPORTED_ASSET_CODES = Object.keys(STELLAR_ASSETS) as SupportedAssetCode[]; + +export function isSupportedAssetCode(value: string): value is SupportedAssetCode { + return SUPPORTED_ASSET_CODES.includes(value as SupportedAssetCode); +} diff --git a/src/features/wallet/services/stellar-spike.ts b/src/features/wallet/services/stellar-spike.ts index 49028ca..c8345a6 100644 --- a/src/features/wallet/services/stellar-spike.ts +++ b/src/features/wallet/services/stellar-spike.ts @@ -1,7 +1,7 @@ import { Buffer } from 'buffer'; import process from 'process'; import 'react-native-get-random-values'; -import * as StellarSdk from '@stellar/stellar-sdk'; +import { Horizon, Keypair } from '@stellar/stellar-sdk'; export type StellarSpikeResult = | { success: true; publicKey: string; secretKey: string; accountData: unknown } @@ -11,7 +11,6 @@ export async function runStellarSpike( horizonUrl = 'https://horizon-testnet.stellar.org' ): Promise { try { - // Stellar SDK expects Node-style globals; provide them in the RN runtime. const globalScope = globalThis as typeof globalThis & { Buffer?: typeof Buffer; process?: typeof process; @@ -19,14 +18,12 @@ export async function runStellarSpike( globalScope.Buffer = globalScope.Buffer ?? Buffer; globalScope.process = globalScope.process ?? process; - const keypair = StellarSdk.Keypair.random(); + const keypair = Keypair.random(); const publicKey = keypair.publicKey(); const secretKey = keypair.secret(); - // stellar-sdk v16 namespaces the Horizon client under `Horizon.Server` - // (the top-level `Server` export was removed). - const server = new StellarSdk.Horizon.Server(horizonUrl); - const accountData = await server.accounts().accountId(publicKey).call(); + const server = new Horizon.Server(horizonUrl); + const accountData = await server.loadAccount(publicKey); return { success: true, @@ -34,10 +31,10 @@ export async function runStellarSpike( secretKey, accountData, }; - } catch (error: any) { + } catch (error: unknown) { return { success: false, - reason: error?.message ?? 'Unknown Stellar spike error.', + reason: error instanceof Error ? error.message : 'Unknown Stellar spike error.', }; } } diff --git a/src/lib/SecureKeyStore.ts b/src/lib/SecureKeyStore.ts index 13503c2..bf72e59 100644 --- a/src/lib/SecureKeyStore.ts +++ b/src/lib/SecureKeyStore.ts @@ -27,7 +27,10 @@ const AUTH_REQUIRED_KEYS: ReadonlySet = new Set([ SECURE_KEYS.WALLET_PUBLIC_KEY, ]); -function buildOptions(key: SecureKey, overrides?: SecureStoreSetOptions): SecureStore.SecureStoreOptions { +function buildOptions( + key: SecureKey, + overrides?: SecureStoreSetOptions +): SecureStore.SecureStoreOptions { const requireAuth = overrides?.requireAuthentication ?? AUTH_REQUIRED_KEYS.has(key); return { diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts new file mode 100644 index 0000000..48e95c1 --- /dev/null +++ b/src/types/shims.d.ts @@ -0,0 +1,3 @@ +declare module 'react-native-get-random-values'; + +declare module 'react-native-passkey'; diff --git a/tsconfig.json b/tsconfig.json index a90e985..0c11f72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "types": ["jest", "node"], "paths": { "@/*": ["./src/*"], "@/assets/*": ["./assets/*"]