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