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: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions src/routes/vault.ts
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions tests/integration/api/vault.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading