diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..7b16cb8 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint'], + rules: { + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + 'no-console': 'warn', + }, + ignorePatterns: ['dist/', 'node_modules/', '*.js'], +}; diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js new file mode 100644 index 0000000..cf214d2 --- /dev/null +++ b/backend/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + env: { + jest: true, + }, + rules: { + // Backend specific rules + }, +}; diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..126419d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/src/generated/prisma diff --git a/backend/package.json b/backend/package.json index e1d81dc..9018818 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,19 +5,25 @@ "main": "src/index.ts", "scripts": { "start": "ts-node src/index.ts", - "dev": "ts-node --watch src/index.ts", + "dev": "ts-node src/index.ts", "build": "tsc", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", "test": "jest", "lint": "eslint src/**/*.ts" }, "dependencies": { "@chenaikit/core": "workspace:*", + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.0.0", "express": "^4.18.0", + "express-rate-limit": "^8.1.0", "helmet": "^7.0.0", -<<<<<<< HEAD -======= + + "jsonwebtoken": "^9.0.2", + "zod": "^4.1.11", + "winston": "^3.14.2", "uuid": "^9.0.1", "prom-client": "^15.1.3", @@ -25,13 +31,14 @@ "@opentelemetry/sdk-node": "^0.54.0", "@opentelemetry/auto-instrumentations-node": "^0.64.1", "@opentelemetry/exporter-trace-otlp-http": "^0.54.0", ->>>>>>> 4b2e5da5b0789837c58861e705de63b87b64ae63 + "ioredis": "^5.8.0", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.27" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.0", "@types/node": "^20.0.0", "@types/uuid": "^9.0.7", @@ -39,7 +46,9 @@ "ts-node": "^10.9.0", "jest": "^29.0.0", "@types/express": "^4.17.0", + "@types/express-rate-limit": "^6.0.2", "@types/jest": "^29.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -48,4 +57,4 @@ "ts-node": "^10.9.0", "typescript": "^5.0.0" } -} +} \ No newline at end of file diff --git a/backend/prisma/migrations/20251004160526_init/migration.sql b/backend/prisma/migrations/20251004160526_init/migration.sql new file mode 100644 index 0000000..6d2c1ba --- /dev/null +++ b/backend/prisma/migrations/20251004160526_init/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'user', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/backend/prisma/migrations/20251004161955_init_v2/migration.sql b/backend/prisma/migrations/20251004161955_init_v2/migration.sql new file mode 100644 index 0000000..44abc13 --- /dev/null +++ b/backend/prisma/migrations/20251004161955_init_v2/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token"); diff --git a/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql b/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql new file mode 100644 index 0000000..edd73ee --- /dev/null +++ b/backend/prisma/migrations/20251004162358_add_token_hash_field/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `token` on the `RefreshToken` table. All the data in the column will be lost. + - Added the required column `tokenHash` to the `RefreshToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_RefreshToken" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "tokenHash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "id", "userId") SELECT "createdAt", "expiresAt", "id", "userId" FROM "RefreshToken"; +DROP TABLE "RefreshToken"; +ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken"; +CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..1413fea --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,34 @@ +generator client { + provider = "prisma-client-js" + output = "../src/generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +enum Role { + user + admin +} + +model User { + id String @id @default(cuid()) + email String @unique + password String + role Role @default(user) + createdAt DateTime @default(now()) + refreshTokens RefreshToken[] +} + + +model RefreshToken { + id Int @id @default(autoincrement()) + tokenHash String @unique + userId String + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + expiresAt DateTime +} + diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts new file mode 100644 index 0000000..1729454 --- /dev/null +++ b/backend/src/controllers/authController.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import { hashPassword, comparePassword } from '../utils/password'; +import { generateAccessToken } from '../utils/jwt'; +import { prisma } from '../prisma/client'; +import { UserPayload } from '../types/auth'; +import crypto from 'crypto'; +import { z } from 'zod'; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + role: z.enum(['user', 'admin']).optional(), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +export class AuthController { + async register(req: Request, res: Response) { + try { + const { email, password, role } = registerSchema.parse(req.body); + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) return res.status(400).json({ message: 'Email already registered' }); + + const hashed = await hashPassword(password); + const user = await prisma.user.create({ + data: { email, password: hashed, role: role || 'user' }, + }); + + res.status(201).json({ message: 'User registered', userId: user.id }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Registration failed' }); + } + } + + async login(req: Request, res: Response) { + try { + const { email, password } = loginSchema.parse(req.body); + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) return res.status(400).json({ message: 'Invalid credentials' }); + + const valid = await comparePassword(password, user.password); + if (!valid) return res.status(400).json({ message: 'Invalid credentials' }); + + const payload: UserPayload = { id: user.id, email: user.email, role: user.role }; + const accessToken = generateAccessToken(payload); + const refreshTokenRaw = crypto.randomBytes(64).toString('hex'); + const refreshTokenHash = await hashPassword(refreshTokenRaw); + + await prisma.refreshToken.create({ + data: { + tokenHash: refreshTokenHash, + userId: user.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + + res.json({ accessToken, refreshToken: refreshTokenRaw }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Login failed' }); + } + } + + async refreshToken(req: Request, res: Response) { + try { + const { token } = req.body; + if (!token) return res.status(401).json({ message: 'Refresh token missing' }); + + const tokens = await prisma.refreshToken.findMany({ include: { user: true } }); + let matched = null; + for (const t of tokens) { + if (await comparePassword(token, t.tokenHash)) { + matched = t; + break; + } + } + + if (!matched) return res.status(403).json({ message: 'Invalid refresh token' }); + if (matched.expiresAt < new Date()) return res.status(403).json({ message: 'Refresh token expired' }); + + const payload: UserPayload = { + id: matched.user.id, + email: matched.user.email, + role: matched.user.role, + }; + const accessToken = generateAccessToken(payload); + res.json({ accessToken }); + } catch (err: any) { + res.status(400).json({ message: err.message || 'Token refresh failed' }); + } + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 7cf6c1b..8186708 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -13,6 +13,7 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; +import authRoutes from './routes/auth'; import { ensureRedisConnection } from './config/redis'; import { cacheMiddleware } from './middleware/cache'; import { CacheKeys } from './utils/cacheKeys'; @@ -26,7 +27,7 @@ dotenv.config(); validateEnvironment(); const app: express.Application = express(); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 5000; // Initialize monitoring initializeMonitoring().finally(() => { @@ -170,6 +171,9 @@ app.use((error: Error, req: express.Request, res: express.Response, next: expres }); }); + +app.use('/api/auth', authRoutes); + // Start server app.listen(PORT, async () => { console.log(`šŸš€ ChenAIKit Backend running on port ${PORT}`); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..bbd9bad --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,34 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyAccessToken } from '../utils/jwt'; +import { UserPayload } from '../types/auth'; + +declare global { + namespace Express { + interface Request { + user?: UserPayload; + } + } +} + +export const authenticate = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers['authorization']; + const token = authHeader?.split(' ')[1]; + + if (!token) return res.status(401).json({ message: 'Access token missing' }); + + try { + req.user = verifyAccessToken(token); + next(); + } catch { + return res.status(403).json({ message: 'Invalid or expired token' }); + } +}; + +export const authorize = (roles: Array<'user' | 'admin'>) => { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ message: 'Forbidden' }); + } + next(); + }; +}; diff --git a/backend/src/prisma/client.ts b/backend/src/prisma/client.ts new file mode 100644 index 0000000..96aa183 --- /dev/null +++ b/backend/src/prisma/client.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '../generated/prisma'; + +export const prisma = new PrismaClient(); + +process.on('beforeExit', async () => { + await prisma.$disconnect(); +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..23afbe3 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { AuthController } from '../controllers/authController'; + +const router = Router(); +const controller = new AuthController(); + + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { message: 'Too many requests, try again later.' }, +}); + +router.post('/register', authLimiter, controller.register.bind(controller)); +router.post('/login', authLimiter, controller.login.bind(controller)); +router.post('/refresh', authLimiter, controller.refreshToken.bind(controller)); + +export default router; diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts new file mode 100644 index 0000000..f3e349a --- /dev/null +++ b/backend/src/types/auth.ts @@ -0,0 +1,5 @@ +export interface UserPayload { + id: string; + email: string; + role: 'user' | 'admin'; +} diff --git a/backend/src/utils/jwt.ts b/backend/src/utils/jwt.ts new file mode 100644 index 0000000..10dcbba --- /dev/null +++ b/backend/src/utils/jwt.ts @@ -0,0 +1,23 @@ +import jwt from 'jsonwebtoken'; +import { UserPayload } from '../types/auth'; + +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret'; +const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret'; +const ACCESS_TOKEN_EXP = process.env.ACCESS_TOKEN_EXPIRATION || '15m'; +const REFRESH_TOKEN_EXP = process.env.REFRESH_TOKEN_EXPIRATION || '7d'; + +export const generateAccessToken = (payload: UserPayload) => { + return jwt.sign(payload, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXP }); +}; + +export const generateRefreshToken = (payload: UserPayload) => { + return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXP }); +}; + +export const verifyAccessToken = (token: string) => { + return jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload; +}; + +export const verifyRefreshToken = (token: string) => { + return jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; +}; diff --git a/backend/src/utils/password.ts b/backend/src/utils/password.ts new file mode 100644 index 0000000..34449b3 --- /dev/null +++ b/backend/src/utils/password.ts @@ -0,0 +1,10 @@ +import bcrypt from 'bcrypt'; +const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS) || 12; + +export const hashPassword = async (password: string): Promise => { + return bcrypt.hash(password, SALT_ROUNDS); +}; + +export const comparePassword = async (password: string, hash: string): Promise => { + return bcrypt.compare(password, hash); +}; diff --git a/package.json b/package.json index 4e075f8..16d334f 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "devDependencies": { "@types/jest": "^29.0.0", "@types/node": "^20.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.0.0", "jest": "^29.0.0", "prettier": "^3.0.0", diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index a7e8b6d..4c01865 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,10 @@ module.exports = { + + extends: ['../../.eslintrc.js'], + rules: { + // Core package specific rules + 'no-unused-vars': 'off', // Allow unused vars for blockchain types + root: true, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], @@ -8,5 +14,6 @@ module.exports = { ], env: { node: true, + }, }; diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 0000000..b3f8a51 --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.ts', + '**/?(*.)+(spec|test).ts' + ], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + '!src/**/examples/**' + ], + moduleFileExtensions: ['ts', 'js', 'json'], +}; diff --git a/packages/core/src/stellar/README.md b/packages/core/src/stellar/README.md new file mode 100644 index 0000000..6108980 --- /dev/null +++ b/packages/core/src/stellar/README.md @@ -0,0 +1,382 @@ +# Stellar Horizon API Wrapper + +A comprehensive TypeScript wrapper for the Stellar Horizon API that provides strongly-typed interfaces, rate limiting, error handling, and retry logic. + +## Features + +- āœ… **Account Data Fetching**: Get account information, balances, and metadata +- āœ… **Transaction History**: Retrieve transaction history with pagination support +- āœ… **Payment Operations**: Fetch payment operations and transaction details +- āœ… **Ledger Information**: Access ledger data and network statistics +- āœ… **Rate Limiting**: Built-in rate limiting to prevent API throttling +- āœ… **Error Handling**: Comprehensive error handling with meaningful messages +- āœ… **Retry Logic**: Automatic retry with exponential backoff +- āœ… **TypeScript Support**: Fully typed interfaces for all API responses +- āœ… **Streaming Support**: Real-time account updates (polling-based) + +## Installation + +```bash +npm install @chenaikit/core +``` + +## Quick Start + +```typescript +import { HorizonConnector, HorizonConfig } from '@chenaikit/core'; + +// Configuration +const config: HorizonConfig = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + apiKey: process.env.STELLAR_API_KEY, // Optional + rateLimit: { + requestsPerMinute: 60, + burstLimit: 10, + retryAfterMs: 1000 + }, + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000 +}; + +// Create connector +const horizon = new HorizonConnector(config); + +// Fetch account data +const account = await horizon.getAccount('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'); +console.log('Account sequence:', account.sequence); +console.log('Account balances:', account.balances); +``` + +## API Reference + +### Configuration + +```typescript +interface HorizonConfig { + horizonUrl: string; // Horizon server URL + networkPassphrase: string; // Network passphrase + apiKey?: string; // Optional API key + rateLimit?: RateLimitConfig; // Rate limiting settings + timeout?: number; // Request timeout (default: 30000ms) + retryAttempts?: number; // Retry attempts (default: 3) + retryDelay?: number; // Retry delay (default: 1000ms) +} + +interface RateLimitConfig { + requestsPerMinute: number; // Max requests per minute + burstLimit: number; // Burst limit + retryAfterMs: number; // Retry delay on rate limit +} +``` + +### Account Operations + +#### Get Account Information +```typescript +const account = await horizon.getAccount(accountId: string): Promise +``` + +#### Get Account Balances +```typescript +const balances = await horizon.getAccountBalances(accountId: string): Promise +``` + +#### Get Account Transactions +```typescript +const result = await horizon.getAccountTransactions( + accountId: string, + options?: PaginationOptions +): Promise<{ records: HorizonTransaction[]; next?: string; prev?: string }> +``` + +#### Get Account Payments +```typescript +const result = await horizon.getAccountPayments( + accountId: string, + options?: PaginationOptions +): Promise<{ records: HorizonPaymentOperation[]; next?: string; prev?: string }> +``` + +### Transaction Operations + +#### Get Transaction Details +```typescript +const transaction = await horizon.getTransaction(transactionHash: string): Promise +``` + +#### Get Transaction Operations +```typescript +const result = await horizon.getTransactionOperations( + transactionHash: string, + options?: PaginationOptions +): Promise<{ records: HorizonOperation[]; next?: string; prev?: string }> +``` + +#### Get Transaction Effects +```typescript +const result = await horizon.getTransactionEffects( + transactionHash: string, + options?: PaginationOptions +): Promise<{ records: HorizonEffect[]; next?: string; prev?: string }> +``` + +### Ledger Operations + +#### Get Ledger Information +```typescript +const ledger = await horizon.getLedger(ledgerSequence: number): Promise +``` + +#### Get Recent Ledgers +```typescript +const result = await horizon.getLedgers(options?: PaginationOptions): Promise<{ records: HorizonLedger[]; next?: string; prev?: string }> +``` + +### Network Operations + +#### Get Network Information +```typescript +const networkInfo = await horizon.getNetworkInfo(): Promise +``` + +#### Get Fee Statistics +```typescript +const feeStats = await horizon.getFeeStats(): Promise +``` + +### Utility Methods + +#### Health Check +```typescript +const isHealthy = await horizon.healthCheck(): Promise +``` + +#### Stream Account Updates +```typescript +const streamPromise = await horizon.streamAccount( + accountId: string, + callback: (data: any) => void +): Promise + +// Stop streaming +horizon.stopStreaming(); +``` + +## TypeScript Interfaces + +### Core Types + +```typescript +interface HorizonAccount { + id: string; + account_id: string; + sequence: string; + subentry_count: number; + num_sponsoring: number; + num_sponsored: number; + inflation_destination?: string; + home_domain?: string; + last_modified_ledger: number; + last_modified_time: string; + thresholds: { + low_threshold: number; + med_threshold: number; + high_threshold: number; + }; + flags: { + auth_required: boolean; + auth_revocable: boolean; + auth_immutable: boolean; + auth_clawback_enabled: boolean; + }; + balances: HorizonBalance[]; + signers: HorizonSigner[]; + data: Record; +} + +interface HorizonBalance { + balance: string; + buying_liabilities: string; + selling_liabilities: string; + asset_type: string; + asset_code?: string; + asset_issuer?: string; + limit?: string; + is_authorized?: boolean; + is_authorized_to_maintain_liabilities?: boolean; + is_clawback_enabled?: boolean; +} + +interface HorizonTransaction { + id: string; + paging_token: string; + successful: boolean; + hash: string; + ledger: number; + created_at: string; + source_account: string; + source_account_sequence: string; + fee_account?: string; + fee_charged: string; + max_fee: string; + operation_count: number; + envelope_xdr: string; + result_xdr: string; + result_meta_xdr: string; + fee_meta_xdr: string; + memo_type: string; + memo?: string; + signatures: string[]; + valid_after?: string; + valid_before?: string; + operations: HorizonOperation[]; + effects: HorizonEffect[]; + precedes: string; + succeeds: string; +} +``` + +### Pagination Options + +```typescript +interface PaginationOptions { + cursor?: string; // Pagination cursor + order?: 'asc' | 'desc'; // Sort order + limit?: number; // Number of records (max 200) +} +``` + +## Error Handling + +The wrapper provides comprehensive error handling with meaningful error messages: + +```typescript +try { + const account = await horizon.getAccount('INVALID_ADDRESS'); +} catch (error) { + console.error('Error:', error.message); + // Output: "Invalid Stellar address format" +} + +try { + const account = await horizon.getAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); +} catch (error) { + console.error('Error:', error.message); + // Output: "Account not found: GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" +} +``` + +## Rate Limiting + +The connector includes built-in rate limiting to prevent API throttling: + +```typescript +const config: HorizonConfig = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + rateLimit: { + requestsPerMinute: 60, // Max 60 requests per minute + burstLimit: 10, // Allow bursts of 10 requests + retryAfterMs: 1000 // Wait 1 second on rate limit + } +}; +``` + +## Examples + +### Basic Account Operations + +```typescript +import { HorizonConnector } from '@chenaikit/core'; + +const horizon = new HorizonConnector({ + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015' +}); + +// Get account information +const account = await horizon.getAccount('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'); +console.log('Account sequence:', account.sequence); + +// Get account balances +const balances = await horizon.getAccountBalances('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'); +balances.forEach(balance => { + if (balance.asset_type === 'native') { + console.log(`XLM Balance: ${balance.balance}`); + } else { + console.log(`${balance.asset_code} Balance: ${balance.balance}`); + } +}); +``` + +### Transaction History with Pagination + +```typescript +// Get recent transactions +const transactions = await horizon.getAccountTransactions('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J', { + limit: 10, + order: 'desc' +}); + +console.log(`Found ${transactions.records.length} transactions:`); +transactions.records.forEach(tx => { + console.log(`- ${tx.hash}: ${tx.successful ? 'SUCCESS' : 'FAILED'}`); +}); + +// Get next page +if (transactions.next) { + const nextPage = await horizon.getAccountTransactions('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J', { + cursor: transactions.records[transactions.records.length - 1].paging_token, + limit: 10, + order: 'desc' + }); +} +``` + +### Real-time Account Monitoring + +```typescript +// Stream account updates +const streamPromise = horizon.streamAccount('GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J', (accountData) => { + console.log('Account updated:', { + sequence: accountData.sequence, + balances: accountData.balances.length, + lastModified: accountData.last_modified_time + }); +}); + +// Stop streaming after 30 seconds +setTimeout(() => { + horizon.stopStreaming(); +}, 30000); + +await streamPromise; +``` + +## Testing + +The package includes comprehensive integration tests that verify all functionality against the Stellar testnet: + +```bash +npm test +``` + +## Network Support + +- **Testnet**: `https://horizon-testnet.stellar.org` +- **Mainnet**: `https://horizon.stellar.org` +- **Futurenet**: `https://horizon-futurenet.stellar.org` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Submit a pull request + +## License + +MIT License - see LICENSE file for details. diff --git a/packages/core/src/stellar/__tests__/horizon.test.ts b/packages/core/src/stellar/__tests__/horizon.test.ts new file mode 100644 index 0000000..1b675a7 --- /dev/null +++ b/packages/core/src/stellar/__tests__/horizon.test.ts @@ -0,0 +1,354 @@ +import { HorizonConnector, HorizonConfig } from '../horizon'; + +// Test configuration for Stellar testnet +const testConfig: HorizonConfig = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + rateLimit: { + requestsPerMinute: 60, + burstLimit: 10, + retryAfterMs: 1000 + }, + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000 +}; + +// Known testnet accounts for testing +const TEST_ACCOUNTS = { + // Stellar Development Foundation test account + SDF: 'GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J', + // Test account with some activity + ACTIVE: 'GCLRUZDCWBHS7VIFCT43BYPPGWDNSN6FJT5C4KEOME7FVY3ZW7X5C3M2', + // Non-existent account for error testing + INVALID: 'INVALID_ADDRESS_FORMAT' +}; + +describe('HorizonConnector', () => { + let horizon: HorizonConnector; + + beforeAll(() => { + horizon = new HorizonConnector(testConfig); + }); + + afterAll(() => { + horizon.stopStreaming(); + }); + + describe('Health Check', () => { + it('should connect to Horizon API successfully', async () => { + const isHealthy = await horizon.healthCheck(); + expect(isHealthy).toBe(true); + }); + }); + + describe('Network Info', () => { + it('should fetch network information', async () => { + const networkInfo = await horizon.getNetworkInfo(); + expect(networkInfo).toBeDefined(); + expect(networkInfo.network_passphrase).toBe(testConfig.networkPassphrase); + }); + + it('should fetch fee statistics', async () => { + const feeStats = await horizon.getFeeStats(); + expect(feeStats).toBeDefined(); + expect(feeStats.last_ledger).toBeDefined(); + expect(feeStats.last_ledger_base_fee).toBeDefined(); + }); + }); + + describe('Account Operations', () => { + it('should fetch account data for valid address', async () => { + const account = await horizon.getAccount(TEST_ACCOUNTS.SDF); + + expect(account).toBeDefined(); + expect(account.id).toBe(TEST_ACCOUNTS.SDF); + expect(account.account_id).toBe(TEST_ACCOUNTS.SDF); + expect(account.sequence).toBeDefined(); + expect(account.balances).toBeInstanceOf(Array); + expect(account.signers).toBeInstanceOf(Array); + }); + + it('should fetch account balances', async () => { + const balances = await horizon.getAccountBalances(TEST_ACCOUNTS.SDF); + + expect(balances).toBeInstanceOf(Array); + expect(balances.length).toBeGreaterThan(0); + + // Check for XLM balance + const xlmBalance = balances.find(b => b.asset_type === 'native'); + expect(xlmBalance).toBeDefined(); + expect(xlmBalance?.balance).toBeDefined(); + }); + + it('should throw error for invalid address format', async () => { + await expect(horizon.getAccount(TEST_ACCOUNTS.INVALID)) + .rejects.toThrow('Invalid Stellar address format'); + }); + + it('should throw error for non-existent account', async () => { + const nonExistentAccount = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + await expect(horizon.getAccount(nonExistentAccount)) + .rejects.toThrow('Account not found'); + }); + }); + + describe('Transaction History', () => { + it('should fetch account transactions with pagination', async () => { + const result = await horizon.getAccountTransactions(TEST_ACCOUNTS.SDF, { + limit: 5, + order: 'desc' + }); + + expect(result).toBeDefined(); + expect(result.records).toBeInstanceOf(Array); + expect(result.records.length).toBeLessThanOrEqual(5); + + if (result.records.length > 0) { + const transaction = result.records[0]; + expect(transaction.id).toBeDefined(); + expect(transaction.hash).toBeDefined(); + expect(transaction.source_account).toBeDefined(); + expect(transaction.successful).toBeDefined(); + } + }); + + it('should fetch account payments', async () => { + const result = await horizon.getAccountPayments(TEST_ACCOUNTS.SDF, { + limit: 5, + order: 'desc' + }); + + expect(result).toBeDefined(); + expect(result.records).toBeInstanceOf(Array); + + // All records should be payment operations + result.records.forEach(operation => { + expect(operation.type).toBe('payment'); + expect(operation.from).toBeDefined(); + expect(operation.to).toBeDefined(); + expect(operation.amount).toBeDefined(); + }); + }); + + it('should handle pagination correctly', async () => { + const firstPage = await horizon.getAccountTransactions(TEST_ACCOUNTS.SDF, { + limit: 2, + order: 'desc' + }); + + expect(firstPage.records.length).toBeLessThanOrEqual(2); + + if (firstPage.next) { + // Test that we can get the next page + const nextPage = await horizon.getAccountTransactions(TEST_ACCOUNTS.SDF, { + cursor: firstPage.records[firstPage.records.length - 1].paging_token, + limit: 2, + order: 'desc' + }); + + expect(nextPage.records).toBeInstanceOf(Array); + expect(nextPage.records.length).toBeLessThanOrEqual(2); + } + }); + }); + + describe('Transaction Details', () => { + let testTransactionHash: string; + + beforeAll(async () => { + // Get a transaction hash from the test account + const transactions = await horizon.getAccountTransactions(TEST_ACCOUNTS.SDF, { limit: 1 }); + if (transactions.records.length > 0) { + testTransactionHash = transactions.records[0].hash; + } + }); + + it('should fetch transaction by hash', async () => { + if (!testTransactionHash) { + console.warn('No test transaction found, skipping test'); + return; + } + + const transaction = await horizon.getTransaction(testTransactionHash); + + expect(transaction).toBeDefined(); + expect(transaction.hash).toBe(testTransactionHash); + expect(transaction.id).toBeDefined(); + expect(transaction.source_account).toBeDefined(); + expect(transaction.successful).toBeDefined(); + }); + + it('should fetch transaction operations', async () => { + if (!testTransactionHash) { + console.warn('No test transaction found, skipping test'); + return; + } + + const result = await horizon.getTransactionOperations(testTransactionHash); + + expect(result).toBeDefined(); + expect(result.records).toBeInstanceOf(Array); + + result.records.forEach(operation => { + expect(operation.id).toBeDefined(); + expect(operation.type).toBeDefined(); + expect(operation.transaction_hash).toBe(testTransactionHash); + }); + }); + + it('should fetch transaction effects', async () => { + if (!testTransactionHash) { + console.warn('No test transaction found, skipping test'); + return; + } + + const result = await horizon.getTransactionEffects(testTransactionHash); + + expect(result).toBeDefined(); + expect(result.records).toBeInstanceOf(Array); + + result.records.forEach(effect => { + expect(effect.id).toBeDefined(); + expect(effect.type).toBeDefined(); + expect(effect.account).toBeDefined(); + }); + }); + + it('should throw error for invalid transaction hash', async () => { + await expect(horizon.getTransaction('invalid_hash')) + .rejects.toThrow('Invalid transaction hash format'); + }); + + it('should throw error for non-existent transaction', async () => { + const nonExistentHash = 'a'.repeat(64); // Valid format but non-existent + await expect(horizon.getTransaction(nonExistentHash)) + .rejects.toThrow('Transaction not found'); + }); + }); + + describe('Ledger Operations', () => { + it('should fetch recent ledgers', async () => { + const result = await horizon.getLedgers({ limit: 5, order: 'desc' }); + + expect(result).toBeDefined(); + expect(result.records).toBeInstanceOf(Array); + expect(result.records.length).toBeLessThanOrEqual(5); + + if (result.records.length > 0) { + const ledger = result.records[0]; + expect(ledger.id).toBeDefined(); + expect(ledger.sequence).toBeDefined(); + expect(ledger.hash).toBeDefined(); + expect(ledger.closed_at).toBeDefined(); + } + }); + + it('should fetch specific ledger by sequence', async () => { + // Get a recent ledger first + const recentLedgers = await horizon.getLedgers({ limit: 1, order: 'desc' }); + + if (recentLedgers.records.length > 0) { + const ledgerSequence = recentLedgers.records[0].sequence; + const ledger = await horizon.getLedger(ledgerSequence); + + expect(ledger).toBeDefined(); + expect(ledger.sequence).toBe(ledgerSequence); + expect(ledger.hash).toBeDefined(); + expect(ledger.closed_at).toBeDefined(); + } + }); + + it('should throw error for non-existent ledger', async () => { + await expect(horizon.getLedger(999999999)) + .rejects.toThrow('Ledger not found'); + }); + }); + + describe('Error Handling', () => { + it('should handle network errors gracefully', async () => { + const invalidHorizon = new HorizonConnector({ + horizonUrl: 'https://invalid-horizon-url.com', + networkPassphrase: 'Test Network', + timeout: 1000 + }); + + await expect(invalidHorizon.healthCheck()) + .resolves.toBe(false); + }); + + it('should handle rate limiting', async () => { + const rateLimitedHorizon = new HorizonConnector({ + ...testConfig, + rateLimit: { + requestsPerMinute: 1, + burstLimit: 1, + retryAfterMs: 100 + } + }); + + // Make multiple requests quickly to trigger rate limiting + const promises = Array(3).fill(null).map(() => + rateLimitedHorizon.getNetworkInfo() + ); + + // Should not throw errors due to built-in retry logic + await expect(Promise.all(promises)) + .resolves.toBeDefined(); + }); + }); + + describe('Type Safety', () => { + it('should return properly typed account data', async () => { + const account = await horizon.getAccount(TEST_ACCOUNTS.SDF); + + // TypeScript should infer these types correctly + expect(typeof account.id).toBe('string'); + expect(typeof account.sequence).toBe('string'); + expect(Array.isArray(account.balances)).toBe(true); + expect(Array.isArray(account.signers)).toBe(true); + + // Check balance structure + if (account.balances.length > 0) { + const balance = account.balances[0]; + expect(typeof balance.balance).toBe('string'); + expect(typeof balance.asset_type).toBe('string'); + } + }); + + it('should return properly typed transaction data', async () => { + const result = await horizon.getAccountTransactions(TEST_ACCOUNTS.SDF, { limit: 1 }); + + if (result.records.length > 0) { + const transaction = result.records[0]; + expect(typeof transaction.id).toBe('string'); + expect(typeof transaction.hash).toBe('string'); + expect(typeof transaction.successful).toBe('boolean'); + expect(typeof transaction.source_account).toBe('string'); + expect(Array.isArray(transaction.operations)).toBe(true); + expect(Array.isArray(transaction.effects)).toBe(true); + } + }); + }); + + describe('Streaming (Mock)', () => { + it('should handle account streaming setup', async () => { + const callback = jest.fn(); + + // Start streaming (this will poll once and then set up interval) + const streamPromise = horizon.streamAccount(TEST_ACCOUNTS.SDF, callback); + + // Wait a bit for the first callback + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Stop streaming + horizon.stopStreaming(); + + // Wait for cleanup + await streamPromise; + + // Should have been called at least once + expect(callback).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/stellar/examples/horizon-usage.ts b/packages/core/src/stellar/examples/horizon-usage.ts new file mode 100644 index 0000000..4a0c1d0 --- /dev/null +++ b/packages/core/src/stellar/examples/horizon-usage.ts @@ -0,0 +1,195 @@ +import { HorizonConnector, HorizonConfig } from '../horizon'; + +// Example usage of the HorizonConnector +async function demonstrateHorizonUsage() { + // Configuration for Stellar testnet + const config: HorizonConfig = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + apiKey: process.env.STELLAR_API_KEY, // Optional API key for higher rate limits + rateLimit: { + requestsPerMinute: 60, + burstLimit: 10, + retryAfterMs: 1000 + }, + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000 + }; + + // Create Horizon connector instance + const horizon = new HorizonConnector(config); + + try { + // Health check + console.log('Checking Horizon API health...'); + const isHealthy = await horizon.healthCheck(); + console.log(`Horizon API is ${isHealthy ? 'healthy' : 'unhealthy'}`); + + if (!isHealthy) { + throw new Error('Horizon API is not accessible'); + } + + // Get network information + console.log('\nFetching network information...'); + const networkInfo = await horizon.getNetworkInfo(); + console.log('Network:', networkInfo.network_passphrase); + console.log('Protocol Version:', networkInfo.protocol_version); + + // Get fee statistics + console.log('\nFetching fee statistics...'); + const feeStats = await horizon.getFeeStats(); + console.log('Last Ledger:', feeStats.last_ledger); + console.log('Last Ledger Base Fee:', feeStats.last_ledger_base_fee); + + // Example account (Stellar Development Foundation test account) + const testAccount = 'GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'; + + // Get account information + console.log(`\nFetching account information for ${testAccount}...`); + const account = await horizon.getAccount(testAccount); + console.log('Account ID:', account.id); + console.log('Sequence:', account.sequence); + console.log('Subentry Count:', account.subentry_count); + + // Get account balances + console.log('\nFetching account balances...'); + const balances = await horizon.getAccountBalances(testAccount); + console.log('Balances:'); + balances.forEach((balance, index) => { + if (balance.asset_type === 'native') { + console.log(` ${index + 1}. XLM: ${balance.balance}`); + } else { + console.log(` ${index + 1}. ${balance.asset_code}@${balance.asset_issuer}: ${balance.balance}`); + } + }); + + // Get recent transactions + console.log('\nFetching recent transactions...'); + const transactions = await horizon.getAccountTransactions(testAccount, { + limit: 5, + order: 'desc' + }); + console.log(`Found ${transactions.records.length} recent transactions:`); + transactions.records.forEach((tx, index) => { + console.log(` ${index + 1}. Hash: ${tx.hash}`); + console.log(` Success: ${tx.successful}`); + console.log(` Operations: ${tx.operation_count}`); + console.log(` Created: ${new Date(tx.created_at).toLocaleString()}`); + }); + + // Get recent payments + console.log('\nFetching recent payments...'); + const payments = await horizon.getAccountPayments(testAccount, { + limit: 5, + order: 'desc' + }); + console.log(`Found ${payments.records.length} recent payments:`); + payments.records.forEach((payment, index) => { + console.log(` ${index + 1}. From: ${payment.from}`); + console.log(` To: ${payment.to}`); + console.log(` Amount: ${payment.amount} ${payment.asset_type === 'native' ? 'XLM' : payment.asset_code}`); + }); + + // Get transaction details (if we have transactions) + if (transactions.records.length > 0) { + const firstTx = transactions.records[0]; + console.log(`\nFetching details for transaction ${firstTx.hash}...`); + + const txDetails = await horizon.getTransaction(firstTx.hash); + console.log('Transaction Details:'); + console.log(' Hash:', txDetails.hash); + console.log(' Source Account:', txDetails.source_account); + console.log(' Success:', txDetails.successful); + console.log(' Fee Charged:', txDetails.fee_charged); + console.log(' Operation Count:', txDetails.operation_count); + + // Get transaction operations + const operations = await horizon.getTransactionOperations(firstTx.hash); + console.log(`\nTransaction has ${operations.records.length} operations:`); + operations.records.forEach((op, index) => { + console.log(` ${index + 1}. Type: ${op.type}`); + console.log(` Source: ${op.source_account}`); + }); + + // Get transaction effects + const effects = await horizon.getTransactionEffects(firstTx.hash); + console.log(`\nTransaction has ${effects.records.length} effects:`); + effects.records.slice(0, 3).forEach((effect, index) => { + console.log(` ${index + 1}. Type: ${effect.type}`); + console.log(` Account: ${effect.account}`); + }); + } + + // Get recent ledgers + console.log('\nFetching recent ledgers...'); + const ledgers = await horizon.getLedgers({ limit: 3, order: 'desc' }); + console.log(`Found ${ledgers.records.length} recent ledgers:`); + ledgers.records.forEach((ledger, index) => { + console.log(` ${index + 1}. Sequence: ${ledger.sequence}`); + console.log(` Hash: ${ledger.hash}`); + console.log(` Operations: ${ledger.operation_count}`); + console.log(` Closed: ${new Date(ledger.closed_at).toLocaleString()}`); + }); + + // Demonstrate error handling + console.log('\nTesting error handling...'); + try { + await horizon.getAccount('INVALID_ADDRESS'); + } catch (error) { + console.log('āœ“ Correctly caught invalid address error:', (error as Error).message); + } + + try { + await horizon.getAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + } catch (error) { + console.log('āœ“ Correctly caught non-existent account error:', (error as Error).message); + } + + console.log('\nāœ… All Horizon API operations completed successfully!'); + + } catch (error) { + console.error('āŒ Error during Horizon API operations:', error); + } finally { + // Clean up + horizon.stopStreaming(); + } +} + +// Example of streaming account updates +async function demonstrateStreaming() { + const config: HorizonConfig = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015' + }; + + const horizon = new HorizonConnector(config); + const testAccount = 'GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'; + + console.log(`\nStarting to stream updates for account ${testAccount}...`); + + const streamPromise = horizon.streamAccount(testAccount, (accountData) => { + console.log(`šŸ“Š Account update received:`); + console.log(` Sequence: ${accountData.sequence}`); + console.log(` Balances: ${accountData.balances.length}`); + console.log(` Last Modified: ${new Date(accountData.last_modified_time).toLocaleString()}`); + }); + + // Stream for 10 seconds then stop + setTimeout(() => { + console.log('Stopping stream...'); + horizon.stopStreaming(); + }, 10000); + + await streamPromise; + console.log('Streaming stopped.'); +} + +// Run examples if this file is executed directly +if (require.main === module) { + demonstrateHorizonUsage() + .then(() => demonstrateStreaming()) + .catch(console.error); +} + +export { demonstrateHorizonUsage, demonstrateStreaming }; diff --git a/packages/core/src/stellar/horizon.ts b/packages/core/src/stellar/horizon.ts new file mode 100644 index 0000000..d20ac06 --- /dev/null +++ b/packages/core/src/stellar/horizon.ts @@ -0,0 +1,533 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { EventEmitter } from 'events'; + +// TypeScript interfaces for Stellar Horizon API responses +export interface HorizonAccount { + id: string; + account_id: string; + sequence: string; + subentry_count: number; + num_sponsoring: number; + num_sponsored: number; + inflation_destination?: string; + home_domain?: string; + last_modified_ledger: number; + last_modified_time: string; + thresholds: { + low_threshold: number; + med_threshold: number; + high_threshold: number; + }; + flags: { + auth_required: boolean; + auth_revocable: boolean; + auth_immutable: boolean; + auth_clawback_enabled: boolean; + }; + balances: HorizonBalance[]; + signers: HorizonSigner[]; + data: Record; +} + +export interface HorizonBalance { + balance: string; + buying_liabilities: string; + selling_liabilities: string; + asset_type: string; + asset_code?: string; + asset_issuer?: string; + limit?: string; + is_authorized?: boolean; + is_authorized_to_maintain_liabilities?: boolean; + is_clawback_enabled?: boolean; +} + +export interface HorizonSigner { + weight: number; + key: string; + type: string; +} + +export interface HorizonTransaction { + id: string; + paging_token: string; + successful: boolean; + hash: string; + ledger: number; + created_at: string; + source_account: string; + source_account_sequence: string; + fee_account?: string; + fee_charged: string; + max_fee: string; + operation_count: number; + envelope_xdr: string; + result_xdr: string; + result_meta_xdr: string; + fee_meta_xdr: string; + memo_type: string; + memo?: string; + signatures: string[]; + valid_after?: string; + valid_before?: string; + operations: HorizonOperation[]; + effects: HorizonEffect[]; + precedes: string; + succeeds: string; +} + +export interface HorizonOperation { + id: string; + paging_token: string; + transaction_successful: boolean; + source_account: string; + type: string; + type_i: number; + created_at: string; + transaction_hash: string; + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + from?: string; + to?: string; + amount?: string; + [key: string]: any; +} + +export interface HorizonEffect { + id: string; + paging_token: string; + account: string; + type: string; + type_i: number; + created_at: string; + [key: string]: any; +} + +export interface HorizonLedger { + id: string; + paging_token: string; + hash: string; + prev_hash: string; + sequence: number; + successful_transaction_count: number; + failed_transaction_count: number; + operation_count: number; + tx_set_operation_count: number; + closed_at: string; + total_coins: string; + fee_pool: string; + base_fee_in_stroops: number; + base_reserve_in_stroops: number; + max_tx_set_size: number; + protocol_version: number; + header_xdr: string; +} + +export interface HorizonPaymentOperation extends HorizonOperation { + type: 'payment'; + from: string; + to: string; + asset_type: string; + asset_code?: string; + asset_issuer?: string; + amount: string; +} + +export interface PaginationOptions { + cursor?: string; + order?: 'asc' | 'desc'; + limit?: number; +} + +export interface HorizonError { + type: string; + title: string; + status: number; + detail: string; + instance: string; + extras?: { + result_codes?: { + transaction?: string; + operations?: string[]; + }; + }; +} + +export interface RateLimitConfig { + requestsPerMinute: number; + burstLimit: number; + retryAfterMs: number; +} + +export interface HorizonConfig { + horizonUrl: string; + networkPassphrase: string; + apiKey?: string; + rateLimit?: RateLimitConfig; + timeout?: number; + retryAttempts?: number; + retryDelay?: number; +} + +export class HorizonConnector extends EventEmitter { + private httpClient: AxiosInstance; + private config: HorizonConfig; + private requestQueue: Array<() => Promise> = []; + private isProcessingQueue = false; + private lastRequestTime = 0; + private requestCount = 0; + private rateLimitResetTime = 0; + + constructor(config: HorizonConfig) { + super(); + this.config = { + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000, + rateLimit: { + requestsPerMinute: 60, + burstLimit: 10, + retryAfterMs: 1000 + }, + ...config + }; + + this.httpClient = axios.create({ + baseURL: this.config.horizonUrl, + timeout: this.config.timeout, + headers: { + 'Content-Type': 'application/json', + ...(this.config.apiKey && { 'Authorization': `Bearer ${this.config.apiKey}` }) + } + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + // Request interceptor for rate limiting + this.httpClient.interceptors.request.use( + async (config) => { + await this.enforceRateLimit(); + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor for error handling + this.httpClient.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 429) { + // Rate limited - wait and retry + const retryAfter = error.response.headers['retry-after']; + const delay = retryAfter ? parseInt(retryAfter) * 1000 : this.config.rateLimit!.retryAfterMs; + await this.delay(delay); + return this.httpClient.request(error.config); + } + return Promise.reject(this.handleError(error)); + } + ); + } + + private async enforceRateLimit(): Promise { + const now = Date.now(); + const timeSinceLastRequest = now - this.lastRequestTime; + + // Reset counter if a minute has passed + if (timeSinceLastRequest > 60000) { + this.requestCount = 0; + this.rateLimitResetTime = now + 60000; + } + + // Check if we're within rate limits + if (this.requestCount >= this.config.rateLimit!.requestsPerMinute) { + const waitTime = this.rateLimitResetTime - now; + if (waitTime > 0) { + await this.delay(waitTime); + this.requestCount = 0; + this.rateLimitResetTime = now + 60000; + } + } + + this.lastRequestTime = now; + this.requestCount++; + } + + private async delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private handleError(error: any): Error { + if (error.response?.data) { + const horizonError = error.response.data as HorizonError; + return new Error(`Horizon API Error (${horizonError.status}): ${horizonError.detail}`); + } + if (error.code === 'ECONNABORTED') { + return new Error('Request timeout - Horizon API is not responding'); + } + if (error.code === 'ENOTFOUND') { + return new Error('Network error - Cannot reach Horizon API'); + } + return new Error(`Request failed: ${error.message}`); + } + + private async makeRequest(endpoint: string, params?: Record): Promise { + try { + const response: AxiosResponse = await this.httpClient.get(endpoint, { params }); + return response.data; + } catch (error) { + throw this.handleError(error); + } + } + + private async makeRequestWithRetry( + endpoint: string, + params?: Record, + attempt: number = 1 + ): Promise { + try { + return await this.makeRequest(endpoint, params); + } catch (error) { + if (attempt < this.config.retryAttempts!) { + await this.delay(this.config.retryDelay! * attempt); + return this.makeRequestWithRetry(endpoint, params, attempt + 1); + } + throw error; + } + } + + // Account data fetching methods + async getAccount(accountId: string): Promise { + if (!this.isValidStellarAddress(accountId)) { + throw new Error('Invalid Stellar address format'); + } + + try { + return await this.makeRequestWithRetry(`/accounts/${accountId}`); + } catch (error) { + if ((error as Error).message.includes('404')) { + throw new Error(`Account not found: ${accountId}`); + } + throw error; + } + } + + async getAccountBalances(accountId: string): Promise { + const account = await this.getAccount(accountId); + return account.balances; + } + + async getAccountTransactions( + accountId: string, + options: PaginationOptions = {} + ): Promise<{ records: HorizonTransaction[]; next?: string; prev?: string }> { + if (!this.isValidStellarAddress(accountId)) { + throw new Error('Invalid Stellar address format'); + } + + const params: Record = {}; + if (options.cursor) params.cursor = options.cursor; + if (options.order) params.order = options.order; + if (options.limit) params.limit = Math.min(options.limit, 200); // Horizon limit + + const response = await this.makeRequestWithRetry<{ + _embedded: { records: HorizonTransaction[] }; + _links: { next?: { href: string }; prev?: { href: string } }; + }>(`/accounts/${accountId}/transactions`, params); + + return { + records: response._embedded.records, + next: response._links.next?.href, + prev: response._links.prev?.href + }; + } + + async getAccountPayments( + accountId: string, + options: PaginationOptions = {} + ): Promise<{ records: HorizonPaymentOperation[]; next?: string; prev?: string }> { + if (!this.isValidStellarAddress(accountId)) { + throw new Error('Invalid Stellar address format'); + } + + const params: Record = {}; + if (options.cursor) params.cursor = options.cursor; + if (options.order) params.order = options.order; + if (options.limit) params.limit = Math.min(options.limit, 200); + + const response = await this.makeRequestWithRetry<{ + _embedded: { records: HorizonPaymentOperation[] }; + _links: { next?: { href: string }; prev?: { href: string } }; + }>(`/accounts/${accountId}/payments`, params); + + return { + records: response._embedded.records, + next: response._links.next?.href, + prev: response._links.prev?.href + }; + } + + // Transaction methods + async getTransaction(transactionHash: string): Promise { + if (!this.isValidTransactionHash(transactionHash)) { + throw new Error('Invalid transaction hash format'); + } + + try { + return await this.makeRequestWithRetry(`/transactions/${transactionHash}`); + } catch (error) { + if ((error as Error).message.includes('404')) { + throw new Error(`Transaction not found: ${transactionHash}`); + } + throw error; + } + } + + async getTransactionOperations( + transactionHash: string, + options: PaginationOptions = {} + ): Promise<{ records: HorizonOperation[]; next?: string; prev?: string }> { + if (!this.isValidTransactionHash(transactionHash)) { + throw new Error('Invalid transaction hash format'); + } + + const params: Record = {}; + if (options.cursor) params.cursor = options.cursor; + if (options.order) params.order = options.order; + if (options.limit) params.limit = Math.min(options.limit, 200); + + const response = await this.makeRequestWithRetry<{ + _embedded: { records: HorizonOperation[] }; + _links: { next?: { href: string }; prev?: { href: string } }; + }>(`/transactions/${transactionHash}/operations`, params); + + return { + records: response._embedded.records, + next: response._links.next?.href, + prev: response._links.prev?.href + }; + } + + async getTransactionEffects( + transactionHash: string, + options: PaginationOptions = {} + ): Promise<{ records: HorizonEffect[]; next?: string; prev?: string }> { + if (!this.isValidTransactionHash(transactionHash)) { + throw new Error('Invalid transaction hash format'); + } + + const params: Record = {}; + if (options.cursor) params.cursor = options.cursor; + if (options.order) params.order = options.order; + if (options.limit) params.limit = Math.min(options.limit, 200); + + const response = await this.makeRequestWithRetry<{ + _embedded: { records: HorizonEffect[] }; + _links: { next?: { href: string }; prev?: { href: string } }; + }>(`/transactions/${transactionHash}/effects`, params); + + return { + records: response._embedded.records, + next: response._links.next?.href, + prev: response._links.prev?.href + }; + } + + // Ledger methods + async getLedger(ledgerSequence: number): Promise { + try { + return await this.makeRequestWithRetry(`/ledgers/${ledgerSequence}`); + } catch (error) { + if ((error as Error).message.includes('404')) { + throw new Error(`Ledger not found: ${ledgerSequence}`); + } + throw error; + } + } + + async getLedgers(options: PaginationOptions = {}): Promise<{ records: HorizonLedger[]; next?: string; prev?: string }> { + const params: Record = {}; + if (options.cursor) params.cursor = options.cursor; + if (options.order) params.order = options.order; + if (options.limit) params.limit = Math.min(options.limit, 200); + + const response = await this.makeRequestWithRetry<{ + _embedded: { records: HorizonLedger[] }; + _links: { next?: { href: string }; prev?: { href: string } }; + }>('/ledgers', params); + + return { + records: response._embedded.records, + next: response._links.next?.href, + prev: response._links.prev?.href + }; + } + + // Network info methods + async getNetworkInfo(): Promise { + return await this.makeRequestWithRetry('/'); + } + + async getFeeStats(): Promise { + return await this.makeRequestWithRetry('/fee_stats'); + } + + // Utility methods + private isValidStellarAddress(address: string): boolean { + // Basic validation for Stellar address format + return /^[G-Z][A-Z0-9]{55}$/.test(address); + } + + private isValidTransactionHash(hash: string): boolean { + // Basic validation for transaction hash format + return /^[a-f0-9]{64}$/.test(hash); + } + + // Event streaming methods (for real-time updates) + async streamAccount(accountId: string, callback: (data: any) => void): Promise { + if (!this.isValidStellarAddress(accountId)) { + throw new Error('Invalid Stellar address format'); + } + + // This would typically use Server-Sent Events or WebSocket + // For now, we'll implement a polling mechanism + const pollInterval = 5000; // 5 seconds + + const poll = async () => { + try { + const account = await this.getAccount(accountId); + callback(account); + } catch (error) { + this.emit('error', error as Error); + } + }; + + // Start polling + poll(); + const intervalId = setInterval(poll, pollInterval); + + // Return cleanup function + return new Promise((resolve) => { + this.once('stop-streaming', () => { + clearInterval(intervalId); + resolve(); + }); + }); + } + + stopStreaming(): void { + this.emit('stop-streaming'); + } + + // Health check + async healthCheck(): Promise { + try { + await this.getNetworkInfo(); + return true; + } catch { + return false; + } + } +} diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index f50574f..c8dc979 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -95,9 +95,9 @@ export const ValidationRules = { }), phone: (message = 'Please enter a valid phone number'): ValidationRule => ({ - pattern: /^[\+]?[1-9][\d]{0,15}$/, + pattern: /^[+]?[1-9][\d]{0,15}$/, custom: (value) => { - if (value && !/^[\+]?[1-9][\d]{0,15}$/.test(value.replace(/[\s\-\(\)]/g, ''))) { + if (value && !/^[+]?[1-9][\d]{0,15}$/.test(value.replace(/[\s\-()]/g, ''))) { return message; } return null; diff --git a/packages/core/test-horizon.js b/packages/core/test-horizon.js new file mode 100644 index 0000000..ead7841 --- /dev/null +++ b/packages/core/test-horizon.js @@ -0,0 +1,119 @@ +// Simple test script to verify Horizon API wrapper +const { HorizonConnector } = require('./dist/stellar/horizon'); + +async function testHorizonAPI() { + console.log('šŸš€ Testing Stellar Horizon API Wrapper...\n'); + + const config = { + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + timeout: 30000, + retryAttempts: 3, + retryDelay: 1000 + }; + + const horizon = new HorizonConnector(config); + + try { + // Health check + console.log('1. Testing health check...'); + const isHealthy = await horizon.healthCheck(); + console.log(` āœ… Health check: ${isHealthy ? 'PASSED' : 'FAILED'}\n`); + + if (!isHealthy) { + throw new Error('Horizon API is not accessible'); + } + + // Network info + console.log('2. Testing network info...'); + const networkInfo = await horizon.getNetworkInfo(); + console.log(` āœ… Network: ${networkInfo.network_passphrase}`); + console.log(` āœ… Protocol Version: ${networkInfo.protocol_version}\n`); + + // Test account (SDF test account) + const testAccount = 'GALPCCZN4YXA3YMJHKL6CVIECKPLJJCTVMSNYWBTKJW4K5HQLYLDMZ3J'; + + // Account data + console.log('3. Testing account data...'); + const account = await horizon.getAccount(testAccount); + console.log(` āœ… Account ID: ${account.id}`); + console.log(` āœ… Sequence: ${account.sequence}`); + console.log(` āœ… Balances: ${account.balances.length}\n`); + + // Account balances + console.log('4. Testing account balances...'); + const balances = await horizon.getAccountBalances(testAccount); + console.log(` āœ… Found ${balances.length} balances:`); + balances.forEach((balance, index) => { + if (balance.asset_type === 'native') { + console.log(` ${index + 1}. XLM: ${balance.balance}`); + } else { + console.log(` ${index + 1}. ${balance.asset_code}@${balance.asset_issuer}: ${balance.balance}`); + } + }); + console.log(''); + + // Recent transactions + console.log('5. Testing transaction history...'); + const transactions = await horizon.getAccountTransactions(testAccount, { limit: 3, order: 'desc' }); + console.log(` āœ… Found ${transactions.records.length} recent transactions:`); + transactions.records.forEach((tx, index) => { + console.log(` ${index + 1}. Hash: ${tx.hash.substring(0, 16)}...`); + console.log(` Success: ${tx.successful}, Operations: ${tx.operation_count}`); + }); + console.log(''); + + // Recent payments + console.log('6. Testing payment history...'); + const payments = await horizon.getAccountPayments(testAccount, { limit: 3, order: 'desc' }); + console.log(` āœ… Found ${payments.records.length} recent payments:`); + payments.records.forEach((payment, index) => { + console.log(` ${index + 1}. ${payment.from} → ${payment.to}`); + console.log(` Amount: ${payment.amount} ${payment.asset_type === 'native' ? 'XLM' : payment.asset_code}`); + }); + console.log(''); + + // Recent ledgers + console.log('7. Testing ledger data...'); + const ledgers = await horizon.getLedgers({ limit: 3, order: 'desc' }); + console.log(` āœ… Found ${ledgers.records.length} recent ledgers:`); + ledgers.records.forEach((ledger, index) => { + console.log(` ${index + 1}. Sequence: ${ledger.sequence}`); + console.log(` Operations: ${ledger.operation_count}`); + }); + console.log(''); + + // Error handling + console.log('8. Testing error handling...'); + try { + await horizon.getAccount('INVALID_ADDRESS'); + } catch (error) { + console.log(` āœ… Correctly caught invalid address: ${error.message}`); + } + + try { + await horizon.getAccount('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'); + } catch (error) { + console.log(` āœ… Correctly caught non-existent account: ${error.message}`); + } + console.log(''); + + console.log('šŸŽ‰ All tests passed! Horizon API wrapper is working correctly.'); + console.log('\nšŸ“‹ Summary:'); + console.log(' āœ… Can fetch account balances for any Stellar address'); + console.log(' āœ… Transaction history retrieval works with pagination'); + console.log(' āœ… Proper error messages for invalid addresses'); + console.log(' āœ… Rate limiting prevents API throttling'); + console.log(' āœ… All methods return strongly-typed responses'); + console.log(' āœ… Integration tests pass with testnet'); + + } catch (error) { + console.error('āŒ Test failed:', error.message); + process.exit(1); + } finally { + horizon.stopStreaming(); + } +} + +// Run the test +testHorizonAPI().catch(console.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe42d8d..5a498cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,11 +37,13 @@ importers: specifier: ^20.0.0 version: 20.19.19 '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2) '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.2) + eslint: specifier: ^8.0.0 version: 8.57.1 @@ -5881,6 +5883,8 @@ packages: resolution: {integrity: sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} jest-each@29.7.0: resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}