Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
1 change: 1 addition & 0 deletions src/config/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(',') || '*',
Expand Down
3 changes: 2 additions & 1 deletion src/modules/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,7 +14,7 @@ import { SupabaseService } from '../../database/supabase.client';
{ name: 'nonce-cleanup' },
),
],
controllers: [HealthController],
controllers: [HealthController, StellarTomlController],
providers: [HealthService, SupabaseService],
})
export class HealthModule {}
82 changes: 82 additions & 0 deletions src/modules/health/stellar-toml.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string>('ORG_NAME') || 'StepFi';
const website =
this.configService.get<string>('ORG_URL') || 'https://stepfi.com';
const github =
this.configService.get<string>('ORG_GITHUB') || 'https://github.com/StepFi';

const creditLineId =
this.configService.get<string>(CREDIT_LINE_CONTRACT_ID_KEY) || '';
const liquidityPoolId =
this.configService.get<string>(LIQUIDITY_POOL_CONTRACT_ID_KEY) || '';
const reputationId =
this.configService.get<string>(REPUTATION_CONTRACT_ID_KEY) || '';
const vendorRegistryId =
this.configService.get<string>(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;
}
}
72 changes: 72 additions & 0 deletions test/e2e/stellar-toml.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
});
});