Skip to content
Merged
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
7 changes: 7 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Public site URL, used to resolve absolute OG/social image URLs
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Optional: Redis URL for the distributed rate limiter. When set, all instances
# share a single combined limit instead of each enforcing it independently (#28).
# RATE_LIMIT_REDIS_URL=redis://localhost:6379

# Optional: trust X-Forwarded-For header for per-IP rate limiting (set only behind a trusted proxy).
# TRUST_PROXY=1
61 changes: 61 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"dependencies": {
"@stellar/js-xdr": "^4.0.0",
"@stellar/stellar-sdk": "^16.2.0",
"ioredis": "^6.0.0",
"next": "^14.0.0",
"pg": "^8.13.0",
"react": "^18.0.0",
Expand Down
25 changes: 19 additions & 6 deletions frontend/src/app/api/tx/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import { xdr } from "@stellar/stellar-sdk";
import { getTransaction } from "@/lib/horizon";
import { getInvocationReturnValue } from "@/lib/soroban";
import { decodeInvocation, getResourceUsage } from "@/lib/xdr";
import { rateLimitRequest } from "@/lib/rate-limit";
import { rateLimitAsync, getClientKey } from "@/lib/rate-limit";

export const dynamic = "force-dynamic";

interface Params { params: { hash: string } }

export async function GET(req: Request, { params }: Params) {
const limited = rateLimitRequest(req, { scope: "tx" });
if (limited) return limited;
const key = `tx:${getClientKey(req)}`;
const limitResult = await rateLimitAsync(key, 30, 60_000);
if (!limitResult.success) {
return NextResponse.json(
{ error: "Rate limit exceeded. Try again shortly." },
{ status: 429, headers: { "Retry-After": String(limitResult.retryAfter ?? 60) } }
);
}

let tx;
try {
Expand All @@ -20,9 +26,16 @@ export async function GET(req: Request, { params }: Params) {
return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
}

const envelope = xdr.TransactionEnvelope.fromXDR(tx.envelopeXdr, "base64");
const invocation = decodeInvocation(envelope);
const resources = getResourceUsage(envelope);
let invocation: ReturnType<typeof decodeInvocation> = null;
let resources: ReturnType<typeof getResourceUsage> = null;
try {
const envelope = xdr.TransactionEnvelope.fromXDR(tx.envelopeXdr, "base64");
invocation = decodeInvocation(envelope);
resources = getResourceUsage(envelope);
} catch {
// Envelope uses a newer XDR type this SDK version cannot decode.
// Return the Horizon fields with envelope-derived fields set to null (#26).
}
const returnValue = invocation ? await getInvocationReturnValue(tx.hash) : null;

return NextResponse.json({ ...tx, invocation, resources, returnValue });
Expand Down
74 changes: 71 additions & 3 deletions frontend/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ interface Bucket {
* In-memory, per-process fixed-window limiter. Good enough to stop a single
* client from hammering the public Horizon/Soroban RPC nodes through this
* API — NOT distributed, so it resets per instance on a multi-instance
* deployment. A real deployment behind a shared cache (Redis, etc.) would
* need a proper distributed limiter instead. Buckets are keyed by
* `${scope}:${clientKey}` so different routes can carry independent limits.
* deployment. Set RATE_LIMIT_REDIS_URL to switch to the Redis-backed store
* which enforces a single shared limit across all instances (#28).
*/
const buckets = new Map<string, Bucket>();

Expand All @@ -32,10 +31,60 @@ export interface RateLimitResult {
retryAfter?: number;
}

/**
* Pluggable store interface for the rate limiter (#28).
* The in-memory default is synchronous; a Redis implementation is async.
*/
export interface RateLimitStore {
increment(key: string, windowMs: number): Promise<{ count: number; ttlMs: number }>;
}

/**
* Redis-backed store. Two instances sharing the same Redis enforce one
* combined limit. Selected automatically when RATE_LIMIT_REDIS_URL is set.
*/
export class RedisRateLimitStore implements RateLimitStore {
private client: import("ioredis").Redis | null = null;

constructor(private readonly url: string) {}

private async getClient(): Promise<import("ioredis").Redis> {
if (!this.client) {
const { default: Redis } = await import("ioredis");
this.client = new Redis(this.url, { lazyConnect: true });
await this.client.connect();
}
return this.client;
}

async increment(key: string, windowMs: number): Promise<{ count: number; ttlMs: number }> {
const redis = await this.getClient();
const windowSec = Math.ceil(windowMs / 1000);
// Atomic INCR + EXPIRE: the Lua script ensures the TTL is set exactly once.
const result = (await redis.eval(
`local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return {c, redis.call('PTTL', KEYS[1])}`,
1,
key,
String(windowSec)
)) as [number, number];
return { count: result[0], ttlMs: Math.max(0, result[1]) };
}
}

/** The active store — Redis when RATE_LIMIT_REDIS_URL is set, otherwise in-memory. */
export const rateLimitStore: RateLimitStore | null = process.env.RATE_LIMIT_REDIS_URL
? new RedisRateLimitStore(process.env.RATE_LIMIT_REDIS_URL)
: null;

/**
* Core bucket logic — keyed by an arbitrary string that callers compose.
* Returns a structured result rather than a NextResponse so it can be used
* in non-HTTP contexts (tests, background workers, etc.).
*
* Uses the in-memory store directly (synchronous). For the distributed Redis
* path use rateLimitAsync.
*/
export function rateLimit(key: string, max: number, windowMs: number): RateLimitResult {
const now = Date.now();
Expand All @@ -58,6 +107,25 @@ export function rateLimit(key: string, max: number, windowMs: number): RateLimit
return { success: true, remaining: max - bucket.count };
}

/**
* Async rate-limit check that works with any RateLimitStore, including Redis.
* Falls back to the in-memory rateLimit() when no store is provided and
* RATE_LIMIT_REDIS_URL is not set.
*/
export async function rateLimitAsync(
key: string,
max: number,
windowMs: number,
store: RateLimitStore | null = rateLimitStore
): Promise<RateLimitResult> {
if (!store) return rateLimit(key, max, windowMs);
const { count, ttlMs } = await store.increment(key, windowMs);
if (count > max) {
return { success: false, remaining: 0, retryAfter: Math.ceil(ttlMs / 1000) };
}
return { success: true, remaining: max - count };
}

/**
* Derive a stable client key from an HTTP request.
*
Expand Down
10 changes: 8 additions & 2 deletions indexer/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# Postgres connection string
DATABASE_URL=postgresql://prism:prism@localhost:5432/prism

# Soroban RPC endpoint to poll
# Soroban RPC endpoint to poll. Required — no silent testnet fallback in production.
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# How often to poll for new ledgers, in milliseconds
# How often to poll for new ledgers, in milliseconds. Must be a positive integer.
POLL_INTERVAL_MS=5000

# Port for the health (/healthz) and metrics (/metrics) HTTP endpoints.
HEALTH_PORT=8080

# Unhealthy when the indexer cursor trails the network by more than this many ledgers.
HEALTH_MAX_LEDGER_LAG=60

# Optional: first ledger to index on a fresh database. Must fall inside the RPC node's
# retention window. Ignored once the indexer has a cursor. Unset: start from the latest ledger.
# INDEXER_START_LEDGER=
Loading