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
5 changes: 0 additions & 5 deletions .github/workflows/validate-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ jobs:
validate:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -27,6 +24,4 @@ jobs:

- run: npm ci

- run: npx ts-node scripts/validate-registry.ts
- run: npm ci
- run: node scripts/validate-registry.js
20 changes: 20 additions & 0 deletions __mocks__/ioredis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Minimal ioredis mock for testing — no real Redis connection required.
class Redis {
private store = new Map<string, string>();

on(_event: string, _handler: (...args: unknown[]) => void): this {
return this;
}
async get(key: string): Promise<string | null> {
return this.store.get(key) ?? null;
}
async set(key: string, value: string, ..._args: unknown[]): Promise<"OK"> {
this.store.set(key, value);
return "OK";
}
async quit(): Promise<"OK"> {
return "OK";
}
}

export default Redis;
68 changes: 68 additions & 0 deletions app/api/portfolio/[address]/performance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* GET /api/portfolio/[address]/performance
*
* Returns performance metrics for a specific wallet address.
* Query params:
* - from: Unix timestamp (seconds) — start of window (default: 24h ago)
* - to: Unix timestamp (seconds) — end of window (default: now)
*/

import { NextRequest, NextResponse } from "next/server";
import { MOCK_RAW_EVENTS } from "@/lib/mock-data";

export interface AddressPerformance {
address: string;
totalEvents: number;
uniqueContracts: number;
recentActivity: { contractId: string; eventId: string; timestamp: number }[];
window: { from: number; to: number };
}

interface RouteContext {
params: { address: string };
}

export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { address } = context.params;

if (!address || typeof address !== "string" || address.trim().length === 0) {
return NextResponse.json({ error: "Missing or invalid address parameter" }, { status: 400 });
}

const { searchParams } = new URL(request.url);
const now = Math.floor(Date.now() / 1000);
const from = parseInt(searchParams.get("from") ?? String(now - 86400), 10);
const to = parseInt(searchParams.get("to") ?? String(now), 10);

if (isNaN(from) || isNaN(to) || from > to) {
return NextResponse.json(
{ error: "Invalid time window: 'from' must be <= 'to' and both must be valid Unix timestamps" },
{ status: 400 }
);
}

// Filter events involving the address in topics (case-insensitive substring match)
const addrLower = address.toLowerCase();
const events = MOCK_RAW_EVENTS.filter(
(e) =>
e.timestamp >= from &&
e.timestamp <= to &&
e.topics.some((t) => t.toLowerCase().includes(addrLower))
);

const contractIds = new Set(events.map((e) => e.contractId));
const recentActivity = events
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 10)
.map((e) => ({ contractId: e.contractId, eventId: e.id, timestamp: e.timestamp }));

const result: AddressPerformance = {
address,
totalEvents: events.length,
uniqueContracts: contractIds.size,
recentActivity,
window: { from, to },
};

return NextResponse.json(result);
}
86 changes: 86 additions & 0 deletions app/api/portfolio/performance/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { NextRequest } from "next/server";
import { GET as getPortfolioPerformance } from "@/app/api/portfolio/performance/route";
import { GET as getAddressPerformance } from "@/app/api/portfolio/[address]/performance/route";

function makeRequest(path: string, params?: Record<string, string>): NextRequest {
const url = new URL(`http://localhost${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
return new NextRequest(url.toString());
}

describe("GET /api/portfolio/performance", () => {
it("returns 200 with portfolio metrics", async () => {
const req = makeRequest("/api/portfolio/performance");
const res = await getPortfolioPerformance(req);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("totalEvents");
expect(body).toHaveProperty("uniqueContracts");
expect(body).toHaveProperty("topContracts");
expect(body).toHaveProperty("window");
expect(Array.isArray(body.topContracts)).toBe(true);
});

it("accepts from/to query params", async () => {
const now = Math.floor(Date.now() / 1000);
const req = makeRequest("/api/portfolio/performance", {
from: String(now - 7200),
to: String(now),
});
const res = await getPortfolioPerformance(req);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.window.from).toBe(now - 7200);
expect(body.window.to).toBe(now);
});

it("returns 400 when from > to", async () => {
const now = Math.floor(Date.now() / 1000);
const req = makeRequest("/api/portfolio/performance", {
from: String(now),
to: String(now - 100),
});
const res = await getPortfolioPerformance(req);
expect(res.status).toBe(400);
const body = await res.json();
expect(body).toHaveProperty("error");
});
});

describe("GET /api/portfolio/[address]/performance", () => {
it("returns 200 with address metrics", async () => {
const req = makeRequest("/api/portfolio/GABC1234/performance");
const res = await getAddressPerformance(req, { params: { address: "GABC1234" } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("address", "GABC1234");
expect(body).toHaveProperty("totalEvents");
expect(body).toHaveProperty("uniqueContracts");
expect(body).toHaveProperty("recentActivity");
expect(Array.isArray(body.recentActivity)).toBe(true);
});

it("returns 400 when from > to", async () => {
const now = Math.floor(Date.now() / 1000);
const req = makeRequest("/api/portfolio/GABC/performance", {
from: String(now),
to: String(now - 1),
});
const res = await getAddressPerformance(req, { params: { address: "GABC" } });
expect(res.status).toBe(400);
});

it("returns empty activity for an unknown address", async () => {
const req = makeRequest("/api/portfolio/GNOBODY999/performance");
const res = await getAddressPerformance(req, { params: { address: "GNOBODY999" } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.totalEvents).toBe(0);
expect(body.recentActivity).toEqual([]);
});
});
57 changes: 57 additions & 0 deletions app/api/portfolio/performance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* GET /api/portfolio/performance
*
* Returns aggregated portfolio performance metrics across all tracked contracts.
* Query params:
* - from: Unix timestamp (seconds) — start of window (default: 24h ago)
* - to: Unix timestamp (seconds) — end of window (default: now)
*/

import { NextRequest, NextResponse } from "next/server";
import { MOCK_RAW_EVENTS } from "@/lib/mock-data";

export interface PortfolioPerformance {
totalEvents: number;
translatedEvents: number;
crypticEvents: number;
uniqueContracts: number;
topContracts: { contractId: string; eventCount: number }[];
window: { from: number; to: number };
}

export async function GET(request: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(request.url);
const now = Math.floor(Date.now() / 1000);
const from = parseInt(searchParams.get("from") ?? String(now - 86400), 10);
const to = parseInt(searchParams.get("to") ?? String(now), 10);

if (isNaN(from) || isNaN(to) || from > to) {
return NextResponse.json(
{ error: "Invalid time window: 'from' must be <= 'to' and both must be valid Unix timestamps" },
{ status: 400 }
);
}

const events = MOCK_RAW_EVENTS.filter((e) => e.timestamp >= from && e.timestamp <= to);

const contractCounts = new Map<string, number>();
for (const event of events) {
contractCounts.set(event.contractId, (contractCounts.get(event.contractId) ?? 0) + 1);
}

const topContracts = Array.from(contractCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([contractId, eventCount]) => ({ contractId, eventCount }));

const result: PortfolioPerformance = {
totalEvents: events.length,
translatedEvents: 0, // placeholder — real impl would run translateEvents()
crypticEvents: 0,
uniqueContracts: contractCounts.size,
topContracts,
window: { from, to },
};

return NextResponse.json(result);
}
8 changes: 5 additions & 3 deletions lib/stellar/__tests__/client.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { fetchContractEvents, TESTNET_CONFIG } from "../client";
describe("client integration with MSW", () => {
it("should successfully fetch contract events without live network connection", async () => {
const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";

// fetchContractEvents will call getLatestLedger then getEvents
// our MSW setup intercepts these requests to https://soroban-testnet.stellar.org
const events = await fetchContractEvents(contractId, TESTNET_CONFIG, 123456);

expect(events).toBeDefined();
expect(events.length).toBe(1);
expect((events[0] as any).contractId.contractId()).toBe(contractId);
expect((events[0] as any).type).toBe("contract");
// fetchContractEvents returns RawEvent[], so we check RawEvent fields
expect(events[0].contractId).toBe(contractId);
expect(events[0].id).toBeDefined();
expect(Array.isArray(events[0].topics)).toBe(true);
});
});
14 changes: 13 additions & 1 deletion lib/stellar/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function eventResponseToRawEvent(

return {
id: String(source.id ?? source.pagingToken ?? `${ledger}-0`),
contractId: source.contractId ?? source.contract_id ?? fallbackContractId ?? "unknown",
contractId: extractContractId(source.contractId ?? source.contract_id) ?? fallbackContractId ?? "unknown",
topics: normalizeTopics(source.topics ?? source.topic),
data: normalizeScVal(source.data ?? source.value),
ledger,
Expand All @@ -38,6 +38,18 @@ export function eventResponseToRawEvent(
};
}

/**
* Extracts a contract ID string from either a string or an SDK Contract object.
* The stellar-sdk sometimes returns a Contract instance with a .contractId() method.
*/
function extractContractId(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "contractId" in value && typeof (value as { contractId: unknown }).contractId === "function") {
return (value as { contractId: () => string }).contractId();
}
return undefined;
}

/** Normalizes the full ordered topic vector without dropping secondary topics. */
export function normalizeTopics(topics: unknown): string[] {
if (!Array.isArray(topics)) return [];
Expand Down
2 changes: 1 addition & 1 deletion lib/stellar/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ export function startEventIndexer(options: IndexerOptions): IndexerControls {
if (response.latestLedger) {
cursor = {
lastLedger: response.latestLedger,

paginationCursor: (response as { cursor?: string }).cursor,
};
console.log(`[indexer] Cursor updated to ledger ${cursor.lastLedger}`);
}
Expand Down
16 changes: 13 additions & 3 deletions lib/translator/decode.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { describe, it, expect } from "vitest";
import { translateEvent } from "./registry";
import { interpolateTemplate } from "./decode";
import type { RawEvent } from "./types";
import { translateEvent, matchesEventCriteria } from "./registry";
import {
interpolateTemplate,
isValidHex,
sanitizeHex,
escapeHtml,
detectScValType,
decodeMap,
decodeVec,
decodeEnum,
decodeScVal,
} from "./decode";
import type { RawEvent, TranslationBlueprint } from "./types";

/**
* Mock XDR data for testing Soroban event translation.
Expand Down
Loading