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
74 changes: 71 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Ensure you have the following installed on your system:
## Development Setup

### First-time setup

```bash
# 1. Copy environment config
cp .env.example .env
Expand All @@ -44,6 +45,7 @@ bun run dev
```

### Daily workflow

```bash
make db # start database
bun run dev # start API
Expand All @@ -53,6 +55,7 @@ make db-reset # wipe all data and start fresh
```

### Migrations

```bash
# Run pending migrations
make migrate
Expand Down Expand Up @@ -229,9 +232,9 @@ Request body:

```json
{
"campaign_ref": "ABCDE",
"target_amount": "1000",
"donation_token": "0x1"
"campaign_ref": "ABCDE",
"target_amount": "1000",
"donation_token": "0x1"
}
```

Expand Down Expand Up @@ -261,5 +264,70 @@ For local development without a chain connection:

---

## API: List Wallets

**GET** `/api/v1/wallets` (JWT + admin required)

Returns a list of all wallets in the system.

Request:

```bash
curl -H "Authorization: Bearer <JWT_TOKEN>" http://localhost:3000/api/v1/wallets
```

Response (Success - 200):

```json
{
"success": true,
"data": [
{
"id": "wallet-uuid",
"address": "0x123abc...",
"network": "ETHEREUM",
"chainId": "1",
"chainName": "Ethereum",
"balance": "100.50",
"createdAt": "2026-06-29T10:00:00.000Z",
"updatedAt": "2026-06-29T10:00:00.000Z"
}
]
}
```

Response (Error - 401 Unauthorized):

```json
{
"success": false,
"error": {
"code": "AUTH_MISSING_TOKEN",
"message": "Missing authentication token",
"details": {}
}
}
```
Comment on lines +299 to +310

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document both 401 error variants.

The JWT middleware returns AUTH_INVALID_TOKEN / "Invalid authentication token" for malformed or failed verification, not just AUTH_MISSING_TOKEN. Update the 401 documentation to cover both missing and invalid token cases, or generalize the description to match the middleware behavior.

As per the upstream contract in src/appMiddlewares/jwtAuth.api.ts, the 401 response can have either AUTH_MISSING_TOKEN or AUTH_INVALID_TOKEN depending on the failure mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 299 - 310, The 401 documentation in README currently
covers only the missing-token case, but the JWT middleware in jwtAuth.api.ts can
also return the invalid-token variant. Update the 401 response example or
description to include both AUTH_MISSING_TOKEN/"Missing authentication token"
and AUTH_INVALID_TOKEN/"Invalid authentication token", or generalize the text so
it matches the middleware behavior for both failure modes.


Response (Error - 500 Internal Server Error):

```json
{
"success": false,
"error": {
"code": "WALLET_LIST_FAILED",
"message": "Failed to retrieve wallets",
"details": { "reason": "..." }
}
}
```

Notes:

- Requires valid JWT token in `Authorization: Bearer <token>` header
- Returns empty array if no wallets exist
- Includes wallet address, network, chain info, and balance

---

### Any challenges? Reach out!
130 changes: 130 additions & 0 deletions src/__tests__/wallet.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import type { AddressInfo } from 'node:net';
import type { Response } from 'express';

import { requireAdminApi } from '../appMiddlewares/jwtAuth.api';
import { listWallets } from '../components/v1/wallet/wallet.controller';
import walletRoutes from '../components/v1/wallet/wallet.routes';
import walletRepository from '../components/v1/wallet/wallet.services';
import type { IRequest } from '../types/global';

type MockResponse = {
statusCode: number;
jsonData: any;
status: (code: number) => MockResponse;
json: (data: any) => MockResponse;
};

const createMockResponse = (): MockResponse => {
const response: MockResponse = {
statusCode: 200,
jsonData: null,
status(code: number) {
this.statusCode = code;
return this;
},
json(data: any) {
this.jsonData = data;
return this;
},
};
return response;
};

test('listWallets returns wallet data when the repository succeeds', async () => {
const mockWallets = [
{
id: 'wallet-1',
address: '0x123abc',
network: 'ETHEREUM',
chainId: '1',
chainName: 'Ethereum',
balance: '100.5',
createdAt: new Date(),
updatedAt: new Date(),
},
];

const res = createMockResponse();
const req = { auth: { userId: 'user-123' } } as IRequest;
const originalFind = walletRepository.find.bind(walletRepository);

try {
(
walletRepository as unknown as {
find: typeof walletRepository.find;
}
).find = async () => mockWallets as any;

await listWallets(req, res as unknown as Response);

assert.equal(res.statusCode, 200);
assert.equal(res.jsonData.success, true);
assert.deepEqual(res.jsonData.data, mockWallets);
} finally {
(
walletRepository as unknown as {
find: typeof walletRepository.find;
}
).find = originalFind;
}
});
Comment on lines +50 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect hidden setup (lines 37-49) and any AppDataSource initialization in the test
fd -t f 'wallet.controller.test.ts' --exec sed -n '30,60p' {}
rg -nP 'isInitialized|AppDataSource' src/__tests__/wallet.controller.test.ts

Repository: Fundable-Protocol/Backend

Length of output: 977


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant controller and test sections with line numbers.
controller_file=$(fd -t f 'wallet.controller.ts' src | head -n 1)
test_file='src/__tests__/wallet.controller.test.ts'

printf '\n== %s ==\n' "$controller_file"
sed -n '1,220p' "$controller_file" | cat -n

printf '\n== %s (30-160) ==\n' "$test_file"
sed -n '30,160p' "$test_file" | cat -n

printf '\n== occurrences of AppDataSource / isInitialized in repo ==\n'
rg -n 'AppDataSource|isInitialized' src --glob '!**/dist/**' --glob '!**/build/**'

Repository: Fundable-Protocol/Backend

Length of output: 8758


Stub AppDataSource.isInitialized in the success test
listWallets returns DB_NOT_READY before calling walletRepository.find(), so this case needs AppDataSource.isInitialized = true (or equivalent setup) to exercise the happy path; otherwise it will hit the 500 branch or become order-dependent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/__tests__/wallet.controller.test.ts` around lines 50 - 73, The happy-path
test for listWallets is missing the AppDataSource initialization setup, so it
may exit early with DB_NOT_READY before the walletRepository.find stub is
exercised. Update the wallet.controller test around listWallets to explicitly
set AppDataSource.isInitialized to true (and restore it afterward) so the
success case reliably reaches the mocked repository call and remains
order-independent.


test('wallet routes reject unauthenticated requests', async () => {
const app = express();
app.use('/wallets', walletRoutes);

const server = app.listen(0);
await new Promise<void>((resolve) =>
server.once('listening', () => resolve())
);

try {
const address = server.address() as AddressInfo;
const response = await fetch(
`http://127.0.0.1:${address.port}/wallets`
);
const payload = await response.json();

assert.equal(response.status, 401);
assert.equal(payload.success, false);
assert.equal(payload.error.code, 'AUTH_MISSING_TOKEN');
} finally {
server.close();
}
});

test('requireAdminApi rejects non-admin requests', async () => {
const res = createMockResponse();
const req = {
auth: {
userId: 'user-123',
claims: { role: 'user' },
},
} as IRequest;

const next = () => {
throw new Error('next should not be called');
};

const result = requireAdminApi(req, res as unknown as Response, next);

assert.equal(result, undefined);
assert.equal(res.statusCode, 403);
assert.equal(res.jsonData.success, false);
assert.equal(res.jsonData.error.code, 'FORBIDDEN');
});

test('listWallets returns DB_NOT_READY when the data source is not initialized', async () => {
const res = createMockResponse();
const req = { auth: { userId: 'user-123' } } as IRequest;

await listWallets(req, res as unknown as Response);

assert.equal(res.statusCode, 500);
assert.equal(res.jsonData.success, false);
assert.equal(res.jsonData.error.code, 'DB_NOT_READY');
assert.match(res.jsonData.error.message, /Database not initialized/);
});
49 changes: 49 additions & 0 deletions src/__tests__/wallet.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Response } from 'express';

import type { IRequest } from '../types/global';
import { listWallets } from '../components/v1/wallet/wallet.controller';

const createMockResponse = () => {
const response: Partial<Response> & {
statusCode: number;
jsonData: any;
status: (code: number) => any;
json: (data: any) => any;
} = {
statusCode: 200,
jsonData: null,
status(code: number) {
this.statusCode = code;
return this;
},
json(data: any) {
this.jsonData = data;
return this;
},
};
return response;
};

test('listWallets returns DB_NOT_READY when data source is not initialized', async () => {
const res = createMockResponse();
const req = { auth: { userId: 'user-123' } } as IRequest;

await listWallets(req, res as Response);

assert.equal(res.statusCode, 500);
assert.equal(res.jsonData.success, false);
assert.equal(res.jsonData.error.code, 'DB_NOT_READY');
assert.match(res.jsonData.error.message, /Database not initialized/);
});

test('wallet routes are mounted on the API v1 router', async () => {
const router = await import('../components/v1/routes.api.v1');
assert.ok(router.default);
});

test('wallet route module exports a router', async () => {
const router = await import('../components/v1/wallet/wallet.routes');
assert.ok(router.default);
});
2 changes: 2 additions & 0 deletions src/components/v1/routes.api.v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

import campaignRoutes from './campaign/campaign.routes';
import donationRoutes from './Donation/donation.routes';
import walletRoutes from './wallet/wallet.routes';
import {
listCampaignDonationsQuerySchema,
listUserDonationsQuerySchema,
Expand All @@ -21,6 +22,7 @@ const router = new EnhancedRouter();

router.use('/campaigns', campaignRoutes);
router.use('/donations', donationRoutes);
router.use('/wallets', walletRoutes);

router.get(
'/campaigns/:campaignId/donations',
Expand Down
5 changes: 2 additions & 3 deletions src/components/v1/routes.v1.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import EnhancedRouter from '../../utils/enhancedRouter';

import distributionRoutes from "./distribution/distrubtion.routes"

import distributionRoutes from './distribution/distrubtion.routes';

const routerV1 = new EnhancedRouter();

routerV1.use("/distributions", distributionRoutes)
routerV1.use('/distributions', distributionRoutes);

export default routerV1.getRouter();
32 changes: 22 additions & 10 deletions src/components/v1/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { Response } from 'express';
import type { Response } from 'express';

import AppDataSource from '../../../config/persistence/data-source';
import { sendError, sendSuccess } from '../../../utils/apiResponse';
import type { IRequest } from '../../../types/global';
import walletRepository from './wallet.services';
import { handleResponse } from '../../../utils/helper';
import { IRequest } from '../../../types/global';

export const listWallets = async (req: IRequest, res: Response) => {
const queryObject = {};
export const listWallets = async (_req: IRequest, res: Response) => {
try {
if (!AppDataSource.isInitialized) {
return sendError(res, 500, {
code: 'DB_NOT_READY',
message: 'Database not initialized',
});
}

const wallets = await walletRepository.findBy(queryObject);

return handleResponse(res, {
data: wallets,
});
const wallets = await walletRepository.find();
return sendSuccess(res, wallets);
} catch (error) {
const err = error as Error;
return sendError(res, 500, {
code: 'WALLET_LIST_FAILED',
message: 'Failed to retrieve wallets',
details: { reason: err.message },
Comment on lines +21 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't expose raw repository errors in the API payload.

Line 16 returns err.message to the client. That can leak DB/ORM internals and makes the external contract depend on backend exception text. Keep the response generic here and log the original error server-side instead.

Proposed fix
     } catch (error) {
-        const err = error as Error;
         return sendError(res, 500, {
             code: 'WALLET_LIST_FAILED',
             message: 'Failed to retrieve wallets',
-            details: { reason: err.message },
+            details: {},
         });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return sendError(res, 500, {
code: 'WALLET_LIST_FAILED',
message: 'Failed to retrieve wallets',
details: { reason: err.message },
return sendError(res, 500, {
code: 'WALLET_LIST_FAILED',
message: 'Failed to retrieve wallets',
details: {},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/v1/wallet/wallet.controller.ts` around lines 13 - 16, The
error payload in wallet.controller should not expose the raw repository
exception text via err.message. Update the error handling around the wallet list
response so sendError returns a generic client-facing details value, and
preserve the original error only in server-side logging from the same controller
flow. Use the wallet listing handler in WalletController as the place to adjust
this contract.

});
}
};
13 changes: 9 additions & 4 deletions src/components/v1/wallet/wallet.routes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import EnhancedRouter from '../../../utils/enhancedRouter';
import { Router } from 'express';

import {
requireAdminApi,
requireJwtAuthApi,
} from '../../../appMiddlewares/jwtAuth.api';
import { listWallets } from './wallet.controller';

const walletRouter = new EnhancedRouter();
const router = Router();

// walletRouter.get('/');
// List all wallets (JWT + admin required)
router.get('/', requireJwtAuthApi, requireAdminApi, listWallets);

export default walletRouter.getRouter();
export default router;