diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx
index a8adf70..b7d03ac 100644
--- a/app/(auth)/register.tsx
+++ b/app/(auth)/register.tsx
@@ -16,6 +16,7 @@ import { Input } from '../../components/shared/Input';
import { useUserStore } from '../../stores/user.store';
import { useAuthStore } from '../../stores/auth.store';
import { useWalletStore } from '../../stores/wallet.store';
+import { authService } from '../../services/auth.service';
import { useTranslation } from '../../hooks/useTranslation';
import type { LearnerProfile } from '../../types/user.types';
@@ -27,6 +28,8 @@ export default function RegisterScreen() {
const setTokens = useAuthStore((s) => s.setTokens);
const setWallet = useAuthStore((s) => s.setWallet);
const publicKey = useWalletStore((s) => s.address);
+ const walletType = useWalletStore((s) => s.walletType);
+ const sessionId = useWalletStore((s) => s.sessionId);
const [displayName, setDisplayName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -49,12 +52,16 @@ export default function RegisterScreen() {
const handleComplete = async () => {
if (!isValid) return;
+ if (!publicKey || !walletType) {
+ setSubmitError('No wallet connected. Please connect your wallet and try again.');
+ return;
+ }
setIsSubmitting(true);
setSubmitError(null);
try {
const profile: LearnerProfile = {
- walletAddress: publicKey ?? '',
+ walletAddress: publicKey,
displayName: displayName.trim(),
role: role ?? 'learner',
school: isLearner ? school.trim() : undefined,
@@ -66,8 +73,11 @@ export default function RegisterScreen() {
setProfile(profile);
- await setTokens('mock-access-token', 'mock-refresh-token');
- await setWallet(publicKey ?? '');
+ // Real wallet-signature auth: sign the SEP-10-style challenge with the
+ // connected wallet and exchange it for JWT tokens.
+ const tokens = await authService.authenticate(publicKey, walletType, sessionId);
+ await setTokens(tokens.accessToken, tokens.refreshToken);
+ await setWallet(publicKey);
} catch (err) {
console.error('[register] failed to complete registration', err);
const message =
diff --git a/components/pages/SignIn.tsx b/components/pages/SignIn.tsx
deleted file mode 100644
index c95f565..0000000
--- a/components/pages/SignIn.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import React from 'react';
-import {
- View,
- Text,
- TextInput,
- TouchableOpacity,
- Pressable,
- Image,
- KeyboardAvoidingView,
- Platform,
- ScrollView,
- Alert,
-} from 'react-native';
-import { User, Lock, Eye, EyeOff, Wallet, ArrowRight } from 'lucide-react-native';
-import { useSignIn } from '../../hooks/auth/use-sign-in';
-
-export default function SignInScreen() {
- const {
- formState,
- isValid,
- handleUsernameChange,
- handlePasswordChange,
- toggleSecureText,
- handleSignIn,
- } = useSignIn();
-
- return (
-
- {/* contentContainerStyle does not support className in NativeWind */}
-
- {/* Logo Section */}
-
-
- {/* Image requires numeric dimensions; resizeMode set via prop */}
-
-
- Trust Up
-
- Build your reputation, unlock your credit
-
-
-
- {/* Form Fields */}
-
- Username
-
-
-
-
-
- Password
-
-
-
-
- {formState.secureText ? (
-
- ) : (
-
- )}
-
-
-
-
- Forgot password?
-
-
- {/* Sign In Button */}
-
-
- Sign In
-
-
-
-
-
- {/* Divider */}
-
-
- OR
-
-
-
- {/* Connect Wallet */}
-
-
- Connect Wallet
-
-
- {/* Footer */}
-
- Don't have an account?
-
- Sign Up
-
-
-
-
- );
-}
diff --git a/hooks/auth/use-sign-in.ts b/hooks/auth/use-sign-in.ts
deleted file mode 100644
index 17434ee..0000000
--- a/hooks/auth/use-sign-in.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useState, useCallback, useMemo } from 'react';
-
-/**
- * Form field state for the sign-in form
- */
-export interface SignInFormState {
- username: string;
- password: string;
- secureText: boolean;
-}
-
-/**
- * Validation errors for sign-in form
- */
-export interface SignInErrors {
- username: string;
- password: string;
-}
-
-/**
- * Return type for useSignIn hook
- */
-export interface UseSignInReturn {
- // Form state
- formState: SignInFormState;
- errors: SignInErrors;
- isSubmitting: boolean;
- isValid: boolean;
-
- // Field change handlers
- handleUsernameChange: (text: string) => void;
- handlePasswordChange: (text: string) => void;
- toggleSecureText: () => void;
-
- // Form submission
- handleSignIn: () => void;
-}
-
-/**
- * Initial form state
- */
-const initialFormState: SignInFormState = {
- username: '',
- password: '',
- secureText: true,
-};
-
-/**
- * Initial errors state
- */
-const initialErrors: SignInErrors = {
- username: '',
- password: '',
-};
-
-/**
- * Custom hook for managing sign-in form state and validation
- */
-export const useSignIn = (): UseSignInReturn => {
- const [formState, setFormState] = useState(initialFormState);
- const [errors, setErrors] = useState(initialErrors);
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- // Field change handlers
- const handleUsernameChange = useCallback((text: string) => {
- setFormState((prev: SignInFormState) => ({ ...prev, username: text }));
-
- if (text.trim().length === 0) {
- setErrors((prev: SignInErrors) => ({ ...prev, username: 'Username is required' }));
- } else {
- setErrors((prev: SignInErrors) => ({ ...prev, username: '' }));
- }
- }, []);
-
- const handlePasswordChange = useCallback((text: string) => {
- setFormState((prev: SignInFormState) => ({ ...prev, password: text }));
-
- if (text.trim().length === 0) {
- setErrors((prev: SignInErrors) => ({ ...prev, password: 'Password is required' }));
- } else {
- setErrors((prev: SignInErrors) => ({ ...prev, password: '' }));
- }
- }, []);
-
- const toggleSecureText = useCallback(() => {
- setFormState((prev: SignInFormState) => ({ ...prev, secureText: !prev.secureText }));
- }, []);
-
- // Form validation
- const isValid = useMemo(() => {
- return (
- formState.username.trim().length > 0 &&
- formState.password.trim().length > 0 &&
- errors.username === '' &&
- errors.password === ''
- );
- }, [formState, errors]);
-
- // Sign-in handler
- const handleSignIn = useCallback(() => {
- if (!isValid) return;
-
- setIsSubmitting(true);
-
- // Simulate API call
- setTimeout(() => {
- console.log('Sign In Data:', {
- username: formState.username,
- password: formState.password,
- });
- setIsSubmitting(false);
- }, 1000);
- }, [isValid, formState]);
-
- return {
- // Form state
- formState,
- errors,
- isSubmitting,
- isValid,
-
- // Field change handlers
- handleUsernameChange,
- handlePasswordChange,
- toggleSecureText,
-
- // Form submission
- handleSignIn,
- };
-};
diff --git a/services/__tests__/auth.service.test.ts b/services/__tests__/auth.service.test.ts
new file mode 100644
index 0000000..8e76ec4
--- /dev/null
+++ b/services/__tests__/auth.service.test.ts
@@ -0,0 +1,137 @@
+/**
+ * Tests for the wallet-signature auth service (services/auth.service.ts).
+ *
+ * Focus: the end-to-end `authenticate` orchestration for the SEP-10-style
+ * challenge-transaction flow —
+ * getNonce → sign challengeXdr with the connected wallet → verify → tokens.
+ *
+ * Both wallet types are covered (Freighter signs directly; Lobstr signs over
+ * WalletConnect using the stored sessionId), along with the error paths:
+ * a wallet signing failure and a missing Lobstr session both surface as a
+ * `WALLET_SIGNING_FAILED` ApiClientError, and network failures propagate as
+ * ApiClientError from the underlying calls.
+ */
+
+/* eslint-disable @typescript-eslint/no-require-imports */
+
+const mockPost = jest.fn();
+const mockSignWithFreighter = jest.fn();
+const mockSignWithLobstr = jest.fn();
+
+jest.mock('../api', () => ({
+ __esModule: true,
+ default: { post: mockPost },
+}));
+
+jest.mock('../wallet.service', () => ({
+ walletService: {
+ signWithFreighter: mockSignWithFreighter,
+ signWithLobstr: mockSignWithLobstr,
+ },
+}));
+
+jest.mock('../sentry', () => ({
+ addBreadcrumb: jest.fn(),
+ captureServiceError: jest.fn(),
+}));
+
+import { authService } from '../auth.service';
+import { ApiClientError, ApiErrorCode } from '../../types/errors';
+
+const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW';
+const NONCE = 'a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890';
+const CHALLENGE_XDR = 'AAAAAgAAAAMOCK_CHALLENGE_XDR';
+const SIGNED_XDR = 'AAAAAgAAAAMOCK_SIGNED_XDR';
+
+const NONCE_RESPONSE = {
+ nonce: NONCE,
+ expiresAt: '2026-01-01T00:05:00.000Z',
+ challengeXdr: CHALLENGE_XDR,
+};
+const TOKENS = {
+ accessToken: 'real.access.jwt',
+ refreshToken: 'real.refresh.jwt',
+ expiresIn: 3600,
+};
+
+/** Wires the two POSTs authenticate makes: /auth/nonce then /auth/verify. */
+function wireHappyPath() {
+ mockPost.mockImplementation((url: string) => {
+ if (url === '/auth/nonce') return Promise.resolve({ data: NONCE_RESPONSE });
+ if (url === '/auth/verify') return Promise.resolve({ data: TOKENS });
+ return Promise.reject(new Error(`unexpected POST ${url}`));
+ });
+}
+
+describe('authService.authenticate (SEP-10 challenge transaction)', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('freighter: fetches a nonce, signs the challengeXdr, verifies as sep0010, returns tokens', async () => {
+ wireHappyPath();
+ mockSignWithFreighter.mockResolvedValue(SIGNED_XDR);
+
+ const tokens = await authService.authenticate(WALLET, 'freighter', null);
+
+ expect(mockPost).toHaveBeenNthCalledWith(1, '/auth/nonce', { wallet: WALLET });
+ expect(mockSignWithFreighter).toHaveBeenCalledWith(CHALLENGE_XDR);
+ expect(mockSignWithLobstr).not.toHaveBeenCalled();
+ expect(mockPost).toHaveBeenNthCalledWith(2, '/auth/verify', {
+ wallet: WALLET,
+ nonce: NONCE,
+ signedXdr: SIGNED_XDR,
+ signatureType: 'sep0010',
+ });
+ expect(tokens).toEqual(TOKENS);
+ });
+
+ it('lobstr: signs the challengeXdr over WalletConnect using the sessionId', async () => {
+ wireHappyPath();
+ mockSignWithLobstr.mockResolvedValue(SIGNED_XDR);
+
+ const tokens = await authService.authenticate(WALLET, 'lobstr', 'session-123');
+
+ expect(mockSignWithLobstr).toHaveBeenCalledWith(CHALLENGE_XDR, 'session-123', WALLET);
+ expect(mockSignWithFreighter).not.toHaveBeenCalled();
+ expect(mockPost).toHaveBeenNthCalledWith(2, '/auth/verify', {
+ wallet: WALLET,
+ nonce: NONCE,
+ signedXdr: SIGNED_XDR,
+ signatureType: 'sep0010',
+ });
+ expect(tokens).toEqual(TOKENS);
+ });
+
+ it('lobstr with no sessionId → WALLET_SIGNING_FAILED (verify never called)', async () => {
+ wireHappyPath();
+
+ await expect(authService.authenticate(WALLET, 'lobstr', null)).rejects.toMatchObject({
+ code: ApiErrorCode.WALLET_SIGNING_FAILED,
+ });
+ expect(mockSignWithLobstr).not.toHaveBeenCalled();
+ expect(mockPost).toHaveBeenCalledTimes(1); // only /auth/nonce
+ expect(mockPost).not.toHaveBeenCalledWith('/auth/verify', expect.anything());
+ });
+
+ it('wraps a wallet signing rejection as a WALLET_SIGNING_FAILED ApiClientError', async () => {
+ wireHappyPath();
+ mockSignWithFreighter.mockRejectedValue(new Error('user rejected'));
+
+ const err = await authService.authenticate(WALLET, 'freighter', null).catch((e) => e);
+ expect(err).toBeInstanceOf(ApiClientError);
+ expect(err.code).toBe(ApiErrorCode.WALLET_SIGNING_FAILED);
+ expect(mockPost).not.toHaveBeenCalledWith('/auth/verify', expect.anything());
+ });
+
+ it('propagates a nonce network failure as ApiClientError (signing never attempted)', async () => {
+ mockPost.mockRejectedValueOnce(
+ new ApiClientError({ code: ApiErrorCode.NETWORK_ERROR, message: 'down' })
+ );
+
+ const err = await authService.authenticate(WALLET, 'freighter', null).catch((e) => e);
+ expect(err).toBeInstanceOf(ApiClientError);
+ expect(err.code).toBe(ApiErrorCode.NETWORK_ERROR);
+ expect(mockSignWithFreighter).not.toHaveBeenCalled();
+ });
+});
diff --git a/services/auth.service.ts b/services/auth.service.ts
index 22caaff..88eb0bd 100644
--- a/services/auth.service.ts
+++ b/services/auth.service.ts
@@ -1,10 +1,24 @@
import api from './api';
+import { walletService } from './wallet.service';
import { addBreadcrumb, captureServiceError } from './sentry';
import { ApiClientError, ApiErrorCode } from '../types/errors';
+export type WalletType = 'freighter' | 'lobstr';
+
export interface NonceResponse {
nonce: string;
expiresAt: string;
+ /**
+ * Canonical challenge message (used by the message-signing schemes). Retained
+ * for completeness; the SEP-10 flow signs `challengeXdr` instead.
+ */
+ message?: string;
+ /**
+ * Unsigned SEP-10-style challenge transaction (base64 envelope XDR). The
+ * connected wallet signs this and returns it as `signedXdr` — this is what
+ * lets transaction-only wallets (mobile Lobstr over WalletConnect) authenticate.
+ */
+ challengeXdr: string;
}
export interface AuthTokens {
@@ -33,10 +47,15 @@ export const authService = {
}
},
- async verify(wallet: string, nonce: string, signature: string): Promise {
- addBreadcrumb('auth.service', 'Verifying wallet signature');
+ async verify(wallet: string, nonce: string, signedXdr: string): Promise {
+ addBreadcrumb('auth.service', 'Verifying signed challenge transaction');
try {
- const res = await api.post('/auth/verify', { wallet, nonce, signature });
+ const res = await api.post('/auth/verify', {
+ wallet,
+ nonce,
+ signedXdr,
+ signatureType: 'sep0010',
+ });
addBreadcrumb('auth.service', 'Wallet verified successfully');
return res.data;
} catch (error) {
@@ -51,6 +70,48 @@ export const authService = {
}
},
+ /**
+ * End-to-end wallet-signature login: fetch a nonce + challenge transaction,
+ * have the connected wallet sign the challenge XDR (Lobstr over WalletConnect
+ * or Freighter — both sign transactions, which is exactly what the SEP-10-style
+ * scheme needs), then exchange the signed challenge for JWT tokens.
+ *
+ * Errors surface as `ApiClientError` from the underlying calls; a wallet
+ * rejection/failure surfaces as a `WALLET_SIGNING_FAILED` `ApiClientError`.
+ */
+ async authenticate(
+ wallet: string,
+ walletType: WalletType,
+ sessionId: string | null
+ ): Promise {
+ addBreadcrumb('auth.service', `Authenticating via ${walletType}`);
+ const { nonce, challengeXdr } = await this.getNonce(wallet);
+
+ let signedXdr: string;
+ try {
+ if (walletType === 'lobstr') {
+ if (!sessionId) {
+ throw new Error('Lobstr session not found. Please reconnect your wallet.');
+ }
+ signedXdr = await walletService.signWithLobstr(challengeXdr, sessionId, wallet);
+ } else {
+ signedXdr = await walletService.signWithFreighter(challengeXdr);
+ }
+ } catch (error) {
+ captureServiceError('auth', 'authenticate.sign', error);
+ if (error instanceof ApiClientError) throw error;
+ throw new ApiClientError({
+ code: ApiErrorCode.WALLET_SIGNING_FAILED,
+ message: 'Failed to sign the authentication challenge',
+ userMessage:
+ 'Could not sign the authentication request with your wallet. Please try again.',
+ cause: error,
+ });
+ }
+
+ return this.verify(wallet, nonce, signedXdr);
+ },
+
async refresh(refreshToken: string): Promise {
addBreadcrumb('auth.service', 'Refreshing auth tokens');
try {
diff --git a/types/errors.ts b/types/errors.ts
index 1afdb00..5626cbb 100644
--- a/types/errors.ts
+++ b/types/errors.ts
@@ -30,6 +30,8 @@ export enum ApiErrorCode {
NETWORK_ERROR = 'NETWORK_ERROR',
/** Request was queued for offline replay */
OFFLINE_QUEUED = 'OFFLINE_QUEUED',
+ /** Wallet failed to sign, or the user rejected the signing request */
+ WALLET_SIGNING_FAILED = 'WALLET_SIGNING_FAILED',
/** Catch-all for unexpected errors */
UNKNOWN = 'UNKNOWN',
}
@@ -168,6 +170,8 @@ function userFacingMessage(code: ApiErrorCode, statusCode: number, serverMsg?: s
return 'A network error occurred. Please check your connection.';
case ApiErrorCode.OFFLINE_QUEUED:
return "We'll complete this action once you're back online.";
+ case ApiErrorCode.WALLET_SIGNING_FAILED:
+ return 'Could not sign the request with your wallet. Please try again.';
default:
return 'An unexpected error occurred. Please try again.';
}