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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ POLL_INTERVAL_MS=6000
# there is no N+1 query penalty for watching multiple contracts.
SAC_CONTRACT_IDS=

# ─── NFT Contract IDs ───────────────────────────────────────────────────────
# Comma-separated list of CAP-46 NFT contract IDs to explicitly watch.
# When unset, NFT events are still auto-detected from any contract event that
# carries 4 topics (the CAP-46 transfer(from, to, token_id) structure).
#
# Example (watch a known testnet NFT contract):
# NFT_CONTRACT_IDS=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
NFT_CONTRACT_IDS=

# Legacy alias — CONTRACT_IDS is still accepted for backwards-compatibility.
# SAC_CONTRACT_IDS takes precedence when both are set.
# CONTRACT_IDS=
Expand Down
49 changes: 49 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,55 @@ model TokenTransfer {
@@schema("wraith")
}

// ─── NFT Transfers ────────────────────────────────────────────────────────────
// One row per CAP-46 NFT transfer event (transfer(from, to, token_id)).
model NftTransfer {
id Int @id @default(autoincrement())

// The NFT contract address (C...)
contractId String

// Opaque token identifier decoded from topic[3] (u128 → decimal string,
// bytes → hex, string → as-is)
tokenId String

// Sender — null if zero address (mint)
fromAddress String?

// Recipient — null if zero address (burn)
toAddress String?

ledger Int
ledgerClosedAt DateTime
txHash String

// Stellar RPC paging token — unique per event, used for deduplication
eventId String @unique

createdAt DateTime @default(now())

@@index([contractId])
@@index([tokenId])
@@index([toAddress])
@@index([fromAddress])
@@index([contractId, tokenId])
@@schema("wraith")
}

// ─── NFT Metadata Cache ───────────────────────────────────────────────────────
// Lazily-fetched per-token metadata; populated on first transfer seen.
model NftMetadata {
id Int @id @default(autoincrement())
contractId String
tokenId String
name String?
tokenUri String?
fetchedAt DateTime @default(now())

@@unique([contractId, tokenId])
@@schema("wraith")
}

// ─── Indexer State ────────────────────────────────────────────────────────────
// Persists the last successfully indexed ledger so restarts resume cleanly.
model IndexerState {
Expand Down
184 changes: 184 additions & 0 deletions src/__tests__/nft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { xdr, Address, nativeToScVal } from "@stellar/stellar-sdk";
import {
isNftTransferEvent,
parseNftTransferEvent,
parseNftEvents,
} from "../ingester/nft";
import type { RawEvent } from "../rpc";

// ─── Test addresses (reuse fixtures from decoder tests) ───────────────────────
const ALICE = "GDWCO35QUYQLGO6P7OLW4BZWNMMGGUWNPLRVPLCBVG7YNVDZKUDIW4KN";
const BOB = "GCXOO7OIJZ2HEOZODLOEISNVO6CBPK4PISRJCZYRFT37H7XGHDLB3C7O";
const CONTRACT = "CBC42KFZO33TYVFDOUXFRWXYYXHFGH7W5GM4IJQSXKGFINKL2XPP4XTE";

const COMMON: Omit<RawEvent, "topic" | "value"> = {
id: "0000000000000000001-00001",
type: "contract",
ledger: 100,
ledgerClosedAt: "2024-01-01T00:00:00Z",
contractId: CONTRACT,
txHash: "abc123txhash",
};

/**
* Build a CAP-46 NFT transfer RawEvent:
* topics = [Symbol("transfer"), Address(from), Address(to), u128(tokenId)]
* value = void
*/
function makeNftEvent(tokenId: bigint = 42n, id = COMMON.id): RawEvent {
return {
...COMMON,
id,
topic: [
nativeToScVal("transfer", { type: "symbol" }),
Address.fromString(ALICE).toScVal(),
Address.fromString(BOB).toScVal(),
nativeToScVal(tokenId, { type: "u128" }),
],
value: xdr.ScVal.scvVoid(),
};
}

/**
* Build a SEP-41 fungible transfer RawEvent:
* topics = [Symbol("transfer"), Address(from), Address(to)]
* value = i128(amount)
*/
function makeFungibleEvent(): RawEvent {
return {
...COMMON,
id: "0000000000000000001-00099",
topic: [
nativeToScVal("transfer", { type: "symbol" }),
Address.fromString(ALICE).toScVal(),
Address.fromString(BOB).toScVal(),
],
value: nativeToScVal(1_000_000_000n, { type: "i128" }),
};
}

// ─── isNftTransferEvent ───────────────────────────────────────────────────────

describe("isNftTransferEvent", () => {
it("returns true for 4-topic NFT transfer", () => {
expect(isNftTransferEvent(makeNftEvent())).toBe(true);
});

it("returns false for 3-topic fungible transfer", () => {
expect(isNftTransferEvent(makeFungibleEvent())).toBe(false);
});

it("returns false when symbol is not 'transfer'", () => {
const ev: RawEvent = {
...COMMON,
topic: [
nativeToScVal("mint", { type: "symbol" }),
Address.fromString(ALICE).toScVal(),
Address.fromString(BOB).toScVal(),
nativeToScVal(1n, { type: "u128" }),
],
value: xdr.ScVal.scvVoid(),
};
expect(isNftTransferEvent(ev)).toBe(false);
});

it("returns false for an empty topics array", () => {
const ev: RawEvent = { ...COMMON, topic: [], value: xdr.ScVal.scvVoid() };
expect(isNftTransferEvent(ev)).toBe(false);
});

it("returns false when address topics have wrong ScVal type", () => {
const ev: RawEvent = {
...COMMON,
topic: [
nativeToScVal("transfer", { type: "symbol" }),
xdr.ScVal.scvVoid(), // not an address
xdr.ScVal.scvVoid(),
nativeToScVal(1n, { type: "u128" }),
],
value: xdr.ScVal.scvVoid(),
};
expect(isNftTransferEvent(ev)).toBe(false);
});
});

// ─── parseNftTransferEvent ────────────────────────────────────────────────────

describe("parseNftTransferEvent", () => {
it("parses a u128 tokenId correctly", () => {
const result = parseNftTransferEvent(makeNftEvent(42n));
expect(result).not.toBeNull();
expect(result?.contractId).toBe(CONTRACT);
expect(result?.tokenId).toBe("42");
expect(result?.fromAddress).toBe(ALICE);
expect(result?.toAddress).toBe(BOB);
expect(result?.ledger).toBe(100);
expect(result?.txHash).toBe("abc123txhash");
expect(result?.eventId).toBe(COMMON.id);
});

it("parses a large tokenId (u128 near-max)", () => {
const big = 2n ** 64n + 99n;
const result = parseNftTransferEvent(makeNftEvent(big));
expect(result).not.toBeNull();
expect(result?.tokenId).toBe(big.toString());
});

it("parses a string tokenId", () => {
const ev: RawEvent = {
...COMMON,
id: "0000000000000000001-00010",
topic: [
nativeToScVal("transfer", { type: "symbol" }),
Address.fromString(ALICE).toScVal(),
Address.fromString(BOB).toScVal(),
nativeToScVal("my-nft-token", { type: "string" }),
],
value: xdr.ScVal.scvVoid(),
};
const result = parseNftTransferEvent(ev);
expect(result).not.toBeNull();
expect(result?.tokenId).toBe("my-nft-token");
});

it("returns null for a fungible transfer (3 topics)", () => {
expect(parseNftTransferEvent(makeFungibleEvent())).toBeNull();
});

it("sets ledgerClosedAt as a proper Date", () => {
const result = parseNftTransferEvent(makeNftEvent());
expect(result?.ledgerClosedAt).toBeInstanceOf(Date);
expect(result?.ledgerClosedAt.toISOString()).toBe("2024-01-01T00:00:00.000Z");
});
});

// ─── parseNftEvents ───────────────────────────────────────────────────────────

describe("parseNftEvents", () => {
it("extracts only NFT events from a mixed batch", () => {
const batch: RawEvent[] = [
makeNftEvent(1n, "id-001"),
makeFungibleEvent(),
makeNftEvent(2n, "id-003"),
];
const results = parseNftEvents(batch);
expect(results).toHaveLength(2);
expect(results[0].record.tokenId).toBe("1");
expect(results[1].record.tokenId).toBe("2");
});

it("returns the tokenIdScVal alongside each record", () => {
const [{ record, tokenIdScVal }] = parseNftEvents([makeNftEvent(99n)]);
expect(record.tokenId).toBe("99");
// tokenIdScVal must be the u128 ScVal from topic[3]
expect(tokenIdScVal.switch()).toBe(xdr.ScValType.scvU128());
});

it("returns an empty array when the batch has no NFT events", () => {
expect(parseNftEvents([makeFungibleEvent()])).toHaveLength(0);
});

it("returns an empty array for an empty batch", () => {
expect(parseNftEvents([])).toHaveLength(0);
});
});
87 changes: 86 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, getLastIndexedLedger, prisma } from "./db";
import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, queryNftTransfers, getNftOwner, getNftMetadata, getLastIndexedLedger, prisma } from "./db";
import { getLatestLedger } from "./rpc";
import { getIndexerStats } from "./indexer";

Expand Down Expand Up @@ -497,6 +497,91 @@ export function createApp(): express.Application {
}
);

// ── GET /nfts/transfers ──────────────────────────────────────────────────────
/**
* Query CAP-46 NFT transfer events.
*
* Query params:
* contract — filter to a specific NFT contract (C...)
* token_id — filter to a specific token identifier
* address — filter to transfers where from OR to equals this address
* fromLedger — inclusive lower ledger bound
* toLedger — inclusive upper ledger bound
* limit — page size (max 200, default 50)
* offset — pagination offset (default 0)
*
* Response:
* { total, limit, offset, transfers: [...] }
*/
app.get(
"/nfts/transfers",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { contract, token_id, address, fromLedger, toLedger, limit, offset } = req.query;
const lim = parseIntParam(limit, 50);
const off = parseIntParam(offset, 0);

const result = await queryNftTransfers({
contractId: contract as string | undefined,
tokenId: token_id as string | undefined,
address: address as string | undefined,
fromLedger: fromLedger ? parseIntParam(fromLedger, 0) : undefined,
toLedger: toLedger ? parseIntParam(toLedger, 0) : undefined,
limit: lim,
offset: off,
});

res.json({ ...result, limit: lim, offset: off });
} catch (err) {
next(err);
}
}
);

// ── GET /nfts/owners/:contract/:token_id ─────────────────────────────────────
/**
* Return the current owner of an NFT (the toAddress of its most recent transfer).
* Also includes any cached metadata for the token.
*
* Path params:
* contract — NFT contract address (C...)
* token_id — Token identifier
*
* Response:
* { contract, token_id, owner, metadata: { name, tokenUri } | null }
*/
app.get(
"/nfts/owners/:contract/:token_id",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { contract, token_id } = req.params;

const [owner, metadata] = await Promise.all([
getNftOwner(contract, token_id),
getNftMetadata(contract, token_id),
]);

if (owner === null) {
res.status(404).json({
error: "Token not found. No transfers indexed for this contract/token_id.",
});
return;
}

res.json({
contract,
token_id,
owner,
metadata: metadata
? { name: metadata.name, tokenUri: metadata.tokenUri }
: null,
});
} catch (err) {
next(err);
}
}
);

// ── 404 handler ──────────────────────────────────────────────────────────────
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "Not found" });
Expand Down
Loading