diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59ed0498..b4681f62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,23 @@ jobs: - run: npx tsc --noEmit - run: npm run build + mobile: + name: Mobile — typecheck & test + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend/mobile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/mobile/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + lighthouse-ci: name: Wallet PWA — Lighthouse performance budget runs-on: ubuntu-latest diff --git a/frontend/mobile/README.md b/frontend/mobile/README.md index d42182d8..7bd5f28d 100644 --- a/frontend/mobile/README.md +++ b/frontend/mobile/README.md @@ -1,8 +1,8 @@ # Veil Mobile -Expo (expo-router + TypeScript) mobile app for Veil. This is the bootable shell — -a single placeholder home route (`app/index.tsx`) proving the toolchain runs. No -wallet logic or SDK is wired up yet. +Expo (expo-router + TypeScript) mobile app for Veil. The screens are still +placeholders — no wallet SDK is wired up yet — but the routes exist so deep +links have somewhere to land. ## Getting started @@ -224,3 +224,69 @@ Passkeys need platform association before they resolve on a device: an `associatedDomains` entry (`webcredentials:`) for iOS and a matching `assetlinks.json` on the domain for Android. `EXPO_PUBLIC_PASSKEY_RP_ID` must be the same relying-party id the wallet's passkey was registered against. + +## Deep linking + +Three URL families open the app, and all three resolve to the same in-app routes: + +| Incoming URL | Resolves to | +| --- | --- | +| `veil://pay?to=G…&amount=10` | `/pay` → `/send`, prefilled | +| `https://app.veil.xyz/receive` | `/receive` | +| `web+stellar:pay?destination=G…&amount=10` | `/pay`, raw URI preserved as `uri` | +| anything else | `/` | + +`app/+native-intent.ts` is called by expo-router for every inbound link, on a +cold start (`initial: true`) and on a warm resume (`initial: false`) alike, which +is what makes the two behave identically. It delegates to `resolveDeepLink()` in +`lib/deepLinks.ts`. + +Inbound links are untrusted — any app, web page, or QR code can send one — so the +resolver matches a fixed allowlist of routes and copies only the query parameters +each route declares. Foreign hosts, unknown schemes, unknown paths, and +over-long URLs all fall back to `/` instead of navigating. + +Full SEP-7 validation (address checksums, amount ranges, hostile callbacks) is +the job of the handler in backlog #38. `sdk/src/sep7.ts` already implements it +for the web wallet; the raw URI is forwarded to `/pay` as `uri` so that handler +can parse the original request unmodified. + +### Testing links locally + +The schemes are only registered in a dev-client or standalone build — deep links +do not reach the app through Expo Go. + +```bash +# Android (emulator or device) +adb shell am start -W -a android.intent.action.VIEW \ + -d "veil://pay?to=GABC&amount=10" xyz.veil.wallet +adb shell am start -W -a android.intent.action.VIEW \ + -d "https://app.veil.xyz/receive" xyz.veil.wallet + +# iOS simulator +xcrun simctl openurl booted "veil://pay?to=GABC&amount=10" +xcrun simctl openurl booted "https://app.veil.xyz/receive" +``` + +Run each command twice: once with the app force-quit (cold start) and once with +it backgrounded (warm resume). Both must land on the same screen. + +### Universal / app link setup + +Two verification files are served by the wallet web app from +`frontend/wallet/public/.well-known/`. Both currently carry placeholders that +must be replaced before a store build, or the platforms will silently keep +opening links in the browser: + +- `apple-app-site-association` — replace `APPLE_TEAM_ID` with the Apple Developer + Team ID that signs `xyz.veil.wallet`. The file must be served over HTTPS as + `application/json`, with no redirect and no `.json` extension. +- `assetlinks.json` — replace `ANDROID_RELEASE_CERT_SHA256_FINGERPRINT` with the + SHA-256 fingerprint of the release signing certificate + (`keytool -list -v -keystore -alias `). Add the Play App + Signing fingerprint too if the app is distributed through Google Play. + +`frontend/wallet/next.config.js` pins the `Content-Type` on both files. After +deploying, verify with Apple's CDN +(`https://app-site-association.cdn-apple.com/a/v1/app.veil.xyz`) and Google's +[Digital Asset Links API](https://developers.google.com/digital-asset-links/tools/generator). diff --git a/frontend/mobile/app.config.ts b/frontend/mobile/app.config.ts new file mode 100644 index 00000000..cc5f602c --- /dev/null +++ b/frontend/mobile/app.config.ts @@ -0,0 +1,105 @@ +import type { ExpoConfig } from 'expo/config'; + +/** + * Expo config, replacing `app.json` so the deep-linking surface can carry the + * reasoning behind it. + * + * The values below are duplicated from `lib/deepLinks.ts` rather than imported: + * Expo transpiles this file on its own and then `require`s it, so a relative + * import of a sibling TypeScript module fails to resolve at config-load time. + * `lib/__tests__/appConfig.test.ts` asserts the two stay in agreement — the + * config and the resolver drifting apart is exactly how deep links break + * silently. + * + * Universal links additionally require the two verification files served by the + * wallet web app from `frontend/wallet/public/.well-known/`; see + * `frontend/mobile/README.md` for the values to fill in before a store build. + */ + +/** Custom URL scheme registered by the app. Mirrors `DEEP_LINK_SCHEME`. */ +const DEEP_LINK_SCHEME = 'veil'; + +/** Hosts claimed as universal / app links. Mirrors `ASSOCIATED_DOMAINS`. */ +const ASSOCIATED_DOMAINS = ['app.veil.xyz']; + +/** SEP-7 URI scheme, without the trailing colon. Mirrors `SEP7_SCHEME`. */ +const SEP7_SCHEME = 'web+stellar'; + +const BUNDLE_IDENTIFIER = 'xyz.veil.wallet'; + +/** + * Paths claimed as universal / app links. Kept narrow on purpose: every path + * listed here stops opening in the browser once the app is installed, so the + * marketing site and docs must keep working as web pages. + */ +const LINKED_PATHS = ['pay', 'send', 'receive', 'create-wallet']; + +const config: ExpoConfig = { + name: 'Veil', + slug: 'veil-mobile', + version: '0.1.0', + orientation: 'portrait', + icon: './assets/images/icon.png', + // `veil://` is the app's own scheme; `web+stellar:` is claimed so SEP-7 + // payment requests (backlog #38) open here too. Expo turns both into + // CFBundleURLTypes on iOS and BROWSABLE intent filters on Android at prebuild. + scheme: [DEEP_LINK_SCHEME, SEP7_SCHEME], + userInterfaceStyle: 'automatic', + ios: { + icon: './assets/expo.icon', + bundleIdentifier: BUNDLE_IDENTIFIER, + associatedDomains: ASSOCIATED_DOMAINS.map((domain) => `applinks:${domain}`), + }, + android: { + package: BUNDLE_IDENTIFIER, + adaptiveIcon: { + backgroundColor: '#E6F4FE', + foregroundImage: './assets/images/android-icon-foreground.png', + backgroundImage: './assets/images/android-icon-background.png', + monochromeImage: './assets/images/android-icon-monochrome.png', + }, + predictiveBackGestureEnabled: false, + intentFilters: [ + // Verified app links. `autoVerify` makes Android fetch + // https://app.veil.xyz/.well-known/assetlinks.json at install time; if the + // fingerprint there does not match the installed build, links keep opening + // in the browser rather than failing outright. + { + action: 'VIEW', + autoVerify: true, + category: ['BROWSABLE', 'DEFAULT'], + data: ASSOCIATED_DOMAINS.flatMap((host) => + LINKED_PATHS.map((path) => ({ scheme: 'https', host, pathPrefix: `/${path}` })), + ), + }, + ], + }, + web: { + output: 'static', + favicon: './assets/images/favicon.png', + }, + plugins: [ + 'expo-router', + [ + 'expo-splash-screen', + { + backgroundColor: '#208AEF', + image: './assets/images/splash-icon.png', + imageWidth: 76, + }, + ], + 'expo-secure-store', + [ + 'expo-camera', + { + cameraPermission: 'Veil uses the camera to scan WalletConnect QR codes.', + }, + ], + ], + experiments: { + typedRoutes: true, + reactCompiler: true, + }, +}; + +export default config; diff --git a/frontend/mobile/app.json b/frontend/mobile/app.json deleted file mode 100644 index 6d223d3c..00000000 --- a/frontend/mobile/app.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "expo": { - "name": "Veil", - "slug": "veil-mobile", - "version": "0.1.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "veil", - "userInterfaceStyle": "automatic", - "ios": { - "icon": "./assets/expo.icon" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" - }, - "predictiveBackGestureEnabled": false - }, - "web": { - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "backgroundColor": "#208AEF", - "image": "./assets/images/splash-icon.png", - "imageWidth": 76 - } - ], - "expo-secure-store", - [ - "expo-camera", - { - "cameraPermission": "Veil uses the camera to scan WalletConnect QR codes." - } - ] - ], - "experiments": { - "typedRoutes": true, - "reactCompiler": true - } - } -} diff --git a/frontend/mobile/app/(tabs)/send.tsx b/frontend/mobile/app/(tabs)/send.tsx index 12ca935e..4a6fb217 100644 --- a/frontend/mobile/app/(tabs)/send.tsx +++ b/frontend/mobile/app/(tabs)/send.tsx @@ -1,7 +1,29 @@ import { ScreenScaffold, ComingSoonBadge, NavRow, colors } from '@/components/ScreenScaffold'; +import { useLocalSearchParams } from 'expo-router'; import { Text, View, StyleSheet } from 'react-native'; +/** + * Send tab. + * + * This route owns the prefill contract deep links depend on — `to`, `amount`, + * `asset` and `memo` arrive as query parameters from `veil://send`, + * `https://app.veil.xyz/send`, or a SEP-7 request forwarded by `/pay`. The + * fields are read-only placeholders until the send UI lands, but the values + * are surfaced here so a deep link is visibly carried through end to end. + */ export default function SendTab() { + const params = useLocalSearchParams<{ + to?: string; + amount?: string; + asset?: string; + memo?: string; + }>(); + + const recipient = firstValue(params.to); + const amount = firstValue(params.amount); + const asset = firstValue(params.asset) || 'XLM'; + const memo = firstValue(params.memo); + return ( Recipient - G… or C… address, contact, or @handle + + {recipient || 'G… or C… address, contact, or @handle'} + Amount - 0.00 + + {amount ? `${amount} ${asset}` : '0.00'} + + {memo ? ( + + Memo + + {memo} + + + ) : null} + @@ -27,6 +62,12 @@ export default function SendTab() { ); } +/** expo-router yields `string | string[]` for a repeated query key. */ +function firstValue(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? ''; + return value ?? ''; +} + const styles = StyleSheet.create({ card: { padding: 18, diff --git a/frontend/mobile/app/+native-intent.ts b/frontend/mobile/app/+native-intent.ts new file mode 100644 index 00000000..05ddcf6e --- /dev/null +++ b/frontend/mobile/app/+native-intent.ts @@ -0,0 +1,25 @@ +import { FALLBACK_ROUTE, resolveDeepLink } from '../lib/deepLinks'; + +/** + * expo-router calls this for every inbound deep link before it builds any + * navigation state — on a cold start (`initial: true`) and on a warm resume + * (`initial: false`) alike. Returning a normalised in-app path here is what + * makes both paths land on the same screen. + * + * It runs during launch, so it must never throw and must never block: + * {@link resolveDeepLink} is pure, synchronous, and falls back to the home + * route instead of raising. + */ +export function redirectSystemPath({ + path, + initial, +}: { + path: string; + initial: boolean; +}): string { + try { + return resolveDeepLink(path, { initial }); + } catch { + return FALLBACK_ROUTE; + } +} diff --git a/frontend/mobile/app/create-wallet.tsx b/frontend/mobile/app/create-wallet.tsx new file mode 100644 index 00000000..4ba3391b --- /dev/null +++ b/frontend/mobile/app/create-wallet.tsx @@ -0,0 +1,103 @@ +import { useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { useTheme } from "../hooks/useTheme"; +import type { ThemeColors } from "../lib/theme"; + +/** + * Placeholder wallet creation screen. The real passkey registration and + * factory deploy land with the SDK wiring; this route exists so onboarding + * links (`veil://create-wallet`) and the home screen have a destination. + */ +export default function CreateWallet() { + const router = useRouter(); + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const [status, setStatus] = useState<"idle" | "creating" | "created">("idle"); + + const handleCreate = () => { + setStatus("creating"); + // Stands in for passkey registration + on-chain deploy. + setTimeout(() => setStatus("created"), 400); + }; + + return ( + + Create your wallet + + Your device biometrics guard the wallet. No seed phrase to write down. + + + {status === "created" ? ( + <> + + Wallet created + + router.replace("/")} + style={styles.button} + > + Continue + + + ) : ( + + + {status === "creating" ? "Waiting for biometric…" : "Create wallet"} + + + )} + + ); +} + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.background, + gap: 16, + padding: 24, + }, + title: { + color: colors.textStrong, + fontSize: 24, + fontWeight: "700", + }, + caption: { + color: colors.textSecondary, + fontSize: 14, + textAlign: "center", + }, + success: { + color: colors.textStrong, + fontSize: 18, + fontWeight: "600", + }, + button: { + backgroundColor: colors.accent, + borderRadius: 12, + marginTop: 8, + paddingHorizontal: 28, + paddingVertical: 14, + }, + buttonDisabled: { + opacity: 0.6, + }, + buttonText: { + color: colors.onAccent, + fontSize: 16, + fontWeight: "600", + }, + }); diff --git a/frontend/mobile/app/pay.tsx b/frontend/mobile/app/pay.tsx new file mode 100644 index 00000000..eabcc644 --- /dev/null +++ b/frontend/mobile/app/pay.tsx @@ -0,0 +1,29 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; + +/** + * Entry point for inbound payment requests: `veil://pay?…`, + * `https://app.veil.xyz/pay?…`, and SEP-7 `web+stellar:pay?…` URIs, all of + * which `lib/deepLinks.ts` normalises onto this route. + * + * It deliberately holds no UI of its own — it hands the request to the send + * screen so the form arrives prefilled. Full SEP-7 validation of the original + * URI (address checksums, amount ranges, callback safety) is the job of the + * handler in backlog #38, which will read the raw `uri` parameter forwarded + * here and can then replace this redirect with a confirmation step. + */ +export default function Pay() { + const params = useLocalSearchParams<{ + to?: string; + amount?: string; + asset?: string; + memo?: string; + }>(); + + const forwarded: Record = {}; + for (const key of ["to", "amount", "asset", "memo"] as const) { + const value = params[key]; + if (typeof value === "string" && value) forwarded[key] = value; + } + + return ; +} diff --git a/frontend/mobile/lib/__tests__/appConfig.test.ts b/frontend/mobile/lib/__tests__/appConfig.test.ts new file mode 100644 index 00000000..7c2e60a9 --- /dev/null +++ b/frontend/mobile/lib/__tests__/appConfig.test.ts @@ -0,0 +1,95 @@ +import config from '../../app.config'; +import { + ASSOCIATED_DOMAINS, + DEEP_LINK_SCHEME, + FALLBACK_ROUTE, + SEP7_SCHEME, + resolveDeepLink, +} from '../deepLinks'; + +/** + * `app.config.ts` cannot import from `lib/deepLinks.ts` — Expo transpiles the + * config file alone and then requires it, so a relative TypeScript import fails + * to resolve at config-load time. These tests are what keeps the duplicated + * constants honest: a scheme registered natively but unhandled at runtime (or + * the reverse) is a deep link that silently does nothing. + */ + +const schemes = Array.isArray(config.scheme) + ? config.scheme + : config.scheme + ? [config.scheme] + : []; + +const appLinkFilter = (config.android?.intentFilters ?? []).find((filter) => filter.autoVerify); + +// Expo types `data` as one entry or an array of them; normalise to an array. +const rawData = appLinkFilter?.data; +const appLinkData = rawData ? (Array.isArray(rawData) ? rawData : [rawData]) : []; + +describe('app.config.ts — schemes', () => { + it('registers the scheme the resolver handles', () => { + expect(schemes).toContain(DEEP_LINK_SCHEME); + }); + + it('registers the SEP-7 scheme', () => { + expect(schemes).toContain(SEP7_SCHEME); + }); + + it('registers nothing the resolver would ignore', () => { + for (const scheme of schemes) { + expect(resolveDeepLink(`${scheme}:pay?destination=G`)).not.toBe(FALLBACK_ROUTE); + } + }); +}); + +describe('app.config.ts — associated domains', () => { + it('declares an applinks entry per associated domain', () => { + expect(config.ios?.associatedDomains).toEqual( + ASSOCIATED_DOMAINS.map((domain) => `applinks:${domain}`), + ); + }); + + it('verifies the same hosts on Android', () => { + const hosts = appLinkData.map((entry) => entry.host); + expect(new Set(hosts)).toEqual(new Set(ASSOCIATED_DOMAINS)); + }); + + it('claims https links only, and marks them for verification', () => { + expect(appLinkFilter?.autoVerify).toBe(true); + for (const entry of appLinkData) { + expect(entry.scheme).toBe('https'); + } + }); + + it('requires BROWSABLE so links from a browser reach the app', () => { + expect(appLinkFilter?.category).toContain('BROWSABLE'); + }); +}); + +describe('app.config.ts — claimed paths', () => { + it('claims only paths the resolver routes', () => { + // Collected rather than asserted in the loop so a failure names every + // offending URL at once instead of stopping at the first. + const claimedButNotRouted = appLinkData + .map((entry) => `https://${entry.host}${entry.pathPrefix}`) + .filter((url) => resolveDeepLink(url) === FALLBACK_ROUTE); + + expect(claimedButNotRouted).toEqual([]); + }); + + it('claims the payment-request path, which is the point of the exercise', () => { + const prefixes = appLinkData.map((entry) => entry.pathPrefix); + expect(prefixes).toContain('/pay'); + }); +}); + +describe('app.config.ts — identifiers', () => { + it('uses the same identifier on both platforms', () => { + expect(config.ios?.bundleIdentifier).toBe(config.android?.package); + }); + + it('sets an identifier at all, without which neither link type can be claimed', () => { + expect(config.ios?.bundleIdentifier).toBeTruthy(); + }); +}); diff --git a/frontend/mobile/lib/__tests__/deepLinks.test.ts b/frontend/mobile/lib/__tests__/deepLinks.test.ts new file mode 100644 index 00000000..b8c670f1 --- /dev/null +++ b/frontend/mobile/lib/__tests__/deepLinks.test.ts @@ -0,0 +1,148 @@ +import { FALLBACK_ROUTE, MAX_DEEP_LINK_LENGTH, resolveDeepLink } from '../deepLinks'; + +const DESTINATION = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +describe('resolveDeepLink — veil:// custom scheme', () => { + it('routes a bare screen link', () => { + expect(resolveDeepLink('veil://receive')).toBe('/receive'); + }); + + it('forwards known query parameters', () => { + expect(resolveDeepLink(`veil://pay?to=${DESTINATION}&amount=10`)).toBe( + `/pay?to=${DESTINATION}&amount=10`, + ); + }); + + it('accepts the host-less form', () => { + expect(resolveDeepLink('veil:send')).toBe('/send'); + }); + + it('tolerates a trailing slash and mixed case', () => { + expect(resolveDeepLink('veil://Receive/')).toBe('/receive'); + }); + + it('drops the fragment', () => { + expect(resolveDeepLink('veil://send?amount=5#top')).toBe('/send?amount=5'); + }); + + it('sends an unknown screen to the fallback route', () => { + expect(resolveDeepLink('veil://settings/admin')).toBe(FALLBACK_ROUTE); + }); + + it('sends the scheme root to the fallback route', () => { + expect(resolveDeepLink('veil://')).toBe(FALLBACK_ROUTE); + }); +}); + +describe('resolveDeepLink — universal / app links', () => { + it('routes an associated-domain link', () => { + expect(resolveDeepLink(`https://app.veil.xyz/pay?to=${DESTINATION}`)).toBe( + `/pay?to=${DESTINATION}`, + ); + }); + + it('ignores the port', () => { + expect(resolveDeepLink('https://app.veil.xyz:443/receive')).toBe('/receive'); + }); + + it('matches the host case-insensitively', () => { + expect(resolveDeepLink('https://APP.VEIL.XYZ/send')).toBe('/send'); + }); + + it('rejects a foreign host', () => { + expect(resolveDeepLink('https://evil.example/pay?to=ATTACKER')).toBe(FALLBACK_ROUTE); + }); + + it('rejects a look-alike subdomain', () => { + expect(resolveDeepLink('https://app.veil.xyz.evil.example/pay')).toBe(FALLBACK_ROUTE); + }); + + it('rejects userinfo smuggling', () => { + expect(resolveDeepLink('https://app.veil.xyz@evil.example/pay')).toBe(FALLBACK_ROUTE); + }); +}); + +describe('resolveDeepLink — SEP-7 payment requests', () => { + it('maps SEP-7 fields onto the pay route and keeps the raw URI', () => { + const uri = `web+stellar:pay?destination=${DESTINATION}&amount=12.5&asset_code=USDC`; + const target = resolveDeepLink(uri); + + expect(target.startsWith('/pay?')).toBe(true); + + const query = new URLSearchParams(target.slice(target.indexOf('?') + 1)); + expect(query.get('to')).toBe(DESTINATION); + expect(query.get('amount')).toBe('12.5'); + expect(query.get('asset')).toBe('USDC'); + expect(query.get('uri')).toBe(uri); + }); + + it('forwards the raw URI even when no fields map', () => { + expect(resolveDeepLink('web+stellar:pay')).toBe( + `/pay?uri=${encodeURIComponent('web+stellar:pay')}`, + ); + }); + + it('rejects a non-pay SEP-7 operation', () => { + expect(resolveDeepLink('web+stellar:tx?xdr=AAAA')).toBe(FALLBACK_ROUTE); + }); +}); + +describe('resolveDeepLink — paths handed over by expo-router', () => { + it('accepts an already-stripped path', () => { + expect(resolveDeepLink('/send?amount=3')).toBe('/send?amount=3'); + }); + + it('resolves an alias', () => { + expect(resolveDeepLink('/request?amount=3')).toBe('/receive?amount=3'); + }); +}); + +describe('resolveDeepLink — hostile and malformed input', () => { + it.each([ + ['empty string', ''], + ['whitespace', ' '], + ['no scheme', 'pay?to=GABC'], + ['unknown scheme', 'javascript:alert(1)'], + ['file scheme', 'file:///etc/passwd'], + ])('sends %s to the fallback route', (_label, input) => { + expect(resolveDeepLink(input)).toBe(FALLBACK_ROUTE); + }); + + it('rejects an over-long URL without parsing it', () => { + const oversized = `veil://pay?to=${'G'.repeat(MAX_DEEP_LINK_LENGTH)}`; + expect(resolveDeepLink(oversized)).toBe(FALLBACK_ROUTE); + }); + + it('drops parameters the target route does not accept', () => { + expect(resolveDeepLink('veil://receive?amount=5&callback=https://evil.example')).toBe( + '/receive?amount=5', + ); + }); + + it('keeps the first value of a repeated parameter', () => { + expect(resolveDeepLink('veil://send?amount=1&amount=999')).toBe('/send?amount=1'); + }); + + it('survives a malformed percent-escape', () => { + expect(resolveDeepLink('veil://%E0%A4%A')).toBe(FALLBACK_ROUTE); + }); + + it('ignores a non-string input', () => { + expect(resolveDeepLink(undefined as unknown as string)).toBe(FALLBACK_ROUTE); + }); +}); + +describe('resolveDeepLink — cold start vs warm resume', () => { + const links = [ + 'veil://pay?to=' + DESTINATION, + 'https://app.veil.xyz/receive', + 'web+stellar:pay?destination=' + DESTINATION, + 'veil://create-wallet', + ]; + + it.each(links)('resolves %s identically on both launch paths', (link) => { + expect(resolveDeepLink(link, { initial: true })).toBe( + resolveDeepLink(link, { initial: false }), + ); + }); +}); diff --git a/frontend/mobile/lib/deepLinks.ts b/frontend/mobile/lib/deepLinks.ts new file mode 100644 index 00000000..b01496a3 --- /dev/null +++ b/frontend/mobile/lib/deepLinks.ts @@ -0,0 +1,241 @@ +/** + * Inbound deep-link resolution for the Veil mobile app. + * + * Three families of URL reach the app, and all three must land on the same + * in-app routes whether the app was cold-started by the link or was already + * running in the background (warm resume): + * + * 1. `veil://pay?...` custom scheme (app.config.ts `scheme`) + * 2. `https://app.veil.xyz/pay?...` iOS universal link / Android app link + * 3. `web+stellar:pay?destination=...` SEP-7 payment request + * + * Everything here is pure string handling with no React Native or Expo imports, + * so it can be unit-tested directly and reused by the SEP-7 handler in + * backlog #38. + * + * Inbound links are untrusted input: any app, web page, or QR code can send one. + * {@link resolveDeepLink} therefore never echoes an arbitrary path back to the + * router — it matches against a fixed allowlist of routes and copies only known + * query parameters, falling back to the home route for anything unrecognised. + */ + +/** Custom URL scheme registered by the app (kept in sync with `app.config.ts`). */ +export const DEEP_LINK_SCHEME = 'veil'; + +/** Hosts whose `https://` links are claimed as universal / app links. */ +export const ASSOCIATED_DOMAINS = ['app.veil.xyz'] as const; + +/** SEP-7 URI scheme, without the trailing colon. */ +export const SEP7_SCHEME = 'web+stellar'; + +/** Route used whenever a link is missing, malformed, or unrecognised. */ +export const FALLBACK_ROUTE = '/'; + +/** + * Hard cap on the length of an inbound URL. SEP-7 caps its URIs at 7168 chars; + * anything past that is not a real request and is rejected without parsing. + */ +export const MAX_DEEP_LINK_LENGTH = 7168; + +/** + * Routes reachable from outside the app, and the query parameters each one + * accepts. Parameters outside this list are dropped rather than forwarded, so a + * crafted link cannot smuggle state into a screen that never expected it. + */ +const LINKABLE_ROUTES: Record = { + '/pay': ['to', 'amount', 'asset', 'memo', 'msg', 'uri'], + '/send': ['to', 'amount', 'asset', 'memo'], + '/receive': ['amount', 'asset'], + '/create-wallet': [], +}; + +/** Aliases for paths that read naturally in a shared link but are not routes. */ +const PATH_ALIASES: Record = { + '': '/', + '/': '/', + '/request': '/receive', + '/payment-request': '/pay', +}; + +/** + * SEP-7 `pay` field names mapped onto the query parameters the `/pay` route + * understands. Full validation of the URI (address checksums, amount ranges, + * hostile callbacks) belongs to the SEP-7 handler in backlog #38 — this mapping + * only decides *where* the link goes, and the raw URI is forwarded as `uri` so + * that handler can re-parse the original, unmodified request. + */ +const SEP7_PARAM_MAP: Record = { + destination: 'to', + amount: 'amount', + asset_code: 'asset', + memo: 'memo', + msg: 'msg', +}; + +/** Context supplied by expo-router when it hands the app an inbound link. */ +export type DeepLinkContext = { + /** + * True when the link cold-started the app, false on a warm resume. Resolution + * is identical for both — the flag is threaded through only so callers can + * distinguish the two when logging. + */ + initial?: boolean; +}; + +function isAssociatedDomain(host: string): boolean { + return (ASSOCIATED_DOMAINS as readonly string[]).includes(host.toLowerCase()); +} + +/** + * Split a URL into its path and query without using the `URL` constructor, + * which rejects non-special schemes like `web+stellar:` inconsistently across + * Hermes, Node, and browsers. + */ +function splitPathAndQuery(rest: string): { path: string; query: string } { + const withoutFragment = rest.split('#')[0] ?? ''; + const queryIndex = withoutFragment.indexOf('?'); + return queryIndex === -1 + ? { path: withoutFragment, query: '' } + : { + path: withoutFragment.slice(0, queryIndex), + query: withoutFragment.slice(queryIndex + 1), + }; +} + +/** Normalise a raw path to a single leading slash and no trailing slash. */ +function normalizePath(path: string): string { + const decoded = decodeURIComponent(path.trim()); + const trimmed = `/${decoded.replace(/^\/+/, '').replace(/\/+$/, '')}`; + return trimmed === '/' ? '/' : trimmed.toLowerCase(); +} + +/** + * Decode one query component. React Native's own `URLSearchParams` is a stub + * whose accessors throw, so query strings are parsed by hand here rather than + * depending on which polyfill happens to be installed at launch time. + */ +function decodeComponent(value: string): string { + try { + return decodeURIComponent(value.replace(/\+/g, ' ')); + } catch { + // A malformed percent-escape is not worth discarding the whole link over. + return value; + } +} + +function parseQuery(query: string): Array<[string, string]> { + const pairs: Array<[string, string]> = []; + for (const part of query.split('&')) { + if (!part) continue; + const equalsIndex = part.indexOf('='); + const key = equalsIndex === -1 ? part : part.slice(0, equalsIndex); + const value = equalsIndex === -1 ? '' : part.slice(equalsIndex + 1); + pairs.push([decodeComponent(key), decodeComponent(value)]); + } + return pairs; +} + +function buildTarget(route: string, query: string, rename?: Record): string { + const allowed = LINKABLE_ROUTES[route]; + if (!allowed) return FALLBACK_ROUTE; + + const forwarded: Array<[string, string]> = []; + const seen = new Set(); + + for (const [rawKey, value] of parseQuery(query)) { + const key = rename ? rename[rawKey] : rawKey; + // First occurrence wins; a repeated key must not overwrite it. + if (!key || !allowed.includes(key) || seen.has(key)) continue; + const trimmed = value.trim(); + if (!trimmed) continue; + seen.add(key); + forwarded.push([key, trimmed]); + } + + if (forwarded.length === 0) return route; + const serialized = forwarded + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + return `${route}?${serialized}`; +} + +/** + * Resolve an inbound URL to an in-app route. + * + * Always returns a path the router can navigate to; unrecognised, malformed, or + * foreign links resolve to {@link FALLBACK_ROUTE} rather than throwing, because + * a throw here happens during launch and would take the app down with it. + * + * @example + * resolveDeepLink('veil://pay?to=GABC&amount=10') // '/pay?to=GABC&amount=10' + * resolveDeepLink('https://app.veil.xyz/receive') // '/receive' + * resolveDeepLink('web+stellar:pay?destination=GABC') // '/pay?to=GABC&uri=…' + * resolveDeepLink('https://evil.example/pay') // '/' + */ +export function resolveDeepLink(url: string, _context: DeepLinkContext = {}): string { + if (typeof url !== 'string') return FALLBACK_ROUTE; + const raw = url.trim(); + if (!raw || raw.length > MAX_DEEP_LINK_LENGTH) return FALLBACK_ROUTE; + + try { + // expo-router hands `+native-intent` an already-stripped path (e.g. `/pay?x=1`) + // when the link matched the app's own scheme, so accept that shape too. + if (raw.startsWith('/')) { + return resolveInAppPath(raw); + } + + const schemeEnd = raw.indexOf(':'); + if (schemeEnd === -1) return FALLBACK_ROUTE; + const scheme = raw.slice(0, schemeEnd).toLowerCase(); + const rest = raw.slice(schemeEnd + 1); + + if (scheme === SEP7_SCHEME) { + return resolveSep7Uri(rest, raw); + } + + if (scheme === DEEP_LINK_SCHEME) { + // `veil://pay?x=1` and the host-less `veil:pay?x=1` are both valid. + return resolveInAppPath(rest.replace(/^\/\//, '/')); + } + + if (scheme === 'https' || scheme === 'http') { + return resolveWebLink(rest); + } + + return FALLBACK_ROUTE; + } catch { + return FALLBACK_ROUTE; + } +} + +function resolveInAppPath(pathAndQuery: string): string { + const { path, query } = splitPathAndQuery(pathAndQuery); + const normalized = normalizePath(path); + const route = PATH_ALIASES[normalized] ?? normalized; + if (!(route in LINKABLE_ROUTES)) return FALLBACK_ROUTE; + return buildTarget(route, query); +} + +function resolveWebLink(rest: string): string { + // `rest` is everything after `https:` — strip the `//` and peel off the host. + const authorityAndPath = rest.replace(/^\/\//, ''); + const slashIndex = authorityAndPath.search(/[/?#]/); + const authority = slashIndex === -1 ? authorityAndPath : authorityAndPath.slice(0, slashIndex); + // Drop any userinfo (`user@host`) and port so `evil.com@app.veil.xyz` cannot + // masquerade as an associated domain. + const host = authority.split('@').pop()?.split(':')[0] ?? ''; + if (!isAssociatedDomain(host)) return FALLBACK_ROUTE; + + const pathAndQuery = slashIndex === -1 ? '/' : authorityAndPath.slice(slashIndex); + return resolveInAppPath(pathAndQuery); +} + +function resolveSep7Uri(rest: string, originalUri: string): string { + const { path, query } = splitPathAndQuery(rest); + // SEP-7 puts the operation where a path would go: `web+stellar:pay?…`. + if (path.replace(/^\/+/, '').toLowerCase() !== 'pay') return FALLBACK_ROUTE; + + const target = buildTarget('/pay', query, SEP7_PARAM_MAP); + const separator = target.includes('?') ? '&' : '?'; + return `${target}${separator}uri=${encodeURIComponent(originalUri)}`; +} diff --git a/frontend/wallet/next.config.js b/frontend/wallet/next.config.js index 1da0a4ff..c241ec6b 100644 --- a/frontend/wallet/next.config.js +++ b/frontend/wallet/next.config.js @@ -8,6 +8,22 @@ const nextConfig = { // Allow imports from outside the Next.js project root (e.g. ../../sdk/src) externalDir: true, }, + async headers() { + // iOS refuses an apple-app-site-association file that is not served as + // JSON, and the file has no extension so Next cannot infer the type. + // Android is stricter still: assetlinks.json must be reachable over HTTPS + // with no redirect. Both files live in public/.well-known/. + return [ + { + source: '/.well-known/apple-app-site-association', + headers: [{ key: 'Content-Type', value: 'application/json' }], + }, + { + source: '/.well-known/assetlinks.json', + headers: [{ key: 'Content-Type', value: 'application/json' }], + }, + ] + }, webpack: (config) => { // When webpack compiles SDK source files from ../../sdk/src/, it resolves // node_modules going up from that directory and misses the wallet's diff --git a/frontend/wallet/public/.well-known/apple-app-site-association b/frontend/wallet/public/.well-known/apple-app-site-association new file mode 100644 index 00000000..2df9c7e3 --- /dev/null +++ b/frontend/wallet/public/.well-known/apple-app-site-association @@ -0,0 +1,19 @@ +{ + "applinks": { + "details": [ + { + "appIDs": ["APPLE_TEAM_ID.xyz.veil.wallet"], + "components": [ + { "/": "/pay", "comment": "Payment requests" }, + { "/": "/pay/*", "comment": "Payment requests" }, + { "/": "/send", "comment": "Send flow" }, + { "/": "/send/*", "comment": "Send flow" }, + { "/": "/receive", "comment": "Receive flow" }, + { "/": "/receive/*", "comment": "Receive flow" }, + { "/": "/create-wallet", "comment": "Onboarding" }, + { "/": "/*", "exclude": true, "comment": "Everything else stays on the web" } + ] + } + ] + } +} diff --git a/frontend/wallet/public/.well-known/assetlinks.json b/frontend/wallet/public/.well-known/assetlinks.json new file mode 100644 index 00000000..160ed80e --- /dev/null +++ b/frontend/wallet/public/.well-known/assetlinks.json @@ -0,0 +1,10 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "xyz.veil.wallet", + "sha256_cert_fingerprints": ["ANDROID_RELEASE_CERT_SHA256_FINGERPRINT"] + } + } +]