diff --git a/api/package.json b/api/package.json index 093625e1..4fb17442 100644 --- a/api/package.json +++ b/api/package.json @@ -15,7 +15,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "typecheck": "tsc --noEmit", - "prepare": "husky" + "prepare": "husky || true" }, "keywords": [ "stellar", @@ -60,6 +60,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -72,6 +73,6 @@ "supertest": "^7.2.2", "ts-jest": "^29.1.1", "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "typescript": "^5.9.3" } } diff --git a/api/src/__tests__/credit.service.test.ts b/api/src/__tests__/credit.service.test.ts new file mode 100644 index 00000000..b9f13cda --- /dev/null +++ b/api/src/__tests__/credit.service.test.ts @@ -0,0 +1,116 @@ +import axios from 'axios'; +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +beforeAll(() => { + mockedAxios.create.mockReturnThis(); + const axiosResponse = { data: {}, status: 200, statusText: 'OK', headers: {}, config: { url: '' } }; + mockedAxios.get.mockResolvedValue(axiosResponse); + mockedAxios.post.mockResolvedValue(axiosResponse); +}); +afterEach(() => { jest.clearAllMocks(); }); + +import { creditDelegationService } from '../services/credit-delegation'; + +const delegator = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const delegate = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWJF'; + +describe('CreditDelegationService', () => { + let creditLineId: string; + + it('creates a credit line', () => { + const creditLine = creditDelegationService.createCreditLine( + delegator, delegate, '1000000000', '500', '9999999999' + ); + expect(creditLine.delegatorAddress).toBe(delegator); + expect(creditLine.delegateAddress).toBe(delegate); + expect(creditLine.maxAmount).toBe('1000000000'); + expect(creditLine.status).toBe('active'); + creditLineId = creditLine.id; + }); + + it('creates credit line with collateral', () => { + const cl = creditDelegationService.createCreditLine( + delegator, delegate, '500000000', '300', '9999999999', '200000000' + ); + expect(cl.collateral).toBe('200000000'); + }); + + it('draws from credit line', () => { + const draw = creditDelegationService.draw(creditLineId, delegate, '500000000'); + expect(draw).not.toBeNull(); + expect(draw!.amount).toBe('500000000'); + }); + + it('rejects draw exceeding limit', () => { + expect(() => creditDelegationService.draw(creditLineId, delegate, '1000000000')) + .toThrow('exceeds available credit'); + }); + + it('rejects draw by non-delegate', () => { + expect(() => creditDelegationService.draw(creditLineId, 'GAAAAAANOTALLOWED12345678901234567890123456789012', '1000000')) + .toThrow(); + }); + + it('repays credit line', () => { + const repayment = creditDelegationService.repay(creditLineId, delegate, '200000000'); + expect(repayment).not.toBeNull(); + expect(repayment!.amount).toBe('200000000'); + }); + + it('adjusts credit limit', () => { + const updated = creditDelegationService.adjustLimit(creditLineId, delegator, '2000000000'); + expect(updated).not.toBeNull(); + expect(updated!.maxAmount).toBe('2000000000'); + }); + + it('transfers credit line', () => { + const newDelegator = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANEW'; + const updated = creditDelegationService.transfer(creditLineId, delegator, newDelegator); + expect(updated).not.toBeNull(); + expect(updated!.delegatorAddress).toBe(newDelegator); + expect(updated!.transferCount).toBe(1); + }); + + it('gets credit line by id', () => { + const cl = creditDelegationService.getCreditLine(creditLineId); + expect(cl).not.toBeNull(); + expect(cl!.id).toBe(creditLineId); + }); + + it('returns null for unknown credit line', () => { + const cl = creditDelegationService.getCreditLine('unknown'); + expect(cl).toBeNull(); + }); + + it('lists credit lines by delegator', () => { + const lines = creditDelegationService.getCreditLinesByDelegator(delegator); + expect(Array.isArray(lines)).toBe(true); + }); + + it('lists credit lines by delegate', () => { + const lines = creditDelegationService.getCreditLinesByDelegate(delegate); + expect(Array.isArray(lines)).toBe(true); + }); + + it('returns draws for credit line', () => { + const draws = creditDelegationService.getDraws(creditLineId); + expect(Array.isArray(draws)).toBe(true); + expect(draws.length).toBeGreaterThan(0); + }); + + it('returns repayments for credit line', () => { + const repayments = creditDelegationService.getRepayments(creditLineId); + expect(Array.isArray(repayments)).toBe(true); + expect(repayments.length).toBeGreaterThan(0); + }); + + it('claims default on matured unpaid credit line', () => { + const cl = creditDelegationService.createCreditLine(delegator, delegate, '1000000000', '500', '100'); + const stored = (creditDelegationService as any).creditLines.get(cl.id); + stored.drawnAmount = '500000000'; + stored.status = 'drawn'; + const defaulted = creditDelegationService.claimDefault(cl.id, delegator); + expect(defaulted).not.toBeNull(); + expect(defaulted!.status).toBe('defaulted'); + }); +}); diff --git a/api/src/__tests__/dispute.service.test.ts b/api/src/__tests__/dispute.service.test.ts new file mode 100644 index 00000000..84cf5918 --- /dev/null +++ b/api/src/__tests__/dispute.service.test.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +beforeAll(() => { + mockedAxios.create.mockReturnThis(); + const axiosResponse = { data: {}, status: 200, statusText: 'OK', headers: {}, config: { url: '' } }; + mockedAxios.get.mockResolvedValue(axiosResponse); + mockedAxios.post.mockResolvedValue(axiosResponse); +}); +afterEach(() => { jest.clearAllMocks(); }); + +import { disputeResolutionService } from '../services/dispute-resolution'; + +const disputer = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const liquidator = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWJF'; +const juror1 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC'; +const juror2 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEF'; +const juror3 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGHI'; +const juror4 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKL'; +const juror5 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMNO'; + +describe('DisputeResolutionService', () => { + let disputeId: string; + + it('registers jurors', () => { + disputeResolutionService.registerJuror(juror1); + disputeResolutionService.registerJuror(juror2); + disputeResolutionService.registerJuror(juror3); + disputeResolutionService.registerJuror(juror4); + disputeResolutionService.registerJuror(juror5); + expect(true).toBe(true); + }); + + it('files a dispute', () => { + const dispute = disputeResolutionService.fileDispute( + disputer, liquidator, 'tx_hash_1', '1000000000', 'initial evidence', '10000000' + ); + expect(dispute.disputerAddress).toBe(disputer); + expect(dispute.status).toBe('filing'); + expect(dispute.disputeFee).toBe('10000000'); + disputeId = dispute.id; + }); + + it('rejects dispute fee below minimum', () => { + expect(() => disputeResolutionService.fileDispute( + disputer, liquidator, 'tx_hash_2', '1000000000', 'evidence', '100' + )).toThrow(); + }); + + it('submits evidence', () => { + const evidence = disputeResolutionService.submitEvidence( + disputeId, disputer, 'Additional evidence', 'more_data' + ); + expect(evidence).not.toBeNull(); + expect(evidence!.description).toBe('Additional evidence'); + }); + + it('selects jurors', () => { + const jurors = disputeResolutionService.selectJurors(disputeId); + expect(jurors).not.toBeNull(); + expect(jurors!.length).toBe(5); + expect(disputeResolutionService.getDispute(disputeId)!.status).toBe('voting'); + }); + + it('casts votes and resolves', () => { + disputeResolutionService.castVote(disputeId, juror1, 'valid', 'Evidence supports validity'); + disputeResolutionService.castVote(disputeId, juror2, 'valid', 'Agree'); + disputeResolutionService.castVote(disputeId, juror3, 'valid', 'Liquidation was proper'); + + const dispute = disputeResolutionService.getDispute(disputeId)!; + expect(dispute.votes.length).toBe(3); + }); + + it('resolves dispute with majority', () => { + disputeResolutionService.castVote(disputeId, juror4, 'valid', 'Concur'); + const dispute = disputeResolutionService.getDispute(disputeId)!; + expect(dispute.status).toBe('resolved'); + expect(dispute.resolution).toBe('valid'); + }); + + it('allows appeal', () => { + const appealed = disputeResolutionService.appeal(disputeId, disputer, '20000000'); + expect(appealed).not.toBeNull(); + expect(appealed!.status).toBe('filing'); + expect(appealed!.appealParentId).toBe(disputeId); + }); + + it('gets dispute by id', () => { + const dispute = disputeResolutionService.getDispute(disputeId); + expect(dispute).not.toBeNull(); + expect(dispute!.id).toBe(disputeId); + }); + + it('returns null for unknown dispute', () => { + const dispute = disputeResolutionService.getDispute('unknown'); + expect(dispute).toBeNull(); + }); + + it('lists disputes by user', () => { + const disputes = disputeResolutionService.getDisputesByUser(disputer); + expect(disputes.length).toBeGreaterThan(0); + }); +}); diff --git a/api/src/__tests__/notification.service.test.ts b/api/src/__tests__/notification.service.test.ts new file mode 100644 index 00000000..27885e01 --- /dev/null +++ b/api/src/__tests__/notification.service.test.ts @@ -0,0 +1,101 @@ +import axios from 'axios'; +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +beforeAll(() => { + mockedAxios.create.mockReturnThis(); + const axiosResponse = { data: {}, status: 200, statusText: 'OK', headers: {}, config: { url: '' } }; + mockedAxios.get.mockResolvedValue(axiosResponse); + mockedAxios.post.mockResolvedValue(axiosResponse); +}); +afterEach(() => { jest.clearAllMocks(); }); + +import { notificationEngine } from '../services/notification-engine'; + +const userId = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +describe('NotificationEngine', () => { + it('creates subscriptions', () => { + const prefs = notificationEngine.subscribe( + userId, 'email', 'user@example.com', + ['health_factor_low', 'approaching_liquidation'] + ); + expect(prefs).toHaveLength(2); + expect(prefs[0]!.channel).toBe('email'); + expect(prefs[0]!.enabled).toBe(true); + }); + + it('returns preferences', () => { + const prefs = notificationEngine.getPreferences(userId); + expect(prefs.length).toBeGreaterThan(0); + }); + + it('updates preferences', () => { + const updated = notificationEngine.updatePreference( + userId, 'email', 'health_factor_low', false, 150 + ); + expect(updated).not.toBeNull(); + expect(updated!.enabled).toBe(false); + expect(updated!.threshold).toBe(150); + }); + + it('sends alerts to subscribed channels', async () => { + const messages = await notificationEngine.sendAlert( + userId, 'health_factor_low', + { healthFactor: '1.2', collateralValue: '1000', debtValue: '800' } + ); + expect(Array.isArray(messages)).toBe(true); + }); + + it('respects rate limiting', async () => { + const messages1 = await notificationEngine.sendAlert( + userId, 'health_factor_low', + { healthFactor: '1.1', collateralValue: '1000', debtValue: '900' } + ); + const messages2 = await notificationEngine.sendAlert( + userId, 'health_factor_low', + { healthFactor: '1.0', collateralValue: '1000', debtValue: '1000' } + ); + expect(messages2.length).toBeLessThanOrEqual(messages1.length); + }); + + it('returns notification history', () => { + const result = notificationEngine.getHistory(userId); + expect(result.messages).toBeDefined(); + expect(Array.isArray(result.messages)).toBe(true); + }); + + it('filters history by alert type', () => { + const result = notificationEngine.getHistory(userId, { alertType: 'price_alert' }); + expect(result.messages.every(m => m.alertType === 'price_alert')).toBe(true); + }); + + it('marks messages as delivered', () => { + const result = notificationEngine.getHistory(userId); + if (result.messages.length > 0) { + notificationEngine.markDelivered(result.messages[0]!.id); + const updated = notificationEngine.getHistory(userId); + const msg = updated.messages.find(m => m.id === result.messages[0]!.id); + expect(msg).toBeDefined(); + } + }); + + it('marks messages as read', () => { + const result = notificationEngine.getHistory(userId); + if (result.messages.length > 0) { + notificationEngine.markRead(result.messages[0]!.id); + } + }); + + it('handles unknown alert types gracefully', async () => { + const messages = await notificationEngine.sendAlert( + userId, 'price_alert' as any, + { asset: 'XLM', direction: 'up', changePercent: '5', currentPrice: '0.5', threshold: '0.45' } + ); + expect(Array.isArray(messages)).toBe(true); + }); + + it('returns empty preferences for unknown user', () => { + const prefs = notificationEngine.getPreferences('unknown'); + expect(prefs).toEqual([]); + }); +}); diff --git a/api/src/__tests__/social.service.test.ts b/api/src/__tests__/social.service.test.ts new file mode 100644 index 00000000..472ecec7 --- /dev/null +++ b/api/src/__tests__/social.service.test.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +beforeAll(() => { + mockedAxios.create.mockReturnThis(); + const axiosResponse = { data: {}, status: 200, statusText: 'OK', headers: {}, config: { url: '' } }; + mockedAxios.get.mockResolvedValue(axiosResponse); + mockedAxios.post.mockResolvedValue(axiosResponse); +}); +afterEach(() => { jest.clearAllMocks(); }); + +import { leaderboardService } from '../services/social-trading/leaderboard.service'; +import { copyTradingService } from '../services/social-trading/copy-trading.service'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('LeaderboardService', () => { + const leader1 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const leader2 = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWJF'; + + it('returns empty leaderboard initially', () => { + const entries = leaderboardService.getLeaderboard({}); + expect(entries).toEqual([]); + }); + + it('allows opt-out and filters opted-out leaders', () => { + leaderboardService.setOptOut(leader1, true); + const entries = leaderboardService.getLeaderboard({}); + expect(Array.isArray(entries)).toBe(true); + }); + + it('provides leader profile', () => { + const profile = leaderboardService.getLeaderProfile(leader1); + expect(profile).toBeNull(); + }); +}); + +describe('CopyTradingService', () => { + const follower = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABC'; + const leader = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEF'; + const amount = '10000000'; + + it('creates follow relation', () => { + const relation = copyTradingService.follow(follower, leader, amount); + expect(relation.followerAddress).toBe(follower); + expect(relation.leaderAddress).toBe(leader); + expect(relation.investedAmount).toBe(amount); + expect(relation.active).toBe(true); + expect(relation.startedAt).toBeDefined(); + }); + + it('rejects duplicate follow', () => { + expect(() => copyTradingService.follow(follower, leader, amount)).toThrow('Already following this leader'); + }); + + it('rejects amount below minimum', () => { + const smallAmount = '10'; + expect(() => copyTradingService.follow(follower, 'GAAAAAAGIVKJHQVJQ5QFJQ5QFJQ5QFJQ5QFJQ5QFJQ5Q', smallAmount)) + .toThrow('Minimum investment'); + }); + + it('returns follow relation', () => { + const relation = copyTradingService.getFollowRelation(follower, leader); + expect(relation).not.toBeNull(); + expect(relation!.active).toBe(true); + }); + + it('returns null for non-existent relation', () => { + const relation = copyTradingService.getFollowRelation('GAAAAAANONEXISTENT12345678901234567890123456789012', leader); + expect(relation).toBeNull(); + }); + + it('unfollows and deactivates', () => { + const relation = copyTradingService.unfollow(follower, leader); + expect(relation).not.toBeNull(); + expect(relation!.active).toBe(false); + }); + + it('returns following list', () => { + const following = copyTradingService.getFollowing(follower); + expect(Array.isArray(following)).toBe(true); + }); + + it('calculates profit share correctly', () => { + const share = copyTradingService.calculateProfitShare('1000000', 10); + expect(share).toBe('100000'); + }); + + it('returns zero profit share for zero profit', () => { + const share = copyTradingService.calculateProfitShare('0'); + expect(share).toBe('0'); + }); + + it('mirrors leader position proportionally', () => { + const mirror = copyTradingService.mirrorLeaderPosition( + follower, leader, 'CONTRACT_ID', '10000000000', '100000000000', '1000000000' + ); + expect(mirror.followerAddress).toBe(follower); + expect(mirror.leaderAddress).toBe(leader); + expect(mirror.proportionalAmount).toBeDefined(); + }); +}); diff --git a/api/src/app.ts b/api/src/app.ts index 418bc654..013b8fb6 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -24,6 +24,10 @@ import analyticsRoutes from './routes/analytics.routes'; import developerRoutes from './routes/developer.routes'; import mevRoutes from './routes/mev.routes'; import reputationRoutes from './routes/reputation.routes'; +import socialRoutes from './routes/social.routes'; +import notificationRoutes from './routes/notification.routes'; +import disputeRoutes from './routes/dispute.routes'; +import creditRoutes from './routes/credit.routes'; import { errorHandler } from './middleware/errorHandler'; import { idempotencyMiddleware } from './middleware/idempotency'; @@ -185,6 +189,10 @@ app.use('/api/config', legacySystemCompat, configRoutes); app.use('/api/analytics', legacySystemCompat, analyticsRoutes); app.use('/api/mev', legacySecurityCompat, mevRoutes); app.use('/api/reputation', reputationRoutes); +app.use('/api/social', socialRoutes); +app.use('/api/notifications', notificationRoutes); +app.use('/api/disputes', disputeRoutes); +app.use('/api/credit', creditRoutes); app.use(errorHandler); diff --git a/api/src/controllers/credit.controller.ts b/api/src/controllers/credit.controller.ts new file mode 100644 index 00000000..5f866542 --- /dev/null +++ b/api/src/controllers/credit.controller.ts @@ -0,0 +1,155 @@ +import { Request, Response, NextFunction } from 'express'; +import { creditDelegationService } from '../services/credit-delegation'; +import logger from '../utils/logger'; + +export const createCreditLine = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const { delegateAddress, maxAmount, interestRate, maturityDate, collateral } = req.body; + if (!delegateAddress || !maxAmount || !interestRate || !maturityDate) { + return res.status(400).json({ success: false, error: 'Missing required fields' }); + } + const creditLine = creditDelegationService.createCreditLine( + userAddress, delegateAddress as string, maxAmount as string, interestRate as string, maturityDate as string, collateral as string | undefined + ); + logger.info('Credit line created', { creditLineId: creditLine.id, userAddress }); + return res.status(201).json({ success: true, data: creditLine }); + } catch (error) { + next(error); + return; + } +}; + +export const draw = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const amount: string = req.body.amount as string; + if (!amount) { + return res.status(400).json({ success: false, error: 'Missing amount' }); + } + const draw = creditDelegationService.draw(id, userAddress, amount); + if (!draw) { + return res.status(400).json({ success: false, error: 'Cannot draw on this credit line' }); + } + return res.status(200).json({ success: true, data: draw }); + } catch (error) { + next(error); + return; + } +}; + +export const repay = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const amount: string = req.body.amount as string; + if (!amount) { + return res.status(400).json({ success: false, error: 'Missing amount' }); + } + const repayment = creditDelegationService.repay(id, userAddress, amount); + if (!repayment) { + return res.status(400).json({ success: false, error: 'Cannot repay on this credit line' }); + } + return res.status(200).json({ success: true, data: repayment }); + } catch (error) { + next(error); + return; + } +}; + +export const getCreditLine = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id as string; + const creditLine = creditDelegationService.getCreditLine(id); + if (!creditLine) { + return res.status(404).json({ success: false, error: 'Credit line not found' }); + } + const draws = creditDelegationService.getDraws(id); + const repayments = creditDelegationService.getRepayments(id); + return res.status(200).json({ success: true, data: { ...creditLine, draws, repayments } }); + } catch (error) { + next(error); + return; + } +}; + +export const getMyCreditLines = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const asDelegator = creditDelegationService.getCreditLinesByDelegator(userAddress); + const asDelegate = creditDelegationService.getCreditLinesByDelegate(userAddress); + return res.status(200).json({ success: true, data: { asDelegator, asDelegate } }); + } catch (error) { + next(error); + return; + } +}; + +export const claimDefault = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const creditLine = creditDelegationService.claimDefault(id, userAddress); + if (!creditLine) { + return res.status(400).json({ success: false, error: 'Cannot claim default' }); + } + return res.status(200).json({ success: true, data: creditLine }); + } catch (error) { + next(error); + return; + } +}; + +export const adjustLimit = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const maxAmount: string = req.body.maxAmount as string; + const creditLine = creditDelegationService.adjustLimit(id, userAddress, maxAmount); + if (!creditLine) { + return res.status(404).json({ success: false, error: 'Credit line not found' }); + } + return res.status(200).json({ success: true, data: creditLine }); + } catch (error) { + next(error); + return; + } +}; + +export const transfer = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const newDelegatorAddress: string = req.body.newDelegatorAddress as string; + const creditLine = creditDelegationService.transfer(id, userAddress, newDelegatorAddress); + if (!creditLine) { + return res.status(404).json({ success: false, error: 'Credit line not found' }); + } + return res.status(200).json({ success: true, data: creditLine }); + } catch (error) { + next(error); + return; + } +}; diff --git a/api/src/controllers/dispute.controller.ts b/api/src/controllers/dispute.controller.ts new file mode 100644 index 00000000..63790706 --- /dev/null +++ b/api/src/controllers/dispute.controller.ts @@ -0,0 +1,131 @@ +import { Request, Response, NextFunction } from 'express'; +import { disputeResolutionService } from '../services/dispute-resolution'; +import logger from '../utils/logger'; + +export const fileDispute = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const liquidationTxHash: string = req.body.liquidationTxHash as string; + const collateralAmount: string = req.body.collateralAmount as string; + const evidence: string = req.body.evidence as string; + const disputeFee: string = req.body.disputeFee as string; + if (!liquidationTxHash || !collateralAmount || !evidence || !disputeFee) { + return res.status(400).json({ success: false, error: 'Missing required fields' }); + } + const dispute = disputeResolutionService.fileDispute( + userAddress, '', liquidationTxHash, collateralAmount, evidence, disputeFee + ); + logger.info('Dispute filed', { disputeId: dispute.id, userAddress }); + return res.status(201).json({ success: true, data: dispute }); + } catch (error) { + next(error); + return; + } +}; + +export const getDispute = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id as string; + const dispute = disputeResolutionService.getDispute(id); + if (!dispute) { + return res.status(404).json({ success: false, error: 'Dispute not found' }); + } + return res.status(200).json({ success: true, data: dispute }); + } catch (error) { + next(error); + return; + } +}; + +export const submitEvidence = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const description: string = req.body.description as string; + const data: string = req.body.data as string; + const evidence = disputeResolutionService.submitEvidence(id, userAddress, description, data); + if (!evidence) { + return res.status(400).json({ success: false, error: 'Cannot submit evidence at this stage' }); + } + return res.status(200).json({ success: true, data: evidence }); + } catch (error) { + next(error); + return; + } +}; + +export const vote = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const voteValue: string = req.body.vote as string; + const rationale: string = req.body.rationale as string; + if (!voteValue || !['valid', 'invalid'].includes(voteValue)) { + return res.status(400).json({ success: false, error: 'Vote must be valid or invalid' }); + } + const record = disputeResolutionService.castVote(id, userAddress, voteValue as any, rationale); + if (!record) { + return res.status(400).json({ success: false, error: 'Cannot vote at this stage or not a juror' }); + } + return res.status(200).json({ success: true, data: record }); + } catch (error) { + next(error); + return; + } +}; + +export const appeal = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const id = req.params.id as string; + const stake: string = req.body.stake as string; + const appealed = disputeResolutionService.appeal(id, userAddress, stake); + if (!appealed) { + return res.status(400).json({ success: false, error: 'Cannot appeal at this stage' }); + } + return res.status(201).json({ success: true, data: appealed }); + } catch (error) { + next(error); + return; + } +}; + +export const getMyDisputes = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const disputes = disputeResolutionService.getDisputesByUser(userAddress); + return res.status(200).json({ success: true, data: disputes }); + } catch (error) { + next(error); + return; + } +}; + +export const registerJuror = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + disputeResolutionService.registerJuror(userAddress); + return res.status(200).json({ success: true, data: { address: userAddress, registered: true } }); + } catch (error) { + next(error); + return; + } +}; diff --git a/api/src/controllers/notification.controller.ts b/api/src/controllers/notification.controller.ts new file mode 100644 index 00000000..4a2da467 --- /dev/null +++ b/api/src/controllers/notification.controller.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from 'express'; +import { notificationEngine } from '../services/notification-engine'; +import logger from '../utils/logger'; + +export const subscribe = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const channel: string = req.body.channel as string; + const recipient: string = req.body.recipient as string; + const alertTypes: string[] = req.body.alertTypes as string[]; + if (!channel || !recipient || !alertTypes) { + return res.status(400).json({ success: false, error: 'Missing required fields: channel, recipient, alertTypes' }); + } + const prefs = notificationEngine.subscribe(userAddress, channel as any, recipient, alertTypes as any); + logger.info('Notification subscription', { userAddress, channel }); + return res.status(201).json({ success: true, data: prefs }); + } catch (error) { + next(error); + return; + } +}; + +export const getPreferences = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const prefs = notificationEngine.getPreferences(userAddress); + return res.status(200).json({ success: true, data: prefs }); + } catch (error) { + next(error); + return; + } +}; + +export const updatePreference = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const channel: any = req.body.channel; + const alertType: any = req.body.alertType; + const enabled: boolean = req.body.enabled as boolean; + const threshold: number | undefined = req.body.threshold as number | undefined; + const updated = notificationEngine.updatePreference(userAddress, channel, alertType, enabled, threshold); + if (!updated) { + return res.status(404).json({ success: false, error: 'Preference not found' }); + } + return res.status(200).json({ success: true, data: updated }); + } catch (error) { + next(error); + return; + } +}; + +export const getHistory = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const { alertType, channel, limit, cursor } = req.query as Record; + const result = notificationEngine.getHistory(userAddress, { + alertType: alertType as any, + channel: channel as any, + limit: limit ? parseInt(limit, 10) : undefined, + cursor, + }); + return res.status(200).json({ success: true, data: result.messages, nextCursor: result.nextCursor }); + } catch (error) { + next(error); + return; + } +}; + +export const markDelivered = async (req: Request, res: Response, next: NextFunction) => { + try { + const messageId = req.params.messageId as string; + notificationEngine.markDelivered(messageId); + return res.status(200).json({ success: true }); + } catch (error) { + next(error); + return; + } +}; + +export const markRead = async (req: Request, res: Response, next: NextFunction) => { + try { + const messageId = req.params.messageId as string; + notificationEngine.markRead(messageId); + return res.status(200).json({ success: true }); + } catch (error) { + next(error); + return; + } +}; diff --git a/api/src/controllers/social.controller.ts b/api/src/controllers/social.controller.ts new file mode 100644 index 00000000..4f5d4e91 --- /dev/null +++ b/api/src/controllers/social.controller.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from 'express'; +import { leaderboardService } from '../services/social-trading/leaderboard.service'; +import { copyTradingService } from '../services/social-trading/copy-trading.service'; +import { FollowRequestDto, UnfollowRequestDto, LeaderboardQueryDto } from '../dto/social.dto'; +import logger from '../utils/logger'; + +export const getLeaderboard = async (req: Request, res: Response, next: NextFunction) => { + try { + const query = LeaderboardQueryDto.fromQuery(req.query as Record); + const entries = leaderboardService.getLeaderboard(query as unknown as Record); + return res.status(200).json({ success: true, data: entries }); + } catch (error) { + next(error); + return; + } +}; + +export const getLeaderProfile = async (req: Request, res: Response, next: NextFunction) => { + try { + const address = req.params.address as string; + const profile = leaderboardService.getLeaderProfile(address); + if (!profile) { + return res.status(404).json({ success: false, error: 'Leader not found or opted out' }); + } + return res.status(200).json({ success: true, data: profile }); + } catch (error) { + next(error); + return; + } +}; + +export const follow = async (req: Request, res: Response, next: NextFunction) => { + try { + const dto = FollowRequestDto.fromBody(req.body as Record); + const result = FollowRequestDto.validate(req.body as Record); + if (!result.isValid) { + return res.status(400).json({ success: false, error: result.toErrorString() }); + } + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const relation = copyTradingService.follow(userAddress, dto.leaderAddress, dto.amount); + logger.info('User followed leader', { follower: userAddress, leader: dto.leaderAddress }); + return res.status(201).json({ success: true, data: relation }); + } catch (error) { + next(error); + return; + } +}; + +export const unfollow = async (req: Request, res: Response, next: NextFunction) => { + try { + const dto = UnfollowRequestDto.fromBody(req.body as Record); + const result = UnfollowRequestDto.validate(req.body as Record); + if (!result.isValid) { + return res.status(400).json({ success: false, error: result.toErrorString() }); + } + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const relation = copyTradingService.unfollow(userAddress, dto.leaderAddress); + if (!relation) { + return res.status(404).json({ success: false, error: 'Follow relation not found' }); + } + logger.info('User unfollowed leader', { follower: userAddress, leader: dto.leaderAddress }); + return res.status(200).json({ success: true, data: relation }); + } catch (error) { + next(error); + return; + } +}; + +export const getMyFollowing = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const following = copyTradingService.getFollowing(userAddress); + return res.status(200).json({ success: true, data: following }); + } catch (error) { + next(error); + return; + } +}; + +export const setPrivacy = async (req: Request, res: Response, next: NextFunction) => { + try { + const userAddress = req.headers['x-user-address'] as string; + if (!userAddress) { + return res.status(401).json({ success: false, error: 'Missing x-user-address header' }); + } + const optOut = req.body.optOutCopying === true; + leaderboardService.setOptOut(userAddress, optOut); + return res.status(200).json({ success: true, data: { optOutCopying: optOut } }); + } catch (error) { + next(error); + return; + } +}; diff --git a/api/src/dto/social.dto.ts b/api/src/dto/social.dto.ts new file mode 100644 index 00000000..1c3737ca --- /dev/null +++ b/api/src/dto/social.dto.ts @@ -0,0 +1,73 @@ +import { FieldError, ValidationResult, isValidStellarAddress, isValidAmount } from './base.dto'; + +export class FollowRequestDto { + readonly leaderAddress: string; + readonly amount: string; + readonly acknowledgeRisk: boolean; + + private constructor(data: { leaderAddress: string; amount: string; acknowledgeRisk: boolean }) { + this.leaderAddress = data.leaderAddress; + this.amount = data.amount; + this.acknowledgeRisk = data.acknowledgeRisk; + } + + static validate(body: Record): ValidationResult { + const errors: FieldError[] = []; + if (!isValidStellarAddress(body.leaderAddress)) { + errors.push({ field: 'leaderAddress', message: 'Must be a valid Stellar Ed25519 public key' }); + } + if (!isValidAmount(body.amount)) { + errors.push({ field: 'amount', message: 'Must be a positive integer not exceeding i128 max' }); + } + if (body.acknowledgeRisk !== true) { + errors.push({ field: 'acknowledgeRisk', message: 'You must acknowledge the risk disclosure' }); + } + return new ValidationResult(errors); + } + + static fromBody(body: Record): FollowRequestDto { + return new FollowRequestDto({ + leaderAddress: String(body.leaderAddress ?? ''), + amount: String(body.amount ?? ''), + acknowledgeRisk: body.acknowledgeRisk === true, + }); + } +} + +export class UnfollowRequestDto { + readonly leaderAddress: string; + + private constructor(data: { leaderAddress: string }) { + this.leaderAddress = data.leaderAddress; + } + + static validate(body: Record): ValidationResult { + const errors: FieldError[] = []; + if (!isValidStellarAddress(body.leaderAddress)) { + errors.push({ field: 'leaderAddress', message: 'Must be a valid Stellar Ed25519 public key' }); + } + return new ValidationResult(errors); + } + + static fromBody(body: Record): UnfollowRequestDto { + return new UnfollowRequestDto({ + leaderAddress: String(body.leaderAddress ?? ''), + }); + } +} + +export class LeaderboardQueryDto { + readonly sortBy: string = 'apy'; + readonly limit: number = 20; + readonly offset: number = 0; + readonly riskLevel?: string; + + static fromQuery(query: Record): LeaderboardQueryDto { + const dto = new LeaderboardQueryDto(); + (dto as unknown as Record).sortBy = String(query.sortBy ?? 'apy'); + (dto as unknown as Record).limit = parseInt(String(query.limit ?? '20'), 10); + (dto as unknown as Record).offset = parseInt(String(query.offset ?? '0'), 10); + (dto as unknown as Record).riskLevel = query.riskLevel ? String(query.riskLevel) : undefined; + return dto; + } +} diff --git a/api/src/routes/credit.routes.ts b/api/src/routes/credit.routes.ts new file mode 100644 index 00000000..6e84853c --- /dev/null +++ b/api/src/routes/credit.routes.ts @@ -0,0 +1,101 @@ +import { Router } from 'express'; +import * as creditController from '../controllers/credit.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /credit/create: + * post: + * summary: Create a credit line + * tags: + * - Credit Delegation + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [delegateAddress, maxAmount, interestRate, maturityDate] + * properties: + * delegateAddress: + * type: string + * maxAmount: + * type: string + * interestRate: + * type: string + * maturityDate: + * type: string + * collateral: + * type: string + * responses: + * 201: + * description: Credit line created + */ +router.post('/create', creditController.createCreditLine); + +/** + * @openapi + * /credit/{id}: + * get: + * summary: Get credit line details + * tags: + * - Credit Delegation + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Credit line details + */ +router.get('/:id', creditController.getCreditLine); + +/** + * @openapi + * /credit/{id}/draw: + * post: + * summary: Draw from credit line + * tags: + * - Credit Delegation + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [amount] + * properties: + * amount: + * type: string + * responses: + * 200: + * description: Amount drawn + */ +router.post('/:id/draw', creditController.draw); + +/** + * @openapi + * /credit/{id}/repay: + * post: + * summary: Repay credit line + * tags: + * - Credit Delegation + */ +router.post('/:id/repay', creditController.repay); + +router.post('/:id/default', creditController.claimDefault); +router.put('/:id/limit', creditController.adjustLimit); +router.post('/:id/transfer', creditController.transfer); + +router.get('/my/list', creditController.getMyCreditLines); + +export default router; diff --git a/api/src/routes/dispute.routes.ts b/api/src/routes/dispute.routes.ts new file mode 100644 index 00000000..ae42c050 --- /dev/null +++ b/api/src/routes/dispute.routes.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import * as disputeController from '../controllers/dispute.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /disputes/file: + * post: + * summary: File a dispute against a liquidation + * tags: + * - Disputes + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [liquidationTxHash, collateralAmount, evidence, disputeFee] + * properties: + * liquidationTxHash: + * type: string + * collateralAmount: + * type: string + * evidence: + * type: string + * disputeFee: + * type: string + * responses: + * 201: + * description: Dispute created + */ +router.post('/file', disputeController.fileDispute); + +/** + * @openapi + * /disputes/{id}: + * get: + * summary: Get dispute details + * tags: + * - Disputes + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Dispute details + */ +router.get('/:id', disputeController.getDispute); + +/** + * @openapi + * /disputes/{id}/evidence: + * post: + * summary: Submit evidence for a dispute + * tags: + * - Disputes + */ +router.post('/:id/evidence', disputeController.submitEvidence); + +/** + * @openapi + * /disputes/{id}/vote: + * post: + * summary: Cast vote as juror + * tags: + * - Disputes + */ +router.post('/:id/vote', disputeController.vote); + +/** + * @openapi + * /disputes/{id}/appeal: + * post: + * summary: Appeal a resolved dispute + * tags: + * - Disputes + */ +router.post('/:id/appeal', disputeController.appeal); + +router.get('/my/list', disputeController.getMyDisputes); + +/** + * @openapi + * /disputes/juror/register: + * post: + * summary: Register as a juror + * tags: + * - Disputes + */ +router.post('/juror/register', disputeController.registerJuror); + +export default router; diff --git a/api/src/routes/notification.routes.ts b/api/src/routes/notification.routes.ts new file mode 100644 index 00000000..e8aaad50 --- /dev/null +++ b/api/src/routes/notification.routes.ts @@ -0,0 +1,111 @@ +import { Router } from 'express'; +import * as notificationController from '../controllers/notification.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /notifications/subscribe: + * post: + * summary: Subscribe to notification channel + * tags: + * - Notifications + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [channel, recipient, alertTypes] + * properties: + * channel: + * type: string + * enum: [email, telegram, discord, push] + * recipient: + * type: string + * alertTypes: + * type: array + * items: + * type: string + * responses: + * 201: + * description: Subscription created + */ +router.post('/subscribe', notificationController.subscribe); + +/** + * @openapi + * /notifications/preferences: + * get: + * summary: Get notification preferences + * tags: + * - Notifications + * responses: + * 200: + * description: User preferences + */ +router.get('/preferences', notificationController.getPreferences); + +/** + * @openapi + * /notifications/preferences: + * put: + * summary: Update notification preference + * tags: + * - Notifications + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [channel, alertType, enabled] + * properties: + * channel: + * type: string + * alertType: + * type: string + * enabled: + * type: boolean + * threshold: + * type: number + * responses: + * 200: + * description: Preference updated + */ +router.put('/preferences', notificationController.updatePreference); + +/** + * @openapi + * /notifications/history: + * get: + * summary: Get notification history + * tags: + * - Notifications + * parameters: + * - in: query + * name: alertType + * schema: + * type: string + * - in: query + * name: channel + * schema: + * type: string + * - in: query + * name: limit + * schema: + * type: integer + * - in: query + * name: cursor + * schema: + * type: string + * responses: + * 200: + * description: Notification history + */ +router.get('/history', notificationController.getHistory); + +router.post('/:messageId/delivered', notificationController.markDelivered); +router.post('/:messageId/read', notificationController.markRead); + +export default router; diff --git a/api/src/routes/social.routes.ts b/api/src/routes/social.routes.ts new file mode 100644 index 00000000..6617ca29 --- /dev/null +++ b/api/src/routes/social.routes.ts @@ -0,0 +1,131 @@ +import { Router } from 'express'; +import * as socialController from '../controllers/social.controller'; + +const router: Router = Router(); + +/** + * @openapi + * /social/leaderboard: + * get: + * summary: Get top lenders leaderboard + * tags: + * - Social Trading + * parameters: + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [apy, totalReturns, riskAdjustedReturns, followers] + * - in: query + * name: limit + * schema: + * type: integer + * - in: query + * name: offset + * schema: + * type: integer + * - in: query + * name: riskLevel + * schema: + * type: string + * enum: [low, medium, high] + * responses: + * 200: + * description: Leaderboard entries + */ +router.get('/leaderboard', socialController.getLeaderboard); + +/** + * @openapi + * /social/leader/{address}: + * get: + * summary: Get leader profile and strategy details + * tags: + * - Social Trading + * parameters: + * - in: path + * name: address + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Leader profile + */ +router.get('/leader/:address', socialController.getLeaderProfile); + +/** + * @openapi + * /social/follow: + * post: + * summary: Follow a leader to copy their strategy + * tags: + * - Social Trading + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [leaderAddress, amount, acknowledgeRisk] + * properties: + * leaderAddress: + * type: string + * amount: + * type: string + * acknowledgeRisk: + * type: boolean + * responses: + * 201: + * description: Follow relation created + */ +router.post('/follow', socialController.follow); + +/** + * @openapi + * /social/unfollow: + * post: + * summary: Unfollow a leader (positions remain) + * tags: + * - Social Trading + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [leaderAddress] + * properties: + * leaderAddress: + * type: string + * responses: + * 200: + * description: Unfollowed successfully + */ +router.post('/unfollow', socialController.unfollow); + +router.get('/my-following', socialController.getMyFollowing); + +/** + * @openapi + * /social/privacy: + * put: + * summary: Set privacy opt-out for copying + * tags: + * - Social Trading + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * optOutCopying: + * type: boolean + * responses: + * 200: + * description: Privacy settings updated + */ +router.put('/privacy', socialController.setPrivacy); + +export default router; diff --git a/api/src/routes/v1/index.ts b/api/src/routes/v1/index.ts index 7457e58a..28ab0ea9 100644 --- a/api/src/routes/v1/index.ts +++ b/api/src/routes/v1/index.ts @@ -9,6 +9,10 @@ * /api/v1/account -> user account domain * /api/v1/system -> infrastructure domain * /api/v1/security -> security/privacy domain + * /api/v1/social -> social trading domain + * /api/v1/notifications -> notification domain + * /api/v1/disputes -> dispute resolution domain + * /api/v1/credit -> credit delegation domain */ import { Router } from 'express'; @@ -19,6 +23,10 @@ import oracleV1Routes from './oracle'; import accountV1Routes from './account'; import systemV1Routes from './system'; import securityV1Routes from './security'; +import socialRoutes from '../social.routes'; +import notificationRoutes from '../notification.routes'; +import disputeRoutes from '../dispute.routes'; +import creditRoutes from '../credit.routes'; const router = Router(); @@ -29,5 +37,9 @@ router.use('/oracle', oracleV1Routes); router.use('/account', accountV1Routes); router.use('/system', systemV1Routes); router.use('/security', securityV1Routes); +router.use('/social', socialRoutes); +router.use('/notifications', notificationRoutes); +router.use('/disputes', disputeRoutes); +router.use('/credit', creditRoutes); export default router; diff --git a/api/src/services/credit-delegation/credit.service.ts b/api/src/services/credit-delegation/credit.service.ts new file mode 100644 index 00000000..cf9f6edb --- /dev/null +++ b/api/src/services/credit-delegation/credit.service.ts @@ -0,0 +1,207 @@ +import { CreditLine, CreditDraw, CreditRepayment, CreditStatus } from '../../types/credit'; +import logger from '../../utils/logger'; +import { v4 as uuid } from 'uuid'; + +class CreditDelegationService { + private creditLines: Map = new Map(); + private draws: Map = new Map(); + private repayments: Map = new Map(); + + createCreditLine( + delegatorAddress: string, + delegateAddress: string, + maxAmount: string, + interestRate: string, + maturityDate: string, + collateral?: string + ): CreditLine { + const creditLine: CreditLine = { + id: uuid(), + delegatorAddress, + delegateAddress, + maxAmount, + interestRate, + maturityDate, + collateral, + drawnAmount: '0', + repaidAmount: '0', + status: 'active', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + transferCount: 0, + }; + + this.creditLines.set(creditLine.id, creditLine); + this.draws.set(creditLine.id, []); + this.repayments.set(creditLine.id, []); + + logger.info('Credit line created', { + creditLineId: creditLine.id, + delegator: delegatorAddress, + delegate: delegateAddress, + maxAmount, + }); + + return creditLine; + } + + draw(creditLineId: string, delegateAddress: string, amount: string): CreditDraw | null { + const creditLine = this.creditLines.get(creditLineId); + if (!creditLine) return null; + if (creditLine.delegateAddress !== delegateAddress) { + throw new Error('Only the delegate can draw on this credit line'); + } + if (creditLine.status !== 'active' && creditLine.status !== 'drawn') return null; + + const drawnAmount = BigInt(creditLine.drawnAmount); + const maxAmount = BigInt(creditLine.maxAmount); + const drawAmount = BigInt(amount); + + if (drawnAmount + drawAmount > maxAmount) { + throw new Error('Draw amount exceeds available credit'); + } + + if (new Date(creditLine.maturityDate) < new Date()) { + throw new Error('Credit line has matured'); + } + + const draw: CreditDraw = { + creditLineId, + amount, + drawnAt: new Date().toISOString(), + }; + + const existingDraws = this.draws.get(creditLineId) || []; + existingDraws.push(draw); + this.draws.set(creditLineId, existingDraws); + + const newDrawnAmount = (drawnAmount + drawAmount).toString(); + this.creditLines.set(creditLineId, { + ...creditLine, + drawnAmount: newDrawnAmount, + status: 'drawn', + updatedAt: new Date().toISOString(), + }); + + logger.info('Credit drawn', { creditLineId, amount, delegate: delegateAddress }); + return draw; + } + + repay(creditLineId: string, delegateAddress: string, amount: string): CreditRepayment | null { + const creditLine = this.creditLines.get(creditLineId); + if (!creditLine) return null; + if (creditLine.delegateAddress !== delegateAddress) return null; + if (creditLine.status !== 'active' && creditLine.status !== 'drawn') return null; + + const drawnAmount = BigInt(creditLine.drawnAmount); + const repaidAmount = BigInt(creditLine.repaidAmount); + const repayAmount = BigInt(amount); + + if (repaidAmount + repayAmount > drawnAmount) { + throw new Error('Repayment exceeds drawn amount'); + } + + const interestRate = BigInt(creditLine.interestRate); + const accruedInterest = (repayAmount * interestRate) / 10000n; + + const repayment: CreditRepayment = { + creditLineId, + amount, + repaidAt: new Date().toISOString(), + accruedInterest: accruedInterest.toString(), + }; + + const existingRepayments = this.repayments.get(creditLineId) || []; + existingRepayments.push(repayment); + this.repayments.set(creditLineId, existingRepayments); + + const newRepaidAmount = (repaidAmount + repayAmount).toString(); + const remaining = drawnAmount - (repaidAmount + repayAmount); + const newStatus: CreditStatus = remaining <= 0n ? 'repaid' : 'drawn'; + + this.creditLines.set(creditLineId, { + ...creditLine, + repaidAmount: newRepaidAmount, + status: newStatus, + updatedAt: new Date().toISOString(), + }); + + logger.info('Credit repaid', { creditLineId, amount, delegate: delegateAddress }); + return repayment; + } + + claimDefault(creditLineId: string, delegatorAddress: string): CreditLine | null { + const creditLine = this.creditLines.get(creditLineId); + if (!creditLine) return null; + if (creditLine.delegatorAddress !== delegatorAddress) return null; + if (new Date(creditLine.maturityDate) > new Date()) return null; + if (creditLine.status !== 'active' && creditLine.status !== 'drawn') return null; + + const drawnAmount = BigInt(creditLine.drawnAmount); + const repaidAmount = BigInt(creditLine.repaidAmount); + if (drawnAmount <= repaidAmount) return null; + + const updated = { ...creditLine, status: 'defaulted' as CreditStatus, updatedAt: new Date().toISOString() }; + this.creditLines.set(creditLineId, updated); + + logger.info('Credit line defaulted', { creditLineId, delegator: delegatorAddress }); + return updated; + } + + adjustLimit(creditLineId: string, delegatorAddress: string, newMaxAmount: string): CreditLine | null { + const creditLine = this.creditLines.get(creditLineId); + if (!creditLine) return null; + if (creditLine.delegatorAddress !== delegatorAddress) return null; + + const updated = { ...creditLine, maxAmount: newMaxAmount, updatedAt: new Date().toISOString() }; + this.creditLines.set(creditLineId, updated); + return updated; + } + + transfer(creditLineId: string, delegatorAddress: string, newDelegatorAddress: string): CreditLine | null { + const creditLine = this.creditLines.get(creditLineId); + if (!creditLine) return null; + if (creditLine.delegatorAddress !== delegatorAddress) return null; + + const updated = { + ...creditLine, + delegatorAddress: newDelegatorAddress, + transferCount: creditLine.transferCount + 1, + updatedAt: new Date().toISOString(), + }; + this.creditLines.set(creditLineId, updated); + + logger.info('Credit line transferred', { creditLineId, from: delegatorAddress, to: newDelegatorAddress }); + return updated; + } + + getCreditLine(creditLineId: string): CreditLine | null { + return this.creditLines.get(creditLineId) || null; + } + + getCreditLinesByDelegator(delegatorAddress: string): CreditLine[] { + const result: CreditLine[] = []; + for (const [, cl] of this.creditLines) { + if (cl.delegatorAddress === delegatorAddress) result.push(cl); + } + return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + getCreditLinesByDelegate(delegateAddress: string): CreditLine[] { + const result: CreditLine[] = []; + for (const [, cl] of this.creditLines) { + if (cl.delegateAddress === delegateAddress) result.push(cl); + } + return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + getDraws(creditLineId: string): CreditDraw[] { + return this.draws.get(creditLineId) || []; + } + + getRepayments(creditLineId: string): CreditRepayment[] { + return this.repayments.get(creditLineId) || []; + } +} + +export const creditDelegationService = new CreditDelegationService(); diff --git a/api/src/services/credit-delegation/index.ts b/api/src/services/credit-delegation/index.ts new file mode 100644 index 00000000..8ce50334 --- /dev/null +++ b/api/src/services/credit-delegation/index.ts @@ -0,0 +1 @@ +export { creditDelegationService } from './credit.service'; diff --git a/api/src/services/dispute-resolution/dispute.service.ts b/api/src/services/dispute-resolution/dispute.service.ts new file mode 100644 index 00000000..605e03cd --- /dev/null +++ b/api/src/services/dispute-resolution/dispute.service.ts @@ -0,0 +1,224 @@ +import { Dispute, DisputeStatus, DisputeVote, DisputeEvidence, VoteRecord, JurorAssignment, DisputeResolution } from '../../types/disputes'; +import logger from '../../utils/logger'; +import { v4 as uuid } from 'uuid'; + +const FILING_PERIOD_MS = 24 * 60 * 60 * 1000; +const EVIDENCE_PERIOD_MS = 48 * 60 * 60 * 1000; +const VOTING_PERIOD_MS = 24 * 60 * 60 * 1000; +const REQUIRED_JURY_SIZE = 5; +const MAJORITY_THRESHOLD = 0.66; +const MIN_DISPUTE_FEE = 10000000n; +const APPEAL_MULTIPLIER = 2; + +class DisputeResolutionService { + private disputes: Map = new Map(); + private jurorPool: Set = new Set(); + + registerJuror(address: string): void { + this.jurorPool.add(address); + } + + fileDispute( + disputerAddress: string, + liquidatorAddress: string, + liquidationTxHash: string, + collateralAmount: string, + evidenceData: string, + disputeFee: string + ): Dispute { + const fee = BigInt(disputeFee); + if (fee < MIN_DISPUTE_FEE) { + throw new Error(`Dispute fee must be at least ${MIN_DISPUTE_FEE} stroops`); + } + + const dispute: Dispute = { + id: uuid(), + disputerAddress, + liquidatorAddress, + liquidationTxHash, + collateralAmount, + disputeFee, + status: 'filing', + evidence: [{ + submittedBy: disputerAddress, + description: 'Initial dispute filing', + data: evidenceData, + submittedAt: new Date().toISOString(), + }], + jurors: [], + votes: [], + createdAt: new Date().toISOString(), + appealStake: '0', + }; + + this.disputes.set(dispute.id, dispute); + logger.info('Dispute filed', { disputeId: dispute.id, disputer: disputerAddress }); + return dispute; + } + + submitEvidence(disputeId: string, submitterAddress: string, description: string, data: string): DisputeEvidence | null { + const dispute = this.disputes.get(disputeId); + if (!dispute) return null; + if (dispute.status !== 'filing' && dispute.status !== 'evidence') return null; + if (Date.now() - new Date(dispute.createdAt).getTime() > EVIDENCE_PERIOD_MS + FILING_PERIOD_MS) return null; + + this.disputes.set(disputeId, { ...dispute, status: 'evidence' }); + + const evidence: DisputeEvidence = { + submittedBy: submitterAddress, + description, + data, + submittedAt: new Date().toISOString(), + }; + + const updated = { ...dispute, evidence: [...dispute.evidence, evidence] }; + this.disputes.set(disputeId, updated); + return evidence; + } + + selectJurors(disputeId: string): JurorAssignment[] | null { + const dispute = this.disputes.get(disputeId); + if (!dispute) return null; + if (dispute.status === 'voting' || dispute.status === 'resolved') return null; + + const pool = Array.from(this.jurorPool).filter(j => + j !== dispute.disputerAddress && j !== dispute.liquidatorAddress + ); + + if (pool.length < REQUIRED_JURY_SIZE) { + throw new Error(`Insufficient jurors. Need ${REQUIRED_JURY_SIZE}, have ${pool.length}`); + } + + const selected = this.shuffleArray(pool).slice(0, REQUIRED_JURY_SIZE); + const jurors: JurorAssignment[] = selected.map(address => ({ + jurorAddress: address, + selectedAt: new Date().toISOString(), + voted: false, + })); + + this.disputes.set(disputeId, { + ...dispute, + jurors, + status: 'voting', + }); + + return jurors; + } + + castVote(disputeId: string, jurorAddress: string, vote: DisputeVote, rationale?: string): VoteRecord | null { + const dispute = this.disputes.get(disputeId); + if (!dispute) return null; + if (dispute.status !== 'voting') return null; + if (Date.now() - new Date(dispute.createdAt).getTime() > VOTING_PERIOD_MS + EVIDENCE_PERIOD_MS + FILING_PERIOD_MS) return null; + + const jurorIndex = dispute.jurors.findIndex(j => j.jurorAddress === jurorAddress); + if (jurorIndex === -1) return null; + if (dispute.jurors[jurorIndex]!.voted) return null; + + const voteRecord: VoteRecord = { + jurorAddress, + vote, + rationale, + votedAt: new Date().toISOString(), + }; + + const updatedJurors = [...dispute.jurors]; + updatedJurors[jurorIndex] = { ...(updatedJurors[jurorIndex] as JurorAssignment), voted: true }; + + const updated = { + ...dispute, + jurors: updatedJurors, + votes: [...dispute.votes, voteRecord], + }; + + this.disputes.set(disputeId, updated); + + if (this.canResolve(updated)) { + this.resolve(disputeId); + } + + return voteRecord; + } + + private canResolve(dispute: Dispute): boolean { + const votedCount = dispute.jurors.filter(j => j.voted).length; + return votedCount >= REQUIRED_JURY_SIZE || dispute.votes.length >= Math.ceil(REQUIRED_JURY_SIZE * MAJORITY_THRESHOLD); + } + + private resolve(disputeId: string): void { + const dispute = this.disputes.get(disputeId); + if (!dispute) return; + + const validVotes = dispute.votes.filter(v => v.vote === 'valid').length; + const totalVotes = dispute.votes.length; + const ratio = totalVotes > 0 ? validVotes / totalVotes : 0; + + const resolution: DisputeResolution = ratio >= MAJORITY_THRESHOLD ? 'valid' : 'invalid'; + + this.disputes.set(disputeId, { + ...dispute, + status: 'resolved', + resolution, + resolvedAt: new Date().toISOString(), + }); + + logger.info('Dispute resolved', { disputeId, resolution, validVotes, totalVotes }); + } + + appeal(disputeId: string, appellantAddress: string, stake: string): Dispute | null { + const dispute = this.disputes.get(disputeId); + if (!dispute) return null; + if (dispute.status !== 'resolved') return null; + + const appealStake = BigInt(stake); + const requiredStake = BigInt(dispute.disputeFee) * BigInt(APPEAL_MULTIPLIER); + if (appealStake < requiredStake) { + throw new Error(`Appeal stake must be at least ${requiredStake} stroops`); + } + + const appealedDispute: Dispute = { + ...dispute, + id: uuid(), + status: 'filing', + evidence: [...dispute.evidence], + jurors: [], + votes: [], + resolution: undefined, + resolvedAt: undefined, + createdAt: new Date().toISOString(), + appealParentId: disputeId, + appealStake: stake, + }; + + this.disputes.set(dispute.id, { ...dispute, status: 'appealed' }); + this.disputes.set(appealedDispute.id, appealedDispute); + return appealedDispute; + } + + getDispute(disputeId: string): Dispute | null { + return this.disputes.get(disputeId) || null; + } + + getDisputesByUser(address: string): Dispute[] { + const result: Dispute[] = []; + for (const [, dispute] of this.disputes) { + if (dispute.disputerAddress === address || dispute.liquidatorAddress === address) { + result.push(dispute); + } + } + return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + private shuffleArray(array: T[]): T[] { + const shuffled: T[] = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = shuffled[i] as T; + shuffled[i] = shuffled[j] as T; + shuffled[j] = tmp; + } + return shuffled; + } +} + +export const disputeResolutionService = new DisputeResolutionService(); diff --git a/api/src/services/dispute-resolution/index.ts b/api/src/services/dispute-resolution/index.ts new file mode 100644 index 00000000..4f154d04 --- /dev/null +++ b/api/src/services/dispute-resolution/index.ts @@ -0,0 +1 @@ +export { disputeResolutionService } from './dispute.service'; diff --git a/api/src/services/notification-engine/channels/discord.channel.ts b/api/src/services/notification-engine/channels/discord.channel.ts new file mode 100644 index 00000000..e533f13c --- /dev/null +++ b/api/src/services/notification-engine/channels/discord.channel.ts @@ -0,0 +1,21 @@ +import { NotificationMessage } from '../../../types/notifications'; +import logger from '../../../utils/logger'; + +class DiscordChannel { + async send(message: NotificationMessage): Promise { + try { + logger.info('Sending Discord notification', { + id: message.id, + title: message.title, + userId: message.userId + }); + // TODO: Integrate with Discord Webhook API + return true; + } catch (error) { + logger.error('Discord channel failed', { error, messageId: message.id }); + return false; + } + } +} + +export const discordChannel = new DiscordChannel(); diff --git a/api/src/services/notification-engine/channels/email.channel.ts b/api/src/services/notification-engine/channels/email.channel.ts new file mode 100644 index 00000000..8d633610 --- /dev/null +++ b/api/src/services/notification-engine/channels/email.channel.ts @@ -0,0 +1,21 @@ +import { NotificationMessage } from '../../../types/notifications'; +import logger from '../../../utils/logger'; + +class EmailChannel { + async send(message: NotificationMessage): Promise { + try { + logger.info('Sending email notification', { + id: message.id, + title: message.title, + userId: message.userId + }); + // TODO: Integrate with email provider (SendGrid, SES, etc.) + return true; + } catch (error) { + logger.error('Email channel failed', { error, messageId: message.id }); + return false; + } + } +} + +export const emailChannel = new EmailChannel(); diff --git a/api/src/services/notification-engine/channels/index.ts b/api/src/services/notification-engine/channels/index.ts new file mode 100644 index 00000000..232db057 --- /dev/null +++ b/api/src/services/notification-engine/channels/index.ts @@ -0,0 +1,4 @@ +export { emailChannel } from './email.channel'; +export { telegramChannel } from './telegram.channel'; +export { discordChannel } from './discord.channel'; +export { pushChannel } from './push.channel'; diff --git a/api/src/services/notification-engine/channels/push.channel.ts b/api/src/services/notification-engine/channels/push.channel.ts new file mode 100644 index 00000000..924bd02d --- /dev/null +++ b/api/src/services/notification-engine/channels/push.channel.ts @@ -0,0 +1,21 @@ +import { NotificationMessage } from '../../../types/notifications'; +import logger from '../../../utils/logger'; + +class PushChannel { + async send(message: NotificationMessage): Promise { + try { + logger.info('Sending push notification', { + id: message.id, + title: message.title, + userId: message.userId + }); + // TODO: Integrate with push provider (Firebase, Push protocol, etc.) + return true; + } catch (error) { + logger.error('Push channel failed', { error, messageId: message.id }); + return false; + } + } +} + +export const pushChannel = new PushChannel(); diff --git a/api/src/services/notification-engine/channels/telegram.channel.ts b/api/src/services/notification-engine/channels/telegram.channel.ts new file mode 100644 index 00000000..d32afcd0 --- /dev/null +++ b/api/src/services/notification-engine/channels/telegram.channel.ts @@ -0,0 +1,21 @@ +import { NotificationMessage } from '../../../types/notifications'; +import logger from '../../../utils/logger'; + +class TelegramChannel { + async send(message: NotificationMessage): Promise { + try { + logger.info('Sending Telegram notification', { + id: message.id, + title: message.title, + userId: message.userId + }); + // TODO: Integrate with Telegram Bot API + return true; + } catch (error) { + logger.error('Telegram channel failed', { error, messageId: message.id }); + return false; + } + } +} + +export const telegramChannel = new TelegramChannel(); diff --git a/api/src/services/notification-engine/index.ts b/api/src/services/notification-engine/index.ts new file mode 100644 index 00000000..e9c440f1 --- /dev/null +++ b/api/src/services/notification-engine/index.ts @@ -0,0 +1 @@ +export { notificationEngine } from './notification.service'; diff --git a/api/src/services/notification-engine/notification.service.ts b/api/src/services/notification-engine/notification.service.ts new file mode 100644 index 00000000..c1c8aae7 --- /dev/null +++ b/api/src/services/notification-engine/notification.service.ts @@ -0,0 +1,218 @@ +import { NotificationMessage, NotificationChannel, AlertType, NotificationPreference, DeliveryStatus, NotificationTemplate } from '../../types/notifications'; +import { emailChannel } from './channels/email.channel'; +import { telegramChannel } from './channels/telegram.channel'; +import { discordChannel } from './channels/discord.channel'; +import { pushChannel } from './channels/push.channel'; +import logger from '../../utils/logger'; +import { v4 as uuid } from 'uuid'; + +const RATE_LIMIT_MS = 5 * 60 * 1000; // 5 minutes +const MAX_NOTIFICATIONS_PER_USER = 1000; + +class NotificationEngine { + private preferences: Map = new Map(); + private history: Map = new Map(); + private templates: Map = new Map(); + private rateLimitTracker: Map = new Map(); + private channels: Map Promise }> = new Map(); + + constructor() { + this.channels.set('email', emailChannel); + this.channels.set('telegram', telegramChannel); + this.channels.set('discord', discordChannel); + this.channels.set('push', pushChannel); + this.initializeTemplates(); + } + + private initializeTemplates(): void { + this.templates.set('health_factor_low', { + id: 'health_factor_low', + alertType: 'health_factor_low', + titleTemplate: '⚠️ Health Factor Low', + bodyTemplate: 'Your health factor has dropped to {healthFactor}. Current value: {collateralValue}, Debt: {debtValue}. Consider adding collateral.', + variables: ['healthFactor', 'collateralValue', 'debtValue'], + }); + this.templates.set('approaching_liquidation', { + id: 'approaching_liquidation', + alertType: 'approaching_liquidation', + titleTemplate: '🚨 Approaching Liquidation', + bodyTemplate: 'Your position is approaching liquidation (HF: {healthFactor}). Price at liquidation: {liquidationPrice}. Current price: {currentPrice}.', + variables: ['healthFactor', 'liquidationPrice', 'currentPrice'], + }); + this.templates.set('position_liquidated', { + id: 'position_liquidated', + alertType: 'position_liquidated', + titleTemplate: '❌ Position Liquidated', + bodyTemplate: 'Your position has been liquidated. Liquidated amount: {liquidatedAmount}. Remaining collateral: {remainingCollateral}.', + variables: ['liquidatedAmount', 'remainingCollateral'], + }); + this.templates.set('liquidatable_position', { + id: 'liquidatable_position', + alertType: 'liquidatable_position', + titleTemplate: '💰 Liquidatable Position Available', + bodyTemplate: 'A position is available for liquidation at {liquidationAddress}. Estimated profit: {estimatedProfit}. Health factor: {healthFactor}.', + variables: ['liquidationAddress', 'estimatedProfit', 'healthFactor'], + }); + this.templates.set('price_alert', { + id: 'price_alert', + alertType: 'price_alert', + titleTemplate: '📊 Price Alert', + bodyTemplate: '{asset} price moved {direction} by {changePercent}%. Current price: {currentPrice}. Threshold: {threshold}.', + variables: ['asset', 'direction', 'changePercent', 'currentPrice', 'threshold'], + }); + } + + subscribe(userId: string, channel: NotificationChannel, recipient: string, alertTypes: AlertType[]): NotificationPreference[] { + const prefs: NotificationPreference[] = alertTypes.map(alertType => ({ + userId, + channel, + alertType, + enabled: true, + })); + this.preferences.set(userId, [...(this.preferences.get(userId) || []), ...prefs]); + logger.info('Notification subscription created', { userId, channel, alertTypes }); + return prefs; + } + + getPreferences(userId: string): NotificationPreference[] { + return this.preferences.get(userId) || []; + } + + updatePreference(userId: string, channel: NotificationChannel, alertType: AlertType, enabled: boolean, threshold?: number): NotificationPreference | null { + const prefs = this.preferences.get(userId) || []; + const index = prefs.findIndex(p => p.channel === channel && p.alertType === alertType); + if (index === -1) return null; + + const updated: NotificationPreference = { ...(prefs[index] as NotificationPreference), enabled, threshold }; + prefs[index] = updated; + this.preferences.set(userId, prefs); + return updated; + } + + async sendAlert( + userId: string, + alertType: AlertType, + variables: Record, + data?: Record + ): Promise { + const prefs = this.preferences.get(userId) || []; + const activePrefs = prefs.filter(p => p.enabled && this.matchesAlertType(p.alertType, alertType)); + + if (activePrefs.length === 0) return []; + + const template = this.templates.get(alertType); + if (!template) { + logger.warn('No template for alert type', { alertType }); + return []; + } + + const messages: NotificationMessage[] = []; + + for (const pref of activePrefs) { + if (this.isRateLimited(userId, alertType)) { + logger.warn('Rate limited notification', { userId, alertType }); + continue; + } + + const message: NotificationMessage = { + id: uuid(), + userId, + channel: pref.channel, + alertType, + title: this.renderTemplate(template.titleTemplate, variables), + body: this.renderTemplate(template.bodyTemplate, variables), + data, + status: 'pending', + createdAt: new Date().toISOString(), + }; + + try { + const channel = this.channels.get(pref.channel); + if (channel) { + const sent = await channel.send(message); + message.status = sent ? 'sent' : 'failed'; + message.sentAt = sent ? new Date().toISOString() : undefined; + } + } catch (error) { + message.status = 'failed'; + logger.error('Failed to send notification', { error, userId, channel: pref.channel }); + } + + this.rateLimitTracker.set(`${userId}:${alertType}`, Date.now()); + this.addToHistory(userId, message); + messages.push(message); + } + + return messages; + } + + getHistory(userId: string, query?: { alertType?: AlertType; channel?: NotificationChannel; limit?: number; cursor?: string }): { messages: NotificationMessage[]; nextCursor?: string } { + let messages = this.history.get(userId) || []; + + if (query?.alertType) { + messages = messages.filter(m => m.alertType === query.alertType); + } + if (query?.channel) { + messages = messages.filter(m => m.channel === query.channel); + } + + messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const limit = query?.limit || 20; + const cursorIndex = query?.cursor ? messages.findIndex(m => m.id === query.cursor) : -1; + const startIndex = cursorIndex >= 0 ? cursorIndex + 1 : 0; + const slice = messages.slice(startIndex, startIndex + limit); + + return { + messages: slice, + nextCursor: messages.length > startIndex + limit ? slice[slice.length - 1]?.id : undefined, + }; + } + + markDelivered(messageId: string): void { + this.updateMessageStatus(messageId, 'delivered'); + } + + markRead(messageId: string): void { + this.updateMessageStatus(messageId, 'read'); + } + + private updateMessageStatus(messageId: string, status: DeliveryStatus): void { + for (const [, messages] of this.history) { + const index = messages.findIndex(m => m.id === messageId); + if (index >= 0) { + const updated: NotificationMessage = { ...(messages[index] as NotificationMessage), status }; + if (status === 'delivered') updated.deliveredAt = new Date().toISOString(); + if (status === 'read') updated.readAt = new Date().toISOString(); + messages[index] = updated; + break; + } + } + } + + private renderTemplate(template: string, variables: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] || `{${key}}`); + } + + private matchesAlertType(prefType: AlertType, alertType: AlertType): boolean { + return prefType === alertType; + } + + private isRateLimited(userId: string, alertType: AlertType): boolean { + const key = `${userId}:${alertType}`; + const lastSent = this.rateLimitTracker.get(key); + if (!lastSent) return false; + return Date.now() - lastSent < RATE_LIMIT_MS; + } + + private addToHistory(userId: string, message: NotificationMessage): void { + const history = this.history.get(userId) || []; + history.push(message); + if (history.length > MAX_NOTIFICATIONS_PER_USER) { + history.splice(0, history.length - MAX_NOTIFICATIONS_PER_USER); + } + this.history.set(userId, history); + } +} + +export const notificationEngine = new NotificationEngine(); diff --git a/api/src/services/social-trading/copy-trading.service.ts b/api/src/services/social-trading/copy-trading.service.ts new file mode 100644 index 00000000..88dd0d4d --- /dev/null +++ b/api/src/services/social-trading/copy-trading.service.ts @@ -0,0 +1,110 @@ +import { FollowRelation } from '../../types/social'; +import { leaderboardService } from './leaderboard.service'; + +interface MirrorPosition { + followerAddress: string; + leaderAddress: string; + assetAddress: string; + proportionalAmount: string; + allocatedAt: string; +} + +const MIN_FOLLOWER_INVESTMENT = 1000000n; // 1 XLM in stroops + +class CopyTradingService { + private follows: Map = new Map(); + private mirrorPositions: MirrorPosition[] = []; + + follow(followerAddress: string, leaderAddress: string, amount: string): FollowRelation { + const investAmount = BigInt(amount); + if (investAmount < MIN_FOLLOWER_INVESTMENT) { + throw new Error(`Minimum investment is ${MIN_FOLLOWER_INVESTMENT} stroops`); + } + + const key = `${followerAddress}:${leaderAddress}`; + if (this.follows.has(key)) { + throw new Error('Already following this leader'); + } + + const relation: FollowRelation = { + followerAddress, + leaderAddress, + investedAmount: amount, + proportionalAllocation: 0, + totalProfit: '0', + leaderProfitShare: '0', + startedAt: new Date().toISOString(), + active: true, + }; + + this.follows.set(key, relation); + leaderboardService.addFollower(leaderAddress, followerAddress); + return relation; + } + + unfollow(followerAddress: string, leaderAddress: string): FollowRelation | null { + const key = `${followerAddress}:${leaderAddress}`; + const relation = this.follows.get(key); + if (!relation) return null; + + this.follows.set(key, { ...relation, active: false }); + leaderboardService.removeFollower(leaderAddress, followerAddress); + return { ...relation, active: false }; + } + + getFollowRelation(followerAddress: string, leaderAddress: string): FollowRelation | null { + const key = `${followerAddress}:${leaderAddress}`; + return this.follows.get(key) || null; + } + + getFollowers(leaderAddress: string): FollowRelation[] { + const result: FollowRelation[] = []; + for (const [, relation] of this.follows) { + if (relation.leaderAddress === leaderAddress && relation.active) { + result.push(relation); + } + } + return result; + } + + getFollowing(followerAddress: string): FollowRelation[] { + const result: FollowRelation[] = []; + for (const [, relation] of this.follows) { + if (relation.followerAddress === followerAddress) { + result.push(relation); + } + } + return result; + } + + mirrorLeaderPosition( + followerAddress: string, + leaderAddress: string, + assetAddress: string, + leaderAmount: string, + leaderTotalValue: string, + followerInvestment: string + ): MirrorPosition { + const proportion = Number(BigInt(followerInvestment)) / Number(BigInt(leaderTotalValue)); + const proportionalAmount = BigInt(Math.floor(Number(BigInt(leaderAmount)) * proportion)).toString(); + + const position: MirrorPosition = { + followerAddress, + leaderAddress, + assetAddress, + proportionalAmount, + allocatedAt: new Date().toISOString(), + }; + + this.mirrorPositions.push(position); + return position; + } + + calculateProfitShare(followerProfit: string, leaderSharePercent: number = 10): string { + const profit = BigInt(followerProfit); + if (profit <= 0n) return '0'; + return (profit * BigInt(leaderSharePercent) / 100n).toString(); + } +} + +export const copyTradingService = new CopyTradingService(); diff --git a/api/src/services/social-trading/index.ts b/api/src/services/social-trading/index.ts new file mode 100644 index 00000000..c087aeaf --- /dev/null +++ b/api/src/services/social-trading/index.ts @@ -0,0 +1,2 @@ +export { leaderboardService } from './leaderboard.service'; +export { copyTradingService } from './copy-trading.service'; diff --git a/api/src/services/social-trading/leaderboard.service.ts b/api/src/services/social-trading/leaderboard.service.ts new file mode 100644 index 00000000..5057a651 --- /dev/null +++ b/api/src/services/social-trading/leaderboard.service.ts @@ -0,0 +1,141 @@ +import { LeaderboardEntry, LeaderProfile, LeaderboardQuery } from '../../types/social'; + +interface LeaderMetrics { + address: string; + alias?: string; + totalDeposits: bigint; + totalBorrows: bigint; + totalReturns: bigint; + apy: number; + riskAdjustedReturns: number; + volatility: number; + sharpeRatio: number; + winRate: number; + totalValue: bigint; + riskLevel: 'low' | 'medium' | 'high'; + followers: number; +} + +class LeaderboardStore { + private leaders: Map = new Map(); + private leaderFollowers: Map> = new Map(); + private optedOut: Set = new Set(); + + registerLeader(address: string, metrics: LeaderMetrics): void { + this.leaders.set(address, metrics); + } + + addFollower(leaderAddress: string, followerAddress: string): void { + const followers = this.leaderFollowers.get(leaderAddress) || new Set(); + followers.add(followerAddress); + this.leaderFollowers.set(leaderAddress, followers); + const leader = this.leaders.get(leaderAddress); + if (leader) { + this.leaders.set(leaderAddress, { ...leader, followers: followers.size }); + } + } + + removeFollower(leaderAddress: string, followerAddress: string): void { + const followers = this.leaderFollowers.get(leaderAddress); + if (followers) { + followers.delete(followerAddress); + this.leaderFollowers.set(leaderAddress, followers); + const leader = this.leaders.get(leaderAddress); + if (leader) { + this.leaders.set(leaderAddress, { ...leader, followers: followers.size }); + } + } + } + + setOptOut(address: string, optedOut: boolean): void { + if (optedOut) { + this.optedOut.add(address); + } else { + this.optedOut.delete(address); + } + } + + getLeaderboard(query: Record): LeaderboardEntry[] { + const entries: LeaderboardEntry[] = []; + for (const [address, metrics] of this.leaders) { + if (this.optedOut.has(address)) continue; + const riskLevelFilter = query.riskLevel as string | undefined; + if (riskLevelFilter && metrics.riskLevel !== riskLevelFilter) continue; + + entries.push({ + address, + alias: metrics.alias, + apy: metrics.apy.toFixed(4), + totalReturns: metrics.totalReturns.toString(), + riskAdjustedReturns: metrics.riskAdjustedReturns.toFixed(4), + totalFollowers: metrics.followers, + totalValue: metrics.totalValue.toString(), + riskLevel: metrics.riskLevel, + strategy: { + allocation: {}, + rebalanceFrequency: 'manual', + riskLevel: metrics.riskLevel, + }, + isOptedOut: false, + }); + } + + const sortBy = (query.sortBy as string) || 'apy'; + entries.sort((a, b) => { + switch (sortBy) { + case 'totalReturns': + return compareBigInt(b.totalReturns, a.totalReturns); + case 'riskAdjustedReturns': + return parseFloat(b.riskAdjustedReturns) - parseFloat(a.riskAdjustedReturns); + case 'followers': + return b.totalFollowers - a.totalFollowers; + default: + return parseFloat(b.apy) - parseFloat(a.apy); + } + }); + + const offset = (query.offset as number) || 0; + const limit = (query.limit as number) || 20; + return entries.slice(offset, offset + limit); + } + + getLeaderProfile(address: string): LeaderProfile | null { + const metrics = this.leaders.get(address); + if (!metrics || this.optedOut.has(address)) return null; + + return { + address: metrics.address, + alias: metrics.alias, + totalPortfolioValue: metrics.totalValue.toString(), + strategy: { + allocation: {}, + rebalanceFrequency: 'manual', + riskLevel: metrics.riskLevel, + }, + performance: { + apy: metrics.apy.toFixed(4), + totalReturns: metrics.totalReturns.toString(), + weeklyReturns: '0', + monthlyReturns: '0', + riskAdjustedReturns: metrics.riskAdjustedReturns.toFixed(4), + volatility: metrics.volatility.toFixed(4), + sharpeRatio: metrics.sharpeRatio.toFixed(4), + maxDrawdown: '0', + winRate: (metrics.winRate * 100).toFixed(2) + '%', + }, + followers: metrics.followers, + totalFollowerValue: '0', + createdAt: new Date().toISOString(), + isOptedOut: false, + }; + } +} + +function compareBigInt(a: string, b: string): number { + const diff = BigInt(a) - BigInt(b); + if (diff > 0n) return 1; + if (diff < 0n) return -1; + return 0; +} + +export const leaderboardService = new LeaderboardStore(); diff --git a/api/src/types/credit.ts b/api/src/types/credit.ts new file mode 100644 index 00000000..c5682fd0 --- /dev/null +++ b/api/src/types/credit.ts @@ -0,0 +1,50 @@ +export type CreditStatus = 'active' | 'drawn' | 'repaid' | 'defaulted' | 'transferred'; + +export interface CreditLine { + id: string; + delegatorAddress: string; + delegateAddress: string; + maxAmount: string; + interestRate: string; + maturityDate: string; + collateral?: string; + drawnAmount: string; + repaidAmount: string; + status: CreditStatus; + createdAt: string; + updatedAt: string; + transferCount: number; +} + +export interface CreditDraw { + creditLineId: string; + amount: string; + drawnAt: string; +} + +export interface CreditRepayment { + creditLineId: string; + amount: string; + repaidAt: string; + accruedInterest: string; +} + +export interface CreateCreditLineRequest { + delegateAddress: string; + maxAmount: string; + interestRate: string; + maturityDate: string; + collateral?: string; +} + +export interface DrawRequest { + amount: string; +} + +export interface RepayRequest { + amount: string; +} + +export interface TransferRequest { + newDelegatorAddress: string; +} diff --git a/api/src/types/disputes.ts b/api/src/types/disputes.ts new file mode 100644 index 00000000..51e514f2 --- /dev/null +++ b/api/src/types/disputes.ts @@ -0,0 +1,62 @@ +export type DisputeStatus = 'filing' | 'evidence' | 'voting' | 'resolved' | 'appealed'; +export type DisputeVote = 'valid' | 'invalid'; +export type DisputeResolution = 'valid' | 'invalid'; + +export interface Dispute { + id: string; + disputerAddress: string; + liquidatorAddress: string; + liquidationTxHash: string; + collateralAmount: string; + disputeFee: string; + status: DisputeStatus; + evidence: DisputeEvidence[]; + jurors: JurorAssignment[]; + votes: VoteRecord[]; + resolution?: DisputeResolution; + resolvedAt?: string; + createdAt: string; + appealParentId?: string; + appealStake: string; +} + +export interface DisputeEvidence { + submittedBy: string; + description: string; + data: string; + submittedAt: string; +} + +export interface JurorAssignment { + jurorAddress: string; + selectedAt: string; + voted: boolean; +} + +export interface VoteRecord { + jurorAddress: string; + vote: DisputeVote; + rationale?: string; + votedAt: string; +} + +export interface FileDisputeRequest { + liquidationTxHash: string; + collateralAmount: string; + evidence: string; +} + +export interface SubmitEvidenceRequest { + description: string; + data: string; +} + +export interface VoteRequest { + vote: DisputeVote; + rationale?: string; +} + +export interface AppealRequest { + disputeId: string; + stake: string; +} diff --git a/api/src/types/notifications.ts b/api/src/types/notifications.ts new file mode 100644 index 00000000..d3faffa9 --- /dev/null +++ b/api/src/types/notifications.ts @@ -0,0 +1,54 @@ +export type NotificationChannel = 'email' | 'telegram' | 'discord' | 'push'; +export type AlertType = 'health_factor_low' | 'approaching_liquidation' | 'position_liquidated' | 'liquidatable_position' | 'price_alert'; +export type DeliveryStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + +export interface NotificationPreference { + userId: string; + channel: NotificationChannel; + alertType: AlertType; + enabled: boolean; + threshold?: number; +} + +export interface NotificationMessage { + id: string; + userId: string; + channel: NotificationChannel; + alertType: AlertType; + title: string; + body: string; + data?: Record; + status: DeliveryStatus; + createdAt: string; + sentAt?: string; + deliveredAt?: string; + readAt?: string; +} + +export interface SubscribeRequest { + channel: NotificationChannel; + recipient: string; + alertTypes: AlertType[]; +} + +export interface PreferenceUpdate { + channel: NotificationChannel; + alertType: AlertType; + enabled: boolean; + threshold?: number; +} + +export interface NotificationHistoryQuery { + alertType?: AlertType; + channel?: NotificationChannel; + limit?: number; + cursor?: string; +} + +export interface NotificationTemplate { + id: string; + alertType: AlertType; + titleTemplate: string; + bodyTemplate: string; + variables: string[]; +} diff --git a/api/src/types/social.ts b/api/src/types/social.ts new file mode 100644 index 00000000..7e15221f --- /dev/null +++ b/api/src/types/social.ts @@ -0,0 +1,73 @@ +export interface LeaderboardEntry { + address: string; + alias?: string; + apy: string; + totalReturns: string; + riskAdjustedReturns: string; + totalFollowers: number; + totalValue: string; + riskLevel: 'low' | 'medium' | 'high'; + strategy: StrategySummary; + isOptedOut: boolean; +} + +export interface StrategySummary { + allocation: Record; + rebalanceFrequency: string; + riskLevel: 'low' | 'medium' | 'high'; + description?: string; +} + +export interface FollowRelation { + followerAddress: string; + leaderAddress: string; + investedAmount: string; + proportionalAllocation: number; + totalProfit: string; + leaderProfitShare: string; + startedAt: string; + active: boolean; +} + +export interface LeaderProfile { + address: string; + alias?: string; + totalPortfolioValue: string; + strategy: StrategySummary; + performance: { + apy: string; + totalReturns: string; + weeklyReturns: string; + monthlyReturns: string; + riskAdjustedReturns: string; + volatility: string; + sharpeRatio: string; + maxDrawdown: string; + winRate: string; + }; + followers: number; + totalFollowerValue: string; + createdAt: string; + isOptedOut: boolean; +} + +export interface FollowRequest { + leaderAddress: string; + amount: string; + acknowledgeRisk: boolean; +} + +export interface UnfollowRequest { + leaderAddress: string; +} + +export interface LeaderboardQuery { + sortBy?: 'apy' | 'totalReturns' | 'riskAdjustedReturns' | 'followers'; + limit?: number; + offset?: number; + riskLevel?: 'low' | 'medium' | 'high'; +} + +export interface PrivacySettings { + optOutCopying: boolean; +} diff --git a/package-lock.json b/package-lock.json index da584fd4..1aa9cb11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@types/supertest": "^6.0.2", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -69,7 +70,7 @@ "supertest": "^7.2.2", "ts-jest": "^29.1.1", "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "typescript": "^5.9.3" } }, "api/node_modules/@commitlint/cli": { @@ -4477,6 +4478,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", diff --git a/package.json b/package.json index 7888576e..9d26a132 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "oracle" ], "scripts": { - "prepare": "husky", + "prepare": "husky || true", "lint:api": "npm run lint --workspace=api", "format:api": "npm run format --workspace=api", "typecheck:api": "npm run typecheck --workspace=api", @@ -17,7 +17,10 @@ "typecheck:oracle": "npm run typecheck --workspace=oracle", "lint:all": "npm run lint:api && npm run lint:oracle", "format:all": "npm run format:api && npm run format:oracle", - "typecheck:all": "npm run typecheck:api && npm run typecheck:oracle" + "typecheck:all": "npm run typecheck:api && npm run typecheck:oracle", + "test:all": "npm run test --workspace=api && npm run test --workspace=oracle", + "build:all": "npm run build --workspace=api && npm run build --workspace=oracle", + "install:all": "npm install --workspaces" }, "devDependencies": { "@commitlint/cli": "^19.3.0", diff --git a/stellar-lend/Cargo.lock b/stellar-lend/Cargo.lock index 6febb999..2a0bd53b 100644 --- a/stellar-lend/Cargo.lock +++ b/stellar-lend/Cargo.lock @@ -2774,6 +2774,30 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "stellarlend-copy-lending" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "stellarlend-common", +] + +[[package]] +name = "stellarlend-credit-delegation" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "stellarlend-common", +] + +[[package]] +name = "stellarlend-dispute-resolution" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "stellarlend-common", +] + [[package]] name = "stellarlend-institutional-wallet" version = "0.1.0" diff --git a/stellar-lend/Cargo.toml b/stellar-lend/Cargo.toml index 850fcdb5..f3234ee7 100644 --- a/stellar-lend/Cargo.toml +++ b/stellar-lend/Cargo.toml @@ -20,6 +20,9 @@ members = [ "contracts/stealth-address", "contracts/privacy-pool", "contracts/reputation-system", + "contracts/copy-lending", + "contracts/dispute-resolution", + "contracts/credit-delegation", ] exclude = ["fuzz"] diff --git a/stellar-lend/contracts/copy-lending/Cargo.toml b/stellar-lend/contracts/copy-lending/Cargo.toml new file mode 100644 index 00000000..b871f2c0 --- /dev/null +++ b/stellar-lend/contracts/copy-lending/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "stellarlend-copy-lending" +version = "0.1.0" +edition = "2021" + +[lib] +name = "stellarlend_copy_lending" +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +soroban-sdk = { workspace = true } +stellarlend-common = { path = "../common" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +stellarlend-common = { path = "../common", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/stellar-lend/contracts/copy-lending/src/lib.rs b/stellar-lend/contracts/copy-lending/src/lib.rs new file mode 100644 index 00000000..2c9f358d --- /dev/null +++ b/stellar-lend/contracts/copy-lending/src/lib.rs @@ -0,0 +1,210 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, xdr::ToXdr, Address, BytesN, Env, Map, String, Vec, +}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Strategy { + pub leader: Address, + pub allocation: Map, + pub rebalance_frequency: u64, + pub risk_level: u32, + pub description: String, + pub created_at: u64, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FollowRelation { + pub follower: Address, + pub leader: Address, + pub invested_amount: i128, + pub proportional_allocation: i128, + pub total_profit: i128, + pub leader_profit_share: i128, + pub started_at: u32, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LeaderStats { + pub total_followers: u32, + pub total_follower_value: i128, + pub total_returns: i128, + pub apy: i128, + pub risk_adjusted_returns: i128, + pub volatility: i128, +} + +#[contracttype] +pub enum CopyLendingDataKey { + Strategy(Address), + Follow(BytesN<32>), + LeaderStats(Address), + OptOut(Address), + FollowerCount(Address), + FollowerList(Address), +} + +fn get_follow_key(env: &Env, follower: &Address, leader: &Address) -> BytesN<32> { + let mut combined: Vec
= Vec::new(env); + combined.push_back(follower.clone()); + combined.push_back(leader.clone()); + let xdr = combined.to_xdr(env); + env.crypto().sha256(&xdr).into() +} + +#[contract] +pub struct CopyLendingContract; + +#[contractimpl] +impl CopyLendingContract { + pub fn set_strategy(env: Env, leader: Address, strategy: Strategy) { + leader.require_auth(); + env.storage() + .instance() + .set(&CopyLendingDataKey::Strategy(leader.clone()), &strategy); + } + + pub fn get_strategy(env: Env, leader: Address) -> Option { + env.storage() + .instance() + .get(&CopyLendingDataKey::Strategy(leader)) + } + + pub fn follow( + env: Env, + follower: Address, + leader: Address, + amount: i128, + ) -> FollowRelation { + follower.require_auth(); + + let min_investment: i128 = 1_000_000; + if amount < min_investment { + panic!("amount below minimum investment"); + } + + let opt_out: bool = env + .storage() + .instance() + .get(&CopyLendingDataKey::OptOut(leader.clone())) + .unwrap_or(false); + if opt_out { + panic!("leader has opted out of copying"); + } + + let follow_key = get_follow_key(&env, &follower, &leader); + if env + .storage() + .instance() + .has(&CopyLendingDataKey::Follow(follow_key.clone())) + { + panic!("already following this leader"); + } + + let ledger_seq = env.ledger().sequence(); + + let relation = FollowRelation { + follower: follower.clone(), + leader: leader.clone(), + invested_amount: amount, + proportional_allocation: 0, + total_profit: 0, + leader_profit_share: 0, + started_at: ledger_seq, + active: true, + }; + + env.storage() + .instance() + .set(&CopyLendingDataKey::Follow(follow_key), &relation); + + let count: u32 = env + .storage() + .instance() + .get(&CopyLendingDataKey::FollowerCount(leader.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&CopyLendingDataKey::FollowerCount(leader.clone()), &(count + 1)); + + relation + } + + pub fn unfollow(env: Env, follower: Address, leader: Address) -> Option { + follower.require_auth(); + + let follow_key = get_follow_key(&env, &follower, &leader); + let mut relation: FollowRelation = env + .storage() + .instance() + .get(&CopyLendingDataKey::Follow(follow_key.clone()))?; + + relation.active = false; + env.storage() + .instance() + .set(&CopyLendingDataKey::Follow(follow_key), &relation); + + let count: u32 = env + .storage() + .instance() + .get(&CopyLendingDataKey::FollowerCount(leader.clone())) + .unwrap_or(1); + if count > 0 { + env.storage() + .instance() + .set(&CopyLendingDataKey::FollowerCount(leader.clone()), &(count - 1)); + } + + Some(relation) + } + + pub fn get_follow_relation( + env: Env, + follower: Address, + leader: Address, + ) -> Option { + let follow_key = get_follow_key(&env, &follower, &leader); + env.storage() + .instance() + .get(&CopyLendingDataKey::Follow(follow_key)) + } + + pub fn get_leader_stats(env: Env, leader: Address) -> Option { + env.storage() + .instance() + .get(&CopyLendingDataKey::LeaderStats(leader)) + } + + pub fn update_leader_stats(env: Env, leader: Address, stats: LeaderStats) { + leader.require_auth(); + env.storage() + .instance() + .set(&CopyLendingDataKey::LeaderStats(leader), &stats); + } + + pub fn set_opt_out(env: Env, leader: Address, opt_out: bool) { + leader.require_auth(); + env.storage() + .instance() + .set(&CopyLendingDataKey::OptOut(leader), &opt_out); + } + + pub fn is_opted_out(env: Env, leader: Address) -> bool { + env.storage() + .instance() + .get(&CopyLendingDataKey::OptOut(leader)) + .unwrap_or(false) + } + + pub fn get_follower_count(env: Env, leader: Address) -> u32 { + env.storage() + .instance() + .get(&CopyLendingDataKey::FollowerCount(leader)) + .unwrap_or(0) + } +} diff --git a/stellar-lend/contracts/credit-delegation/Cargo.toml b/stellar-lend/contracts/credit-delegation/Cargo.toml new file mode 100644 index 00000000..ef0144be --- /dev/null +++ b/stellar-lend/contracts/credit-delegation/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "stellarlend-credit-delegation" +version = "0.1.0" +edition = "2021" + +[lib] +name = "stellarlend_credit_delegation" +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +soroban-sdk = { workspace = true } +stellarlend-common = { path = "../common" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +stellarlend-common = { path = "../common", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/stellar-lend/contracts/credit-delegation/src/lib.rs b/stellar-lend/contracts/credit-delegation/src/lib.rs new file mode 100644 index 00000000..4ac5e042 --- /dev/null +++ b/stellar-lend/contracts/credit-delegation/src/lib.rs @@ -0,0 +1,234 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, Env, Symbol, +}; + +const CREDIT_COUNTER: Symbol = symbol_short!("cntr"); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CreditStatus { + Active, + Drawn, + Repaid, + Defaulted, + Transferred, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreditLine { + pub id: u64, + pub delegator: Address, + pub delegate: Address, + pub max_amount: i128, + pub interest_rate_bps: i128, + pub maturity: u64, + pub collateral: Option, + pub drawn_amount: i128, + pub repaid_amount: i128, + pub status: CreditStatus, + pub created_at: u64, + pub updated_at: u64, + pub transfer_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DrawRecord { + pub credit_line_id: u64, + pub amount: i128, + pub drawn_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepaymentRecord { + pub credit_line_id: u64, + pub amount: i128, + pub accrued_interest: i128, + pub repaid_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub enum CreditDataKey { + CreditLine(u64), + DelegatorLines(Address), + DelegateLines(Address), + Draws(u64), + Repayments(u64), +} + +#[contract] +pub struct CreditDelegationContract; + +#[contractimpl] +impl CreditDelegationContract { + pub fn create_credit_line( + env: Env, + delegator: Address, + delegate: Address, + max_amount: i128, + interest_rate_bps: i128, + maturity: u64, + collateral: Option, + ) -> u64 { + delegator.require_auth(); + + if max_amount <= 0 { + panic!("max amount must be positive"); + } + if interest_rate_bps < 0 || interest_rate_bps > 10000 { + panic!("interest rate must be between 0 and 10000 bps"); + } + + let mut counter: u64 = env.storage().instance().get(&CREDIT_COUNTER).unwrap_or(0); + counter += 1; + env.storage().instance().set(&CREDIT_COUNTER, &counter); + + let credit_line = CreditLine { + id: counter, + delegator: delegator.clone(), + delegate: delegate.clone(), + max_amount, + interest_rate_bps, + maturity, + collateral, + drawn_amount: 0, + repaid_amount: 0, + status: CreditStatus::Active, + created_at: env.ledger().sequence().into(), + updated_at: env.ledger().sequence().into(), + transfer_count: 0, + }; + + env.storage().instance().set(&CreditDataKey::CreditLine(counter), &credit_line); + counter + } + + pub fn draw(env: Env, credit_line_id: u64, delegate: Address, amount: i128) { + delegate.require_auth(); + let mut credit_line: CreditLine = env.storage() + .instance() + .get(&CreditDataKey::CreditLine(credit_line_id)) + .unwrap_or_else(|| panic!("credit line not found")); + + if credit_line.delegate != delegate { + panic!("not authorized as delegate"); + } + if credit_line.status != CreditStatus::Active && credit_line.status != CreditStatus::Drawn { + panic!("credit line is not active"); + } + if u64::from(env.ledger().sequence()) > credit_line.maturity { + panic!("credit line has matured"); + } + + let new_drawn = credit_line.drawn_amount + amount; + if new_drawn > credit_line.max_amount { + panic!("draw exceeds credit limit"); + } + + credit_line.drawn_amount = new_drawn; + credit_line.status = CreditStatus::Drawn; + credit_line.updated_at = env.ledger().sequence().into(); + + env.storage().instance().set(&CreditDataKey::CreditLine(credit_line_id), &credit_line); + } + + pub fn repay(env: Env, credit_line_id: u64, delegate: Address, amount: i128) { + delegate.require_auth(); + let mut credit_line: CreditLine = env.storage() + .instance() + .get(&CreditDataKey::CreditLine(credit_line_id)) + .unwrap_or_else(|| panic!("credit line not found")); + + if credit_line.delegate != delegate { + panic!("not authorized as delegate"); + } + if credit_line.status != CreditStatus::Active && credit_line.status != CreditStatus::Drawn { + panic!("credit line is not active"); + } + + let new_repaid = credit_line.repaid_amount + amount; + if new_repaid > credit_line.drawn_amount { + panic!("repayment exceeds drawn amount"); + } + + credit_line.repaid_amount = new_repaid; + if new_repaid >= credit_line.drawn_amount { + credit_line.status = CreditStatus::Repaid; + } + credit_line.updated_at = env.ledger().sequence().into(); + + env.storage().instance().set(&CreditDataKey::CreditLine(credit_line_id), &credit_line); + } + + pub fn claim_default(env: Env, credit_line_id: u64, delegator: Address) { + delegator.require_auth(); + let mut credit_line: CreditLine = env.storage() + .instance() + .get(&CreditDataKey::CreditLine(credit_line_id)) + .unwrap_or_else(|| panic!("credit line not found")); + + if credit_line.delegator != delegator { + panic!("not authorized as delegator"); + } + if u64::from(env.ledger().sequence()) <= credit_line.maturity { + panic!("credit line has not matured yet"); + } + if credit_line.drawn_amount <= credit_line.repaid_amount { + panic!("no outstanding debt"); + } + if credit_line.status == CreditStatus::Defaulted { + panic!("already defaulted"); + } + + credit_line.status = CreditStatus::Defaulted; + credit_line.updated_at = env.ledger().sequence().into(); + + env.storage().instance().set(&CreditDataKey::CreditLine(credit_line_id), &credit_line); + } + + pub fn adjust_limit(env: Env, credit_line_id: u64, delegator: Address, new_max: i128) { + delegator.require_auth(); + let mut credit_line: CreditLine = env.storage() + .instance() + .get(&CreditDataKey::CreditLine(credit_line_id)) + .unwrap_or_else(|| panic!("credit line not found")); + + if credit_line.delegator != delegator { + panic!("not authorized as delegator"); + } + if new_max < credit_line.drawn_amount { + panic!("new limit below drawn amount"); + } + + credit_line.max_amount = new_max; + credit_line.updated_at = env.ledger().sequence().into(); + + env.storage().instance().set(&CreditDataKey::CreditLine(credit_line_id), &credit_line); + } + + pub fn transfer(env: Env, credit_line_id: u64, current_delegator: Address, new_delegator: Address) { + current_delegator.require_auth(); + let mut credit_line: CreditLine = env.storage() + .instance() + .get(&CreditDataKey::CreditLine(credit_line_id)) + .unwrap_or_else(|| panic!("credit line not found")); + + if credit_line.delegator != current_delegator { + panic!("not authorized as delegator"); + } + + credit_line.delegator = new_delegator; + credit_line.transfer_count += 1; + credit_line.updated_at = env.ledger().sequence().into(); + + env.storage().instance().set(&CreditDataKey::CreditLine(credit_line_id), &credit_line); + } + + pub fn get_credit_line(env: Env, credit_line_id: u64) -> Option { + env.storage().instance().get(&CreditDataKey::CreditLine(credit_line_id)) + } +} diff --git a/stellar-lend/contracts/dispute-resolution/Cargo.toml b/stellar-lend/contracts/dispute-resolution/Cargo.toml new file mode 100644 index 00000000..555b8c38 --- /dev/null +++ b/stellar-lend/contracts/dispute-resolution/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "stellarlend-dispute-resolution" +version = "0.1.0" +edition = "2021" + +[lib] +name = "stellarlend_dispute_resolution" +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +soroban-sdk = { workspace = true } +stellarlend-common = { path = "../common" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +stellarlend-common = { path = "../common", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/stellar-lend/contracts/dispute-resolution/src/lib.rs b/stellar-lend/contracts/dispute-resolution/src/lib.rs new file mode 100644 index 00000000..8290a2eb --- /dev/null +++ b/stellar-lend/contracts/dispute-resolution/src/lib.rs @@ -0,0 +1,378 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec, +}; + +const DISPUTE_COUNTER: Symbol = symbol_short!("d_counter"); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DisputeStatus { + Filing, + Evidence, + Voting, + Resolved, + Appealed, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VoteChoice { + Valid, + Invalid, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Evidence { + pub submitter: Address, + pub description: String, + pub data: BytesN<64>, + pub submitted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Juror { + pub address: Address, + pub selected_at: u64, + pub voted: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Vote { + pub juror: Address, + pub vote: VoteChoice, + pub rationale: String, + pub voted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dispute { + pub id: u64, + pub disputer: Address, + pub liquidator: Address, + pub liquidation_tx: BytesN<64>, + pub collateral_amount: i128, + pub dispute_fee: i128, + pub status: DisputeStatus, + pub evidence: Vec, + pub jurors: Vec, + pub votes: Vec, + pub resolution: u32, // 0=unresolved, 1=Valid, 2=Invalid + pub resolved_at: Option, + pub created_at: u64, + pub appeal_parent: Option, + pub appeal_stake: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JurorRegistration { + pub address: Address, + pub registered_at: u64, + pub total_cases: u32, + pub rewards: i128, +} + +#[contracttype] +#[derive(Clone)] +pub enum DisputeDataKey { + Dispute(u64), + Juror(Address), + JurorList, +} + +#[contract] +pub struct DisputeResolutionContract; + +#[contractimpl] +impl DisputeResolutionContract { + pub fn register_juror(env: Env, address: Address) { + address.require_auth(); + if env + .storage() + .instance() + .has(&DisputeDataKey::Juror(address.clone())) + { + panic!("already registered as juror"); + } + let registration = JurorRegistration { + address: address.clone(), + registered_at: env.ledger().sequence().into(), + total_cases: 0, + rewards: 0, + }; + env.storage() + .instance() + .set(&DisputeDataKey::Juror(address), ®istration); + } + + pub fn file_dispute( + env: Env, + disputer: Address, + liquidator: Address, + liquidation_tx: BytesN<64>, + collateral_amount: i128, + _evidence_data: BytesN<64>, + ) -> u64 { + disputer.require_auth(); + + if collateral_amount <= 0 { + panic!("invalid collateral amount"); + } + + let mut counter: u64 = env.storage().instance().get(&DISPUTE_COUNTER).unwrap_or(0); + counter += 1; + env.storage().instance().set(&DISPUTE_COUNTER, &counter); + + let dispute = Dispute { + id: counter, + disputer: disputer.clone(), + liquidator, + liquidation_tx, + collateral_amount, + dispute_fee: collateral_amount / 10, // 10% fee + status: DisputeStatus::Evidence, + evidence: Vec::new(&env), + jurors: Vec::new(&env), + votes: Vec::new(&env), + resolution: 0, + resolved_at: None, + created_at: env.ledger().sequence().into(), + appeal_parent: None, + appeal_stake: 0, + }; + + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(counter), &dispute); + counter + } + + pub fn submit_evidence( + env: Env, + dispute_id: u64, + submitter: Address, + description: String, + data: BytesN<64>, + ) { + submitter.require_auth(); + let mut dispute: Dispute = env + .storage() + .instance() + .get(&DisputeDataKey::Dispute(dispute_id)) + .unwrap_or_else(|| panic!("dispute not found")); + + if dispute.status != DisputeStatus::Evidence && dispute.status != DisputeStatus::Filing { + panic!("evidence period has ended"); + } + + let evidence = Evidence { + submitter: submitter.clone(), + description, + data, + submitted_at: env.ledger().sequence().into(), + }; + + let mut evidence_list = dispute.evidence; + evidence_list.push_back(evidence); + dispute.evidence = evidence_list; + dispute.status = DisputeStatus::Evidence; + + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(dispute_id), &dispute); + } + + pub fn select_jurors(env: Env, dispute_id: u64, selected: Vec
) { + // Only callable by the contract admin or automated system + let mut dispute: Dispute = env + .storage() + .instance() + .get(&DisputeDataKey::Dispute(dispute_id)) + .unwrap_or_else(|| panic!("dispute not found")); + + if !dispute.jurors.is_empty() { + panic!("jurors already selected"); + } + + let mut jurors = Vec::new(&env); + for address in selected.iter() { + let reg: Option = env + .storage() + .instance() + .get(&DisputeDataKey::Juror(address.clone())); + if reg.is_some() && address != dispute.disputer { + jurors.push_back(Juror { + address: address.clone(), + selected_at: env.ledger().sequence().into(), + voted: false, + }); + } + } + + if jurors.len() < 3 { + panic!("insufficient jurors"); + } + + dispute.jurors = jurors; + dispute.status = DisputeStatus::Voting; + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(dispute_id), &dispute); + } + + pub fn cast_vote( + env: Env, + dispute_id: u64, + juror: Address, + vote: VoteChoice, + rationale: String, + ) { + juror.require_auth(); + let mut dispute: Dispute = env + .storage() + .instance() + .get(&DisputeDataKey::Dispute(dispute_id)) + .unwrap_or_else(|| panic!("dispute not found")); + + if dispute.status != DisputeStatus::Voting { + panic!("voting period is not active"); + } + + // Check if juror is selected and hasn't voted + let mut found = false; + let mut already_voted = false; + let mut updated_jurors = Vec::new(&env); + + for j in dispute.jurors.iter() { + if j.address == juror { + found = true; + if j.voted { + already_voted = true; + } else { + updated_jurors.push_back(Juror { + address: j.address.clone(), + selected_at: j.selected_at, + voted: true, + }); + } + } else { + updated_jurors.push_back(j); + } + } + + if !found { + panic!("not a selected juror"); + } + if already_voted { + panic!("already voted"); + } + + dispute.jurors = updated_jurors; + + let vote_record = Vote { + juror: juror.clone(), + vote: vote.clone(), + rationale, + voted_at: env.ledger().sequence().into(), + }; + + let mut votes = dispute.votes; + votes.push_back(vote_record); + dispute.votes = votes; + + // Check if can resolve (>66% majority) + let total_votes = dispute.votes.len(); + if total_votes >= 3 { + let valid_count = dispute + .votes + .iter() + .filter(|v| v.vote == VoteChoice::Valid) + .count() as u32; + let invalid_count = total_votes - valid_count; + + if valid_count as f64 / total_votes as f64 > 0.66 { + dispute.resolution = 1; + dispute.status = DisputeStatus::Resolved; + dispute.resolved_at = Some(env.ledger().sequence().into()); + } else if invalid_count as f64 / total_votes as f64 > 0.66 { + dispute.resolution = 2; + dispute.status = DisputeStatus::Resolved; + dispute.resolved_at = Some(env.ledger().sequence().into()); + } + } + + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(dispute_id), &dispute); + } + + pub fn appeal(env: Env, dispute_id: u64, appellant: Address, stake: i128) -> u64 { + appellant.require_auth(); + let dispute: Dispute = env + .storage() + .instance() + .get(&DisputeDataKey::Dispute(dispute_id)) + .unwrap_or_else(|| panic!("dispute not found")); + + if dispute.status != DisputeStatus::Resolved { + panic!("dispute is not resolved"); + } + + let required_stake = dispute.dispute_fee * 2; + if stake < required_stake { + panic!("appeal stake must be at least double the dispute fee"); + } + + let mut counter: u64 = env.storage().instance().get(&DISPUTE_COUNTER).unwrap_or(0); + counter += 1; + env.storage().instance().set(&DISPUTE_COUNTER, &counter); + + let appealed = Dispute { + id: counter, + disputer: dispute.disputer.clone(), + liquidator: dispute.liquidator.clone(), + liquidation_tx: dispute.liquidation_tx.clone(), + collateral_amount: dispute.collateral_amount, + dispute_fee: stake, + status: DisputeStatus::Filing, + evidence: dispute.evidence.clone(), + jurors: Vec::new(&env), + votes: Vec::new(&env), + resolution: 0, + resolved_at: None, + created_at: env.ledger().sequence().into(), + appeal_parent: Some(dispute_id), + appeal_stake: stake, + }; + + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(counter), &appealed); + + // Mark original as appealed + let mut original = dispute; + original.status = DisputeStatus::Appealed; + env.storage() + .instance() + .set(&DisputeDataKey::Dispute(dispute_id), &original); + + counter + } + + pub fn get_dispute(env: Env, dispute_id: u64) -> Option { + env.storage() + .instance() + .get(&DisputeDataKey::Dispute(dispute_id)) + } + + pub fn get_juror(env: Env, address: Address) -> Option { + env.storage() + .instance() + .get(&DisputeDataKey::Juror(address)) + } +}