Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
ee269fb
build(deps): bump soroban-sdk in /app/contract in the cargo-deps group
dependabot[bot] Aug 10, 2026
c47da6c
build(deps): bump the npm-deps group with 92 updates
dependabot[bot] Aug 10, 2026
fc98755
feat(contract): Implement emergency mode, metadata endpoints, memo su…
topsonDev Aug 26, 2026
fd03a94
feat(contract): Implement comprehensive hook registry, per-asset fees…
popsman01 Aug 26, 2026
c685592
feat(contract): Implement stealth address privacy v2, escrow TTL exte…
psamist001 Aug 26, 2026
49474ec
feat(mobile): Implement crash reporting, app updates, enhanced scan-t…
Emmyt24 Aug 26, 2026
fedf16b
feat: Implement granular pause policy, fee collector rotation with hi…
miracle605 Aug 26, 2026
623581b
feat(contract): Add fuzzing harness, batch cleanup, and mainnet deplo…
temma02 Aug 26, 2026
d0b328c
feat(mobile): improve security and wallet sessions
Saharadigitals256 Aug 26, 2026
b696e23
feat: connect real workflow data and sync
minorkime-design Aug 26, 2026
1c3d4ab
feat(mobile): add payment utility features
ochojilalucyochanya-byte Aug 26, 2026
4bd309a
feat(mobile): expand core app experiences
Pidoko257 Aug 26, 2026
edaae68
feat: improve dashboard UX and accessibility
whitman21 Aug 26, 2026
157d272
feat(mobile): improve onboarding and transaction UX
princestevealo Aug 26, 2026
1f021a9
feat: add transaction filters haptics deep links and push registration
silver257-web Aug 27, 2026
de38a76
fix(mobile): complete security and runtime flows
yungjay21 Aug 27, 2026
2b0057d
Merge pull request #144 from yungjay21/fix/mobile-runtime-polish
Viky207 Aug 27, 2026
1b11001
Merge pull request #143 from silver257-web/feature/complete-notificat…
Viky207 Aug 27, 2026
7b3c936
Merge pull request #142 from princestevealo/feat/mobile-ux-improvements
Viky207 Aug 27, 2026
326a2e8
Merge pull request #140 from whitman21/feat/dashboard-ux-improvements
Viky207 Aug 27, 2026
b0afc00
Merge pull request #139 from Pidoko257/feat/mobile-experience-updates
Viky207 Aug 27, 2026
cafad63
Merge pull request #138 from ochojilalucyochanya-byte/feature/mobile-…
Viky207 Aug 27, 2026
454cea0
Merge pull request #137 from temma02/feature/fuzzing-cleanup-deployment
Viky207 Aug 27, 2026
ddc1eca
Merge pull request #133 from psamist001/feature/privacy-ttl-nonce-evi…
Viky207 Aug 27, 2026
14c8660
Merge pull request #1 from Viky207/dependabot/cargo/app/contract/carg…
Viky207 Aug 27, 2026
ab5ab44
Merge pull request #2 from Viky207/dependabot/npm_and_yarn/npm-deps-0…
Viky207 Aug 27, 2026
7a0b24c
Merge pull request #131 from topsonDev/feature/contract-enhancements-…
Viky207 Aug 27, 2026
4300462
feat: error envelope, Redis sliding-window rate limit, Horizon circui…
smokeylenz1-commits Aug 27, 2026
2c1f61d
feat(backend): webhook retry/DLQ, composite health, env rate limits, …
kenedybokephraim-boop Aug 27, 2026
0877a2b
Merge branch 'main' into feature/tasks-108-109-110-111
Viky207 Aug 27, 2026
9f7529e
Merge pull request #130 from popsman01/feature/tasks-108-109-110-111
Viky207 Aug 27, 2026
c25d78b
Merge branch 'main' into feature/multi-feature-implementation
Viky207 Aug 27, 2026
e123a7e
Merge pull request #132 from miracle605/feature/multi-feature-impleme…
Viky207 Aug 27, 2026
7a3de98
Merge branch 'main' into feat/mobile-features-100-101-102-103
Viky207 Aug 27, 2026
9be1553
Merge pull request #134 from Emmyt24/feat/mobile-features-100-101-102…
Viky207 Aug 27, 2026
2741b86
Merge branch 'main' into feature/mobile-security-session-network
Viky207 Aug 27, 2026
e7263f4
Merge pull request #135 from Saharadigitals256/feature/mobile-securit…
Viky207 Aug 27, 2026
bdbfdfe
Merge branch 'main' into feature/real-workflows-sync
Viky207 Aug 27, 2026
db599d6
Merge pull request #136 from minorkime-design/feature/real-workflows-…
Viky207 Aug 27, 2026
ebb2ff8
Merge branch 'main' into feature/error-envelope-swagger-validation
Viky207 Aug 27, 2026
29e1f3b
Merge pull request #146 from kenedybokephraim-boop/feature/webhook-re…
Viky207 Aug 27, 2026
1b2fe9a
feat(backend): add ETag-based caching for compose/simulate responses
bokkykennedy79-spec Aug 27, 2026
1e93192
feat(backend): align payment link expiration job with issue spec
kwest0002-lab Aug 27, 2026
654181a
feat(contract): add multisig roles and governance benchmarks
Valenciajrn Aug 28, 2026
1bd5290
feat: resolve issues #97, #64, #63, #62
Onipinsaiye Aug 28, 2026
15631d9
feat: implement team management, OG images, payment history, and tran…
Onipinsaiye Aug 28, 2026
f129d7d
feat(backend): implement configurable username search ranking (#28)
soterika Aug 28, 2026
348b2a3
feat(backend): add Sentry performance monitoring for transaction path…
soterika Aug 28, 2026
1141442
feat(backend): implement feature flag audit with actor attribution (#26)
soterika Aug 28, 2026
7d11ece
feat(backend): map Stellar SDK exceptions to stable HTTP error codes …
soterika Aug 28, 2026
105ee75
Merge pull request #151 from Onipinsaiye/feat/issues-62-63-64-97
Viky207 Aug 28, 2026
4dfc13b
Merge branch 'main' into feat/issues-97-64-63-62
Viky207 Aug 28, 2026
180bf21
Merge pull request #150 from Onipinsaiye/feat/issues-97-64-63-62
Viky207 Aug 28, 2026
ed05920
Merge pull request #155 from soterika/feat/issue-24-stellar-exception…
Viky207 Aug 28, 2026
73578e1
Merge pull request #154 from soterika/feat/issue-26-audit-actor-attri…
Viky207 Aug 28, 2026
e07f826
Merge pull request #153 from soterika/feat/issue-27-sentry-performanc…
Viky207 Aug 28, 2026
0ba6f9d
Merge branch 'main' into feat/issue-28-username-search-ranking
Viky207 Aug 28, 2026
71591d3
Merge pull request #152 from soterika/feat/issue-28-username-search-r…
Viky207 Aug 28, 2026
fe1b7fe
Merge pull request #149 from Valenciajrn/feat/admin-governance-benchm…
Viky207 Aug 28, 2026
c0581a9
Merge pull request #148 from kwest0002-lab/feature/payment-link-expir…
Viky207 Aug 28, 2026
5443763
Merge branch 'main' into feature/etag-caching-compose-simulate
Viky207 Aug 28, 2026
2d819f0
Merge pull request #147 from bokkykennedy79-spec/feature/etag-caching…
Viky207 Aug 28, 2026
999483f
Merge branch 'main' into feature/error-envelope-swagger-validation
Viky207 Aug 28, 2026
d538ee0
Merge pull request #145 from smokeylenz1-commits/feature/error-envelo…
Viky207 Aug 28, 2026
b4662a4
test(indexer-lag): add unit tests for IndexerLagService and IndexerLa…
oziginwambada Aug 29, 2026
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
8 changes: 8 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ jobs:
- name: Build
run: pnpm run build

- name: OpenAPI spec must not diverge
run: pnpm run docs:export:check
env:
NODE_ENV: test
NETWORK: testnet
SUPABASE_URL: https://spec-export.supabase.co
SUPABASE_ANON_KEY: spec-export-key

# Tests are run separately to avoid CI failures during development
# Uncomment when tests are stable
# - name: Run Unit Tests
Expand Down
69 changes: 36 additions & 33 deletions app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,52 @@
"private": true,
"description": "QuickEx NestJS backend API",
"dependencies": {
"@nestjs/common": "^10.4.22",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^3.0.4",
"@nestjs/swagger": "^7.4.2",
"@nestjs/common": "^11.1.28",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.28",
"@nestjs/event-emitter": "^3.1.0",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.6",
"@nestjs/throttler": "^6.5.0",
"@sentry/nestjs": "^10.46.0",
"@sentry/node": "^10.46.0",
"@sentry/profiling-node": "^10.46.0",
"@stellar/stellar-sdk": "^14.5.0",
"@supabase/supabase-js": "^2.0.0",
"@sentry/nestjs": "^10.69.0",
"@sentry/node": "^10.69.0",
"@sentry/profiling-node": "^10.69.0",
"@stellar/stellar-sdk": "^16.2.0",
"@supabase/supabase-js": "^2.112.2",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"helmet": "^8.1.0",
"ipaddr.js": "^2.4.0",
"joi": "^18.0.2",
"lru-cache": "^11.3.5",
"class-validator": "^0.15.1",
"helmet": "^8.3.0",
"ioredis": "^6.0.0",
"ipaddr.js": "^2.5.0",
"joi": "^18.2.3",
"lru-cache": "^11.5.2",
"nest-winston": "^1.10.2",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.1.13",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3",
"uuid": "^9.0.1",
"uuid": "^14.0.1",
"winston": "^3.19.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"@types/supertest": "^2.0.16",
"@types/uuid": "^9.0.1",
"@nestjs/cli": "^11.0.24",
"@nestjs/testing": "^11.1.28",
"@types/jest": "^30.0.0",
"@types/node": "^26.1.2",
"@types/supertest": "^7.2.1",
"@types/uuid": "^11.0.0",
"@types/yargs": "^17.0.35",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.0.0",
"fast-check": "^4.8.0",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"@typescript-eslint/eslint-plugin": "^8.66.0",
"@typescript-eslint/parser": "^8.66.0",
"eslint": "^10.8.0",
"fast-check": "^4.9.0",
"jest": "^30.4.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.0",
"typescript": "^5.2.2"
"typescript": "^7.0.2"
},
"pnpm": {
"neverBuiltDependencies": [
Expand All @@ -69,6 +70,8 @@
"test:int:coverage": "jest --config jest.int.config.ts --coverage",
"test:e2e": "jest --config jest.e2e.config.ts",
"soroban:deploy": "ts-node scripts/soroban-deploy.ts",
"docs:export": "ts-node scripts/export-openapi.ts",
"docs:export:check": "pnpm run docs:export && git diff --exit-code -- openapi.json",
"test:fuzz": "jest --config jest.fuzz.config.ts",
"test:fuzz:ci": "jest --config jest.fuzz.config.ts --runInBand",
"test:smoke": "jest --config jest.e2e.config.ts test/smoke.e2e-spec.ts --testTimeout=30000",
Expand Down
61 changes: 61 additions & 0 deletions app/backend/scripts/export-openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Exports the generated OpenAPI specification to `openapi.json`.
*
* Used by CI to fail when the committed spec diverges from the application's
* generated spec (see .github/workflows/backend.yml "OpenAPI spec divergence").
*
* Run: pnpm run docs:export
*/
import { writeFileSync } from "fs";
import { join } from "path";
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";

// Provide minimal valid config so the app can boot and build the document.
process.env.NODE_ENV = process.env.NODE_ENV ?? "test";
process.env.NETWORK = process.env.NETWORK ?? "testnet";
process.env.SUPABASE_URL =
process.env.SUPABASE_URL ?? "https://spec-export.supabase.co";
process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "spec-export-key";
process.env.SENTRY_DSN = process.env.SENTRY_DSN ?? "";

import { AppModule } from "../src/app.module";
import { AppConfigService } from "../src/config";

async function main() {
const app = await NestFactory.create(AppModule, { logger: false });

const config = app.get(AppConfigService);
const swaggerConfig = new DocumentBuilder()
.setTitle("QuickEx Backend")
.setDescription(
"QuickEx API documentation - A Stellar-based exchange platform. " +
`Currently connected to: ${config.network}`,
)
.setVersion("v1")
.addTag("health", "Health check endpoints")
.addTag("usernames", "Username management endpoints")
.addTag("links", "Payment link validation and metadata endpoints")
.addTag("transactions", "Stellar transaction and payment history")
.addTag("scam-alerts", "Fraud detection and link scanning")
.addTag("analytics", "Dashboard analytics, time-series insights, and report exports")
.addTag("metrics", "Application performance and health metrics")
.addTag("stellar", "Verified assets, path preview, Soroban preflight")
.addTag("contracts", "Contract registry publication and discovery")
.addTag("developer", "Developer self-service: ping, webhook testing, key management, health score")
.build();

const document = SwaggerModule.createDocument(app, swaggerConfig);
const outPath = join(process.cwd(), "openapi.json");
writeFileSync(outPath, JSON.stringify(document, null, 2));
// eslint-disable-next-line no-console
console.log(`OpenAPI spec written to ${outPath}`);

await app.close();
}

void main().catch((err) => {
// eslint-disable-next-line no-console
console.error("Failed to export OpenAPI spec:", err);
process.exit(1);
});
42 changes: 42 additions & 0 deletions app/backend/src/analytics/analytics-events.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Observable } from 'rxjs';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

@Injectable()
export class AnalyticsEventsService {
constructor(private readonly eventEmitter: EventEmitter2) {}

stream(publicKey: string): Observable<MessageEvent> {
return new Observable<MessageEvent>((subscriber) => {
const listener = (eventName: string, payload: unknown) => {
if (!isRecord(payload)) return;

const eventPublicKey = payload.recipientPublicKey ?? payload.publicKey ?? payload.owner;
if (eventPublicKey !== publicKey) return;

subscriber.next({
type: 'analytics.updated',
data: JSON.stringify({
eventType: eventName,
eventId: payload.eventId ?? null,
occurredAt: new Date().toISOString(),
}),
});
};

this.eventEmitter.onAny(listener);
const heartbeat = setInterval(() => {
subscriber.next({ type: 'analytics.heartbeat', data: '{}' });
}, 30_000);

return () => {
this.eventEmitter.offAny(listener);
clearInterval(heartbeat);
};
});
}
}
87 changes: 87 additions & 0 deletions app/backend/src/analytics/analytics-stale-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Injectable, Logger } from "@nestjs/common";
import { LRUCache } from "lru-cache";

import { AppConfigService } from "../config";
import { AnalyticsInterval } from "./dto/analytics-query.dto";
import type { AnalyticsReport } from "./analytics.service";

export type StaleCacheEntry = {
report: AnalyticsReport;
generatedAt: number;
};

/**
* Stores the last successful analytics report per (publicKey, interval) so the
* dashboard can keep serving (stale) data when the data source is unavailable.
*
* Entries are retained for up to `MAX_STALE_AGE_MS` (24h) and a fresh result is
* cached with a configurable TTL (default 5m). On failure the controller signals
* staleness via the `X-Cache-Status: stale` header.
*/
@Injectable()
export class AnalyticsStaleCache {
private readonly logger = new Logger(AnalyticsStaleCache.name);
private readonly cache: LRUCache<string, StaleCacheEntry>;

/** Stale data is only ever served up to 24 hours old. */
private readonly MAX_STALE_AGE_MS = 24 * 60 * 60 * 1000;

constructor(private readonly appConfig: AppConfigService) {
const ttl = Math.max(this.appConfig.analyticsStaleCacheTtlMs, 1000);
this.cache = new LRUCache<string, StaleCacheEntry>({
max: 1000,
// Retain entries long enough to serve stale data within the 24h cap.
ttl: Math.max(ttl, this.MAX_STALE_AGE_MS),
updateAgeOnGet: false,
});
}

/**
* Separate cache key per publicKey, interval, organization and date window.
*/
getCacheKey(
publicKey: string,
interval: AnalyticsInterval,
organizationId?: string,
startDate?: string,
endDate?: string,
): string {
return [
"quickex",
"analytics",
"stale",
publicKey,
interval,
organizationId ?? "anon",
startDate ?? "default-start",
endDate ?? "default-end",
].join(":");
}

get(kind: "fresh" | "stale", key: string): StaleCacheEntry | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;

const age = Date.now() - entry.generatedAt;
if (age > this.MAX_STALE_AGE_MS) {
this.cache.delete(key);
return undefined;
}

// A "fresh" hit means the cached copy is within the configured TTL — no need
// to recompute on failure paths that can still serve it. `kind` is used to
// keep semantics explicit; both resolve to the same entry.
void kind;

return entry;
}

set(key: string, entry: StaleCacheEntry): void {
this.cache.set(key, entry, {
ttl: Math.max(
this.appConfig.analyticsStaleCacheTtlMs,
this.MAX_STALE_AGE_MS,
),
});
}
}
44 changes: 34 additions & 10 deletions app/backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Controller, Get, Query, Req, Res, UseGuards } from '@nestjs/common';
import { Controller, Get, Query, Req, Res, Sse, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import { AnalyticsService } from './analytics.service';
import { AnalyticsEventsService } from './analytics-events.service';
import {
AnalyticsQueryDto,
ExportReportQueryDto,
Expand All @@ -14,21 +15,44 @@ import {
@UseGuards(ApiKeyGuard)
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
constructor(
private readonly analyticsService: AnalyticsService,
private readonly analyticsEventsService: AnalyticsEventsService,
) {}

@Sse('events')
@ApiOperation({ summary: 'Stream analytics updates for a public key' })
events(@Query('publicKey') publicKey: string) {
return this.analyticsEventsService.stream(publicKey);
}

@Get('report')
@ApiOperation({
summary: 'Fetch dashboard analytics report (summary, asset distribution, and time-series)',
})
@ApiResponse({ status: 200, description: 'Analytics report generated' })
async getReport(@Req() req: Request, @Query() query: TimeSeriesQueryDto) {
return this.analyticsService.getAnalyticsReport(
query.publicKey,
query.startDate,
query.endDate,
query.interval,
req.organizationContext?.organizationId,
);
async getReport(
@Req() req: Request,
@Res() res: Response,
@Query() query: TimeSeriesQueryDto,
) {
const { report, cacheStatus } =
await this.analyticsService.getAnalyticsReportWithStatus(
query.publicKey,
query.startDate,
query.endDate,
query.interval,
req.organizationContext?.organizationId,
);

if (cacheStatus === 'stale') {
res.set('X-Cache-Status', 'stale');
res.set('X-QuickEx-Stale-Data', 'true');
} else {
res.set('X-Cache-Status', 'fresh');
}

return res.status(200).json(report);
}

@Get('time-series')
Expand Down
4 changes: 3 additions & 1 deletion app/backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { ApiKeysModule } from '../api-keys/api-keys.module';
import { SupabaseModule } from '../supabase/supabase.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { AnalyticsEventsService } from './analytics-events.service';
import { AnalyticsStaleCache } from './analytics-stale-cache';

@Module({
imports: [SupabaseModule, ApiKeysModule],
controllers: [AnalyticsController],
providers: [AnalyticsService],
providers: [AnalyticsService, AnalyticsEventsService, AnalyticsStaleCache],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
Expand Down
Loading