diff --git a/.env.example b/.env.example index cdd779a..1f2b5ba 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,19 @@ EXPO_PUBLIC_APP_ENV=development # Get a project ID at https://cloud.walletconnect.com # EXPO_PUBLIC_WALLET_CONNECT_PROJECT_ID= -# Embedded wallets (Privy, optional) -# Enable Email and Google login plus Ethereum embedded wallets in the Privy -# dashboard. Both public values are safe to ship; never put a Privy secret here. +# ─── Embedded Wallets — Privy (optional) ────────────────────────────── +# Enables social/email login with automatic wallet creation for non-crypto +# native users. When both values are set, the onboarding screen shows a +# "Get started without a wallet" option backed by Privy's MPC embedded wallets. +# +# Setup: +# 1. Create a Privy app at https://dashboard.privy.io +# 2. Enable "Email" and "Google" login methods +# 3. Enable "Embedded Wallets → Ethereum" with createOnLogin = users-without-wallets +# 4. Copy the App ID and Client ID below +# +# Both values are public (safe to ship in the app binary). Never put a Privy +# secret key here. See docs/embedded-wallet-provider.md for the full evaluation. # EXPO_PUBLIC_PRIVY_APP_ID= # EXPO_PUBLIC_PRIVY_CLIENT_ID= diff --git a/.maestro/08-embedded-wallet-entry.yaml b/.maestro/08-embedded-wallet-entry.yaml index 9bdb9eb..f7f0069 100644 --- a/.maestro/08-embedded-wallet-entry.yaml +++ b/.maestro/08-embedded-wallet-entry.yaml @@ -1,19 +1,67 @@ appId: xyz.guildpass.mobile --- -# E2E Test: Embedded wallet entry point +# E2E Test: Embedded wallet onboarding flow +# # Run this flow in a build configured with EXPO_PUBLIC_PRIVY_APP_ID and # EXPO_PUBLIC_PRIVY_CLIENT_ID. OTP completion is intentionally not automated: # it requires a real email inbox and verifies the provider-hosted flow. +# +# CI note: For automated CI runs, use a Privy test-mode app ID that returns +# deterministic OTP codes, or mock the Privy SDK at the provider level. - launchApp: clearState: true + +# ── Onboarding screen renders ───────────────────────────────────────── - assertVisible: id: "onboarding-screen" - assertVisible: id: "onboarding-embedded-wallet-button" + +# ── Tap into embedded wallet flow ────────────────────────────────────── - tapOn: id: "onboarding-embedded-wallet-button" - assertVisible: id: "embedded-wallet-onboarding" + +# ── Custody disclosure is visible before sign-in ─────────────────────── +- assertVisible: + id: "custody-disclosure" +- assertVisible: + text: "About your embedded wallet" + +# ── Expand disclosure and verify detail content ──────────────────────── +- tapOn: + id: "custody-disclosure-toggle" +- assertVisible: + id: "custody-disclosure-details" +- assertVisible: + text: "How your keys are secured" +- assertVisible: + text: "Trade-offs vs. self-custody" +- assertVisible: + id: "custody-disclosure-learn-more" + +# ── Collapse disclosure ──────────────────────────────────────────────── +- tapOn: + id: "custody-disclosure-toggle" +- assertNotVisible: + id: "custody-disclosure-details" + +# ── Sign-in UI elements are present ─────────────────────────────────── +- assertVisible: + id: "social-google-button" - assertVisible: id: "embedded-wallet-email-input" +- assertVisible: + id: "embedded-wallet-email-button" + +# ── "I have a wallet" back button returns to onboarding choice ───────── +- assertVisible: + id: "embedded-wallet-back-button" +- tapOn: + id: "embedded-wallet-back-button" +- assertVisible: + id: "onboarding-embedded-wallet-button" +- assertVisible: + id: "onboarding-get-started-button" diff --git a/.maestro/15-embedded-wallet-disconnect.yaml b/.maestro/15-embedded-wallet-disconnect.yaml new file mode 100644 index 0000000..aaf08bf --- /dev/null +++ b/.maestro/15-embedded-wallet-disconnect.yaml @@ -0,0 +1,32 @@ +appId: xyz.guildpass.mobile +--- +# E2E Test: Embedded wallet disconnect flow +# +# Verifies that an embedded-wallet connection displays the correct connection +# label on the profile screen and that disconnect returns the user to onboarding. +# +# Prerequisites: This flow assumes the app is already connected with an embedded +# wallet. In CI, seed the wallet store directly via Maestro's runScript or use a +# Privy test-mode build that auto-completes login. +# +# NOTE: Full Privy SDK logout verification cannot be done in E2E without mocking +# the provider. This test verifies the UI contract: disconnect clears the wallet +# state and navigates back to onboarding. + +# ── Verify embedded wallet is shown on profile ───────────────────────── +- assertVisible: + id: "profile-screen" +- assertVisible: + id: "profile-connected" +- assertVisible: + text: "Embedded Wallet" +- assertVisible: + id: "connected-wallet-address" + +# ── Disconnect ───────────────────────────────────────────────────────── +- tapOn: + id: "wallet-disconnect-button" + +# ── Verify return to wallet connect form ─────────────────────────────── +- assertVisible: + id: "wallet-connect-form" diff --git a/README.md b/README.md index 6f7fbe1..f3b8094 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The application is built on a robust, feature-driven foundation designed for lon | `app/` | Expo Router file-based pages and layouts | | `app/access-scanner.tsx` | QR code scanner screen using expo-camera | | `src/features/access/` | Access check hooks and QR payload validation | +| `src/features/wallet/` | Wallet connectors, embedded wallet (Privy), stores | | `src/` | Feature modules, hooks, stores, and services | | `docs/` | Architecture and integration guides | | `tests/` | Vitest unit tests | @@ -252,7 +253,7 @@ Deep links work when the app is cold-started (not already running). The app will ## 🗺️ Roadmap - [ ] **Native Wallet Integration**: Support for WalletConnect, MetaMask, and Coinbase Wallet. -- [ ] **Smart Onboarding**: Social login and embedded wallets for non-crypto native users. +- [x] **Smart Onboarding**: Social login and embedded wallets for non-crypto native users. - [x] **Push Notifications**: Real-time alerts for role updates and access grants. - [x] **QR Access Verification**: Scan GuildPass QR codes to verify token-gated resource access from the mobile app. - [ ] **Offline Resilience**: Advanced caching layer for viewing memberships without connectivity. diff --git a/docs/architecture.md b/docs/architecture.md index c213802..9a8d5f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,29 @@ declared in `src/lib/`, so the fan-out is readable, ordered, and awaitable in on Adding a cross-feature transition means adding it to one of these modules, not importing another feature's store into a hook or component. +### Wallet providers + +Three wallet paths are supported, all converging through the `WalletConnector` interface +into `useWalletStore`: + +| Path | Provider | Connection kind | How it works | +|---|---|---|---| +| **Manual entry** | None | `manual` | User pastes an EVM address; `createManualConnector` wraps it | +| **WalletConnect** | WalletConnect v2 | `walletconnect` | WC modal → EIP-1193 → `createWalletConnectConnector` | +| **Embedded wallet** | Privy (`@privy-io/expo`) | `embedded` | Email OTP or Google OAuth → Privy provisions MPC wallet → `createEmbeddedConnector` wraps the address | + +**Key design principle:** Privy is only the provisioning layer. Once the embedded wallet +address enters `useWalletStore`, every downstream flow (memberships, guilds, access checks, +sync, attestations) sees a standard EVM address. No screen or hook needs to know the wallet +was provisioned by Privy. + +The embedded path is feature-flagged via `EXPO_PUBLIC_PRIVY_APP_ID` and +`EXPO_PUBLIC_PRIVY_CLIENT_ID` environment variables. When both are set, +`isEmbeddedWalletEnabled` is `true` and the onboarding screen offers the social/email option. + +See `docs/embedded-wallet-provider.md` for the provider evaluation, security model, and +custody trade-off documentation. + ### Selectors Subscribe with a selector, never by calling the store hook bare. `useWalletStore()` diff --git a/docs/embedded-wallet-provider.md b/docs/embedded-wallet-provider.md new file mode 100644 index 0000000..b1cb52a --- /dev/null +++ b/docs/embedded-wallet-provider.md @@ -0,0 +1,131 @@ +# Embedded Wallet Provider Evaluation + +> **Decision:** Privy (`@privy-io/expo`) +> **Date:** 2026-07-27 +> **Status:** Adopted — integrated into `src/features/wallet/` + +## Evaluation Criteria + +| Criterion | Weight | Notes | +|---|---|---| +| React Native / Expo SDK maturity | Critical | Must have first-party Expo support; no bare-RN-only workarounds | +| Social login providers | High | Email + Google required; Apple required for App Store | +| Wallet provisioning model | High | Automatic on first login; no user ceremony | +| Custody / key security model | High | Must NOT be fully custodial; MPC or TEE preferred | +| Chain support | Medium | All EVM chains (GuildPass is EVM-only) | +| Cost at launch scale | Medium | Free tier or low-cost for <1000 MAU | +| Wallet export | Medium | Users should be able to extract their key if desired | + +--- + +## Providers Evaluated + +### Privy ✅ (Selected) + +| Criterion | Assessment | +|---|---| +| **SDK** | `@privy-io/expo` — first-party, actively maintained, Expo-native. Already in `package.json` | +| **Social login** | Email OTP, Google, Apple, Twitter, Discord, GitHub, Farcaster | +| **Wallet provisioning** | `createOnLogin: "users-without-wallets"` — zero-ceremony Ethereum wallet creation | +| **Custody model** | **MPC 2-of-3 key splitting**: device share (Secure Enclave), Privy infra share (HSM-backed), recovery share (encrypted to user's auth factor). No single party ever holds the full private key | +| **Chain support** | All EVM chains | +| **Cost** | Free: 1,000 MAU. Growth plan: usage-based pricing | +| **Wallet export** | Supported — users can extract their full private key through the SDK | +| **Recovery** | Re-authenticate with same email/social account on any device | + +**Why Privy:** +1. Only provider with a first-party, production-ready Expo SDK (`@privy-io/expo`) +2. MPC key model is the gold standard for embedded wallets — no seed phrase, no full custodial risk +3. `createOnLogin` config eliminates all wallet ceremony +4. Already integrated and partially wired in the codebase +5. Auth-factor-based recovery means users can't lose access by losing a device + +### Web3Auth ❌ + +| Criterion | Assessment | +|---|---| +| **SDK** | `@web3auth/react-native-sdk` — available but heavier; requires additional native module configuration that conflicts with Expo managed workflow | +| **Social login** | Broad provider support | +| **Custody model** | Threshold key splitting (similar to MPC) | +| **Why not** | Heavier SDK footprint, more complex configuration, less Expo-native than Privy. Would require `expo prebuild` for native module linking | + +### Magic (now Magic.link) ❌ + +| Criterion | Assessment | +|---|---| +| **SDK** | `@magic-sdk/react-native` — deprecated React Native SDK, limited maintenance | +| **Why not** | Deprecated RN SDK with no Expo-specific support. Would require significant custom bridging | + +### Turnkey ❌ + +| Criterion | Assessment | +|---|---| +| **SDK** | Lower-level API; no React Native SDK. Requires building custom auth + wallet UI | +| **Why not** | Too low-level for a mobile app. Would require building the entire auth flow, wallet UI, and key management from scratch | + +### Particle Network ❌ + +| Criterion | Assessment | +|---|---| +| **SDK** | `@particle-network/rn-auth` — available but less mature Expo support | +| **Why not** | Less mature Expo support compared to Privy. Smaller developer community | + +--- + +## Security Model + +### What Privy Protects Against + +- **Seed phrase loss/theft**: No seed phrase exists. Key shares are distributed across device, Privy infrastructure, and an auth-factor-encrypted recovery share. +- **Device loss**: User re-authenticates with their email/social account on a new device to reconstruct their key. +- **Single point of compromise**: MPC ensures no single party (not Privy, not the device) ever holds the full private key. + +### What Privy Does NOT Protect Against + +- **Privy infrastructure compromise**: If Privy's HSM-backed infrastructure is breached AND the attacker obtains the device share, the key could be reconstructed. This is a fundamental trust trade-off of any embedded wallet provider. +- **Social account takeover + device theft**: An attacker who compromises both the user's auth factor (email/Google account) AND their device could potentially reconstruct the key. +- **Nation-state actors**: Out of scope, consistent with the project's [threat model](./threat-model.md). + +### Alignment with GuildPass Threat Model + +The existing [threat model](./threat-model.md) already documents "Future: embedded private keys" as a **Critical** asset with planned storage in `expo-secure-store` / Secure Enclave. Privy's device share leverages exactly this: the device key share is stored in the platform's hardware-backed secure enclave (iOS Keychain / Android Keystore), aligned with the existing security architecture. + +--- + +## Integration Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Onboarding Screen │ +│ ┌─────────────────────────────────────────────────┐│ +│ │ CustodyDisclosure (trade-off explanation) ││ +│ │ Email OTP / Google OAuth sign-in ││ +│ └─────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐│ +│ │ Privy SDK (useLoginWithEmail / useLoginWithOAuth)│ +│ │ → auto-creates embedded Ethereum wallet ││ +│ │ → returns EVM address ││ +│ └─────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐│ +│ │ createEmbeddedConnector(address) ││ +│ │ → same WalletConnector interface as manual/WC ││ +│ └─────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐│ +│ │ useWalletStore ││ +│ │ walletAddress = "0x...", connectionKind = "embedded" │ +│ └─────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ All downstream flows (memberships, guilds, access │ +│ checks, sync, attestations) see a standard EVM │ +│ address — completely provider-agnostic. │ +└─────────────────────────────────────────────────────┘ +``` + +Key design principle: **Privy is only the provisioning layer.** Once the embedded wallet address enters `useWalletStore`, every downstream flow treats it identically to a manually-entered or WalletConnect address. No screen, hook, or service needs to know the wallet was provisioned by Privy. diff --git a/src/features/wallet/CustodyDisclosure.tsx b/src/features/wallet/CustodyDisclosure.tsx new file mode 100644 index 0000000..4ee6b9a --- /dev/null +++ b/src/features/wallet/CustodyDisclosure.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import { View, Text, TouchableOpacity, Linking } from "react-native"; + +const PRIVY_SECURITY_URL = "https://docs.privy.io/guide/security"; + +/** + * Custody disclosure shown during embedded-wallet onboarding. + * + * Renders a summary line that is always visible, with a collapsible detail + * section the user can expand. This satisfies the acceptance criterion of + * "clear, honest in-app explanation" without forcing a blocking modal that + * would hurt conversion. + */ +export function CustodyDisclosure({ testID = "custody-disclosure" }: { testID?: string }) { + const [expanded, setExpanded] = useState(false); + + return ( + + {/* ── Always-visible summary ─────────────────────────────────── */} + setExpanded((prev) => !prev)} + accessibilityRole="button" + accessibilityLabel={expanded ? "Collapse wallet details" : "Expand wallet details"} + accessibilityState={{ expanded }} + testID="custody-disclosure-toggle" + activeOpacity={0.7} + > + + + 🔐 + + About your embedded wallet + + + + {expanded ? "Hide ▲" : "Details ▼"} + + + + A secure wallet will be created for you automatically — no seed phrase needed. + + + + {/* ── Expanded detail section ────────────────────────────────── */} + {expanded ? ( + + + + + + + + + + Linking.openURL(PRIVY_SECURITY_URL)} + accessibilityRole="link" + accessibilityLabel="Learn more about Privy security" + testID="custody-disclosure-learn-more" + className="mt-2" + > + + Learn more about Privy's security model → + + + + ) : null} + + ); +} + +function DetailItem({ emoji, title, body }: { emoji: string; title: string; body: string }) { + return ( + + + {emoji} {title} + + + {body} + + + ); +} diff --git a/src/features/wallet/EmbeddedWalletOnboarding.tsx b/src/features/wallet/EmbeddedWalletOnboarding.tsx index 1c07c4a..52b8474 100644 --- a/src/features/wallet/EmbeddedWalletOnboarding.tsx +++ b/src/features/wallet/EmbeddedWalletOnboarding.tsx @@ -7,6 +7,7 @@ import { usePrivy, } from "@privy-io/expo"; import { Button } from "../../components/Button"; +import { CustodyDisclosure } from "./CustodyDisclosure"; import { useWallet } from "./useWallet"; type Props = { onComplete(): void; onBack(): void }; @@ -81,6 +82,7 @@ export function EmbeddedWalletOnboarding({ onComplete, onBack }: Props) { Sign in with email or Google. A secure wallet will be created for you automatically. +