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
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ concurrency:
cancel-in-progress: true

jobs:
secrets-scan:
name: Secrets Scanning (Gitleaks)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

vulnerability-scan:
name: Dependency Vulnerability Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm audit --audit-level=high

backend:
name: Backend (Nest)
runs-on: ubuntu-latest
Expand Down
14 changes: 12 additions & 2 deletions src/intents/intents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ import { IntentsController } from "./intents.controller";
import { IntentsGateway } from "./intents.gateway";
import { IntentsSweeperService } from "./intents-sweeper.service";
import { SolversModule } from "../solvers/solvers.module";
import { IntentsRepository, InMemoryIntentsRepository } from "./intents.repository";

@Module({
imports: [SolversModule],
controllers: [IntentsController],
providers: [IntentsService, IntentsGateway, IntentsSweeperService],
exports: [IntentsService],
providers: [
IntentsService,
IntentsGateway,
IntentsSweeperService,
{
provide: IntentsRepository,
useClass: InMemoryIntentsRepository,
},
],
exports: [IntentsService, IntentsRepository],
})
export class IntentsModule {}

76 changes: 76 additions & 0 deletions src/intents/intents.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Injectable } from "@nestjs/common";
import { Intent, IntentState } from "./intents.types";

export abstract class IntentsRepository {
abstract save(intent: Intent): Intent | Promise<Intent>;
abstract findById(id: string): Intent | undefined | Promise<Intent | undefined>;
abstract findAll(): Intent[] | Promise<Intent[]>;
abstract findByState(state: IntentState): Intent[] | Promise<Intent[]>;
abstract findByUser(user: string): Intent[] | Promise<Intent[]>;
abstract update(id: string, patch: Partial<Intent>): Intent | null | Promise<Intent | null>;
}

@Injectable()
export class InMemoryIntentsRepository implements IntentsRepository {
private readonly intents = new Map<string, Intent>();

save(intent: Intent): Intent {
this.intents.set(intent.intentId, intent);
return intent;
}

findById(id: string): Intent | undefined {
return this.intents.get(id);
}

findAll(): Intent[] {
return [...this.intents.values()].sort((a, b) => b.createdAt - a.createdAt);
}

findByState(state: IntentState): Intent[] {
return this.findAll().filter((i) => i.state === state);
}

findByUser(user: string): Intent[] {
return this.findAll().filter((i) => i.user.toLowerCase() === user.toLowerCase());
}

update(id: string, patch: Partial<Intent>): Intent | null {
const existing = this.intents.get(id);
if (!existing) return null;
const updated = { ...existing, ...patch };
this.intents.set(id, updated);
return updated;
}
}

/**
* Postgres implementation stub for production persistent storage integration.
*/
@Injectable()
export class PostgresIntentsRepository implements IntentsRepository {
// Database connection / ORM entity manager would be injected here
save(intent: Intent): Intent {
throw new Error("Method not implemented. Configure Postgres connection.");
}

findById(id: string): Intent | undefined {
throw new Error("Method not implemented. Configure Postgres connection.");
}

findAll(): Intent[] {
throw new Error("Method not implemented. Configure Postgres connection.");
}

findByState(state: IntentState): Intent[] {
throw new Error("Method not implemented. Configure Postgres connection.");
}

findByUser(user: string): Intent[] {
throw new Error("Method not implemented. Configure Postgres connection.");
}

update(id: string, patch: Partial<Intent>): Intent | null {
throw new Error("Method not implemented. Configure Postgres connection.");
}
}
28 changes: 13 additions & 15 deletions src/intents/intents.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { Injectable } from "@nestjs/common";
import { Injectable, Inject, Optional } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { Intent, IntentState } from "./intents.types";
import { buildSeedIntents } from "./intents.seed";
import { IntentsRepository, InMemoryIntentsRepository } from "./intents.repository";

@Injectable()
export class IntentsService {
private readonly intents = new Map<string, Intent>();
private readonly repository: IntentsRepository;

constructor() {
constructor(@Optional() @Inject(IntentsRepository) repository?: IntentsRepository) {
this.repository = repository || new InMemoryIntentsRepository();
this.seed();
}

Expand All @@ -20,32 +22,27 @@ export class IntentsService {
createdAt: now,
deadline: data.deadline ?? now + 1800,
};
this.intents.set(intent.intentId, intent);
return intent;
return this.repository.save(intent) as Intent;
}

get(id: string): Intent | undefined {
return this.intents.get(id);
return this.repository.findById(id) as Intent | undefined;
}

getAll(): Intent[] {
return [...this.intents.values()].sort((a, b) => b.createdAt - a.createdAt);
return this.repository.findAll() as Intent[];
}

getByState(state: IntentState): Intent[] {
return this.getAll().filter((i) => i.state === state);
return this.repository.findByState(state) as Intent[];
}

getByUser(user: string): Intent[] {
return this.getAll().filter((i) => i.user.toLowerCase() === user.toLowerCase());
return this.repository.findByUser(user) as Intent[];
}

update(id: string, patch: Partial<Intent>): Intent | null {
const existing = this.intents.get(id);
if (!existing) return null;
const updated = { ...existing, ...patch };
this.intents.set(id, updated);
return updated;
return this.repository.update(id, patch) as Intent | null;
}

private seed() {
Expand All @@ -56,7 +53,8 @@ export class IntentsService {
intentId: uuidv4(),
createdAt: now - Math.floor(Math.random() * 600),
};
this.intents.set(intent.intentId, intent);
this.repository.save(intent);
}
}
}

31 changes: 31 additions & 0 deletions src/soroban/account-rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from "@nestjs/common";
import { Request } from "express";

@Injectable()
export class AccountRateLimitGuard implements CanActivate {
private readonly requestCounts = new Map<string, { count: number; resetTime: number }>();
private readonly WINDOW_MS = 60 * 1000; // 1 minute window
private readonly MAX_REQUESTS = 30; // Maximum 30 requests per minute per IP

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const ip = (request.headers["x-forwarded-for"] as string) || request.ip || "unknown-ip";
const now = Date.now();

const record = this.requestCounts.get(ip);
if (!record || now > record.resetTime) {
this.requestCounts.set(ip, { count: 1, resetTime: now + this.WINDOW_MS });
return true;
}

if (record.count >= this.MAX_REQUESTS) {
throw new HttpException(
"Too many account lookup requests. Rate limit exceeded to prevent proxy scanning.",
HttpStatus.TOO_MANY_REQUESTS,
);
}

record.count++;
return true;
}
}
13 changes: 12 additions & 1 deletion src/soroban/soroban.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Controller, Get, Param } from "@nestjs/common";
import { Controller, Get, Param, BadRequestException, UseGuards } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { StrKey } from "@stellar/stellar-sdk";
import { SorobanService } from "./soroban.service";
import { AccountRateLimitGuard } from "./account-rate-limit.guard";

@ApiTags("chain")
@Controller("api/v1/chain")
Expand All @@ -23,7 +25,16 @@ export class SorobanController {
}

@Get("account/:publicKey")
@UseGuards(AccountRateLimitGuard)
getAccount(@Param("publicKey") publicKey: string) {
if (
!publicKey ||
typeof publicKey !== "string" ||
!(StrKey?.isValidEd25519PublicKey ? StrKey.isValidEd25519PublicKey(publicKey) : /^G[A-Z2-7]{55}$/.test(publicKey))
) {
throw new BadRequestException("Invalid Stellar public key format");
}
return this.sorobanService.getAccount(publicKey);
}
}