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
93 changes: 93 additions & 0 deletions src/db/services/users-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@/db/voting-schema';
import { UserWithAddress } from '@/types/users';
import { logger } from '@/utils/logger';
import { validateEthereumAddress } from '@/utils/viem';

export interface NeynarUserResponse {
user: {
Expand Down Expand Up @@ -102,6 +103,19 @@ export class UsersService {

// If address is provided and user doesn't have it, add it
if (address) {
// Validate address before processing
try {
validateEthereumAddress(address);
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Invalid address format',
};
}

const hasAddress = (existingUser.addresses as Address[])?.some(
addr => addr.address.toLowerCase() === address.toLowerCase()
);
Expand Down Expand Up @@ -159,6 +173,17 @@ export class UsersService {

// Check if address-only user exists
if (address && fid === undefined) {
// Validate address before processing
try {
validateEthereumAddress(address);
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Invalid address format',
};
}

const existingUserByAddress = await this.getUserByAddress(address);
if (existingUserByAddress) {
return {
Expand All @@ -181,6 +206,17 @@ export class UsersService {

// Add address if provided (with xmtp_enabled: false initially)
if (address) {
// Validate address before insertion
try {
validateEthereumAddress(address);
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Invalid address format',
};
}

console.log('[UsersService] Adding address to new user:', {
userId: user.id,
address,
Expand Down Expand Up @@ -227,6 +263,14 @@ export class UsersService {
}

async getUserByAddress(address: string): Promise<UserWithAddress | null> {
// Validate address before querying
try {
validateEthereumAddress(address);
} catch {
// Return null for invalid addresses instead of throwing (this is a lookup method)
return null;
}

const result = await this.db
.select({
id: users.id,
Expand Down Expand Up @@ -267,6 +311,17 @@ export class UsersService {
error?: string;
}> {
try {
// Validate address before processing
try {
validateEthereumAddress(address);
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Invalid address format',
};
}

// First find or create the user
const userResult = await this.getUser(userId);

Expand Down Expand Up @@ -344,6 +399,17 @@ export class UsersService {
error?: string;
}> {
try {
// Validate address before processing
try {
validateEthereumAddress(address);
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Invalid address format',
};
}

// Check if address exists for this user
const existingAddress = await this.db
.select()
Expand Down Expand Up @@ -764,6 +830,33 @@ export class UsersService {

logger.debug(`🔍 DEBUG: Cleaned username: "${cleanUsername}"`);

// Validate input length - Neynar API requires q parameter to be 20 characters or less
if (cleanUsername.length > 20) {
// Check if it looks like an Ethereum address
if (cleanUsername.startsWith('0x') && cleanUsername.length === 42) {
logger.debug(
`❌ DEBUG: Ethereum address search not supported: "${cleanUsername}"`
);
return {
success: true,
users: [],
error:
'Address-based search is not supported. Please search by username instead.',
};
}

// Generic long string error
logger.debug(
`❌ DEBUG: Search query too long (${cleanUsername.length} chars, max 20): "${cleanUsername}"`
);
return {
success: true,
users: [],
error:
'Search query must be 20 characters or less. Please use a shorter search term.',
};
}

try {
// Use Neynar search endpoint for fuzzy matching
if (!this.neynarApiKey) {
Expand Down
54 changes: 54 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
requireSelfOrAdmin,
} from '../middleware/auth';
import { verifyAddressSignature } from '../utils/signature-verification';
import { validateEthereumAddress } from '../utils/viem';
import { sendMemberChangeWebhook } from '../utils/webhook-utils';
import {
addMembersToXmtpGroup,
Expand Down Expand Up @@ -89,6 +90,19 @@ async function sendXmtpGroupInvitation(
users.get('/address/:address', requirePermission('read'), async c => {
const address = c.req.param('address');

// Validate address parameter
try {
validateEthereumAddress(address);
} catch (error) {
return c.json(
{
error:
error instanceof Error ? error.message : 'Invalid address format',
},
400
);
}

const usersService = getUsersService();
const user = await usersService.getUserByAddress(address);

Expand Down Expand Up @@ -423,6 +437,18 @@ users.post('/search', requirePermission('read'), async c => {
return c.json({ error: result.error || 'Search failed' }, 500);
}

// Handle validation errors (e.g., search term too long, address search)
if (result.error) {
return c.json(
{
error: result.error,
users: result.users || [],
hint: 'Try searching with a shorter username or handle',
},
400
);
}

// For each user result, create or get the user to ensure we have their database ID
const usersWithDbIds = await Promise.all(
(result.users || []).map(async user => {
Expand Down Expand Up @@ -457,6 +483,21 @@ users.get('/verifications', requirePermission('read'), async c => {
return c.json({ error: 'FID parameter required' }, 400);
}

// Validate address parameter if provided
if (address) {
try {
validateEthereumAddress(address);
} catch (error) {
return c.json(
{
error:
error instanceof Error ? error.message : 'Invalid address format',
},
400
);
}
}

try {
// Using correct Neynar API endpoint for verifications by FID
const url = new URL('https://hub-api.neynar.com/v1/verificationsByFid');
Expand Down Expand Up @@ -927,6 +968,19 @@ users.get(
async c => {
const address = c.req.param('address');

// Validate address parameter
try {
validateEthereumAddress(address);
} catch (error) {
return c.json(
{
error:
error instanceof Error ? error.message : 'Invalid address format',
},
400
);
}

const usersService = getUsersService();
const user = await usersService.getUserByAddress(address);

Expand Down
29 changes: 28 additions & 1 deletion src/utils/viem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createPublicClient, http } from 'viem';
import { createPublicClient, http, isAddress } from 'viem';
import { base, mainnet } from 'viem/chains';

// Map chain IDs to chain configurations
Expand All @@ -20,3 +20,30 @@ export function getPublicClient(chainId: number) {
transport: http(),
});
}

/**
* Validate an Ethereum address using viem's isAddress function
* Ensures address is 0x prefixed and 42 characters long with valid hex characters
* @param address - The address to validate
* @throws Error if address is invalid
*/
export function validateEthereumAddress(address: string): void {
if (!address) {
throw new Error('Address is required');
}

if (!isAddress(address)) {
throw new Error(
`Invalid Ethereum address: ${address}. Address must be 0x prefixed and 42 characters long with valid hexadecimal characters.`
);
}
}

/**
* Check if a string is a valid Ethereum address
* @param address - The address to check
* @returns true if valid, false otherwise
*/
export function isValidEthereumAddress(address: string): boolean {
return address && isAddress(address);
}
19 changes: 11 additions & 8 deletions tests/routes/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,9 @@ describe('Users Routes', () => {

it('should return 404 for non-existent address', async () => {
const headers = getTestAuthHeaders();
const res = await app.request('/api/v1/users/address/0xnonexistent', {
// Use a unique address that won't be used by any other tests
const nonExistentAddress = `0x${Date.now().toString(16).padStart(40, '0')}`;
const res = await app.request(`/api/v1/users/address/${nonExistentAddress}`, {
headers: headers.user1,
});

Expand All @@ -344,7 +346,7 @@ describe('Users Routes', () => {
const res = await app.request('/api/v1/users', {
method: 'POST',
headers: { ...headers.eliza, 'Content-Type': 'application/json' },
body: JSON.stringify({ address: '0xnewaddress' }),
body: JSON.stringify({ address: '0x1111111111111111111111111111111111111111' }),
});
const data = await res.json();

Expand All @@ -359,7 +361,7 @@ describe('Users Routes', () => {
const res = await app.request('/api/v1/users', {
method: 'POST',
headers: { ...headers.eliza, 'Content-Type': 'application/json' },
body: JSON.stringify({ address: '0xnewaddress2', fid: 99998 }),
body: JSON.stringify({ address: '0x2222222222222222222222222222222222222222', fid: 99998 }),
});
const data = await res.json();

Expand Down Expand Up @@ -394,24 +396,25 @@ describe('Users Routes', () => {
expect(data.error).toBe('Invalid request data');
});

it('should handle service error', async () => {
it('should return 400 for invalid address format', async () => {
const headers = getTestAuthHeaders();
// Test with valid request data - service should handle gracefully
const res = await app.request('/api/v1/users', {
method: 'POST',
headers: { ...headers.eliza, 'Content-Type': 'application/json' },
body: JSON.stringify({ address: 'invalid-address-format' }),
});

// Should succeed since address validation isn't done at service level
expect(res.status).toBe(201);
// Should return 400 since address validation is now done at service level
const data = await res.json();
expect(res.status).toBe(400);
expect(data.error).toContain('Invalid Ethereum address');
});

it('should require authentication', async () => {
const res = await app.request('/api/v1/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: '0xnewaddress' }),
body: JSON.stringify({ address: '0x3333333333333333333333333333333333333333' }),
});
const data = await res.json();

Expand Down
Loading