diff --git a/.env.example b/.env.example index 625ce085..17f0f7f4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e3e710de..f05b6b43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/__tests__/nft.test.ts b/src/__tests__/nft.test.ts new file mode 100644 index 00000000..be39e9c0 --- /dev/null +++ b/src/__tests__/nft.test.ts @@ -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 = { + 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); + }); +}); diff --git a/src/api.ts b/src/api.ts index 94c7dd29..9b50afe8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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"; @@ -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" }); diff --git a/src/db.ts b/src/db.ts index 3c2b60a0..a34ee623 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,5 @@ import { PrismaClient, Prisma } from "@prisma/client"; +import type { NftTransferRecord, NftMetadataPayload } from "./ingester/nft"; // ─── Singleton Prisma client ────────────────────────────────────────────────── // Re-use one connection pool across the process. @@ -205,6 +206,103 @@ export async function querySummary(params: SummaryQueryParams): Promise { + if (records.length === 0) return 0; + const result = await prisma.nftTransfer.createMany({ + data: records, + skipDuplicates: true, + }); + return result.count; +} + +export async function getNftMetadata( + contractId: string, + tokenId: string +): Promise<{ name: string | null; tokenUri: string | null } | null> { + return prisma.nftMetadata.findUnique({ + where: { contractId_tokenId: { contractId, tokenId } }, + select: { name: true, tokenUri: true }, + }); +} + +export async function upsertNftMetadata( + contractId: string, + tokenId: string, + data: NftMetadataPayload +): Promise { + await prisma.nftMetadata.upsert({ + where: { contractId_tokenId: { contractId, tokenId } }, + create: { contractId, tokenId, name: data.name ?? null, tokenUri: data.tokenUri ?? null }, + update: { name: data.name ?? null, tokenUri: data.tokenUri ?? null, fetchedAt: new Date() }, + }); +} + +export type NftTransferQueryParams = { + contractId?: string; + tokenId?: string; + address?: string; + fromLedger?: number; + toLedger?: number; + limit?: number; + offset?: number; +}; + +export async function queryNftTransfers(params: NftTransferQueryParams) { + const { + contractId, + tokenId, + address, + fromLedger, + toLedger, + limit = 50, + offset = 0, + } = params; + + const where: Prisma.NftTransferWhereInput = { + ...(contractId ? { contractId } : {}), + ...(tokenId ? { tokenId } : {}), + ...(address ? { OR: [{ fromAddress: address }, { toAddress: address }] } : {}), + ...(fromLedger || toLedger + ? { + ledger: { + ...(fromLedger ? { gte: fromLedger } : {}), + ...(toLedger ? { lte: toLedger } : {}), + }, + } + : {}), + }; + + const cap = Math.min(limit, 200); + const [total, transfers] = await prisma.$transaction([ + prisma.nftTransfer.count({ where }), + prisma.nftTransfer.findMany({ + where, + orderBy: [{ ledger: "desc" }, { id: "desc" }], + take: cap, + skip: offset, + }), + ]); + + return { total, transfers }; +} + +/** + * Return the current owner of a token: the toAddress of its most recent transfer. + */ +export async function getNftOwner( + contractId: string, + tokenId: string +): Promise { + const latest = await prisma.nftTransfer.findFirst({ + where: { contractId, tokenId, toAddress: { not: null } }, + orderBy: [{ ledger: "desc" }, { id: "desc" }], + select: { toAddress: true }, + }); + return latest?.toAddress ?? null; +} + // ─── Combined address query ─────────────────────────────────────────────────── export type AllTransfersQueryParams = { address: string; diff --git a/src/indexer.ts b/src/indexer.ts index 6aeeefb7..4cb62b17 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -3,11 +3,25 @@ import { fetchEventsSafe, getLatestLedger, withRetry, validateNetworkConfig } fr import { parseEvents } from "./decoder"; import { upsertTransfers, + upsertNftTransfers, + getNftMetadata, + upsertNftMetadata, getLastIndexedLedger, setLastIndexedLedger, pruneOldTransfers, } from "./db"; import { emitTransfer } from "./events"; +import { isNftTransferEvent, parseNftEvents, fetchNftMetadata } from "./ingester/nft"; + +// ─── NFT Contract IDs ───────────────────────────────────────────────────────── +/** + * Resolve the list of NFT contract IDs to watch. + * Falls back to empty — NFT events can still be auto-detected by topic structure. + */ +export function resolveNftContractIds(): string[] { + const raw = process.env.NFT_CONTRACT_IDS ?? ""; + return raw.split(",").map((s) => s.trim()).filter(Boolean); +} // ─── SAC Contract IDs ───────────────────────────────────────────────────────── // The native XLM SAC address on mainnet and testnet respectively. @@ -55,6 +69,9 @@ export function resolveSacContractIds(): string[] { const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS ?? "6000", 10); const BATCH_SIZE = parseInt(process.env.EVENTS_BATCH_SIZE ?? "10000", 10); const SAC_CONTRACT_IDS = resolveSacContractIds(); +const NFT_CONTRACT_IDS = resolveNftContractIds(); +// Combined watch list — deduplicated so we don't request the same contract twice +const ALL_CONTRACT_IDS = [...new Set([...SAC_CONTRACT_IDS, ...NFT_CONTRACT_IDS])]; // Stellar testnet RPC retains ~7 days ≈ 120 000 ledgers (at ~5s per ledger). // We cap the back-fill look-back so we never request a ledger that's already pruned. @@ -96,7 +113,7 @@ async function pollOnce( // fetchEventsSafe bisects on XDR decode errors so newer protocol ledgers // don't crash the whole indexer — they're skipped with a warning instead. const { events, highestLedger } = await fetchEventsSafe( - fromLedger, latestLedger, SAC_CONTRACT_IDS, BATCH_SIZE + fromLedger, latestLedger, ALL_CONTRACT_IDS, BATCH_SIZE ); if (events.length === 0) { @@ -104,22 +121,46 @@ async function pollOnce( return highestLedger; } - // Parse - const records = parseEvents(events); + // Split events by type: NFT (4 topics) vs fungible (3 topics) + const fungibleEvents = events.filter((e) => !isNftTransferEvent(e)); + const nftRawEvents = events.filter((e) => isNftTransferEvent(e)); - // Persist + // ── Fungible path ──────────────────────────────────────────────────────────── + const records = parseEvents(fungibleEvents); const inserted = await upsertTransfers(records); totalIndexed += inserted; - // Broadcast each new record to WebSocket subscribers if (inserted > 0) { records.forEach(emitTransfer); } + // ── NFT path ───────────────────────────────────────────────────────────────── + const nftParsed = parseNftEvents(nftRawEvents); + const nftRecords = nftParsed.map((p) => p.record); + const nftInserted = await upsertNftTransfers(nftRecords); + totalIndexed += nftInserted; + + // Lazy-load metadata for unique (contractId, tokenId) pairs not yet cached + if (nftParsed.length > 0) { + const seen = new Set(); + for (const { record, tokenIdScVal } of nftParsed) { + const key = `${record.contractId}:${record.tokenId}`; + if (seen.has(key)) continue; + seen.add(key); + const cached = await getNftMetadata(record.contractId, record.tokenId); + if (!cached) { + const meta = await fetchNftMetadata(record.contractId, tokenIdScVal).catch(() => ({})); + await upsertNftMetadata(record.contractId, record.tokenId, meta).catch((e) => + console.error("[indexer] NFT metadata upsert failed:", e) + ); + } + } + } + await setLastIndexedLedger(highestLedger); console.log( - `[indexer] Processed ${events.length} events → ${inserted} new records saved (ledger ${highestLedger})` + `[indexer] Processed ${events.length} events → ${inserted} fungible + ${nftInserted} NFT records saved (ledger ${highestLedger})` ); return highestLedger; @@ -134,6 +175,13 @@ export async function startIndexer(): Promise { console.log( `[indexer] Watching SAC contracts (${SAC_CONTRACT_IDS.length}): ${SAC_CONTRACT_IDS.join(", ")}` ); + if (NFT_CONTRACT_IDS.length > 0) { + console.log( + `[indexer] Watching NFT contracts (${NFT_CONTRACT_IDS.length}): ${NFT_CONTRACT_IDS.join(", ")}` + ); + } else { + console.log("[indexer] NFT auto-detection enabled (set NFT_CONTRACT_IDS for explicit watch)"); + } startedAt = Date.now(); diff --git a/src/ingester/nft.ts b/src/ingester/nft.ts new file mode 100644 index 00000000..9169f6c2 --- /dev/null +++ b/src/ingester/nft.ts @@ -0,0 +1,166 @@ +import { + scValToNative, + Address, + Contract, + TransactionBuilder, + Account, + Networks, + xdr, +} from "@stellar/stellar-sdk"; +import type { RawEvent } from "../rpc"; +import { getRpc } from "../rpc"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface NftTransferRecord { + contractId: string; + tokenId: string; + fromAddress: string | null; + toAddress: string | null; + ledger: number; + ledgerClosedAt: Date; + txHash: string; + eventId: string; +} + +export interface NftMetadataPayload { + name?: string; + tokenUri?: string; +} + +// ─── Detection ──────────────────────────────────────────────────────────────── + +/** + * CAP-46 NFT transfer events have 4 topics: + * [Symbol("transfer"), Address(from), Address(to), ScVal(token_id)] + * and a void value — distinguishing them from SEP-41 fungible transfers which + * have 3 topics and an i128 amount as the value. + */ +export function isNftTransferEvent(raw: RawEvent): boolean { + if (!raw.topic || raw.topic.length < 4) return false; + try { + const sym = scValToNative(raw.topic[0]); + if (sym !== "transfer") return false; + if (raw.topic[1].switch() !== xdr.ScValType.scvAddress()) return false; + if (raw.topic[2].switch() !== xdr.ScValType.scvAddress()) return false; + } catch { + return false; + } + return true; +} + +// ─── Decoding ───────────────────────────────────────────────────────────────── + +function decodeTokenId(scVal: xdr.ScVal): string { + try { + const native = scValToNative(scVal); + if (typeof native === "bigint") return native.toString(); + if (typeof native === "number") return String(native); + if (native instanceof Uint8Array) return Buffer.from(native).toString("hex"); + if (typeof native === "string") return native; + return String(native); + } catch { + // Fall back to raw XDR base64 so we never lose the event + return scVal.toXDR("base64"); + } +} + +/** + * Parse a single raw RPC event into a NftTransferRecord. + * Returns null for non-NFT events; never throws. + */ +export function parseNftTransferEvent(raw: RawEvent): NftTransferRecord | null { + if (!isNftTransferEvent(raw)) return null; + const { topic, contractId, ledger, ledgerClosedAt, txHash, id: eventId } = raw; + try { + const fromAddress = Address.fromScVal(topic[1]).toString(); + const toAddress = Address.fromScVal(topic[2]).toString(); + const tokenId = decodeTokenId(topic[3]); + return { + contractId, + tokenId, + fromAddress, + toAddress, + ledger, + ledgerClosedAt: new Date(ledgerClosedAt), + txHash, + eventId, + }; + } catch { + return null; + } +} + +/** + * Parse a batch of raw events, returning only NFT transfer records. + */ +export function parseNftEvents( + rawEvents: RawEvent[] +): Array<{ record: NftTransferRecord; tokenIdScVal: xdr.ScVal }> { + const results: Array<{ record: NftTransferRecord; tokenIdScVal: xdr.ScVal }> = []; + for (const raw of rawEvents) { + if (!isNftTransferEvent(raw)) continue; + const record = parseNftTransferEvent(raw); + if (record) { + results.push({ record, tokenIdScVal: raw.topic[3] }); + } + } + return results; +} + +// ─── Metadata fetch ─────────────────────────────────────────────────────────── + +/** + * Lazily fetch NFT metadata by simulating contract calls. + * Tries `token_uri(token_id)` and `name()` — both optional by spec. + * Never throws; returns whatever could be fetched. + */ +export async function fetchNftMetadata( + contractId: string, + tokenIdScVal: xdr.ScVal +): Promise { + const network = (process.env.STELLAR_NETWORK ?? "testnet").toLowerCase(); + const networkPassphrase = + network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; + + // Any valid address works as a simulation source — it doesn't need funds. + const dummy = new Account( + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", + "0" + ); + const contract = new Contract(contractId); + const rpc = getRpc(); + const result: NftMetadataPayload = {}; + + // Try token_uri(token_id) + try { + const tx = new TransactionBuilder(dummy, { fee: "100", networkPassphrase }) + .addOperation(contract.call("token_uri", tokenIdScVal)) + .setTimeout(30) + .build(); + const sim = await rpc.simulateTransaction(tx); + if ("result" in sim && sim.result) { + const val = scValToNative(sim.result.retval); + if (typeof val === "string") result.tokenUri = val; + } + } catch { + // function absent or simulation failure — skip silently + } + + // Try name() for the collection name + try { + const tx = new TransactionBuilder(dummy, { fee: "100", networkPassphrase }) + .addOperation(contract.call("name")) + .setTimeout(30) + .build(); + const sim = await rpc.simulateTransaction(tx); + if ("result" in sim && sim.result) { + const val = scValToNative(sim.result.retval); + if (typeof val === "string") result.name = val; + } + } catch { + // function absent or simulation failure — skip silently + } + + return result; +}