diff --git a/IMPLEMENTATION_GUIDE.md b/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..dc41524 --- /dev/null +++ b/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,279 @@ +# SEP-24 Fiat On-Ramp Implementation Guide + +## Overview + +This implementation adds Stellar SEP-24 interactive deposit flow to NovaFund, allowing users to deposit fiat currency directly into their Stellar wallets as USDC through anchor providers like MoneyGram Access. + +## What Was Implemented + +### Backend (NestJS) + +1. **SEP-24 Module** (`backend/src/sep24/`) + - `anchor.service.ts` - Communicates with SEP-24 anchor providers + - `sep24.service.ts` - Business logic for deposit management + - `sep24.controller.ts` - REST API endpoints + - `sep24.module.ts` - Module configuration + - `dto/sep24.dto.ts` - Data transfer objects + - `tasks/deposit-poll.task.ts` - Background task for polling deposit status + +2. **Database Schema** + - Added `FiatDeposit` model to Prisma schema + - Migration file: `backend/prisma/migrations/20260327000000_add_fiat_deposits/migration.sql` + +3. **API Endpoints** + - `POST /api/v1/sep24/deposit` - Initiate fiat deposit + - `GET /api/v1/sep24/deposit/:id` - Get deposit status + - `POST /api/v1/sep24/callback` - Webhook for anchor updates + +### Frontend (Next.js) + +1. **On-Ramp Page** (`frontend/src/app/onramp/page.tsx`) + - User interface for initiating deposits + - Amount input and wallet selection + - Opens anchor interactive window + - Real-time status polling with visual feedback + - Transaction completion tracking + +2. **Wallet Context** (`frontend/src/contexts/WalletContext.tsx`) + - Manages Freighter wallet connection + - Provides wallet address to components + - Persists connection state + +3. **UI Components** + - `OnRampButton.tsx` - Quick access button for navigation + - Integrated with existing NovaFund theme (dark mode, gradients) + +## Setup Instructions + +### 1. Backend Setup + +```bash +cd backend + +# Install dependencies (if needed) +npm install axios @nestjs/schedule + +# Update environment variables +cp .env.example .env +``` + +Edit `.env` and add: +```bash +SEP24_ANCHOR_DOMAIN=testanchor.stellar.org +SEP24_CALLBACK_URL=https://your-domain.com/api/v1/sep24/callback +``` + +### 2. Database Migration + +```bash +cd backend + +# Run Prisma migration +npx prisma migrate dev + +# Generate Prisma client +npx prisma generate +``` + +### 3. Frontend Setup + +No additional dependencies needed. The implementation uses existing packages. + +### 4. Start Services + +```bash +# Backend +cd backend +npm run start:dev + +# Frontend +cd frontend +npm run dev +``` + +## Usage Flow + +1. User navigates to `/onramp` page or clicks "Add Funds" button +2. User connects Freighter wallet (if not already connected) +3. User enters deposit amount in USD +4. User clicks "Continue to Deposit" +5. Backend generates interactive URL from anchor +6. Frontend opens anchor window (popup) +7. User completes deposit with anchor (bank transfer, card, etc.) +8. Frontend polls for status updates every 5 seconds +9. Backend receives webhook from anchor on completion +10. USDC appears in user's Stellar wallet + +## Testing + +### Test on Stellar Testnet + +1. Install Freighter wallet extension +2. Switch Freighter to Testnet +3. Fund wallet with testnet XLM from friendbot: https://friendbot.stellar.org +4. Navigate to http://localhost:3000/onramp +5. Connect wallet and initiate deposit +6. Complete deposit flow in anchor window + +### Manual API Testing + +```bash +# Initiate deposit +curl -X POST http://localhost:3000/api/v1/sep24/deposit \ + -H "Content-Type: application/json" \ + -d '{ + "walletAddress": "GXXX...XXX", + "assetCode": "USDC", + "amount": 100, + "anchorProvider": "moneygram" + }' + +# Check status +curl http://localhost:3000/api/v1/sep24/deposit/{depositId} +``` + +## Configuration Options + +### Anchor Providers + +The default anchor is `testanchor.stellar.org` for testing. For production: + +1. **MoneyGram Access** + - Domain: `moneygram.stellar.org` + - Supports: USD, EUR, GBP + - KYC required for larger amounts + +2. **Other SEP-24 Anchors** + - Update `SEP24_ANCHOR_DOMAIN` in `.env` + - Ensure anchor supports USDC + +### Customization + +**Styling**: The on-ramp page uses NovaFund's existing theme: +- Dark mode (slate-950 background) +- Gradient accents (cyan-to-blue) +- Glassmorphism effects +- Responsive design + +**Branding**: Modify `frontend/src/app/onramp/page.tsx`: +- Update header text +- Change color gradients +- Adjust feature descriptions + +## Integration Points + +### Add On-Ramp Button to Navigation + +Add to `frontend/src/components/layout/Header.tsx`: + +```tsx +import OnRampButton from '../OnRampButton'; + +// In the navigation section: + +``` + +### Link to Project Funding + +To auto-fund a project after deposit, pass `projectId`: + +```typescript +await fetch(`${apiUrl}/sep24/deposit`, { + method: "POST", + body: JSON.stringify({ + walletAddress, + assetCode: "USDC", + amount: 100, + projectId: "project-id-here", // Optional + }), +}); +``` + +## Monitoring + +### Database Queries + +```sql +-- Check recent deposits +SELECT * FROM fiat_deposits +ORDER BY created_at DESC +LIMIT 10; + +-- Check pending deposits +SELECT * FROM fiat_deposits +WHERE status NOT IN ('completed', 'error') +AND created_at > NOW() - INTERVAL '24 hours'; + +-- Deposit success rate +SELECT + status, + COUNT(*) as count, + AVG(EXTRACT(EPOCH FROM (completed_at - created_at))) as avg_duration_seconds +FROM fiat_deposits +WHERE created_at > NOW() - INTERVAL '7 days' +GROUP BY status; +``` + +### Logs + +Backend logs include: +- Deposit initiation +- Status polling +- Anchor callbacks +- Errors and failures + +## Security Considerations + +1. **Wallet Validation**: Stellar addresses are validated before processing +2. **Rate Limiting**: Add rate limiting to deposit endpoints (recommended) +3. **Webhook Verification**: Verify webhook signatures from anchors (TODO) +4. **HTTPS**: Use HTTPS in production for all communications +5. **Error Handling**: Sensitive errors are logged but not exposed to users + +## Troubleshooting + +### Deposit Stuck in Pending + +- Check anchor status page +- Verify webhook URL is accessible +- Check backend logs for errors +- Manually poll anchor API + +### Wallet Not Connecting + +- Ensure Freighter is installed +- Check network (testnet vs mainnet) +- Clear browser cache +- Check console for errors + +### Interactive Window Blocked + +- Allow popups for the domain +- Alternative: Use iframe mode (modify frontend) + +## Future Enhancements + +- [ ] Support multiple anchor providers +- [ ] Withdrawal flow (SEP-24 withdraw) +- [ ] KYC integration +- [ ] Transaction limits and compliance +- [ ] Multi-currency support (EUR, GBP) +- [ ] Direct project funding from on-ramp +- [ ] Email notifications on completion +- [ ] Mobile app support +- [ ] Webhook signature verification +- [ ] Rate limiting and abuse prevention + +## Documentation + +- SEP-24 Spec: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md +- Stellar Anchors: https://www.stellar.org/anchors +- MoneyGram Access: https://moneygram.stellar.org + +## Support + +For issues or questions: +1. Check backend logs: `backend/logs/` +2. Review database records: `fiat_deposits` table +3. Test with testnet anchor first +4. Consult SEP-24 documentation diff --git a/backend/.env.example b/backend/.env.example index f721d06..463d1c9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,41 +1,20 @@ -# Database Configuration -DATABASE_HOST=localhost -DATABASE_PORT=5432 -DATABASE_USER=postgres -DATABASE_PASSWORD=password -DATABASE_NAME=novafund +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/novafund" -# Redis Configuration +# Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= -REDIS_DB=0 -REDIS_DEFAULT_TTL=300 -REDIS_MAX_KEYS=1000 -# JWT Configuration -JWT_SECRET=your-secret-key-here -JWT_EXPIRATION=86400 - -# Server Configuration -PORT=3000 -API_PREFIX=api/v1 -NODE_ENV=development - -# Stellar Blockchain Configuration +# Stellar Configuration STELLAR_NETWORK=testnet -STELLAR_RPC_URL=https://soroban-testnet.stellar.org -STELLAR_BACKUP_RPC_URLS=https://soroban-testnet.stellar.org:443,https://stellar-testnet.rpc.explorer.ai/api STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -STELLAR_SPONSOR_SECRET_KEY=S... (replace with actual sponsor secret key) +STELLAR_SPONSOR_SECRET_KEY= -# RPC Fallback Configuration -RPC_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 -RPC_CIRCUIT_BREAKER_RECOVERY_TIMEOUT=60000 -RPC_CIRCUIT_BREAKER_MONITORING_PERIOD=30000 -RPC_HEALTH_CHECK_INTERVAL=30000 -RPC_REQUEST_TIMEOUT=10000 +# SEP-24 Fiat On-Ramp +SEP24_ANCHOR_DOMAIN=testanchor.stellar.org +SEP24_CALLBACK_URL=https://your-domain.com/api/v1/sep24/callback # Contract IDs (replace with actual deployed contract addresses) PROJECT_LAUNCH_CONTRACT_ID=CD... @@ -48,16 +27,17 @@ TOKEN_FACTORY_CONTRACT_ID=CD... STELLAR_USDC_CONTRACT_ID= STELLAR_EURC_CONTRACT_ID= -# Indexer Configuration -INDEXER_POLL_INTERVAL_MS=5000 -INDEXER_REORG_DEPTH_THRESHOLD=5 -INDEXER_MAX_EVENTS_PER_FETCH=100 -INDEXER_RETRY_ATTEMPTS=3 -INDEXER_RETRY_DELAY_MS=1000 -# Optional: Start from specific ledger (for initial sync) -# INDEXER_START_LEDGER=1000000 +# Email (for notifications) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= -# Bridge Configuration -ETHERSCAN_API_KEY=your-etherscan-api-key -STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org -# Poll interval is hardcoded to 30s via @Cron(EVERY_30_SECONDS) +# Web Push (for notifications) +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT= + +# JWT +JWT_SECRET=your-secret-key-here diff --git a/backend/prisma/migrations/20260327000000_add_fiat_deposits/migration.sql b/backend/prisma/migrations/20260327000000_add_fiat_deposits/migration.sql new file mode 100644 index 0000000..30fc299 --- /dev/null +++ b/backend/prisma/migrations/20260327000000_add_fiat_deposits/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "FiatDeposit" ( + "id" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "assetCode" TEXT NOT NULL DEFAULT 'USDC', + "amount" DOUBLE PRECISION, + "status" TEXT NOT NULL, + "anchorProvider" TEXT NOT NULL, + "anchorTransactionId" TEXT, + "stellarTransactionId" TEXT, + "interactiveUrl" TEXT NOT NULL, + "projectId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "FiatDeposit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FiatDeposit_walletAddress_idx" ON "FiatDeposit"("walletAddress"); + +-- CreateIndex +CREATE INDEX "FiatDeposit_status_idx" ON "FiatDeposit"("status"); + +-- CreateIndex +CREATE INDEX "FiatDeposit_anchorTransactionId_idx" ON "FiatDeposit"("anchorTransactionId"); + +-- CreateIndex +CREATE INDEX "FiatDeposit_projectId_idx" ON "FiatDeposit"("projectId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5434bec..0b5ad60 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -283,3 +283,25 @@ model YieldEvent { @@index([isActive]) @@map("yield_events") } + +model FiatDeposit { + id String @id @default(cuid()) + walletAddress String @map("wallet_address") + assetCode String @default("USDC") @map("asset_code") + amount Float? + status String + anchorProvider String @map("anchor_provider") + anchorTransactionId String? @unique @map("anchor_transaction_id") + stellarTransactionId String? @map("stellar_transaction_id") + interactiveUrl String @map("interactive_url") + projectId String? @map("project_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + completedAt DateTime? @map("completed_at") + + @@index([walletAddress]) + @@index([status]) + @@index([anchorTransactionId]) + @@index([projectId]) + @@map("fiat_deposits") +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff075c1..99c33e7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { VerificationModule } from './verification/verification.module'; import { RedisModule } from './redis/redis.module'; import { ProjectModule } from './project/project.module'; import { StellarModule } from './stellar/stellar.module'; +import { Sep24Module } from './sep24/sep24.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { StellarModule } from './stellar/stellar.module'; RelayModule, VerificationModule, ProjectModule, + Sep24Module, ], controllers: [AppController, UserController], providers: [AppService], diff --git a/backend/src/sep24/README.md b/backend/src/sep24/README.md new file mode 100644 index 0000000..141e5e1 --- /dev/null +++ b/backend/src/sep24/README.md @@ -0,0 +1,185 @@ +# SEP-24 Fiat On-Ramp Integration + +This module implements Stellar SEP-24 interactive deposit flow, allowing users to deposit fiat currency directly into their Stellar wallets as USDC. + +## Features + +- Interactive deposit flow via anchor providers (e.g., MoneyGram Access) +- Real-time transaction status tracking +- Webhook support for anchor callbacks +- Redis caching for performance +- Database persistence for audit trail + +## Architecture + +### Backend Components + +1. **AnchorService** (`anchor.service.ts`) + - Communicates with SEP-24 anchor providers + - Fetches stellar.toml configuration + - Initiates interactive deposit flows + - Polls transaction status + +2. **Sep24Service** (`sep24.service.ts`) + - Business logic for deposit management + - Database operations via Prisma + - Redis caching + - Status polling and updates + +3. **Sep24Controller** (`sep24.controller.ts`) + - REST API endpoints: + - `POST /sep24/deposit` - Initiate deposit + - `GET /sep24/deposit/:id` - Get deposit status + - `POST /sep24/callback` - Webhook for anchor updates + +### Frontend Components + +1. **OnRamp Page** (`frontend/src/app/onramp/page.tsx`) + - User interface for initiating deposits + - Amount input and wallet selection + - Opens anchor interactive window + - Real-time status polling + - Transaction completion tracking + +2. **WalletContext** (`frontend/src/contexts/WalletContext.tsx`) + - Manages Freighter wallet connection + - Provides wallet address to components + +## Configuration + +### Environment Variables + +```bash +# SEP-24 Configuration +SEP24_ANCHOR_DOMAIN=testanchor.stellar.org +SEP24_CALLBACK_URL=https://your-domain.com/api/v1/sep24/callback +``` + +### Supported Anchors + +- MoneyGram Access (default) +- Any SEP-24 compliant anchor + +## Usage Flow + +1. User navigates to `/onramp` page +2. User enters deposit amount +3. Frontend calls `POST /sep24/deposit` with wallet address +4. Backend fetches anchor interactive URL +5. Frontend opens anchor window (popup or iframe) +6. User completes deposit with anchor +7. Frontend polls `GET /sep24/deposit/:id` for status +8. Anchor sends webhook to `POST /sep24/callback` on completion +9. USDC appears in user's wallet + +## Database Schema + +```prisma +model FiatDeposit { + id String @id @default(cuid()) + walletAddress String + assetCode String @default("USDC") + amount Float? + status String + anchorProvider String + anchorTransactionId String? @unique + stellarTransactionId String? + interactiveUrl String + projectId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? +} +``` + +## Status Flow + +1. `pending_user_transfer_start` - Waiting for user to complete deposit +2. `pending_anchor` - Anchor processing fiat deposit +3. `pending_stellar` - Submitting to Stellar network +4. `completed` - USDC deposited to wallet +5. `error` - Deposit failed + +## API Reference + +### POST /sep24/deposit + +Initiate a new fiat deposit. + +**Request:** +```json +{ + "walletAddress": "GXXX...XXX", + "assetCode": "USDC", + "amount": 100, + "anchorProvider": "moneygram", + "language": "en" +} +``` + +**Response:** +```json +{ + "id": "clxxx", + "interactiveUrl": "https://anchor.com/deposit?token=xxx", + "status": "pending_user_transfer_start" +} +``` + +### GET /sep24/deposit/:id + +Get deposit status. + +**Response:** +```json +{ + "id": "clxxx", + "status": "completed", + "amount": 100, + "assetCode": "USDC", + "stellarTransactionId": "abc123..." +} +``` + +## Testing + +### Test with Stellar Testnet + +1. Use testnet anchor: `testanchor.stellar.org` +2. Connect Freighter wallet to testnet +3. Fund wallet with testnet XLM from friendbot +4. Initiate deposit through UI + +### Manual Testing + +```bash +# Initiate deposit +curl -X POST http://localhost:3000/api/v1/sep24/deposit \ + -H "Content-Type: application/json" \ + -d '{ + "walletAddress": "GXXX...XXX", + "assetCode": "USDC", + "amount": 100 + }' + +# Check status +curl http://localhost:3000/api/v1/sep24/deposit/{depositId} +``` + +## Security Considerations + +- Validate wallet addresses before processing +- Rate limit deposit endpoints +- Verify webhook signatures from anchors +- Use HTTPS for all communications +- Store sensitive data encrypted +- Implement proper error handling + +## Future Enhancements + +- Support for multiple anchor providers +- Withdrawal flow (SEP-24 withdraw) +- KYC integration +- Transaction limits and compliance +- Multi-currency support +- Direct project funding from on-ramp diff --git a/backend/src/sep24/anchor.service.spec.ts b/backend/src/sep24/anchor.service.spec.ts new file mode 100644 index 0000000..1bec7f6 --- /dev/null +++ b/backend/src/sep24/anchor.service.spec.ts @@ -0,0 +1,111 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { AnchorService } from './anchor.service'; +import axios from 'axios'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('AnchorService', () => { + let service: AnchorService; + let configService: ConfigService; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'SEP24_ANCHOR_DOMAIN') return 'testanchor.stellar.org'; + return undefined; + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnchorService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(AnchorService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getDepositUrl', () => { + it('should return interactive deposit URL', async () => { + const params = { + asset_code: 'USDC', + account: 'GTEST123', + amount: '100', + }; + + const mockToml = 'TRANSFER_SERVER_SEP0024="https://testanchor.stellar.org/sep24"'; + const mockInteractiveUrl = 'https://testanchor.stellar.org/sep24/deposit?token=abc'; + + mockedAxios.get.mockResolvedValueOnce({ data: mockToml }); + mockedAxios.post.mockResolvedValueOnce({ + data: { url: mockInteractiveUrl }, + }); + + const result = await service.getDepositUrl(params); + + expect(result).toBe(mockInteractiveUrl); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://testanchor.stellar.org/.well-known/stellar.toml', + ); + expect(mockedAxios.post).toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const params = { + asset_code: 'USDC', + account: 'GTEST123', + }; + + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); + + await expect(service.getDepositUrl(params)).rejects.toThrow('Network error'); + }); + }); + + describe('getTransactionStatus', () => { + it('should return transaction status from anchor', async () => { + const transactionId = 'anchor123'; + const mockToml = 'TRANSFER_SERVER_SEP0024="https://testanchor.stellar.org/sep24"'; + const mockTransaction = { + id: transactionId, + status: 'completed', + stellar_transaction_id: 'stellar123', + }; + + mockedAxios.get.mockResolvedValueOnce({ data: mockToml }); + mockedAxios.get.mockResolvedValueOnce({ + data: { transaction: mockTransaction }, + }); + + const result = await service.getTransactionStatus(transactionId); + + expect(result).toEqual(mockTransaction); + }); + }); + + describe('parseToml', () => { + it('should parse TOML format correctly', () => { + const tomlString = ` +# Comment +TRANSFER_SERVER_SEP0024="https://anchor.com/sep24" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + `; + + const result = (service as any).parseToml(tomlString); + + expect(result.TRANSFER_SERVER_SEP0024).toBe('https://anchor.com/sep24'); + expect(result.NETWORK_PASSPHRASE).toBe('Test SDF Network ; September 2015'); + }); + }); +}); diff --git a/backend/src/sep24/anchor.service.ts b/backend/src/sep24/anchor.service.ts new file mode 100644 index 0000000..a0f3d51 --- /dev/null +++ b/backend/src/sep24/anchor.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +interface AnchorDepositParams { + asset_code: string; + account: string; + amount?: string; + lang?: string; +} + +@Injectable() +export class AnchorService { + private readonly logger = new Logger(AnchorService.name); + private readonly anchorDomain: string; + private readonly anchorUrl: string; + + constructor(private readonly config: ConfigService) { + this.anchorDomain = this.config.get('SEP24_ANCHOR_DOMAIN') || 'testanchor.stellar.org'; + this.anchorUrl = `https://${this.anchorDomain}`; + } + + async getDepositUrl(params: AnchorDepositParams): Promise { + try { + // Get SEP-24 info + const infoResponse = await axios.get(`${this.anchorUrl}/.well-known/stellar.toml`); + const toml = this.parseToml(infoResponse.data); + const transferServerUrl = toml.TRANSFER_SERVER_SEP0024 || `${this.anchorUrl}/sep24`; + + // Request interactive deposit + const response = await axios.post( + `${transferServerUrl}/transactions/deposit/interactive`, + new URLSearchParams({ + asset_code: params.asset_code, + account: params.account, + ...(params.amount && { amount: params.amount }), + lang: params.lang || 'en', + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + return response.data.url; + } catch (error) { + this.logger.error(`Failed to get deposit URL: ${error.message}`); + throw error; + } + } + + async getTransactionStatus(transactionId: string) { + try { + const infoResponse = await axios.get(`${this.anchorUrl}/.well-known/stellar.toml`); + const toml = this.parseToml(infoResponse.data); + const transferServerUrl = toml.TRANSFER_SERVER_SEP0024 || `${this.anchorUrl}/sep24`; + + const response = await axios.get(`${transferServerUrl}/transaction`, { + params: { id: transactionId }, + }); + + return response.data.transaction; + } catch (error) { + this.logger.error(`Failed to get transaction status: ${error.message}`); + throw error; + } + } + + private parseToml(tomlString: string): Record { + const result: Record = {}; + const lines = tomlString.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && valueParts.length > 0) { + const value = valueParts + .join('=') + .trim() + .replace(/^["']|["']$/g, ''); + result[key.trim()] = value; + } + } + } + + return result; + } +} diff --git a/backend/src/sep24/dto/sep24.dto.ts b/backend/src/sep24/dto/sep24.dto.ts new file mode 100644 index 0000000..4ec28f4 --- /dev/null +++ b/backend/src/sep24/dto/sep24.dto.ts @@ -0,0 +1,46 @@ +import { IsString, IsOptional, IsNumber, IsEnum } from 'class-validator'; + +export class InitiateDepositDto { + @IsString() + walletAddress: string; + + @IsString() + @IsOptional() + assetCode?: string; + + @IsNumber() + @IsOptional() + amount?: number; + + @IsString() + @IsOptional() + anchorProvider?: string; + + @IsString() + @IsOptional() + language?: string; + + @IsString() + @IsOptional() + projectId?: string; +} + +export class DepositStatusDto { + id: string; + status: string; + amount?: number; + assetCode: string; + stellarTransactionId?: string; + message?: string; +} + +export class AnchorCallbackDto { + transaction: { + id: string; + status: string; + stellar_transaction_id?: string; + amount_in?: string; + amount_out?: string; + [key: string]: any; + }; +} diff --git a/backend/src/sep24/sep24.controller.spec.ts b/backend/src/sep24/sep24.controller.spec.ts new file mode 100644 index 0000000..4c3ec82 --- /dev/null +++ b/backend/src/sep24/sep24.controller.spec.ts @@ -0,0 +1,99 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Sep24Controller } from './sep24.controller'; +import { Sep24Service } from './sep24.service'; + +describe('Sep24Controller', () => { + let controller: Sep24Controller; + let service: Sep24Service; + + const mockSep24Service = { + initiateDeposit: jest.fn(), + getDepositStatus: jest.fn(), + handleAnchorCallback: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [Sep24Controller], + providers: [ + { + provide: Sep24Service, + useValue: mockSep24Service, + }, + ], + }).compile(); + + controller = module.get(Sep24Controller); + service = module.get(Sep24Service); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initiateDeposit', () => { + it('should initiate a deposit', async () => { + const dto = { + walletAddress: 'GTEST123', + assetCode: 'USDC', + amount: 100, + }; + + const expectedResult = { + id: 'deposit123', + interactiveUrl: 'https://anchor.com/deposit', + status: 'pending_user_transfer_start', + }; + + mockSep24Service.initiateDeposit.mockResolvedValue(expectedResult); + + const result = await controller.initiateDeposit(dto); + + expect(result).toEqual(expectedResult); + expect(mockSep24Service.initiateDeposit).toHaveBeenCalledWith(dto); + }); + }); + + describe('getDepositStatus', () => { + it('should return deposit status', async () => { + const depositId = 'deposit123'; + const expectedStatus = { + id: depositId, + status: 'completed', + amount: 100, + assetCode: 'USDC', + stellarTransactionId: 'stellar123', + }; + + mockSep24Service.getDepositStatus.mockResolvedValue(expectedStatus); + + const result = await controller.getDepositStatus(depositId); + + expect(result).toEqual(expectedStatus); + expect(mockSep24Service.getDepositStatus).toHaveBeenCalledWith(depositId); + }); + }); + + describe('handleCallback', () => { + it('should handle anchor callback', async () => { + const dto = { + transaction: { + id: 'anchor123', + status: 'completed', + stellar_transaction_id: 'stellar123', + }, + }; + + mockSep24Service.handleAnchorCallback.mockResolvedValue(undefined); + + const result = await controller.handleCallback(dto); + + expect(result).toEqual({ success: true }); + expect(mockSep24Service.handleAnchorCallback).toHaveBeenCalledWith( + 'anchor123', + 'completed', + dto.transaction, + ); + }); + }); +}); diff --git a/backend/src/sep24/sep24.controller.ts b/backend/src/sep24/sep24.controller.ts new file mode 100644 index 0000000..322db54 --- /dev/null +++ b/backend/src/sep24/sep24.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Post, Get, Body, Param, HttpCode } from '@nestjs/common'; +import { Sep24Service } from './sep24.service'; +import { InitiateDepositDto, AnchorCallbackDto } from './dto/sep24.dto'; + +@Controller('sep24') +export class Sep24Controller { + constructor(private readonly sep24Service: Sep24Service) {} + + @Post('deposit') + async initiateDeposit(@Body() dto: InitiateDepositDto) { + return this.sep24Service.initiateDeposit(dto); + } + + @Get('deposit/:id') + async getDepositStatus(@Param('id') id: string) { + return this.sep24Service.getDepositStatus(id); + } + + @Post('callback') + @HttpCode(200) + async handleCallback(@Body() dto: AnchorCallbackDto) { + await this.sep24Service.handleAnchorCallback( + dto.transaction.id, + dto.transaction.status, + dto.transaction, + ); + return { success: true }; + } +} diff --git a/backend/src/sep24/sep24.module.ts b/backend/src/sep24/sep24.module.ts new file mode 100644 index 0000000..3c2c5d5 --- /dev/null +++ b/backend/src/sep24/sep24.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { Sep24Controller } from './sep24.controller'; +import { Sep24Service } from './sep24.service'; +import { AnchorService } from './anchor.service'; +import { DepositPollTask } from './tasks/deposit-poll.task'; +import { DatabaseModule } from '../database.module'; +import { RedisModule } from '../redis/redis.module'; + +@Module({ + imports: [DatabaseModule, RedisModule, ScheduleModule.forRoot()], + controllers: [Sep24Controller], + providers: [Sep24Service, AnchorService, DepositPollTask], + exports: [Sep24Service], +}) +export class Sep24Module {} diff --git a/backend/src/sep24/sep24.service.spec.ts b/backend/src/sep24/sep24.service.spec.ts new file mode 100644 index 0000000..7e0a2ed --- /dev/null +++ b/backend/src/sep24/sep24.service.spec.ts @@ -0,0 +1,241 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Sep24Service } from './sep24.service'; +import { PrismaService } from '../prisma.service'; +import { RedisService } from '../redis/redis.service'; +import { AnchorService } from './anchor.service'; +import { BadRequestException } from '@nestjs/common'; + +describe('Sep24Service', () => { + let service: Sep24Service; + let prismaService: PrismaService; + let redisService: RedisService; + let anchorService: AnchorService; + + const mockPrismaService = { + fiatDeposit: { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + }, + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + + const mockAnchorService = { + getDepositUrl: jest.fn(), + getTransactionStatus: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + Sep24Service, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: RedisService, + useValue: mockRedisService, + }, + { + provide: AnchorService, + useValue: mockAnchorService, + }, + ], + }).compile(); + + service = module.get(Sep24Service); + prismaService = module.get(PrismaService); + redisService = module.get(RedisService); + anchorService = module.get(AnchorService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initiateDeposit', () => { + it('should create a deposit and return interactive URL', async () => { + const dto = { + walletAddress: 'GTEST123', + assetCode: 'USDC', + amount: 100, + }; + + const mockInteractiveUrl = 'https://anchor.com/deposit?token=abc'; + const mockDeposit = { + id: 'deposit123', + walletAddress: dto.walletAddress, + assetCode: dto.assetCode, + amount: dto.amount, + status: 'pending_user_transfer_start', + anchorProvider: 'moneygram', + interactiveUrl: mockInteractiveUrl, + projectId: null, + }; + + mockAnchorService.getDepositUrl.mockResolvedValue(mockInteractiveUrl); + mockPrismaService.fiatDeposit.create.mockResolvedValue(mockDeposit); + mockRedisService.set.mockResolvedValue(undefined); + + const result = await service.initiateDeposit(dto); + + expect(result).toEqual({ + id: 'deposit123', + interactiveUrl: mockInteractiveUrl, + status: 'pending_user_transfer_start', + }); + expect(mockAnchorService.getDepositUrl).toHaveBeenCalledWith({ + asset_code: 'USDC', + account: dto.walletAddress, + amount: '100', + lang: 'en', + }); + expect(mockPrismaService.fiatDeposit.create).toHaveBeenCalled(); + expect(mockRedisService.set).toHaveBeenCalled(); + }); + }); + + describe('getDepositStatus', () => { + it('should return cached status if available', async () => { + const depositId = 'deposit123'; + const cachedStatus = { + id: depositId, + status: 'completed', + amount: 100, + assetCode: 'USDC', + }; + + mockRedisService.get.mockResolvedValue(cachedStatus); + + const result = await service.getDepositStatus(depositId); + + expect(result).toEqual(cachedStatus); + expect(mockRedisService.get).toHaveBeenCalledWith(`sep24:deposit:${depositId}`); + expect(mockPrismaService.fiatDeposit.findUnique).not.toHaveBeenCalled(); + }); + + it('should fetch from database if not cached', async () => { + const depositId = 'deposit123'; + const mockDeposit = { + id: depositId, + walletAddress: 'GTEST123', + assetCode: 'USDC', + amount: 100, + status: 'pending_anchor', + anchorTransactionId: 'anchor123', + stellarTransactionId: null, + projectId: null, + }; + + const mockAnchorStatus = { + id: 'anchor123', + status: 'completed', + stellar_transaction_id: 'stellar123', + }; + + mockRedisService.get.mockResolvedValue(null); + mockPrismaService.fiatDeposit.findUnique.mockResolvedValue(mockDeposit); + mockAnchorService.getTransactionStatus.mockResolvedValue(mockAnchorStatus); + mockPrismaService.fiatDeposit.update.mockResolvedValue({ + ...mockDeposit, + status: 'completed', + }); + + const result = await service.getDepositStatus(depositId); + + expect(result.status).toBe('completed'); + expect(mockPrismaService.fiatDeposit.findUnique).toHaveBeenCalledWith({ + where: { id: depositId }, + }); + }); + + it('should throw error if deposit not found', async () => { + const depositId = 'nonexistent'; + + mockRedisService.get.mockResolvedValue(null); + mockPrismaService.fiatDeposit.findUnique.mockResolvedValue(null); + + await expect(service.getDepositStatus(depositId)).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateDepositStatus', () => { + it('should update deposit status and cache', async () => { + const depositId = 'deposit123'; + const newStatus = 'completed'; + const anchorData = { + id: 'anchor123', + stellar_transaction_id: 'stellar123', + }; + + const updatedDeposit = { + id: depositId, + status: newStatus, + stellarTransactionId: 'stellar123', + anchorTransactionId: 'anchor123', + projectId: null, + }; + + mockPrismaService.fiatDeposit.update.mockResolvedValue(updatedDeposit); + mockRedisService.set.mockResolvedValue(undefined); + mockRedisService.del.mockResolvedValue(undefined); + + const result = await service.updateDepositStatus(depositId, newStatus, anchorData); + + expect(result).toEqual(updatedDeposit); + expect(mockPrismaService.fiatDeposit.update).toHaveBeenCalledWith({ + where: { id: depositId }, + data: expect.objectContaining({ + status: newStatus, + stellarTransactionId: 'stellar123', + anchorTransactionId: 'anchor123', + }), + }); + expect(mockRedisService.set).toHaveBeenCalled(); + }); + }); + + describe('handleAnchorCallback', () => { + it('should update deposit status on callback', async () => { + const transactionId = 'anchor123'; + const status = 'completed'; + const data = { stellar_transaction_id: 'stellar123' }; + + const mockDeposit = { + id: 'deposit123', + anchorTransactionId: transactionId, + }; + + mockPrismaService.fiatDeposit.findFirst.mockResolvedValue(mockDeposit); + mockPrismaService.fiatDeposit.update.mockResolvedValue({ + ...mockDeposit, + status, + }); + mockRedisService.set.mockResolvedValue(undefined); + + await service.handleAnchorCallback(transactionId, status, data); + + expect(mockPrismaService.fiatDeposit.findFirst).toHaveBeenCalledWith({ + where: { anchorTransactionId: transactionId }, + }); + }); + + it('should handle callback for non-existent deposit', async () => { + const transactionId = 'nonexistent'; + + mockPrismaService.fiatDeposit.findFirst.mockResolvedValue(null); + + await expect( + service.handleAnchorCallback(transactionId, 'completed', {}), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/backend/src/sep24/sep24.service.ts b/backend/src/sep24/sep24.service.ts new file mode 100644 index 0000000..57b6e67 --- /dev/null +++ b/backend/src/sep24/sep24.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../prisma.service'; +import { RedisService } from '../redis/redis.service'; +import { AnchorService } from './anchor.service'; +import { InitiateDepositDto, DepositStatusDto } from './dto/sep24.dto'; + +@Injectable() +export class Sep24Service { + private readonly logger = new Logger(Sep24Service.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly anchorService: AnchorService, + ) {} + + async initiateDeposit(dto: InitiateDepositDto) { + this.logger.log(`Initiating deposit for wallet ${dto.walletAddress}`); + + // Get interactive URL from anchor + const interactiveUrl = await this.anchorService.getDepositUrl({ + asset_code: dto.assetCode || 'USDC', + account: dto.walletAddress, + amount: dto.amount?.toString(), + lang: dto.language || 'en', + }); + + // Create deposit record + const deposit = await this.prisma.fiatDeposit.create({ + data: { + walletAddress: dto.walletAddress, + assetCode: dto.assetCode || 'USDC', + amount: dto.amount, + status: 'pending_user_transfer_start', + anchorProvider: dto.anchorProvider || 'moneygram', + interactiveUrl, + projectId: dto.projectId, + }, + }); + + // Cache deposit for quick lookup + await this.redisService.set( + `sep24:deposit:${deposit.id}`, + deposit, + 3600, // 1 hour + ); + + return { + id: deposit.id, + interactiveUrl, + status: deposit.status, + }; + } + + async getDepositStatus(depositId: string): Promise { + // Try cache first + const cached = await this.redisService.get(`sep24:deposit:${depositId}`); + if (cached) { + return cached as DepositStatusDto; + } + + // Fetch from database + const deposit = await this.prisma.fiatDeposit.findUnique({ + where: { id: depositId }, + }); + + if (!deposit) { + throw new BadRequestException('Deposit not found'); + } + + // Check status with anchor + if (deposit.anchorTransactionId) { + const anchorStatus = await this.anchorService.getTransactionStatus( + deposit.anchorTransactionId, + ); + + // Update if status changed + if (anchorStatus.status !== deposit.status) { + await this.updateDepositStatus(depositId, anchorStatus.status, anchorStatus); + } + + return { + id: deposit.id, + status: anchorStatus.status, + amount: deposit.amount, + assetCode: deposit.assetCode, + stellarTransactionId: anchorStatus.stellar_transaction_id, + message: anchorStatus.message, + }; + } + + return { + id: deposit.id, + status: deposit.status, + amount: deposit.amount, + assetCode: deposit.assetCode, + }; + } + + async updateDepositStatus(depositId: string, status: string, anchorData?: any) { + const deposit = await this.prisma.fiatDeposit.update({ + where: { id: depositId }, + data: { + status, + stellarTransactionId: anchorData?.stellar_transaction_id, + anchorTransactionId: anchorData?.id, + completedAt: status === 'completed' ? new Date() : undefined, + }, + }); + + // Update cache + await this.redisService.set(`sep24:deposit:${depositId}`, deposit, 3600); + + // Invalidate related caches + if (deposit.projectId) { + await this.redisService.del(`project:${deposit.projectId}`); + } + + return deposit; + } + + async handleAnchorCallback(transactionId: string, status: string, data: any) { + this.logger.log(`Anchor callback for transaction ${transactionId}: ${status}`); + + const deposit = await this.prisma.fiatDeposit.findFirst({ + where: { anchorTransactionId: transactionId }, + }); + + if (!deposit) { + this.logger.warn(`Deposit not found for anchor transaction ${transactionId}`); + return; + } + + await this.updateDepositStatus(deposit.id, status, data); + } +} diff --git a/backend/src/sep24/tasks/deposit-poll.task.ts b/backend/src/sep24/tasks/deposit-poll.task.ts new file mode 100644 index 0000000..335fdb2 --- /dev/null +++ b/backend/src/sep24/tasks/deposit-poll.task.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Sep24Service } from '../sep24.service'; +import { PrismaService } from '../../prisma.service'; + +@Injectable() +export class DepositPollTask { + private readonly logger = new Logger(DepositPollTask.name); + + constructor( + private readonly sep24Service: Sep24Service, + private readonly prisma: PrismaService, + ) {} + + @Cron(CronExpression.EVERY_30_SECONDS) + async pollPendingDeposits() { + try { + // Find deposits that are pending and not completed + const pendingDeposits = await this.prisma.fiatDeposit.findMany({ + where: { + status: { + notIn: ['completed', 'error', 'incomplete'], + }, + anchorTransactionId: { + not: null, + }, + createdAt: { + // Only poll deposits from last 24 hours + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }, + take: 50, // Limit to avoid overload + }); + + if (pendingDeposits.length === 0) { + return; + } + + this.logger.log(`Polling ${pendingDeposits.length} pending deposits`); + + // Poll each deposit status + await Promise.allSettled( + pendingDeposits.map(async (deposit) => { + try { + await this.sep24Service.getDepositStatus(deposit.id); + } catch (error) { + this.logger.error(`Failed to poll deposit ${deposit.id}: ${error.message}`); + } + }), + ); + } catch (error) { + this.logger.error(`Deposit poll task failed: ${error.message}`); + } + } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index d38458d..0658390 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -4,6 +4,7 @@ import Header from "../components/layout/Header"; import Footer from "../components/layout/Footer"; import { NotificationProvider } from "../contexts/NotificationContext"; import { SocialProvider } from "../contexts/SocialContext"; +import { WalletProvider } from "../contexts/WalletContext"; import { LiveNotificationToast } from "../components/notifications/LiveNotificationToast"; import "../styles/globals.css"; @@ -22,16 +23,18 @@ export default function RootLayout({ return ( - - -
- -
- {children} -
-