diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb15212..d73ccc13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,9 @@ jobs: runs-on: ubuntu-latest outputs: frontend: ${{ steps.filter.outputs.frontend }} + frontend_visual: ${{ steps.filter.outputs.frontend_visual }} backend: ${{ steps.filter.outputs.backend }} + backend_code: ${{ steps.filter.outputs.backend_code }} mobile: ${{ steps.filter.outputs.mobile }} contracts: ${{ steps.filter.outputs.contracts }} steps: @@ -56,9 +58,32 @@ jobs: frontend: - 'frontend/**' - 'frontend/pnpm-lock.yaml' + frontend_visual: + - 'frontend/src/**' + - 'frontend/public/**' + - 'frontend/tests/**' + - 'frontend/package.json' + - 'frontend/pnpm-lock.yaml' + - 'frontend/playwright.config.*' + - 'frontend/next.config.*' + - 'frontend/tailwind.config.*' + - 'frontend/postcss.config.*' + - 'frontend/tsconfig.json' backend: - 'backend/**' - 'backend/pnpm-lock.yaml' + # Database migrations still run for any backend change, but the + # TypeScript/test suite is only needed when executable backend + # code or its generated Prisma input changes. + backend_code: + - 'backend/src/**' + - 'backend/package.json' + - 'backend/pnpm-lock.yaml' + - 'backend/tsconfig.json' + - 'backend/jest.config.js' + - 'backend/eslint.config.*' + - 'backend/prisma/schema.prisma' + - 'backend/prisma/seed.ts' mobile: - 'mobile/**' - 'mobile/pnpm-lock.yaml' @@ -267,8 +292,8 @@ jobs: name: Visual Regression (Playwright) runs-on: ubuntu-latest needs: [changes, frontend] - if: needs.changes.outputs.frontend == 'true' - timeout-minutes: 15 + if: needs.changes.outputs.frontend_visual == 'true' + timeout-minutes: 20 defaults: run: working-directory: frontend @@ -396,17 +421,20 @@ jobs: run: pnpm exec prisma migrate deploy - name: Build - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' run: pnpm build - name: Prepare backend test database if: needs.changes.outputs.backend == 'true' env: DATABASE_URL: postgresql://test:test@localhost:5432/test - run: pnpm exec prisma db push + # This database is created for the job and contains no data. Prisma + # otherwise blocks on the warning for adding the RefreshToken.jti + # unique constraint, even though accepting it cannot discard test data. + run: pnpm exec prisma db push --accept-data-loss - name: Test - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' env: NODE_ENV: test JWT_SECRET: test-jwt-secret-value-with-minimum-length-32 @@ -416,7 +444,7 @@ jobs: run: pnpm exec jest --config jest.config.js --forceExit --detectOpenHandles --json --outputFile=jest-results.json || true - name: Detect flake candidates (backend) - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' run: | node ../scripts/flake-reporter.mjs \ --results jest-results.json \ @@ -425,7 +453,7 @@ jobs: --scope backend | tee -a $GITHUB_STEP_SUMMARY - name: Fail if tests failed (deferred from --json run) - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' run: | node -e " const r = require('./jest-results.json'); @@ -436,7 +464,7 @@ jobs: " - name: Upload flake report (backend) - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' uses: actions/upload-artifact@v4 with: name: flake-report-backend-${{ github.run_number }} @@ -444,33 +472,33 @@ jobs: retention-days: 30 - name: Enforce Admin Regression Test Policy - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' run: | chmod +x ../scripts/check-admin-test-coverage.sh ../scripts/check-admin-test-coverage.sh - name: Validate example env files against the schema - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' env: NODE_ENV: test run: pnpm tsx scripts/validate-env-examples.ts - name: Admin auth regression suite - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' env: NODE_ENV: test JWT_SECRET: test-jwt-secret-value-with-minimum-length-32 run: pnpm jest --config jest.config.js --forceExit --detectOpenHandles --testPathPatterns='admin\.auth\.ci-regression' --verbose - name: Validate admin OpenAPI docs - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' env: NODE_ENV: test JWT_SECRET: test-jwt-secret-value-with-minimum-length-32 run: pnpm jest --config jest.config.js --forceExit --detectOpenHandles --testPathPatterns='openapi\.(drift|docs)\.test' --verbose - name: Upload coverage report - if: needs.changes.outputs.backend == 'true' + if: needs.changes.outputs.backend_code == 'true' uses: actions/upload-artifact@v4 with: name: backend-coverage @@ -527,7 +555,7 @@ jobs: mobile-e2e-ios: name: Mobile E2E Smoke — iOS - runs-on: macos-latest + runs-on: macos-15 needs: [changes, mobile] if: needs.changes.outputs.mobile == 'true' timeout-minutes: 25 @@ -552,10 +580,36 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + # Expo SDK 52's expo-device uses the removed TARGET_OS_SIMULATOR macro; + # Xcode 16.4 requires Swift's targetEnvironment condition instead. + - name: Patch expo-device for Xcode 16.4 + run: | + node <<'NODE' + const fs = require('fs'); + const path = require('path'); + const packagePath = require.resolve('expo-device/package.json'); + const sourcePath = path.join(path.dirname(packagePath), 'ios', 'UIDevice.swift'); + const source = fs.readFileSync(sourcePath, 'utf8'); + const legacy = ' return TARGET_OS_SIMULATOR != 0'; + const fixed = [ + ' #if targetEnvironment(simulator)', + ' return true', + ' #else', + ' return false', + ' #endif', + ].join('\n'); + if (source.includes(legacy)) { + fs.writeFileSync(sourcePath, source.replace(legacy, fixed)); + } else if (!source.includes('#if targetEnvironment(simulator)')) { + throw new Error(`Unexpected expo-device source: ${sourcePath}`); + } + console.log(`Patched ${sourcePath}`); + NODE + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '16.4' # macOS 26 runner images no longer ship two tools this job needs: # xcpretty (the release gem set was dropped from the image) and @@ -575,7 +629,7 @@ jobs: uses: actions/cache@v4 with: path: mobile/ios - key: mobile-expo-prebuild-ios-${{ hashFiles('mobile/app.config.ts', 'mobile/app.json', 'mobile/package.json') }} + key: mobile-expo-prebuild-ios-${{ hashFiles('mobile/app.json', 'mobile/app.config.ts', 'mobile/plugins/**', 'mobile/package.json') }} - name: Expo prebuild (iOS) run: pnpm expo prebuild --platform ios --non-interactive @@ -587,6 +641,7 @@ jobs: # runner actually has instead of hardcoding "iPhone 15, OS=17.5". - name: Resolve available iOS simulator run: | + set -euo pipefail RESOLVED=$(xcrun simctl list devices available --json | node -e ' let raw = ""; process.stdin.on("data", (c) => (raw += c)).on("end", () => { @@ -617,6 +672,7 @@ jobs: - name: Build iOS app for simulator run: | + set -o pipefail xcodebuild \ -workspace ios/Amana.xcworkspace \ -scheme Amana \ @@ -708,7 +764,7 @@ jobs: uses: actions/cache@v4 with: path: mobile/android - key: mobile-expo-prebuild-android-${{ hashFiles('mobile/app.config.ts', 'mobile/app.json', 'mobile/package.json') }} + key: mobile-expo-prebuild-android-${{ hashFiles('mobile/app.json', 'mobile/app.config.ts', 'mobile/plugins/**', 'mobile/package.json') }} restore-keys: mobile-expo-prebuild-android- - name: Expo prebuild (Android) @@ -716,9 +772,25 @@ jobs: env: CI: true + - name: Align Android Kotlin compiler + run: | + sed -i "s/kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.24'/kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'/" android/build.gradle + if grep -q '^android.kotlinVersion=' android/gradle.properties; then + sed -i 's/^android.kotlinVersion=.*/android.kotlinVersion=1.9.25/' android/gradle.properties + else + echo 'android.kotlinVersion=1.9.25' >> android/gradle.properties + fi + sed -i 's/^kotlin = "1.9.24"$/kotlin = "1.9.25"/' node_modules/react-native/gradle/libs.versions.toml + REACT_NATIVE_GRADLE_PLUGIN_PACKAGE=$(node --print "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + REACT_NATIVE_GRADLE_PLUGIN_CATALOG="${REACT_NATIVE_GRADLE_PLUGIN_PACKAGE%/package.json}/gradle/libs.versions.toml" + sed -i 's/^kotlin = "1.9.24"$/kotlin = "1.9.25"/' "$REACT_NATIVE_GRADLE_PLUGIN_CATALOG" + grep -n 'kotlinVersion' android/build.gradle android/gradle.properties + grep -n '^kotlin =' node_modules/react-native/gradle/libs.versions.toml + grep -n '^kotlin =' "$REACT_NATIVE_GRADLE_PLUGIN_CATALOG" + - name: Build Android APK (debug) run: | - cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug + cd android && ./gradlew -Pandroid.kotlinVersion=1.9.25 :app:assembleDebug :app:assembleDebugAndroidTest -DtestBuildType=debug env: CI: true @@ -787,7 +859,11 @@ jobs: - name: Security audit (cargo) if: needs.changes.outputs.contracts == 'true' working-directory: contracts - run: cargo audit + # Soroban SDK 25.3.0 pins crypto-bigint 0.5.5. RustSec's fix is in + # 0.7.6, which is not API-compatible with the SDK's elliptic-curve + # dependency; keep this advisory visible in the log until Soroban + # publishes a compatible dependency update. + run: cargo audit --ignore RUSTSEC-2026-0387 - name: Deployment safety checks if: needs.changes.outputs.contracts == 'true' diff --git a/.github/workflows/mobile-e2e-nightly.yml b/.github/workflows/mobile-e2e-nightly.yml index b256f3e7..b9299fc9 100644 --- a/.github/workflows/mobile-e2e-nightly.yml +++ b/.github/workflows/mobile-e2e-nightly.yml @@ -13,7 +13,7 @@ on: jobs: mobile-e2e-ios-nightly: name: Nightly Smoke — iOS - runs-on: macos-latest + runs-on: macos-15 timeout-minutes: 25 defaults: run: @@ -38,22 +38,41 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest-stable + xcode-version: '16.4' - name: Expo prebuild (iOS) run: pnpm expo prebuild --platform ios --non-interactive env: CI: true + - name: Select available iOS simulator + run: | + set -euo pipefail + DEVICE=$(xcrun simctl list devices available -j | jq -r ' + .devices | to_entries[] as $runtime | $runtime.value[] + | select(.name | startswith("iPhone")) + | "\(.name)|\($runtime.key | sub("^com.apple.CoreSimulator.SimRuntime.iOS-"; "") | gsub("-"; "."))|\(.udid)" + ' | head -1) + if [[ -z "$DEVICE" ]]; then + echo "No available iPhone simulator found" >&2 + exit 1 + fi + IFS='|' read -r DEVICE_NAME DEVICE_OS DEVICE_UDID <<< "$DEVICE" + echo "Using iOS simulator: $DEVICE_NAME ($DEVICE_OS, $DEVICE_UDID)" + echo "DETOX_IOS_DEVICE_TYPE=$DEVICE_NAME" >> "$GITHUB_ENV" + echo "DETOX_IOS_DEVICE_OS=iOS $DEVICE_OS" >> "$GITHUB_ENV" + echo "SIMULATOR_UDID=$DEVICE_UDID" >> "$GITHUB_ENV" + - name: Build iOS app for simulator run: | + set -o pipefail xcodebuild \ -workspace ios/Amana.xcworkspace \ -scheme Amana \ -configuration Debug \ -sdk iphonesimulator \ -derivedDataPath ios/build \ - -destination "platform=iOS Simulator,name=iPhone 15,OS=17.5" \ + -destination "id=$SIMULATOR_UDID" \ build \ | xcpretty --color env: @@ -61,8 +80,7 @@ jobs: - name: Boot iOS simulator run: | - UDID=$(xcrun simctl list devices available | grep "iPhone 15" | grep "17.5" | head -1 | grep -E -o '\([-A-F0-9]+\)' | tr -d '()') - xcrun simctl boot "$UDID" || true + xcrun simctl boot "$SIMULATOR_UDID" || true - name: Run Detox smoke tests (iOS) run: pnpm test:e2e:ios @@ -109,6 +127,10 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + # The default `tools` package no longer exists in current cmdline-tools + # and makes sdkmanager exit 1; platform-tools is all this job needs. + packages: platform-tools system-images;android-34;google_apis;x86_64 - name: Cache AVD uses: actions/cache@v4 @@ -134,9 +156,19 @@ jobs: env: CI: true + - name: Align Android Kotlin compiler + run: | + sed -i "s/kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.24'/kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'/" android/build.gradle + if grep -q '^android.kotlinVersion=' android/gradle.properties; then + sed -i 's/^android.kotlinVersion=.*/android.kotlinVersion=1.9.25/' android/gradle.properties + else + echo 'android.kotlinVersion=1.9.25' >> android/gradle.properties + fi + grep -n 'kotlinVersion' android/build.gradle android/gradle.properties + - name: Build Android APK (debug) run: | - cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug + cd android && ./gradlew -Pandroid.kotlinVersion=1.9.25 assembleDebug assembleAndroidTest -DtestBuildType=debug env: CI: true @@ -163,7 +195,7 @@ jobs: # Runs the same smoke suite against a low-end device from # mobile/docs/DEVICE_MATRIX.md's supported matrix (minimum API 31, on a # low-RAM/low-CPU profile) instead of the default Pixel 6 API 34 used - # above — see mobile/.detoxrc.js's `emulatorLowEnd` device. + # above — see mobile/.detoxrc.cjs's `emulatorLowEnd` device. mobile-e2e-android-lowend-nightly: name: Nightly Smoke — Android (low-end device) runs-on: ubuntu-latest @@ -196,6 +228,10 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + # The default `tools` package no longer exists in current cmdline-tools + # and makes sdkmanager exit 1; platform-tools is all this job needs. + packages: platform-tools system-images;android-34;google_apis;x86_64 - name: Cache AVD uses: actions/cache@v4 diff --git a/backend/prisma/migrations/20260529000002_add_missing_indexes/migration.sql b/backend/prisma/migrations/20260529000002_add_missing_indexes/migration.sql index 3264c4a8..c6afb354 100644 --- a/backend/prisma/migrations/20260529000002_add_missing_indexes/migration.sql +++ b/backend/prisma/migrations/20260529000002_add_missing_indexes/migration.sql @@ -1,6 +1,9 @@ -- Add missing indexes for performance optimization -- These indexes were identified during schema review to improve query performance +-- Trade.version is in schema.prisma but no earlier migration creates it +ALTER TABLE "Trade" ADD COLUMN IF NOT EXISTS "version" INTEGER NOT NULL DEFAULT 0; + -- Index on Trade.version for optimistic concurrency queries CREATE INDEX IF NOT EXISTS "Trade_version_idx" ON "Trade"("version"); diff --git a/backend/prisma/migrations/20260729000000_add_stream_models/migration.sql b/backend/prisma/migrations/20260729000000_add_stream_models/migration.sql new file mode 100644 index 00000000..a45dc349 --- /dev/null +++ b/backend/prisma/migrations/20260729000000_add_stream_models/migration.sql @@ -0,0 +1,38 @@ +-- Base tables for vested token streams and their clawback audit events. +-- Later migrations add lifecycle, maintenance-lock, and admin-tag fields. +CREATE TABLE "Stream" ( + "id" SERIAL NOT NULL, + "streamId" VARCHAR(255) NOT NULL, + "recipient" VARCHAR(255) NOT NULL, + "totalVested" VARCHAR(100) NOT NULL, + "claimed" VARCHAR(100) NOT NULL DEFAULT '0', + "unclaimed" VARCHAR(100) NOT NULL DEFAULT '0', + "pendingClawback" VARCHAR(100) NOT NULL DEFAULT '0', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Stream_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "StreamClawbackEvent" ( + "id" SERIAL NOT NULL, + "streamId" VARCHAR(255) NOT NULL, + "admin" VARCHAR(255) NOT NULL, + "amount" VARCHAR(100) NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL, + "txHash" VARCHAR(255) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StreamClawbackEvent_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Stream_streamId_key" ON "Stream"("streamId"); +CREATE INDEX "Stream_streamId_idx" ON "Stream"("streamId"); +CREATE INDEX "Stream_recipient_idx" ON "Stream"("recipient"); +CREATE UNIQUE INDEX "StreamClawbackEvent_streamId_txHash_key" ON "StreamClawbackEvent"("streamId", "txHash"); +CREATE INDEX "StreamClawbackEvent_streamId_idx" ON "StreamClawbackEvent"("streamId"); +CREATE INDEX "StreamClawbackEvent_admin_idx" ON "StreamClawbackEvent"("admin"); + +ALTER TABLE "StreamClawbackEvent" + ADD CONSTRAINT "StreamClawbackEvent_streamId_fkey" + FOREIGN KEY ("streamId") REFERENCES "Stream"("streamId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260829000001_add_job_heartbeat/migration.sql b/backend/prisma/migrations/20260829000001_add_job_heartbeat/migration.sql index bd1495da..243625d5 100644 --- a/backend/prisma/migrations/20260829000001_add_job_heartbeat/migration.sql +++ b/backend/prisma/migrations/20260829000001_add_job_heartbeat/migration.sql @@ -3,7 +3,7 @@ -- CreateTable JobHeartbeat CREATE TABLE "JobHeartbeat" ( - "id" SERIAL NOT NULL PRIMARY KEY, + "id" SERIAL NOT NULL, "jobType" VARCHAR(100) NOT NULL UNIQUE, "lastHeartbeat" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "status" VARCHAR(50) NOT NULL DEFAULT 'idle', diff --git a/frontend/docs/accessibility-report.md b/frontend/docs/accessibility-report.md index d87bd57a..32649c72 100644 --- a/frontend/docs/accessibility-report.md +++ b/frontend/docs/accessibility-report.md @@ -13,7 +13,7 @@ | `/` (Landing) | 0 | ✅ Pass | | `/trades` | 0 | ✅ Pass | | `/trades/create` (Step1→Step3) | 0 | ✅ Pass after fixes | -| `/trades/[id]` | 0 | ✅ Pass after prompt removal | +| `/trades/[id]` | 0 | ✅ Pass (the dispute action still uses `window.prompt` — see note below) | | `/admin/streams` | 0 | ✅ Pass | | `/admin/streams/[id]` | 0 | ✅ Pass | | `/admin/audit` | 0 | ✅ Pass | @@ -24,6 +24,25 @@ Run: `pnpm test -- src/__tests__/accessibility --verbose` (blocking in CI) +> **Coverage notes (audited against `src/app/` on 2026-10-21)** +> +> - The route rows above come from `routes.axe.test.tsx`, which renders a +> minimal shell (`

` + breadcrumb) per route, **not** the real page. It is a +> structural baseline only. Real-page axe checks live in the dedicated suites in +> `src/__tests__/accessibility/` (`assets`, `streams`, `vaultManage`, +> `mediatorDisputes`, `tradeWizard`, `cooperativeBulkImport`, `clawbackModal`, +> `connectivityBanner`, `focusSkipLinkContrast`). +> - Routes that exist in `src/app/` but have no row in this baseline (and are not +> in `routes.axe.test.tsx`): `/assets/[id]`, `/reputation`, `/settings`, +> `/streams`, `/streams/[id]`, `/cooperative/bulk-import`, `/access-denied`. +> The `/mediator/disputes` row also names `[id]`, but `routes.axe.test.tsx` +> only lists `/mediator/disputes`. +> - `/trades/[id]` still calls `window.prompt("Enter dispute reason (min 10 +> characters):")` when a dispute is initiated +> (`src/app/trades/[id]/page.tsx`). That native prompt is not covered by axe and +> is not keyboard/screen-reader friendly; the "prompt removal" below applies to +> the create → deposit → release path only. + ## Summary — Component Coverage | Component | Before | After | Violations Found | Fixed | @@ -101,9 +120,9 @@ Run: `pnpm test -- src/__tests__/accessibility --verbose` (blocking in CI) 6. Step 3 review: Tab to `Lock Funds & Create Trade` → Enter → `LegalDisclaimerModal` traps focus (`role="alertdialog"`, Esc closes, focus returns to trigger). Confirm → funds locked, success toast announced (`role="alert"`). 7. Navigate to trade detail (`TradeListItem` card reachable via Tab, Enter to view) 8. Tab to `Deposit` / `Release` action bar buttons — each has `aria-label` and `focus-visible` ring; confirm modals trap focus; no keyboard trap observed. -9. Verify: full journey completable with keyboard only, no `window.prompt` (removed), no focus loss. +9. Verify: full journey completable with keyboard only, no `window.prompt` on the create → deposit → release path, no focus loss. (The dispute action on `/trades/[id]` still uses `window.prompt`; it is not part of this journey.) -Tested via `jest + @testing-library/user-event` tab simulation and manual Chromium `Tab` walkthrough recorded in `frontend/tests/a11y-keyboard-walkthrough.md`. +Tested via `jest + @testing-library/user-event` tab simulation and a manual Chromium `Tab` walkthrough. The separate recording file `frontend/tests/a11y-keyboard-walkthrough.md` was never added to the repo; this section is the only written record. ## Screen-Reader Spot Checks (recorded 2026-08-31) @@ -143,7 +162,7 @@ pnpm test -- src/__tests__/accessibility --verbose ## Audit Trail - Baseline recorded at `frontend/docs/accessibility-report.md#baseline---all-routes-2026-08-31` -- CI gate: `.github/workflows/ci.yml:98` (`pnpm test -- src/__tests__/accessibility --verbose`) -- Keyboard script: this file + `frontend/tests/a11y-keyboard-walkthrough.md` (if present, else this section) +- CI gate: `.github/workflows/ci.yml`, step "A11y axe audit (blocking)" (`pnpm test -- src/__tests__/accessibility --verbose`) +- Keyboard script: this file (there is no separate `frontend/tests/a11y-keyboard-walkthrough.md`) - Screen-reader log: table above diff --git a/frontend/docs/admin-route-guard.md b/frontend/docs/admin-route-guard.md index 8da02118..b32f4f65 100644 --- a/frontend/docs/admin-route-guard.md +++ b/frontend/docs/admin-route-guard.md @@ -1,7 +1,7 @@ # Admin route guard `src/app/admin/layout.tsx` wraps every page under `/admin` (currently -`/admin/streams` and `/admin/audit`). +`/admin/streams`, `/admin/streams/[id]` and `/admin/audit`). Before rendering any child page it checks, in order: @@ -10,15 +10,17 @@ Before rendering any child page it checks, in order: 2. `isAuthenticated` - if `false`, the caller is redirected to `/access-denied`. 3. `useIsAdmin()` - if the connected wallet is not on the admin allowlist - (`NEXT_PUBLIC_ADMIN_WALLETS`), the caller is redirected to - `/access-denied`. + (`NEXT_PUBLIC_ADMIN_WALLETS`, parsed by `src/lib/adminAccess.ts`), the + caller is redirected to `/access-denied`. Only once all three checks pass does the layout render its children. This is a **client-side UX guard**, not the security boundary - the backend independently enforces admin access via `adminMiddleware` (`ADMIN_STELLAR_PUBKEYS`), so a request that reaches the API is still checked -there regardless of what this layout allowed through. Individual admin pages -additionally render their own `ForbiddenState` if a 403 comes back from the -API, as defense in depth for the case where wallet state changes after the -layout's initial check. +there regardless of what this layout allowed through. The list pages +(`/admin/streams` and `/admin/audit`) additionally render their own +`ForbiddenState` if a 403 comes back from the API, as defense in depth for the +case where wallet state changes after the layout's initial check. +`/admin/streams/[id]` has no `ForbiddenState` yet: a 403 there surfaces through +its generic `ErrorState` message. diff --git a/frontend/docs/i18n.md b/frontend/docs/i18n.md index d4c2942f..036b0601 100644 --- a/frontend/docs/i18n.md +++ b/frontend/docs/i18n.md @@ -37,8 +37,9 @@ On-chain asset formatters (`formatXlm`, `formatUsdc`, `formatStroops`) live in 2. Reference it with `t("namespace.key")`. The key type is derived from the catalog, so a typo is a compile error. 3. Never ship a raw user-facing literal in JSX. The ESLint rule - `react/jsx-no-literals` (see `eslint.config` — enable for `src/app` & - `src/components`) flags them; the pseudo-locale build is the visual backstop. + `react/jsx-no-literals` would flag them, but it is **not enabled** in + `eslint.config.mjs` yet, so nothing enforces this in CI today; the + pseudo-locale build is the visual backstop. ## Formatting values @@ -103,7 +104,7 @@ runtime: ## Definition of done checklist -- [ ] No raw user-facing literals outside the catalog (lint-enforced) +- [ ] No raw user-facing literals outside the catalog (reviewed manually until `react/jsx-no-literals` is enabled) - [ ] Money renders as `₦1,234.56` everywhere - [ ] `NEXT_PUBLIC_PSEUDO_LOCALE=1 pnpm build` passes visual smoke - [ ] New strings documented here if they introduce a namespace diff --git a/frontend/docs/loading-states.md b/frontend/docs/loading-states.md index e43a9538..3fd760ff 100644 --- a/frontend/docs/loading-states.md +++ b/frontend/docs/loading-states.md @@ -18,8 +18,8 @@ for content areas, no unstyled gaps, no layout shift when real data arrives. | Token | Value | Role | | --- | --- | --- | -| `bg-skeleton-base` | `#1A3D2C` (surface-2) | Resting fill | -| `bg-skeleton-sheen` | `rgba(240,245,241,0.08)` | Shimmer highlight | +| `bg-skeleton-base` | `#E8EDE9` light / `#1A3D2C` dark | Resting fill | +| `bg-skeleton-sheen` | `rgba(26,46,31,0.06)` light / `rgba(240,245,241,0.08)` dark | Shimmer highlight | | `animate-skeleton-pulse` | 1.6s opacity pulse | Resting motion (no reflow) | | `animate-skeleton-shimmer` | 1.6s translateX sweep | Highlight sweep | @@ -36,41 +36,54 @@ for content areas, no unstyled gaps, no layout shift when real data arrives. ## Data-surface inventory +Audited against `src/app/` on 2026-10-21. + | Route / surface | Pattern | Boundary | | --- | --- | --- | -| `/dashboard` header + stat cards | `Skeleton` header + 3× `SkeletonCard` | page-level `loading.tsx` | -| `/dashboard` activity feed | `SkeletonList rows={4}` | inline (client fetch) | -| `/trades` table | `TradesTableSkeleton` (rows of `Skeleton`) | inline (`loading` flag) | -| `/trades/[id]` detail | `LoadingState variant="card"` ×2 | route `loading.tsx` | -| `/trades/create` review totals | inline text `Skeleton` | inline | -| `/vault` + `/vault/manage` | 4× `LoadingState variant="card"` | inline (`isLoading`) | -| `/assets`, `/assets/[id]` | `LoadingState` | route segment | -| `/reputation` | `Skeleton` header + `RepScoreRing` placeholder | inline | -| `/mediator/disputes` list | `SkeletonList` | inline | -| `/admin/streams`, `/admin/audit` | `LoadingState variant="row"` list | inline | -| Wallet balance widget | inline `Skeleton width={80} height={16}` | inline | +| `/dashboard` | `Skeleton` header + 4× `SkeletonCard` stat cards, then `SkeletonList rows={4}` for the activity feed | inline (`if (loading)` branch of `page.tsx`) | +| `/trades` | `TradesTableSkeleton` (rows of `Skeleton`) | inline (`loading` flag) | +| `/trades/[id]` | Centred `animate-spin` spinner while `useTradeDetail` loads — **gap**, not a skeleton | inline (`loading` flag) | +| `/vault` | Static pulsing blocks in `loading.tsx`, then 4× `LoadingState variant="card"` | route `loading.tsx` + inline (`isLoading`) | +| `/vault/manage` | No skeleton — only a "Loading…" button label / spinning icon — **gap** | none | +| `/assets` | `AssetTableSkeleton` (built from `Skeleton`) | inline | +| `/assets/[id]` | Page-local `LoadingState()` function (not the shared `LoadingState`) | inline (`isLoading`) | +| `/reputation` | `SkeletonReputationPage`: `Skeleton` header + circular score-ring placeholder | inline | +| `/mediator/disputes` | `SkeletonList rows={PAGE_SIZE}` | inline | +| `/mediator/disputes/[id]` | No skeleton while evidence loads (tracks a `VideoLoadState` only) — **gap** | none | +| `/admin/streams`, `/admin/audit` | `SkeletonList rows={PAGE_SIZE}` | inline | +| `/admin/streams/[id]`, `/streams/[id]` | `LoadingState variant="card" rows={3}` | inline (`isLoading`) | + +No async content to skeleton: `/` (landing), `/access-denied`, `/streams` +(static index with links), `/settings` (wallet connect / sign-in buttons only), +`/cooperative/bulk-import` (upload form; the only request is the submit +mutation) and the `/dev-test/*` fixtures. > Any surface not listed here that fetches async data needs a row added and a -> skeleton wired before merge. +> skeleton wired before merge. The three **gap** rows above predate this rule. ## Suspense boundary convention - **Route-level** (`app//loading.tsx`): first paint of a navigation — - use `LoadingState` matching the page's primary layout. + use `LoadingState` matching the page's primary layout. Today only + `app/vault/loading.tsx` exists, and it uses static pulsing blocks rather than + `LoadingState`; every other route handles loading inline. - **Component-level** (`` / `isLoading` branch): secondary data that streams in after the shell — use the matching primitive from the inventory. ## CLS budget / Lighthouse CI -`frontend/lighthouserc.json` asserts `cumulative-layout-shift ≤ 0.02` (Web Vitals -"good" is ≤ 0.1; we hold a tighter internal budget) on the key routes. Wire into -CI: +Every Lighthouse config in `frontend/` asserts +`cumulative-layout-shift ≤ 0.02` as an **error** (Web Vitals "good" is ≤ 0.1; we +hold a tighter internal budget): -```yaml -- run: pnpm build && npx --yes @lhci/cli@0.13.x autorun -``` +| Config | Routes | Run by | +| --- | --- | --- | +| `lighthouserc.json` | `/`, `/dashboard`, `/trades`, `/vault`, `/trades/create` | manual / local `lhci autorun` | +| `lighthouserc.trades-create.json`, `lighthouserc.dashboard.json`, `lighthouserc.vault.json` | one critical page each | `.github/workflows/lighthouse.yml` ("Lighthouse — Critical Pages", `@lhci/cli@0.15.1`) | -A regression above budget fails the job. +CI only measures `/trades/create`, `/dashboard` and `/vault`; the other routes in +the inventory are covered by the manual pass below. A regression above budget on +any of the three fails the workflow. ## Slow-network manual pass diff --git a/frontend/docs/offline-cache-conflict-matrix.md b/frontend/docs/offline-cache-conflict-matrix.md index 6a08472c..ca893c9a 100644 --- a/frontend/docs/offline-cache-conflict-matrix.md +++ b/frontend/docs/offline-cache-conflict-matrix.md @@ -34,8 +34,12 @@ All mutations (create trade, deposit, confirm, dispute) go through `api.trades.* directly. When online they succeed immediately and the caller calls `invalidateAndRefetch()` to drop the stale entry and pull fresh data. -When offline, mutations are expected to be queued by the offline write queue -(issue #74). On replay success the server response is written into cache. +When offline, mutations are queued by the offline write queue +(`src/stores/offlineQueueStore.ts`, replayed from `ConnectivityBanner`; see +`offline-resilience.md`). The queue does not touch this cache: replay does not +write server responses into it, and it does not call `cacheInvalidate`. A cached +entry is refreshed by the next `useCached*` revalidation or +`invalidateAndRefetch()`. --- @@ -70,8 +74,8 @@ The rule is: **server always wins on content; last write wins for timing**. | Scenario | Resolution | Behaviour | |---|---|---| | Online write succeeds (2xx) | **Server wins** | Cache invalidated; fresh fetch triggered by `invalidateAndRefetch()` | -| Offline queue replayed; server returns 2xx | **Server wins** | Server response body written to cache; `isStale` cleared | -| Offline queue replayed; server returns 409 Conflict | **Server wins** | Local entry evicted via `cacheInvalidate`; error surfaced to user; fresh fetch on next online event | +| Offline queue replayed; server returns 2xx | **Server wins** | Action removed from the queue. The cache is not written by replay; the entry is refreshed by the next revalidation / `invalidateAndRefetch()` | +| Offline queue replayed; server returns 409 Conflict | **Server wins** | Not special-cased: the action stays queued, the "Partial sync" toast is shown, and the cache entry is left as-is until the next revalidation | | Server data `updatedAt` > local `cachedAt` | **Server wins** | Overwrites local entry; `isStale` cleared | | Local `cachedAt` appears newer (clock skew ≤ 30s) | **Server wins** | Server response still overwrites; clock skew is logged but not acted upon | | Corruption in localStorage (JSON parse error) | **Neither** | Affected key removed; treat as cache miss; fresh fetch attempted | diff --git a/frontend/docs/offline-resilience.md b/frontend/docs/offline-resilience.md index a5b91765..42edf47b 100644 --- a/frontend/docs/offline-resilience.md +++ b/frontend/docs/offline-resilience.md @@ -15,23 +15,23 @@ - `src/stores/offlineQueueStore.ts` — zustand persist `amana-offline-queue` in localStorage, holds `QueuedAction[] {id, type, endpoint, method, body, idempotencyKey, correlationId, createdAt, attempts}`. - `TradeContext` draft persists to `localStorage amana:draft-trade` (data + step) — survives refresh/restart. - `Step3Review` checks `isOffline`: if true, enqueues `create-trade` with generated `idempotencyKey`/`correlationId` via `enqueue`, shows "Queued offline" toast with correlation, does not fire network. Banner shows pending. -- Clear pending UX: `ConnectivityBanner` + `Step3Review` pendingCount badge + `PendingBadge` component + sr-only hints. +- Clear pending UX: `ConnectivityBanner` + `Step3Review` pendingCount badge + `PendingBadge` (exported from `ConnectivityBanner.tsx`) + sr-only hints. ## Replay on reconnect with conflict handling via idempotency keys - On `!isOffline && wasOffline && queue.length>0`, `ConnectivityBanner` calls `replay(executor)` where executor does `request(endpoint, {method, body, headers: {"Idempotency-Key": key, "X-Correlation-Id": corr}})`. -- Same `idempotencyKey` reused on replay — backend `idempotencyMiddleware` (`idempotency:{method}:{path}:{key}` + `SET NX EX 30`) honors reuse: duplicate-send prevented, cache HIT returns prior response with `X-Idempotency-Cache: HIT`. If 409 due to already-processed, executor treats as success and dequeue. -- Attempts incremented; failed kept for next reconnect; partial sync toast warns. +- Same `idempotencyKey` reused on replay — backend `idempotencyMiddleware` (`backend/src/middleware/idempotency.ts`; response cached under `idempotency:{method}:{path}:{key}`, in-flight lock `idempotency:lock:{method}:{path}:{key}` via `SET NX EX 30`) honors reuse: duplicate-send prevented, cache HIT returns prior response with `X-Idempotency-Cache: HIT`. +- The replay executor does **not** special-case a 409: any error thrown by `request(...)` leaves the action in the queue. Attempts are incremented, failed actions are kept for the next reconnect, and a partial sync toast warns. ## Integration with optimistic store / offline queue precedence - Precedence: **Offline queue owns mutations while offline**; optimistic store (`tradeStore`) is NOT applied for offline enqueues (no patch). When online, `tradeStore.updateTradeOptimistic` does optimistic patch with snapshot rollback + dedup window + toast correlation. If offline, no optimistic patch — pending banner is source of truth. -- On reconnect, after `replay` succeeds, call `fetchTrades` to reconcile list (store re-fetches from server, which now contains the replayed trade). This avoids optimistic/server desync. +- After `replay` settles, `ConnectivityBanner` shows the Synced / Partial sync toast and calls `retryOnline()`. It does not call `fetchTrades` itself, so a list already on screen is reconciled by the page's own fetch or cache revalidation (see `offline-cache-conflict-matrix.md`), not by the replay. ## Tests simulating flaky connectivity (offline -> online mid-flow) - `src/__tests__/offline.queue.test.tsx` — enqueue persists to localStorage, survives simulated refresh, replay reuses same key, pending UX, E2E offline->online simulation green. - `src/__tests__/tradeStore.optimistic.test.ts` — failure-mid-flight restores prior UI state, triple-click dedup yields single intent. -- Manual: `Step3Review` with `isOffline=true` enqueues, refresh preserves draft, go online → replay + fetch → trade appears. +- Manual: `Step3Review` with `isOffline=true` enqueues, refresh preserves draft, go online → replay → trade appears once the trade list next refreshes. Run: `pnpm test -- offline.queue tradeStore.optimistic --verbose` diff --git a/frontend/docs/optimistic-updates.md b/frontend/docs/optimistic-updates.md index 22b563a1..1a0f930e 100644 --- a/frontend/docs/optimistic-updates.md +++ b/frontend/docs/optimistic-updates.md @@ -39,5 +39,5 @@ ## No regression in actual mutation correctness -- All existing `tradeStore.test.ts` scenarios still pass (fetch, setPage, setFilter, addTrade, update/remove happy path). New `pendingActions` is additive, not breaking. +- All existing `src/__tests__/tradeStore.test.ts` scenarios still pass (fetch, setPage, setFilter, addTrade, update/remove happy path). New `pendingActions` is additive, not breaking. - `lib/api/trades.ts` now accepts optional `{idempotencyKey, correlationId}` without breaking callers that omit opts. diff --git a/frontend/docs/wallet-states.md b/frontend/docs/wallet-states.md index ba7c710b..420bcbda 100644 --- a/frontend/docs/wallet-states.md +++ b/frontend/docs/wallet-states.md @@ -32,6 +32,15 @@ resolves to the `WALLET_TIMEOUT` sentinel → `timeout` state → visible retry, within 8 seconds. There is no code path where a user action produces no feedback. +## Integration status + +`state-machine.ts` and `WalletStateCallout` (`src/components/wallet/`) are +implemented and unit-tested, but no route renders `WalletStateCallout` yet and +nothing outside that component imports the state machine. Until it is wired +into a connect flow, the table above describes the behaviour the component +produces in isolation, and the manual test matrix below only applies once it is +mounted. + ## Telemetry `trackWalletState(state, meta)` emits: diff --git a/mobile/.detoxrc.js b/mobile/.detoxrc.cjs similarity index 98% rename from mobile/.detoxrc.js rename to mobile/.detoxrc.cjs index 47278ba4..09d9155d 100644 --- a/mobile/.detoxrc.js +++ b/mobile/.detoxrc.cjs @@ -3,7 +3,7 @@ module.exports = { testRunner: { args: { $0: 'jest', - config: 'e2e/jest.config.js', + config: 'e2e/jest.config.cjs', }, jest: { setupTimeout: 120_000, diff --git a/mobile/.env.example b/mobile/.env.example index 57e827ef..1d693f08 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -7,6 +7,8 @@ EXPO_PUBLIC_ANALYTICS_ENABLED=true # Stellar network (testnet or public) EXPO_PUBLIC_STELLAR_NETWORK=testnet +# Optional: Soroban RPC used to submit signed transactions (defaults per network) +# EXPO_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org # Push notifications EXPO_PUBLIC_PUSH_PROVIDER=expo diff --git a/mobile/.env.mobile.example b/mobile/.env.mobile.example index 57e827ef..1d693f08 100644 --- a/mobile/.env.mobile.example +++ b/mobile/.env.mobile.example @@ -7,6 +7,8 @@ EXPO_PUBLIC_ANALYTICS_ENABLED=true # Stellar network (testnet or public) EXPO_PUBLIC_STELLAR_NETWORK=testnet +# Optional: Soroban RPC used to submit signed transactions (defaults per network) +# EXPO_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org # Push notifications EXPO_PUBLIC_PUSH_PROVIDER=expo diff --git a/mobile/README.md b/mobile/README.md index bd3d613b..c6e2e270 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -99,6 +99,8 @@ Configure for your environment: to use the versioned backend lane; set empty to fall back to legacy aliases (deprecated). Admin and health endpoints are never versioned. - `EXPO_PUBLIC_STELLAR_NETWORK` – testnet or public network +- `EXPO_PUBLIC_STELLAR_RPC_URL` – optional Soroban RPC used to submit signed transactions + (defaults to the public RPC for the selected network) - `EXPO_PUBLIC_PUSH_PROVIDER` – expo or firebase ### Run in development diff --git a/mobile/app.config.ts b/mobile/app.config.ts index a2b06537..76a38d33 100644 --- a/mobile/app.config.ts +++ b/mobile/app.config.ts @@ -57,13 +57,14 @@ const config: ExpoConfig = { favicon: './assets/favicon.png', }, plugins: [ + './plugins/with-kotlin-version.cjs', 'expo-secure-store', [ 'expo-notifications', { icon: './assets/notification-icon.png', color: '#ffffff', - sounds: ['./assets/notification-sound.wav'], + sounds: ['./assets/notification_sound.wav'], }, ], ], diff --git a/mobile/app.json b/mobile/app.json index 2711d96f..90c1736a 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -60,7 +60,7 @@ { "icon": "./assets/notification-icon.png", "color": "#ffffff", - "sounds": ["./assets/notification-sound.wav"] + "sounds": ["./assets/notification_sound.wav"] } ] ], diff --git a/mobile/assets/notification-sound.wav b/mobile/assets/notification_sound.wav similarity index 100% rename from mobile/assets/notification-sound.wav rename to mobile/assets/notification_sound.wav diff --git a/mobile/docs/DEVICE_MATRIX.md b/mobile/docs/DEVICE_MATRIX.md index 90655a07..1392e47f 100644 --- a/mobile/docs/DEVICE_MATRIX.md +++ b/mobile/docs/DEVICE_MATRIX.md @@ -1,7 +1,7 @@ # Supported Device Matrix This document defines the minimum and recommended OS versions for Amana Mobile. -The Detox CI jobs enforce these via `.detoxrc.js` device configuration. +The Detox CI jobs enforce these via `.detoxrc.cjs` device configuration. ## iOS @@ -10,8 +10,8 @@ The Detox CI jobs enforce these via `.detoxrc.js` device configuration. | iPhone (modern) | 16.0 | 17.5 | iPhone 15 / iOS 17.5 | | iPad (optional) | 16.0 | 17.5 | iPad Pro 12.9" | -**Enforcement:** The `.detoxrc.js` `simulator` device config pins `iPhone 15 / iOS 17.5`. -To test a new iOS major version, update both `.detoxrc.js` and this table in the same PR. +**Enforcement:** The `.detoxrc.cjs` `simulator` device config pins `iPhone 15 / iOS 17.5`. +To test a new iOS major version, update both `.detoxrc.cjs` and this table in the same PR. ## Android @@ -21,7 +21,7 @@ To test a new iOS major version, update both `.detoxrc.js` and this table in the | Phone (low-end, min) | 31 (12) | 31 (12) | Pixel 3a API 31 | | Tablet | 31 (12) | 34 (14) | — | -**Enforcement:** The `.detoxrc.js` `emulator` device config pins `Pixel_6_API_34` (AVD name), +**Enforcement:** The `.detoxrc.cjs` `emulator` device config pins `Pixel_6_API_34` (AVD name), and the `emulatorLowEnd` device config pins `Pixel_3a_API_31` — a representative low-end device at the *minimum* supported API, run with a constrained memory allocation (`-memory 2048`) to approximate real low-end hardware rather than the CI runner's default. @@ -35,7 +35,7 @@ Both AVDs must exist on the CI runner; the CI workflow creates them via `avdmana ## Adding a new device configuration -1. Add the device entry to the `devices` section of `.detoxrc.js`. +1. Add the device entry to the `devices` section of `.detoxrc.cjs`. 2. Add a corresponding configuration under `configurations`. 3. Update this table. 4. Add the new CI job step in `.github/workflows/mobile-e2e-nightly.yml` under the correct matrix entry. diff --git a/mobile/docs/admin-errors.md b/mobile/docs/admin-errors.md index 04d1a845..68f7469f 100644 --- a/mobile/docs/admin-errors.md +++ b/mobile/docs/admin-errors.md @@ -33,7 +33,7 @@ Each admin screen follows the same pattern: typed API wrapper, role gate, dual-s | --- | --- | --- | --- | | `AdminStreamsOverviewScreen` | `GET /admin/streams` (list) | `errorView` (single) | SEED list rendered before fetch resolves. Shows `OfflineBanner` and disables the action buttons while offline. | | `AdminTradesBatchScreen` | `POST /admin/trades/batch/status` | `actionErrorView` (single) | Banner's retry button re-fires the batch with the same text input. Inputs survive error. | -| `AdminContractScreen` | `POST /admin/contract/mediators`, `PATCH /admin/contract/fee` | per-section (`medErrorView`, `feeErrorView`) | Each section has its own banner so failure on one doesn't block the other. Surfaces `unsignedXdr` for the admin to sign externally (TODO: wire Freighter). | +| `AdminContractScreen` | `POST /admin/contract/mediators`, `PATCH /admin/contract/fee` | per-section (`medErrorView`, `feeErrorView`) | Each section has its own banner so failure on one doesn't block the other. Surfaces `unsignedXdr` with a "Sign & submit with Freighter" action (`SignAndSubmitXdr` → `services/freighterSigner`); the XDR stays copyable for external signing when Freighter is unavailable. | | `AdminFeaturesScreen` | `GET /admin/features`, `PATCH /admin/features/:name` | `loadErrorView` + `rowErrorView` | Switch uses optimistic update; rolls back to the previous value on PATCH failure so the UI matches the backend. The banner's retry only reloads after a failed load; after a failed toggle the admin flips the switch again. | See `mobile/src/screens/ADMIN_SCREENS.md` for each screen's error and empty states. `AdminStreamScreen` (audit trail, not registered in `AppNavigator`) predates this pattern and still shows a plain error message with a Retry button instead of `AdminErrorBanner`. diff --git a/mobile/e2e/deep-link.e2e.ts b/mobile/e2e/deep-link.e2e.ts index 29240293..012ab667 100644 --- a/mobile/e2e/deep-link.e2e.ts +++ b/mobile/e2e/deep-link.e2e.ts @@ -13,6 +13,7 @@ import { device, element, by, waitFor } from 'detox'; import { mockAuthLaunchArgs } from './helpers/auth'; +import { startMockServer, stopMockServer } from './helpers/mockServer'; const TIMEOUT = 15_000; // ms — generous for emulator cold-start @@ -25,6 +26,15 @@ function tradeDeepLink(tradeId: string): string { } describe('Deep linking — TradeDetailScreen', () => { + beforeAll(async () => { + await startMockServer(); + }); + + afterAll(async () => { + await device.terminateApp(); + await stopMockServer(); + }); + it('cold start: launching with a trade deep link lands directly on that trade\'s detail screen', async () => { await device.launchApp({ newInstance: true, diff --git a/mobile/e2e/dispute-resolution.e2e.ts b/mobile/e2e/dispute-resolution.e2e.ts index 43fb26db..8dd97425 100644 --- a/mobile/e2e/dispute-resolution.e2e.ts +++ b/mobile/e2e/dispute-resolution.e2e.ts @@ -11,7 +11,7 @@ * 7. Evidence upload completes successfully * * This suite runs against the Expo dev-client build on both Android emulator - * and iOS simulator (see .detoxrc.js for device configs). + * and iOS simulator (see .detoxrc.cjs for device configs). * * The mock server (e2e/helpers/mockServer.ts) must be running before the * test; it is started by globalSetup via jest.config.js. diff --git a/mobile/e2e/evidence-capture.e2e.ts b/mobile/e2e/evidence-capture.e2e.ts index 84b9f767..02c070a6 100644 --- a/mobile/e2e/evidence-capture.e2e.ts +++ b/mobile/e2e/evidence-capture.e2e.ts @@ -11,7 +11,7 @@ * 7. Test error handling when upload fails * * This suite runs against the Expo dev-client build on both Android emulator - * and iOS simulator (see .detoxrc.js for device configs). + * and iOS simulator (see .detoxrc.cjs for device configs). * * The mock server (e2e/helpers/mockServer.ts) must be running before the * test; it is started by globalSetup via jest.config.js. diff --git a/mobile/e2e/jest.config.js b/mobile/e2e/jest.config.cjs similarity index 100% rename from mobile/e2e/jest.config.js rename to mobile/e2e/jest.config.cjs diff --git a/mobile/e2e/notification-tap.e2e.ts b/mobile/e2e/notification-tap.e2e.ts index 424145f9..39f46ae0 100644 --- a/mobile/e2e/notification-tap.e2e.ts +++ b/mobile/e2e/notification-tap.e2e.ts @@ -13,6 +13,7 @@ import { device, element, by, waitFor } from 'detox'; import { mockAuthLaunchArgs } from './helpers/auth'; +import { startMockServer, stopMockServer } from './helpers/mockServer'; const TIMEOUT = 15_000; // ms — generous for emulator cold-start @@ -31,6 +32,15 @@ function tradeStatusNotification(tradeId: string) { } describe('Push notification tap-through — TradeDetailScreen', () => { + beforeAll(async () => { + await startMockServer(); + }); + + afterAll(async () => { + await device.terminateApp(); + await stopMockServer(); + }); + beforeEach(async () => { await device.launchApp({ newInstance: true, diff --git a/mobile/e2e/smoke.e2e.ts b/mobile/e2e/smoke.e2e.ts index 8b5c1e46..7d01130f 100644 --- a/mobile/e2e/smoke.e2e.ts +++ b/mobile/e2e/smoke.e2e.ts @@ -9,7 +9,7 @@ * 5. Offline banner appears when NetInfo reports no connectivity * * This suite runs against the Expo dev-client build on both Android emulator - * and iOS simulator (see .detoxrc.js for device configs). + * and iOS simulator (see .detoxrc.cjs for device configs). * * The mock server (e2e/helpers/mockServer.ts) must be running before the * test; it is started by globalSetup via jest.config.js. @@ -17,6 +17,7 @@ import { device, element, by, expect as detoxExpect, waitFor } from 'detox'; import { mockAuthLaunchArgs, MOCK_WALLET_ADDRESS } from './helpers/auth'; +import { startMockServer, stopMockServer } from './helpers/mockServer'; const TIMEOUT = 15_000; // ms — generous for emulator cold-start @@ -25,6 +26,8 @@ const TIMEOUT = 15_000; // ms — generous for emulator cold-start // --------------------------------------------------------------------------- beforeAll(async () => { + await startMockServer(); + // Launch with mock auth args so the app reads a pre-seeded JWT from // launchArgs and skips the real wallet-connect flow. await device.launchApp({ @@ -48,6 +51,7 @@ afterEach(async () => { afterAll(async () => { await device.terminateApp(); + await stopMockServer(); }); // --------------------------------------------------------------------------- diff --git a/mobile/e2e/trade-creation.e2e.ts b/mobile/e2e/trade-creation.e2e.ts index ec4b2a1e..65c3eddb 100644 --- a/mobile/e2e/trade-creation.e2e.ts +++ b/mobile/e2e/trade-creation.e2e.ts @@ -11,7 +11,7 @@ * 7. Test validation and error handling * * This suite runs against the Expo dev-client build on both Android emulator - * and iOS simulator (see .detoxrc.js for device configs). + * and iOS simulator (see .detoxrc.cjs for device configs). * * The mock server (e2e/helpers/mockServer.ts) must be running before the * test; it is started by globalSetup via jest.config.js. diff --git a/mobile/eslint.config.mjs b/mobile/eslint.config.mjs index 80a6db64..d83c5fa1 100644 --- a/mobile/eslint.config.mjs +++ b/mobile/eslint.config.mjs @@ -7,7 +7,7 @@ import reactNative from 'eslint-plugin-react-native'; export default [ eslint.configs.recommended, { - files: ['**/*.{ts,tsx,js,jsx}'], + files: ['**/*.{ts,tsx,js,jsx,cjs}'], languageOptions: { parser: tsparser, parserOptions: { @@ -30,6 +30,7 @@ export default [ FormData: 'readonly', Blob: 'readonly', fetch: 'readonly', + AbortController: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', }, @@ -55,6 +56,7 @@ export default [ ], languageOptions: { globals: { + global: 'readonly', jest: 'readonly', describe: 'readonly', it: 'readonly', diff --git a/mobile/jest.config.cjs b/mobile/jest.config.cjs index 88b97c05..849366fd 100644 --- a/mobile/jest.config.cjs +++ b/mobile/jest.config.cjs @@ -1,4 +1,7 @@ module.exports = { preset: 'jest-expo', transformIgnorePatterns: [], + // The first test of a suite pays the cold-start cost of loading react-native + // and the screen under test; on shared CI runners that can exceed 15 seconds. + testTimeout: 30000, }; diff --git a/mobile/package.json b/mobile/package.json index c4547a9b..39748f71 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", - "type": "module", + "type": "commonjs", "scripts": { "start": "expo start", "android": "expo start --android", @@ -24,9 +24,9 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^1.23.1", + "@react-native-community/netinfo": "~11.4.1", "@react-navigation/native": "^6.1.17", "@react-navigation/stack": "^6.3.29", - "@react-native-community/netinfo": "~11.4.1", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.6.1", "@testing-library/jest-native": "^5.4.3", @@ -41,18 +41,19 @@ "expo-sqlite": "15.0.3", "react": "^18.3.1", "react-native": "0.76.2", - "react-native-gesture-handler": "^2.17.1", - "react-native-safe-area-context": "^4.10.5", - "react-native-screens": "^4.20.0", + "react-native-gesture-handler": "~2.20.2", + "react-native-launch-arguments": "4.1.1", + "react-native-safe-area-context": "4.12.0", + "react-native-screens": "~4.4.0", "zustand": "^5.0.0" }, "devDependencies": { "@eslint/js": "^8.57.1", - "@pact-foundation/pact": "16.5.0", "@expo/config-plugins": "^8.0.8", + "@pact-foundation/pact": "16.5.0", + "@testing-library/react-native": "^12.9.0", "@types/jest": "^29.5.12", "@types/node": "^20.10.0", - "@testing-library/react-native": "^12.9.0", "@types/react": "^18.3.3", "@types/react-native": "^0.73.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -71,7 +72,8 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "@pact-foundation/pact-core" + "@pact-foundation/pact-core", + "detox" ] } } diff --git a/mobile/plugins/with-kotlin-version.cjs b/mobile/plugins/with-kotlin-version.cjs new file mode 100644 index 00000000..c5224ffb --- /dev/null +++ b/mobile/plugins/with-kotlin-version.cjs @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const { withGradleProperties, withProjectBuildGradle } = require('@expo/config-plugins'); + +/** + * Keep the generated Android project compatible with the Expo Modules Core + * version resolved by the lockfile. + */ +module.exports = function withKotlinVersion(config) { + config = withGradleProperties(config, (project) => { + const property = project.modResults.find( + (item) => item.type === 'property' && item.key === 'android.kotlinVersion', + ); + + if (property) { + property.value = '1.9.25'; + } else { + project.modResults.push({ + type: 'property', + key: 'android.kotlinVersion', + value: '1.9.25', + }); + } + + return project; + }); + + return withProjectBuildGradle(config, (project) => { + if (project.modResults.language !== 'groovy') { + return project; + } + + let contents = project.modResults.contents.replace( + /kotlinVersion = findProperty\('android\.kotlinVersion'\) \?: '1\.9\.24'/, + "kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'", + ); + + project.modResults.contents = contents; + + return project; + }); +}; diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index d1a96bc7..cee62a65 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) '@react-navigation/stack': specifier: ^6.3.29 - version: 6.4.1(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.33.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-screens@4.28.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + version: 6.4.1(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) '@stellar/freighter-api': specifier: ^6.0.1 version: 6.0.1 @@ -63,14 +63,17 @@ importers: specifier: 0.76.2 version: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) react-native-gesture-handler: - specifier: ^2.17.1 - version: 2.33.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + specifier: ~2.20.2 + version: 2.20.2(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + react-native-launch-arguments: + specifier: 4.1.1 + version: 4.1.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) react-native-safe-area-context: - specifier: ^4.10.5 - version: 4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + specifier: 4.12.0 + version: 4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) react-native-screens: - specifier: ^4.20.0 - version: 4.28.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + specifier: ~4.4.0 + version: 4.4.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) zustand: specifier: ^5.0.0 version: 5.0.15(@types/react@18.3.31)(react@18.3.1) @@ -1452,9 +1455,6 @@ packages: resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. - '@types/react-test-renderer@19.3.0': - resolution: {integrity: sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==} - '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -4644,20 +4644,26 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-native-gesture-handler@2.33.0: - resolution: {integrity: sha512-aFMjweXGrsDKwxjAiH7Kl+nc7qAudl0MF6Bqlxf9QQEUNDWBpsQ5WXA7sF6eLFaeRQGEq4ERKr6iuD+GfmunFA==} + react-native-gesture-handler@2.20.2: + resolution: {integrity: sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg==} peerDependencies: react: '*' react-native: '*' - react-native-safe-area-context@4.14.1: - resolution: {integrity: sha512-+tUhT5WBl8nh5+P+chYhAjR470iCByf9z5EYdCEbPaAK3Yfzw+o8VRPnUgmPAKlSccOgQBxx3NOl/Wzckn9ujg==} + react-native-launch-arguments@4.1.1: + resolution: {integrity: sha512-7tkJNHKhn37eXmGKz4UJ+47AywEqb3fknUsJ1GjCLNL0cpxDddKr43JbLrPGpASIxUSu36oYV/a0o0T6IWyKMg==} + peerDependencies: + react: '>=16.8.1' + react-native: '>=0.60.0-rc.0 <1.0.x' + + react-native-safe-area-context@4.12.0: + resolution: {integrity: sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==} peerDependencies: react: '*' react-native: '*' - react-native-screens@4.28.0: - resolution: {integrity: sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==} + react-native-screens@4.4.0: + resolution: {integrity: sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==} peerDependencies: react: '*' react-native: '*' @@ -7670,12 +7676,12 @@ snapshots: react-is: 16.13.1 use-latest-callback: 0.2.6(react@18.3.1) - '@react-navigation/elements@1.3.31(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': + '@react-navigation/elements@1.3.31(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': dependencies: '@react-navigation/native': 6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) react: 18.3.1 react-native: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) - react-native-safe-area-context: 4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) '@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': dependencies: @@ -7690,16 +7696,16 @@ snapshots: dependencies: nanoid: 3.3.19 - '@react-navigation/stack@6.4.1(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.33.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-screens@4.28.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': + '@react-navigation/stack@6.4.1(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-screens@4.4.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1)': dependencies: - '@react-navigation/elements': 1.3.31(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + '@react-navigation/elements': 1.3.31(@react-navigation/native@6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1))(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) '@react-navigation/native': 6.1.18(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) color: 4.2.3 react: 18.3.1 react-native: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) - react-native-gesture-handler: 2.33.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) - react-native-safe-area-context: 4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) - react-native-screens: 4.28.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + react-native-gesture-handler: 2.20.2(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.4.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1) warn-once: 0.1.1 '@segment/loosely-validate-event@2.0.0': @@ -7848,10 +7854,6 @@ snapshots: - supports-color - utf-8-validate - '@types/react-test-renderer@19.3.0': - dependencies: - '@types/react': 18.3.31 - '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -11678,21 +11680,26 @@ snapshots: react-is@18.3.1: {} - react-native-gesture-handler@2.33.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): + react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): dependencies: '@egjs/hammerjs': 2.0.17 - '@types/react-test-renderer': 19.3.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 + prop-types: 15.8.1 + react: 18.3.1 + react-native: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) + + react-native-launch-arguments@4.1.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): + dependencies: react: 18.3.1 react-native: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) - react-native-safe-area-context@4.14.1(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): + react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-native: 0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1) - react-native-screens@4.28.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): + react-native-screens@4.4.0(react-native@0.76.2(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@types/react@18.3.31)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-freeze: 1.0.4(react@18.3.1) diff --git a/mobile/react-native.config.js b/mobile/react-native.config.js new file mode 100644 index 00000000..2b9739e9 --- /dev/null +++ b/mobile/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependencies: { + expo: { + platforms: { + android: { + packageImportPath: 'import expo.modules.ExpoModulesPackage;', + packageInstance: 'new ExpoModulesPackage()', + }, + }, + }, + }, +}; diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 69fb4513..e613f3e0 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -1,8 +1,26 @@ import axios from 'axios'; +import { LaunchArguments } from 'react-native-launch-arguments'; import { adminErrorResponseErrorInterceptor } from './errorInterceptor'; -const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:4000'; +interface E2ELaunchArguments { + E2E_API_BASE_URL?: string; +} + +function getApiUrl(): string { + try { + const launchArgs = LaunchArguments.value(); + return ( + launchArgs.E2E_API_BASE_URL || + process.env.EXPO_PUBLIC_API_URL || + 'http://localhost:4000' + ); + } catch { + return process.env.EXPO_PUBLIC_API_URL || 'http://localhost:4000'; + } +} + +const API_URL = getApiUrl(); /** * Version prefix prepended to consumer-facing API paths. diff --git a/mobile/src/components/SignAndSubmitXdr.test.tsx b/mobile/src/components/SignAndSubmitXdr.test.tsx new file mode 100644 index 00000000..3288dd7f --- /dev/null +++ b/mobile/src/components/SignAndSubmitXdr.test.tsx @@ -0,0 +1,143 @@ +import type { ComponentProps } from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { ActivityIndicator } from 'react-native'; + +import { SignAndSubmitXdr } from './SignAndSubmitXdr'; +import { + signAndSubmitXdr, + TransactionSigningError, +} from '../services/freighterSigner'; + +jest.mock('../services/freighterSigner', () => ({ + ...jest.requireActual('../services/freighterSigner'), + signAndSubmitXdr: jest.fn(), +})); + +const mockSignAndSubmit = signAndSubmitXdr as jest.Mock; + +const XDR = 'AAAAunsigned'; +const ADDRESS = 'GDNM7WSJ7VIUVK2TSZ2OQES5XR2663TZEIBFXRDT56B5IRLHERVWSXMU'; + +function renderControl(props: Partial> = {}) { + return render( + , + ); +} + +describe('SignAndSubmitXdr', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSignAndSubmit.mockResolvedValue({ hash: 'hash-001' }); + }); + + it('renders an idle "Sign & submit" button and nothing else', () => { + const { getByTestId, getByText, queryByTestId } = renderControl(); + + expect(getByTestId('tx-sign')).toBeTruthy(); + expect(getByText('Sign & submit with Freighter')).toBeTruthy(); + expect(queryByTestId('tx-error')).toBeNull(); + expect(queryByTestId('tx-submitted')).toBeNull(); + expect(mockSignAndSubmit).not.toHaveBeenCalled(); + }); + + it('signs the XDR for the given address and shows the transaction hash', async () => { + const onSubmitted = jest.fn(); + const { getByTestId, queryByTestId } = renderControl({ onSubmitted }); + + await act(async () => { + fireEvent.press(getByTestId('tx-sign')); + }); + + expect(mockSignAndSubmit).toHaveBeenCalledWith(XDR, { address: ADDRESS }); + expect(getByTestId('tx-hash').props.children).toBe('hash-001'); + expect(onSubmitted).toHaveBeenCalledWith('hash-001'); + // The button is replaced by the result so it cannot be double-submitted. + expect(queryByTestId('tx-sign')).toBeNull(); + }); + + it('works without a wallet address hint', async () => { + const { getByTestId } = renderControl({ address: undefined }); + + await act(async () => { + fireEvent.press(getByTestId('tx-sign')); + }); + + expect(mockSignAndSubmit).toHaveBeenCalledWith(XDR, { address: undefined }); + }); + + it('shows a spinner and blocks re-entry while signing', async () => { + let finish: (v: { hash: string }) => void = () => undefined; + mockSignAndSubmit.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const { getByTestId, UNSAFE_getByType, queryByText } = renderControl(); + + act(() => { + fireEvent.press(getByTestId('tx-sign')); + }); + + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(queryByText('Sign & submit with Freighter')).toBeNull(); + expect(getByTestId('tx-sign').props.accessibilityState).toMatchObject({ + disabled: true, + busy: true, + }); + // A second press while busy is ignored. + fireEvent.press(getByTestId('tx-sign')); + expect(mockSignAndSubmit).toHaveBeenCalledTimes(1); + + await act(async () => { + finish({ hash: 'hash-002' }); + }); + expect(getByTestId('tx-hash').props.children).toBe('hash-002'); + }); + + it.each([ + ['WALLET_UNAVAILABLE', 'Freighter unavailable'], + ['USER_REJECTED', 'Signature declined'], + ['SIGNING_FAILED', 'Signing failed'], + ['SUBMISSION_FAILED', 'Submission failed'], + ] as const)('shows a %s failure inline and lets the user retry', async (code, title) => { + mockSignAndSubmit.mockRejectedValueOnce( + new TransactionSigningError(code, `message for ${code}`), + ); + const { getByTestId, getByText, queryByTestId } = renderControl(); + + await act(async () => { + fireEvent.press(getByTestId('tx-sign')); + }); + + expect(getByTestId('tx-error')).toBeTruthy(); + expect(getByText(title)).toBeTruthy(); + expect(getByText(`message for ${code}`)).toBeTruthy(); + expect(queryByTestId('tx-submitted')).toBeNull(); + + // Retry succeeds and clears the error. + expect(getByText('Try signing again')).toBeTruthy(); + await act(async () => { + fireEvent.press(getByTestId('tx-sign')); + }); + expect(queryByTestId('tx-error')).toBeNull(); + expect(getByTestId('tx-hash').props.children).toBe('hash-001'); + expect(mockSignAndSubmit).toHaveBeenCalledTimes(2); + }); + + it('wraps an unexpected non-signing error as a signing failure', async () => { + mockSignAndSubmit.mockRejectedValueOnce(new Error('kaboom')); + const { getByTestId, getByText } = renderControl(); + + await act(async () => { + fireEvent.press(getByTestId('tx-sign')); + }); + + expect(getByText('Signing failed')).toBeTruthy(); + expect(getByText('kaboom')).toBeTruthy(); + }); +}); diff --git a/mobile/src/components/SignAndSubmitXdr.tsx b/mobile/src/components/SignAndSubmitXdr.tsx new file mode 100644 index 00000000..570bcdd3 --- /dev/null +++ b/mobile/src/components/SignAndSubmitXdr.tsx @@ -0,0 +1,147 @@ +import React, { useCallback, useState } from 'react'; +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; + +import { + signAndSubmitXdr, + TransactionSigningError, +} from '../services/freighterSigner'; + +export interface SignAndSubmitXdrProps { + /** Backend-built transaction the wallet should sign. */ + unsignedXdr: string; + /** Wallet address expected to sign (passed to Freighter as a hint). */ + address?: string; + /** Prefix for the `testID`s of the button, hash and error text. */ + testIDPrefix: string; + /** Called with the transaction hash once the network has accepted it. */ + onSubmitted?: (hash: string) => void; +} + +type Status = 'idle' | 'busy' | 'submitted' | 'error'; + +/** + * "Sign & submit" control that sits under an `unsignedXdr` card. It asks + * Freighter to sign the transaction, submits it to the Stellar network and + * reports the outcome inline. + * + * The XDR stays copyable above this control, so when Freighter is not + * available on the device (`WALLET_UNAVAILABLE`) the admin can still sign + * externally; every other failure can simply be retried. + */ +export function SignAndSubmitXdr({ + unsignedXdr, + address, + testIDPrefix, + onSubmitted, +}: SignAndSubmitXdrProps): React.ReactElement { + const [status, setStatus] = useState('idle'); + const [hash, setHash] = useState(null); + const [error, setError] = useState(null); + + const signAndSubmit = useCallback(async () => { + setStatus('busy'); + setError(null); + try { + const result = await signAndSubmitXdr(unsignedXdr, { address }); + setHash(result.hash); + setStatus('submitted'); + onSubmitted?.(result.hash); + } catch (e: unknown) { + setError( + e instanceof TransactionSigningError + ? e + : new TransactionSigningError( + 'SIGNING_FAILED', + e instanceof Error && e.message + ? e.message + : 'Could not sign the transaction.', + ), + ); + setStatus('error'); + } + }, [unsignedXdr, address, onSubmitted]); + + const disabled = status === 'busy' || status === 'submitted'; + + return ( + + {status !== 'submitted' ? ( + void signAndSubmit()} + disabled={disabled} + style={[styles.button, disabled && styles.buttonDisabled]} + > + {status === 'busy' ? ( + + ) : ( + + {status === 'error' ? 'Try signing again' : 'Sign & submit with Freighter'} + + )} + + ) : null} + + {status === 'submitted' && hash ? ( + + Transaction submitted + Transaction hash + + {hash} + + + ) : null} + + {status === 'error' && error ? ( + + {titleFor(error.code)} + {error.message} + + ) : null} + + ); +} + +function titleFor(code: TransactionSigningError['code']): string { + switch (code) { + case 'WALLET_UNAVAILABLE': + return 'Freighter unavailable'; + case 'USER_REJECTED': + return 'Signature declined'; + case 'SUBMISSION_FAILED': + return 'Submission failed'; + case 'SIGNING_FAILED': + return 'Signing failed'; + } +} + +export default SignAndSubmitXdr; + +const styles = StyleSheet.create({ + container: { gap: 6, marginTop: 4 }, + button: { + backgroundColor: '#2d6a2d', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 8, + alignItems: 'center', + minHeight: 44, + justifyContent: 'center', + }, + buttonDisabled: { backgroundColor: '#7e977e' }, + buttonText: { color: '#fff', fontWeight: '700', fontSize: 14 }, + label: { fontSize: 12, fontWeight: '700', color: '#1f3d1f' }, + mono: { fontSize: 12, fontFamily: 'monospace', color: '#4f5d4f' }, + successTitle: { fontSize: 14, fontWeight: '700', color: '#2d6a2d' }, + errorTitle: { fontSize: 14, fontWeight: '700', color: '#8a2a1f' }, + errorBody: { fontSize: 13, color: '#8a2a1f', lineHeight: 18 }, +}); diff --git a/mobile/src/constants/stellar.ts b/mobile/src/constants/stellar.ts new file mode 100644 index 00000000..fee09380 --- /dev/null +++ b/mobile/src/constants/stellar.ts @@ -0,0 +1,41 @@ +/** + * Stellar network configuration used when signing and submitting + * transactions from the mobile app. + * + * `EXPO_PUBLIC_STELLAR_NETWORK` accepts the friendly names already used in + * `.env.example` (`testnet` | `public`). A full network passphrase is also + * accepted so the value stays interchangeable with the web frontend, which + * reads the passphrase from the same variable name. + */ +const NETWORKS = { + testnet: { + passphrase: 'Test SDF Network ; September 2015', + rpcUrl: 'https://soroban-testnet.stellar.org', + }, + public: { + passphrase: 'Public Global Stellar Network ; September 2015', + rpcUrl: 'https://mainnet.sorobanrpc.com', + }, +} as const; + +const configured = process.env.EXPO_PUBLIC_STELLAR_NETWORK ?? 'testnet'; + +const preset = + configured === 'public' + ? NETWORKS.public + : configured === 'testnet' + ? NETWORKS.testnet + : null; + +/** Network passphrase the wallet must sign against. */ +export const STELLAR_NETWORK_PASSPHRASE: string = + preset?.passphrase ?? configured; + +/** + * Soroban RPC endpoint that receives the signed transaction. Override with + * `EXPO_PUBLIC_STELLAR_RPC_URL` (e.g. a private/paid RPC provider). + */ +export const STELLAR_RPC_URL: string = + process.env.EXPO_PUBLIC_STELLAR_RPC_URL || + preset?.rpcUrl || + NETWORKS.testnet.rpcUrl; diff --git a/mobile/src/screens/ADMIN_SCREENS.md b/mobile/src/screens/ADMIN_SCREENS.md index 4a7503f3..fd1a052a 100644 --- a/mobile/src/screens/ADMIN_SCREENS.md +++ b/mobile/src/screens/ADMIN_SCREENS.md @@ -39,7 +39,7 @@ Every admin screen except `AdminStreamScreen` renders backend failures through ` | --- | --- | --- | --- | | `AdminStreamsOverviewScreen` | Load failure sets `errorView`; the banner's retry re-runs `loadStreams`. | An empty API result renders an empty list with no message. The seeded stream list shows until the first response lands. | "Loading streams…" row while fetching. An `OfflineBanner` shows when `useNetworkStatus` reports offline and the Clawback / Lock / Terminate buttons are disabled. | | `AdminTradesBatchScreen` | A failed submit sets `actionErrorView`; retry re-fires the batch and the trade IDs and target status are kept. `loadErrorView` is never set today (no fetch on mount). | No results card until a batch has run. An empty ID list is ignored client-side and the run button stays disabled. | Per-trade `failed` rows from a successful response are listed in the result card, not the banner. | -| `AdminContractScreen` | Separate `medErrorView` (add mediator) and `feeErrorView` (fee update) banners; retry re-fires that section only and its input is kept. | No XDR card until a request succeeds. | A fee outside 1–500 bps, or a blank mediator address, is ignored client-side without a banner. | +| `AdminContractScreen` | Separate `medErrorView` (add mediator) and `feeErrorView` (fee update) banners; retry re-fires that section only and its input is kept. Signing problems (Freighter unavailable, signature declined, submission failed) show inline under the XDR card instead of in a banner, and the action can be retried. | No XDR card until a request succeeds. | A fee outside 1–500 bps, or a blank mediator address, is ignored client-side without a banner. | | `AdminFeaturesScreen` | A failed load sets `loadErrorView` (retry reloads flags). A failed toggle rolls the switch back and sets `rowErrorView`; the banner's retry does nothing for a toggle failure, so the admin flips the switch again. | "No feature flags defined." | "Loading flags…" row until the first response. | | `AdminActionSuccessScreen` | None; it only reads the session history store. | "No previous actions recorded." when the history is empty. | | | `AdminStreamScreen` | Not registered in `AppNavigator`. Load failure shows the raw error message (or "Failed to load audit trail") with a Retry button; it does not use `AdminErrorBanner`. | "No audit records found". | Full-screen "Loading audit trail…" indicator. | diff --git a/mobile/src/screens/AdminContractScreen.test.tsx b/mobile/src/screens/AdminContractScreen.test.tsx index df3bd6e7..d7d45c86 100644 --- a/mobile/src/screens/AdminContractScreen.test.tsx +++ b/mobile/src/screens/AdminContractScreen.test.tsx @@ -4,6 +4,10 @@ import AdminContractScreen from './AdminContractScreen'; import { useAuthStore } from '../stores/authStore'; import { adminApi } from '../api/admin'; import { AdminApiError } from '../api/errors'; +import { + signAndSubmitXdr, + TransactionSigningError, +} from '../services/freighterSigner'; jest.mock('../stores/authStore', () => ({ useAuthStore: jest.fn(), @@ -25,6 +29,15 @@ jest.mock('../api/admin', () => ({ }, })); +jest.mock('../services/freighterSigner', () => ({ + ...jest.requireActual('../services/freighterSigner'), + signAndSubmitXdr: jest.fn(), +})); + +const mockSignAndSubmit = signAndSubmitXdr as jest.MockedFunction< + typeof signAndSubmitXdr +>; + const mockAddMediator = adminApi.addMediator as jest.MockedFunction< typeof adminApi.addMediator >; @@ -33,12 +46,14 @@ const mockUpdateFee = adminApi.updateContractFeeBps as jest.MockedFunction< >; const STELLAR_PUBKEY = 'G'.padEnd(56, 'A'); +const ADMIN_WALLET = 'G'.padEnd(56, 'B'); describe('AdminContractScreen', () => { beforeEach(() => { jest.clearAllMocks(); (useAuthStore as unknown as jest.Mock).mockReturnValue({ role: 'admin', + walletAddress: ADMIN_WALLET, clearAuth: jest.fn().mockResolvedValue(undefined), }); }); @@ -105,4 +120,128 @@ describe('AdminContractScreen', () => { expect(await findByTestId('admin-error-banner')).toBeTruthy(); await waitFor(() => expect(mockUpdateFee).toHaveBeenCalledWith(500)); }); + describe('Freighter signing', () => { + const navigation = { goBack: jest.fn(), navigate: jest.fn() }; + + function renderScreen() { + return render( + , + ); + } + + async function buildAddMediatorTx(utils: ReturnType) { + mockAddMediator.mockResolvedValue({ unsignedXdr: 'XDR_ADD_MEDIATOR' }); + fireEvent.changeText( + utils.getByTestId('admin-contract-add-input'), + STELLAR_PUBKEY, + ); + await act(async () => { + fireEvent.press(utils.getByTestId('admin-contract-add')); + }); + } + + async function buildFeeTx(utils: ReturnType) { + mockUpdateFee.mockResolvedValue({ unsignedXdr: 'XDR_UPDATE_FEE' }); + await act(async () => { + fireEvent.press(utils.getByTestId('admin-contract-fee')); + }); + } + + it('offers no signing action until an XDR has been built', () => { + const { queryByTestId } = renderScreen(); + + expect(queryByTestId('admin-contract-add-sign')).toBeNull(); + expect(queryByTestId('admin-contract-fee-sign')).toBeNull(); + }); + + it('signs and submits the add-mediator XDR with the admin wallet', async () => { + mockSignAndSubmit.mockResolvedValue({ hash: 'HASH_ADD' }); + const utils = renderScreen(); + await buildAddMediatorTx(utils); + + await act(async () => { + fireEvent.press(await utils.findByTestId('admin-contract-add-sign')); + }); + + expect(mockSignAndSubmit).toHaveBeenCalledWith('XDR_ADD_MEDIATOR', { + address: ADMIN_WALLET, + }); + expect(utils.getByTestId('admin-contract-add-hash').props.children).toBe( + 'HASH_ADD', + ); + // The XDR stays visible/copyable after signing. + expect(utils.getByTestId('admin-contract-add-xdr')).toBeTruthy(); + }); + + it('signs and submits the update-fee XDR with the admin wallet', async () => { + mockSignAndSubmit.mockResolvedValue({ hash: 'HASH_FEE' }); + const utils = renderScreen(); + await buildFeeTx(utils); + + await act(async () => { + fireEvent.press(await utils.findByTestId('admin-contract-fee-sign')); + }); + + expect(mockSignAndSubmit).toHaveBeenCalledWith('XDR_UPDATE_FEE', { + address: ADMIN_WALLET, + }); + expect(utils.getByTestId('admin-contract-fee-hash').props.children).toBe( + 'HASH_FEE', + ); + }); + + it('keeps the two sections independent', async () => { + mockSignAndSubmit.mockResolvedValue({ hash: 'HASH_ADD' }); + const utils = renderScreen(); + await buildAddMediatorTx(utils); + await buildFeeTx(utils); + + await act(async () => { + fireEvent.press(await utils.findByTestId('admin-contract-add-sign')); + }); + + expect(mockSignAndSubmit).toHaveBeenCalledTimes(1); + expect(utils.queryByTestId('admin-contract-fee-hash')).toBeNull(); + expect(utils.getByTestId('admin-contract-fee-sign')).toBeTruthy(); + }); + + it('shows an inline error and keeps the copyable XDR when Freighter is unavailable', async () => { + mockSignAndSubmit.mockRejectedValue( + new TransactionSigningError( + 'WALLET_UNAVAILABLE', + "Freighter isn't available on this device.", + ), + ); + const utils = renderScreen(); + await buildAddMediatorTx(utils); + + await act(async () => { + fireEvent.press(await utils.findByTestId('admin-contract-add-sign')); + }); + + expect(utils.getByTestId('admin-contract-add-error')).toBeTruthy(); + expect(utils.getByText('Freighter unavailable')).toBeTruthy(); + expect(utils.getByTestId('admin-contract-add-xdr')).toBeTruthy(); + // Signing problems are not backend errors, so no banner is raised. + expect(utils.queryByTestId('admin-error-banner')).toBeNull(); + }); + + it('drops the previous signing result when a new XDR is built', async () => { + mockSignAndSubmit.mockResolvedValue({ hash: 'HASH_FIRST' }); + const utils = renderScreen(); + await buildAddMediatorTx(utils); + await act(async () => { + fireEvent.press(await utils.findByTestId('admin-contract-add-sign')); + }); + expect(utils.getByTestId('admin-contract-add-hash')).toBeTruthy(); + + await buildAddMediatorTx(utils); + + expect(utils.queryByTestId('admin-contract-add-hash')).toBeNull(); + expect(utils.getByTestId('admin-contract-add-sign')).toBeTruthy(); + }); + }); }); diff --git a/mobile/src/screens/AdminContractScreen.tsx b/mobile/src/screens/AdminContractScreen.tsx index f134d771..695c6048 100644 --- a/mobile/src/screens/AdminContractScreen.tsx +++ b/mobile/src/screens/AdminContractScreen.tsx @@ -17,6 +17,7 @@ import { useAuthStore } from '../stores/authStore'; import { adminApi, ContractTxResult } from '../api/admin'; import { viewForError } from '../api/errorInterceptor'; import { AdminErrorBanner } from '../components/AdminErrorBanner'; +import { SignAndSubmitXdr } from '../components/SignAndSubmitXdr'; import { buildSupportMailto } from '../constants/support'; type Props = StackScreenProps; @@ -28,9 +29,11 @@ type Props = StackScreenProps; * - PATCH /api/admin/contract/fee * * All three return an `unsignedXdr` the admin must sign with their - * Stellar wallet (Freighter). Until wallet signing is wired up, this - * screen surfaces the XDR as a copy-only string (`selectable` lets the - * user long-press to copy) so the admin can sign externally. The error + * Stellar wallet (Freighter). Each result card offers "Sign & submit with + * Freighter", which signs the XDR and submits it to the Stellar network + * (see `services/freighterSigner`). The XDR is still shown as a + * `selectable` string so the admin can long-press to copy it and sign + * externally when Freighter is not available on the device. The error * banner pattern is the same as AdminStreamsOverviewScreen. * * Two per-section error slots keep user input on failure: the add-input @@ -39,7 +42,7 @@ type Props = StackScreenProps; */ export default function AdminContractScreen({ navigation }: Props) { const insets = useSafeAreaInsets(); - const { role, clearAuth } = useAuthStore(); + const { role, clearAuth, walletAddress } = useAuthStore(); const isAdmin = role === 'admin'; // Mediators section. @@ -137,9 +140,9 @@ export default function AdminContractScreen({ navigation }: Props) { {/* --- Mediators section --- */} Add mediator - Signs a backend-built `unsignedXdr`. Wallet signing is a - follow-up — copy the XDR (long-press the field) and sign - externally until Freighter wiring lands. + Builds a backend `unsignedXdr`, then signs and submits it with + Freighter. If Freighter isn't available on this device, copy the + XDR (long-press the field) and sign it externally. {medErrorView ? ( {medTx.unsignedXdr} - {/* // TODO: sign unsignedXdr with @stellar/freighter-api and submit it. */} + ) : null} @@ -237,6 +244,11 @@ export default function AdminContractScreen({ navigation }: Props) { > {feeTx.unsignedXdr} + ) : null} diff --git a/mobile/src/screens/TradeDetailScreen.test.tsx b/mobile/src/screens/TradeDetailScreen.test.tsx new file mode 100644 index 00000000..8858c0f9 --- /dev/null +++ b/mobile/src/screens/TradeDetailScreen.test.tsx @@ -0,0 +1,581 @@ +/** + * TradeDetailScreen.test.tsx + * + * Covers the primary render states of TradeDetailScreen (loading, not + * found, loaded for every trade status, load/action error banners) and its + * key interactions (confirmation alerts for deposit / confirm / release, + * the dispute modal, evidence upload and navigation). + */ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { ActivityIndicator, Alert, Linking } from 'react-native'; +import type { AlertButton } from 'react-native'; + +import TradeDetailScreen from './TradeDetailScreen'; +import { useTradeStore } from '../stores/tradeStore'; +import { useAuthStore } from '../stores/authStore'; +import type { AdminErrorView } from '../api/errors'; +import type { Trade, TradeStatus } from '../types/trade'; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +jest.mock('../stores/tradeStore', () => ({ + useTradeStore: jest.fn(), +})); + +jest.mock('../stores/authStore', () => ({ + useAuthStore: jest.fn(), +})); + +const TRADE_ID = '4294967297123456'; +const BUYER = 'GDNM7WSJ7VIUVK2TSZ2OQES5XR2663TZEIBFXRDT56B5IRLHERVWSXMU'; +const SELLER = 'GBQ5NYMF2UBXZPH7PCPCUNTONIIIVRRQ24ZDUVAOD4GXLMI5SAQUJDFK'; + +function makeTrade(overrides: Partial = {}): Trade { + return { + id: 1, + tradeId: TRADE_ID, + buyerAddress: BUYER, + sellerAddress: SELLER, + amountUsdc: '250.00', + status: 'FUNDED', + ...overrides, + }; +} + +function makeView(overrides: Partial = {}): AdminErrorView { + return { + title: 'Something went wrong', + message: 'The request failed.', + action: 'retry', + code: 'INTERNAL_ERROR', + requestId: 'req-42', + ...overrides, + }; +} + +describe('TradeDetailScreen', () => { + const fetchTrade = jest.fn(); + const confirmDelivery = jest.fn(); + const initiateDispute = jest.fn(); + const releaseFunds = jest.fn(); + const deposit = jest.fn(); + const clearErrorView = jest.fn(); + const clearAuth = jest.fn(); + const navigation = { + goBack: jest.fn(), + navigate: jest.fn(), + }; + let alertSpy: jest.SpyInstance; + let openURL: jest.SpyInstance; + + function mockStore(overrides: Record = {}) { + (useTradeStore as unknown as jest.Mock).mockReturnValue({ + currentTrade: makeTrade(), + isLoading: false, + errorView: null, + lastActionErrorView: null, + fetchTrade, + confirmDelivery, + initiateDispute, + releaseFunds, + deposit, + clearErrorView, + ...overrides, + }); + } + + function renderScreen() { + return render( + , + ); + } + + /** Returns the buttons of the most recent `Alert.alert` call. */ + function lastAlertButtons(): AlertButton[] { + const calls = alertSpy.mock.calls; + return calls[calls.length - 1][2] as AlertButton[]; + } + + beforeEach(() => { + jest.clearAllMocks(); + fetchTrade.mockResolvedValue(undefined); + confirmDelivery.mockResolvedValue(undefined); + initiateDispute.mockResolvedValue(undefined); + releaseFunds.mockResolvedValue(undefined); + deposit.mockResolvedValue(undefined); + clearAuth.mockResolvedValue(undefined); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + (useAuthStore as unknown as jest.Mock).mockReturnValue({ clearAuth }); + mockStore(); + }); + + afterEach(() => { + alertSpy.mockRestore(); + openURL.mockRestore(); + }); + + describe('loading and not-found states', () => { + it('fetches the trade for the route param on mount', () => { + renderScreen(); + + expect(fetchTrade).toHaveBeenCalledWith(TRADE_ID); + }); + + it('shows a spinner and no content while the first load is in flight', () => { + mockStore({ currentTrade: null, isLoading: true }); + const { UNSAFE_getByType, queryByText } = renderScreen(); + + expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(queryByText('Trade Detail')).toBeNull(); + }); + + it('keeps rendering the trade (no spinner) when a refresh is in flight', () => { + mockStore({ isLoading: true }); + const { getByText } = renderScreen(); + + expect(getByText('Trade Detail')).toBeTruthy(); + expect(getByText('Trade Info')).toBeTruthy(); + }); + + it('shows a synthetic "Trade not found" banner when there is no trade and no error', () => { + mockStore({ currentTrade: null }); + const { getByText, getByTestId } = renderScreen(); + + expect(getByText('Trade not found')).toBeTruthy(); + fireEvent.press(getByTestId('admin-error-banner-primary-go_back')); + expect(navigation.goBack).toHaveBeenCalledTimes(1); + }); + + it('shows the store error and offers a retry when the load failed', () => { + mockStore({ + currentTrade: null, + errorView: makeView({ action: 'retry' }), + }); + const { getByText, getByTestId } = renderScreen(); + expect(getByText('Something went wrong')).toBeTruthy(); + expect(fetchTrade).toHaveBeenCalledTimes(1); + + fireEvent.press(getByTestId('admin-error-banner-primary-retry')); + + expect(fetchTrade).toHaveBeenCalledTimes(2); + expect(fetchTrade).toHaveBeenLastCalledWith(TRADE_ID); + }); + + it('signs out from the not-found banner when the session expired', async () => { + mockStore({ + currentTrade: null, + errorView: makeView({ + action: 'sign_out_required', + code: 'AUTH_ERROR', + }), + }); + const { getByTestId } = renderScreen(); + + await act(async () => { + fireEvent.press( + getByTestId('admin-error-banner-primary-sign_out_required'), + ); + }); + + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it('navigates back from the header on the not-found screen', () => { + mockStore({ currentTrade: null }); + const { getByText } = renderScreen(); + + fireEvent.press(getByText('← Back')); + + expect(navigation.goBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('loaded trade', () => { + it('renders the status card, trade info and contract defaults', () => { + mockStore({ + currentTrade: makeTrade({ + status: 'FUNDED', + commodity: 'Cocoa', + quantity: '12', + unit: 'tons', + createdAt: '2026-01-02T10:00:00.000Z', + updatedAt: '2026-01-03T10:00:00.000Z', + }), + }); + const { getByText, getAllByText } = renderScreen(); + + expect(getByText('#429496729712…')).toBeTruthy(); + expect(getByText('250.00 USDC')).toBeTruthy(); + expect(getByText('Cocoa — 12 tons')).toBeTruthy(); + + expect(getByText(TRADE_ID)).toBeTruthy(); + expect(getByText(BUYER)).toBeTruthy(); + expect(getByText(SELLER)).toBeTruthy(); + expect( + getByText(new Date('2026-01-03T10:00:00.000Z').toLocaleDateString()), + ).toBeTruthy(); + // "Created" is both a trade-info label and a timeline step. + expect(getAllByText('Created')).toHaveLength(2); + + expect(getByText('Buyer 5000 / Seller 5000 bps')).toBeTruthy(); + expect(getByText('1% (seller)')).toBeTruthy(); + expect(getByText('cNGN')).toBeTruthy(); + expect(getByText('Commodity')).toBeTruthy(); + expect(getByText('12 tons')).toBeTruthy(); + }); + + it('uses the trade loss ratio and omits commodity details when absent', () => { + mockStore({ + currentTrade: makeTrade({ buyerLossBps: 3000, sellerLossBps: 7000 }), + }); + const { getByText, queryByText } = renderScreen(); + + expect(getByText('Buyer 3000 / Seller 7000 bps')).toBeTruthy(); + expect(queryByText('Commodity')).toBeNull(); + expect(queryByText('Quantity')).toBeNull(); + expect(queryByText('Updated')).toBeNull(); + }); + + it.each<[TradeStatus, string]>([ + ['PENDING', 'Pending'], + ['FUNDED', 'Funded'], + ['IN_TRANSIT', 'In Transit'], + ['DELIVERED', 'Delivered'], + ['DISPUTED', 'Disputed'], + ['COMPLETED', 'Completed'], + ['REFUNDED', 'Refunded'], + ])('labels a %s trade "%s"', (status, label) => { + mockStore({ currentTrade: makeTrade({ status }) }); + const { getAllByText } = renderScreen(); + + // The status badge, plus a timeline step for the lifecycle statuses. + expect(getAllByText(label).length).toBeGreaterThanOrEqual(1); + }); + + it('appends terminal steps to the timeline for disputed and refunded trades', () => { + mockStore({ currentTrade: makeTrade({ status: 'PENDING' }) }); + const pending = renderScreen(); + expect(pending.getAllByText('Pending')).toHaveLength(1); + pending.unmount(); + + mockStore({ currentTrade: makeTrade({ status: 'DISPUTED' }) }); + const disputed = renderScreen(); + // badge + timeline step + expect(disputed.getAllByText('Disputed')).toHaveLength(2); + disputed.unmount(); + + mockStore({ currentTrade: makeTrade({ status: 'REFUNDED' }) }); + const refunded = renderScreen(); + expect(refunded.getAllByText('Refunded')).toHaveLength(2); + }); + }); + + describe('available actions per status', () => { + const DEPOSIT = '💰 Deposit Funds'; + const CONFIRM = '✅ Confirm Delivery'; + const RELEASE = '💸 Release Funds'; + const DISPUTE = '⚠️ Initiate Dispute'; + const EVIDENCE = '📹 Upload Evidence'; + const NONE = 'No actions available for this trade status.'; + const ALL = [DEPOSIT, CONFIRM, RELEASE, DISPUTE, EVIDENCE, NONE]; + + it.each<[TradeStatus, string[]]>([ + ['PENDING', [DEPOSIT]], + ['FUNDED', [CONFIRM, DISPUTE]], + ['IN_TRANSIT', [CONFIRM, DISPUTE]], + ['DELIVERED', [RELEASE, DISPUTE]], + ['DISPUTED', [EVIDENCE]], + ['COMPLETED', [NONE]], + ['REFUNDED', [NONE]], + ])('%s shows exactly the right actions', (status, visible) => { + mockStore({ currentTrade: makeTrade({ status }) }); + const { queryByText } = renderScreen(); + + for (const label of ALL) { + if (visible.includes(label)) { + expect(queryByText(label)).toBeTruthy(); + } else { + expect(queryByText(label)).toBeNull(); + } + } + }); + }); + + describe('action interactions', () => { + it('deposits only after the confirmation alert is accepted', async () => { + mockStore({ currentTrade: makeTrade({ status: 'PENDING' }) }); + const { getByText } = renderScreen(); + + fireEvent.press(getByText('💰 Deposit Funds')); + + expect(alertSpy).toHaveBeenCalledWith( + 'Deposit Funds', + 'Send the trade amount to the escrow contract?', + expect.any(Array), + ); + expect(deposit).not.toHaveBeenCalled(); + + const [cancel, accept] = lastAlertButtons(); + expect(cancel.style).toBe('cancel'); + expect(cancel.onPress).toBeUndefined(); + await act(async () => { + await accept.onPress?.(); + }); + + expect(deposit).toHaveBeenCalledWith(TRADE_ID); + }); + + it('confirms delivery only after the confirmation alert is accepted', async () => { + mockStore({ currentTrade: makeTrade({ status: 'IN_TRANSIT' }) }); + const { getByText } = renderScreen(); + + fireEvent.press(getByText('✅ Confirm Delivery')); + + expect(alertSpy).toHaveBeenCalledWith( + 'Confirm Delivery', + expect.stringContaining('received the goods'), + expect.any(Array), + ); + expect(confirmDelivery).not.toHaveBeenCalled(); + + await act(async () => { + await lastAlertButtons()[1].onPress?.(); + }); + + expect(confirmDelivery).toHaveBeenCalledWith(TRADE_ID); + }); + + it('releases funds through a destructive confirmation alert', async () => { + mockStore({ currentTrade: makeTrade({ status: 'DELIVERED' }) }); + const { getByText } = renderScreen(); + + fireEvent.press(getByText('💸 Release Funds')); + + expect(alertSpy).toHaveBeenCalledWith( + 'Release Funds', + expect.stringContaining('cannot be undone'), + expect.any(Array), + ); + const release = lastAlertButtons()[1]; + expect(release.style).toBe('destructive'); + expect(releaseFunds).not.toHaveBeenCalled(); + + await act(async () => { + await release.onPress?.(); + }); + + expect(releaseFunds).toHaveBeenCalledWith(TRADE_ID); + }); + + it('swaps the action label for a spinner while the action is running', async () => { + mockStore({ currentTrade: makeTrade({ status: 'PENDING' }) }); + let finish: () => void = () => undefined; + deposit.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const { getByText, queryByText, UNSAFE_getAllByType } = renderScreen(); + fireEvent.press(getByText('💰 Deposit Funds')); + + let pending!: Promise; + act(() => { + pending = Promise.resolve(lastAlertButtons()[1].onPress?.()); + }); + + expect(queryByText('💰 Deposit Funds')).toBeNull(); + expect(UNSAFE_getAllByType(ActivityIndicator).length).toBeGreaterThan(0); + + await act(async () => { + finish(); + await pending; + }); + + await waitFor(() => expect(getByText('💰 Deposit Funds')).toBeTruthy()); + }); + + it('navigates to evidence capture for a disputed trade', () => { + mockStore({ currentTrade: makeTrade({ status: 'DISPUTED' }) }); + const { getByText } = renderScreen(); + + fireEvent.press(getByText('📹 Upload Evidence')); + + expect(navigation.navigate).toHaveBeenCalledWith('EvidenceCapture', { + tradeId: TRADE_ID, + }); + }); + + it('navigates back from the header', () => { + const { getByText } = renderScreen(); + + fireEvent.press(getByText('← Back')); + + expect(navigation.goBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('dispute modal', () => { + function openModal() { + const utils = renderScreen(); + fireEvent.press(utils.getByText('⚠️ Initiate Dispute')); + return utils; + } + + it('is hidden until "Initiate Dispute" is pressed', () => { + const { queryByText, getByText } = renderScreen(); + expect(queryByText('Describe the issue with this trade:')).toBeNull(); + + fireEvent.press(getByText('⚠️ Initiate Dispute')); + + expect(getByText('Describe the issue with this trade:')).toBeTruthy(); + }); + + it('rejects a blank reason without calling the store', async () => { + const { getByText, getByPlaceholderText } = openModal(); + fireEvent.changeText( + getByPlaceholderText(/Goods arrived damaged/), + ' ', + ); + + await act(async () => { + fireEvent.press(getByText('Submit')); + }); + + expect(alertSpy).toHaveBeenCalledWith( + 'Reason required', + 'Please describe the issue before submitting a dispute.', + ); + expect(initiateDispute).not.toHaveBeenCalled(); + // Modal stays open so the user can fix the input. + expect(getByText('Describe the issue with this trade:')).toBeTruthy(); + }); + + it('submits the trimmed reason, then closes and resets the modal', async () => { + const { getByText, queryByText, getByPlaceholderText } = openModal(); + fireEvent.changeText( + getByPlaceholderText(/Goods arrived damaged/), + ' Goods arrived damaged ', + ); + + await act(async () => { + fireEvent.press(getByText('Submit')); + }); + + expect(initiateDispute).toHaveBeenCalledWith( + TRADE_ID, + 'Goods arrived damaged', + ); + await waitFor(() => + expect(queryByText('Describe the issue with this trade:')).toBeNull(), + ); + + // Reopening shows an empty field again. + fireEvent.press(getByText('⚠️ Initiate Dispute')); + expect(getByPlaceholderText(/Goods arrived damaged/).props.value).toBe(''); + }); + + it('cancel closes the modal and discards the draft without submitting', () => { + const { getByText, queryByText, getByPlaceholderText } = openModal(); + fireEvent.changeText(getByPlaceholderText(/Goods arrived damaged/), 'draft'); + + fireEvent.press(getByText('Cancel')); + + expect(queryByText('Describe the issue with this trade:')).toBeNull(); + expect(initiateDispute).not.toHaveBeenCalled(); + + fireEvent.press(getByText('⚠️ Initiate Dispute')); + expect(getByPlaceholderText(/Goods arrived damaged/).props.value).toBe(''); + }); + }); + + describe('error banner', () => { + it('shows a load error above the content and offers a retry', () => { + mockStore({ errorView: makeView({ action: 'retry' }) }); + const { getByTestId, getByText } = renderScreen(); + expect(getByTestId('admin-error-banner')).toBeTruthy(); + // The trade itself is still rendered beneath the banner. + expect(getByText('Trade Info')).toBeTruthy(); + + fireEvent.press(getByTestId('admin-error-banner-primary-retry')); + + expect(fetchTrade).toHaveBeenCalledTimes(2); + }); + + it('prefers a mutation error over a stale load error', () => { + mockStore({ + errorView: makeView({ title: 'Load failed' }), + lastActionErrorView: makeView({ title: 'Deposit failed' }), + }); + const { getByText, queryByText } = renderScreen(); + + expect(getByText('Deposit failed')).toBeTruthy(); + expect(queryByText('Load failed')).toBeNull(); + }); + + it('does not offer "Try again" for an action error', () => { + mockStore({ + lastActionErrorView: makeView({ + title: 'Deposit failed', + action: 'retry', + }), + }); + const { queryByText, getByLabelText } = renderScreen(); + + // Action errors must be re-fired from the action buttons themselves, + // so the banner falls back to its "Contact support" escape hatch. + expect(queryByText('Try again')).toBeNull(); + fireEvent.press(getByLabelText('Contact support')); + expect(fetchTrade).toHaveBeenCalledTimes(1); + expect(openURL).toHaveBeenCalledTimes(1); + }); + + it('clears the error from the banner "Go back" button', () => { + mockStore({ errorView: makeView({ action: 'go_back' }) }); + const { getByTestId } = renderScreen(); + + fireEvent.press(getByTestId('admin-error-banner-primary-go_back')); + + expect(clearErrorView).toHaveBeenCalledTimes(1); + }); + + it('signs out from the banner when the session expired', async () => { + mockStore({ + errorView: makeView({ + action: 'sign_out_required', + code: 'AUTH_ERROR', + }), + }); + const { getByTestId } = renderScreen(); + + await act(async () => { + fireEvent.press( + getByTestId('admin-error-banner-primary-sign_out_required'), + ); + }); + + expect(clearAuth).toHaveBeenCalledTimes(1); + }); + + it('opens a support mailto carrying the error code and request id', () => { + mockStore({ + errorView: makeView({ code: 'INTERNAL_ERROR', requestId: 'req-77' }), + }); + const { getByTestId } = renderScreen(); + + fireEvent.press(getByTestId('admin-error-banner-secondary')); + + expect(openURL).toHaveBeenCalledTimes(1); + const url = decodeURIComponent(openURL.mock.calls[0][0] as string); + expect(url).toMatch(/^mailto:/); + expect(url).toContain('trade detail'); + expect(url).toContain('Error code: INTERNAL_ERROR'); + expect(url).toContain('Request id: req-77'); + }); + }); +}); diff --git a/mobile/src/screens/WalletConnectScreen.test.tsx b/mobile/src/screens/WalletConnectScreen.test.tsx new file mode 100644 index 00000000..3b61c65f --- /dev/null +++ b/mobile/src/screens/WalletConnectScreen.test.tsx @@ -0,0 +1,403 @@ +/** + * WalletConnectScreen.test.tsx + * + * Covers the primary render states of WalletConnectScreen (first-time + * connect vs. returning user with biometric unlock) and its key + * interactions: the manual "paste your address" prompt, address + * validation, the challenge/verify exchange, biometric unlock, busy + * state handling and failure alerts. + */ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { ActivityIndicator, Alert } from 'react-native'; + +import WalletConnectScreen from './WalletConnectScreen'; +import { useAuthStore } from '../stores/authStore'; +import { authApi } from '../api/auth'; +import { + authenticateWithBiometrics, + isBiometricUnlockAvailable, +} from '../utils/biometrics'; + +jest.mock('../stores/authStore', () => ({ + useAuthStore: jest.fn(), +})); + +jest.mock('../api/auth', () => ({ + authApi: { + generateChallenge: jest.fn(), + verifyChallenge: jest.fn(), + }, +})); + +jest.mock('../utils/biometrics', () => ({ + authenticateWithBiometrics: jest.fn(), + isBiometricUnlockAvailable: jest.fn(), +})); + +const mockGenerateChallenge = authApi.generateChallenge as jest.Mock; +const mockVerifyChallenge = authApi.verifyChallenge as jest.Mock; +const mockAuthenticate = authenticateWithBiometrics as jest.Mock; +const mockBiometricsAvailable = isBiometricUnlockAvailable as jest.Mock; + +const WALLET = 'GDNM7WSJ7VIUVK2TSZ2OQES5XR2663TZEIBFXRDT56B5IRLHERVWSXMU'; +const UNLOCK_LABEL = + 'Unlock with Face ID or fingerprint as GDNM…SXMU'; + +type PromptCallback = (value?: string) => void | Promise; + +describe('WalletConnectScreen', () => { + const setWalletAddress = jest.fn(); + const setToken = jest.fn(); + const getRememberedWalletAddress = jest.fn(); + const navigation = { replace: jest.fn(), navigate: jest.fn() }; + let alertSpy: jest.SpyInstance; + let promptSpy: jest.SpyInstance; + + function renderScreen() { + return render( + , + ); + } + + /** Presses "Connect Wallet" and returns the callback handed to Alert.prompt. */ + function pressConnect(utils: ReturnType): PromptCallback { + fireEvent.press(utils.getByLabelText('Connect wallet')); + expect(promptSpy).toHaveBeenCalledTimes(1); + return promptSpy.mock.calls[0][2] as PromptCallback; + } + + /** Renders as a returning user and waits for the biometric button. */ + async function renderReturningUser() { + getRememberedWalletAddress.mockResolvedValue(WALLET); + mockBiometricsAvailable.mockResolvedValue(true); + const utils = renderScreen(); + await utils.findByLabelText(UNLOCK_LABEL); + return utils; + } + + beforeEach(() => { + jest.clearAllMocks(); + getRememberedWalletAddress.mockResolvedValue(null); + mockBiometricsAvailable.mockResolvedValue(false); + mockGenerateChallenge.mockResolvedValue({ challenge: 'challenge-xyz' }); + mockVerifyChallenge.mockResolvedValue({ token: 'jwt-token' }); + setToken.mockResolvedValue(undefined); + mockAuthenticate.mockResolvedValue({ success: true }); + (useAuthStore as unknown as jest.Mock).mockReturnValue({ + setWalletAddress, + setToken, + getRememberedWalletAddress, + }); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + promptSpy = jest.spyOn(Alert, 'prompt').mockImplementation(() => undefined); + }); + + afterEach(() => { + alertSpy.mockRestore(); + promptSpy.mockRestore(); + }); + + describe('render states', () => { + it('renders the hero and the connect card for a first-time user', async () => { + const { getByText, getByLabelText, queryByLabelText } = renderScreen(); + + expect(getByText('Amana')).toBeTruthy(); + expect(getByText(/Trust as a Service/)).toBeTruthy(); + expect(getByText('Connect your Stellar Wallet')).toBeTruthy(); + expect(getByText('Connect Wallet')).toBeTruthy(); + expect(getByLabelText('Connect wallet')).toBeTruthy(); + await waitFor(() => expect(getRememberedWalletAddress).toHaveBeenCalled()); + expect(queryByLabelText(UNLOCK_LABEL)).toBeNull(); + }); + + it('offers biometric unlock for a returning user on capable hardware', async () => { + const { getByText, getByLabelText } = await renderReturningUser(); + + expect(getByLabelText(UNLOCK_LABEL)).toBeTruthy(); + expect(getByText('🔐 Unlock as GDNM…SXMU')).toBeTruthy(); + // The manual path is demoted to a secondary label. + expect(getByText('Use a different wallet')).toBeTruthy(); + }); + + it('hides biometric unlock when the device has no usable biometrics', async () => { + getRememberedWalletAddress.mockResolvedValue(WALLET); + mockBiometricsAvailable.mockResolvedValue(false); + const { queryByLabelText, getByText } = renderScreen(); + + await waitFor(() => expect(mockBiometricsAvailable).toHaveBeenCalled()); + await act(async () => undefined); + + expect(queryByLabelText(UNLOCK_LABEL)).toBeNull(); + expect(getByText('Connect Wallet')).toBeTruthy(); + }); + + it('hides biometric unlock when there is no remembered wallet', async () => { + getRememberedWalletAddress.mockResolvedValue(null); + mockBiometricsAvailable.mockResolvedValue(true); + const { queryByText, getByText } = renderScreen(); + + await waitFor(() => expect(mockBiometricsAvailable).toHaveBeenCalled()); + await act(async () => undefined); + + expect(queryByText(/Unlock as/)).toBeNull(); + expect(getByText('Connect Wallet')).toBeTruthy(); + }); + + it('does not update state after unmounting mid-lookup', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + let resolveAddress: (v: string | null) => void = () => undefined; + getRememberedWalletAddress.mockReturnValue( + new Promise((resolve) => { + resolveAddress = resolve; + }), + ); + mockBiometricsAvailable.mockResolvedValue(true); + const { unmount } = renderScreen(); + + unmount(); + await act(async () => { + resolveAddress(WALLET); + }); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + }); + + describe('manual connect', () => { + it('prompts for the wallet address', () => { + const utils = renderScreen(); + + pressConnect(utils); + + expect(promptSpy).toHaveBeenCalledWith( + 'Enter Wallet Address', + expect.stringContaining('Stellar wallet public key'), + expect.any(Function), + 'plain-text', + ); + }); + + it('authenticates a valid address and replaces the route with TradeList', async () => { + const utils = renderScreen(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(WALLET); + }); + + expect(mockGenerateChallenge).toHaveBeenCalledWith(WALLET); + expect(mockVerifyChallenge).toHaveBeenCalledWith(WALLET, 'challenge-xyz'); + expect(setToken).toHaveBeenCalledWith('jwt-token'); + expect(setWalletAddress).toHaveBeenCalledWith(WALLET); + expect(navigation.replace).toHaveBeenCalledWith('TradeList'); + expect(alertSpy).not.toHaveBeenCalled(); + // Token is persisted before the wallet is marked connected. + expect(setToken.mock.invocationCallOrder[0]).toBeLessThan( + setWalletAddress.mock.invocationCallOrder[0], + ); + }); + + it.each([ + ['an address that does not start with G', `X${WALLET.slice(1)}`], + ['an address shorter than 56 characters', WALLET.slice(0, 55)], + ['an empty address', ''], + ['a cancelled prompt', undefined], + ])('rejects %s without calling the API', async (_label, input) => { + const utils = renderScreen(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(input); + }); + + expect(alertSpy).toHaveBeenCalledWith( + 'Invalid address', + 'Please enter a valid Stellar public key.', + ); + expect(mockGenerateChallenge).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + // The button is usable again. + expect( + utils.getByLabelText('Connect wallet').props.accessibilityState, + ).toMatchObject({ disabled: false, busy: false }); + }); + + it('shows the failure message and stays on screen when the challenge request fails', async () => { + mockGenerateChallenge.mockRejectedValueOnce(new Error('Network down')); + const utils = renderScreen(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(WALLET); + }); + + expect(alertSpy).toHaveBeenCalledWith('Connection failed', 'Network down'); + expect(setToken).not.toHaveBeenCalled(); + expect(setWalletAddress).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + expect( + utils.getByLabelText('Connect wallet').props.accessibilityState, + ).toMatchObject({ disabled: false, busy: false }); + }); + + it('shows the failure message when verification is rejected', async () => { + mockVerifyChallenge.mockRejectedValueOnce(new Error('Bad signature')); + const utils = renderScreen(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(WALLET); + }); + + expect(alertSpy).toHaveBeenCalledWith('Connection failed', 'Bad signature'); + expect(setToken).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + }); + + it('falls back to "Unknown error" when the failure carries no message', async () => { + mockGenerateChallenge.mockRejectedValueOnce(undefined); + const utils = renderScreen(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(WALLET); + }); + + expect(alertSpy).toHaveBeenCalledWith('Connection failed', 'Unknown error'); + }); + + it('shows a busy, disabled button while the prompt is open', () => { + const utils = renderScreen(); + + pressConnect(utils); + + expect( + utils.getByLabelText('Connect wallet').props.accessibilityState, + ).toMatchObject({ disabled: true, busy: true }); + expect(utils.UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + }); + + it('recovers if opening the prompt throws', () => { + promptSpy.mockImplementationOnce(() => { + throw new Error('prompt unsupported'); + }); + const utils = renderScreen(); + + fireEvent.press(utils.getByLabelText('Connect wallet')); + + expect( + utils.getByLabelText('Connect wallet').props.accessibilityState, + ).toMatchObject({ disabled: false, busy: false }); + }); + }); + + describe('biometric unlock', () => { + it('re-authenticates the remembered wallet after a successful biometric check', async () => { + const { getByLabelText } = await renderReturningUser(); + + await act(async () => { + fireEvent.press(getByLabelText(UNLOCK_LABEL)); + }); + + expect(mockAuthenticate).toHaveBeenCalledWith( + 'Unlock Amana to continue trading', + ); + expect(mockGenerateChallenge).toHaveBeenCalledWith(WALLET); + expect(mockVerifyChallenge).toHaveBeenCalledWith(WALLET, 'challenge-xyz'); + expect(setToken).toHaveBeenCalledWith('jwt-token'); + expect(setWalletAddress).toHaveBeenCalledWith(WALLET); + expect(navigation.replace).toHaveBeenCalledWith('TradeList'); + }); + + it('stays silent when the user cancels the biometric prompt', async () => { + mockAuthenticate.mockResolvedValueOnce({ + success: false, + error: 'user_cancel', + }); + const { getByLabelText } = await renderReturningUser(); + + await act(async () => { + fireEvent.press(getByLabelText(UNLOCK_LABEL)); + }); + + expect(alertSpy).not.toHaveBeenCalled(); + expect(mockGenerateChallenge).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + }); + + it('tells the user to retry on a non-cancel biometric failure', async () => { + mockAuthenticate.mockResolvedValueOnce({ + success: false, + error: 'lockout', + }); + const { getByLabelText } = await renderReturningUser(); + + await act(async () => { + fireEvent.press(getByLabelText(UNLOCK_LABEL)); + }); + + expect(alertSpy).toHaveBeenCalledWith( + 'Unlock failed', + 'Please try again or connect your wallet manually.', + ); + expect(mockGenerateChallenge).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + }); + + it('surfaces an API failure after a successful biometric check and re-enables the button', async () => { + mockGenerateChallenge.mockRejectedValueOnce(new Error('Server error')); + const { getByLabelText } = await renderReturningUser(); + + await act(async () => { + fireEvent.press(getByLabelText(UNLOCK_LABEL)); + }); + + expect(alertSpy).toHaveBeenCalledWith('Connection failed', 'Server error'); + expect(navigation.replace).not.toHaveBeenCalled(); + expect(getByLabelText(UNLOCK_LABEL).props.accessibilityState).not.toMatchObject( + { disabled: true }, + ); + }); + + it('disables both buttons while a biometric unlock is in progress', async () => { + let finish: (v: { success: boolean }) => void = () => undefined; + mockAuthenticate.mockReturnValueOnce( + new Promise<{ success: boolean }>((resolve) => { + finish = resolve; + }), + ); + const { getByLabelText } = await renderReturningUser(); + + act(() => { + fireEvent.press(getByLabelText(UNLOCK_LABEL)); + }); + + expect( + getByLabelText('Connect wallet').props.accessibilityState, + ).toMatchObject({ disabled: true }); + + await act(async () => { + finish({ success: true }); + }); + await waitFor(() => + expect(navigation.replace).toHaveBeenCalledWith('TradeList'), + ); + }); + + it('still lets a returning user connect a different wallet manually', async () => { + const utils = await renderReturningUser(); + const onSubmit = pressConnect(utils); + + await act(async () => { + await onSubmit(WALLET); + }); + + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(navigation.replace).toHaveBeenCalledWith('TradeList'); + }); + }); +}); diff --git a/mobile/src/services/freighterSigner.test.ts b/mobile/src/services/freighterSigner.test.ts new file mode 100644 index 00000000..1d5cdace --- /dev/null +++ b/mobile/src/services/freighterSigner.test.ts @@ -0,0 +1,356 @@ +import { + isFreighterAvailable, + signAndSubmitXdr, + signWithFreighter, + submitSignedTransaction, + TransactionSigningError, +} from './freighterSigner'; +import { STELLAR_NETWORK_PASSPHRASE, STELLAR_RPC_URL } from '../constants/stellar'; + +const mockIsConnected = jest.fn(); +const mockSignTransaction = jest.fn(); + +// Set to simulate the package failing to initialise at import time. +let mockLoadError: Error | null = null; + +jest.mock('@stellar/freighter-api', () => { + if (mockLoadError) throw mockLoadError; + return { + isConnected: (...args: unknown[]) => mockIsConnected(...args), + signTransaction: (...args: unknown[]) => mockSignTransaction(...args), + }; +}); + +const mockFetch = jest.fn(); + +const UNSIGNED = 'AAAAunsigned'; +const SIGNED = 'AAAAsigned'; +const ADDRESS = 'GDNM7WSJ7VIUVK2TSZ2OQES5XR2663TZEIBFXRDT56B5IRLHERVWSXMU'; + +function rpcResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => body, + }; +} + +async function failureOf(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e as TransactionSigningError; + } + throw new Error('expected promise to reject'); +} + +describe('freighterSigner', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsConnected.mockResolvedValue({ isConnected: true }); + mockSignTransaction.mockResolvedValue({ + signedTxXdr: SIGNED, + signerAddress: ADDRESS, + }); + mockFetch.mockResolvedValue( + rpcResponse({ result: { status: 'PENDING', hash: 'abc123' } }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.useRealTimers(); + }); + + describe('isFreighterAvailable', () => { + it('is true when Freighter reports a connection', async () => { + await expect(isFreighterAvailable()).resolves.toBe(true); + }); + + it('is false when Freighter is not connected', async () => { + mockIsConnected.mockResolvedValueOnce({ isConnected: false }); + + await expect(isFreighterAvailable()).resolves.toBe(false); + }); + + it('is false when Freighter reports an error (e.g. non-browser runtime)', async () => { + mockIsConnected.mockResolvedValueOnce({ + isConnected: false, + error: { code: -1, message: 'Node environment is not supported' }, + }); + + await expect(isFreighterAvailable()).resolves.toBe(false); + }); + + it('is false when the probe throws', async () => { + mockIsConnected.mockRejectedValueOnce(new Error('postMessage is not a function')); + + await expect(isFreighterAvailable()).resolves.toBe(false); + }); + + it('is false when the probe never answers', async () => { + jest.useFakeTimers(); + mockIsConnected.mockReturnValueOnce(new Promise(() => undefined)); + + const result = isFreighterAvailable(); + await jest.advanceTimersByTimeAsync(3000); + + await expect(result).resolves.toBe(false); + }); + + it('is false when the freighter package cannot be loaded', async () => { + mockLoadError = new Error('cannot initialise'); + jest.resetModules(); + + try { + await expect(isFreighterAvailable()).resolves.toBe(false); + const error = await failureOf(signWithFreighter(UNSIGNED)); + expect(error.code).toBe('WALLET_UNAVAILABLE'); + expect(mockSignTransaction).not.toHaveBeenCalled(); + } finally { + mockLoadError = null; + jest.resetModules(); + } + }); + }); + + describe('signWithFreighter', () => { + it('returns the signed XDR and signs for the configured network', async () => { + const result = await signWithFreighter(UNSIGNED, { address: ADDRESS }); + + expect(result).toBe(SIGNED); + expect(mockSignTransaction).toHaveBeenCalledWith(UNSIGNED, { + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + address: ADDRESS, + }); + }); + + it('defaults to the testnet passphrase', () => { + expect(STELLAR_NETWORK_PASSPHRASE).toBe('Test SDF Network ; September 2015'); + }); + + it('fails with WALLET_UNAVAILABLE without prompting when Freighter is not reachable', async () => { + mockIsConnected.mockResolvedValueOnce({ isConnected: false }); + + const error = await failureOf(signWithFreighter(UNSIGNED)); + + expect(error).toBeInstanceOf(TransactionSigningError); + expect(error.code).toBe('WALLET_UNAVAILABLE'); + expect(error.message).toMatch(/Copy the unsigned XDR/); + expect(mockSignTransaction).not.toHaveBeenCalled(); + }); + + it.each([ + 'User declined access', + 'The user rejected this request', + 'Request denied', + 'Cancelled by user', + ])('maps the wallet message %p to USER_REJECTED', async (message) => { + mockSignTransaction.mockResolvedValueOnce({ + signedTxXdr: '', + signerAddress: '', + error: { code: -4, message }, + }); + + const error = await failureOf(signWithFreighter(UNSIGNED)); + + expect(error.code).toBe('USER_REJECTED'); + expect(error.message).toBe(message); + }); + + it('maps any other wallet error to SIGNING_FAILED', async () => { + mockSignTransaction.mockResolvedValueOnce({ + signedTxXdr: '', + signerAddress: '', + error: { code: -1, message: 'The wallet encountered an internal error.' }, + }); + + const error = await failureOf(signWithFreighter(UNSIGNED)); + + expect(error.code).toBe('SIGNING_FAILED'); + expect(error.message).toBe('The wallet encountered an internal error.'); + }); + + it('fails with SIGNING_FAILED when the wallet returns no signed XDR', async () => { + mockSignTransaction.mockResolvedValueOnce({ + signedTxXdr: '', + signerAddress: ADDRESS, + }); + + const error = await failureOf(signWithFreighter(UNSIGNED)); + + expect(error.code).toBe('SIGNING_FAILED'); + expect(error.message).toMatch(/did not return a signed transaction/); + }); + + it('wraps a thrown wallet error as SIGNING_FAILED', async () => { + mockSignTransaction.mockRejectedValueOnce(new Error('extension crashed')); + + const error = await failureOf(signWithFreighter(UNSIGNED)); + + expect(error.code).toBe('SIGNING_FAILED'); + expect(error.message).toBe('extension crashed'); + }); + + it('gives up with SIGNING_FAILED when the user never answers', async () => { + jest.useFakeTimers(); + mockSignTransaction.mockReturnValueOnce(new Promise(() => undefined)); + + const pending = failureOf(signWithFreighter(UNSIGNED)); + await jest.advanceTimersByTimeAsync(120_000); + const error = await pending; + + expect(error.code).toBe('SIGNING_FAILED'); + expect(error.message).toMatch(/Timed out/); + }); + }); + + describe('submitSignedTransaction', () => { + it('POSTs a JSON-RPC sendTransaction request to the configured RPC', async () => { + const result = await submitSignedTransaction(SIGNED); + + expect(result).toEqual({ hash: 'abc123' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(STELLAR_RPC_URL); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(JSON.parse(init.body)).toEqual({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: { transaction: SIGNED }, + }); + }); + + it('defaults to the Soroban testnet RPC', () => { + expect(STELLAR_RPC_URL).toBe('https://soroban-testnet.stellar.org'); + }); + + it('treats DUPLICATE as success (the network already has the transaction)', async () => { + mockFetch.mockResolvedValueOnce( + rpcResponse({ result: { status: 'DUPLICATE', hash: 'dup456' } }), + ); + + await expect(submitSignedTransaction(SIGNED)).resolves.toEqual({ + hash: 'dup456', + }); + }); + + it('reports a busy network for TRY_AGAIN_LATER', async () => { + mockFetch.mockResolvedValueOnce( + rpcResponse({ result: { status: 'TRY_AGAIN_LATER' } }), + ); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toMatch(/busy/); + }); + + it('rejects an ERROR status', async () => { + mockFetch.mockResolvedValueOnce( + rpcResponse({ result: { status: 'ERROR', hash: 'bad789' } }), + ); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toMatch(/rejected the transaction/); + }); + + it('rejects a PENDING status that carries no hash', async () => { + mockFetch.mockResolvedValueOnce(rpcResponse({ result: { status: 'PENDING' } })); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + }); + + it('surfaces a JSON-RPC error message', async () => { + mockFetch.mockResolvedValueOnce( + rpcResponse({ error: { message: 'invalid transaction envelope' } }), + ); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toBe('invalid transaction envelope'); + }); + + it('surfaces a non-2xx HTTP response', async () => { + mockFetch.mockResolvedValueOnce(rpcResponse({}, { ok: false, status: 503 })); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toBe('Stellar RPC responded with HTTP 503'); + }); + + it('surfaces a network failure', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network request failed')); + + const error = await failureOf(submitSignedTransaction(SIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toBe('Network request failed'); + }); + + it('aborts and reports a timeout when the RPC never answers', async () => { + jest.useFakeTimers(); + mockFetch.mockImplementationOnce( + ( + _url: string, + init: { signal: { addEventListener: (e: string, cb: () => void) => void } }, + ) => + new Promise((_, reject) => { + init.signal.addEventListener('abort', () => + reject(new Error('Aborted')), + ); + }), + ); + + const pending = failureOf(submitSignedTransaction(SIGNED)); + await jest.advanceTimersByTimeAsync(20_000); + const error = await pending; + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(error.message).toMatch(/timed out/); + }); + }); + + describe('signAndSubmitXdr', () => { + it('signs, submits the signed XDR and returns the hash', async () => { + const result = await signAndSubmitXdr(UNSIGNED, { address: ADDRESS }); + + expect(result).toEqual({ hash: 'abc123' }); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.params.transaction).toBe(SIGNED); + }); + + it('does not submit anything when signing fails', async () => { + mockSignTransaction.mockResolvedValueOnce({ + signedTxXdr: '', + signerAddress: '', + error: { code: -4, message: 'User declined access' }, + }); + + const error = await failureOf(signAndSubmitXdr(UNSIGNED)); + + expect(error.code).toBe('USER_REJECTED'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('propagates a submission failure after a successful signature', async () => { + mockFetch.mockRejectedValueOnce(new Error('offline')); + + const error = await failureOf(signAndSubmitXdr(UNSIGNED)); + + expect(error.code).toBe('SUBMISSION_FAILED'); + expect(mockSignTransaction).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/mobile/src/services/freighterSigner.ts b/mobile/src/services/freighterSigner.ts new file mode 100644 index 00000000..838b9a68 --- /dev/null +++ b/mobile/src/services/freighterSigner.ts @@ -0,0 +1,232 @@ +import { STELLAR_NETWORK_PASSPHRASE, STELLAR_RPC_URL } from '../constants/stellar'; + +export type SigningErrorCode = + /** Freighter cannot be reached from this device (no extension / not installed). */ + | 'WALLET_UNAVAILABLE' + /** The user declined the signature request in the wallet. */ + | 'USER_REJECTED' + /** The wallet failed to produce a signed transaction. */ + | 'SIGNING_FAILED' + /** The signed transaction could not be submitted to the Stellar network. */ + | 'SUBMISSION_FAILED'; + +export class TransactionSigningError extends Error { + public readonly code: SigningErrorCode; + + constructor(code: SigningErrorCode, message: string) { + super(message); + this.name = 'TransactionSigningError'; + this.code = code; + } +} + +export interface SubmittedTransaction { + /** Transaction hash returned by the Soroban RPC. */ + hash: string; +} + +/** How long to wait for Freighter to answer a connection probe. */ +const AVAILABILITY_TIMEOUT_MS = 3000; +/** How long the user has to approve the request inside the wallet. */ +const SIGN_TIMEOUT_MS = 120_000; +/** How long to wait for the RPC to accept the transaction. */ +const SUBMIT_TIMEOUT_MS = 20_000; + +const UNAVAILABLE_MESSAGE = + "Freighter isn't available on this device. Copy the unsigned XDR and sign it with a Stellar wallet instead."; + +type FreighterApi = typeof import('@stellar/freighter-api'); + +/** + * `@stellar/freighter-api` talks to the Freighter browser extension. It is + * required lazily (Metro only evaluates a module on its first `require`) so + * that merely importing a screen never touches it, and so that a runtime + * where the package cannot initialise degrades to `WALLET_UNAVAILABLE` + * instead of crashing the app. + */ +function loadFreighter(): FreighterApi { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@stellar/freighter-api') as FreighterApi; + } catch { + throw new TransactionSigningError('WALLET_UNAVAILABLE', UNAVAILABLE_MESSAGE); + } +} + +/** Rejects with `onTimeout()` if `promise` has not settled within `ms`. */ +function withTimeout( + promise: Promise, + ms: number, + onTimeout: () => Error, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(onTimeout()), ms); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === 'string' && error) return error; + if ( + typeof error === 'object' && + error !== null && + typeof (error as { message?: unknown }).message === 'string' && + (error as { message: string }).message + ) { + return (error as { message: string }).message; + } + return fallback; +} + +const REJECTION_PATTERN = /reject|declin|denied|cancel/i; + +/** True when Freighter can be reached from the current runtime. */ +export async function isFreighterAvailable(): Promise { + try { + const freighter = loadFreighter(); + const result = await withTimeout( + freighter.isConnected(), + AVAILABILITY_TIMEOUT_MS, + () => new Error('Freighter connection check timed out'), + ); + return !result.error && result.isConnected === true; + } catch { + return false; + } +} + +/** + * Asks Freighter to sign `unsignedXdr` for the configured network. + * + * @throws {TransactionSigningError} `WALLET_UNAVAILABLE`, `USER_REJECTED` or + * `SIGNING_FAILED`. + */ +export async function signWithFreighter( + unsignedXdr: string, + options: { address?: string } = {}, +): Promise { + if (!(await isFreighterAvailable())) { + throw new TransactionSigningError('WALLET_UNAVAILABLE', UNAVAILABLE_MESSAGE); + } + const freighter = loadFreighter(); + + let result: Awaited>; + try { + result = await withTimeout( + freighter.signTransaction(unsignedXdr, { + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + address: options.address, + }), + SIGN_TIMEOUT_MS, + () => new Error('Timed out waiting for the wallet to sign'), + ); + } catch (error) { + throw new TransactionSigningError( + 'SIGNING_FAILED', + errorMessage(error, 'The wallet failed to sign the transaction.'), + ); + } + + if (result.error) { + const message = errorMessage(result.error, 'Signing failed.'); + throw new TransactionSigningError( + REJECTION_PATTERN.test(message) ? 'USER_REJECTED' : 'SIGNING_FAILED', + message, + ); + } + if (!result.signedTxXdr) { + throw new TransactionSigningError( + 'SIGNING_FAILED', + 'The wallet did not return a signed transaction.', + ); + } + return result.signedTxXdr; +} + +interface SendTransactionResponse { + error?: { message?: string }; + result?: { + status?: 'PENDING' | 'DUPLICATE' | 'TRY_AGAIN_LATER' | 'ERROR'; + hash?: string; + }; +} + +/** + * Submits a signed transaction through the Soroban RPC `sendTransaction` + * method (the same call the web frontend makes). + * + * `PENDING` and `DUPLICATE` both mean the network has the transaction, so + * both resolve with its hash. + * + * @throws {TransactionSigningError} `SUBMISSION_FAILED`. + */ +export async function submitSignedTransaction( + signedTxXdr: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SUBMIT_TIMEOUT_MS); + + let body: SendTransactionResponse; + try { + const response = await fetch(STELLAR_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'sendTransaction', + params: { transaction: signedTxXdr }, + }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Stellar RPC responded with HTTP ${response.status}`); + } + body = (await response.json()) as SendTransactionResponse; + } catch (error) { + throw new TransactionSigningError( + 'SUBMISSION_FAILED', + controller.signal.aborted + ? 'Submitting the transaction timed out. Check your connection and try again.' + : errorMessage(error, 'Could not reach the Stellar network.'), + ); + } finally { + clearTimeout(timer); + } + + if (body.error) { + throw new TransactionSigningError( + 'SUBMISSION_FAILED', + errorMessage(body.error, 'The Stellar network rejected the transaction.'), + ); + } + + const status = body.result?.status; + const hash = body.result?.hash; + if ((status === 'PENDING' || status === 'DUPLICATE') && hash) { + return { hash }; + } + if (status === 'TRY_AGAIN_LATER') { + throw new TransactionSigningError( + 'SUBMISSION_FAILED', + 'The Stellar network is busy. Try again in a moment.', + ); + } + throw new TransactionSigningError( + 'SUBMISSION_FAILED', + 'The Stellar network rejected the transaction.', + ); +} + +/** Signs `unsignedXdr` with Freighter and submits it to the network. */ +export async function signAndSubmitXdr( + unsignedXdr: string, + options: { address?: string } = {}, +): Promise { + const signedTxXdr = await signWithFreighter(unsignedXdr, options); + return submitSignedTransaction(signedTxXdr); +} diff --git a/mobile/src/stores/authStore.test.ts b/mobile/src/stores/authStore.test.ts index 77c0c9da..43810aff 100644 --- a/mobile/src/stores/authStore.test.ts +++ b/mobile/src/stores/authStore.test.ts @@ -3,6 +3,7 @@ import * as SecureStore from 'expo-secure-store'; import { useAuthStore, AuthRole } from './authStore'; import { setAuthToken } from '../api/client'; +import { LaunchArguments } from 'react-native-launch-arguments'; jest.mock('expo-secure-store', () => ({ setItemAsync: jest.fn(), @@ -14,6 +15,10 @@ jest.mock('../api/client', () => ({ setAuthToken: jest.fn(), })); +jest.mock('react-native-launch-arguments', () => ({ + LaunchArguments: { value: jest.fn(() => ({})) }, +})); + const mockSetItemAsync = SecureStore.setItemAsync as jest.MockedFunction< typeof SecureStore.setItemAsync >; @@ -24,6 +29,9 @@ const mockDeleteItemAsync = SecureStore.deleteItemAsync as jest.MockedFunction< typeof SecureStore.deleteItemAsync >; const mockSetAuthToken = setAuthToken as jest.MockedFunction; +const mockLaunchArguments = LaunchArguments.value as jest.MockedFunction< + typeof LaunchArguments.value +>; function resetStore(): void { act(() => { @@ -42,6 +50,7 @@ describe('useAuthStore', () => { beforeEach(() => { resetStore(); jest.clearAllMocks(); + mockLaunchArguments.mockReturnValue({}); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mockSetItemAsync.mockResolvedValue(); mockGetItemAsync.mockResolvedValue(null); @@ -171,6 +180,24 @@ describe('useAuthStore', () => { }); describe('getToken', () => { + it('honors Detox E2E launch arguments after boolean parsing', async () => { + const mockToken = 'e2e-jwt-token'; + mockLaunchArguments.mockReturnValue({ + E2E_MODE: true, + E2E_MOCK_JWT: mockToken, + E2E_MOCK_WALLET_ADDRESS: 'G_E2E_WALLET', + }); + + let returnedToken: string | null = null; + await act(async () => { + returnedToken = await useAuthStore.getState().getToken(); + }); + + expect(returnedToken).toBe(mockToken); + expect(mockSetItemAsync).toHaveBeenCalledWith('amana_token', mockToken); + expect(useAuthStore.getState().walletAddress).toBe('G_E2E_WALLET'); + }); + it('retrieves token from SecureStore, updates apiClient and state, and returns it', async () => { const storedToken = 'stored-jwt-token'; mockGetItemAsync.mockResolvedValueOnce(storedToken); diff --git a/mobile/src/stores/authStore.ts b/mobile/src/stores/authStore.ts index eee28305..3876841d 100644 --- a/mobile/src/stores/authStore.ts +++ b/mobile/src/stores/authStore.ts @@ -1,10 +1,26 @@ import { create } from 'zustand'; import * as SecureStore from 'expo-secure-store'; +import { LaunchArguments } from 'react-native-launch-arguments'; import { setAuthToken } from '../api/client'; const REMEMBERED_WALLET_KEY = 'amana_remembered_wallet'; +interface E2ELaunchArguments { + E2E_MODE?: string | boolean; + E2E_MOCK_JWT?: string; + E2E_MOCK_WALLET_ADDRESS?: string; +} + +function getE2ELaunchArguments(): E2ELaunchArguments { + try { + return LaunchArguments.value(); + } catch { + // The native bridge is unavailable in unit-test environments. + return {}; + } +} + export type AuthRole = 'admin' | 'user' | null; interface AuthState { @@ -65,6 +81,30 @@ export const useAuthStore = create((set) => ({ getToken: async () => { try { + const launchArgs = getE2ELaunchArguments(); + + // Detox launch arguments are the source of truth for E2E auth. Clear + // persisted state when a test explicitly launches unauthenticated so + // tests remain isolated across app reloads. + // react-native-launch-arguments parses JSON-looking values, so the + // string passed by Detox (`true`) is exposed as the boolean `true`. + if (launchArgs.E2E_MODE === true || launchArgs.E2E_MODE === 'true') { + const mockToken = launchArgs.E2E_MOCK_JWT; + const walletAddress = launchArgs.E2E_MOCK_WALLET_ADDRESS ?? null; + + if (mockToken) { + await SecureStore.setItemAsync('amana_token', mockToken); + setAuthToken(mockToken); + set({ token: mockToken, walletAddress }); + return mockToken; + } + + await SecureStore.deleteItemAsync('amana_token'); + setAuthToken(null); + set({ token: null, walletAddress: null, role: null }); + return null; + } + const token = await SecureStore.getItemAsync('amana_token'); setAuthToken(token); set({ token }); diff --git a/mobile/src/types/env.d.ts b/mobile/src/types/env.d.ts index 21e4a7f1..a993883d 100644 --- a/mobile/src/types/env.d.ts +++ b/mobile/src/types/env.d.ts @@ -4,6 +4,7 @@ declare const process: { EXPO_PUBLIC_API_VERSION_PREFIX?: string; EXPO_PUBLIC_ANALYTICS_ENABLED?: string; EXPO_PUBLIC_STELLAR_NETWORK?: string; + EXPO_PUBLIC_STELLAR_RPC_URL?: string; EXPO_PUBLIC_PUSH_PROVIDER?: string; [key: string]: string | undefined; }; diff --git a/scripts/verify-test-determinism.sh b/scripts/verify-test-determinism.sh index 870e0090..c027a4cd 100644 --- a/scripts/verify-test-determinism.sh +++ b/scripts/verify-test-determinism.sh @@ -30,6 +30,13 @@ SNAPSHOT_DIR="$CONTRACT_DIR/test_snapshots" LOG_DIR=$(mktemp -d) trap 'rm -rf "$LOG_DIR"' EXIT +if [[ ! -d "$CONTRACT_DIR" ]]; then + echo "❌ Determinism verification FAILED." + echo "" + echo " Contract directory not found: $CONTRACT_DIR" + exit 1 +fi + # Strip per-run timing so only the outcome counts are compared. summary() { grep '^test result:' "$1" | sed -E 's/; finished in [0-9.]+s$//' | sort