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
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ model User {
name String?
role String @default("USER")
googleId String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
wallets Wallet[]
Expand Down
5 changes: 5 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { createTransactionBuilder } = require('./builders/transaction.builder');
const { createEscrowService } = require('./services/escrow.service');
const { createHorizonService } = require('./services/horizon.service');
const { createStellarService } = require('./services/stellar.service');
const { createEmbeddedWalletProvider } = require('./providers/embedded-wallet.provider');

let config;
try {
Expand Down Expand Up @@ -43,6 +44,8 @@ const transactionBuilder = createTransactionBuilder({ server, contract, config }
const escrowService = createEscrowService({ transactionBuilder, config, escrowRepository, transactionRepository });
const horizonService = createHorizonService({ server });
const stellarService = createStellarService({ config, server });
// eslint-disable-next-line no-unused-vars
const embeddedWalletProvider = createEmbeddedWalletProvider({ config });

const app = express();
const PORT = config.PORT;
Expand All @@ -52,12 +55,14 @@ app.use(express.json());

const authRoutes = require('./routes/auth.routes');
const usersRoutes = require('./routes/users.routes');
const accountsRoutes = require('./routes/accounts.routes');

// API Routes
const relayerRoutes = createRelayerRoutes({ escrowService, horizonService, stellarService });
app.use('/health', healthRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/users/me', authenticate, usersRoutes);
app.use('/api/accounts/me', authenticate, accountsRoutes);
app.use('/api/relayer', relayerRoutes);

// Error Handling Middleware
Expand Down
62 changes: 62 additions & 0 deletions src/providers/embedded-wallet.provider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const crypto = require('crypto');

/**
* @typedef {Object} IWalletProvider
* @property {function(string): Promise<{ address: string }>} createWallet - Creates a new embedded wallet for a user.
* @property {function(string): Promise<{ address: string } | null>} getWallet - Retrieves the embedded wallet address for a user.
*/

/**
* Factory function for the generic Embedded Wallet Provider abstraction.
*
* This provider satisfies the IWalletProvider interface. It acts as an adapter
* to an underlying embedded wallet infrastructure (e.g., Privy, Turnkey).
* By strictly returning only public addresses (and never private keys), it ensures
* the backend remains completely non-custodial and safely agnostic to the actual provider.
*
* @param {Object} [deps] - Dependencies
* @param {Object} [deps.config] - Application configuration.
* @returns {IWalletProvider}
*/
const createEmbeddedWalletProvider = ({ _config } = {}) => {
// In-memory store for mocked wallet addresses.
// In a real integration, this would communicate with the provider's API.
const mockWallets = new Map();

/**
* Creates a new embedded wallet for a user.
*
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<{ address: string }>} The public address of the generated wallet.
*/
const createWallet = async (userId) => {
if (!userId) {
throw new Error('userId is required to create a wallet');
}

// Stub: Generate a deterministic mock address based on userId
const address = `G_MOCK_${crypto.createHash('sha256').update(userId).digest('hex').substring(0, 40).toUpperCase()}`;
mockWallets.set(userId, address);

return { address };
};

/**
* Retrieves the embedded wallet address for a user.
*
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<{ address: string } | null>} The public address, or null if not found.
*/
const getWallet = async (userId) => {
if (!userId) {
throw new Error('userId is required to get a wallet');
}

const address = mockWallets.get(userId) || null;
return address ? { address } : null;
};

return { createWallet, getWallet };
};

module.exports = { createEmbeddedWalletProvider };
30 changes: 30 additions & 0 deletions src/routes/accounts.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const express = require('express');
const router = express.Router();
const { UserRepository } = require('../repositories/user.repository');
const prisma = require('../clients/prisma.client');
const AppError = require('../errors/AppError');

const userRepository = new UserRepository(prisma);

router.get('/', async (req, res, next) => {
try {
const user = await userRepository.findById(req.user.id);
if (!user) {
throw new AppError('User not found', 404);
}

// Return only logical account state
res.status(200).json({
success: true,
message: 'Account status retrieved successfully',
data: {
isActive: user.isActive,
createdAt: user.createdAt,
},
});
} catch (error) {
next(error);
}
});

module.exports = router;
52 changes: 52 additions & 0 deletions tests/providers/embedded-wallet.provider.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const { createEmbeddedWalletProvider } = require('../../src/providers/embedded-wallet.provider');

describe('EmbeddedWalletProvider', () => {
let provider;

beforeEach(() => {
provider = createEmbeddedWalletProvider({ config: { NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015' } });
});

describe('createWallet', () => {
it('creates a deterministic mock wallet address for a user ID', async () => {
const userId = 'user-123';
const result = await provider.createWallet(userId);

expect(result).toHaveProperty('address');
expect(result.address.startsWith('G_MOCK_')).toBe(true);
expect(result.address.length).toBeGreaterThan(10);
});

it('returns the same address for the same user ID', async () => {
const userId = 'user-456';
const result1 = await provider.createWallet(userId);
const result2 = await provider.createWallet(userId);

expect(result1.address).toBe(result2.address);
});

it('throws an error if userId is missing', async () => {
await expect(provider.createWallet()).rejects.toThrow('userId is required');
});
});

describe('getWallet', () => {
it('returns null for a user without a wallet', async () => {
const result = await provider.getWallet('unknown-user');
expect(result).toBeNull();
});

it('returns the generated wallet for a user', async () => {
const userId = 'user-789';
const created = await provider.createWallet(userId);

const retrieved = await provider.getWallet(userId);
expect(retrieved).not.toBeNull();
expect(retrieved.address).toBe(created.address);
});

it('throws an error if userId is missing', async () => {
await expect(provider.getWallet()).rejects.toThrow('userId is required');
});
});
});
84 changes: 84 additions & 0 deletions tests/routes/accounts.routes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const request = require('supertest');
const express = require('express');
const errorHandler = require('../../src/middleware/error.middleware');
const AppError = require('../../src/errors/AppError');

// Mock dependencies
jest.mock('../../src/clients/prisma.client', () => ({}));

const mockFindById = jest.fn();

jest.mock('../../src/repositories/user.repository', () => {
return {
UserRepository: jest.fn().mockImplementation(() => ({
findById: mockFindById,
})),
};
});

const accountsRoutes = require('../../src/routes/accounts.routes');

const app = express();
app.use(express.json());
app.use((req, res, next) => {
// Mock authenticate middleware behavior
if (req.headers.authorization === 'Bearer valid-token') {
req.user = { id: 'user-123' };
next();
} else if (req.headers.authorization === 'Bearer valid-token-not-found') {
req.user = { id: 'not-found' };
next();
} else {
next(new AppError('Unauthorized', 401));
}
});
app.use('/api/accounts/me', accountsRoutes);
app.use(errorHandler);

describe('Accounts Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('GET /api/accounts/me', () => {
it('returns 200 and logical account status for authenticated user', async () => {
mockFindById.mockResolvedValue({
id: 'user-123',
email: 'test@example.com',
name: 'Test User',
role: 'USER',
passwordHash: 'secret-hash',
isActive: true,
createdAt: '2023-01-01T00:00:00.000Z',
});

const res = await request(app)
.get('/api/accounts/me')
.set('Authorization', 'Bearer valid-token');

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.isActive).toBe(true);
expect(res.body.data.createdAt).toBe('2023-01-01T00:00:00.000Z');
expect(res.body.data).not.toHaveProperty('email');
expect(res.body.data).not.toHaveProperty('passwordHash');
});

it('returns 404 if authenticated user not found in DB', async () => {
mockFindById.mockResolvedValue(null);

const res = await request(app)
.get('/api/accounts/me')
.set('Authorization', 'Bearer valid-token-not-found');

expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.message).toBe('User not found');
});

it('returns 401 if unauthenticated', async () => {
const res = await request(app).get('/api/accounts/me');
expect(res.status).toBe(401);
});
});
});
Loading