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
47 changes: 38 additions & 9 deletions package-lock.json

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

15 changes: 12 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"roots": ["<rootDir>/src"],
"testMatch": ["**/__tests__/**/*.test.ts"],
"moduleFileExtensions": ["ts", "js", "json"],
"roots": [
"<rootDir>/src"
],
"testMatch": [
"**/__tests__/**/*.test.ts"
],
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"clearMocks": true
},
"dependencies": {
Expand All @@ -29,6 +37,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^8.3.2",
"prom-client": "^15.1.3",
"ws": "^8.20.0"
},
"devDependencies": {
Expand Down
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,16 @@ model IndexerState {

@@schema("wraith")
}

// ─── Token Metadata ───────────────────────────────────────────────────────────
// Caches token symbol, name, and decimals to avoid redundant RPC calls.
model TokenMetadata {
contractId String @id
symbol String
name String
decimals Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@schema("wraith")
}
109 changes: 109 additions & 0 deletions src/__tests__/decoder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { xdr } from "@stellar/stellar-sdk";
import { parseEvent } from "../decoder";
import * as fixtures from "./fixtures/events.json";

describe("Soroban XDR Decoder", () => {
const common = {
ledger: 100,
ledgerClosedAt: "2024-01-01T00:00:00Z",
contractId: fixtures.contractId,
txHash: "abc123txhash",
id: "0000000000000000001-00001",
type: "contract",
};

it("correctly parses a 'transfer' event", () => {
const raw = {
...common,
topic: fixtures.transfer.topic.map((t) => xdr.ScVal.fromXDR(t, "base64")),
value: xdr.ScVal.fromXDR(fixtures.transfer.value, "base64"),
};

const result = parseEvent(raw);
expect(result).not.toBeNull();
expect(result?.eventType).toBe("transfer");
expect(result?.fromAddress).toBe(fixtures.alice);
expect(result?.toAddress).toBe(fixtures.bob);
expect(result?.amount).toBe("1000000000");
});

it("correctly parses a 'mint' event", () => {
const raw = {
...common,
topic: fixtures.mint.topic.map((t) => xdr.ScVal.fromXDR(t, "base64")),
value: xdr.ScVal.fromXDR(fixtures.mint.value, "base64"),
};

const result = parseEvent(raw);
expect(result).not.toBeNull();
expect(result?.eventType).toBe("mint");
expect(result?.fromAddress).toBeNull(); // mint has no from for our purposes
expect(result?.toAddress).toBe(fixtures.bob);
expect(result?.amount).toBe("5000000000");
});

it("correctly parses a 'burn' event", () => {
const raw = {
...common,
topic: fixtures.burn.topic.map((t) => xdr.ScVal.fromXDR(t, "base64")),
value: xdr.ScVal.fromXDR(fixtures.burn.value, "base64"),
};

const result = parseEvent(raw);
expect(result).not.toBeNull();
expect(result?.eventType).toBe("burn");
expect(result?.fromAddress).toBe(fixtures.alice);
expect(result?.toAddress).toBeNull();
expect(result?.amount).toBe("100");
});

it("correctly parses a 'clawback' event", () => {
const raw = {
...common,
topic: fixtures.clawback.topic.map((t) => xdr.ScVal.fromXDR(t, "base64")),
value: xdr.ScVal.fromXDR(fixtures.clawback.value, "base64"),
};

const result = parseEvent(raw);
expect(result).not.toBeNull();
expect(result?.eventType).toBe("clawback");
expect(result?.fromAddress).toBe(fixtures.alice);
expect(result?.toAddress).toBeNull();
expect(result?.amount).toBe("200");
});

it("throws on malformed XDR topics", () => {
const raw = {
...common,
topic: fixtures.transfer.topic.slice(0, 1).map((t) => xdr.ScVal.fromXDR(t, "base64")), // Missing topics
value: xdr.ScVal.fromXDR(fixtures.transfer.value, "base64"),
};

expect(() => parseEvent(raw)).toThrow(/Malformed transfer event/);
});

it("throws on invalid ScVal type in topics", () => {
const raw = {
...common,
topic: [
xdr.ScVal.fromXDR(fixtures.transfer.topic[0], "base64"),
xdr.ScVal.scvVoid(), // Invalid address type
xdr.ScVal.scvVoid(),
],
value: xdr.ScVal.fromXDR(fixtures.transfer.value, "base64"),
};

expect(() => parseEvent(raw)).toThrow();
});

it("returns null for non-token events", () => {
const raw = {
...common,
topic: [xdr.ScVal.scvSymbol("something_else")],
value: xdr.ScVal.scvVoid(),
};

const result = parseEvent(raw);
expect(result).toBeNull();
});
});
39 changes: 39 additions & 0 deletions src/__tests__/fixtures/events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"transfer": {
"topic": [
"AAAADwAAAAh0cmFuc2Zlcg==",
"AAAAEgAAAAAAAAAA7CdvsKYgszvP+5duBzZrGGNSzXrjV6xBqb+G1HlVBos=",
"AAAAEgAAAAAAAAAArud9yE50cjsuGtxESbV3hBerj0SikWcRLPfz/uY41h0="
],
"value": "AAAACgAAAAAAAAAAAAAAADuaygA="
},
"mint": {
"topic": [
"AAAADwAAAARtaW50",
"AAAAEgAAAAAAAAAA7CdvsKYgszvP+5duBzZrGGNSzXrjV6xBqb+G1HlVBos=",
"AAAAEgAAAAAAAAAArud9yE50cjsuGtxESbV3hBerj0SikWcRLPfz/uY41h0="
],
"value": "AAAACgAAAAAAAAAAAAAAASoF8gA="
},
"burn": {
"topic": [
"AAAADwAAAARidXJu",
"AAAAEgAAAAAAAAAA7CdvsKYgszvP+5duBzZrGGNSzXrjV6xBqb+G1HlVBos="
],
"value": "AAAACgAAAAAAAAAAAAAAAAAAAGQ="
},
"clawback": {
"topic": [
"AAAADwAAAAhjbGF3YmFjaw==",
"AAAAEgAAAAAAAAAA7CdvsKYgszvP+5duBzZrGGNSzXrjV6xBqb+G1HlVBos="
],
"value": "AAAACgAAAAAAAAAAAAAAAAAAAMg="
},
"malformed": {
"topic": ["invalid_base64"],
"value": "invalid_base64"
},
"alice": "GDWCO35QUYQLGO6P7OLW4BZWNMMGGUWNPLRVPLCBVG7YNVDZKUDIW4KN",
"bob": "GCXOO7OIJZ2HEOZODLOEISNVO6CBPK4PISRJCZYRFT37H7XGHDLB3C7O",
"contractId": "CBC42KFZO33TYVFDOUXFRWXYYXHFGH7W5GM4IJQSXKGFINKL2XPP4XTE"
}
28 changes: 28 additions & 0 deletions src/__tests__/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import request from "supertest";
import { createApp } from "../api";

describe("Prometheus Metrics", () => {
const app = createApp();

it("GET /metrics returns Prometheus text format", async () => {
const res = await request(app).get("/metrics");

expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/plain");

// Check for some default metrics
expect(res.text).toContain("process_cpu_seconds_total");

// Check for our custom metrics
expect(res.text).toContain("trades_ingested_total");
expect(res.text).toContain("amm_snapshots_total");
expect(res.text).toContain("price_requests_total");
expect(res.text).toContain("db_query_duration_seconds");
});

it("metrics endpoint is not gated by rate limits (optional check)", async () => {
// This is hard to test without many requests, but we verified the order in api.ts
const res = await request(app).get("/metrics");
expect(res.status).toBe(200);
});
});
Loading