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: 1 addition & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/providers/wallet.provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} 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 };
45 changes: 44 additions & 1 deletion src/routes/wallets.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
15 changes: 15 additions & 0 deletions src/validation/schemas/wallet.schema.js
Original file line number Diff line number Diff line change
@@ -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 };
99 changes: 98 additions & 1 deletion tests/routes/wallets.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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();
});

Expand Down Expand Up @@ -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');
});
});
});
Loading