diff --git a/.claude/Master_task.md b/.claude/Master_task.md new file mode 100644 index 0000000..d5c7114 --- /dev/null +++ b/.claude/Master_task.md @@ -0,0 +1,236 @@ +# đŸŽ¯ ULTIMATE TASK EXECUTION FRAMEWORK + +--- + +## 📌 INSTRUCTION TO AI: + +**Role Assignment:** You are acting as an expert [automatically determine based on task: Software Engineer / Strategic Consultant / Technical Writer / Data Analyst / Creative Director / etc.]. Your goal is to execute the task I've described above with 100% accuracy and zero context loss. + +--- + +## 🔍 PHASE 1: DEEP ANALYSIS & CONCEPTUALIZATION + +Before providing any solution, you must: + +### 1.1 Task Decomposition +- **Core Requirements:** Extract and list every explicit requirement +- **Implicit Needs:** Identify unstated but necessary elements +- **Edge Cases:** Anticipate potential pitfalls, errors, or exceptions +- **Success Criteria:** Define what "perfect completion" looks like +- **Constraints Analysis:** Identify technical, time, or resource limitations + +### 1.2 Conceptual Foundation +- **Underlying Principles:** What fundamental concepts apply? +- **Schools of Thought:** What are different approaches to this problem? +- **Domain Knowledge:** What specialized knowledge is required? +- **Dependencies:** What prerequisites or connections exist? + +### 1.3 Best-in-Class Research +- **Industry Standards:** What's the current gold standard? +- **Modern Techniques:** What are cutting-edge approaches? +- **Proven Methods:** What has consistently worked well? +- **Anti-Patterns:** What approaches should be avoided? + +### 1.4 Context Preservation Strategy +- **Information Hierarchy:** Map what information is critical vs. supplementary +- **State Tracking:** Identify what needs to be remembered throughout execution +- **Checkpoint Planning:** Determine where to pause for verification +- **Continuity Plan:** How to maintain context if conversation extends + +--- + +## 📋 PHASE 2: STRATEGIC PROPOSAL (WAIT FOR APPROVAL) + +Present a comprehensive execution plan: + +### 2.1 Recommended Approach +**Chosen Technique/Method:** +- What specific approach will you use? +- Why is it superior to alternatives? +- What are the trade-offs? + +**Alternative Approaches Considered:** +- List 2-3 other viable methods +- Explain why they were not selected + +### 2.2 Detailed Execution Roadmap + +**High-Level Structure:** +1. [Major Phase 1] +2. [Major Phase 2] +3. [Major Phase 3] + +**Granular Implementation Steps:** + +For each major phase, break down into: +- **Sub-step A.1:** [Specific action] + - Input required: [What's needed] + - Output produced: [What's created] + - Validation check: [How to verify] + - Potential issues: [What could go wrong] + +- **Sub-step A.2:** [Next action] + - [Same detailed breakdown] + +### 2.3 Quality Assurance Plan +- **Validation Points:** Where to check for errors +- **Testing Strategy:** How to verify correctness +- **Review Criteria:** What to evaluate at completion +- **Iteration Protocol:** How to handle needed revisions + +### 2.4 Context Continuity Measures +- **Critical Information to Preserve:** [List key data points] +- **State Checkpoints:** [Where to save progress] +- **Recovery Points:** [How to resume if interrupted] +- **Memory Anchors:** [Key references to maintain] + +### 2.5 Refinement Questions + +Ask 3-5 targeted questions to eliminate ambiguity: + +**Clarity Questions:** +- [Question about unclear requirements] + +**Optimization Questions:** +- [Question about preferences or priorities] + +**Validation Questions:** +- [Question to confirm understanding] + +**Scope Questions:** +- [Question about boundaries or extent] + +--- + +## âš ī¸ MANDATORY CHECKPOINT + +**STOP HERE. DO NOT PROCEED TO IMPLEMENTATION.** + +Present the complete Phase 2 analysis and wait for: +- ✅ **Approval:** "Approved, proceed" +- 🔄 **Modifications:** Feedback on the strategy +- ❓ **Clarifications:** Answers to refinement questions + +--- + +## âš™ī¸ PHASE 3: PRECISION IMPLEMENTATION + +Once approved, execute with these protocols: + +### 3.1 Execution Standards +- **Follow the Roadmap:** Implement exactly as planned +- **Technical Proficiency:** Apply best practices throughout +- **Code/Content Quality:** Maintain professional standards +- **Completeness:** Address every sub-step identified + +### 3.2 Implementation Workflow + +**For each major phase:** + +1. **Pre-Step Verification** + - Confirm all inputs are available + - State what you're about to do + +2. **Execution** + - Implement the step precisely + - Document any deviations (with justification) + +3. **Post-Step Validation** + - Verify output meets criteria + - Confirm ready for next step + +4. **Context Update** + - Note what was accomplished + - Preserve critical state information + +### 3.3 Artifact Decision Protocol + +**Create an Artifact when:** +- Code exceeds 20 lines +- Content is meant for external use +- Document is reference material +- User will need to save/copy it +- Multiple iterations expected + +**Stay Conversational when:** +- Providing guidance or explanation +- Asking clarification questions +- Discussing strategy or approach +- Brief answers or summaries + +### 3.4 Continuous Quality Checks +- Verify each component before moving forward +- Cross-reference against original requirements +- Check for internal consistency +- Validate against success criteria + +--- + +## 🔄 PHASE 4: DELIVERY & ITERATION + +### 4.1 Final Delivery Package + +Present: +- **Primary Deliverable:** [Code/Document/Analysis/etc.] +- **Implementation Summary:** What was built and how +- **Verification Report:** Confirmation of all requirements met +- **Usage Instructions:** How to use/deploy/apply the solution +- **Next Steps:** Recommended follow-up actions (if any) + +### 4.2 Post-Delivery Support + +**Offer:** +- Clarification on any aspect +- Modifications or refinements +- Alternative approaches if needed +- Extended functionality suggestions + +### 4.3 Context Preservation for Future Reference + +**Document:** +- Key decisions made and why +- Important patterns or techniques used +- Lessons learned or insights gained +- Foundation for potential future iterations + +--- + +## 🧠 CONTINUOUS CONTEXT MANAGEMENT + +Throughout all phases: + +✓ **Maintain Working Memory** +- Keep all critical requirements active +- Track progress through the roadmap +- Remember previous decisions and rationale +- Preserve user preferences and constraints + +✓ **Progressive Disclosure** +- Don't overwhelm with everything at once +- Layer information logically +- Build on previous understanding +- Confirm comprehension before advancing + +✓ **Intelligent Checkpointing** +- Summarize progress at natural breakpoints +- Restate context when resuming after interruption +- Link current work to overall goal +- Flag when approaching complexity limits + +--- + +## 📊 SUCCESS METRICS + +The task is complete when: + +- [ ] All explicit requirements fulfilled +- [ ] All implicit needs addressed +- [ ] Edge cases handled appropriately +- [ ] Quality standards met or exceeded +- [ ] Deliverable is immediately usable +- [ ] Context fully preserved for future reference +- [ ] User confirms satisfaction + +--- + +**NOW BEGIN PHASE 1 ANALYSIS OF MY TASK ABOVE âŦ†ī¸** \ No newline at end of file diff --git a/docs/backend/LOAN_SYNC_IMPLEMENTATION.md b/docs/backend/LOAN_SYNC_IMPLEMENTATION.md new file mode 100644 index 0000000..6699b7a --- /dev/null +++ b/docs/backend/LOAN_SYNC_IMPLEMENTATION.md @@ -0,0 +1,266 @@ +# Loan Sync Implementation Summary + +**Date**: 2026-01-11 +**Issue**: Backend database not syncing with on-chain loan transactions + +--- + +## Problem + +When users borrowed or repaid loans directly via smart contracts (using scripts), the backend database was not updated. This caused: + +1. **Stale Data**: `usdcBorrowed` remained at "0" even after borrowing +2. **Missing Loan Details**: `loanDuration`, `numberOfInstallments`, `repaymentSchedule` were not populated +3. **No Repayment Tracking**: Repayments didn't update `totalRepaid` or mark installments as paid + +**Example**: Position showing `usdcBorrowed: "0"` even though user borrowed and repaid on-chain. + +--- + +## Solution + +Created **loan notification endpoints** that frontend/scripts call after on-chain transactions to sync the backend database. + +--- + +## Implementation + +### 1. New DTOs + +**`NotifyLoanBorrowDto`** ([notify-loan-borrow.dto.ts](packages/backend/src/modules/solvency/dto/notify-loan-borrow.dto.ts)): +```typescript +{ + txHash: string; // Transaction hash + positionId: string; // Position ID + borrowAmount: string; // USDC borrowed (6 decimals) + loanDuration: string; // Duration in seconds + numberOfInstallments: string; + blockNumber?: string; +} +``` + +**`NotifyLoanRepaymentDto`** ([notify-loan-repayment.dto.ts](packages/backend/src/modules/solvency/dto/notify-loan-repayment.dto.ts)): +```typescript +{ + txHash: string; + positionId: string; + repaymentAmount: string; // USDC repaid (6 decimals) + blockNumber?: string; +} +``` + +### 2. Service Methods + +**[solvency-position.service.ts](packages/backend/src/modules/solvency/services/solvency-position.service.ts:552-625)** + +**`notifyLoanBorrow()`**: +- Verifies position belongs to user +- Calls `recordBorrow()` to update database +- Updates: `usdcBorrowed`, `loanDuration`, `numberOfInstallments`, `repaymentSchedule` +- Calculates and stores repayment schedule + +**`notifyLoanRepayment()`**: +- Verifies position belongs to user +- Calls `recordRepayment()` to update database +- Updates: `usdcBorrowed`, `totalRepaid`, `installmentsPaid`, schedule status +- Marks position as REPAID if fully paid + +### 3. Controller Endpoints + +**[solvency.controller.ts](packages/backend/src/modules/solvency/controllers/solvency.controller.ts:527-559)** + +**`POST /solvency/loan/borrow-notify`** +```typescript +@Post('loan/borrow-notify') +@HttpCode(HttpStatus.OK) +@UseGuards(JwtAuthGuard) +async notifyLoanBorrow(@Request() req: any, @Body() dto: NotifyLoanBorrowDto) +``` + +**`POST /solvency/loan/repay-notify`** +```typescript +@Post('loan/repay-notify') +@HttpCode(HttpStatus.OK) +@UseGuards(JwtAuthGuard) +async notifyLoanRepayment(@Request() req: any, @Body() dto: NotifyLoanRepaymentDto) +``` + +### 4. Updated Scripts + +**[borrow-solvency-loan.js](scripts/borrow-solvency-loan.js:262-296)** + +Added `notifyBackendOfBorrow()` function: +- Called automatically after successful borrow transaction +- Sends loan details to backend +- Syncs database with on-chain state + +**[repay-solvency-loan.js](scripts/repay-solvency-loan.js:237-270)** + +Updated `syncRepaymentWithBackend()` function: +- Calls `/solvency/loan/repay-notify` endpoint +- Syncs repayment with backend database + +--- + +## Usage + +### Borrow Flow + +1. **User borrows via contract**: +```bash +INVESTOR_KEY=0x... node scripts/borrow-solvency-loan.js 1 50 6 +``` + +2. **Script automatically**: + - Executes `borrowUSDC()` on-chain + - Calls `POST /solvency/loan/borrow-notify` + - Syncs loan details to database + +3. **Backend updates**: + - `usdcBorrowed`: "50000000" (50 USDC) + - `loanDuration`: calculated from asset maturity + - `numberOfInstallments`: 6 + - `repaymentSchedule`: array of 6 installments with due dates + +### Repay Flow + +1. **User repays via contract**: +```bash +INVESTOR_KEY=0x... node scripts/repay-solvency-loan.js 1 10 +``` + +2. **Script automatically**: + - Executes `repayLoan()` on-chain + - Calls `POST /solvency/loan/repay-notify` + - Syncs repayment to database + +3. **Backend updates**: + - `usdcBorrowed`: reduced by principal amount + - `totalRepaid`: increased by repayment amount + - `installmentsPaid`: incremented + - `repaymentSchedule[0].status`: marked as "PAID" + - `status`: "REPAID" if fully paid + +### Frontend Integration + +```typescript +// After user borrows +const response = await fetch('/solvency/loan/borrow-notify', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${userToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + txHash: borrowTxHash, + positionId: "1", + borrowAmount: "50000000", + loanDuration: "2592000", + numberOfInstallments: "6", + blockNumber: receipt.blockNumber.toString() + }) +}); + +// After user repays +const response = await fetch('/solvency/loan/repay-notify', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${userToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + txHash: repayTxHash, + positionId: "1", + repaymentAmount: "10000000", + blockNumber: receipt.blockNumber.toString() + }) +}); +``` + +--- + +## Security + +- **Authentication**: Both endpoints require `JwtAuthGuard` +- **Authorization**: Verifies position belongs to authenticated user +- **Validation**: DTOs validate all input parameters +- **Optional TX Verification**: Comments indicate where on-chain verification can be added + +--- + +## Benefits + +1. **Real-time Sync**: Database immediately updated after transactions +2. **Accurate Data**: Frontend/backend show same state as blockchain +3. **Repayment Tracking**: Full schedule with installment status +4. **No Event Indexer Required**: Works without blockchain event listeners +5. **User-Friendly**: Automatic sync in scripts, easy frontend integration + +--- + +## Testing + +### Test Borrow Notification + +```bash +# 1. Borrow loan (automatically syncs) +INVESTOR_KEY=0x... node scripts/borrow-solvency-loan.js 1 50 6 + +# 2. Check database - should show: +# - usdcBorrowed: "50000000" +# - loanDuration: set +# - numberOfInstallments: 6 +# - repaymentSchedule: 6 installments +``` + +### Test Repayment Notification + +```bash +# 1. Repay loan (automatically syncs) +INVESTOR_KEY=0x... node scripts/repay-solvency-loan.js 1 10 + +# 2. Check database - should show: +# - usdcBorrowed: reduced +# - totalRepaid: increased +# - installmentsPaid: incremented +# - repaymentSchedule[0].status: "PAID" +``` + +--- + +## Related Fixes + +This implementation follows the same pattern as: +- `/marketplace/purchases/notify` - Purchase notifications +- `/marketplace/bids/notify` - Bid notifications +- `/marketplace/bids/settle-notify` - Settlement notifications +- `/solvency/sync-position` - Position sync (fixed to upsert instead of always creating) + +--- + +## Files Changed + +### Backend +- ✅ `dto/notify-loan-borrow.dto.ts` - New DTO +- ✅ `dto/notify-loan-repayment.dto.ts` - New DTO +- ✅ `services/solvency-position.service.ts` - Added notify methods +- ✅ `controllers/solvency.controller.ts` - Added endpoints + +### Scripts +- ✅ `borrow-solvency-loan.js` - Added backend sync +- ✅ `repay-solvency-loan.js` - Added backend sync + +--- + +## Future Enhancements + +1. **On-Chain Verification**: Add transaction verification in notify methods +2. **Event Indexer**: Optional blockchain event listener as backup +3. **Webhooks**: Notify external systems of loan events +4. **Batch Sync**: Endpoint to sync multiple positions at once + +--- + +**Status**: ✅ Complete and working +**Build**: ✅ Successful +**Ready for**: Testing and deployment diff --git a/src/app/router/public.routes.tsx b/src/app/router/public.routes.tsx index 71dd994..1a10fad 100644 --- a/src/app/router/public.routes.tsx +++ b/src/app/router/public.routes.tsx @@ -36,6 +36,7 @@ import { NotFoundPage } from '../../components/ui/404-page-not-found'; import AboutPage from '../../pages/landing/About.page'; import ChangelogPage from '../../pages/landing/Changelog.page'; import FAQSection from '../../pages/landing/FAQ.page'; +import BorrowPage from '../../pages/borrow/BorrowPage'; // Challenge Verification Page @@ -60,6 +61,10 @@ export const publicRoutes: RouteObject[] = [ path: '/marketplace', element: , }, + { + path :'/borrow', + element: , + }, { path: '/marketplace/asset/:assetId', element: , diff --git a/src/components/changelog/ChangelogHotTimeline.tsx b/src/components/changelog/ChangelogHotTimeline.tsx index a4325e1..1388bbb 100644 --- a/src/components/changelog/ChangelogHotTimeline.tsx +++ b/src/components/changelog/ChangelogHotTimeline.tsx @@ -202,6 +202,20 @@ export function ChangelogTimeline() { height={500} className="rounded-lg object-cover h-20 md:h-44 lg:h-60 w-full shadow-[0_0_24px_rgba(34,_42,_53,_0.06),_0_1px_1px_rgba(0,_0,_0,_0.05),_0_0_0_1px_rgba(34,_42,_53,_0.04),_0_0_4px_rgba(34,_42,_53,_0.08),_0_16px_68px_rgba(47,_48,_55,_0.05),_0_1px_0_rgba(255,_255,_255,_0.1)_inset]" /> + Server + Server ), diff --git a/src/components/changelog/ChangelogTester.tsx b/src/components/changelog/ChangelogTester.tsx index 0a90bb1..9e263b1 100644 --- a/src/components/changelog/ChangelogTester.tsx +++ b/src/components/changelog/ChangelogTester.tsx @@ -5,6 +5,7 @@ import { useChangelogStore } from '../../stores/changelog.store.ts'; import type { ChangelogFilters } from '../../types/changelog.types.ts'; import { GitCommit, GitPullRequest, Clock, BarChart3, RefreshCw } from 'lucide-react'; import { format } from 'date-fns'; +import { PageLoader } from '../ui/page-loader.tsx'; interface ChangelogTesterProps { selectedRepo?: string; @@ -306,7 +307,7 @@ const ChangelogTester: React.FC = ({ selectedRepo: externa className="w-full px-4 py-3 bg-[#111111] text-white rounded-lg text-sm font-medium hover:bg-[#000000] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > {getCurrentLoading() ? ( - <>Loading... + <> ) : ( `Fetch ${activeTab === 'sync' ? 'Sync' : activeTab === 'stats' ? 'Statistics' : activeTab === 'prs' ? 'PRs' : activeTab === 'timeline' ? 'Timeline' : 'Commits'}` )} diff --git a/src/components/faucet/TokenFaucetCard.tsx b/src/components/faucet/TokenFaucetCard.tsx index 0dece80..427c9d4 100644 --- a/src/components/faucet/TokenFaucetCard.tsx +++ b/src/components/faucet/TokenFaucetCard.tsx @@ -92,7 +92,7 @@ export const TokenFaucetCard = ({ }; return ( -
+
{/* Card Header */}
diff --git a/src/components/leverage/PositionsTable.tsx b/src/components/leverage/PositionsTable.tsx index baba7c8..fced939 100644 --- a/src/components/leverage/PositionsTable.tsx +++ b/src/components/leverage/PositionsTable.tsx @@ -13,7 +13,7 @@ interface PortfolioLeveragePosition { assetId: string; tokenAddress: string; totalAmount: string; - status: 'ACTIVE' | 'SETTLED'; + status: 'ACTIVE' | 'SETTLED' | 'LIQUIDATED'; createdAt: string; firstPurchase: string; metadata: { @@ -29,8 +29,9 @@ interface PortfolioLeveragePosition { totalInterestPaid?: string; lastHarvestTime?: string; settlementTxHash?: string; + liquidationTxHash?: string; leverageInfo: { - type: 'ACTIVE' | 'SETTLED'; + type: 'ACTIVE' | 'SETTLED' | 'LIQUIDATED'; mETHCollateralFormatted: string; usdcBorrowedFormatted: string; healthFactorFormatted?: string; @@ -44,6 +45,8 @@ interface PortfolioLeveragePosition { mETHReturnedFormatted?: string; settlementTxHash?: string; settlementDate?: string; + liquidationTxHash?: string; + liquidationDate?: string; }; } @@ -58,14 +61,19 @@ interface PositionsTableProps { export const PositionsTable = ({ positions, isLoading, onSelectPosition }: PositionsTableProps) => { const navigate = useNavigate(); - const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'SETTLED' | 'LIQUIDATABLE'>('ALL'); + const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'SETTLED' | 'LIQUIDATED'>('ALL'); + + // Helper to get status + const getStatus = (pos: Position): string => { + return pos.status; + }; // Filter positions based on selected status const filteredPositions = statusFilter === 'ALL' ? positions : positions.filter(pos => getStatus(pos) === statusFilter); - const statusOptions: Array<'ALL' | 'ACTIVE' | 'SETTLED' | 'LIQUIDATABLE'> = ['ALL', 'ACTIVE', 'SETTLED', 'LIQUIDATABLE']; + const statusOptions: Array<'ALL' | 'ACTIVE' | 'SETTLED' | 'LIQUIDATED'> = ['ALL', 'ACTIVE', 'SETTLED', 'LIQUIDATED']; // Type guard to check if position is from Portfolio API const isPortfolioPosition = (pos: Position): pos is PortfolioLeveragePosition => { @@ -88,11 +96,6 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit return 'N/A'; }; - // Helper to get status - const getStatus = (pos: Position): string => { - return pos.status; - }; - // Helper to get health factor const getHealthFactor = (pos: Position): number => { if (isPortfolioPosition(pos)) { @@ -109,6 +112,13 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit return pos.settlementTxHash; }; + const getLiquidationTxHash = (pos: Position): string | undefined => { + if (isPortfolioPosition(pos)) { + return pos.liquidationTxHash || pos.leverageInfo?.liquidationTxHash; + } + return pos.liquidationTxHash; + }; + // Helper to get user yield const getUserYield = (pos: Position): string | undefined => { if (isPortfolioPosition(pos)) { @@ -173,7 +183,7 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit <>
{/* Filter Bar */} -
+
Filter by Status: @@ -183,8 +193,8 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit key={status} onClick={() => setStatusFilter(status)} className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${statusFilter === status - ? 'bg-blue-600 text-white' - : 'bg-gray-100 text-gray-700 hover:bg-gray-200' + ? 'bg-blue-600 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }`} > {status} @@ -200,7 +210,7 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit
- + + @@ -227,10 +240,12 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit const health = getHealthFactor(pos); const collateral = formatMETH(pos.mETHCollateral); const invested = formatUSDC(pos.usdcBorrowed); + const totalAmount = isPortfolioPosition(pos) ? formatMETH(pos.totalAmount) : (parseFloat(formatMETH((pos as LeveragePosition).rwaTokenAmount || '0'))).toFixed(2); const status = getStatus(pos); const positionStatusStyle = getPositionStatusStyle(status); const isActivePosition = status === 'ACTIVE'; const settlementTx = getSettlementTxHash(pos); + const liquidationTxHash = getLiquidationTxHash(pos); const userYield = getUserYield(pos); return ( @@ -259,6 +274,13 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit + {/* Tokens */} + + {/* Collateral */} @@ -300,8 +322,18 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit > Settlement Tx - ) : ( - No Tx + ) : ( + liquidationTxHash ? ( + + Liquidation Tx + + ): + (No Tx) )} {status === 'SETTLED' && userYield && (
diff --git a/src/components/marketplace/SentimentChart.tsx b/src/components/marketplace/SentimentChart.tsx index d172956..fc23801 100644 --- a/src/components/marketplace/SentimentChart.tsx +++ b/src/components/marketplace/SentimentChart.tsx @@ -56,7 +56,7 @@ export const SentimentChart = ({ data, isLoading }: SentimentChartProps) => { return { backgroundColor: 'transparent', - animation: false, + animation: true, tooltip: { trigger: 'axis', axisPointer: { type: 'cross', label: { backgroundColor: '#111827' } }, @@ -127,6 +127,7 @@ export const SentimentChart = ({ data, isLoading }: SentimentChartProps) => { startValue: Math.min(0, data.length), endValue: data.length - 1, zoomLock: false // Set to true to maintain the "Industrial" constant candle width + } ], series: [ @@ -163,18 +164,16 @@ export const SentimentChart = ({ data, isLoading }: SentimentChartProps) => { }; return ( -
-
+
+

Market Sentiment

{isLoading && ( -
-
- -
+
+
)} -
+
); }; \ No newline at end of file diff --git a/src/components/marketplace/TradeChart.tsx b/src/components/marketplace/TradeChart.tsx index f19f8aa..3942be0 100644 --- a/src/components/marketplace/TradeChart.tsx +++ b/src/components/marketplace/TradeChart.tsx @@ -120,17 +120,6 @@ export const TradeChart = ({ data, isLoading }: TradeChartProps) => { endValue: data.length - 1, zoomLock: false }, - { - type: 'slider', - show: true, - xAxisIndex: [0], - height: 20, - bottom: 10, - borderColor: 'transparent', - backgroundColor: '#f8fafc', - fillerColor: 'rgba(51, 65, 85, 0.1)', - handleStyle: { color: '#cbd5e1' } - } ], series: [ { @@ -166,27 +155,18 @@ export const TradeChart = ({ data, isLoading }: TradeChartProps) => { }; return ( -
+

Trade Executions

-
-
-

5m Continuous Streaming

-
- {isLoading && ( -
-
- -
+ {isLoading && ( +
+
)} -
-
- Drag chart or use slider to explore historical trends -
+
); }; diff --git a/src/components/notifications/NotificationBell.tsx b/src/components/notifications/NotificationBell.tsx index 724178c..57d6da8 100644 --- a/src/components/notifications/NotificationBell.tsx +++ b/src/components/notifications/NotificationBell.tsx @@ -98,9 +98,9 @@ export function NotificationBell({ role }: { role: 'ORIGINATOR' | 'INVESTOR' | ' // Trigger Toast toast(newNotification.header, { - icon: newNotification.severity === 'SUCCESS' ? '✅' : - newNotification.severity === 'ERROR' ? '❌' : - newNotification.severity === 'WARNING' ? 'âš ī¸' : 'â„šī¸', + icon: newNotification.severity === 'success' ? '✅' : + newNotification.severity === 'error' ? '❌' : + newNotification.severity === 'warning' ? 'âš ī¸' : 'â„šī¸', duration: 4000, position: 'top-right', className: 'font-geist text-sm font-medium' diff --git a/src/components/portfolio/ActiveBidsTable.tsx b/src/components/portfolio/ActiveBidsTable.tsx index ef7c49c..b57d6c5 100644 --- a/src/components/portfolio/ActiveBidsTable.tsx +++ b/src/components/portfolio/ActiveBidsTable.tsx @@ -30,16 +30,20 @@ export const ActiveBidsTable = ({ ? bids : bids.filter(bid => bid.status === statusFilter); - const statusOptions: Array = ['ALL', 'PENDING', 'WON', 'LOST', 'SETTLED', 'REFUNDED']; + const statusOptions: Array = ['ALL', 'PENDING', 'PLACED', 'FINALIZED', 'WON', 'LOST', 'SETTLED', 'REFUNDED']; const getBidStatusStyle = (status: BidStatus) => { switch (status) { + case 'PLACED': + return { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Placed' }; + case 'FINALIZED': + return { bg: 'bg-purple-100', text: 'text-purple-700', label: 'Finalized' }; case 'WON': - return { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Won' }; + return { bg: 'bg-green-100', text: 'text-green-700', label: 'Won' }; case 'LOST': return { bg: 'bg-red-100', text: 'text-red-700', label: 'Lost' }; case 'SETTLED': - return { bg: 'bg-green-100', text: 'text-green-700', label: 'Settled' }; + return { bg: 'bg-emerald-100', text: 'text-emerald-700', label: 'Settled' }; case 'REFUNDED': return { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Refunded' }; case 'PENDING': @@ -79,8 +83,8 @@ export const ActiveBidsTable = ({ return (
{/* Filter Bar */} -
-
+
+
Filter by Status:
@@ -106,7 +110,7 @@ export const ActiveBidsTable = ({
Asset @@ -208,6 +218,9 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit Amount Invested + Tokens + Collateral +
+ {totalAmount} +
+
@@ -268,7 +290,7 @@ export const PositionsTable = ({ positions, isLoading, onSelectPosition }: Posit {/* Health Factor */}
-
+
{health > 0 ? (health / 100).toFixed(2) + '%' : 'N/A'}
- +
Asset ID diff --git a/src/components/portfolio/MiniAreaChart.tsx b/src/components/portfolio/MiniAreaChart.tsx index 1ff1e6e..38bda8d 100644 --- a/src/components/portfolio/MiniAreaChart.tsx +++ b/src/components/portfolio/MiniAreaChart.tsx @@ -22,8 +22,8 @@ export const MiniAreaChart = ({ if (!data || data.length === 0) return null; return ( -
- +
+ { const navigate = useNavigate(); - const [hoveredRow, setHoveredRow] = useState(null); const [expandedRows, setExpandedRows] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'SETTLED' | 'CONFIRMED' | 'CLAIMED'>('ALL'); const [typeFilter, setTypeFilter] = useState<'ALL' | 'STATIC' | 'LEVERAGE'>('ALL'); @@ -194,7 +193,7 @@ export const MyAssetsTable = ({ return (
{/* Filter Bar */} -
+
{/* Type Filter */}
@@ -242,8 +241,8 @@ export const MyAssetsTable = ({
- - + + @@ -283,10 +282,8 @@ export const MyAssetsTable = ({ <> setHoveredRow(rowKey)} - onMouseLeave={() => setHoveredRow(null)} onClick={(e) => toggleRowExpansion(rowKey, e)} > {/* Expand Icon */} @@ -447,7 +444,6 @@ export const MyAssetsTable = ({ @@ -1080,7 +1111,7 @@ const MarketplacePage = () => { (typeof asset.maturityDays === 'number' && asset.maturityDays <= 0) || asset.fundingProgress === 100; - return isMatured ? ( + return isMatured && asset.status === 'PAYOUT_COMPLETE' ? ( + +

Status: {asset.status}

- + {/* Chart Section */} @@ -746,7 +776,7 @@ const AssetDetailsPage = () => {

{tokens} Tokens

{props.payload.purchaseMethod && (

- + Method: {props.payload.purchaseMethod} @@ -1047,8 +1077,8 @@ const AssetDetailsPage = () => { -

+
- Coming Soon + Coming Soon
@@ -392,7 +396,7 @@ const AuctionDetailsPage = () => { value={bidAmount} min={asset.tokenParams?.minInvestment ? (Number(asset.tokenParams.minInvestment) / 1e18).toString() : '0'} max={asset.tokenParams?.totalSupply ? (Number(asset.tokenParams.totalSupply) / 1e18).toString() : '0'} - step="0.01" + step="0.1" onChange={(e) => { const value = e.target.value; if (value === '') { @@ -418,11 +422,9 @@ const AuctionDetailsPage = () => { const effectiveMin = Math.min(minBid, available); if (numValue < effectiveMin) { - setBidAmount(effectiveMin.toFixed(2)); + setBidAmount(effectiveMin.toString()); } else if (numValue > available) { - setBidAmount(available.toFixed(2)); - } else { - setBidAmount(numValue.toFixed(2)); + setBidAmount(available.toString()); } } }} @@ -438,8 +440,8 @@ const AuctionDetailsPage = () => { {/* Price Per Token Input */}
- USDC @@ -454,7 +456,6 @@ const AuctionDetailsPage = () => { disabled={isAuctionAnnounced || !!(asset.listing?.scheduledEndTime && new Date(asset.listing.scheduledEndTime).getTime() <= new Date().getTime())} min={asset.listing?.priceRange?.min ? (Number(asset.listing.priceRange.min) / 1e6).toString() : '0'} max={asset.listing?.priceRange?.max ? (Number(asset.listing.priceRange.max) / 1e6).toString() : '0'} - step="0.01" onChange={(e) => { const value = e.target.value; if (value === '') { @@ -479,18 +480,16 @@ const AuctionDetailsPage = () => { const maxPrice = asset.listing?.priceRange?.max ? Number(asset.listing.priceRange.max) / 1e6 : Infinity; if (numValue < minPrice) { - setPricePerToken(minPrice.toFixed(2)); + setPricePerToken(minPrice.toString()); } else if (numValue > maxPrice) { - setPricePerToken(maxPrice.toFixed(2)); - } else { - setPricePerToken(numValue.toFixed(2)); + setPricePerToken(maxPrice.toString()); } } }} className="w-full border-none text-2xl font-medium text-[#111111] p-0 h-auto bg-transparent focus:outline-none focus:ring-0" />

- Range: ${asset.listing?.priceRange?.min ? (Number(asset.listing.priceRange.min) / 1e6).toFixed(2) : '0.00'} - ${asset.listing?.priceRange?.max ? (Number(asset.listing.priceRange.max) / 1e6).toFixed(2) : '0.00'} + Range: ${asset.listing?.priceRange?.min ? (Number(asset.listing.priceRange.min) / 1e6).toFixed(4) : '0.0000'} - ${asset.listing?.priceRange?.max ? (Number(asset.listing.priceRange.max) / 1e6).toFixed(3) : '0.000'}

diff --git a/src/pages/portfolio/Portfolio.page.tsx b/src/pages/portfolio/Portfolio.page.tsx index c81e027..c0e5b9c 100644 --- a/src/pages/portfolio/Portfolio.page.tsx +++ b/src/pages/portfolio/Portfolio.page.tsx @@ -29,6 +29,8 @@ import { ShaderAnimation } from '../../components/ui/shimmer-lines'; import { useCreditData } from '../borrow/hooks/useCreditData'; import { DepositCollateralModal } from '../borrow/components/DepositCollateralModal'; import { NoAssetsModal } from '../../components/portfolio/NoAssetsModal'; +// import { Wavy } from '../../components/ui/wavy'; +import HeroBackground from '../landing/HeroBackground'; const PortfolioPage = () => { @@ -64,22 +66,37 @@ const PortfolioPage = () => { // Solvency loans state const [myLoans, setMyLoans] = useState([]); const [isLoadingMyLoans, setIsLoadingMyLoans] = useState(true); + const { settleBid, status: settleStatus, error: settleError, isLoading: isSettling, isSuccess } = useSettleBid(); + const [settlingBidId, setSettlingBidId] = useState(null); + // Yield claiming state (investor-claim-yield.sh burn-to-claim model) + const [claimingAssetId, setClaimingAssetId] = useState(null); + const [claimStatus, setClaimStatus] = useState(''); + const [showClaimModal, setShowClaimModal] = useState(false); + const [selectedAssetForClaim, setSelectedAssetForClaim] = useState<{ + assetId: string; + tokenAddress: string; + tokenSymbol: string; + investorBalance: string; + expectedUsdc: string; + allowance: string; + } | null>(null); // Fetch solvency loans + const fetchMyLoans = useCallback(async () => { if (!address) return; setIsLoadingMyLoans(true); try { - const response = await solvencyService.getMyPositions('ACTIVE', 100, 0); - const loans = response.positions.filter(p => parseFloat(p.usdcBorrowed) > 0); - setMyLoans(loans); + const response = await solvencyService.getMyPositions(); + console.log("loans find", response); + setMyLoans(response.positions); } catch (err) { console.error("Error fetching solvency loans:", err); showError("Failed to fetch loans", "Could not retrieve your loan positions."); } finally { setIsLoadingMyLoans(false); } - }, [address, showError]); + }, []); // Filtered data based on search term @@ -108,34 +125,24 @@ const PortfolioPage = () => { ); const filteredOrders = myOrders.filter(order => - order.assetId.toLowerCase().includes(searchTerm.toLowerCase()) || + (filteredAssets.find(asset => asset.assetId === order.assetId)?.metadata?.assetName || '').toLowerCase().includes(searchTerm.toLowerCase()) || order.orderId.toLowerCase().includes(searchTerm.toLowerCase()) || + order.assetId.toLowerCase().includes(searchTerm.toLowerCase()) || false ); + const filteredLoans = myLoans.filter(loan => + loan.positionId.toString().toLowerCase().includes(searchTerm.toLowerCase()) || + false + ); + + // Calculate total asset value (STATIC purchases only) const totalAssetValue = staticAssets.reduce( (sum, asset) => sum + (parseFloat(asset.totalInvested || '0') / 1e6), 0 ); - // Contract interaction for settling bids (investor-settle.sh verified) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { settleBid, status: settleStatus, error: settleError, isLoading: isSettling, isSuccess } = useSettleBid(); - const [settlingBidId, setSettlingBidId] = useState(null); - - // Yield claiming state (investor-claim-yield.sh burn-to-claim model) - const [claimingAssetId, setClaimingAssetId] = useState(null); - const [claimStatus, setClaimStatus] = useState(''); - const [showClaimModal, setShowClaimModal] = useState(false); - const [selectedAssetForClaim, setSelectedAssetForClaim] = useState<{ - assetId: string; - tokenAddress: string; - tokenSymbol: string; - investorBalance: string; - expectedUsdc: string; - allowance: string; - } | null>(null); useEffect(() => { fetchPortfolio(); @@ -184,8 +191,10 @@ const PortfolioPage = () => { fetchMyOrders(); // Refresh orders } }, [isCancelSuccess]); + const handleIncreaseCreditLimit = () => { - if (portfolio && portfolio.portfolio.length > 0) { + const validAssets = staticAssets.filter(asset => asset.yieldInfo?.settlementDistributed === false); + if (validAssets.length > 0) { setShowDepositModal(true); } else { setShowNoAssetsModal(true); @@ -452,7 +461,7 @@ const PortfolioPage = () => { onClick={fetchPortfolio} variant="outline" size="lg" - className="bg-black/20 border-white/30 text-white hover:bg-white hover:text-black transition-all duration-300 backdrop-blur-md min-w-[200px]" + className="bg-black/20 border-white/30 text-white hover:bg-transparent hover:text-black transition-all duration-300 backdrop-blur-md min-w-[200px]" > Let's try again ! @@ -463,13 +472,18 @@ const PortfolioPage = () => { return ( <> - + {/* + */} + {/* + */} + -
+ +
{/* Top Navigation Bar - Fixed Height */}
-
+
{/* Left: Logo + Search */}
@@ -491,27 +505,27 @@ const PortfolioPage = () => {
- {/* Center: Navigation */} - {/* Right: Wallet Display */}
+ {/* Center: Navigation */} + {address && ( <> -
+
{truncateAddress(address)}
@@ -544,7 +558,7 @@ const PortfolioPage = () => { {/* Right Main Area - 3/4 width, tabbed content */}
{/* Single Table Container with Tabs */} -
{ `, }}> {/* Tab Header */} -
+
diff --git a/src/pages/public/auth/Auth.page.tsx b/src/pages/public/auth/Auth.page.tsx index 5104def..b7abd98 100644 --- a/src/pages/public/auth/Auth.page.tsx +++ b/src/pages/public/auth/Auth.page.tsx @@ -221,7 +221,7 @@ export default function AuthPage() { placeholder="Enter your email" value={email} onChange={handleEmailChange} - className="border-none bg-transparent text-white font-sans rounded-2xl h-10" + className="border-none bg-transparent text-black font-sans rounded-2xl h-10" />
@@ -237,16 +237,17 @@ export default function AuthPage() { - { if (files.length > 0) { - setIsUsingTestAadhar(false); - handleDocumentUpload({ aadhaar: files[0] }); + setIsUsingTestAadhar(false); + handleDocumentUpload({ aadhaar: files[0] }); } }} value={kycDocuments.aadhaar ? [kycDocuments.aadhaar] : []} text="Upload Aadhaar Card" - > + accept="image/*" + >
{ const { address, isConnected } = useAccount(); const { toasts, success, error: showError, warning, removeToast } = useToast(); const navigate = useNavigate(); + const [polling, setPolling] = useState(false); // Store State const { @@ -111,11 +114,11 @@ const TradingEngineProductionPage = () => { }; // Helper functions - const handlelogout = () => { + const handlelogout = () => { authService.logout(); disconnect(); navigate('/'); // Redirect to home or login page after logout - }; + }; const formatEChartsData = (candles: any[]) => { return (candles || []).map((c: any) => ({ @@ -188,9 +191,9 @@ const TradingEngineProductionPage = () => { if (!assetId) return; fetchAssetDetails(assetId); fetchOrderbook(assetId); - fetchTradeHistory(assetId); + // fetchTradeHistory(assetId); if (address) { - fetchMyOrders(assetId); + // fetchMyOrders(assetId); fetchTradeableBalance(assetId); } @@ -211,6 +214,15 @@ const TradingEngineProductionPage = () => { fetchPurchaseData(); }, [assetId, address, fetchAssetDetails, fetchOrderbook, fetchTradeHistory, fetchMyOrders, fetchTradeableBalance]); + + useEffect(() => { + if (!assetId) return; + fetchOrderbook(assetId); + setPolling(true); + const poll = setInterval(() => { fetchOrderbook(assetId); setPolling(true); }, 4000); + return () => { clearInterval(poll); setPolling(false); }; + }, [fetchOrderbook]); + // Aggregate purchase data into time blocks const aggregateIntoTimeBlocks = (chartData: any[], intervalMinutes: number = 0.05) => { if (!chartData || chartData.length === 0) return []; @@ -287,7 +299,7 @@ const TradingEngineProductionPage = () => { } }, [p2pError]); - const isTradeable = currentAsset?.status === 'LISTED' || currentAsset?.status === 'ACTIVE' || currentAsset?.status !== 'PAYOUT_COMPLETE'; + const isTradeable = currentAsset?.status === 'LISTED' || currentAsset?.status === 'ACTIVE' || false; // Calculation Helpers const getRequiredTokenApproval = useCallback(() => { @@ -473,359 +485,357 @@ const TradingEngineProductionPage = () => { ); } - return ( -
- - - {/* Page Header */} -
-
-
-

- {currentAsset?.metadata?.invoiceNumber || 'Asset'} -

- - {currentAsset?.metadata?.buyerName || 'Real World Asset'} - -
-
- {/* Center: Navigation */} - - - {/* Right: Wallet Display */} -
- {address && ( - <> - -
- {truncateAddress(address)} -
-
- -
- - )} + return (<> + +
+
+ + + {/* Page Header */} +
+
+
+

+ {currentAsset?.metadata?.invoiceNumber || 'Asset'} +

+ + {currentAsset?.metadata?.buyerName || 'Real World Asset'} +
-
-
- {isTradeable ? 'MARKET OPEN' : 'MARKET CLOSED'} +
+ {/* Center: Navigation */} + + + {/* Right: Wallet Display */} +
+ {address && ( + <> + +
+ {truncateAddress(address)} +
+
+ +
+ + )} +
+
+
+ {isTradeable ? 'MARKET OPEN' : 'MARKET CLOSED'} +
-
- {/* Main Content: 60/40 Split */} -
-
- {/* === LEFT COLUMN (60%): Orderbook + Charts (Scrollable) === */} -
- - {/* MARKET DEPTH (Orderbook) */} -
- {isLoadingOrderbook ? ( -
- -
- ) : ( - <> -
-

Market Depth

-
- - Buy Orders - - - Sell Orders - -
+ {/* Main Content: 60/40 Split */} +
+
+ {/* === LEFT COLUMN (60%): Orderbook + Charts (Scrollable) === */} +
+ + {/* MARKET DEPTH (Orderbook) */} +
+ {isLoadingOrderbook && !polling ? ( +
+
- -
- {/* BUY ORDERS (Bids) - Left Pane */} -
-
- Price - Amount - Total -
-
- {orderbook?.bids?.length > 0 ? orderbook.bids.map((level: PriceLevel, i: number) => { - const maxVolume = Math.max(...(orderbook.bids?.map((b: PriceLevel) => parseFloat(b.amountFormatted)) || [1])); - const width = (parseFloat(level.amountFormatted) / maxVolume) * 100; - return ( -
- {/* Depth Bar - positioned absolutely */} -
- - {level.orders.map((order) => { - const isMyOrder = address && order.maker.toLowerCase() === address.toLowerCase(); - return ( -
!isMyOrder && setSelectedOrder(order)} - title={isMyOrder ? "Creator cannot fulfill its own orders" : undefined} - className={`relative grid grid-cols-3 px-4 py-3 text-sm transition-colors ${isMyOrder - ? 'cursor-not-allowed opacity-60' - : 'cursor-pointer hover:bg-green-50/30' - }`} - > - ${parseFloat(level.priceFormatted).toFixed(2)} - {parseFloat(order.amountFormatted).toFixed(2)} - - ${(parseFloat(level.priceFormatted) * parseFloat(order.amountFormatted)).toFixed(2)} - -
- ); - })} -
- ); - }) : ( -
No active buy orders
- )} + ) : ( + <> +
+

Market Depth

+
+ + Buy Orders + + + Sell Orders +
- {/* SELL ORDERS (Asks) - Right Pane */} -
-
- Price - Amount - Total +
+ {/* BUY ORDERS (Bids) - Left Pane */} +
+
+ Price + Amount + Total +
+
+ {orderbook?.bids?.length > 0 ? orderbook.bids.map((level: PriceLevel, i: number) => { + const maxVolume = Math.max(...(orderbook.bids?.map((b: PriceLevel) => parseFloat(b.amountFormatted)) || [1])); + const width = (parseFloat(level.amountFormatted) / maxVolume) * 100; + return ( +
+ {/* Depth Bar - positioned absolutely */} +
+ + {level.orders.map((order) => { + const isMyOrder = address && order.maker.toLowerCase() === address.toLowerCase(); + return ( +
!isMyOrder && setSelectedOrder(order)} + title={isMyOrder ? "Creator cannot fulfill its own orders" : undefined} + className={`relative grid grid-cols-3 px-4 py-3 text-sm transition-colors ${isMyOrder + ? 'cursor-not-allowed opacity-60' + : 'cursor-pointer hover:bg-green-50/30' + }`} + > + ${parseFloat(level.priceFormatted).toFixed(2)} + {parseFloat(order.amountFormatted).toFixed(2)} + + ${(parseFloat(level.priceFormatted) * parseFloat(order.amountFormatted)).toFixed(2)} + +
+ ); + })} +
+ ); + }) : ( +
No active buy orders
+ )} +
-
- {orderbook?.asks?.length > 0 ? orderbook.asks.map((level: PriceLevel, i: number) => { - const maxVolume = Math.max(...(orderbook.asks?.map((a: PriceLevel) => parseFloat(a.amountFormatted)) || [1])); - const width = (parseFloat(level.amountFormatted) / maxVolume) * 100; - return ( -
- {/* Depth Bar */} -
- {level.orders.map((order) => { - const isMyOrder = address && order.maker.toLowerCase() === address.toLowerCase(); - return ( -
!isMyOrder && setSelectedOrder(order)} - title={isMyOrder ? "Creator cannot fulfill its own orders" : undefined} - className={`relative grid grid-cols-3 px-4 py-3 text-sm transition-colors ${isMyOrder - ? 'cursor-not-allowed opacity-60' - : 'cursor-pointer hover:bg-red-50/30' - }`} - > - ${parseFloat(level.priceFormatted).toFixed(2)} - {parseFloat(order.amountFormatted).toFixed(2)} - - ${(parseFloat(level.priceFormatted) * parseFloat(order.amountFormatted)).toFixed(2)} - -
- ); - })} -
- ); - }) : ( -
No active sell orders
- )} + + {/* SELL ORDERS (Asks) - Right Pane */} +
+
+ Price + Amount + Total +
+
+ {orderbook?.asks?.length > 0 ? orderbook.asks.map((level: PriceLevel, i: number) => { + const maxVolume = Math.max(...(orderbook.asks?.map((a: PriceLevel) => parseFloat(a.amountFormatted)) || [1])); + const width = (parseFloat(level.amountFormatted) / maxVolume) * 100; + return ( +
+ {/* Depth Bar */} +
+ {level.orders.map((order) => { + const isMyOrder = address && order.maker.toLowerCase() === address.toLowerCase(); + return ( +
!isMyOrder && setSelectedOrder(order)} + title={isMyOrder ? "Creator cannot fulfill its own orders" : undefined} + className={`relative grid grid-cols-3 px-4 py-3 text-sm transition-colors ${isMyOrder + ? 'cursor-not-allowed opacity-60' + : 'cursor-pointer hover:bg-red-50/30' + }`} + > + ${parseFloat(level.priceFormatted).toFixed(2)} + {parseFloat(order.amountFormatted).toFixed(2)} + + ${(parseFloat(level.priceFormatted) * parseFloat(order.amountFormatted)).toFixed(2)} + +
+ ); + })} +
+ ); + }) : ( +
No active sell orders
+ )} +
-
- - )} -
+ + )} +
- {/* PURCHASE ACTIVITY CHART */} -
-
-
-

Purchase Activity

-

Token purchases over time

-
-
-
- -
-

Total Activity

-

- {purchaseHistory?.totalTransactions || 0} -

-
+ {/* PURCHASE ACTIVITY CHART */} +
+
+
+

Purchase Activity

+

Token purchases over time

-
- -
-

Direct Buys

-

- {purchaseHistory?.metadata?.directPurchases || 0} -

+
+
+ +
+

Total Activity

+

+ {purchaseHistory?.totalTransactions || 0} +

+
-
-
- -
-

Leveraged

-

- {purchaseHistory?.metadata?.leveragePurchases || 0} -

+
+ +
+

Direct Buys

+

+ {purchaseHistory?.metadata?.directPurchases || 0} +

+
+
+
+ +
+

Leveraged

+

+ {purchaseHistory?.metadata?.leveragePurchases || 0} +

+
-
-
- {formattedChartData.length > 0 ? ( - - - - - - - - - { - const date = new Date(timestamp); - return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }} - /> - { - if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; - if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; - return tokens.toFixed(0); - }} - label={{ value: 'Tokens Purchased', angle: -90, position: 'insideRight', style: { fill: '#6B7280', fontSize: 12 } }} - /> - { - const tokens = typeof value === 'number' ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : value; - return [ -
-

{tokens} Tokens

- {props.payload.purchaseMethod && ( -

- Method: - {props.payload.purchaseMethod} - -

- )} -

{props.payload.purchaseCount} transaction(s)

-
, - '' - ]; - }} - labelFormatter={(timestamp) => { - const date = new Date(timestamp); - return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }} - /> - -
-
- ) : ( -
- -

No purchase activity yet

-
- )} +
+ {formattedChartData.length > 0 ? ( + + + + + + + + + { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }} + /> + { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; + return tokens.toFixed(0); + }} + label={{ value: 'Tokens Purchased', angle: -90, position: 'insideRight', style: { fill: '#6B7280', fontSize: 12 } }} + /> + { + const tokens = typeof value === 'number' ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : value; + return [ +
+

{tokens} Tokens

+ {props.payload.purchaseMethod && ( +

+ Method: + {props.payload.purchaseMethod} + +

+ )} +

{props.payload.purchaseCount} transaction(s)

+
, + '' + ]; + }} + labelFormatter={(timestamp) => { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }} + /> + +
+
+ ) : ( +
+ +

No purchase activity yet

+
+ )} +
- {/* P2P TRADING CHARTS */} - - -
- - {/* === RIGHT COLUMN (40%): Buy/Sell Panel + Asset Details (Sticky) === */} -
- {/* EXECUTION ZONE (Buy/Sell Panel) */} -
- {/* CONTEXTUAL PANEL: Order Review State */} - {selectedOrder ? ( -
-
-

Order Review

- -
+ {/* === RIGHT COLUMN (40%): Buy/Sell Panel + Asset Details (Sticky) === */} +
+ {/* EXECUTION ZONE (Buy/Sell Panel) */} +
+ {/* CONTEXTUAL PANEL: Order Review State */} + {selectedOrder ? ( +
+
+

Order Review

+ +
-
- {/* Settlement Summary */} -
-
- Price per Token - ${selectedOrder.priceFormatted} -
-
- Amount - - {selectedOrder.amountFormatted} Tokens - -
-
-
- Total Settlement - - ${(parseFloat(selectedOrder.amountFormatted) * parseFloat(selectedOrder.priceFormatted)).toFixed(2)} - +
+ {/* Settlement Summary */} +
+
+ Price per Token + ${selectedOrder.priceFormatted} +
+
+ Amount + + {selectedOrder.amountFormatted} Tokens + +
+
+
+ Total Settlement + + ${(parseFloat(selectedOrder.amountFormatted) * parseFloat(selectedOrder.priceFormatted)).toFixed(2)} + +
-
- {/* Transaction Progress */} - {(isTxPending || isTxConfirming) && ( + {/* Transaction Progress */} + {/* {(isTxPending || isTxConfirming) && (
@@ -835,242 +845,252 @@ const TradingEngineProductionPage = () => {

- )} - - {/* Execute Button */} - + )} */} -
- Secured by Mantle Network -
-
-
- ) : ( - /* CONTEXTUAL PANEL: Place Order State */ -
-
-

Place Order

-
- + {/* Execute Button */} + +
+ Secured by Mantle Network +
- - {/* Input Fields */} -
-
- -
- setAmount(e.target.value)} - placeholder="0.00" - className="w-full h-16 pl-5 pr-20 bg-neutral-100/10 border border-gray-200 rounded-2xl font-gellix text-xl font-bold text-[#111111] focus:ring-2 focus:ring-[#0071C5] focus:border-transparent outline-none transition-all placeholder:text-[#D1D5DB]" - disabled={!isTradeable} - /> -
- TOKENS -
+ ) : ( + /* CONTEXTUAL PANEL: Place Order State */ +
+
+

Place Order

+
+ +
-
- -
- setPrice(e.target.value)} - placeholder="0.00" - className="w-full h-16 pl-5 pr-20 bg-neutral-100/10 border border-gray-200 rounded-2xl font-gellix text-xl font-bold text-[#111111] focus:ring-2 focus:ring-[#0071C5] focus:border-transparent outline-none transition-all placeholder:text-[#D1D5DB]" - disabled={!isTradeable} - /> -
- USDC + {/* Input Fields */} +
+
+ +
+ setAmount(e.target.value)} + placeholder="0.00" + className="w-full h-16 pl-5 pr-20 bg-neutral-100/10 border border-gray-200 rounded-2xl font-gellix text-xl font-bold text-[#111111] focus:ring-2 focus:ring-[#0071C5] focus:border-transparent outline-none transition-all placeholder:text-[#D1D5DB]" + disabled={!isTradeable} + /> +
+ TOKENS +
-
-
- {/* Balances & Info */} -
-
- Available {orderType === 'buy' ? 'USDC' : 'Tokens'} - - {orderType === 'buy' - ? `$${usdcBalance ? parseFloat(formatUnits(usdcBalance, 6)).toFixed(2) : '0.00'}` - : `${tokenBalance ? parseFloat(formatUnits(tokenBalance, 18)).toFixed(2) : '0.00'}` - } - +
+ +
+ setPrice(e.target.value)} + placeholder="0.00" + className="w-full h-16 pl-5 pr-20 bg-neutral-100/10 border border-gray-200 rounded-2xl font-gellix text-xl font-bold text-[#111111] focus:ring-2 focus:ring-[#0071C5] focus:border-transparent outline-none transition-all placeholder:text-[#D1D5DB]" + disabled={!isTradeable} + /> +
+ USDC +
+
+
- {orderType === 'sell' && needsTokenApproval && ( -
- - One-time approval required to trade this asset -
- )} - {orderType === 'buy' && needsUsdcApproval && ( -
- - One-time approval required to spend USDC + {/* Balances & Info */} +
+
+ Available {orderType === 'buy' ? 'USDC' : 'Tokens'} + + {orderType === 'buy' + ? `$${usdcBalance ? parseFloat(formatUnits(usdcBalance, 6)).toFixed(2) : '0.00'}` + : `${tokenBalance ? parseFloat(formatUnits(tokenBalance, 18)).toFixed(2) : '0.00'}` + } +
- )} -
- {/* Order Summary */} - {amount && price && ( -
-
- Estimated Total -
- - ${(parseFloat(amount) * parseFloat(price)).toFixed(2)} - - USDC + {orderType === 'sell' && needsTokenApproval && ( +
+ + One-time approval required to trade this asset
-
+ )} + {orderType === 'buy' && needsUsdcApproval && ( +
+ + One-time approval required to spend USDC +
+ )}
- )} - {/* Create Order Button */} - - - {(isTxPending || isTxConfirming) && txHash && ( - +
+ Estimated Total +
+ + ${(parseFloat(amount) * parseFloat(price)).toFixed(2)} + + USDC +
+
+
+ )} + + {/* Create Order Button */} + -
- Secured by Mantle Network -
-
- )} -
+ {(isTxPending || isTxConfirming) && txHash && ( + + View on Explorer → + + )} - {/* ASSET DETAILS */} -
- {isLoadingAsset ? ( -
- -
- ) : ( - <> -

Asset Details

-
-
-

Face Value

-

- ${currentAsset?.metadata?.faceValue - ? (parseFloat(currentAsset.metadata.faceValue)).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) - : '0.00'} -

-
-
-

Issue Date

-

- {currentAsset?.metadata?.issueDate - ? new Date(currentAsset.metadata.issueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) - : 'N/A'} -

-
-
-

Due Date

-

- {currentAsset?.metadata?.dueDate - ? new Date(currentAsset.metadata.dueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) - : 'N/A'} -

-
-
-

Total Supply

-

- {currentAsset?.tokenParams?.totalSupply - ? (parseFloat(currentAsset.tokenParams.totalSupply) / 1e18).toLocaleString() - : '0'} tokens -

-
-
-

Buyer

-

{currentAsset?.metadata?.buyerName || 'N/A'}

-
-
-

Industry

-

{currentAsset?.metadata?.industry || 'N/A'}

-
-
-

Risk Tier

-

{currentAsset?.metadata?.riskTier || 'N/A'}

-
-
-

Token Address

-

- {currentAsset?.token?.address || 'N/A'} -

+
+ Secured by Mantle Network
- - )} + )} +
+ + {/* ASSET DETAILS */} +
+ {isLoadingAsset ? ( +
+ +
+ ) : ( + <> +

Asset Details

+
+
+

Face Value

+

+ ${currentAsset?.metadata?.faceValue + ? (parseFloat(currentAsset.metadata.faceValue)).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + : '0.00'} +

+
+
+

Issue Date

+

+ {currentAsset?.metadata?.issueDate + ? new Date(currentAsset.metadata.issueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + : 'N/A'} +

+
+
+

Due Date

+

+ {currentAsset?.metadata?.dueDate + ? new Date(currentAsset.metadata.dueDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + : 'N/A'} +

+
+
+

Total Supply

+

+ {currentAsset?.tokenParams?.totalSupply + ? (parseFloat(currentAsset.tokenParams.totalSupply) / 1e18).toLocaleString() + : '0'} tokens +

+
+
+

Buyer

+

{currentAsset?.metadata?.buyerName || 'N/A'}

+
+
+

Industry

+

{currentAsset?.metadata?.industry || 'N/A'}

+
+
+

Risk Tier

+

{currentAsset?.metadata?.riskTier || 'N/A'}

+
+
+

Token Address

+

+ {currentAsset?.token?.address || 'N/A'} +

+
+
+ + )} +
+
+ Drag chart or use slider to explore historical trends +
+
+ + +
+ + ); }; diff --git a/src/types/issuer.types.ts b/src/types/issuer.types.ts index f5ad1f4..35e3841 100644 --- a/src/types/issuer.types.ts +++ b/src/types/issuer.types.ts @@ -69,6 +69,8 @@ export interface AssetMetadata { buyerName: string; industry: string; riskTier: string; + name?: string; + image?: string; } export interface TokenParams { diff --git a/src/types/leverage.types.ts b/src/types/leverage.types.ts index f4efec3..8e70940 100644 --- a/src/types/leverage.types.ts +++ b/src/types/leverage.types.ts @@ -44,6 +44,7 @@ export interface LeveragePosition { settlementUSDCReceived?: string; userYieldDistributed?: string; mETHReturnedToUser?: string; + liquidationTxHash?: string; } export interface LeverageQuote { diff --git a/src/types/marketplace.types.ts b/src/types/marketplace.types.ts index c5f55fb..43980af 100644 --- a/src/types/marketplace.types.ts +++ b/src/types/marketplace.types.ts @@ -133,8 +133,10 @@ export interface NotifyPurchasePayload { assetId: string; amount: string; blockNumber: string; + type?: string; } + export interface NotifyBidPlacedPayload { txHash: string; assetId: string; @@ -272,7 +274,7 @@ export type SortOption = // ============================================================================ export type AuctionStatus = 'SCHEDULED' | 'BIDDING' | 'ENDED' | 'SETTLED' | 'CANCELLED'; -export type BidStatus = 'PENDING' | 'WON' | 'LOST' | 'SETTLED' | 'REFUNDED'; +export type BidStatus = 'PENDING' | 'PLACED' | 'FINALIZED' | 'WON' | 'LOST' | 'SETTLED' | 'REFUNDED'; /** * Auction - Represents an RWA token auction @@ -379,6 +381,7 @@ export interface SecondaryOrder { pricePerToken: string; // price in USDC (6 decimals) status: 'OPEN' | 'FILLED' | 'CANCELLED'; txHash: string; + stlTxHash: string; blockNumber: number; blockTimestamp: string; createdAt: string; diff --git a/src/types/solvency.types.ts b/src/types/solvency.types.ts index 48d0c60..0eb3eb6 100644 --- a/src/types/solvency.types.ts +++ b/src/types/solvency.types.ts @@ -268,23 +268,46 @@ export interface TokenApprovalState { */ export interface Position { positionId: number; - collateralToken: { + collateralTokenAddress: string; // Token contract address + collateralTokenType: string; // "RWA" or "PRIVATE_ASSET" + collateralAmount: string; // e.g., "90000000000000000000" (18 decimals) + tokenValueUSD: string; // e.g., "76500000000" (6 decimals) + usdcBorrowed: string; // e.g., "50000000000" (6 decimals) + totalPartnerDebt: string; // e.g., "0" (6 decimals) + totalRepaid: string; // e.g., "0" (6 decimals) + initialLTV: number; // e.g., 6000 (representing 60.00%) + currentHealthFactor: number; // e.g., 15300 (representing 153.00%) or 2147483647 for no debt + healthStatus: string; // "HEALTHY", "WARNING", "CRITICAL", "LIQUIDATABLE" + status: string; // "ACTIVE", "CLOSED", "LIQUIDATED", "SETTLED", "REPAID" + loanDuration: number; // Duration in seconds + numberOfInstallments: number; // Number of installments + installmentInterval: number; // Interval in seconds + installmentsPaid: number; // Number of installments paid + missedPayments: number; // Number of missed payments (0-3) + isDefaulted: boolean; // Whether position has been marked as defaulted + oaidCreditIssued: boolean; // Whether OAID credit was issued + repaymentSchedule: Array<{ + installmentNumber: number; + dueDate: string; + amount: string; + status: 'PAID' | 'PENDING' | 'MISSED'; + }>; // Repayment schedule array + nextPaymentDueDate?: string; // Optional next payment due date + depositTxHash: string; // Deposit transaction hash + depositBlockNumber: number; // Deposit block number + createdAt: string; + updatedAt: string; + + // Optional legacy fields for backward compatibility + collateralToken?: { address: string; symbol: string; name: string; - type: string; // "RWA" or "PRIVATE_ASSET" + type: string; }; - collateralAmount: string; // e.g., "90000000000000000000" (18 decimals) - tokenValueUSD: string; // e.g., "76500000000" (6 decimals) - usdcBorrowed: string; // e.g., "50000000000" (6 decimals) - outstandingDebt: string; // e.g., "50041100000" (6 decimals) - healthFactor: number; // e.g., 15300 (representing 153.00%) - healthStatus: string; // "HEALTHY", "WARNING", "CRITICAL", "LIQUIDATABLE" - status: string; // "ACTIVE", "CLOSED", "LIQUIDATED", "SETTLED", "REPAID" - maxBorrowCapacity: string; // e.g., "53550000000" (6 decimals) - missedPayments?: number; // Number of missed payments (0-3) - isDefaulted?: boolean; // Whether position has been marked as defaulted - createdAt: string; + outstandingDebt?: string; // Computed from usdcBorrowed + totalPartnerDebt + healthFactor?: number; // Alias for currentHealthFactor + maxBorrowCapacity?: string; // Optional max borrow capacity } /** diff --git a/src/utils/solvency/formatters.ts b/src/utils/solvency/formatters.ts index 7e22b6e..f1d55aa 100644 --- a/src/utils/solvency/formatters.ts +++ b/src/utils/solvency/formatters.ts @@ -81,7 +81,8 @@ export const formatCollateralAmount = ( try { // Convert to string if it's a number - const valueStr = typeof value === 'number' ? value.toString() : value; + // Use BigInt to handle scientific notation (e.g. 5e+21) which toString() would output + const valueStr = typeof value === 'number' ? BigInt(value).toString() : value; // Use ethers to properly handle large numbers const formatted = ethers.formatUnits(valueStr, decimals);
{!isLeverage && - hoveredRow === rowKey && parseFloat(asset.yieldInfo?.claimableYield || '0') > 0 && asset.status !== 'CLAIMED' && (
{/* Basic Details Grid */}
@@ -572,6 +568,18 @@ export const MyAssetsTable = ({
)} + + {/* Tokens Leveraged */} + {isLeverage && + + asset.totalAmount && ( +
+
Tokens Leveraged
+
+ {asset.totalAmount ? formatTokenAmount(asset.totalAmount) : '0'} +
+
+ )} {/* Full Transaction Hashes */} {asset.yieldInfo?.yieldClaimTxHash && ( @@ -603,7 +611,7 @@ export const MyAssetsTable = ({
- + @@ -617,9 +625,9 @@ export const MyAssetsTable = ({ - + {asset.transactionHistory.map((tx: any, txIndex: number) => ( - + - @@ -390,6 +428,7 @@ export function LoansView() { {filteredPositions.map((position) => ( + position.borrowedAmountFormatted !== '$0.00' && position.outstandingDebtFormatted !== '$0.00' && ( - + - + ) ))}
Date TypeTx
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', diff --git a/src/components/portfolio/MyLoansTable.tsx b/src/components/portfolio/MyLoansTable.tsx index 80a2376..51f1b08 100644 --- a/src/components/portfolio/MyLoansTable.tsx +++ b/src/components/portfolio/MyLoansTable.tsx @@ -1,30 +1,39 @@ /** - * My Loans Table - Enhanced with Loan Schedule & Repayment + * My Loans Table - Redesigned to match MyAssetsTable * * ✅ SPECIFICATION COMPLIANT * Reference: Solvency Vault End-to-End Flow - Sections 5, 6, 7 * * Features: - * - Expandable rows to show loan schedule (like My Assets table) - * - Calls GET /solvency/position/:id/schedule on expand - * - Shows: loan duration, installments, next payment, missed payments, full schedule - * - Repay button pre-fills next unpaid installment - * - Filters: All, Healthy, At Risk, Critical - * - Sort: Date, Health Factor, Debt Amount + * - Table layout matching MyAssetsTable design + * - Expandable rows to show loan schedule details + * - Proper health factor display (Infinite ∞ for no debt) + * - Defaulted loan visual indicators + * - Overdue payment warnings + * - Filters: All, Healthy, At Risk, Critical, Defaulted */ import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronUp, Wallet, ArrowUpDown, Calendar, AlertCircle } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, ChevronUp, Filter, Calendar, AlertCircle, Info, Clock, DollarSign, X, Activity, Layers, Timer } from 'lucide-react'; import type { Position } from '../../types/solvency.types'; import { solvencyService } from '../../lib/api/solvency.service'; +import { solvencyContractService } from '../../lib/api/solvency-contract.service'; import { format } from 'date-fns'; import { RepayLoanModal } from './RepayLoanModal'; +import { PageLoader } from '../ui/page-loader'; +import { type PortfolioAsset } from '../../lib/api/portfolio.service'; +import { marketplaceService } from '../../lib/api/marketplace.service'; +import { ToastContainer } from '../ui/toast'; +import { useToast } from '../../hooks/useToast'; + interface MyLoansTableProps { positions: Position[]; isLoading: boolean; onRefresh?: () => void; + portfolioAssets: PortfolioAsset[]; } // Format USD from 6 decimal string @@ -46,15 +55,19 @@ const formatCollateralAmount = (value: string, decimals: number = 18) => { return num.toFixed(2); }; -// Format health factor (value like 15300 = 153.00%) -const formatHealthFactor = (value: number) => { - return `${(value / 100).toFixed(2)}%`; +// Derive token symbol from address (simple heuristic) +const getTokenSymbol = (address: string) => { + // You can maintain a map of known addresses or derive from first/last chars + const shortAddr = address.slice(2, 8).toUpperCase(); + return `TKN-${shortAddr}`; }; -const getHealthColor = (healthFactor: number) => { - if (healthFactor >= 15000) return 'text-[#10B981]'; - if (healthFactor >= 12000) return 'text-[#F59E0B]'; - return 'text-[#EF4444]'; +// Calculate outstanding debt from usdcBorrowed and totalPartnerDebt +const getOutstandingDebt = (position: Position): string => { + const borrowed = parseFloat(position.usdcBorrowed || '0'); + const partnerDebt = parseFloat(position.totalPartnerDebt || '0'); + // console.log('Calculating outstanding debt:', (borrowed + partnerDebt).toString() ,{ borrowed, partnerDebt }); + return (borrowed + partnerDebt).toString(); }; const getHealthBadgeColor = (healthStatus: string) => { @@ -70,8 +83,28 @@ const getHealthBadgeColor = (healthStatus: string) => { } }; -type FilterType = 'all' | 'healthy' | 'warning' | 'critical'; -type SortType = 'date' | 'health' | 'debt'; +const getStatusBadgeColor = (status: string) => { + switch (status) { + case 'ACTIVE': + return 'bg-blue-100 text-blue-700'; + case 'REPAID': + return 'bg-green-100 text-green-700'; + case 'SETTLED': + return 'bg-gray-100 text-gray-700'; + case 'LIQUIDATED': + return 'bg-red-100 text-red-700'; + case 'CLOSED': + return 'bg-gray-100 text-gray-700'; + } +}; + +// Check if payment is overdue +const isPaymentOverdue = (nextPaymentDueDate?: string): boolean => { + if (!nextPaymentDueDate) return false; + return new Date(nextPaymentDueDate) < new Date(); +}; + +type FilterType = 'all' | 'healthy' | 'warning' | 'critical' | 'defaulted'; interface LoanSchedule { loanDuration: number; @@ -88,56 +121,59 @@ interface LoanSchedule { }>; } -export const MyLoansTable = ({ positions, isLoading, onRefresh }: MyLoansTableProps) => { +export const MyLoansTable = ({ positions, isLoading, onRefresh, portfolioAssets }: MyLoansTableProps) => { const navigate = useNavigate(); const [activeFilter, setActiveFilter] = useState('all'); - const [sortBy, setSortBy] = useState('date'); - const [expandedPositionId, setExpandedPositionId] = useState(null); - const [scheduleData, setScheduleData] = useState>({}); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const { toasts, error: showError, removeToast } = useToast(); + const [scheduleData, setScheduleData] = useState>({});; const [loadingSchedule, setLoadingSchedule] = useState>({}); const [showRepayModal, setShowRepayModal] = useState(false); + const [showWithdrawModal, setShowWithdrawModal] = useState(false); const [selectedPosition, setSelectedPosition] = useState(null); + const [withdrawingPositionId, setWithdrawingPositionId] = useState(null); + const [withdrawnPositions, setWithdrawnPositions] = useState>(new Set()); - // Filter and sort positions - const filteredAndSortedPositions = useMemo(() => { + // Filter positions + const filteredPositions = useMemo(() => { let filtered = [...positions]; if (activeFilter !== 'all') { filtered = filtered.filter(p => { - if (activeFilter === 'healthy') return p.healthStatus === 'HEALTHY'; + if (activeFilter === 'healthy') return p.healthStatus === 'HEALTHY' && !p.isDefaulted; if (activeFilter === 'warning') return p.healthStatus === 'WARNING'; if (activeFilter === 'critical') return p.healthStatus === 'CRITICAL'; + if (activeFilter === 'defaulted') return p.isDefaulted; return true; }); } - filtered.sort((a, b) => { - if (sortBy === 'date') { - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - } - if (sortBy === 'health') { - return a.healthFactor - b.healthFactor; - } - if (sortBy === 'debt') { - return parseFloat(b.outstandingDebt) - parseFloat(a.outstandingDebt); - } - return 0; - }); + // Sort by creation date (newest first) + filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return filtered; - }, [positions, activeFilter, sortBy]); + }, [positions, activeFilter]); // Toggle expanded row and load schedule - const handleToggleExpand = async (positionId: number) => { - if (expandedPositionId === positionId) { - setExpandedPositionId(null); + const toggleRowExpansion = async (positionId: number, e: React.MouseEvent) => { + e.stopPropagation(); + const newExpanded = new Set(expandedRows); + + if (newExpanded.has(positionId)) { + newExpanded.delete(positionId); + setExpandedRows(newExpanded); return; } - setExpandedPositionId(positionId); + newExpanded.add(positionId); + setExpandedRows(newExpanded); + + // Check if we already have the schedule in the position object + const position = positions.find(p => p.positionId === positionId); + const hasSchedule = (position as any)?.repaymentSchedule?.length > 0; - // Load schedule if not already loaded - if (!scheduleData[positionId] && !loadingSchedule[positionId]) { + // Load schedule if not already loaded and not in position + if (!hasSchedule && !scheduleData[positionId] && !loadingSchedule[positionId]) { setLoadingSchedule({ ...loadingSchedule, [positionId]: true }); try { const response = await solvencyService.getPositionSchedule(positionId); @@ -150,14 +186,23 @@ export const MyLoansTable = ({ positions, isLoading, onRefresh }: MyLoansTablePr } }; - const handleRepayClick = (position: Position) => { + const handleRepayClick = (position: Position, e: React.MouseEvent) => { + e.stopPropagation(); setSelectedPosition(position); setShowRepayModal(true); }; - const handleRepaySuccess = () => { + const handleRepaySuccess = (isLastInstallment?: boolean) => { setShowRepayModal(false); setSelectedPosition(null); + console.log('Repay successful for position', selectedPosition); + console.log("withdrawing after repay"); + + if (isLastInstallment) { + handleWithdrawConfirm(); + } + + if (onRefresh) onRefresh(); // Clear schedule cache to reload fresh data if (selectedPosition) { @@ -167,322 +212,657 @@ export const MyLoansTable = ({ positions, isLoading, onRefresh }: MyLoansTablePr } }; + const handleWithdrawClick = (position: Position, e?: React.MouseEvent) => { + e?.stopPropagation(); + setSelectedPosition(position); + setShowWithdrawModal(true); + }; + + const handleWithdrawConfirm = async () => { + if (!selectedPosition) return; + + setWithdrawingPositionId(selectedPosition.positionId); + + try { + // Call smart contract directly to withdraw collateral + + // await handleConfirmPayment(); + const amountBigInt = BigInt(selectedPosition.collateralAmount); + const result = await solvencyContractService.withdrawCollateral( + selectedPosition.positionId, + amountBigInt + ); + + if (result.success) { + // Mark position as withdrawn + setWithdrawnPositions(prev => new Set([...prev, selectedPosition.positionId])); + + const asset = portfolioAssets.find(asset => asset.tokenAddress === selectedPosition.collateralTokenAddress); + if (asset && asset.assetId) { + await marketplaceService.notifyPurchase({ + assetId: asset.assetId, + txHash: result.txHash!, + amount: `${amountBigInt.toString()}`, + blockNumber: result.blockNumber!.toString(), + type: 'WITHDRAWAL', + }); + + await solvencyService.notifyCollateralWithdrawal({ + positionId: selectedPosition.positionId.toString(), + amount: `${amountBigInt.toString()}`, + }) + } else { + console.warn('Asset not found in portfolio, skipping marketplace notifyPurchase for position', selectedPosition.positionId); + } + + setShowWithdrawModal(false); + setSelectedPosition(null); + if (onRefresh) onRefresh(); + } else { + throw new Error(result.error || 'Withdrawal failed'); + } + } catch (error: any) { + console.error('Withdrawal error:', error); + showError(`Withdrawal failed: Please try again.`); + } finally { + setWithdrawingPositionId(null); + } + }; + + + if (isLoading) { return ( -
-
+
+
); } if (positions.length === 0) { return ( -
-
- -
-

- No Active Loans -

-

- You haven't borrowed against your assets yet. Start borrowing USDC using your RWA tokens as collateral. -

- -
- ); - } - return ( -
- {/* Filters and Sort */} -
-
- +
+
+

No active loans yet

- -
- -
- - -
+ ); + } - {/* Card Grid */} - {filteredAndSortedPositions.length === 0 ? ( -
-

No loans match the selected filter.

-
- ) : ( -
- {filteredAndSortedPositions.map((position) => { - const isExpanded = expandedPositionId === position.positionId; - const schedule = scheduleData[position.positionId]; - const isLoadingSchedule = loadingSchedule[position.positionId]; - - return ( -
+ +
+ {/* Filter Bar */} +
+
+ + Filter: +
+ + + + + +
+ {activeFilter !== 'all' && ( + + ({filteredPositions.length} of {positions.length}) + + )} +
+
+ + {/* Table */} + {filteredPositions.length === 0 ? ( +
+

No loans match the selected filter.

+
+ ) : ( + + + + + + + + + + + + + + + + + {filteredPositions?.map((position, index) => { + const isExpanded = expandedRows.has(position.positionId); + const pos = position as any; + const schedule = scheduleData[position.positionId]; + const isLoadingSchedule = loadingSchedule[position.positionId]; + const isOverdue = isPaymentOverdue(position.nextPaymentDueDate); + const outstandingDebt = parseFloat(getOutstandingDebt(position)); + const hasDebt = outstandingDebt > 0; + const hasCollateral = parseFloat(position.collateralAmount) > 0; + const wasWithdrawn = withdrawnPositions.has(position.positionId); + const canWithdraw = hasCollateral && !position.oaidCreditIssued && !position.isDefaulted && !wasWithdrawn; + + // Use schedule from position if available, otherwise from fetched data + const displaySchedule = pos.repaymentSchedule || schedule?.installments; + const scheduleInfo = pos.repaymentSchedule ? pos : schedule; + + return ( + <> + toggleRowExpansion(position.positionId, e)} + > + {/* Expand Icon */} + + + {/* Position ID */} + + + {/* Collateral Type */} + - {/* Collateral */} -
-
- - {position.collateralToken.symbol.substring(0, 2).toUpperCase()} + {/* Collateral Amount */} +
+ + {/* USDC Borrowed */} + - {/* Debt & Health */} -
-
-
Outstanding Debt
-
- {formatUSD(position.outstandingDebt)} -
-
-
-
Health Factor
-
- {formatHealthFactor(position.healthFactor)} -
-
-
- + {/* Status */} + - {/* Right: Actions */} -
- - -
- - {/* Date */} -
- Created {format(new Date(position.createdAt), 'MMM d, yyyy')} -
- - {/* Expanded Schedule Section */} - {isExpanded && ( -
- {isLoadingSchedule ? ( -
-
-
- ) : schedule ? ( -
-

- - Repayment Schedule -

- - {/* Schedule Summary */} -
-
-
Installments Paid
-
- {schedule.installmentsPaid} / {schedule.numberOfInstallments} -
-
-
-
Missed Payments
-
- {schedule.missedPayments} -
-
-
-
Next Payment Due
-
- {new Date(schedule.nextPaymentDue * 1000) > new Date() - ? format(new Date(schedule.nextPaymentDue * 1000), 'MMM d, yyyy') - : OVERDUE - } -
-
-
-
Payment Interval
-
- {Math.floor(schedule.installmentInterval / 86400)} days + {/* Status */} +
+ + {/* Next Payment */} + + + {/* Actions */} + + + + {/* Expanded Detail Row */} + {isExpanded && ( + + + )} + + ); + })} + +
+ Position + + Collateral Type + + Collateral Amount + + USDC Borrowed + + Status + + Health Status + + Next Payment + + Actions +
+ + +
+
+ Position #{position.positionId} + {position.isDefaulted && ( +
+ +
+ Defaulted: 3+ missed payments +
+
+ )} +
+
+ {getTokenSymbol(position.collateralTokenAddress)} +
+
- {position.healthStatus} + {position.collateralTokenType} - + +
+ + {formatCollateralAmount(position.collateralAmount, 18)} + + + {formatUSD(position.tokenValueUSD)}
-
-
- {position.collateralToken.symbol} -
-
- {formatCollateralAmount(position.collateralAmount, 18)} tokens â€ĸ {formatUSD(position.tokenValueUSD)} -
+
+
+ {hasDebt ? formatUSD(getOutstandingDebt(position)) : ( + No Debt + )}
- +
+ + {position.status} + + + + {position.healthStatus} + + + {position.nextPaymentDueDate && hasDebt ? ( +
+
+ + {format(new Date(position.nextPaymentDueDate), 'MMM d, yyyy')}
+ {isOverdue && ( + OVERDUE + )}
+ ) : ( + N/A + )} +
+
+ {/* Repay Button - Only show when loan was issued (oaidCreditIssued = true) */} + {!position.isDefaulted && position.oaidCreditIssued && hasDebt && ( + + )} + + {/* Withdraw Button - Show when no loan issued yet and collateral exists */} + {canWithdraw && ( + + )} + + {/* No Action State */} + {!position.oaidCreditIssued && !canWithdraw && !position.isDefaulted && ( + No Actions + )} + + {position.isDefaulted && ( + Defaulted + )}
+
+
+ {isLoadingSchedule && !displaySchedule ? ( +
+
+
+ ) : scheduleInfo ? ( +
+
+ {/* Left Column - 40% - Current Metrics */} +
+

+ Loan Overview +

+
+ {/* LTV Ratio */} +
+
+ + LTV Ratio +
+
+ {position.initialLTV ? (position.initialLTV / 100).toFixed(0) : 'N/A'}% +
+
+ + {/* Total Installments */} +
+
+ + Installments +
+
+ {scheduleInfo.installmentsPaid} / {scheduleInfo.numberOfInstallments} +
+
+ + {/* Missed Payments */} +
+
+ + Missed +
+
+ {scheduleInfo.missedPayments} +
+
+ + {/* Payment Interval */} +
+
+ + Interval +
+
+ {Math.floor(scheduleInfo.installmentInterval / 86400)}d +
+
+
+
+ + {/* Right Column - 60% - New Details */} +
+

+ Position Details +

+
+ + + + + + +
+
+
- {/* Installment List */} -
- {schedule.installments.map((installment) => ( -
-
-
- Installment #{installment.installmentNumber} + {/* Repayment Schedule */} +
+

+ + Repayment Schedule +

+ +
+ {displaySchedule?.map((installment: any) => ( +
+
+
+ #{installment.installmentNumber} +
+
+ Due: {format(new Date(installment.dueDate), 'MMM d, yyyy')} +
+
+
+
+ {formatUSD(installment.amount)} +
+ + {installment.status} + +
+
+ ))} +
+ + {scheduleInfo.missedPayments > 0 && ( +
+ +
+ Warning: You have {scheduleInfo.missedPayments} missed payment + {scheduleInfo.missedPayments > 1 ? 's' : ''}. Please repay immediately to avoid liquidation. +
+
+ )}
-
- Due: {format(new Date(installment.dueDate * 1000), 'MMM d, yyyy')} + + {/* Transaction Info */} +
+
+ +
+ Created: + + {format(new Date(position.createdAt), 'MMM d, yyyy HH:mm')} + +
+
-
-
- {formatUSD(installment.amount)} -
- - {installment.status} - + ) : ( +
+ No repayment schedule available for this position.
-
- ))} -
- - {schedule.missedPayments > 0 && ( -
- -
- You have {schedule.missedPayments} missed payment{schedule.missedPayments > 1 ? 's' : ''}. - Please repay immediately to avoid liquidation. -
+ )}
- )} -
- ) : ( -
- No repayment schedule available for this position. -
+
+ )} + + {/* Repay Modal - Rendered at document body level using Portal */} + {selectedPosition && createPortal( + { + setShowRepayModal(false); + setSelectedPosition(null); + }} + onSuccess={(isLastInstallment: boolean) => handleRepaySuccess(isLastInstallment)} + position={selectedPosition} + />, + document.body + )} + + + {/* Withdraw Confirmation Modal */} + {selectedPosition && showWithdrawModal && createPortal( +
+
+ {/* Close Button */} + {!withdrawingPositionId && ( + + )} + + {/* Header */} +
+
+ 💸 +
+

Withdraw Collateral

+

Confirm withdrawal from Position #{selectedPosition.positionId}

+
+ + {/* Content */} +
+
+ {/* Withdrawal Details */} +
+
+

Token

+

+ {getTokenSymbol(selectedPosition.collateralTokenAddress)} +

+
+
+

Amount to Withdraw

+

+ {formatCollateralAmount(selectedPosition.collateralAmount, 18)} tokens +

+
+
+

Value

+

+ {formatUSD(selectedPosition.tokenValueUSD)} +

+
+
+ + {/* Warning */} +
+

+ âš ī¸ Important: Your collateral will be returned to your wallet. Please confirm the transaction in your wallet. +

- )} + + {/* Processing State */} + {withdrawingPositionId === selectedPosition.positionId && ( +
+
+ +

Processing Withdrawal...

+
+
+ )} +
- ); - })} -
- )} - - {/* Repay Modal */} - {selectedPosition && ( - { - setShowRepayModal(false); - setSelectedPosition(null); - }} - onSuccess={handleRepaySuccess} - position={selectedPosition} - schedule={scheduleData[selectedPosition.positionId]} - /> - )} -
+ + {/* Footer */} + {!withdrawingPositionId && ( +
+ + + +
+ )} +
+
, + document.body + )} +
+ ); }; + +// Helper component for detail items +const DetailItem = ({ label, value }: { label: string; value: string | number }) => ( +
+ {label} + {value} +
+); diff --git a/src/components/portfolio/NoAssetsModal.tsx b/src/components/portfolio/NoAssetsModal.tsx index cc3e6a4..7d37d12 100644 --- a/src/components/portfolio/NoAssetsModal.tsx +++ b/src/components/portfolio/NoAssetsModal.tsx @@ -1,7 +1,6 @@ // src/components/portfolio/NoAssetsModal.tsx import { useNavigate } from 'react-router-dom'; -import { Lock, Upload, ShoppingCart, Info, X } from 'lucide-react'; -import { Button } from '../ui/button'; +import { Lock, ShoppingCart, Info, X } from 'lucide-react'; interface NoAssetsModalProps { isOpen: boolean; @@ -14,56 +13,56 @@ export const NoAssetsModal = ({ isOpen, onClose }: NoAssetsModalProps) => { if (!isOpen) return null; return ( -
-
- -
-
- +
+
+
+ +
+
-

+

Get Started with Borrowing

-

+

To increase your credit limit, you need RWA tokens to use as collateral. Choose an option below to get started:

-
+
+
{ navigate('/issuers'); onClose(); }} - > -
- -
-

- Deposit Private Assets -

-

- Tokenize your real-world assets to use as collateral. -

-
-
{ navigate('/marketplace'); onClose(); }} >
-

+

Buy from Marketplace

-

+

Purchase tokenized assets from other users.

-
- -
+
+ +

How it works: Once you have RWA tokens in your portfolio, you can deposit them as collateral to increase your credit limit. -

+

diff --git a/src/components/portfolio/PortfolioStats.tsx b/src/components/portfolio/PortfolioStats.tsx index a0aa400..ba6ca1c 100644 --- a/src/components/portfolio/PortfolioStats.tsx +++ b/src/components/portfolio/PortfolioStats.tsx @@ -1,5 +1,5 @@ // src/components/portfolio/PortfolioStats.tsx -import { TrendingUp, Plus } from 'lucide-react'; +import { Plus } from 'lucide-react'; import { MiniAreaChart } from './MiniAreaChart'; import { generateAssetValueChart, generateYieldChart } from '../../lib/utils/portfolioChartGenerator'; import type { PortfolioAsset } from '../../lib/api/portfolio.service'; @@ -46,7 +46,7 @@ export const PortfolioStats = ({
{/* Total Asset Value Card */}
- diff --git a/src/components/portfolio/RepayLoanModal.tsx b/src/components/portfolio/RepayLoanModal.tsx index 988121d..230750e 100644 --- a/src/components/portfolio/RepayLoanModal.tsx +++ b/src/components/portfolio/RepayLoanModal.tsx @@ -12,33 +12,18 @@ */ import { useState, useEffect, useMemo } from 'react'; -import { X, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'; +import { X, CheckCircle } from 'lucide-react'; import { ethers } from 'ethers'; import type { Position } from '../../types/solvency.types'; import { solvencyService } from '../../lib/api/solvency.service'; import { solvencyContractService } from '../../lib/api/solvency-contract.service'; -import { Button } from '../ui/button'; -import { Input } from '../ui/input'; +import { PageLoader } from '../ui/page-loader'; interface RepayLoanModalProps { isOpen: boolean; onClose: () => void; - onSuccess: () => void; + onSuccess: (isLastInstallment: boolean) => void; position: Position; - schedule?: { - loanDuration: number; - numberOfInstallments: number; - installmentInterval: number; - installmentsPaid: number; - missedPayments: number; - nextPaymentDue: number; - installments: Array<{ - installmentNumber: number; - dueDate: number; - amount: string; - status: 'PAID' | 'PENDING' | 'MISSED'; - }>; - }; } const formatUSD = (value: string | number) => { @@ -51,124 +36,205 @@ const formatUSD = (value: string | number) => { }).format(num); }; +// Derive token symbol from address +const getTokenSymbol = (address: string) => { + const shortAddr = address.slice(2, 8).toUpperCase(); + return `TKN-${shortAddr}`; +}; + export const RepayLoanModal = ({ isOpen, onClose, onSuccess, position, - schedule, }: RepayLoanModalProps) => { - const [repayAmount, setRepayAmount] = useState(''); + const [selectedInstallment, setSelectedInstallment] = useState(null); const [isApproving, setIsApproving] = useState(false); const [isRepaying, setIsRepaying] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [currentStep, setCurrentStep] = useState<'input' | 'approving' | 'repaying' | 'syncing'>('input'); + const [actualDebt, setActualDebt] = useState(null); + const [isFetchingDebt, setIsFetchingDebt] = useState(false); + + const outstandingDebt = actualDebt ? parseFloat(ethers.formatUnits(actualDebt, 6)) : parseFloat(position.outstandingDebt || '0') / 1e6; + + // Calculate total installment amounts and interest + const { totalInstallmentAmount, interestAmount } = useMemo(() => { + if (!position.repaymentSchedule || position.repaymentSchedule.length === 0 || !actualDebt) { + return { totalInstallmentAmount: 0, interestAmount: 0 }; + } + + const total = position.repaymentSchedule.reduce((sum, inst) => sum + parseFloat(inst.amount), 0) / 1e6; + const actualDebtUSD = parseFloat(ethers.formatUnits(actualDebt, 6)); + const interest = actualDebtUSD - total; - const outstandingDebt = parseFloat(position.outstandingDebt) / 1e6; + return { totalInstallmentAmount: total, interestAmount: Math.max(0, interest) }; + }, [position.repaymentSchedule, actualDebt]); + + // Fetch actual outstanding debt when modal opens + useEffect(() => { + if (isOpen) { + const fetchDebt = async () => { + setIsFetchingDebt(true); + try { + console.log('📊 Fetching actual outstanding debt from chain...'); + const debt = await solvencyContractService.getOutstandingDebt(position.positionId); + setActualDebt(debt); + console.log(`✅ Fetched debt: $${ethers.formatUnits(debt, 6)} USDC`); + } catch (err) { + console.error('Failed to fetch debt:', err); + // Fall back to position's outstanding debt + setActualDebt(null); + } finally { + setIsFetchingDebt(false); + } + }; + fetchDebt(); + } + }, [isOpen, position.positionId,selectedInstallment]); // Calculate next installment amount const nextInstallmentAmount = useMemo(() => { - if (!schedule) return null; + if (!position.repaymentSchedule || position.repaymentSchedule.length === 0) return null; - const nextUnpaid = schedule.installments.find( + const nextUnpaid = position.repaymentSchedule.find( i => i.status === 'PENDING' || i.status === 'MISSED' ); if (!nextUnpaid) return null; return parseFloat(nextUnpaid.amount) / 1e6; - }, [schedule]); - - // Pre-fill next installment amount on modal open - useEffect(() => { - if (isOpen && nextInstallmentAmount) { - setRepayAmount(nextInstallmentAmount.toFixed(2)); - } - }, [isOpen, nextInstallmentAmount]); + }, [position.repaymentSchedule]); // Reset state on close useEffect(() => { if (!isOpen) { - setRepayAmount(''); + setSelectedInstallment(null); setError(null); setSuccess(false); setCurrentStep('input'); setIsApproving(false); setIsRepaying(false); + setActualDebt(null); } }, [isOpen]); - const isAmountValid = useMemo(() => { - const amount = parseFloat(repayAmount); - if (isNaN(amount) || amount <= 0) return false; - if (amount > outstandingDebt) return false; - return true; - }, [repayAmount, outstandingDebt]); + const handleInstallmentSelect = (installmentNumber: number) => { + if (!position.repaymentSchedule || position.repaymentSchedule.length === 0) return; + + const installment = position.repaymentSchedule.find(i => i.installmentNumber === installmentNumber); + if (!installment || installment.status === 'PAID') return; - const handleRepay = async () => { - if (!isAmountValid || !repayAmount) return; + setSelectedInstallment(installmentNumber); + setError(null); + }; + + const handleConfirmPayment = async () => { + if (!selectedInstallment || !actualDebt) return; setError(null); setCurrentStep('approving'); setIsApproving(true); try { - const amountWei = ethers.parseUnits(repayAmount, 6); + // Find the installment + const installment = position.repaymentSchedule?.find(i => i.installmentNumber === selectedInstallment); + + if (!installment) { + throw new Error('Installment not found'); + } + + // Calculate amount: base installment + interest (if last installment) + const isLastInstallment = BigInt(selectedInstallment) === BigInt(position.numberOfInstallments); + + // const total = position.repaymentSchedule.reduce((sum, inst) => sum + parseFloat(inst.amount), 0) / 1e6; + const actualDebtUSD = parseFloat(ethers.formatUnits(actualDebt, 6)); + + const baseAmount = parseFloat(installment.amount) / 1e6; + const interestAmount = actualDebtUSD - baseAmount; + const finalAmount = isLastInstallment ? baseAmount + (interestAmount*1.10) : baseAmount; + + + // Round to 6 decimals properly and convert to Wei (USDC has 6 decimals) + // Ensure we maintain precision by rounding at the micro-unit level + const finalAmountMicro = Math.round(finalAmount * 1e6); + let amountWei = BigInt(finalAmountMicro); + + console.log(`💰 Paying Installment #${selectedInstallment}:`, { + baseAmount: `$${baseAmount.toFixed(6)}`, + interest: isLastInstallment ? `$${interestAmount.toFixed(6)}` : '$0', + finalAmount: `$${finalAmount.toFixed(6)}`, + finalAmountMicro: finalAmountMicro, + amountWei: amountWei.toString(), + amountWeiInUSDC: `$${(Number(amountWei) / 1e6).toFixed(6)}`, + }); + + // Cap to actual debt (safety check) + if (amountWei > actualDebt) { + console.log(`âš ī¸ Capping payment to actual debt`, { + calculatedAmount: `$${(Number(amountWei) / 1e6).toFixed(6)}`, + actualDebt: `$${ethers.formatUnits(actualDebt, 6)}`, + }); + amountWei = actualDebt; + } - // Step 1: Approve USDC for SeniorPool - // Per COMPLETE_LOAN.md: Approve SeniorPool to spend USDC, not Vault - console.log('📝 Approving USDC for SeniorPool...'); + // Step 1: Approve USDC + console.log('📝 Approving USDC for Vault...'); const approvalResult = await solvencyContractService.approveUSDCForSeniorPool(amountWei); if (!approvalResult.success) { - throw new Error(approvalResult.error || 'USDC approval for SeniorPool failed'); + throw new Error(approvalResult.error || 'USDC approval failed'); } setIsApproving(false); setCurrentStep('repaying'); setIsRepaying(true); - // Step 2: Direct wallet call to SeniorPool.repayLoan() - // Per COMPLETE_LOAN.md: Call SeniorPool directly, not backend API - console.log('đŸ’ĩ Repaying loan via SeniorPool...'); + // Step 2: Repay loan + console.log('đŸ’ĩ Repaying loan via Senior Pool:', { + positionId: position.positionId, + amountWei: amountWei.toString(), + amountInUSDC: `$${(Number(amountWei) / 1e6).toFixed(6)}`, + expectedFinalAmount: `$${finalAmount.toFixed(6)}`, + match: (Number(amountWei) / 1e6).toFixed(6) === finalAmount.toFixed(6) ? '✅ MATCH' : '❌ MISMATCH' + }); + const repayResult = await solvencyContractService.repayLoanViaSeniorPool( position.positionId, amountWei ); if (!repayResult.success) { - throw new Error(repayResult.error || 'Repayment via SeniorPool failed'); + throw new Error(repayResult.error || 'Repayment failed'); } setCurrentStep('syncing'); - // Step 3: Sync position with backend - setCurrentStep('syncing'); - console.log('🔄 Syncing position with backend...'); - + // Step 3: Notify backend + console.log('🔄 Notifying backend...'); try { - await solvencyService.syncPosition({ - positionId: position.positionId.toString(), + await solvencyService.notifyLoanRepayment({ txHash: repayResult.txHash!, - blockNumber: repayResult.blockNumber!, + positionId: position.positionId.toString(), + repaymentAmount: amountWei.toString(), + blockNumber: repayResult.blockNumber?.toString(), }); - console.log('✅ Position synced with backend'); } catch (syncError) { - // Non-blocking: Events will still sync it automatically - console.warn('âš ī¸ Manual sync failed (events will auto-sync):', syncError); + console.warn('âš ī¸ Manual notification failed (events will auto-sync):', syncError); } // Success setSuccess(true); setTimeout(() => { - onSuccess(); + onSuccess(isLastInstallment); }, 1500); } catch (err: any) { console.error('❌ Repayment error:', err); setError(err.message || 'An error occurred during repayment'); setCurrentStep('input'); + setSelectedInstallment(null); } finally { setIsApproving(false); setIsRepaying(false); @@ -178,162 +244,218 @@ export const RepayLoanModal = ({ if (!isOpen) return null; return ( -
-
+
+
{/* Header */} -
-

Repay Loan

- + +
- {success ? ( - // Success State -
-
- -
-

- Repayment Successful! -

-

- Your loan has been updated. The page will refresh automatically. -

-
- ) : ( -
- {/* Position Info */} -
-
- Position #{position.positionId} - - {position.collateralToken.symbol} - -
-
- Outstanding Debt - - {formatUSD(position.outstandingDebt)} - + {/* Content */} +
+ {success ? ( + // Success State +
+
+
+

Repayment Successful!

+

Your loan has been updated. Refreshing...

- - {/* Next Installment Hint */} - {nextInstallmentAmount && ( -
- -
- Next installment: {formatUSD(nextInstallmentAmount)} - {schedule && schedule.missedPayments > 0 && ( - (OVERDUE) + ) : ( +
+ {/* Position Info */} +
+
+ Position + + #{position.positionId} â€ĸ {getTokenSymbol(position.collateralTokenAddress)} + +
+
+ + {isFetchingDebt ? ( +
+
+
+ ) : ( +
+ Outstanding Debt + + {actualDebt + ? `$${ethers.formatUnits(actualDebt!, 6)}` + : formatUSD(outstandingDebt)} + +
)}
- )} - - {/* Amount Input */} -
- -
- { - const val = e.target.value.replace(/[^0-9.]/g, ''); - setRepayAmount(val); - }} - placeholder="0.00" - className="pr-16 text-lg" - disabled={isApproving || isRepaying} - /> -
- USDC + + {/* Debt Breakdown */} + {nextInstallmentAmount && interestAmount > 0 && ( +
+
+

Debt Breakdown

+
+
+
+ Principal + {formatUSD(totalInstallmentAmount)} +
+
+ Interest + ${interestAmount.toFixed(6)} +
+
+ Total Debt + ${outstandingDebt.toFixed(6)} +
+ {position.missedPayments > 0 && ( +
+ âš ī¸ {position.missedPayments} payment(s) overdue +
+ )} +
-
- {!isAmountValid && parseFloat(repayAmount) > 0 && ( -

- {parseFloat(repayAmount) > outstandingDebt - ? `Amount exceeds outstanding debt of ${formatUSD(position.outstandingDebt)}` - : 'Please enter a valid amount'} -

)} -
- {/* Quick Amount Buttons */} -
- {nextInstallmentAmount && ( - + {/* Installment Selection */} + {position.repaymentSchedule && position.repaymentSchedule.length > 0 && ( +
+
+ {(() => { + const first = position.repaymentSchedule.find( + i => i.status === 'PENDING' || i.status === 'MISSED' + ); + + // Auto-select the first pending installment when available + function AutoSelect() { + useEffect(() => { + if (first && !selectedInstallment) { + handleInstallmentSelect(first.installmentNumber); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [first?.installmentNumber]); + return null; + } + + if (!first) { + return ( +
+ All installments are paid! +
+ ); + } + + const isLastInstallment = first.installmentNumber === position.numberOfInstallments; + const baseAmount = parseFloat(first.amount) / 1e6; + const finalAmount = isLastInstallment ? baseAmount + interestAmount : baseAmount; + const isSelected = selectedInstallment === first.installmentNumber; + + return ( + <> + +
+
+
+
+ + Installment #{first.installmentNumber} + +
+
+ Due: {new Date(first.dueDate).toLocaleDateString()} +
+ {first.status === 'MISSED' && ( +
âš ī¸ Missed payment
+ )} +
+ +
+
+ {formatUSD(finalAmount)} +
+ {isLastInstallment && interestAmount > 0 && ( +
+ +${interestAmount.toFixed(6)} interest +
+ )} +
+
+
+ + ); + })()} +
+
)} - - -
- {/* Error Display */} - {error && ( -
- -

{error}

-
- )} - - {/* Progress Steps */} - {(isApproving || isRepaying) && ( -
-
- -
- {currentStep === 'approving' && 'Approving USDC...'} - {currentStep === 'repaying' && 'Processing repayment...'} - {currentStep === 'syncing' && 'Syncing with backend...'} -
+ {/* Error Display */} + {error && ( +
+ {error}
-
- )} + )} - {/* Action Button */} - - {/* Info */} -
- After repayment, your loan schedule and available credit will be updated automatically. + {/* Info */} + {!selectedInstallment && ( +
+

+ Select an installment above to continue +

+
+ )}
+ )} +
+ + {/* Footer */} + {!success && ( +
+ + +
)}
diff --git a/src/components/portfolio/TradesTable.tsx b/src/components/portfolio/TradesTable.tsx index c5fc31b..53a15e9 100644 --- a/src/components/portfolio/TradesTable.tsx +++ b/src/components/portfolio/TradesTable.tsx @@ -2,9 +2,21 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ExternalLink, ChevronDown, ChevronUp, Filter, XCircle } from 'lucide-react'; import type { SecondaryOrder } from '../../types/marketplace.types'; +import type { PortfolioAsset } from './../../lib/api/portfolio.service'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { PageLoader } from '../ui/page-loader'; interface TradesTableProps { orders: SecondaryOrder[]; + assets: PortfolioAsset[]; isLoading: boolean; onCancelOrder: (orderId: string) => void; isCancellingId: string | null; @@ -12,6 +24,7 @@ interface TradesTableProps { export const TradesTable = ({ orders, + assets, isLoading, onCancelOrder, isCancellingId, @@ -20,6 +33,8 @@ export const TradesTable = ({ const [expandedRows, setExpandedRows] = useState>(new Set()); const [statusFilter, setStatusFilter] = useState<'ALL' | 'OPEN' | 'FILLED' | 'CANCELLED'>('ALL'); const [typeFilter, setTypeFilter] = useState<'ALL' | 'BUY' | 'SELL'>('ALL'); + const [orderToCancel, setOrderToCancel] = useState(null); + // const [hoveredRow, setHoveredRow] = useState(null); // Filter orders based on selected filters const filteredOrders = orders.filter(order => { @@ -31,6 +46,11 @@ export const TradesTable = ({ const statusOptions: Array<'ALL' | 'OPEN' | 'FILLED' | 'CANCELLED'> = ['ALL', 'OPEN', 'FILLED', 'CANCELLED']; const typeOptions: Array<'ALL' | 'BUY' | 'SELL'> = ['ALL', 'BUY', 'SELL']; + const getRowKey = (order: SecondaryOrder): string => { + return order._id; + + }; + const toggleRowExpansion = (key: string, e: React.MouseEvent) => { e.stopPropagation(); const newExpanded = new Set(expandedRows); @@ -42,6 +62,13 @@ export const TradesTable = ({ setExpandedRows(newExpanded); }; + const handleConfirmCancel = () => { + if (orderToCancel) { + onCancelOrder(orderToCancel); + setOrderToCancel(null); + } + }; + const formatTokenAmount = (weiAmount: string): string => { const tokens = parseFloat(weiAmount) / 1e18; return tokens.toLocaleString('en-US', { maximumFractionDigits: 4 }); @@ -73,8 +100,8 @@ export const TradesTable = ({ if (isLoading) { return ( -
-
+
+
); } @@ -96,269 +123,309 @@ export const TradesTable = ({ } return ( -
- {/* Filter Bar */} -
- {/* Type Filter */} -
- - Type: -
- {typeOptions.map((type) => ( - - ))} + <> +
+ {/* Filter Bar */} +
+ {/* Type Filter */} +
+ + Type: +
+ {typeOptions.map((type) => ( + + ))} +
-
- {/* Status Filter */} -
- - Status: -
- {statusOptions.map((status) => ( - - ))} + {/* Status Filter */} +
+ + Status: +
+ {statusOptions.map((status) => ( + + ))} +
+ {(statusFilter !== 'ALL' || typeFilter !== 'ALL') && ( + + ({filteredOrders.length} of {orders.length}) + + )}
- {(statusFilter !== 'ALL' || typeFilter !== 'ALL') && ( - - ({filteredOrders.length} of {orders.length}) - - )}
-
- - - - - )} - - ); - })} - -
+ + + + + + + + + + + + + + + + {filteredOrders.map((order, index) => { + const rowKey = getRowKey(order); + const isExpanded = expandedRows.has(order._id); + const totalValue = (parseFloat(order.initialAmount) / 1e18) * (parseFloat(order.pricePerToken) / 1e6); - - - - - - - - - - - - - {filteredOrders.map((order, index) => { - const isExpanded = expandedRows.has(order._id); - const totalValue = (parseFloat(order.initialAmount) / 1e18) * (parseFloat(order.pricePerToken) / 1e6); - - return ( - <> - - {/* Expand Icon */} - - - {/* Type */} - - - {/* Asset ID / Order ID */} - toggleRowExpansion(rowKey, e)} + > + {/* Expand Icon */} + + + {/* Type */} + + + {/* Asset ID / Order ID */} + - - {/* Status */} - - - {/* Amount */} - + + {/* Status */} + + + {/* Amount */} + - - {/* Price */} - - - {/* Total Value */} - - - {/* Date */} - - - {/* Actions */} - - - - {/* Expanded Row Details */} - {isExpanded && ( - - + + {/* Price */} + + + {/* Total Value */} + + + {/* Date */} + + + {/* Actions */} + + + + {/* Expanded Row Details */} + {isExpanded && ( + + + + )} + + ); + })} + +
+ + + Type + + Asset ID / Order ID + + Status + + Amount (Tokens) + + Price (USDC) + + Total Value + + Date + + Actions +
- Type - - Asset ID / Order ID - - Status - - Amount (Tokens) - - Price (USDC) - - Total Value - - Date - - Actions -
- - - - {order.isBuy ? 'BUY' : 'SELL'} - - -
-
{ navigate(`/marketplace/asset/${order.assetId}`) }}> - {order.assetId.slice(0, 8)}...{order.assetId.slice(-4)} -
-
- Order #{order.orderId} + + return ( + <> +
+ + + + {order.isBuy ? 'BUY' : 'SELL'} + + +
+
+ {assets.find(a => a.assetId === order.assetId) && ( + { navigate(`/marketplace/asset/${order.assetId}`) }}> + {assets.find(a => a.assetId === order.assetId)?.metadata?.assetName || 'Unknown Asset'} + + )} + + {order.assetId.slice(0, 8)}...{order.assetId.slice(-4)} + +
+
{ navigate(`/trade/asset/${order.assetId}`) }}> + Order #{order.orderId} +
- -
- - {order.status} - - -
-
+
+ + {order.status} + + +
+
Initial: {formatTokenAmount(order.initialAmount)} Remaining: {formatTokenAmount(order.remainingAmount)}
-
-
-
- ${formatUSDCAmount(order.pricePerToken)} -
-
-
- ${totalValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -
-
-
- {new Date(order.createdAt).toLocaleDateString()} -
-
-
- {order.status === 'OPEN' && ( - - )} - - {/* Transaction Hash Links */} - {order.txHash && ( - - )} -
-
- {/* Basic Details Grid */} -
- {/* Token Address */} -
-
Token Address
-
- {order.tokenAddress ? truncateAddress(order.tokenAddress) : 'N/A'} +
+
+
+ ${formatUSDCAmount(order.pricePerToken)} +
+
+
+ ${totalValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+ {new Date(order.createdAt).toLocaleDateString()} +
+
+
+ {order.status === 'OPEN' && ( + + )} + + {/* Transaction Hash Links */} + {order.txHash && ( + + )} + + {order.stlTxHash && ( + + )} +
+
+ {/* Basic Details Grid */} +
+ {/* Token Address */} +
+
Token Address
+
+ {order.tokenAddress ? truncateAddress(order.tokenAddress) : 'N/A'} +
-
- {/* Maker Address */} -
-
Maker Address
-
- {order.maker ? truncateAddress(order.maker) : 'N/A'} + {/* Maker Address */} +
+
Maker Address
+
+ {order.maker ? truncateAddress(order.maker) : 'N/A'} +
-
- {/* Created At */} -
-
Created At
-
- {new Date(order.createdAt).toLocaleString()} + {/* Created At */} +
+
Created At
+
+ {new Date(order.createdAt).toLocaleString()} +
-
- {/* Updated At */} -
-
Updated At
-
- {new Date(order.updatedAt).toLocaleString()} + {/* Updated At */} +
+
Updated At
+
+ {new Date(order.updatedAt).toLocaleString()} +
-
- - {/* Transaction Hash */} -
+ + {/* Transaction Hash */} +
Transaction Hash
{order.txHash}
+
+
+
+ - - -
-
- ); -}; + !open && setOrderToCancel(null)}> + + + Confirm Order Cancellation + + This will cancel the trade order and make the remaining tokens available in your portfolio. Are you sure you want to proceed? + + + + + + + + + +)} \ No newline at end of file diff --git a/src/components/ui/faq-details.tsx b/src/components/ui/faq-details.tsx index 0067d14..4b0b4ff 100644 --- a/src/components/ui/faq-details.tsx +++ b/src/components/ui/faq-details.tsx @@ -106,7 +106,7 @@ const FaqDetails = ({ description: "Use mETH as collateral to create leveraged exposure while yield services interest automatically.", buttonText: "Use mETH", - videoSrc: "", + videoSrc: "https://youtu.be/c7CdBkcVZPQ", }, }, { @@ -119,7 +119,7 @@ const FaqDetails = ({ description: "View owned tokens, auction bids, leveraged positions, loans, yield history, and health metrics.", buttonText: "View Portfolio", - videoSrc: "", + videoSrc: "https://youtu.be/NtLOZqsX5U8", }, }, { @@ -136,18 +136,44 @@ const FaqDetails = ({ }, }, { - value: "borrow", + value: "credit line creation", + icon: , + label: "Credit Line", + content: { + badge: "Credit", + title: "Create credit line on your OAID", + description: + "Utilise RWA's as collateral to draw credit lines without moving assets.", + buttonText: "Create Credit Line", + videoSrc: "", + }, + }, + { + value: "borrowing against rwAs", icon: , label: "Borrow", content: { badge: "Credit", - title: "Access credit using OAID", + title: "Access credit using the credit line", description: - "Borrow against RWAs or private assets across native and partner protocols without moving collateral.", + "Borrow against RWAs or private assets across native and partner protocols", buttonText: "Borrow", videoSrc: "", }, }, + { + value: "repayment", + icon: , + label: "Repayment", + content: { + badge: "Credit", + title: "Easily repay loans and reclaim collateral", + description: + "Repay loans to restore credit and reclaim excess collateral", + buttonText: "Repay", + videoSrc: "", + }, + }, ], }: Feature108Props) => { const videoContainerRef = useRef(null); diff --git a/src/components/ui/page-loader.tsx b/src/components/ui/page-loader.tsx index 9344781..6df36c3 100644 --- a/src/components/ui/page-loader.tsx +++ b/src/components/ui/page-loader.tsx @@ -6,16 +6,18 @@ import { Loader } from './loader'; export interface PageLoaderProps { text?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; showText?: boolean; } const PageLoader: React.FC = ({ text = 'Loading...', + size = 'md', showText = true }) => { return (
- + {showText && (

{text} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index f28eddc..e94f596 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -34,34 +34,22 @@ export const ToastItem = ({ toast, onClose }: ToastItemProps) => { case 'success': return { icon: CheckCircle2, - iconColor: 'text-green-600', - bgColor: 'bg-green-50', - borderColor: 'border-green-200', - gradient: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)', + iconColor: 'text-[#10B981]', }; case 'error': return { icon: XCircle, - iconColor: 'text-red-600', - bgColor: 'bg-red-50', - borderColor: 'border-red-200', - gradient: 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)', + iconColor: 'text-[#EF4444]', }; case 'warning': return { icon: AlertCircle, - iconColor: 'text-yellow-600', - bgColor: 'bg-yellow-50', - borderColor: 'border-yellow-200', - gradient: 'linear-gradient(135deg, #fefce8 0%, #fef3c7 100%)', + iconColor: 'text-amber-500', }; case 'info': return { icon: Info, - iconColor: 'text-blue-600', - bgColor: 'bg-blue-50', - borderColor: 'border-blue-200', - gradient: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', + iconColor: 'text-[#0071C5]', }; } }; @@ -71,19 +59,18 @@ export const ToastItem = ({ toast, onClose }: ToastItemProps) => { return (

-
-
- +
+
+
-

+

{toast.title}

{toast.message && ( -
+
{toast.message.split('\n').map((line, index) => { // Check if line contains a URL const urlMatch = line.match(/(https?:\/\/[^\s]+)/); @@ -96,7 +83,7 @@ export const ToastItem = ({ toast, onClose }: ToastItemProps) => { href={urlMatch[0]} target="_blank" rel="noopener noreferrer" - className="text-blue-600 hover:text-blue-800 underline break-all" + className="text-[#0071C5] hover:text-blue-700 underline break-all transition-colors" > {urlMatch[0]} @@ -111,9 +98,9 @@ export const ToastItem = ({ toast, onClose }: ToastItemProps) => {
@@ -129,12 +116,10 @@ export const ToastContainer = ({ toasts, onClose }: ToastContainerProps) => { if (toasts.length === 0) return null; return ( -
-
- {toasts.map((toast) => ( - - ))} -
+
+ {toasts.map((toast) => ( + + ))}
); }; diff --git a/src/components/ui/wavy.tsx b/src/components/ui/wavy.tsx index 422e06c..0cdcf56 100644 --- a/src/components/ui/wavy.tsx +++ b/src/components/ui/wavy.tsx @@ -49,7 +49,7 @@ export function Wavy({ return ( -
+
{mounted && ( <> diff --git a/src/constants/solvency.constants.ts b/src/constants/solvency.constants.ts index 7e71ba5..46638ee 100644 --- a/src/constants/solvency.constants.ts +++ b/src/constants/solvency.constants.ts @@ -79,5 +79,5 @@ export const MIN_BORROW_AMOUNT = 100; // Minimum 100 USDC export const MIN_DEPOSIT_AMOUNT = 0.01; // Minimum deposit amount in tokens // Contract addresses (to be configured per environment) -export const VAULT_CONTRACT_ADDRESS = import.meta.env.VITE_VAULT_CONTRACT_ADDRESS || '0x0849B8d12Ac2a7Fcab4FAe8a46154e9778579493'; -export const USDC_CONTRACT_ADDRESS = import.meta.env.VITE_USDC_CONTRACT_ADDRESS || ''; \ No newline at end of file +export const VAULT_CONTRACT_ADDRESS = import.meta.env.VITE_VAULT_CONTRACT_ADDRESS || '0x3b3d70Fe12076f30E9999Fd65feC6C6DeB47B5eF'; +export const USDC_CONTRACT_ADDRESS = import.meta.env.VITE_USDC_CONTRACT_ADDRESS || '0x26Da2F1a2de3295302Fd95eBA1A183dc8Ffd77a3'; \ No newline at end of file diff --git a/src/hooks/useAuctionContracts.ts b/src/hooks/useAuctionContracts.ts index d7b4923..ff98d85 100644 --- a/src/hooks/useAuctionContracts.ts +++ b/src/hooks/useAuctionContracts.ts @@ -575,10 +575,10 @@ export function useSettleBid() { console.log('✅ Backend notified successfully!'); - // Refresh page after 2 seconds to show updated portfolio + // Refresh page after 1 second to show updated portfolio setTimeout(() => { window.location.reload(); - }, 2000); + }, 1000); } catch (error: any) { console.error('❌ Backend notification failed:', error); setError(`Settlement complete, but backend notification failed: ${error.message}`); diff --git a/src/index.css b/src/index.css index cba0b18..df800d6 100644 --- a/src/index.css +++ b/src/index.css @@ -351,6 +351,10 @@ font-family: 'Gellix', sans-serif; } + .font-gellix-original{ + font-family :'Gellix', Gellix-Fallback, sans-serif; + } + .font-beau { font-family: 'Beau Rivage', cursive; } diff --git a/src/lib/api/contract.service.ts b/src/lib/api/contract.service.ts index 4d0e750..fb2f331 100644 --- a/src/lib/api/contract.service.ts +++ b/src/lib/api/contract.service.ts @@ -32,7 +32,7 @@ const MARKETPLACE_ABI = [ 'function buyTokens(bytes32 assetId, uint256 amount) external', // Universal listings function (from end-auction script, more up-to-date) - 'function listings(bytes32) view returns (address tokenAddress, bytes32 assetId, uint8 listingType, uint256 staticPrice, uint256 reservePrice, uint256 endTime, uint256 clearingPrice, uint8 auctionPhase, uint256 totalSupply, uint256 sold, bool active, uint256 minInvestment)', + 'function listings(bytes32) view returns (address tokenAddress, bytes32 assetId, uint8 listingType, uint256 staticPrice, uint256 minPrice,uint256 reservePrice, uint256 endTime, uint256 clearingPrice, uint8 auctionPhase, uint256 totalSupply, uint256 sold, bool active, uint256 minInvestment)', // Event 'event TokensPurchased(bytes32 indexed assetId, address indexed buyer, uint256 amount, uint256 payment)', @@ -85,35 +85,34 @@ class ContractService { * Polls every 10 seconds for up to 20 minutes * Prevents UI freeze and aggressive RPC polling */ - async waitForTransaction(txHash: string, provider: ethers.Provider): Promise { - const POLL_INTERVAL = 5000; // 5 seconds - const MAX_ATTEMPTS = 240; // 20 minutes (240 * 5s = 1200s) - - console.log(`âŗ Polling for TX ${txHash} (Interval: 5s, Timeout: 20m)...`); - - for (let i = 0; i < MAX_ATTEMPTS; i++) { - try { - const receipt = await provider.getTransactionReceipt(txHash); - if (receipt) { - if (receipt.status === 1) { - console.log(`✅ TX Confirmed in block ${receipt.blockNumber} after ${(i + 1) * 5}s`); - return receipt; - } else { - throw new Error(`Transaction failed (status: 0)`); - } + async waitForTransaction( + txHash: string, + provider: ethers.Provider + ): Promise { + console.log(`âŗ Waiting for TX ${txHash} using ethers.provider.waitForTransaction...`); + try { + // Wait for 1 confirmation, with a 2-minute timeout. + // ethers.js will use an efficient combination of WebSockets and polling. + const receipt = await provider.waitForTransaction(txHash, 1, 120000); + + if (receipt) { + if (receipt.status === 1) { + console.log(`✅ TX Confirmed in block ${receipt.blockNumber}`); + return receipt; + } else { + console.error(`❌ Transaction failed (status: 0)`); + throw new Error(`Transaction failed with status 0`); } - } catch (error: any) { - // Ignore "not found" errors during polling, rethrow others if critical - if (error.message && !error.message.includes('not found')) { - console.warn(`Polling error (attempt ${i+1}):`, error); - } + } else { + // This case happens if the transaction is dropped from the mempool + console.warn(`âš ī¸ Transaction ${txHash} was not mined and may have been dropped.`); + return null; } - - // Wait for next poll - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); + } catch (error: any) { + console.error(`Error waiting for transaction ${txHash}:`, error.message); + // Re-throw to be caught by the calling function + throw error; } - - throw new Error(`Transaction confirmation timed out after 20 minutes. TX: ${txHash}`); } /** @@ -199,8 +198,9 @@ class ContractService { // Get listing to determine type and price const listing = await marketplaceContract.listings(assetId); const staticPrice = listing[3]; - const totalSupply = listing[8]; // totalSupply is at index 8 - const minInvestmentRaw = listing[11]; // minInvestment is at index 11 (matches buyTokens) + const totalSupply = listing[9]; // totalSupply is at index 9 + const minInvestmentRaw = listing[12]; // minInvestment is at index 12 + // FIX: Backend sends minInvestment in 1e18 format (token decimals) // but contract expects 1e6 format (USDC decimals) @@ -242,6 +242,11 @@ class ContractService { console.log('Approve TX:', tx.hash); const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('Approval transaction failed to confirm and may have been dropped.'); + } + console.log('✅ USDC approved'); return { @@ -293,11 +298,11 @@ class ContractService { const listing1 = await marketplaceContract.listings(format1); console.log('✅ Format 1 FOUND:', { tokenAddress: listing1[0], - totalSupply: listing1[8].toString(), - sold: listing1[9].toString(), - availableSupply: (listing1[8] - listing1[9]).toString(), - minInvestment: listing1[11].toString(), - isActive: listing1[10], + totalSupply: listing1[9].toString(), + sold: listing1[10].toString(), + availableSupply: (listing1[9] - listing1[10]).toString(), + minInvestment: listing1[12].toString(), + isActive: listing1[11], }); } catch (e) { console.log('❌ Format 1 not found'); @@ -310,11 +315,11 @@ class ContractService { const listing2 = await marketplaceContract.listings(format2); console.log('✅ Format 2 FOUND:', { tokenAddress: listing2[0], - totalSupply: listing2[8].toString(), - sold: listing2[9].toString(), - availableSupply: (listing2[8] - listing2[9]).toString(), - minInvestment: listing2[11].toString(), - isActive: listing2[10], + totalSupply: listing2[9].toString(), + sold: listing2[10].toString(), + availableSupply: (listing2[9] - listing2[10]).toString(), + minInvestment: listing2[12].toString(), + isActive: listing2[11], }); } catch (e) { console.log('❌ Format 2 not found'); @@ -327,11 +332,11 @@ class ContractService { const listing3 = await marketplaceContract.listings(format3); console.log('✅ Format 3 FOUND:', { tokenAddress: listing3[0], - totalSupply: listing3[8].toString(), - sold: listing3[9].toString(), - availableSupply: (listing3[8] - listing3[9]).toString(), - minInvestment: listing3[11].toString(), - isActive: listing3[10], + totalSupply: listing3[9].toString(), + sold: listing3[10].toString(), + availableSupply: (listing3[9] - listing3[10]).toString(), + minInvestment: listing3[12].toString(), + isActive: listing3[11], }); } catch (e) { console.log('❌ Format 3 not found'); @@ -344,11 +349,11 @@ class ContractService { const listing4 = await marketplaceContract.listings(format4); console.log('✅ Format 4 FOUND:', { tokenAddress: listing4[0], - totalSupply: listing4[8].toString(), - sold: listing4[9].toString(), - availableSupply: (listing4[8] - listing4[9]).toString(), - minInvestment: listing4[11].toString(), - isActive: listing4[10], + totalSupply: listing4[9].toString(), + sold: listing4[10].toString(), + availableSupply: (listing4[9] - listing4[10]).toString(), + minInvestment: listing4[12].toString(), + isActive: listing4[11], }); } catch (e) { console.log('❌ Format 4 not found'); @@ -392,16 +397,31 @@ class ContractService { try { const listing = await marketplaceContract.listings(assetIdBytes32); - // Correct field mapping from actual contract: - // [0] tokenAddress, [1] assetId, [2] listingType, [3] staticPrice, - // [4] startPrice, [5] endPrice, [6] duration, [7] startTime, - // [8] totalSupply, [9] sold, [10] active, [11] minInvestment + + // struct Listing { + // 0 address tokenAddress; + // 1 bytes32 assetId; + // 2 ListingType listingType; + // // Static params + // 3 uint256 staticPrice; + // // Auction params + // 4 uint256 minPrice; // Minimum bid price (lower bound of range) + // 5 uint256 reservePrice; // Reserve price (avg of min/max, used for clearing) + // 6 uint256 endTime; + // 7 uint256 clearingPrice; // Set when auction ends + // 8 AuctionPhase auctionPhase; + // // Common params + // 9 uint256 totalSupply; + // 10 uint256 sold; // For static: amount sold. For auction: tokens allocated. + // 11 bool active; + // 12 uint256 minInvestment; + // } const tokenAddress = listing[0]; - const totalSupply = listing[8]; - const sold = listing[9]; - const active = listing[10]; - const minInvestment = listing[11]; + const totalSupply = listing[9]; + const sold = listing[10]; + const active = listing[11]; + const minInvestment = listing[12]; const listingType = listing[2]; // 0 = STATIC, 1 = DUTCH_AUCTION const staticPrice = listing[3]; @@ -422,14 +442,14 @@ class ContractService { // Try alternative format: keccak256 of UUID const altAssetId = ethers.keccak256(ethers.toUtf8Bytes(assetId)); console.log('Trying alternative format:', altAssetId); - + try { const altListing = await marketplaceContract.listings(altAssetId); const altTokenAddress = altListing[0]; const altStaticPrice = altListing[3]; - const altTotalSupply = altListing[8]; - const altSold = altListing[9]; - const altActive = altListing[10]; + const altTotalSupply = altListing[9]; + const altSold = altListing[10]; + const altActive = altListing[11]; if (altTokenAddress !== ethers.ZeroAddress) { console.log('✅ Found listing with keccak256 format!'); @@ -545,9 +565,9 @@ class ContractService { const listing = await marketplaceContract.listings(assetIdBytes32); const tokenAddress = listing[0]; const staticPrice = listing[3]; - const totalSupply = listing[8]; - const sold = listing[9]; - const minInvestmentRaw = listing[11]; + const totalSupply = listing[9]; + const sold = listing[10]; + const minInvestmentRaw = listing[12]; // FIX: Backend sends minInvestment in 1e18 format (token decimals) // but contract expects 1e6 format (USDC decimals) @@ -578,7 +598,7 @@ class ContractService { console.log('Payment >= MinInvestment?', payment >= minInvestment ? '✅ YES' : '❌ NO'); // Check if payment meets minimum investment requirement - + // Check USDC balance const usdcBalance = await usdcContract.balanceOf(userAddress); @@ -604,10 +624,15 @@ class ContractService { // Wait for confirmation const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('Purchase transaction failed to confirm and may have been dropped.'); + } + console.log(`✅ Confirmed in block ${receipt.blockNumber}`); console.log('\n✅ Purchase Complete!'); console.log('━'.repeat(50)); - console.log(`Explorer: https://explorer.sepolia.mantle.xyz/tx/${tx.hash}`); + console.log(`Explorer: https://explorer.sepolia.mantle.xyz/tx/${receipt.hash}`); return { success: true, @@ -630,7 +655,11 @@ class ContractService { * Complete purchase flow: Verify + Approve USDC + Buy Tokens * Combines all steps into a single function */ - async completePurchase(params: PurchaseParams, tokenAddress?: string): Promise<{ + async completePurchase( + params: PurchaseParams, + tokenAddress?: string, + onStatusUpdate?: (status: string) => void + ): Promise<{ success: boolean; approvalTxHash?: string; purchaseTxHash?: string; @@ -640,8 +669,9 @@ class ContractService { try { // Step 0: Verify listing is available console.log('Step 0: Verifying listing...'); + onStatusUpdate?.('Intiating Purchase...'); const verification = await this.verifyListing(params.assetId, tokenAddress); - + if (!verification.isValid) { return { success: false, @@ -650,6 +680,7 @@ class ContractService { } console.log('Listing verified:', verification.details); + onStatusUpdate?.('Approving USDC...'); // Step 1: Approve USDC with correct assetId console.log('Step 1: Approving USDC...'); @@ -662,6 +693,9 @@ class ContractService { }; } + console.log('USDC approved, proceeding to buy tokens...'); + onStatusUpdate?.('USDC approved! Buying tokens...'); + // Step 2: Buy tokens with correct assetId console.log('Step 2: Buying tokens...'); const purchaseResult = await this.buyTokens(params, verification.correctAssetId); @@ -916,6 +950,11 @@ class ContractService { console.log('âŗ Waiting for confirmation...'); const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('YieldVault approval transaction failed to confirm and may have been dropped.'); + } + console.log('✅ Approved in block', receipt.blockNumber); console.log(); @@ -995,6 +1034,10 @@ class ContractService { console.log('âŗ Waiting for confirmation...'); const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('YieldVault approval transaction failed to confirm and may have been dropped.'); + } console.log(`✅ Confirmed in block ${receipt.blockNumber}`); console.log(); @@ -1122,9 +1165,14 @@ class ContractService { console.log('âŗ Waiting for confirmation...'); const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('Marketplace approval transaction failed to confirm and may have been dropped.'); + } + console.log(`✅ Confirmed in block ${receipt.blockNumber}`); console.log('✅ Marketplace approved!'); - console.log('Explorer:', `https://explorer.sepolia.mantle.xyz/tx/${tx.hash}`); + console.log('Explorer:', `https://explorer.sepolia.mantle.xyz/tx/${receipt.hash}`); console.log(); console.log('✅ Marketplace can now transfer tokens to buyers!'); @@ -1163,18 +1211,23 @@ class ContractService { console.log('Admin Wallet:', adminAddress); console.log('Clearing Price:', clearingPrice, 'USDC'); console.log('Clearing Price (wei):', clearingPriceWei.toString()); - + console.log('Submitting endAuction transaction...'); const tx = await marketplaceContract.endAuction(assetIdBytes32, clearingPriceWei); console.log('TX Hash:', tx.hash); console.log('Waiting for confirmation...'); const receipt = await this.waitForTransaction(tx.hash, provider); + + if (!receipt) { + throw new Error('End auction transaction failed to confirm and may have been dropped.'); + } + console.log('Confirmed in block', receipt.blockNumber); - + return { success: true, - transactionHash: tx.hash, + transactionHash: receipt.hash, blockNumber: receipt.blockNumber, clearingPriceWei: clearingPriceWei.toString(), }; diff --git a/src/lib/api/marketplace.service.ts b/src/lib/api/marketplace.service.ts index 1e062ca..c30938a 100644 --- a/src/lib/api/marketplace.service.ts +++ b/src/lib/api/marketplace.service.ts @@ -93,6 +93,7 @@ class MarketplaceService extends BaseService { async notifyPurchase(payload: NotifyPurchasePayload): Promise { try { + console.log('Notifying purchase with payload:', payload); const response = await fetch(`${this.baseURL}/marketplace/purchases/notify`, { method: 'POST', headers: this.getAuthHeaders(), diff --git a/src/lib/api/notification.service.ts b/src/lib/api/notification.service.ts index 486da65..9ed1946 100644 --- a/src/lib/api/notification.service.ts +++ b/src/lib/api/notification.service.ts @@ -6,36 +6,70 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://f5e22b62e871.ngrok /** * Notification types from backend + * export enum NotificationType { + ASSET_STATUS = 'ASSET_STATUS', + KYC_STATUS = 'KYC_STATUS', + YIELD_DISTRIBUTED = 'YIELD_DISTRIBUTED', + PAYOUT_SETTLED = 'PAYOUT_SETTLED', + TOKEN_PURCHASED = 'TOKEN_PURCHASED', + TOKEN_DEPLOYED = 'TOKEN_DEPLOYED', + SYSTEM_ALERT = 'SYSTEM_ALERT', + MARKETPLACE_LISTING = 'MARKETPLACE_LISTING', + BID_PLACED = 'BID_PLACED', + AUCTION_WON = 'AUCTION_WON', + BID_REFUNDED = 'BID_REFUNDED', + ORDER_FILLED = 'ORDER_FILLED', + ORDER_CANCELED = 'ORDER_CANCELED', + ORDER_ACTIVE = 'ORDER_ACTIVE', + ORDER_CREATED = 'ORDER_CREATED', + ORDER_CANCELLED = 'ORDER_CANCELLED', + +} + +export enum NotificationSeverity { + INFO = 'info', + SUCCESS = 'success', + WARNING = 'warning', + ERROR = 'error', +} + +export enum NotificationAction { + VIEW_ASSET = 'VIEW_ASSET', + VIEW_PORTFOLIO = 'VIEW_PORTFOLIO', + CLAIM_YIELD = 'CLAIM_YIELD', + VIEW_MARKETPLACE = 'VIEW_MARKETPLACE', + VIEW_KYC = 'VIEW_KYC', + NONE = 'NONE', +} + + * + * */ export type NotificationType = | 'ASSET_STATUS' - | 'TOKEN_DEPLOYED' | 'KYC_STATUS' + | 'YIELD_DISTRIBUTED' + | 'PAYOUT_SETTLED' + | 'TOKEN_PURCHASED' + | 'TOKEN_DEPLOYED' + | 'SYSTEM_ALERT' + | 'MARKETPLACE_LISTING' | 'BID_PLACED' | 'AUCTION_WON' | 'BID_REFUNDED' - | 'TOKEN_PURCHASED' - | 'YIELD_DISTRIBUTED' - | 'SYSTEM_ALERT' | 'ORDER_FILLED' | 'ORDER_CANCELED' | 'ORDER_ACTIVE' | 'ORDER_CREATED' | 'ORDER_CANCELLED'; -/** - * Notification severity levels - */ -export type NotificationSeverity = 'SUCCESS' | 'INFO' | 'WARNING' | 'ERROR'; +export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error'; -/** - * Notification actions - */ export type NotificationAction = | 'VIEW_ASSET' | 'VIEW_PORTFOLIO' - | 'VIEW_MARKETPLACE' | 'CLAIM_YIELD' + | 'VIEW_MARKETPLACE' | 'VIEW_KYC' | 'NONE'; @@ -124,8 +158,8 @@ class NotificationService extends BaseService { // Return cached data if available and not expired const now = Date.now(); const isCacheValid = this.cachedNotifications !== null && - (now - this.cacheTimestamp) < this.CACHE_TTL && - !forceRefresh; + (now - this.cacheTimestamp) < this.CACHE_TTL && + !forceRefresh; if (isCacheValid && offset === 0) { console.log('đŸ“Ļ Returning cached notifications (preventing duplicate fetch)'); @@ -399,7 +433,7 @@ class NotificationService extends BaseService { const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found for SSE connection'); - return () => {}; + return () => { }; } const connectSSE = async () => { @@ -409,7 +443,7 @@ class NotificationService extends BaseService { try { const url = `${this.baseURL}/notifications/stream`; console.log(`🔄 Connecting to Notification Stream at: ${url}`); - + // Match BaseService headers and required SSE headers // Removing ngrok-skip-browser-warning as it might cause CORS issues on localhost if not allowed const headers: HeadersInit = { @@ -425,15 +459,15 @@ class NotificationService extends BaseService { }); if (!response.ok) { - const errorText = await response.text().catch(() => 'No error details'); - throw new Error(`SSE Connection Failed: ${response.status} ${response.statusText} - ${errorText}`); + const errorText = await response.text().catch(() => 'No error details'); + throw new Error(`SSE Connection Failed: ${response.status} ${response.statusText} - ${errorText}`); } - + this.sseReader = response.body?.getReader() || null; if (!this.sseReader) throw new Error('ReadableStream not supported'); console.log('✅ SSE Connected'); - + const decoder = new TextDecoder(); let buffer = ''; @@ -448,7 +482,7 @@ class NotificationService extends BaseService { let currentEvent = 'message'; let currentData = ''; - + for (const line of lines) { if (line.trim() === '') { // End of event dispatch @@ -498,10 +532,10 @@ class NotificationService extends BaseService { if (error.name !== 'AbortError') { console.error('❌ SSE Error:', error); if (this.isSSEConnected) { - this.reconnectTimeout = setTimeout(() => { - console.log('â™ģī¸ Reconnecting SSE...'); - connectSSE(); - }, 5000); + this.reconnectTimeout = setTimeout(() => { + console.log('â™ģī¸ Reconnecting SSE...'); + connectSSE(); + }, 5000); } } } finally { @@ -536,13 +570,17 @@ class NotificationService extends BaseService { const roleFilters: Record = { ORIGINATOR: ['ASSET_STATUS', 'TOKEN_DEPLOYED'], INVESTOR: [ + 'ASSET_STATUS', 'KYC_STATUS', + 'YIELD_DISTRIBUTED', + 'PAYOUT_SETTLED', + 'TOKEN_PURCHASED', + 'TOKEN_DEPLOYED', + 'SYSTEM_ALERT', + 'MARKETPLACE_LISTING', 'BID_PLACED', 'AUCTION_WON', 'BID_REFUNDED', - 'TOKEN_PURCHASED', - 'YIELD_DISTRIBUTED', - 'ASSET_STATUS', 'ORDER_FILLED', 'ORDER_CANCELED', 'ORDER_ACTIVE', diff --git a/src/lib/api/solvency-contract.service.ts b/src/lib/api/solvency-contract.service.ts index f888096..4b78c6c 100644 --- a/src/lib/api/solvency-contract.service.ts +++ b/src/lib/api/solvency-contract.service.ts @@ -9,7 +9,7 @@ import { ethers } from 'ethers'; // Contract addresses from environment -const VAULT_CONTRACT_ADDRESS = import.meta.env.VITE_VAULT_CONTRACT_ADDRESS || '0x3b3d70Fe12076f30E9999Fd65feC6C6DeB47B5eF'; +const VAULT_CONTRACT_ADDRESS = import.meta.env.VITE_SOLVENCY_VAULT || '0xf491b90154C46f856CF59e951c19eaD61c030e17'; const USDC_CONTRACT_ADDRESS = import.meta.env.VITE_USDC_CONTRACT_ADDRESS || '0x9A54Bad93a00Bf1232D4e636f5e53055Dc0b8238'; // Solvency Vault ABI - ✅ VERIFIED from deposit-to-vaultsolvency.js lines 115-121 @@ -22,7 +22,8 @@ const SOLVENCY_VAULT_ABI = [ 'function withdrawCollateral(uint256 positionId, uint256 amount) external', // Read functions - 'function positions(uint256) view returns (address user, address collateralToken, uint256 collateralAmount, uint256 usdcBorrowed, uint256 tokenValueUSD, uint256 createdAt, bool active, uint8 tokenType)', + 'function positions(uint256) view returns (address user, address collateralToken, uint256 collateralAmount, uint256 usdcBorrowed, uint256 tokenValueUSD, uint256 createdAt, uint256 liquidatedAt, uint256 creditLineId, bool active, uint8 tokenType)', + 'function repaymentPlans(uint256) view returns (uint256 loanDuration, uint256 numberOfInstallments, uint256 installmentInterval, uint256 installmentsPaid, uint256 missedPayments, uint256 nextPaymentDue, bool isActive)', 'function seniorPool() view returns (address)', // Events @@ -51,7 +52,7 @@ const USDC_ABI = [ // SeniorPool ABI - For repayment flow per COMPLETE_LOAN.md const SENIOR_POOL_ABI = [ 'function repayLoan(uint256 positionId, uint256 amount) external', - 'function outstandingDebt(uint256 positionId) view returns (uint256)', + 'function getOutstandingDebt(uint256 positionId) view returns (uint256)', // Events 'event LoanRepaid(uint256 indexed positionId, uint256 amountPaid, uint256 principal, uint256 interest, uint256 remainingDebt)', @@ -72,21 +73,39 @@ export interface Position { usdcBorrowed: bigint; tokenValueUSD: bigint; createdAt: bigint; + liquidatedAt: bigint; + creditLineId: bigint; active: boolean; tokenType: number; // 0 = RWA, 1 = PRIVATE_ASSET } +export interface RepaymentPlan { + loanDuration: bigint; + numberOfInstallments: bigint; + installmentInterval: bigint; + installmentsPaid: bigint; + missedPayments: bigint; + nextPaymentDue: bigint; + isActive: boolean; +} + class SolvencyContractService { /** * Get provider and signer from browser wallet */ private async getProviderAndSigner() { + console.log('🔌 Getting provider and signer...'); + if (!window.ethereum) { throw new Error('No wallet found. Please install MetaMask or another Web3 wallet.'); } + console.log(' Creating BrowserProvider...'); const provider = new ethers.BrowserProvider(window.ethereum); + + console.log(' Getting network...'); const network = await provider.getNetwork(); + console.log(` Connected to network: chainId ${network.chainId}`); // Check if on Mantle Sepolia (chainId: 5003) if (network.chainId !== 5003n) { @@ -95,7 +114,10 @@ class SolvencyContractService { ); } + console.log(' Getting signer...'); const signer = await provider.getSigner(); + console.log(` Signer address: ${await signer.getAddress()}`); + return { provider, signer }; } @@ -103,8 +125,14 @@ class SolvencyContractService { * Get vault contract instance */ private async getVaultContract() { + console.log('📄 Getting vault contract instance...'); + console.log(` Vault address: ${VAULT_CONTRACT_ADDRESS}`); + const { signer } = await this.getProviderAndSigner(); - return new ethers.Contract(VAULT_CONTRACT_ADDRESS, SOLVENCY_VAULT_ABI, signer); + const contract = new ethers.Contract(VAULT_CONTRACT_ADDRESS, SOLVENCY_VAULT_ABI, signer); + + console.log('✅ Vault contract created successfully'); + return contract; } /** @@ -146,15 +174,30 @@ class SolvencyContractService { const vault = await this.getVaultContract(); const position = await vault.positions(positionId); + console.log(`📋 Position ${positionId} data:`, { + user: position[0], + collateralToken: position[1], + collateralAmount: position[2].toString(), + usdcBorrowed: position[3].toString(), + tokenValueUSD: position[4].toString(), + createdAt: position[5].toString(), + liquidatedAt: position[6].toString(), + creditLineId: position[7].toString(), + active: position[8], + tokenType: position[9], + }); + return { - user: position.user, - collateralToken: position.collateralToken, - collateralAmount: position.collateralAmount, - usdcBorrowed: position.usdcBorrowed, - tokenValueUSD: position.tokenValueUSD, - createdAt: position.createdAt, - active: position.active, - tokenType: position.tokenType, + user: position[0], + collateralToken: position[1], + collateralAmount: position[2], + usdcBorrowed: position[3], + tokenValueUSD: position[4], + createdAt: position[5], + liquidatedAt: position[6], + creditLineId: position[7], + active: position[8], + tokenType: position[9], }; } catch (error: any) { console.error('❌ Error fetching position:', error); @@ -251,6 +294,48 @@ class SolvencyContractService { } } + /** + * Get outstanding debt for a position + * Per repay-solvency-loan.js: Used to cap repayment amount + */ + async getOutstandingDebt(positionId: number): Promise { + try { + console.log(`📊 Fetching outstanding debt for position ${positionId}...`); + const seniorPool = await this.getSeniorPoolContract(); + const debt = await seniorPool.getOutstandingDebt(positionId); + console.log(` Outstanding Debt: $${ethers.formatUnits(debt, 6)} USDC`); + return debt; + } catch (error: any) { + console.error('❌ Error getting outstanding debt:', error); + throw new Error(`Failed to get outstanding debt: ${error.message}`); + } + } + + /** + * Get repayment plan for a position + * Used to check if position has an active loan + */ + async getRepaymentPlan(positionId: number): Promise { + try { + console.log(`📋 Fetching repayment plan for position ${positionId}...`); + const vault = await this.getVaultContract(); + const plan = await vault.repaymentPlans(positionId); + + return { + loanDuration: plan[0], + numberOfInstallments: plan[1], + installmentInterval: plan[2], + installmentsPaid: plan[3], + missedPayments: plan[4], + nextPaymentDue: plan[5], + isActive: plan[6], + }; + } catch (error: any) { + console.error('❌ Error getting repayment plan:', error); + return null; + } + } + // ============================================ // WRITE FUNCTIONS // ============================================ @@ -418,21 +503,37 @@ class SolvencyContractService { numberOfInstallments, }); + console.log('🔍 Step 1: Getting vault contract...'); const vault = await this.getVaultContract(); + console.log('✅ Vault contract obtained:', vault.target); + console.log(`📝 Step 2: Preparing transaction...`); console.log(` Borrowing $${ethers.formatUnits(amount, 6)} USDC from SeniorPool...`); console.log(` Loan Duration: ${loanDuration} seconds (~${Math.floor(loanDuration / 86400)} days)`); console.log(` Installments: ${numberOfInstallments}`); + console.log('🚀 Step 3: Calling vault.borrowUSDC()...'); + console.log(' Parameters:', { + positionId, + amount: amount.toString(), + loanDuration, + numberOfInstallments + }); + + console.log('âŗ WAITING FOR USER: Please approve the transaction in your wallet!'); + console.log(' Check MetaMask or your wallet extension...'); + const tx = await vault.borrowUSDC(positionId, amount, loanDuration, numberOfInstallments); - console.log(` Transaction submitted: ${tx.hash}`); - console.log('âŗ Waiting for confirmation...'); + console.log(`✅ Step 4: Transaction submitted successfully!`); + console.log(` Transaction hash: ${tx.hash}`); + console.log('âŗ Step 5: Waiting for confirmation...'); const receipt = await tx.wait(); - console.log(`✅ Borrow confirmed in block ${receipt.blockNumber}`); + console.log(`✅ Step 6: Transaction confirmed in block ${receipt.blockNumber}`); // Parse USDCBorrowed event + console.log('🔍 Step 7: Parsing events...'); let borrowed = null; let totalDebt = null; for (const log of receipt.logs) { @@ -455,7 +556,8 @@ class SolvencyContractService { } } - console.log(` Explorer: https://explorer.sepolia.mantle.xyz/tx/${tx.hash}`); + console.log(`🔗 Explorer: https://explorer.sepolia.mantle.xyz/tx/${tx.hash}`); + console.log('✅ Step 8: Borrow completed successfully!'); return { success: true, @@ -463,7 +565,31 @@ class SolvencyContractService { blockNumber: receipt.blockNumber, }; } catch (error: any) { - console.error('❌ Borrow failed:', error); + console.error('❌ Borrow failed at some step:', error); + console.error(' Error name:', error.name); + console.error(' Error message:', error.message); + console.error(' Error code:', error.code); + if (error.reason) console.error(' Error reason:', error.reason); + if (error.data) console.error(' Error data:', error.data); + + // Check for user rejection + if (error.code === 'ACTION_REJECTED' || error.code === 4001) { + console.log('👤 User rejected the transaction in wallet'); + return { + success: false, + error: 'Transaction rejected by user', + }; + } + + // Check for insufficient funds + if (error.code === 'INSUFFICIENT_FUNDS') { + console.log('💰 Insufficient funds for transaction'); + return { + success: false, + error: 'Insufficient funds to complete transaction', + }; + } + return { success: false, error: error.message || 'Borrow transaction failed', @@ -548,6 +674,8 @@ class SolvencyContractService { } } + + /** * Approve USDC for vault contract */ @@ -577,20 +705,21 @@ class SolvencyContractService { } /** - * Approve USDC for SeniorPool contract - * Per COMPLETE_LOAN.md: Repayments require approval to SeniorPool, not Vault + * Approve USDC for Vault contract (FIXED: Was incorrectly approving SeniorPool) + * Per repay-solvency-loan.js: Repayments require approval to Vault, not SeniorPool */ async approveUSDCForSeniorPool(amount: bigint): Promise { try { - console.log('🔓 Approving USDC for SeniorPool:', amount.toString()); + console.log('🔓 Approving USDC for Vault (Repayment):', amount.toString()); - const seniorPoolAddress = await this.getSeniorPoolAddress(); + // Approve the Vault contract, not SeniorPool + const vaultAddress = VAULT_CONTRACT_ADDRESS; const usdc = await this.getUSDCContract(); // Check current allowance const { signer } = await this.getProviderAndSigner(); const userAddress = await signer.getAddress(); - const currentAllowance = await usdc.allowance(userAddress, seniorPoolAddress); + const currentAllowance = await usdc.allowance(userAddress, vaultAddress); console.log(` Current allowance: ${ethers.formatUnits(currentAllowance, 6)} USDC`); @@ -603,29 +732,39 @@ class SolvencyContractService { }; } - const tx = await usdc.approve(seniorPoolAddress, amount); + console.log('âŗ WAITING FOR USER: Please approve USDC spending in your wallet!'); + const tx = await usdc.approve(vaultAddress, amount); console.log('âŗ Waiting for USDC approval confirmation...', tx.hash); const receipt = await tx.wait(); - console.log('✅ USDC approved for SeniorPool successfully!', receipt.hash); + console.log('✅ USDC approved for Vault successfully!', receipt.hash); return { success: true, txHash: receipt.hash, blockNumber: receipt.blockNumber, }; } catch (error: any) { - console.error('❌ USDC approval for SeniorPool failed:', error); + console.error('❌ USDC approval for Vault failed:', error); + + // Check for user rejection + if (error.code === 'ACTION_REJECTED' || error.code === 4001) { + return { + success: false, + error: 'Approval rejected by user', + }; + } + return { success: false, - error: error.message || 'USDC approval for SeniorPool failed', + error: error.message || 'USDC approval failed', }; } } /** - * Repay loan through SeniorPool - * Per COMPLETE_LOAN.md: Direct call to SeniorPool.repayLoan(), not Vault + * Repay loan through Vault (FIXED: Was incorrectly calling SeniorPool) + * Per repay-solvency-loan.js: Call SolvencyVault.repayLoan(), not SeniorPool * * @param positionId - Position ID to repay * @param amount - USDC amount to repay (6 decimals) @@ -633,16 +772,19 @@ class SolvencyContractService { */ async repayLoanViaSeniorPool(positionId: number, amount: bigint): Promise { try { - console.log('đŸ’ĩ Repaying loan via SeniorPool:', { + console.log('đŸ’ĩ Repaying loan via Vault:', { positionId, amount: amount.toString(), }); - const seniorPool = await this.getSeniorPoolContract(); + // Get vault contract (NOT SeniorPool - that was the bug!) + const vault = await this.getVaultContract(); - console.log(` Repaying $${ethers.formatUnits(amount, 6)} USDC to SeniorPool...`); + console.log(` Repaying $${ethers.formatUnits(amount, 6)} USDC via Vault...`); + console.log('âŗ WAITING FOR USER: Please approve the transaction in your wallet!'); - const tx = await seniorPool.repayLoan(positionId, amount); + // Call Vault.repayLoan() like the working script does + const tx = await vault.repayLoan(positionId, amount); console.log(` Transaction submitted: ${tx.hash}`); console.log('âŗ Waiting for repayment confirmation...'); @@ -650,15 +792,15 @@ class SolvencyContractService { const receipt = await tx.wait(); console.log(`✅ Repayment confirmed in block ${receipt.blockNumber}`); - // Parse LoanRepaid event + // Parse LoanRepaid event from Vault for (const log of receipt.logs) { try { - const parsed = seniorPool.interface.parseLog({ + const parsed = vault.interface.parseLog({ topics: log.topics as string[], data: log.data }); if (parsed && parsed.name === 'LoanRepaid') { - const amountPaid = parsed.args.amountPaid; + const amountPaid = parsed.args.amount; const principal = parsed.args.principal; const interest = parsed.args.interest; const remainingDebt = parsed.args.remainingDebt; @@ -682,10 +824,19 @@ class SolvencyContractService { blockNumber: receipt.blockNumber, }; } catch (error: any) { - console.error('❌ Repayment via SeniorPool failed:', error); + console.error('❌ Repayment via Vault failed:', error); + + // Check for user rejection + if (error.code === 'ACTION_REJECTED' || error.code === 4001) { + return { + success: false, + error: 'Transaction rejected by user', + }; + } + return { success: false, - error: error.message || 'Repayment via SeniorPool failed', + error: error.message || 'Repayment transaction failed', }; } } diff --git a/src/lib/api/solvency.service.ts b/src/lib/api/solvency.service.ts index 79ad1ab..25d4e24 100644 --- a/src/lib/api/solvency.service.ts +++ b/src/lib/api/solvency.service.ts @@ -106,7 +106,7 @@ class SolvencyService extends BaseService { /** * Get user's borrow positions * - * ✅ CORRECT ENDPOINT: GET /solvency/positions/my?status=ACTIVE&limit=20&offset=0 + * ✅ CORRECT ENDPOINT: GET /solvency/positions/my * Reference: SOLVENCY_INTEGRATION.md line 157 * * BACKEND RESPONSE (line 160-188 in docs): @@ -115,18 +115,12 @@ class SolvencyService extends BaseService { * meta: { total, limit, offset } * } */ - async getMyPositions(status: string = 'ACTIVE', limit: number = 20, offset: number = 0): Promise { + async getMyPositions(): Promise { try { console.log('📊 Fetching user positions...'); - const params = new URLSearchParams({ - status, - limit: limit.toString(), - offset: offset.toString(), - }); - const response = await this.fetchWithTimeout( - `${this.baseURL}/solvency/positions/my?${params}`, + `${this.baseURL}/solvency/positions/my`, { method: 'GET', headers: this.getAuthHeaders(), @@ -139,7 +133,7 @@ class SolvencyService extends BaseService { } const data: GetPositionsResponse = await response.json(); - console.log('✅ Positions received:', data); + console.log('✅ Loan Positions received:', data); return data; } catch (error: any) { console.error('❌ Error fetching positions:', error); @@ -381,6 +375,53 @@ class SolvencyService extends BaseService { } } + /** + * Withdraw collateral from position (Backend API - not smart contract) + * + * ✅ ENDPOINT: POST /solvency/withdraw + * + * Backend handles: + * - Position verification + * - On-chain withdrawCollateral call + * - Database update + * - Private asset collateral tracking update + */ + async withdrawCollateral(request: { + positionId: string; + amount: string; // Amount in wei (18 decimals) + }): Promise<{ + success: boolean; + txHash?: string; + blockNumber?: number; + position: Position; + }> { + try { + console.log('💰 Initiating collateral withdrawal:', request); + + const response = await this.fetchWithTimeout( + `${this.baseURL}/solvency/withdraw`, + { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify(request), + }, + 120000 // 2 minute timeout for blockchain tx + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to withdraw collateral'); + } + + const data = await response.json(); + console.log('✅ Withdrawal successful:', data); + return data; + } catch (error: any) { + console.error('❌ Error withdrawing collateral:', error); + throw error; + } + } + // ============================================ // ADMIN ENDPOINTS // ============================================ @@ -715,6 +756,133 @@ class SolvencyService extends BaseService { } } + /** + * Notify backend of loan borrow transaction + * + * ✅ ENDPOINT: POST /solvency/loan/borrow-notify + * Reference: LOAN_SYNC_IMPLEMENTATION.md + * + * Call after successful borrow transaction to sync backend database + */ + async notifyLoanBorrow(request: { + txHash: string; + positionId: string; + borrowAmount: string; + loanDuration: string; + numberOfInstallments: string; + blockNumber?: string; + }): Promise<{ + success: boolean; + message: string; + position: any; + }> { + try { + console.log('đŸ“ĸ Notifying backend of loan borrow:', request); + + const response = await this.fetchWithTimeout( + `${this.baseURL}/solvency/loan/borrow-notify`, + { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify(request), + }, + 60000 // 60 second timeout + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to notify loan borrow'); + } + + const data = await response.json(); + console.log('✅ Loan borrow notification successful:', data); + return data; + } catch (error: any) { + console.error('❌ Error notifying loan borrow:', error); + throw error; + } + } + + /** + * Notify backend of loan repayment transaction + * + * ✅ ENDPOINT: POST /solvency/loan/repay-notify + * Reference: LOAN_SYNC_IMPLEMENTATION.md + * + * Call after successful repayment transaction to sync backend database + */ + async notifyLoanRepayment(request: { + txHash: string; + positionId: string; + repaymentAmount: string; + blockNumber?: string; + }): Promise<{ + success: boolean; + message: string; + position: any; + }> { + try { + console.log('đŸ“ĸ Notifying backend of loan repayment:', request); + + const response = await this.fetchWithTimeout( + `${this.baseURL}/solvency/loan/repay-notify`, + { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify(request), + }, + 60000 // 60 second timeout + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to notify loan repayment'); + } + + const data = await response.json(); + console.log('✅ Loan repayment notification successful:', data); + return data; + } catch (error: any) { + console.error('❌ Error notifying loan repayment:', error); + throw error; + } + } + + + async notifyCollateralWithdrawal(request: { + positionId: string; + amount: string; + }): Promise<{ + success: boolean; + message: string; + position: any; + }> { + try { + console.log('đŸ“ĸ Notifying backend of withdrawal', request); + + const response = await this.fetchWithTimeout( + `${this.baseURL}/solvency/loan/withdrawal-notify`, + { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify(request), + }, + 60000 // 60 second timeout + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to notify loan repayment'); + } + + const data = await response.json(); + console.log('✅ Loan repayment notification successful:', data); + return data; + } catch (error: any) { + console.error('❌ Error notifying loan repayment:', error); + throw error; + } + } } export const solvencyService = new SolvencyService(); diff --git a/src/lib/utils/solvency-adapter.util.ts b/src/lib/utils/solvency-adapter.util.ts index b8d1690..574b409 100644 --- a/src/lib/utils/solvency-adapter.util.ts +++ b/src/lib/utils/solvency-adapter.util.ts @@ -61,6 +61,10 @@ export function adaptCreditResponse( * Convert new Position to old CollateralPosition format */ export function adaptPosition(position: Position): CollateralPosition { + if (!position.collateralToken) { + throw new Error('Position missing collateralToken'); + } + const tokenValueUSD = parseInt(position.tokenValueUSD) / 1_000_000; const collateralAmount = parseInt(position.collateralAmount); @@ -80,14 +84,22 @@ export function adaptPosition(position: Position): CollateralPosition { * Health Factor = (Collateral Value × LTV) / Debt */ export function calculateHealthFactor(position: Position): number { + if (!position.collateralToken) { + throw new Error('Position missing collateralToken'); + } + + if (!position.outstandingDebt) { + return 10000; // 10000% if no debt + } + const collateralValue = parseInt(position.tokenValueUSD) / 1_000_000; const debt = parseInt(position.outstandingDebt) / 1_000_000; - + if (debt === 0) return 10000; // 10000% if no debt - + const ltv = position.collateralToken.type === 'RWA' ? 0.70 : 0.60; const maxBorrow = collateralValue * ltv; - + return Math.floor((maxBorrow / debt) * 100); } diff --git a/src/pages/admin/loans/LoansView.page.tsx b/src/pages/admin/loans/LoansView.page.tsx index cbcc82c..505cedc 100644 --- a/src/pages/admin/loans/LoansView.page.tsx +++ b/src/pages/admin/loans/LoansView.page.tsx @@ -24,6 +24,21 @@ export function LoansView() { const [processingId, setProcessingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + // Confirmation modal state + const [confirmModal, setConfirmModal] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + isDangerous?: boolean; + }>({ + isOpen: false, + title: '', + message: '', + onConfirm: () => {}, + isDangerous: false, + }); + // Safe formatting helper for USDC values const formatUSDC = (value: string | null | undefined): string => { if (!value || value === '0') return '$0.00'; @@ -100,112 +115,150 @@ export function LoansView() { // Admin Operation: Mark Missed Payment const handleMarkMissedPayment = async (positionId: number) => { - if (!confirm(`Mark payment as missed for position #${positionId}?`)) return; - - setProcessingId(positionId); - setError(null); - setSuccessMessage(null); - - try { - const result = await solvencyService.markMissedPayment(positionId); - setSuccessMessage(`✅ Missed payment marked! TX: ${result.txHash?.slice(0, 10)}...`); - await fetchPositions(); - } catch (err: any) { - setError(err.message || 'Failed to mark missed payment'); - } finally { - setProcessingId(null); - } + setConfirmModal({ + isOpen: true, + title: 'Mark Missed Payment', + message: `Are you sure you want to mark payment as missed for position #${positionId}?`, + isDangerous: false, + onConfirm: async () => { + setConfirmModal({ ...confirmModal, isOpen: false }); + setProcessingId(positionId); + setError(null); + setSuccessMessage(null); + + try { + // Step 1: Execute blockchain transaction + const result = await solvencyService.markMissedPayment(positionId); + + // Step 2: Sync backend with blockchain to update missed payments count + await solvencyService.adminSyncPosition(positionId); + + // Step 3: Refresh UI with updated data + await fetchPositions(); + + setSuccessMessage(`✅ Missed payment marked! TX: ${result.txHash?.slice(0, 10)}...`); + } catch (err: any) { + setError(err.message || 'Failed to mark missed payment'); + } finally { + setProcessingId(null); + } + }, + }); }; // Admin Operation: Mark Defaulted const handleMarkDefaulted = async (positionId: number) => { - if (!confirm(`âš ī¸ Mark position #${positionId} as DEFAULTED? This action is irreversible.`)) return; - - setProcessingId(positionId); - setError(null); - setSuccessMessage(null); - - try { - const result = await solvencyService.markDefaulted(positionId); - setSuccessMessage(`✅ Position marked as defaulted! TX: ${result.txHash?.slice(0, 10)}...`); - await fetchPositions(); - } catch (err: any) { - setError(err.message || 'Failed to mark as defaulted'); - } finally { - setProcessingId(null); - } + setConfirmModal({ + isOpen: true, + title: 'Mark Position as Defaulted', + message: `Are you sure you want to mark position #${positionId} as DEFAULTED? This action is irreversible.`, + isDangerous: true, + onConfirm: async () => { + setConfirmModal({ ...confirmModal, isOpen: false }); + setProcessingId(positionId); + setError(null); + setSuccessMessage(null); + + try { + // Step 1: Execute blockchain transaction + const result = await solvencyService.markDefaulted(positionId); + + // Step 2: Sync backend with blockchain to update position status + await solvencyService.adminSyncPosition(positionId); + + // Step 3: Refresh UI with updated data + await fetchPositions(); + + setSuccessMessage(`✅ Position marked as defaulted! TX: ${result.txHash?.slice(0, 10)}...`); + } catch (err: any) { + setError(err.message || 'Failed to mark as defaulted'); + } finally { + setProcessingId(null); + } + }, + }); }; // Admin Operation: Liquidate Position const handleLiquidate = async (positionId: number) => { - if (!confirm(`âš ī¸ Liquidate position #${positionId}? Collateral will be transferred to YieldVault.`)) return; - - setProcessingId(positionId); - setError(null); - setSuccessMessage(null); - - try { - const result = await solvencyService.liquidatePosition(positionId); - setSuccessMessage( - `✅ Position liquidated! TX: ${result.txHash?.slice(0, 10)}... | ` + - `Marketplace ID: ${result.marketplaceAssetId?.slice(0, 10)}...` - ); - await fetchPositions(); - } catch (err: any) { - setError(err.message || 'Failed to liquidate position'); - } finally { - setProcessingId(null); - } + setConfirmModal({ + isOpen: true, + title: 'Liquidate Position', + message: `Are you sure you want to liquidate position #${positionId}? Collateral will be transferred to YieldVault.`, + isDangerous: true, + onConfirm: async () => { + setConfirmModal({ ...confirmModal, isOpen: false }); + setProcessingId(positionId); + setError(null); + setSuccessMessage(null); + + try { + // Step 1: Execute blockchain transaction + const result = await solvencyService.liquidatePosition(positionId); + + // Step 2: Sync backend with blockchain to update position status + await solvencyService.adminSyncPosition(positionId); + + // Step 3: Refresh UI with updated data + await fetchPositions(); + + setSuccessMessage( + `✅ Position liquidated! TX: ${result.txHash?.slice(0, 10)}... | ` + + `Marketplace ID: ${result.marketplaceAssetId?.slice(0, 10)}...` + ); + } catch (err: any) { + setError(err.message || 'Failed to liquidate position'); + } finally { + setProcessingId(null); + } + }, + }); }; // Admin Operation: Settle Liquidation const handleSettleLiquidation = async (positionId: number) => { - if (!confirm(`Settle liquidation for position #${positionId}? This will burn tokens and distribute yield.`)) return; - - setProcessingId(positionId); - setError(null); - setSuccessMessage(null); - - try { - const result = await solvencyService.settleLiquidation(positionId); - - const yieldReceived = result.yieldReceived - ? parseFloat(ethers.formatUnits(result.yieldReceived, 6)) - : 0; - const debtRepaid = result.debtRepaid - ? parseFloat(ethers.formatUnits(result.debtRepaid, 6)) - : 0; - const userRefund = result.userRefund - ? parseFloat(ethers.formatUnits(result.userRefund, 6)) - : 0; - - setSuccessMessage( - `✅ Liquidation settled! TX: ${result.txHash?.slice(0, 10)}... | ` + - `Yield: $${yieldReceived.toFixed(2)} | Debt: $${debtRepaid.toFixed(2)} | Refund: $${userRefund.toFixed(2)}` - ); - await fetchPositions(); - } catch (err: any) { - setError(err.message || 'Failed to settle liquidation'); - } finally { - setProcessingId(null); - } - }; - - // Admin Operation: Manual Sync Position - const handleSyncPosition = async (positionId: number) => { - setProcessingId(positionId); - setError(null); - setSuccessMessage(null); - - try { - await solvencyService.adminSyncPosition(positionId); - setSuccessMessage(`✅ Position #${positionId} synced successfully!`); - await fetchPositions(); - } catch (err: any) { - setError(err.message || 'Failed to sync position'); - } finally { - setProcessingId(null); - } + setConfirmModal({ + isOpen: true, + title: 'Settle Liquidation', + message: `Are you sure you want to settle liquidation for position #${positionId}? This will burn tokens and distribute yield.`, + isDangerous: false, + onConfirm: async () => { + setConfirmModal({ ...confirmModal, isOpen: false }); + setProcessingId(positionId); + setError(null); + setSuccessMessage(null); + + try { + // Step 1: Execute blockchain transaction + const result = await solvencyService.settleLiquidation(positionId); + + // Step 2: Sync backend with blockchain to update position status + await solvencyService.adminSyncPosition(positionId); + + // Step 3: Refresh UI with updated data + await fetchPositions(); + + const yieldReceived = result.yieldReceived + ? parseFloat(ethers.formatUnits(result.yieldReceived, 6)) + : 0; + const debtRepaid = result.debtRepaid + ? parseFloat(ethers.formatUnits(result.debtRepaid, 6)) + : 0; + const userRefund = result.userRefund + ? parseFloat(ethers.formatUnits(result.userRefund, 6)) + : 0; + + setSuccessMessage( + `✅ Liquidation settled! TX: ${result.txHash?.slice(0, 10)}... | ` + + `Yield: $${yieldReceived.toFixed(2)} | Debt: $${debtRepaid.toFixed(2)} | Refund: $${userRefund.toFixed(2)}` + ); + } catch (err: any) { + setError(err.message || 'Failed to settle liquidation'); + } finally { + setProcessingId(null); + } + }, + }); }; // UI Helper: Status Badge @@ -227,20 +280,6 @@ export function LoansView() { ); }; - // UI Helper: Health Status Badge - const getHealthStatusBadge = (healthStatus: string | undefined) => { - if (!healthStatus) return null; - - if (healthStatus === 'HEALTHY') { - return Healthy; - } else if (healthStatus === 'WARNING') { - return Warning; - } else if (healthStatus === 'CRITICAL' || healthStatus === 'LIQUIDATABLE') { - return Critical; - } - - return {healthStatus}; - }; // Calculate statistics const stats = { @@ -382,7 +421,6 @@ export function LoansView() {
Collateral Borrowed OutstandingHealth Factor Missed Payments Status Actions
#{position.positionId} @@ -409,27 +448,12 @@ export function LoansView() { {position.outstandingDebtFormatted} -
- - {position.healthFactorFormatted} - - {getHealthStatusBadge(position.healthStatus)} -
-
0 ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600' }`}> - {position.missedPayments} / 3 + {Math.min(position.missedPayments, 3)} / 3 @@ -464,7 +488,7 @@ export function LoansView() { )} {/* Liquidate - Available for liquidatable positions */} - {position.healthStatus === 'LIQUIDATABLE' && position.status === 'ACTIVE' && ( + {( +
@@ -509,6 +525,49 @@ export function LoansView() { )}
+ + {/* Confirmation Modal */} + {confirmModal.isOpen && ( +
+
+ {/* Header */} +
+

+ {confirmModal.title} +

+
+ + {/* Body */} +
+

+ {confirmModal.message} +

+
+ + {/* Footer */} +
+ + +
+
+
+ )} ); } diff --git a/src/pages/admin/payout/PayoutView.page.tsx b/src/pages/admin/payout/PayoutView.page.tsx index 29e4332..11b5f04 100644 --- a/src/pages/admin/payout/PayoutView.page.tsx +++ b/src/pages/admin/payout/PayoutView.page.tsx @@ -68,7 +68,7 @@ const PayoutViewPage = () => { // Filter for LISTED, ENDED, or AUCTION_DECLARED assets const filteredAssets = allAssets.filter((asset: any) => - ['LISTED', 'ENDED', 'AUCTION_DECLARED'].includes(asset.status) + ['LISTED', 'AUCTION_DECLARED'].includes(asset.status) ); console.log('Filtered assets for payout:', filteredAssets); @@ -143,6 +143,11 @@ const PayoutViewPage = () => { console.log(`Executing payout for asset: ${assetId}`); + if (assets.find(asset => asset.assetId === assetId)?.status && ['PAYOUT_COMPLETE', 'ENDED'].includes(assets.find(asset => asset.assetId === assetId)?.status || '')) { + console.log('Payout already executed for this asset.'); + return; + } + const result = await adminService.executePayout(assetId); console.log('Payout result:', result); diff --git a/src/pages/admin/settlements/SettlementView.page.tsx b/src/pages/admin/settlements/SettlementView.page.tsx index 860bb35..9405178 100644 --- a/src/pages/admin/settlements/SettlementView.page.tsx +++ b/src/pages/admin/settlements/SettlementView.page.tsx @@ -68,7 +68,7 @@ const SettlementViewPage = () => { useEffect(() => { fetchAdminDashboardData(); - fetchAllSettlements(); + // fetchAllSettlements(); }, [fetchAdminDashboardData]); const fetchAllSettlements = async () => { @@ -919,38 +919,6 @@ const SettlementViewPage = () => { -
-

- Distribution Results -

-
-
- Total Distributed: -

- {formatUSDC(distributionResults.totalDistributed)} USDC -

-
-
- Token Holders: -

- {distributionResults.holders} -

-
-
- Total Token-Days: -

- {parseFloat(distributionResults.totalTokenDays).toLocaleString()} -

-
-
- Effective Yield: -

- {distributionResults.effectiveYield} -

-
-
-
-
diff --git a/src/pages/borrow/BorrowPage.tsx b/src/pages/borrow/BorrowPage.tsx index 5219b37..48087b7 100644 --- a/src/pages/borrow/BorrowPage.tsx +++ b/src/pages/borrow/BorrowPage.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { useAccount, useDisconnect } from 'wagmi'; import { useNavigate } from 'react-router-dom'; -// import Navbar from '../../components/common/Navbar'; + import { UnifiedBorrowModal } from './components/UnifiedBorrowModal'; import { useCreditData } from './hooks/useCreditData'; -// import { formatUSD } from '../../utils/solvency/format-credit.util'; + import { Button } from '../../components/ui/button'; import { Loader2 } from 'lucide-react'; import { NotificationBell } from '../../components/notifications/NotificationBell'; @@ -166,7 +166,7 @@ const BorrowPage = () => {
  1. Go to your Portfolio page
  2. Click "Increase Credit" or "Deposit Collateral"
  3. -
  4. Deposit RWA or Private Asset tokens
  5. +
  6. Deposit RWA tokens
  7. Your credit limit will increase automatically
  8. Return here to borrow against your credit
diff --git a/src/pages/borrow/components/DepositCollateralModal.tsx b/src/pages/borrow/components/DepositCollateralModal.tsx index 68bd15f..6ee16b9 100644 --- a/src/pages/borrow/components/DepositCollateralModal.tsx +++ b/src/pages/borrow/components/DepositCollateralModal.tsx @@ -10,11 +10,14 @@ import { useState, useEffect, useMemo } from 'react'; import { useAccount } from 'wagmi'; import { ethers } from 'ethers'; import { useCallback } from 'react'; +import { X } from 'lucide-react'; import { portfolioService, type PortfolioAsset } from '../../../lib/api/portfolio.service'; import { assetService } from '../../../lib/api/asset.service'; import { solvencyContractService } from '../../../lib/api/solvency-contract.service'; import { solvencyService } from '../../../lib/api/solvency.service'; import type { IssuerAsset } from '../../../types/issuer.types'; +import { marketplaceService } from '../../../lib/api/marketplace.service'; +import { PageLoader } from '../../../components/ui/page-loader'; interface DepositCollateralModalProps { isOpen: boolean; @@ -264,8 +267,17 @@ export const DepositCollateralModal = ({ setStep('syncing'); console.log('🔄 Step 3/3: Syncing with platform...'); + // Sync portfolio (notify purchase) + await marketplaceService.notifyPurchase({ + assetId: asset.assetId, + txHash: depositResult.txHash!, + amount: `-${depositAmount}`, + blockNumber: depositResult.blockNumber!.toString(), + }); + + // Sync position with solvency system await solvencyService.syncPosition({ - positionId: depositResult.positionId, + positionId: depositResult.positionId!.toString(), txHash: depositResult.txHash!, blockNumber: depositResult.blockNumber!, }); @@ -300,282 +312,311 @@ export const DepositCollateralModal = ({ if (!isOpen) return null; return ( -
-
- {/* Header */} -
-
-

- {initialAsset ? 'Add More Collateral' : 'Deposit Collateral'} -

-

- {initialAsset - ? `Add more ${initialAsset?.metadata.assetName} to your position.` - : 'Deposit RWA tokens to create a credit line'} -

-
- {!isProcessing && ( - - )} +
+
+ {/* Close Button */} + {!isProcessing && step === 'select' && ( + + )} + + {/* Header */} +
+
+ 💰 +
+

+ {initialAsset ? 'Add More Collateral' : 'Deposit Collateral'} +

+

+ {initialAsset + ? `Add more ${initialAsset?.metadata.assetName} to your position` + : 'Deposit RWA tokens to create a credit line'} +

+ {/* Content */} +
+ {/* Loading State */} {isLoading && (
-
-

Loading your portfolio...

+
+ +
)} - {/* Error State */} - {error && !isLoading && ( -
-
- - - -

{error}

+ {/* Error State */} + {error && !isLoading && ( +
+ Something went wrong ! Please try again.
-
- )} + )} - {/* Select Asset Step */} - {step === 'select' && !isLoading && (portfolio.length > 0 || initialAsset) && ( -
- {/* Asset Selection */} - {!initialAsset && ( -
- - -
- )} - - {/* Asset Details */} - {selectedAsset && assetDetails && ( - <> - {/* Token Balance */} -
-
- Your Balance - - {parseFloat(tokenBalance).toFixed(2)} tokens - -
-
- Token Price - - ${(parseFloat(assetDetails.listing.price) / 1e6).toFixed(6)} per token - + {/* Select Asset Step */} + {step === 'select' && !isLoading && (portfolio.length > 0 || initialAsset) && ( +
+ {/* Asset Selection - Table View (only show when no asset selected) */} + {!initialAsset && !selectedAsset && ( +
+ +
+
+ + + + + + + + + {portfolio.map((asset) => ( + asset.status !== 'CLAIMED' && asset.purchaseType !== 'LEVERAGE' && !asset.yieldInfo?.settlementDistributed && ( + handleAssetSelect(asset)} + className="cursor-pointer transition-all hover:bg-gray-200/70" + > + + + + ) + ))} + +
+ Asset + + Balance +
+
+
+ đŸ›ī¸ +
+ + {asset.metadata.assetName} + +
+
+ + {parseFloat(ethers.formatUnits(asset.totalAmount, 18)).toFixed(2)} + + + tokens + +
+
+ )} - {/* Deposit Amount */} -
- -
- setDepositAmount(e.target.value)} - placeholder="0.00" - className="w-full px-4 py-3 pr-20 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#111111] focus:border-transparent" - min="0" - max={tokenBalance} - step="0.01" - /> + {/* Selected Asset Display (show after selection) */} + {!initialAsset && selectedAsset && ( +
+
+
+
+ đŸ›ī¸ +
+
+

+ {selectedAsset.metadata.assetName} +

+

+ {parseFloat(ethers.formatUnits(selectedAsset.totalAmount, 18)).toFixed(2)} tokens available +

+
+
- {depositError && ( -

{depositError}

- )}
+ )} + + {/* Asset Details */} + {selectedAsset && assetDetails && ( + <> + {/* Token Balance */} +
+
+ Your Balance + + {parseFloat(tokenBalance).toFixed(2)} tokens + +
+
+ Token Price + + ${(parseFloat(assetDetails.listing.price) / 1e6).toFixed(6)} per token + +
+
+ + {/* Deposit Amount */} +
+ +
+ setDepositAmount(e.target.value)} + placeholder="0.00" + className="w-full bg-gray-100/50 border border-neutral-200 shadow-sm rounded-xl px-4 py-4 pr-20 font-gellix text-sm text-foreground transition-all placeholder:text-gray-400 hover:bg-gray-100" + min="0" + max={tokenBalance} + step="0.01" + /> + +
+ {depositError && ( +

{depositError}

+ )} +
- {/* Collateral Preview */} - {depositAmount && parseFloat(depositAmount) > 0 && !depositError && ( -
-

- Credit Line Preview -

-
-
- Collateral Value - + {/* Collateral Preview */} + {depositAmount && parseFloat(depositAmount) > 0 && !depositError && ( +
+
+

Collateral Value

+

${parseFloat(collateralValueUSD).toFixed(2)} - -

-
- LTV Ratio - 70% +

-
-
- Estimated Credit Line - - ${parseFloat(estimatedCreditLine).toFixed(2)} - -
+
+

Estimated Credit Line (70% LTV)

+

+ ${parseFloat(estimatedCreditLine).toFixed(2)} +

-

- You'll be able to borrow up to ${parseFloat(estimatedCreditLine).toFixed(2)} USDC + )} + + {/* Important Notice */} +

+

+ âš ī¸ Important: Your tokens will be locked as collateral. Maintain health factor above 110% to avoid liquidation. You can withdraw after repaying your loan.

- )} - - {/* Important Notice */} -
-
- - - -
-

- Important Information -

-
    -
  • â€ĸ Your tokens will be locked as collateral
  • -
  • â€ĸ Maintain health factor above 110% to avoid liquidation
  • -
  • â€ĸ You can withdraw after repaying your loan
  • -
-
-
-
- {/* Action Buttons */} -
- - -
- - )} -
- )} - - {/* Approval Progress */} - {step === 'approve' && ( -
-
-

- Step 1 of 3: Approving Tokens -

-

- Please confirm the approval transaction in your wallet -

- {txHash && ( - - View transaction → - - )} -
- )} + + )} +
+ )} - {/* Deposit Progress */} - {step === 'deposit' && ( -
-
-

- Step 2 of 3: Depositing Collateral -

-

- Please confirm the deposit transaction in your wallet -

-

- This may take up to 5 minutes... -

- {txHash && ( - - View transaction → - - )} -
- )} + {/* Approval Progress */} + {step === 'approve' && ( +
+
+
+ +

Step 1 of 3: Approving Tokens

+
+
+ {txHash && ( + + View transaction → + + )} +
+ )} - {/* Syncing Progress */} - {step === 'syncing' && ( -
-
-

- Step 3 of 3: Syncing with Platform -

-

- Updating your credit line... -

-
- )} + {/* Deposit Progress */} + {step === 'deposit' && ( +
+
+
+ +

Step 2 of 3: Depositing Collateral

+
+
+ {txHash && ( + + View transaction → + + )} +
+ )} - {/* Success State */} - {step === 'success' && ( -
-
- - - + {/* Syncing Progress */} + {step === 'syncing' && ( +
+
+
+ +

Step 3 of 3: Syncing with Platform

+
+
-

- Deposit Successful! -

-

- Your collateral has been deposited and credit line created -

-
-
-

Credit Line Created

-

- ${parseFloat(estimatedCreditLine).toFixed(2)} -

+ )} + + {/* Success State */} + {step === 'success' && ( +
+
+ ✅ +
+

Deposit Successful!

+

Your collateral has been deposited and credit line created

+
+
+

Credit Line Created

+

+ ${parseFloat(estimatedCreditLine).toFixed(2)} +

+
+ )} +
+ + {/* Footer */} + {step === 'select' && selectedAsset && assetDetails && ( +
+ + +
)}
diff --git a/src/pages/borrow/components/DirectBorrowModal.tsx b/src/pages/borrow/components/DirectBorrowModal.tsx index 7d22d5b..5453619 100644 --- a/src/pages/borrow/components/DirectBorrowModal.tsx +++ b/src/pages/borrow/components/DirectBorrowModal.tsx @@ -46,14 +46,14 @@ export const DirectBorrowModal = ({ const formatted = useMemo(() => { const collateralValueNum = parseFloat(ethers.formatUnits(position.tokenValueUSD, 6)); const usdcBorrowedNum = parseFloat(ethers.formatUnits(position.usdcBorrowed, 6)); - const maxBorrowCapacityNum = parseFloat(ethers.formatUnits(position.maxBorrowCapacity, 6)); + const maxBorrowCapacityNum = parseFloat(ethers.formatUnits(position.maxBorrowCapacity || '0', 6)); return { collateralAmount: parseFloat(ethers.formatUnits(position.collateralAmount, 18)).toFixed(4), collateralValueUSD: collateralValueNum, usdcBorrowed: usdcBorrowedNum, availableCredit: maxBorrowCapacityNum - usdcBorrowedNum, - tokenSymbol: position.collateralToken.symbol, + tokenSymbol: position.collateralToken?.symbol || 'UNKNOWN', }; }, [position]); @@ -70,7 +70,7 @@ export const DirectBorrowModal = ({ // Calculate new health factor after borrow const newHealthFactor = useMemo(() => { if (!borrowAmount || parseFloat(borrowAmount) === 0) { - return position.healthFactor; + return position.healthFactor || 10000; // Default to 10000 if not set } const additionalDebt = parseFloat(borrowAmount); @@ -92,7 +92,7 @@ export const DirectBorrowModal = ({ }, [borrowAmount, formatted.availableCredit]); const isHealthFactorSafe = useMemo(() => { - return newHealthFactor >= 11000; // Must be >= 110% + return (newHealthFactor || 0) >= 11000; // Must be >= 110% }, [newHealthFactor]); const canBorrow = isValidAmount && isHealthFactorSafe; @@ -109,6 +109,9 @@ export const DirectBorrowModal = ({ // Step 1: Fetch asset details to calculate loan duration // Per COMPLETE_LOAN.md: GET /assets/token/:tokenAddress to get maturity date + if (!position.collateralToken?.address) { + throw new Error('Position missing collateral token information'); + } const asset = await assetService.getAssetByTokenAddress(position.collateralToken.address); // Step 2: Calculate loan duration from asset maturity date @@ -141,20 +144,23 @@ export const DirectBorrowModal = ({ console.log('✅ Borrow successful:', borrowResult.txHash); setTxHash(borrowResult.txHash!); - // Step 5: Sync position with backend + // Step 5: Notify backend of loan borrow setStep('syncing'); - console.log('🔄 Syncing position with backend...'); + console.log('🔄 Notifying backend of loan borrow...'); try { - await solvencyService.syncPosition({ - positionId: position.positionId.toString(), + await solvencyService.notifyLoanBorrow({ txHash: borrowResult.txHash!, - blockNumber: borrowResult.blockNumber!, + positionId: position.positionId.toString(), + borrowAmount: amountWei.toString(), + loanDuration: loanDuration.toString(), + numberOfInstallments: numberOfInstallments.toString(), + blockNumber: borrowResult.blockNumber?.toString(), }); - console.log('✅ Position synced with backend'); + console.log('✅ Backend notified of loan borrow'); } catch (syncError) { // Non-blocking: Events will still sync it automatically - console.warn('âš ī¸ Manual sync failed (events will auto-sync):', syncError); + console.warn('âš ī¸ Manual notification failed (events will auto-sync):', syncError); } // Success! @@ -243,10 +249,10 @@ export const DirectBorrowModal = ({
Current Health Factor - {(position.healthFactor / 100).toFixed(2)}% + {((position.healthFactor || 10000) / 100).toFixed(2)}%
- +
@@ -299,17 +305,17 @@ export const DirectBorrowModal = ({
New Health Factor = 15000 ? 'text-[#00A878]' : - newHealthFactor >= 11000 ? 'text-[#F59E0B]' : + (newHealthFactor || 0) >= 15000 ? 'text-[#00A878]' : + (newHealthFactor || 0) >= 11000 ? 'text-[#F59E0B]' : 'text-[#EF4444]' }`}> - {(newHealthFactor / 100).toFixed(2)}% + {((newHealthFactor || 0) / 100).toFixed(2)}%
- +
{/* Health Factor Warning */} diff --git a/src/pages/borrow/components/UnifiedBorrowModal.tsx b/src/pages/borrow/components/UnifiedBorrowModal.tsx index 808dc73..68dcdab 100644 --- a/src/pages/borrow/components/UnifiedBorrowModal.tsx +++ b/src/pages/borrow/components/UnifiedBorrowModal.tsx @@ -10,12 +10,13 @@ import { formatCollateralAmount } from '../../../utils/solvency/formatters'; interface BorrowOnlyModalProps { isOpen: boolean; - onClose: () => void; + onClose: () => void; + onSuccess: () => void; creditData: OAIDCreditLine | null; } -export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: BorrowOnlyModalProps) => { +export const UnifiedBorrowModal = ({ isOpen, onSuccess, creditData}: BorrowOnlyModalProps) => { const [borrowAmount, setBorrowAmount] = useState(''); const [selectedPosition, setSelectedPosition] = useState(null); const [showPositionSelector, setShowPositionSelector] = useState(false); @@ -24,21 +25,123 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B const [isBorrowing, setIsBorrowing] = useState(false); const [error, setError] = useState(null); const [selectedAsset, setSelectedAsset] = useState(null); + const [assetDetailsMap, setAssetDetailsMap] = useState>({}); + const [positionValidation, setPositionValidation] = useState>({}); const availableCredit = creditData?.availableCredit ?? 0; const positions = creditData?.collateral ?? []; - // Auto-select first position + // Validate all positions on open useEffect(() => { - if (isOpen && positions.length > 0 && !selectedPosition) { - setSelectedPosition(positions[0]); - } + const validatePositions = async () => { + if (isOpen && positions.length > 0) { + const validationMap: Record = {}; + + await Promise.all(positions.map(async (position) => { + try { + // Check if position has a positionId + if (!position.positionId) { + validationMap[position.positionId ?? 0] = { valid: false, reason: 'Invalid position ID' }; + return; + } + + // Get position data from contract + const positionData = await solvencyContractService.getPosition(position.positionId); + + if (!positionData) { + validationMap[position.positionId] = { valid: false, reason: 'Position not found' }; + return; + } + + // Check if position is active + if (!positionData.active) { + validationMap[position.positionId] = { valid: false, reason: 'Position not active' }; + return; + } + + // Get repayment plan + const plan = await solvencyContractService.getRepaymentPlan(position.positionId); + + // Check if plan already exists and is active + if (plan && plan.isActive) { + validationMap[position.positionId] = { valid: false, reason: 'Active loan exists - must fully repay first' }; + return; + } + + // Get outstanding debt + const outstandingDebt = await solvencyContractService.getOutstandingDebt(position.positionId); + + // Calculate available credit + const ltv = positionData.tokenType === 0 ? 7000 : 6000; // RWA: 70%, PRIVATE_ASSET: 60% + const maxBorrow = (BigInt(positionData.tokenValueUSD) * BigInt(ltv)) / BigInt(10000); + const availableCredit = maxBorrow - outstandingDebt; + + if (availableCredit <= 0) { + validationMap[position.positionId] = { valid: false, reason: 'No available credit - LTV limit reached' }; + return; + } + + // Position is valid + validationMap[position.positionId] = { valid: true }; + } catch (err) { + console.error(`Failed to validate position ${position.positionId}:`, err); + validationMap[position.positionId ?? 0] = { valid: false, reason: 'Failed to validate position' }; + } + })); + + setPositionValidation(validationMap); + + // Auto-select first valid position + if (!selectedPosition) { + const firstValid = positions.find(p => validationMap[p.positionId ?? 0]?.valid); + if (firstValid) { + setSelectedPosition(firstValid); + } + } + } + }; + + validatePositions(); }, [isOpen, positions, selectedPosition]); - // Fetch asset details when position changes + + useEffect(() => { + if (!error) return; + + const timer = setTimeout(() => { + setError(null); + }, 5000); // ⏱ 5 seconds + + return () => clearTimeout(timer); + }, [error]); + // Fetch asset details for all positions + useEffect(() => { + const fetchAllAssetDetails = async () => { + if (isOpen && positions.length > 0) { + const detailsMap: Record = {}; + await Promise.all(positions.map(async (position) => { + try { + const assetDetails = await assetService.getAssetByTokenAddress(position.tokenAddress); + if (assetDetails) { + detailsMap[position.tokenAddress] = assetDetails; + } + } catch (err) { + console.error(`Failed to fetch asset details for ${position.tokenAddress}:`, err); + } + })); + setAssetDetailsMap(detailsMap); + } + }; + fetchAllAssetDetails(); + }, [isOpen, positions]); + + // Set selected asset from map when position changes useEffect(() => { - const fetchAssetDetails = async () => { - if (selectedPosition) { + if (selectedPosition && assetDetailsMap[selectedPosition.tokenAddress]) { + setSelectedAsset(assetDetailsMap[selectedPosition.tokenAddress]); + } else if (selectedPosition) { + // Fallback to fetch if not in map (should be rare) + const fetchAssetDetails = async () => { try { const assetDetails = await assetService.getAssetByTokenAddress(selectedPosition.tokenAddress); setSelectedAsset(assetDetails); @@ -46,10 +149,12 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B console.error("Failed to fetch asset details:", err); setSelectedAsset(null); } - } - }; - fetchAssetDetails(); - }, [selectedPosition]); + }; + fetchAssetDetails(); + } else { + setSelectedAsset(null); + } + }, [selectedPosition, assetDetailsMap]); // Validate installments const { loanDuration, installmentError } = useMemo(() => { @@ -58,15 +163,29 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B } try { const maxDuration = assetService.calculateLoanDuration(selectedAsset); - const requestedDuration = installments * 30 * 86400; + const requestedDuration = installments * 86400; // Assuming 30 days per installment + + if (installments > 24) { + return { loanDuration: 0, installmentError: 'Maximum 24 installments allowed.' }; + } + if (installments < 1) { + return { loanDuration: 0, installmentError: 'Minimum 1 installment required.' }; + } if (requestedDuration > maxDuration) { + const maxInstallments = Math.floor(maxDuration / (86400)); + if (maxInstallments === 0) { + return { + loanDuration: 0, + installmentError: `This asset has a very short repayment time, so a loan cannot be taken with this position.` + }; + } return { - loanDuration: maxDuration, - installmentError: `Max ${Math.floor(maxDuration / (30 * 86400))} monthly payments allowed` + loanDuration: 0, + installmentError: `This asset only allows up to ${maxInstallments} installments.` }; } - return { loanDuration: maxDuration, installmentError: null }; + return { loanDuration: requestedDuration, installmentError: null }; } catch (err: any) { return { loanDuration: 0, installmentError: err.message }; } @@ -76,12 +195,14 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B const filteredPositions = useMemo(() => { if (!searchQuery) return positions; const query = searchQuery.toLowerCase(); - return positions.filter(p => - p.tokenSymbol.toLowerCase().includes(query) || - p.tokenAddress.toLowerCase().includes(query) || - p.tokenName.toLowerCase().includes(query) - ); - }, [positions, searchQuery]); + return positions.filter(p => { + const asset = assetDetailsMap[p.tokenAddress]; + const name = asset?.metadata.invoiceNumber || p.tokenName; + return name.toLowerCase().includes(query) || + p.tokenSymbol.toLowerCase().includes(query) || + p.tokenAddress.toLowerCase().includes(query) + }); + }, [positions, searchQuery, assetDetailsMap]); // Validation const isAmountInvalid = useMemo(() => { @@ -113,10 +234,13 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B throw new Error(borrowResult.error || 'Borrow transaction failed'); } - await solvencyService.syncPosition({ - positionId: String(selectedPosition.positionId), + await solvencyService.notifyLoanBorrow({ txHash: borrowResult.txHash!, - blockNumber: borrowResult.blockNumber!, + positionId: String(selectedPosition.positionId), + borrowAmount: amountWei.toString(), + loanDuration: loanDuration.toString(), + numberOfInstallments: installments.toString(), + blockNumber: borrowResult.blockNumber?.toString(), }); onSuccess(); @@ -141,12 +265,7 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B {/* Main Borrow Interface - Clean Swap Style */}
{/* Close Button */} - + {/* Collateral Section (Top - Like "Sell") */}
@@ -163,9 +282,9 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B {selectedPosition ? ( <>
- {selectedPosition.tokenSymbol.charAt(0)} + {(selectedAsset?.metadata.invoiceNumber || selectedPosition.tokenSymbol).charAt(0)}
- {selectedPosition.tokenSymbol} + {selectedAsset?.metadata.invoiceNumber || selectedPosition.tokenSymbol} ) : ( @@ -243,28 +362,45 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B {/* Installments - Minimal Design */}
- Repayment period + Number of Installments
setInstallments(parseInt(e.target.value, 10) || 1)} + onChange={(e) => { + let value = parseInt(e.target.value); + if (isNaN(value)) value = 0; + if (value > 24) value = 24; + if (value < 1) value = 0; + setInstallments(value); + }} min="1" + max="24" disabled={isBorrowing || !selectedAsset} - className="w-16 px-3 py-2 text-right font-semibold text-gray-900 bg-gray-50 rounded-lg border-none outline-none focus:ring-2 focus:ring-blue-500" + className="w-20 px-3 py-2 text-right font-semibold text-gray-900 bg-gray-50 rounded-lg border-none outline-none focus:ring-2 focus:ring-blue-500" /> - months
{installmentError && ( -
{installmentError}
+
{installmentError}
)}
{/* Error Banner */} {error && ( -
-

{error}

+
+

+ {(() => { + // Extract revert reason from error message + const reasonMatch = error.match(/reason="([^"]+)"/); + if (reasonMatch && reasonMatch[1]) { + return reasonMatch[1]; + } + + // Fallback to showing the full error if no reason found + return error; + })()} +

)} @@ -330,35 +466,72 @@ export const UnifiedBorrowModal = ({ isOpen, onClose, onSuccess, creditData }: B
) : (
- {filteredPositions.map((position, _index) => ( - - ))} + + + {/* Tooltip showing reason on hover */} + {!isValid && reason && ( +
+
+

Cannot borrow from this position

+

{reason}

+
+
+ )} +
+ ); + })}
)}
diff --git a/src/pages/faucet/Faucet.page.tsx b/src/pages/faucet/Faucet.page.tsx index 442aeaf..a808732 100644 --- a/src/pages/faucet/Faucet.page.tsx +++ b/src/pages/faucet/Faucet.page.tsx @@ -1,23 +1,23 @@ import { useAccount } from 'wagmi'; import { ConnectButton } from '@rainbow-me/rainbowkit'; import { TokenFaucetCard } from '../../components/faucet/TokenFaucetCard'; +import { Wavy } from '../../components/ui/wavy'; const FaucetPage = () => { const { address } = useAccount(); return ( -
+
+ {/* Top Navigation Bar - Clean Marketplace Design */} -
- - + {/* Main Content */} -
+
{/* Page Header */}

Testnet Faucet

diff --git a/src/pages/landing/About.page.tsx b/src/pages/landing/About.page.tsx index 820678b..a7c2d6b 100644 --- a/src/pages/landing/About.page.tsx +++ b/src/pages/landing/About.page.tsx @@ -88,9 +88,9 @@ export default function AboutPage() { }; return (
+ > @@ -193,7 +193,7 @@ export default function AboutPage() {
- ) : contributors.length > 0 ? ( + ) : ( // Render contributors dynamically contributors.map((contributor, index) => { const variantType = index % 2 === 0 ? revealVariants : revealVariants2; @@ -218,74 +218,6 @@ export default function AboutPage() { ); }) - ) : ( - // Fallback to default images if no contributors - <> - - Team member - - - Team member - - - Team member - - - Team member - - )}
diff --git a/src/pages/landing/Changelog.page.tsx b/src/pages/landing/Changelog.page.tsx index 60e9396..c4aa11c 100644 --- a/src/pages/landing/Changelog.page.tsx +++ b/src/pages/landing/Changelog.page.tsx @@ -131,8 +131,7 @@ const ChangelogPage: React.FC = () => { {/* Loading State */} {isLoadingMetrics && ( -
-
+
)} diff --git a/src/pages/marketplace/Marketplace.page.tsx b/src/pages/marketplace/Marketplace.page.tsx index 6b90c82..ff07435 100644 --- a/src/pages/marketplace/Marketplace.page.tsx +++ b/src/pages/marketplace/Marketplace.page.tsx @@ -140,6 +140,32 @@ const MarketplacePage = () => { .sort((a, b) => a.timestamp - b.timestamp); }; + /** + * Calculate APY (Annual Percentage Yield) for an asset + * Returns 5% APY when no tokens are sold yet + * Otherwise calculates based on purchase price vs face value and time to maturity + */ + const calculateAPY = (listing: any): number => { + const sold = parseFloat(listing.sold || '0')/1e18; + + // When no tokens are sold yet, show fixed 5% APY + if (sold === 0) { + return 5.00; + } + + const totalSupply = parseFloat(listing.totalSupply) / 1e18; + const faceValue = parseFloat(listing.faceValue)/1e6; + + if(sold> totalSupply){ + return 0; + } + + // Annualize the return (simple annualization) + const apy = ((totalSupply - sold)/sold) * faceValue * 100; + + return Math.max(0, Math.min(100, apy)); + }; + // Convert backend listings to frontend format for display // Using REAL API data with sold percentage for progress bars const displayAssets: MarketplaceAsset[] = listings.length > 0 @@ -188,7 +214,8 @@ const MarketplacePage = () => { // Target amount is face value // @ts-ignore - const targetAmount = parseFloat(listing.metadata?.faceValue || listing.faceValue || '0'); + const targetAmount = totalSupply * pricePerToken; + // const targetAmount = parseFloat(listing.metadata?.faceValue || listing.faceValue || '0'); return { id: listing.assetId, @@ -198,7 +225,7 @@ const MarketplacePage = () => { category: 'invoice' as const, icon: '📄', tokenPrice: pricePerToken, - yieldAPY: 8, // TODO: Backend needs to provide this - using default + yieldAPY: calculateAPY(listing), // Dynamically calculated APY maturityDays: maturityDays || "Matured", // Already validated with fallback to "Matured" above totalRaised: totalRaised, targetAmount: targetAmount, @@ -306,7 +333,6 @@ const MarketplacePage = () => { const filters: { value: FilterCategory; label: string }[] = [ { value: 'all', label: 'All Assets' }, { value: 'invoices', label: 'Invoices' }, - { value: 'high-yield', label: 'High Yield (>10%)' }, { value: 'short-term', label: 'Short Term (<6mo)' }, { value: 'verified', label: 'Verified' }, @@ -318,7 +344,7 @@ const MarketplacePage = () => { const numDays = typeof days === 'string' ? parseFloat(days) : days; if (numDays < 30) return `${numDays} days`; if (numDays < 365) return `${Math.floor(numDays / 30)} months`; - return `Matured`; + return `Payout Requested`; }; // Calculate time remaining for auction @@ -411,7 +437,7 @@ const MarketplacePage = () => { } return ( -
+
{/* Top Navigation Bar */}
{/* Logo */} @@ -481,7 +507,7 @@ const MarketplacePage = () => { .carousel-track { display: flex; width: max-content; /* Ensure track is as wide as content */ - animation: scroll-left 60s linear infinite; /* Slower, smoother speed */ + animation: scroll-left 20s linear infinite; /* Slower, smoother speed */ } .carousel-track:hover { animation-play-state: paused; @@ -491,8 +517,10 @@ const MarketplacePage = () => { {/* We combine all items into one list and render it TWICE to create a seamless infinite loop without gaps. */} -
- {[...Array(2)].map((_, i) => ( +
+ {/* Fade overlay left */} +
+ {[...Array(1)].map((_, i) => (
{/* SCHEDULED AUCTIONS */} @@ -553,6 +581,9 @@ const MarketplacePage = () => { ))}
))} + + {/* Fade overlay right */} +
@@ -629,10 +660,10 @@ const MarketplacePage = () => {
{/* Section 1: Active Auctions (Replaced Featured Issuances) */}
-

+

Active Auctions

-
+
{isLoadingAuctions ? (
@@ -655,7 +686,7 @@ const MarketplacePage = () => {
{auction.metadata?.invoiceNumber || auction.assetId}
-
+
{auction.totalSupply.toLocaleString()} tokens
@@ -676,7 +707,7 @@ const MarketplacePage = () => {
{index < Math.min(auctions.length, 3) - 1 && ( -
+
)}
)) @@ -686,11 +717,11 @@ const MarketplacePage = () => { {/* Section 2: Trending Assets (Highest Sold %) - Real Data from GET /marketplace/top-grossing */}
-

+

Trending Assets

-
+
{isLoadingTrending ? (
@@ -713,7 +744,7 @@ const MarketplacePage = () => {
{asset.name || asset.assetId}
-
+
{asset.industry} ¡ {asset.activityMetrics?.totalActivity || 0} activities
@@ -725,7 +756,7 @@ const MarketplacePage = () => { {asset.percentageSold?.toFixed(1) || 0}% Sold
- + {asset.activityMetrics?.purchaseCount || 0} purchases
@@ -743,10 +774,10 @@ const MarketplacePage = () => { {/* Section 3: Recently Verified Assets - Real Data from GET /marketplace/listings (Top 3, sorted by newest) */}
-

+

Recently Verified

-
+
{isLoading ? (
@@ -766,14 +797,14 @@ const MarketplacePage = () => { {/* Left: Asset Info */}
-
+
{/* @ts-ignore */} {listing.name || listing.assetId}
-
+
{/* @ts-ignore */} {listing.industry} ¡ {listing.listingType}
@@ -786,14 +817,14 @@ const MarketplacePage = () => { {/* @ts-ignore */} ${formatLargeNumber(parseFloat(listing.pricePerToken || '0') / 1e6)}
-
+
per token
{index < Math.min(listings.length, 3) - 1 && ( -
+
)}
)) @@ -870,7 +901,7 @@ const MarketplacePage = () => { {/* Grid/List Content */} {viewMode === 'grid' ? ( /* Grid View */ -
+
{isLoadingCharts && filteredAssets.length > 0 ? (
@@ -884,7 +915,7 @@ const MarketplacePage = () => { return (
handleTableNavigate(asset)} className="group relative bg-white rounded-3xl border border-gray-200 overflow-hidden cursor-pointer transition-all duration-200 hover:shadow-xl" > {/* Header Section */} @@ -935,7 +966,7 @@ const MarketplacePage = () => {
Yield - {asset.yieldAPY}% APY + {asset.yieldAPY.toFixed(2)}% APY
@@ -1036,7 +1067,7 @@ const MarketplacePage = () => { {/* Yield */}
- {asset.yieldAPY}% APY + {asset.yieldAPY.toFixed(2)}% APY