Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ EXPO_PUBLIC_APP_ENV=development

# ─── WalletConnect (optional) ──────────────────────────────────────────────
# Get a project ID at https://cloud.walletconnect.com
# Required for Native Wallet Integration (MetaMask, Trust Wallet, etc.)
# Connected wallets can be cryptographically verified via signature.
# EXPO_PUBLIC_WALLET_CONNECT_PROJECT_ID=

# ─── Embedded Wallets — Privy (optional) ──────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions .maestro/07-walletconnect-flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ appId: xyz.guildpass.mobile
id: "connected-wallet-address"
- assertVisible:
text: "Manual Entry"
- assertVisible:
text: "Unverified"

# ── Step 7: Navigate to guilds and back ────────────────────────────────
- tapOn:
Expand Down
48 changes: 46 additions & 2 deletions app/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,25 @@ const CONNECTION_LABELS: Record<string, string> = {

export default function Profile() {
const router = useRouter();
const { walletAddress, isConnected, connectionKind, connectManually, disconnect } = useWallet();
const { walletAddress, isConnected, connectionKind, isVerified, verifyOwnership, connectManually, disconnect } = useWallet();
const { open } = useWalletConnectModal();
const { isOffline } = useNetworkStatus();
const [inputValue, setInputValue] = useState(walletAddress || "");
const [error, setError] = useState<string | null>(null);
const [showManualEntry, setShowManualEntry] = useState(false);
const [wcConnecting, setWcConnecting] = useState(false);
const [isVerifying, setIsVerifying] = useState(false);
const [verifyError, setVerifyError] = useState<string | null>(null);

const handleVerifyOwnership = async () => {
setIsVerifying(true);
setVerifyError(null);
const { success, error } = await verifyOwnership();
if (!success) {
setVerifyError(error || "Verification failed");
}
setIsVerifying(false);
};

// ── Field-level validation state ────────────────────────────────────
const [fieldError, setFieldError] = useState<string | null>(null);
Expand Down Expand Up @@ -235,9 +247,41 @@ export default function Profile() {
</Text>
<View className="mb-4">
{walletAddress ? (
<WalletAddress address={walletAddress} testID="connected-wallet-address" />
<View className="flex-row items-center justify-between">
<WalletAddress address={walletAddress} testID="connected-wallet-address" />
{isVerified ? (
<View className="bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded">
<Text className="text-green-700 dark:text-green-400 text-xs font-bold">✓ Verified</Text>
</View>
) : connectionKind === "manual" ? (
<View className="bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded">
<Text className="text-slate-500 dark:text-slate-400 text-xs font-bold">Unverified</Text>
</View>
) : null}
</View>
) : null}
</View>

{!isVerified && connectionKind !== "manual" ? (
<View className="mb-4 p-3 bg-primary/10 dark:bg-primary/5 rounded-lg border border-primary/20">
<Text className="text-text dark:text-slate-100 text-sm mb-2 font-medium">
Verify Ownership
</Text>
<Text className="text-text-muted dark:text-slate-400 text-xs mb-3">
Sign a message to verify you control this wallet and unlock full access.
</Text>
{verifyError ? (
<Text className="text-red-500 text-xs mb-3">{verifyError}</Text>
) : null}
<Button
title={isVerifying ? "Verifying..." : "Verify Wallet"}
onPress={handleVerifyOwnership}
loading={isVerifying}
testID="verify-ownership-button"
/>
</View>
) : null}

<Button
title="Disconnect"
onPress={handleDisconnect}
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Each store lives in the feature that owns it and is the only owner of its state.

| Store | Location | Owns | Persistence |
| ----- | -------- | ---- | ----------- |
| `useWalletStore` | `features/wallet/wallet.store.ts` | Connected address, connection status, connector kind | SecureStore |
| `useWalletStore` | `features/wallet/wallet.store.ts` | Connected address, connection status, connector kind, cryptographic verification state (`isVerified`) | SecureStore |
| `useSessionStore` | `features/session/session.store.ts` | Auth status, token, expiry, session adapter | SecureStore |
| `useSyncStore` | `features/sync/sync.store.ts` | Sync status, per-entity sync metadata, unacknowledged corrections | SecureStore |
| `useReconciliationStore` | `features/notifications/reconciliation.store.ts` | Highest processed `roleChangeSeq` per (guild, wallet) | SecureStore |
Expand Down Expand Up @@ -76,6 +76,8 @@ into `useWalletStore`:
| **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 |

**Trust Model & Verification:** Connecting a wallet via WalletConnect or entering one manually populates the address, but does not inherently prove cryptographic ownership. The `isVerified` state in `useWalletStore` tracks whether the user has successfully signed a verification message (`personal_sign`). Manually entered wallets are permanently unverified. Connected wallets require an explicit signature before `isVerified` becomes true, enabling stronger trust guarantees.

**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
Expand Down
61 changes: 59 additions & 2 deletions src/features/wallet/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const useWallet = (): {
walletAddress: string | null;
isConnected: boolean;
connectionKind: "manual" | "walletconnect" | "embedded" | "coinbase" | "metamask" | null;
isVerified: boolean;
isHydrated: boolean;
connectManually: (address: string) => { success: boolean; error?: string };
/** Store an EVM address created by an embedded wallet provider. */
Expand All @@ -26,6 +27,7 @@ export const useWallet = (): {
request(args: { method: string }): Promise<unknown>;
disconnect(): Promise<void>;
}) => Promise<{ success: boolean; error?: string }>;
verifyOwnership: () => Promise<{ success: boolean; error?: string }>;
disconnect: () => Promise<void>;
} => {
const walletAddress = useWalletStore((s) => s.walletAddress);
Expand All @@ -35,10 +37,13 @@ export const useWallet = (): {
const setWalletAddress = useWalletStore((s) => s.setWalletAddress);
const storeDisconnect = useWalletStore((s) => s.disconnect);

const isVerified = useWalletStore((s) => s.isVerified);
const setVerified = useWalletStore((s) => s.setVerified);

const connectManually = (address: string): { success: boolean; error?: string } => {
const result = validateAndNormalizeAddress(address);
if (!result.valid) return { success: false, error: result.error };
setWalletAddress(result.address, "manual");
setWalletAddress(result.address, "manual", false);
void startWalletSession(result.address!);
return { success: true };
};
Expand All @@ -59,7 +64,8 @@ export const useWallet = (): {
if (!accounts.length) return { success: false, error: "No accounts returned" };
const result = validateAndNormalizeAddress(accounts[0]);
if (!result.valid) return { success: false, error: result.error };
setWalletAddress(result.address, connector.type);
// Connected but not yet verified
setWalletAddress(result.address, connector.type, false);
await startWalletSession(result.address!);
return { success: true };
} catch (e) {
Expand All @@ -79,6 +85,55 @@ export const useWallet = (): {
[setWalletAddress],
);

const verifyOwnership = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!isConnected || !walletAddress) {
return { success: false, error: "No wallet connected" };
}

if (connectionKind === "manual") {
return { success: false, error: "Manual entries cannot be cryptographically verified" };
}

try {
// 1. Get the provider. For WalletConnect, it's stored globally.
const wcProvider = getWalletConnectProvider();
if (!wcProvider) {
return { success: false, error: "No active WalletConnect provider found" };
}

// 2. Request signature
const message = "Sign this message to verify your wallet ownership for GuildPass.";

const { verifyMessage, stringToHex } = await import("viem");
const hexMessage = stringToHex(message);

const signature = await wcProvider.request({
method: "personal_sign",
params: [hexMessage, walletAddress.toLowerCase()],
}) as string;

if (!signature) {
return { success: false, error: "User rejected the signature request" };
}

// 3. Verify signature using viem
const isValid = await verifyMessage({
address: walletAddress as `0x${string}`,
message,
signature: signature as `0x${string}`,
});

if (isValid) {
setVerified(true);
return { success: true };
} else {
return { success: false, error: "Signature verification failed" };
}
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Verification failed" };
}
}, [isConnected, walletAddress, connectionKind, setVerified]);

const disconnect = useCallback(async () => {
// If connected via WalletConnect, tear down the WC session first
if (connectionKind === "walletconnect") {
Expand Down Expand Up @@ -107,11 +162,13 @@ export const useWallet = (): {
walletAddress,
isConnected,
connectionKind,
isVerified,
isHydrated,
connectManually,
connectEmbeddedWallet,
connectWithConnector,
connectWalletConnect,
verifyOwnership,
disconnect,
};
};
Expand Down
9 changes: 7 additions & 2 deletions src/features/wallet/wallet.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export const useWalletStore = create<WalletState & WalletActions & { _hasHydrate
walletAddress: null,
isConnected: false,
connectionKind: null,
isVerified: false,
_hasHydrated: false,
setHasHydrated: (state) => set({ _hasHydrated: state }),
setWalletAddress: (address, kind?: WalletConnectionKind) => {
setWalletAddress: (address, kind?: WalletConnectionKind, isVerified?: boolean) => {
const result = validateAndNormalizeAddress(address);
if (!result.valid) {
return;
Expand All @@ -21,23 +22,27 @@ export const useWalletStore = create<WalletState & WalletActions & { _hasHydrate
walletAddress: result.address,
isConnected: true,
connectionKind: kind ?? "manual",
isVerified: isVerified ?? false,
});
},
setVerified: (status: boolean) => set({ isVerified: status }),
disconnect: () =>
set({
walletAddress: null,
isConnected: false,
connectionKind: null,
isVerified: false,
}),
}),
{
name: "wallet-storage",
storage: createJSONStorage(() => migratingSecureStorage),
// Only persist the address, not transient WC session state
// Only persist the address, connection state, and verification status
partialize: (state) => ({
walletAddress: state.walletAddress,
isConnected: state.isConnected,
connectionKind: state.connectionKind,
isVerified: state.isVerified,
}),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
Expand Down
5 changes: 4 additions & 1 deletion src/features/wallet/wallet.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ export type WalletState = {
isConnected: boolean;
/** Which provider established the current connection */
connectionKind: WalletConnectionKind;
/** Whether ownership of the wallet has been verified via a signature */
isVerified: boolean;
_hasHydrated: boolean;
};

export type WalletActions = {
setWalletAddress: (address: string | null, kind?: WalletConnectionKind) => void;
setWalletAddress: (address: string | null, kind?: WalletConnectionKind, isVerified?: boolean) => void;
setVerified: (status: boolean) => void;
disconnect: () => void;
setHasHydrated: (state: boolean) => void;
};