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
72 changes: 64 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,84 @@ Violations can be reported by opening a private GitHub issue or contacting a mai

- Node.js >= 18 (we recommend [nvm](https://github.com/nvm-sh/nvm))
- npm >= 9
- Git (for cloning the repository)

### Install Dependencies
### 1. Clone the Repository

If you haven't already, fork the repository on GitHub, then clone your fork locally:

```bash
npm install
git clone https://github.com/YOUR_USERNAME/open-audit.git
cd open-audit
```

### Environment Variables
### 2. Install Dependencies

Install the required Node.js dependencies:

```bash
cp .env.example .env.local
npm install
```

The defaults point to Stellar **testnet**, which is safe for development. No changes are required for running tests.
### 3. Environment Configuration

### Start the Dev Server
Copy the example environment configuration file to `.env`:

```bash
npm run dev
cp .env.example .env
```

The app will be available at [http://localhost:3000](http://localhost:3000).
#### Minimum Required Variables

For basic local development, the default values in `.env` are pre-configured to connect to the Stellar **testnet**. The minimum required variables are:

- `NEXT_PUBLIC_HORIZON_URL`: The Horizon REST API endpoint (defaults to `https://horizon-testnet.stellar.org`).
- `NEXT_PUBLIC_SOROBAN_RPC_URL`: The Soroban RPC endpoint (defaults to `https://soroban-testnet.stellar.org`).
- `NEXT_PUBLIC_NETWORK_PASSPHRASE`: The passphrase matching the target network (defaults to `"Test SDF Network ; September 2015"`).
- `NEXT_PUBLIC_NETWORK`: The network identifier (`testnet`, `mainnet`, or `futurenet`, defaults to `testnet`).

#### Optional Services: PostgreSQL & Redis

For basic development with the in-memory mock data path, **PostgreSQL and Redis are optional**.
- If `DATABASE_URL` is not configured, the app automatically falls back to the in-memory mock data path.
- If `REDIS_URL` is not configured, the app falls back to an in-process memory cache.

#### Setting Up PostgreSQL (Optional)

If you need to test database persistence or work on features requiring the database:
1. Ensure PostgreSQL is running and create a local database:
```bash
createdb open_audit
```
2. Configure the `DATABASE_URL` variable in your `.env` file, for example:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/open_audit"
```
3. Run the database migrations:
```bash
npm run db:migrate
```
4. Seed the database with test data:
```bash
npm run db:seed
```

### 4. Start the Development Server

You can run the application in two modes depending on your needs:

- **Basic Dashboard**: To run the Next.js development server for the frontend dashboard:
```bash
npm run dev
```
The app will be available at [http://localhost:3000](http://localhost:3000).

- **Full WebSocket Server**: To run the monolithic server which includes both the frontend and WebSocket event streaming capabilities:
```bash
npm run dev:ws
```
The app will be available at [http://localhost:3000](http://localhost:3000).


---

Expand Down
105 changes: 105 additions & 0 deletions app/api/v1/events/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { authenticateAndRateLimit } from "@/lib/api/middleware";

vi.mock("@/lib/api/middleware", () => ({
authenticateAndRateLimit: vi.fn(),
}));

vi.mock("@/lib/db/client", () => ({
db: {
event: {
findUnique: vi.fn(),
},
},
}));

describe("GET /api/v1/events/[id]", () => {
beforeEach(() => {
vi.resetAllMocks();
});

const createRequest = () => {
return new NextRequest("http://localhost/api/v1/events/some-id", {
headers: {
authorization: "Bearer test_key",
},
});
};

it("returns 401 when authentication fails", async () => {
const mockAuthResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(authenticateAndRateLimit).mockResolvedValue(mockAuthResponse);

const req = createRequest();
const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) });

expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
expect(db.event.findUnique).not.toHaveBeenCalled();
});

it("returns 404 when the event is not found", async () => {
vi.mocked(authenticateAndRateLimit).mockResolvedValue(null);
vi.mocked(db.event.findUnique).mockResolvedValue(null);

const req = createRequest();
const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) });

expect(res.status).toBe(404);
const body = await res.json();
expect(body).toEqual({ error: 'Event with ID "some-id" not found' });
expect(db.event.findUnique).toHaveBeenCalledWith({
where: { id: "some-id" },
});
});

it("returns 200 and the event when found", async () => {
const mockEvent = {
id: "some-id",
contractId: "C123",
ledger: 100,
timestamp: 1626000000,
txHash: "hash123",
topics: ["topic1"],
data: "0xdata",
description: "Transferred tokens",
status: "translated",
blueprintName: "Token",
eventType: "transfer",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

vi.mocked(authenticateAndRateLimit).mockResolvedValue(null);
vi.mocked(db.event.findUnique).mockResolvedValue(mockEvent as any);

const req = createRequest();
const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBe("some-id");
expect(body.contractId).toBe("C123");
expect(body.description).toBe("Transferred tokens");
expect(db.event.findUnique).toHaveBeenCalledWith({
where: { id: "some-id" },
});
});

it("returns 500 or appropriate error response when database query fails", async () => {
vi.mocked(authenticateAndRateLimit).mockResolvedValue(null);
vi.mocked(db.event.findUnique).mockRejectedValue(new Error("Database connection lost"));

const req = createRequest();
const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) });

expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("INTERNAL_ERROR");
expect(body.message).toBe("Database connection lost");
});
});
73 changes: 73 additions & 0 deletions app/api/v1/events/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* GET /api/v1/events/[id]
*
* Fetch a single event by its unique ID.
*
* Path Parameters:
* - id: The unique string ID of the event
*
* Response Schema (200 OK):
* {
* id: string; // Unique event identifier
* contractId: string; // Soroban contract address
* ledger: number; // Ledger sequence number
* timestamp: number; // Unix timestamp of the event
* txHash: string; // Transaction hash
* topics: any; // Array of event topics (stored as JSON array)
* data: string; // Hex-encoded event data
* description: string | null; // Translated human-readable description
* status: string; // Translation status ("translated" | "cryptic")
* blueprintName: string | null; // Blueprint/Contract name
* eventType: string | null; // Event type (e.g., "transfer", "mint")
* createdAt: string; // Database creation timestamp
* updatedAt: string; // Database update timestamp
* }
*
* Response (404 Not Found):
* {
* error: string;
* }
*/

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { authenticateAndRateLimit } from "@/lib/api/middleware";
import { toErrorResponse } from "@/lib/api/error-response";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse> {
try {
// Authenticate the request and check rate limits
const authError = await authenticateAndRateLimit(request);
if (authError) return authError;

// Await the route params in Next.js 15+
const { id } = await params;

if (!id) {
return NextResponse.json(
{ error: "Event ID parameter is missing" },
{ status: 400 }
);
}

// Query Prisma Event table for an exact match on id field
const event = await db.event.findUnique({
where: { id },
});

if (!event) {
return NextResponse.json(
{ error: `Event with ID "${id}" not found` },
{ status: 404 }
);
}

// Return the event as JSON
return NextResponse.json(event);
} catch (error) {
return toErrorResponse(error, { fallbackMessage: "Failed to retrieve event" });
}
}
52 changes: 47 additions & 5 deletions lib/translator/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,56 @@ function buildRegistry(): BlueprintRegistry {
}
}

// 3. Load SDEX (Stellar Classic Order Book) Blueprints
for (const blueprint of createAllSdexBlueprints()) {
register(blueprint);
}

return registry;
}

function interpolate(template: string, values: Record<string, any>): string {
return template.replace(/\{([^}]+)\}/g, (match, key) => {
const [path, format] = key.split(".");
const val = values[path];
if (val && typeof val === "object" && format) {
return val[format] ?? match;
}
return val ?? match;
});
}

function createTranslateFromMapping(mapping: any) {
return (event: RawEvent, lang: Language): TranslationResult | null => {
// 1. Match topics
for (let i = 0; i < mapping.topics.length; i++) {
if (i === 0) {
if (decodeEventName(event.topics[0]) !== mapping.topics[0]) return null;
}
// Future: support matching other topics too
}

const fields: Record<string, any> = {};

// 2. Extract topics[1..]
mapping.event_structure.topics.forEach((t: any, i: number) => {
const hex = event.topics[i + 1];
if (!hex) return;
if (t.type === "address") fields[t.name] = decodeAddress(hex);
else if (t.type === "i128") fields[t.name] = decodeAmount(hex);
else fields[t.name] = hex;
});

// 3. Extract data
if (mapping.event_structure.data) {
const d = mapping.event_structure.data;
if (d.type === "i128") fields[d.name] = decodeAmount(event.data);
else if (d.type === "address") fields[d.name] = decodeAddress(event.data);
else fields[d.name] = event.data;
}

return {
description: interpolate(mapping.english_template, fields),
eventType: mapping.topics[0],
};
};
}

/**
* Builds a `translate` function from a single event-mapping declaration.
* Called by registerUpgrade (eventMappings). Required for the module to load.
Expand Down