Skip to content

feat(mobile): register veil:// scheme and universal/app links - #508

Merged
Miracle656 merged 4 commits into
Miracle656:mainfrom
Olorunfemi20:feat/mobile-deep-linking-498
Jul 30, 2026
Merged

feat(mobile): register veil:// scheme and universal/app links#508
Miracle656 merged 4 commits into
Miracle656:mainfrom
Olorunfemi20:feat/mobile-deep-linking-498

Conversation

@Olorunfemi20

@Olorunfemi20 Olorunfemi20 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What

Registers the veil:// custom scheme and the platform universal/app links, and wires expo-router so an inbound link routes to the right screen from both a cold start and a warm resume.

closes #498

Approach

app.config.ts replaces app.json. Static JSON cannot explain itself, and the deep-linking surface has to agree with the code that resolves links at runtime — the two silently drifting apart is the classic way deep links stop working.

Deriving the config from the resolver's constants turned out not to be possible: Expo transpiles app.config.ts on its own and then requires it, so a relative import of a sibling .ts module fails to resolve at config-load time (npx expo config fails outright). The constants are therefore duplicated, and lib/__tests__/appConfig.test.ts holds the two together instead. It asserts that every natively-registered scheme is one the resolver handles, that the iOS associated domains and the Android intent-filter hosts are the same set, and — the check that matters most — that every pathPrefix claimed as an app link actually resolves to a route rather than the fallback. A path claimed natively but unrouted is a link that opens the app to a blank screen, which is worse than not claiming it.

Resolution itself lives in lib/deepLinks.ts as a pure function with no React Native or Expo imports, which makes it directly unit-testable and reusable by the SEP-7 handler in backlog #38. app/+native-intent.ts is the expo-router hook that calls it. expo-router invokes redirectSystemPath for every inbound link on both launch paths — initial: true on a cold start, initial: false on a warm resume — so routing the two identically falls out of resolving them through the same function rather than through two separate code paths.

Three URL families resolve to the same routes:

Incoming URL Resolves to
veil://pay?to=G…&amount=10 /pay then /send, prefilled
https://app.veil.xyz/receive /receive
web+stellar:pay?destination=G…&amount=10 /pay, raw URI preserved as uri
anything else /

Security

Inbound links are untrusted input: any installed app, web page, or QR code can send one. The resolver therefore never echoes an arbitrary path back to the router. It matches a fixed allowlist of routes, copies only the query parameters each route declares, and falls back to / for anything else. Specifically covered by tests:

  • Foreign hosts (https://evil.example/pay) and look-alike subdomains (app.veil.xyz.evil.example) are rejected.
  • Userinfo smuggling (https://app.veil.xyz@evil.example/pay) is rejected — the host is taken after the last @, not before it.
  • Non-http schemes (javascript:, file:) are rejected.
  • Undeclared parameters are dropped rather than forwarded, so a crafted link cannot smuggle state into a screen that never expected it.
  • URLs over 7168 characters (the SEP-7 cap) are rejected without being parsed.

redirectSystemPath runs during launch, so a throw there takes the app down with it. resolveDeepLink catches internally and returns the fallback route; the hook wraps it a second time as a backstop.

Query strings are parsed by hand rather than with URLSearchParams, because React Native's built-in implementation is a stub whose accessors throw. Depending on which polyfill happens to be installed at launch time is not a good bet for code on the launch path.

SEP-7

Scope here is routing only. web+stellar:pay?… maps its fields onto the /pay route and forwards the original URI untouched as uri, so the handler in backlog #38 can re-parse and fully validate it. Validation is deliberately not duplicated — sdk/src/sep7.ts already implements it (address checksums, amount ranges, memo limits, hostile callback rejection) and that is what #38 should reuse.

Placeholder screens

/pay, /send, /receive, and /create-wallet are added as minimal screens. A deep-link config with nothing to link to cannot be verified, so these exist to give the routes a destination and to own the prefill contract (to, amount, asset, memo) that links depend on. They hold no wallet logic; /pay forwards into the send form.

Universal / app link files

apple-app-site-association and assetlinks.json are served from the wallet web app, with next.config.js pinning Content-Type: application/json on both — iOS refuses an AASA file served as anything else, and the file has no extension for Next to infer from.

Both carry placeholders that must be replaced before a store build, documented in the mobile README:

  • APPLE_TEAM_ID — the Apple Developer Team ID signing xyz.veil.wallet.
  • ANDROID_RELEASE_CERT_SHA256_FINGERPRINT — the release signing certificate fingerprint, plus the Play App Signing fingerprint if distributed through Play.

Until then Android's autoVerify simply fails verification and links keep opening in the browser, which is the safe failure mode; the veil:// scheme works regardless.

Testing

43 unit tests. lib/__tests__/deepLinks.test.ts covers each scheme, the host checks above, malformed input, and a parity check asserting every link resolves identically with initial: true and initial: false. lib/__tests__/appConfig.test.ts covers the config/resolver agreement described above.

Verified locally against a real install: npm run typecheck and npm test are clean, and npx expo config --type public --json resolves to the expected scheme array, associated domains, and intent filter.

Vitest is added as a mobile dev dependency and a new mobile CI job runs npm run typecheck and npm test. The lockfile change is additive — no existing package was removed or version-changed.

Manual device verification, per the README:

adb shell am start -W -a android.intent.action.VIEW \
  -d "veil://pay?to=GABC&amount=10" xyz.veil.wallet
xcrun simctl openurl booted "https://app.veil.xyz/receive"

Run each twice — once force-quit for the cold start, once backgrounded for the warm resume.

Notes for review

  • Universal links cannot be end-to-end verified until the team ID and signing fingerprint are filled in and app.veil.xyz serves the two files. The custom scheme is fully testable today.
  • web+stellar: is declared through Expo's scheme array rather than a hand-written intent filter, so prebuild generates the iOS CFBundleURLTypes entry and the Android intent filter consistently.
  • Query strings could be parsed with URLSearchParams if you would rather rely on Expo's WinterCG polyfill being installed before +native-intent runs. I did not want launch-path code to depend on that ordering, but it would remove about twenty lines.
  • /create-wallet is claimed as a link target; drop it from LINKED_PATHS and LINKABLE_ROUTES if onboarding should not be externally reachable.

@Olorunfemi20
Olorunfemi20 requested a review from Miracle656 as a code owner July 27, 2026 19:31
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

@Olorunfemi20 is attempting to deploy a commit to the miracle656's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@Olorunfemi20 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Register the custom scheme and platform universal/app links so payment
requests and other external entry points open the right screen from both
a cold start and a warm resume.

- app.config.ts replaces app.json so the deep-linking surface is derived
  from the same constants the runtime resolver uses. Declares the veil://
  and web+stellar: schemes, iOS associated domains, an autoVerify Android
  intent filter, and the bundle identifier / package name both need.
- lib/deepLinks.ts resolves an inbound URL to an in-app route. Inbound
  links are untrusted, so it matches a fixed allowlist of routes and
  copies only the query parameters each route declares; foreign hosts,
  unknown schemes, and over-long URLs fall back to the home route rather
  than navigating. Query parsing is hand-rolled because React Native's
  URLSearchParams accessors throw.
- app/+native-intent.ts wires it into expo-router, which calls it for
  both launch paths, and can never throw during launch.
- Placeholder /pay, /send, /receive, and /create-wallet routes give the
  links somewhere to land; /pay forwards a request into the send form and
  preserves the raw SEP-7 URI for the handler in backlog Miracle656#38.
- apple-app-site-association and assetlinks.json are served by the wallet
  web app, with next.config.js pinning their Content-Type. Both carry
  placeholders documented in the mobile README.
@Olorunfemi20
Olorunfemi20 force-pushed the feat/mobile-deep-linking-498 branch from 167b0e2 to 7a40640 Compare July 27, 2026 19:56

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is strong work. lib/deepLinks.ts with 246 lines of tests behind it (deepLinks.test.ts + appConfig.test.ts) is exactly the right shape for something that has to behave identically on cold start and warm resume, and +native-intent.ts is the correct expo-router hook for it. Moving app.jsonapp.config.ts is well-argued in the description, and I agree with the reasoning: the deep-link surface and the resolver that parses links must not drift, and static JSON can't document why it looks the way it does.

Also — thank you for adding the Mobile — typecheck & test job to ci.yml. That gap was ours, and mobile PRs have been merging without any typecheck gate. That's a genuine contribution beyond the issue's scope and it's welcome.

Serving apple-app-site-association and assetlinks.json from frontend/wallet/public/.well-known/ (with the next.config.js headers) is the right call too — universal links don't work without the web side, so that's in scope, not scope creep.

Two things before this can merge.

1. Blocker: package-lock.json conflicts with main

That's the only conflicted file — everything else merges cleanly:

git fetch upstream main
git merge upstream/main
# resolve by regenerating rather than hand-merging:
cd frontend/mobile && rm package-lock.json && npm install
git add package-lock.json

2. Needs coordination: route collision with #507

#507 (navigation shell, Closes #434) is open and creates a tab group containing:

frontend/mobile/app/(tabs)/send.tsx
frontend/mobile/app/(tabs)/receive.tsx

This PR creates them at the root instead:

frontend/mobile/app/send.tsx
frontend/mobile/app/receive.tsx

You both also modify app/index.tsx. Whichever lands second will produce two routes that resolve to the same destination, and the deep-link targets in deepLinks.ts would point at whichever expo-router picks.

This isn't a defect in your PR — it's my sequencing problem, two issues handed out with overlapping surface area. I'm going to land #507 first, since it defines the route tree that everything else drops into. Once it's in, please rebase and point your link targets at the (tabs) paths for send and receive. Your pay, create-wallet, and token/[id] routes are unaffected.

No action needed from you on that second point until #507 lands — I'll comment here when it does.

Not your problem

The Wallet E2E Tests failure is pre-existing on mainhappy-path.spec.ts is timing out on page.waitForURL in the real passkey flow, and the most recent main run fails the same way. Being fixed separately. Ignore it.

@Miracle656

Copy link
Copy Markdown
Owner

Heads-up before you push anything: don't change the app identifier or associated domain yet.

This PR declares xyz.veil.wallet / app.veil.xyz and hardcodes them into .well-known/apple-app-site-association and .well-known/assetlinks.json. #514 independently declares app.veil.mobile / veil.app, and #512 uses rpId: 'veil.app'.

A bundle identifier is permanent once the app ships to either store, and it has to match both the webcredentials: domain for passkeys and the domain serving the association files. So this isn't a merge conflict to resolve between yourselves — I'm supplying the canonical values and will post them here.

Everything else in my review still stands and is safe to act on. Just leave the identifiers alone until I confirm.

Reconcile deep linking with the navigation shell and mobile screens that
landed after this branch was opened:

- Port main's expo-secure-store and expo-camera plugin entries into
  app.config.ts before dropping app.json, so SecureStore and the QR scanner
  keep working.
- Drop this branch's root app/send.tsx and app/receive.tsx; /send and /receive
  are now owned by the (tabs) group. Move the deep-link prefill contract
  (to, amount, asset, memo) into (tabs)/send.tsx so veil://send and the /pay
  redirect still land prefilled.
- Take main's app/index.tsx redirect into (tabs) and main's lib/theme.ts;
  port create-wallet.tsx onto the useTheme/ThemeColors API.
- Switch the two new suites from vitest to the repo's jest setup, and rewrite
  the vitest-only two-argument expect() in appConfig.test.ts.

tsc --noEmit clean; jest 8 suites / 155 tests passing; expo config resolves
both the veil:// and web+stellar: schemes.
`npm ci || npm install` would silently paper over lockfile drift, which is the
one thing the job is there to catch. Verified `npm ci` resolves cleanly against
frontend/mobile/package-lock.json.
@Miracle656

Copy link
Copy Markdown
Owner

Merging. lib/deepLinks.ts is the strongest part of this — an allowlist of routes with per-route permitted query params, host checking, a length cap, and a fallback to / instead of throwing, all behind a pure synchronous function that +native-intent.ts can safely call during launch. That's the right shape for parsing input any app or QR code can send.

The branch needed reconciling against the mobile work that landed after you opened it (the navigation shell from #507 in particular). What I changed:

Config. app.config.ts was missing the expo-secure-store and expo-camera plugin entries that app.json had picked up since — dropping app.json without porting them would have broken SecureStore and the QR scanner. Ported both across before deleting app.json. Verified with npx expo config --type public: both veil and web+stellar schemes and all four plugins resolve.

Routes. /send and /receive are now owned by the (tabs) group, so this branch's root app/send.tsx and app/receive.tsx would have been duplicate routes. Dropped both, and moved the prefill contract they defined — to, amount, asset, memo — into (tabs)/send.tsx, so veil://send?to=…&amount=… and the /pay redirect still land on a prefilled screen. pay.tsx and create-wallet.tsx are kept as-is since main has no equivalent; create-wallet.tsx was ported onto the useTheme / ThemeColors API, as lib/theme.ts no longer exports a flat colors object. app/index.tsx keeps main's redirect into (tabs).

Tests. Both new suites imported from vitest, but the mobile package runs jest via jest-expo. Converted them to jest globals, and rewrote the one vitest-only construct — expect(value, message) takes a second message argument in vitest but not jest. That assertion in appConfig.test.ts now collects every claimed-but-unrouted URL and asserts the list is empty, which also names all offenders on failure instead of stopping at the first.

CI. Changed the new mobile job from npm ci || npm install to plain npm ci — the fallback would silently swallow exactly the lockfile drift the job exists to catch. That immediately caught a real one: @walletconnect/keyvaluestorage peers on @react-native-async-storage/async-storage@1.x while only the root 3.1.1 was locked, so npm 10 (what CI installs) refused the tree. npm 11 accepts it, which is why nobody hit it locally. Fixed on main in 4db81c7.

Two notes for follow-ups, neither blocking:

  • The .well-known files ship placeholders (APPLE_TEAM_ID, ANDROID_RELEASE_CERT_SHA256_FINGERPRINT). Universal links stay inert until those are filled in — your README section covers this well, but it's worth an issue so it isn't forgotten before a store build.
  • ASSOCIATED_DOMAINS and the config constants are duplicated between app.config.ts and lib/deepLinks.ts. Your comment explains why (Expo requires the config standalone), and appConfig.test.ts guards the drift — good call.

Final state: tsc --noEmit clean, jest 8 suites / 155 tests, expo lint clean, all CI green.

@Miracle656
Miracle656 merged commit 3a9e337 into Miracle656:main Jul 30, 2026
11 of 14 checks passed
Miracle656 pushed a commit to collinsezedike/veil that referenced this pull request Jul 30, 2026
Reconcile the dev-client/EAS setup with the config work that landed since:

- app.json no longer exists (Miracle656#508 moved to app.config.ts). Port the
  expo-dev-client plugin across, and add webcredentials: alongside the existing
  applinks: entry — iOS will not offer a passkey for a domain that is not
  claimed as a webcredentials service.
- Keep main's xyz.veil.wallet identifier and app.veil.xyz domain rather than
  this branch's app.veil.mobile / veil.app, so the config stays consistent with
  the .well-known files already served by the wallet.
- Drop react-native-passkey ^3.0.0. main already ships react-native-passkeys
  ^0.4.1, which is what lib/passkey.ts uses; adding the other package would put
  two WebAuthn implementations in the same app.
- expo-dev-client ~5.2.5 does not exist on the SDK 57 line (npm notarget).
  Pinned to ~57.0.10.
- Replace the personal Apple ID in eas.json with a placeholder alongside the
  other REPLACE_WITH_* values.

Cover the new webcredentials entry in appConfig.test.ts.
tsc clean; jest 8 suites / 157 tests; expo lint clean.
Miracle656 pushed a commit to ibochivincent-lang/veil that referenced this pull request Jul 30, 2026
The inbound-routing half of Miracle656#466 landed with Miracle656#508: lib/deepLinks.ts already
maps web+stellar:pay?... onto /pay with the SEP-7 parameters translated, and
app/+native-intent.ts drives it identically on cold start and warm resume.
Reduce this branch to the SEP-7 payload work that is genuinely missing:

- Drop app.json (main uses app.config.ts), the Linking handler added to
  app/_layout.tsx (duplicates +native-intent.ts), and the root app/send.tsx
  (/send is owned by the (tabs) group).
- Trim lib/sep7.ts to the parts deepLinks.ts does not cover: parseSep7Uri,
  parseQrValue, looksLikeStellarAddress and buildSep7PayUri. Remove
  parseVeilLinkUri/parseDeepLink so there is one owner for inbound routing.
- Replace the private destination-only builder in (tabs)/receive.tsx with the
  shared one, so requesting a specific amount is a parameter away.
- Add lib/__tests__/sep7.test.ts (14 cases) covering parsing, the QR bare
  address path, and a build/parse round-trip including memo encoding.

tsc clean; jest 10 suites / 174 tests; expo lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

70. Deep linking config

3 participants