From b08d71f0dd6e0a344b97deee31c94a384ace1c8b Mon Sep 17 00:00:00 2001 From: Kaylahray Date: Wed, 25 Mar 2026 23:33:06 +0100 Subject: [PATCH] feat(vault): add vault state and authenticated balance read APIs --- src/index.ts | 2 + src/routes/vault.ts | 52 +++++++++++++ tests/integration/api/vault.test.ts | 115 ++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/routes/vault.ts create mode 100644 tests/integration/api/vault.test.ts diff --git a/src/index.ts b/src/index.ts index 06f995f..fd94e87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import transactionsRouter from './routes/transactions' import protocolsRouter from './routes/protocols' import depositRouter from './routes/deposit' import withdrawRouter from './routes/withdraw' +import vaultRouter from './routes/vault' const app = express() @@ -41,6 +42,7 @@ app.use('/api/transactions', transactionsRouter) app.use('/api/protocols', protocolsRouter) app.use('/api/deposit', depositRouter) app.use('/api/withdraw', withdrawRouter) +app.use('/api/vault', vaultRouter) // Global error handler — must always be last app.use(errorHandler) diff --git a/src/routes/vault.ts b/src/routes/vault.ts new file mode 100644 index 0000000..d3f9d6d --- /dev/null +++ b/src/routes/vault.ts @@ -0,0 +1,52 @@ +import { Router, Request, Response } from 'express' +import db from '../db' +import { requireAuth } from '../middleware/auth' +import { + getActiveProtocol, + getOnChainAPY, + getOnChainBalance, +} from '../stellar/contract' + +const router = Router() + +function toNumber(value: unknown): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +router.get('/state', async (req: Request, res: Response) => { + const [apy, activeProtocol] = await Promise.all([ + getOnChainAPY(), + getActiveProtocol(), + ]) + + return res.status(200).json({ + apy, + activeProtocol, + }) +}) + +router.get('/balance', requireAuth, async (req: Request, res: Response) => { + const userId = req.auth?.userId + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { walletAddress: true }, + }) + + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const onChain = await getOnChainBalance(user.walletAddress) + + return res.status(200).json({ + balance: toNumber(onChain.balance), + shares: toNumber(onChain.shares), + }) +}) + +export default router diff --git a/tests/integration/api/vault.test.ts b/tests/integration/api/vault.test.ts new file mode 100644 index 0000000..6498e3b --- /dev/null +++ b/tests/integration/api/vault.test.ts @@ -0,0 +1,115 @@ +import request from 'supertest' + +const mockDb = { + session: { findUnique: jest.fn() }, + user: { findUnique: jest.fn() }, + position: { findMany: jest.fn() }, + yieldSnapshot: { findMany: jest.fn() }, + transaction: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +} + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})) + +const mockGetOnChainAPY = jest.fn() +const mockGetActiveProtocol = jest.fn() +const mockGetOnChainBalance = jest.fn() + +jest.mock('../../../src/stellar/contract', () => ({ + getOnChainAPY: () => mockGetOnChainAPY(), + getActiveProtocol: () => mockGetActiveProtocol(), + getOnChainBalance: (stellarPubKey: string) => mockGetOnChainBalance(stellarPubKey), +})) + +import app from '../../../src/index' + +const userId = '550e8400-e29b-41d4-a716-446655440007' +const token = 'vault-token' +const walletAddress = 'GAUTH_USER_STELLAR_PUBKEY' + +describe('Vault API routes', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockDb.session.findUnique.mockResolvedValue({ + id: 'session-vault-1', + userId, + walletAddress, + network: 'TESTNET', + expiresAt: new Date(Date.now() + 60_000), + user: { id: userId, isActive: true }, + }) + + mockDb.user.findUnique.mockResolvedValue({ + walletAddress, + }) + + mockGetOnChainAPY.mockResolvedValue(8.75) + mockGetActiveProtocol.mockResolvedValue('Blend') + mockGetOnChainBalance.mockResolvedValue({ + balance: '1500.25', + shares: '1450.1', + }) + }) + + describe('GET /api/vault/state', () => { + it('returns vault state shape', async () => { + const res = await request(app).get('/api/vault/state') + + expect(res.status).toBe(200) + expect(res.body).toEqual({ + apy: 8.75, + activeProtocol: 'Blend', + }) + expect(mockGetOnChainAPY).toHaveBeenCalledTimes(1) + expect(mockGetActiveProtocol).toHaveBeenCalledTimes(1) + }) + }) + + describe('GET /api/vault/balance', () => { + it('returns 401 without token', async () => { + const res = await request(app).get('/api/vault/balance') + + expect(res.status).toBe(401) + expect(res.body.error).toBe('Unauthorized') + }) + + it('returns 404 when authenticated user is missing', async () => { + mockDb.user.findUnique.mockResolvedValue(null) + + const res = await request(app) + .get('/api/vault/balance') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(404) + expect(res.body.error).toBe('User not found') + }) + + it('returns numeric balance and shares from on-chain reads', async () => { + const res = await request(app) + .get('/api/vault/balance') + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ + balance: 1500.25, + shares: 1450.1, + }) + expect(mockDb.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { walletAddress: true }, + }) + expect(mockGetOnChainBalance).toHaveBeenCalledWith(walletAddress) + }) + }) +})