diff --git a/src/app.js b/src/app.js index a90e533..c49bb2a 100644 --- a/src/app.js +++ b/src/app.js @@ -64,7 +64,7 @@ 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/wallets/me', authenticate, walletsRoutes); +app.use('/api/wallets', authenticate, walletsRoutes); app.use('/api/relayer', relayerRoutes); // Error Handling Middleware diff --git a/src/providers/wallet.provider.js b/src/providers/wallet.provider.js index bc3b22b..12621a3 100644 --- a/src/providers/wallet.provider.js +++ b/src/providers/wallet.provider.js @@ -60,7 +60,18 @@ const createWalletProvider = ({ config } = {}) => { }; }; - return { fundWallet, withdrawFromWallet }; + /** + * Retrieves the mock balance of the wallet. + * + * @param {string} walletAddress - The managed wallet address. + * @returns {Promise} The balance as a string. + */ + const getBalance = async (_walletAddress) => { + // Stub: Returns a mock balance of 1000.00 for testing withdrawals. + return '1000.00'; + }; + + return { fundWallet, withdrawFromWallet, getBalance }; }; module.exports = { createWalletProvider }; diff --git a/src/routes/wallets.routes.js b/src/routes/wallets.routes.js index 714f26e..1ff02d0 100644 --- a/src/routes/wallets.routes.js +++ b/src/routes/wallets.routes.js @@ -3,10 +3,15 @@ const router = express.Router(); const AppError = require('../errors/AppError'); const prisma = require('../clients/prisma.client'); const { WalletRepository } = require('../repositories/wallet.repository'); +const { createWalletProvider } = require('../providers/wallet.provider'); +const { validate } = require('../middleware/validate.middleware'); +const { withdrawSchema } = require('../validation/schemas/wallet.schema'); +const { StrKey } = require('stellar-sdk'); const walletRepository = new WalletRepository(prisma); +const walletProvider = createWalletProvider(); -router.get('/', async (req, res, next) => { +router.get('/me', async (req, res, next) => { try { const userId = req.user.id; const wallet = await walletRepository.findByUserId(userId); @@ -29,4 +34,42 @@ router.get('/', async (req, res, next) => { } }); +router.post('/withdraw', validate(withdrawSchema), async (req, res, next) => { + try { + const userId = req.user.id; + const { destinationAddress, amount, asset } = req.body; + + if (!StrKey.isValidEd25519PublicKey(destinationAddress)) { + throw new AppError('Invalid Stellar destination address', 400); + } + + const wallet = await walletRepository.findByUserId(userId); + if (!wallet) { + throw new AppError('Wallet not found', 404); + } + + const balanceStr = await walletProvider.getBalance(wallet.publicKey); + const balance = Number(balanceStr); + const withdrawAmount = Number(amount); + + if (withdrawAmount > balance) { + throw new AppError('Withdrawal amount exceeds available balance', 400); + } + + const receipt = await walletProvider.withdrawFromWallet({ + walletAddress: wallet.publicKey, + amount, + asset, + }); + + res.status(200).json({ + success: true, + message: 'Withdrawal initiated successfully', + data: receipt, + }); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/src/validation/schemas/wallet.schema.js b/src/validation/schemas/wallet.schema.js new file mode 100644 index 0000000..63ef2d8 --- /dev/null +++ b/src/validation/schemas/wallet.schema.js @@ -0,0 +1,15 @@ +const { z } = require('zod'); + +const withdrawSchema = z.object({ + body: z.object({ + destinationAddress: z.string().min(1, 'Destination address is required'), + amount: z + .string() + .min(1, 'Amount is required') + .regex(/^\d+(\.\d+)?$/, 'Amount must be a positive number') + .refine((value) => Number(value) > 0, 'Amount must be greater than zero'), + asset: z.string().min(1).default('USDC'), + }), +}); + +module.exports = { withdrawSchema }; diff --git a/tests/routes/wallets.routes.test.js b/tests/routes/wallets.routes.test.js index 4ed6171..6c5dba9 100644 --- a/tests/routes/wallets.routes.test.js +++ b/tests/routes/wallets.routes.test.js @@ -2,6 +2,7 @@ const request = require('supertest'); const express = require('express'); const walletsRoutes = require('../../src/routes/wallets.routes'); const { WalletRepository } = require('../../src/repositories/wallet.repository'); +const { createWalletProvider } = require('../../src/providers/wallet.provider'); jest.mock('../../src/clients/prisma.client', () => ({ wallet: { @@ -25,20 +26,39 @@ jest.mock('../../src/repositories/wallet.repository', () => { }; }); +jest.mock('../../src/providers/wallet.provider', () => { + const getBalance = jest.fn(); + const withdrawFromWallet = jest.fn(); + return { + createWalletProvider: jest.fn().mockImplementation(() => ({ + getBalance, + withdrawFromWallet, + })), + }; +}); + const errorHandler = require('../../src/middleware/error.middleware'); const app = express(); app.use(express.json()); // Inject mocked authenticate middleware -app.use('/api/wallets/me', require('../../src/middleware/auth.middleware'), walletsRoutes); +app.use('/api/wallets', require('../../src/middleware/auth.middleware'), walletsRoutes); app.use(errorHandler); describe('Wallets Routes', () => { let mockFindByUserId; + let mockGetBalance; + let mockWithdrawFromWallet; beforeEach(() => { mockFindByUserId = new WalletRepository().findByUserId; + mockGetBalance = createWalletProvider().getBalance; + mockWithdrawFromWallet = createWalletProvider().withdrawFromWallet; + mockFindByUserId.mockReset(); + mockGetBalance.mockReset(); + mockWithdrawFromWallet.mockReset(); + jest.clearAllMocks(); }); @@ -73,4 +93,81 @@ describe('Wallets Routes', () => { expect(res.body.data).not.toHaveProperty('userId'); // only requested fields }); }); + + describe('POST /api/wallets/withdraw', () => { + const validAddress = 'GCTAAYPBHPVJNN6F7IXZT6TRMMGS6GYBZJIOWRTP7HUHIZ5W2K6FR4CI'; + const invalidAddress = 'invalid-address'; + + it('returns 400 if destination address is invalid', async () => { + const res = await request(app).post('/api/wallets/withdraw').send({ + destinationAddress: invalidAddress, + amount: '50.00', + asset: 'USDC' + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Invalid Stellar destination address'); + }); + + it('returns 404 if wallet is not found', async () => { + mockFindByUserId.mockResolvedValue(null); + + const res = await request(app).post('/api/wallets/withdraw').send({ + destinationAddress: validAddress, + amount: '50.00', + asset: 'USDC' + }); + + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Wallet not found'); + }); + + it('returns 400 if withdrawal amount exceeds balance', async () => { + mockFindByUserId.mockResolvedValue({ + id: 'w-1', + publicKey: 'G_MOCK_123', + }); + mockGetBalance.mockResolvedValue('10.00'); // less than 50.00 + + const res = await request(app).post('/api/wallets/withdraw').send({ + destinationAddress: validAddress, + amount: '50.00', + asset: 'USDC' + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Withdrawal amount exceeds available balance'); + }); + + it('returns 200 and initiates withdrawal successfully', async () => { + mockFindByUserId.mockResolvedValue({ + id: 'w-1', + publicKey: 'G_MOCK_123', + }); + mockGetBalance.mockResolvedValue('100.00'); + mockWithdrawFromWallet.mockResolvedValue({ + reference: 'withdraw_123', + status: 'RESERVED', + walletAddress: 'G_MOCK_123', + amount: '50.00', + asset: 'USDC', + network: 'TESTNET', + }); + + const res = await request(app).post('/api/wallets/withdraw').send({ + destinationAddress: validAddress, + amount: '50.00', + asset: 'USDC' + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe('Withdrawal initiated successfully'); + expect(res.body.data).toHaveProperty('reference', 'withdraw_123'); + expect(res.body.data).toHaveProperty('status', 'RESERVED'); + }); + }); });