From c7154fe65c966e23725a8f1464c2136812670bda Mon Sep 17 00:00:00 2001 From: lawsonemmanuel207-hash Date: Tue, 16 Jun 2026 15:50:21 +0100 Subject: [PATCH] Stellar.toml Discoverability Endpoint Added GET /.well-known/stellar.toml via a new StellarTomlController so that Stellar wallets (Lobstr, StellarExpert) can auto-discover StepFi's federation file from the API domain. The endpoint returns a TOML body with org metadata (ORG_NAME, ORG_URL, ORG_GITHUB) and a [[CONTRACTS]] block listing all 4 contract IDs (CREDIT_LINE_CONTRACT_ID, LIQUIDITY_POOL_CONTRACT_ID, REPUTATION_CONTRACT_ID, VENDOR_REGISTRY_CONTRACT_ID) read from ConfigService. Response is cached in-memory with a 1-hour TTL. The route is excluded from the global API prefix and includes Content-Type: text/plain and Access-Control-Allow-Origin: * headers. Full Swagger decorators and a passing e2e test are included. Closed #15 --- context/progress-tracker.md | 19 +++-- src/config/swagger.ts | 1 + src/main.ts | 2 +- src/modules/health/health.module.ts | 3 +- src/modules/health/stellar-toml.controller.ts | 82 +++++++++++++++++++ test/e2e/stellar-toml.e2e-spec.ts | 72 ++++++++++++++++ 6 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 src/modules/health/stellar-toml.controller.ts create mode 100644 test/e2e/stellar-toml.e2e-spec.ts diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 260b8fb..acebec8 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -54,20 +54,21 @@ EAS build for Expo preview → then landing page → then GitHub issues → then ## In Progress -- None currently. +- **Stellar.toml endpoint** — Added `GET /.well-known/stellar.toml` via `StellarTomlController` for Stellar ecosystem discoverability (Lobstr, StellarExpert). Returns TOML metadata with org info and 4 contract IDs. Cached in-memory with 1-hour TTL. (`stellar-toml.controller.ts`, `health.module.ts`, `stellar-toml.e2e-spec.ts`) --- ## Next Up (In Order) -1. **LoanType enum** — Add `LoanType::LearnerInstallment` variant to `creditline-contract/src/types.rs` -2. **Per-installment tracking** — Add `paid: bool` and `paid_at: u64` fields to `RepaymentInstallment` struct -3. **repay_installment()** — New function targeting a specific installment by index (instead of just reducing remaining balance) -4. **Learner grace period** — Make `grace_period_seconds` per-loan (not just global via parameters) -5. **Vouching contract** — New `vouching-contract` crate: `vouch()`, `revoke_vouch()`, `get_vouches()`, `get_vouch_count()` -6. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment` -7. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env` -8. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI +1. **Stellar.toml endpoint** ✅ — `GET /.well-known/stellar.toml` deployed with org metadata and all 4 contract IDs; cached 1-hour TTL +2. **LoanType enum** — Add `LoanType::LearnerInstallment` variant to `creditline-contract/src/types.rs` +3. **Per-installment tracking** — Add `paid: bool` and `paid_at: u64` fields to `RepaymentInstallment` struct +4. **repay_installment()** — New function targeting a specific installment by index (instead of just reducing remaining balance) +5. **Learner grace period** — Make `grace_period_seconds` per-loan (not just global via parameters) +6. **Vouching contract** — New `vouching-contract` crate: `vouch()`, `revoke_vouch()`, `get_vouches()`, `get_vouch_count()` +7. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment` +8. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env` +9. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI --- diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 6d0dbf6..08dbc90 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -58,6 +58,7 @@ export function setupSwagger(app: INestApplication): void { .addTag('transactions', 'Stellar transaction submission and status tracking') .addTag('notifications', 'User notifications — list, read, mark as read') .addTag('health', 'System health checks (database, Horizon, indexer, Redis, BullMQ)') + .addTag('stellar', 'Stellar ecosystem metadata (stellar.toml)') .build(); const document = SwaggerModule.createDocument(app, config, { deepScanRoutes: true }); diff --git a/src/main.ts b/src/main.ts index 30d50ef..d26f5cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ async function bootstrap() { const port = process.env.PORT || 4000; const apiPrefix = process.env.API_PREFIX || 'api/v1'; - app.setGlobalPrefix(apiPrefix, { exclude: ['metrics'] }); + app.setGlobalPrefix(apiPrefix, { exclude: ['metrics', '.well-known/stellar.toml'] }); app.enableCors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index e10bd25..ec42394 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bullmq'; import { HealthController } from './health.controller'; +import { StellarTomlController } from './stellar-toml.controller'; import { HealthService } from './health.service'; import { SupabaseService } from '../../database/supabase.client'; @@ -13,7 +14,7 @@ import { SupabaseService } from '../../database/supabase.client'; { name: 'nonce-cleanup' }, ), ], - controllers: [HealthController], + controllers: [HealthController, StellarTomlController], providers: [HealthService, SupabaseService], }) export class HealthModule {} diff --git a/src/modules/health/stellar-toml.controller.ts b/src/modules/health/stellar-toml.controller.ts new file mode 100644 index 0000000..5a176a2 --- /dev/null +++ b/src/modules/health/stellar-toml.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Get, Logger, Header } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { + CREDIT_LINE_CONTRACT_ID_KEY, + REPUTATION_CONTRACT_ID_KEY, + LIQUIDITY_POOL_CONTRACT_ID_KEY, + VENDOR_REGISTRY_CONTRACT_ID_KEY, +} from '../../stellar/contracts/interfaces'; + +@ApiTags('stellar') +@Controller() +export class StellarTomlController { + private readonly logger = new Logger(StellarTomlController.name); + private cachedToml: string | null = null; + private cacheExpiry = 0; + private readonly CACHE_TTL_MS = 60 * 60 * 1000; + + constructor(private readonly configService: ConfigService) {} + + @Get('.well-known/stellar.toml') + @Header('Content-Type', 'text/plain; charset=utf-8') + @Header('Access-Control-Allow-Origin', '*') + @ApiOperation({ summary: 'Stellar ecosystem metadata file' }) + @ApiResponse({ + status: 200, + description: 'Stellar.toml metadata returned', + content: { 'text/plain': { schema: { type: 'string' } } }, + }) + getStellarToml(): string { + const now = Date.now(); + if (this.cachedToml && now < this.cacheExpiry) { + return this.cachedToml; + } + + const orgName = + this.configService.get('ORG_NAME') || 'StepFi'; + const website = + this.configService.get('ORG_URL') || 'https://stepfi.com'; + const github = + this.configService.get('ORG_GITHUB') || 'https://github.com/StepFi'; + + const creditLineId = + this.configService.get(CREDIT_LINE_CONTRACT_ID_KEY) || ''; + const liquidityPoolId = + this.configService.get(LIQUIDITY_POOL_CONTRACT_ID_KEY) || ''; + const reputationId = + this.configService.get(REPUTATION_CONTRACT_ID_KEY) || ''; + const vendorRegistryId = + this.configService.get(VENDOR_REGISTRY_CONTRACT_ID_KEY) || ''; + + const toml = [ + '# StepFi', + 'ORG_NAME="' + orgName + '"', + 'ORG_URL="' + website + '"', + 'ORG_GITHUB="' + github + '"', + '', + '[[CONTRACTS]]', + 'id = "' + creditLineId + '"', + 'name = "Credit Line"', + '', + '[[CONTRACTS]]', + 'id = "' + liquidityPoolId + '"', + 'name = "Liquidity Pool"', + '', + '[[CONTRACTS]]', + 'id = "' + reputationId + '"', + 'name = "Reputation"', + '', + '[[CONTRACTS]]', + 'id = "' + vendorRegistryId + '"', + 'name = "Vendor Registry"', + '', + ].join('\n'); + + this.cachedToml = toml; + this.cacheExpiry = now + this.CACHE_TTL_MS; + this.logger.log('Stellar.toml cache refreshed'); + + return toml; + } +} diff --git a/test/e2e/stellar-toml.e2e-spec.ts b/test/e2e/stellar-toml.e2e-spec.ts new file mode 100644 index 0000000..e89cb46 --- /dev/null +++ b/test/e2e/stellar-toml.e2e-spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import * as request from 'supertest'; +import { HealthModule } from '../../src/modules/health/health.module'; +import { SupabaseService } from '../../src/database/supabase.client'; + +describe('StellarTomlController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + process.env.SUPABASE_URL = 'https://test.supabase.co'; + process.env.SUPABASE_ANON_KEY = 'test-anon-key'; + process.env.CREDIT_LINE_CONTRACT_ID = 'C_CREDIT_LINE_TEST'; + process.env.LIQUIDITY_POOL_CONTRACT_ID = 'C_LIQUIDITY_POOL_TEST'; + process.env.REPUTATION_CONTRACT_ID = 'C_REPUTATION_TEST'; + process.env.VENDOR_REGISTRY_CONTRACT_ID = 'C_VENDOR_REGISTRY_TEST'; + + const mockSupabase = { + getClient: jest.fn().mockReturnValue({ auth: { getSession: jest.fn().mockResolvedValue({}) } }), + getServiceRoleClient: jest.fn().mockReturnValue({ from: jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ order: jest.fn().mockReturnValue({ limit: jest.fn().mockReturnValue({ single: jest.fn().mockResolvedValue({ data: null }) }) }) }) }) }), + }; + + const mockQueue = { + client: Promise.resolve({ ping: jest.fn().mockResolvedValue('PONG') }), + getWaitingCount: jest.fn().mockResolvedValue(0), + getActiveCount: jest.fn().mockResolvedValue(0), + getDelayedCount: jest.fn().mockResolvedValue(0), + getFailedCount: jest.fn().mockResolvedValue(0), + name: 'test', + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ isGlobal: true }), HealthModule], + }) + .overrideProvider(SupabaseService) + .useValue(mockSupabase) + .overrideProvider('BullQueue_blockchain-indexer') + .useValue(mockQueue) + .overrideProvider('BullQueue_payment-reminders') + .useValue(mockQueue) + .overrideProvider('BullQueue_transaction-status-checker') + .useValue(mockQueue) + .overrideProvider('BullQueue_nonce-cleanup') + .useValue(mockQueue) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/.well-known/stellar.toml (GET)', () => { + it('should return 200 with text/plain content', () => { + return request(app.getHttpServer()) + .get('/.well-known/stellar.toml') + .expect(200) + .expect('Content-Type', /text\/plain/) + .expect('Access-Control-Allow-Origin', '*') + .expect((res) => { + expect(res.text).toContain('C_CREDIT_LINE_TEST'); + expect(res.text).toContain('C_LIQUIDITY_POOL_TEST'); + expect(res.text).toContain('C_REPUTATION_TEST'); + expect(res.text).toContain('C_VENDOR_REGISTRY_TEST'); + expect(res.text).toContain('ORG_NAME="StepFi"'); + }); + }); + }); +});