Connect-time wallet screening for Euler Lite and first-party *.euler.finance SPAs. VPN usage is audit metadata; only the address-screening verdict gates access.
For geographic restrictions see Geo-Blocking. For transaction policy after connect, see Transaction Building.
A connected wallet becomes user-visible only after data-v3 compliance returns an explicit clear or exit-only verdict for that address. Screening is not repeated during reviewed execution.
wagmi address change
│
▼
useAddressScreen.screenConnectedAddress
│
├─ detectVpn() → boolean | null (audit only)
└─ screenAddress(...) → POST /api/internal/screen-address
│
▼
screenAddressUpstream → POST data-v3 /v3/compliance/address-screening
│
┌───────┴────────┬──────────────┐
│ blocked │ clear │ exit-only
▼ ▼ ▼
disconnect + screenedAddress exit-only flag, then
BlockedAddressModal = address screenedAddress = address
useWagmi.address / isConnected stay unset on blocked
| Module | Role |
|---|---|
composables/useWagmi.ts |
Watches the raw wagmi address; address / isConnected stay unset until isAddressScreened |
composables/useAddressScreen.ts |
Module-scoped verdict so every consumer agrees; generation counter drops stale in-flight results |
services/vpn.ts |
Optional client VPN probe |
services/screening.ts |
Browser POST returning clear, exit-only or blocked; any non-ok or malformed response is blocked |
server/api/internal/screen-address.post.ts |
Rate limit 10/60s, validate 0x + 40 hex, proxy upstream |
server/utils/screening.ts |
Fail-closed upstream client |
useWagmi also routes ENS through the screened address so wallet-tied queries run only after a clear or exit-only verdict.
An exit-only address stays connected under the rules in Exit-only addresses. A blocked address disconnects the wallet and opens BlockedAddressModal. Closing it navigates to the deployment default page. Disconnect without a new address resets the screening, VPN, and country caches.
Client timeout: WALLET_SCREENING_TIMEOUT_MS (10s). Abort or throw → restricted.
Lite client body:
{ "address": "0x…", "vpnIsUsed": true }vpnIsUsed is true, false, or null. Missing, invalid, failed, or unsupported VPN measurements are null, never a fabricated false.
Lite response:
{ "addressIsSuspicious": true, "exitOnly": false }addressIsSuspicious !== false is restricted, including a malformed JSON body. A caller that ignores exitOnly therefore keeps an exit-only address blocked. Lite reads addressIsSuspicious: true, exitOnly: true as exit-only, and both must be strict booleans.
The server forwards { address, chain: "all", vpnIsUsed } to data-v3. chain: "all" is explicit so TRM coverage does not depend on the upstream default.
Success envelope required from upstream (data.*): address, addressIsSuspicious, screenedAt, resolvedChain, cached, ruleVersion. Any other 2xx, a partial envelope, or a verdict echoing a different address is suspicious. The optional exitOnly is honoured only on a complete HTTP 200 envelope for the requested address with addressIsSuspicious: true and a strict true; every other case returns exitOnly: false. A clean verdict that also claims exitOnly: true is ambiguous and reported as suspicious.
Invalid Lite body → HTTP 400 Invalid address.
Because first-party sibling SPAs call this path, keep the request/response contract backward-compatible. It stays under /api/internal/ on purpose: /api/public/ would advertise it to external integrators.
VPN evidence does not block connect. useAddressScreen always continues to screenAddress, including when the probe returns true or fails.
deriveVpnIsUsed(event, body.vpnIsUsed):
- A strict client
trueis additional positive evidence. - Otherwise use
getEdgeContext(event).vpnIsUsed. - Client
false/ invalid values cannot clear a trusted edgetrue. - No positive signal and no edge evidence →
null.
Only the cloudflare preset supplies VPN headers (x-is-vpn / x-is-proxy-or-vpn on the request). window.__APP_CONFIG__.vpnDetection is true only then; other presets skip the client probe.
The client probe is HEAD / reading the response header x-is-vpn. server/middleware/cors.ts does not set that response header — it only echoes x-country-code. Treat a client true as extra signal when an edge transform exposes the header; the authoritative Cloudflare evidence is still the request context on POST /api/internal/screen-address.
geo-gate.ts logs VPN/proxy detected when edge.vpnIsUsed === true. It does not block the request.
| Config | Non-production | DOPPLER_ENVIRONMENT=prd |
|---|---|---|
Both ADDRESS_SCREENING_URI and ADDRESS_SCREENING_API_KEY unset |
Disabled — every address passes; logs screening disabled |
Fail closed (missing secrets, not an opt-out) |
| Only one var set | Fail closed | Fail closed |
URI not https: (except http://localhost / 127.0.0.1) |
Fail closed — key must not travel without TLS | Fail closed |
| Upstream non-200, timeout, redirect, malformed envelope | Fail closed | Fail closed |
Redirects are refused (redirect: 'error') so Node cannot replay the API key across a cross-origin redirect.
server/middleware/cors.ts allows https://euler.finance and https://*.euler.finance only for POST /api/internal/screen-address. Configured CORS_ALLOWED_ORIGINS / NUXT_PUBLIC_APP_URL still apply via the regular allowlist. No other internal route gets this exception.
server/plugins/sensitive-route-cache.ts forces Cache-Control: no-store (plus CDN variants) on this path.
Rate limit: 10 requests / 60s, same budget table as Architecture.
Logs hash the address (hashIdentifier); do not add raw addresses to screening log lines.
features/reviewed-execution/policy/engine.ts SUBJECT_CONCERNS.account is []. There is no wallet-screening concern. Preparation does not call screenAddress or detectVpn.
Connect-time gating hides blocked or pending wallets from useWagmi.address. Reviewed execution enforces the accepted exit-only verdict without requesting another screening answer. Derived EVC sub-accounts are bound into the reviewed request but are not screened as separate wallets.
- Wallet connects then immediately disconnects: upstream returned suspicious, non-200, or the client treated a timeout as restricted. Check
screen-addresswarn logs (hashed address) andADDRESS_SCREENING_*. - Every address passes in staging, none in production: both env vars unset. Production fails closed; other envs opt out.
- VPN true in Cloudflare logs but
vpnIsUsed: nullupstream: client probe skipped or response header absent; confirm the request still carriesx-is-vpn/x-is-proxy-or-vpnon the screening POST (EDGE_PROVIDER=cloudflare). - Sibling SPA 403: origin is not
https://*.euler.financeand not onCORS_ALLOWED_ORIGINS. - Local screening against a local upstream:
http://localhost/127.0.0.1only; any other http URI fails closed.
tests/composables/useAddressScreen.test.ts— generation races; VPN never gates disconnect vs allowtests/services/screening.test.ts/tests/services/vpn.test.ts— client POST body and probe skiptests/server/screen-address.test.ts— envelope,chain: "all", VPN merge, TLS URItests/reviewed-execution/preparation-service.test.ts— noscreenAddress/detectVpnduring prepare