diff --git a/indexer/.env.example b/indexer/.env.example index d0e1f91d..dc418fec 100644 --- a/indexer/.env.example +++ b/indexer/.env.example @@ -1,5 +1,20 @@ -DATABASE_URL=postgresql://trustlink:trustlink@localhost:5432/trustlink -CONTRACT_ID=YOUR_CONTRACT_ID_HERE +# Soroban RPC endpoint RPC_URL=https://soroban-testnet.stellar.org -GENESIS_LEDGER=0 -PORT=3000 + +# ── Single contract (original behaviour) ────────────────────────────────────── +# CONTRACT_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN8 + +# ── Multiple contracts (new) — comma-separated, takes precedence over CONTRACT_ID +CONTRACT_IDS=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN8,CYYY... + +# PostgreSQL connection +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/trustlink + +# Redis — used for PubSub (horizontal scaling) and optional query caching +REDIS_URL=redis://localhost:6379 + +# Port for the GraphQL / metrics HTTP server +PORT=4000 + +# Ledger to start from when no checkpoint exists (0 = chain genesis) +# GENESIS_LEDGER=0 diff --git a/indexer/prisma/migrations/0003_multi_contract/migration.sql b/indexer/prisma/migrations/0003_multi_contract/migration.sql new file mode 100644 index 00000000..916f589a --- /dev/null +++ b/indexer/prisma/migrations/0003_multi_contract/migration.sql @@ -0,0 +1,50 @@ +-- Migration: 0003_multi_contract +-- Extend schema to support multiple contract IDs. +-- +-- All attestation and proposal records are now keyed by contractId so a single +-- indexer deployment can track several TrustLink instances simultaneously. + +-- 1. Add contractId column with a temporary default so existing rows get a +-- value. The default is cleared after backfill to enforce NOT NULL. +ALTER TABLE "Attestation" ADD COLUMN "contractId" TEXT; + +-- Backfill existing rows using the CONTRACT_ID env var that was hard-coded +-- before this migration. Operators must set LEGACY_CONTRACT_ID to the value +-- that was in use before upgrading. If unset we fall back to an empty string +-- so the migration succeeds and operators can UPDATE manually. +UPDATE "Attestation" + SET "contractId" = COALESCE(current_setting('app.legacy_contract_id', true), ''); + +-- Make the column required once backfilled. +ALTER TABLE "Attestation" ALTER COLUMN "contractId" SET NOT NULL; + +ALTER TABLE "MultisigProposal" ADD COLUMN "contractId" TEXT; + +UPDATE "MultisigProposal" + SET "contractId" = COALESCE(current_setting('app.legacy_contract_id', true), ''); + +ALTER TABLE "MultisigProposal" ALTER COLUMN "contractId" SET NOT NULL; + +-- 2. Drop the old singleton Checkpoint row (id=1) and replace with a per- +-- contract table keyed by contractId. +DROP TABLE IF EXISTS "Checkpoint"; + +CREATE TABLE "Checkpoint" ( + "contractId" TEXT NOT NULL, + "ledger" INTEGER NOT NULL, + CONSTRAINT "Checkpoint_pkey" PRIMARY KEY ("contractId") +); + +-- Seed a checkpoint for the legacy contract if one was in use. +INSERT INTO "Checkpoint" ("contractId", "ledger") + SELECT COALESCE(current_setting('app.legacy_contract_id', true), ''), 0 + WHERE COALESCE(current_setting('app.legacy_contract_id', true), '') <> '' + ON CONFLICT DO NOTHING; + +-- 3. Add indexes for the new contractId columns. +CREATE INDEX "Attestation_contractId_idx" ON "Attestation"("contractId"); +CREATE INDEX "Attestation_contractId_subject_idx" ON "Attestation"("contractId", "subject"); +CREATE INDEX "Attestation_contractId_issuer_idx" ON "Attestation"("contractId", "issuer"); +CREATE INDEX "Attestation_contractId_claimType_idx" ON "Attestation"("contractId", "claimType"); + +CREATE INDEX "MultisigProposal_contractId_idx" ON "MultisigProposal"("contractId"); diff --git a/indexer/prisma/schema.prisma b/indexer/prisma/schema.prisma index c2696773..6d638084 100644 --- a/indexer/prisma/schema.prisma +++ b/indexer/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { model Attestation { id String @id // deterministic hash ID from the contract + contractId String // the contract that emitted this attestation issuer String subject String claimType String @@ -28,10 +29,15 @@ model Attestation { @@index([claimType]) @@index([subject, claimType]) @@index([timestamp]) + @@index([contractId]) + @@index([contractId, subject]) + @@index([contractId, issuer]) + @@index([contractId, claimType]) } model MultisigProposal { id String @id // proposal ID from the contract + contractId String // the contract that emitted this proposal subject String proposer String claimType String @@ -45,12 +51,13 @@ model MultisigProposal { @@index([subject]) @@index([proposer]) + @@index([contractId]) } -// Singleton row (id=1) tracking the last fully-processed ledger sequence. +// One row per watched contract, tracking the last fully-processed ledger. model Checkpoint { - id Int @id @default(1) - ledger Int + contractId String @id + ledger Int } model Webhook { diff --git a/indexer/src/graphql.ts b/indexer/src/graphql.ts index fa8b9a3c..abbbc060 100644 --- a/indexer/src/graphql.ts +++ b/indexer/src/graphql.ts @@ -6,14 +6,20 @@ export const ATTESTATION_CREATED = "ATTESTATION_CREATED"; export const ATTESTATION_REVOKED = "ATTESTATION_REVOKED"; export const ISSUER_REGISTERED = "ISSUER_REGISTERED"; -type MappedAttestation = Omit & { +type MappedAttestation = Omit< + Attestation, + "timestamp" | "expiration" | "createdAt" | "updatedAt" +> & { timestamp: string; expiration: string | null; createdAt: string; updatedAt: string; }; -type MappedProposal = Omit & { +type MappedProposal = Omit< + MultisigProposal, + "expiresAt" | "createdAt" | "updatedAt" +> & { expiresAt: string; createdAt: string; updatedAt: string; @@ -38,20 +44,82 @@ function mapProposal(p: MultisigProposal): MappedProposal { }; } +// ── Cursor-based pagination helper ──────────────────────────────────────────── + +interface AttestationConnection { + edges: { node: MappedAttestation; cursor: string }[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; + }; + totalCount: number; +} + +async function buildAttestationConnection( + db: PrismaClient, + where: Record, + first?: number, + after?: string +): Promise { + const take = Math.min(first ?? 20, 100); + const cursor = after ? { id: after } : undefined; + const skip = cursor ? 1 : 0; + + const [rows, totalCount] = await Promise.all([ + db.attestation.findMany({ + where, + take, + skip, + cursor, + orderBy: { timestamp: "desc" }, + }), + db.attestation.count({ where }), + ]); + + const edges = rows.map((row) => ({ + node: mapAttestation(row), + cursor: row.id, + })); + + return { + edges, + pageInfo: { + hasNextPage: edges.length === take, + hasPreviousPage: !!after, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }, + totalCount, + }; +} + +// ── Resolvers ───────────────────────────────────────────────────────────────── + export function buildResolvers(db: PrismaClient) { return { Query: { + /** + * Query attestations with optional filters. + * + * The `contractId` argument restricts results to a single tracked + * contract instance. Omitting it returns data across all tracked + * contracts (useful for platform-wide dashboards). + */ attestations: async ( _: unknown, - args: { - subject?: string; - claimType?: string; + args: { + contractId?: string; + subject?: string; + claimType?: string; status?: "ACTIVE" | "REVOKED"; first?: number; after?: string; } ): Promise => { const where: Record = {}; + if (args.contractId) where.contractId = args.contractId; if (args.subject) where.subject = args.subject; if (args.claimType) where.claimType = args.claimType; if (args.status === "ACTIVE") where.isRevoked = false; @@ -60,21 +128,35 @@ export function buildResolvers(db: PrismaClient) { return buildAttestationConnection(db, where, args.first, args.after); }, + /** + * Query attestations by issuer with optional contractId scope. + */ attestationsByIssuer: async ( _: unknown, args: { issuer: string; + contractId?: string; first?: number; after?: string; } ): Promise => { - const where = { issuer: args.issuer }; + const where: Record = { issuer: args.issuer }; + if (args.contractId) where.contractId = args.contractId; return buildAttestationConnection(db, where, args.first, args.after); }, - issuerStats: async (_: unknown, args: { issuer: string }) => { + /** + * Issuer statistics, optionally scoped to a contract instance. + */ + issuerStats: async ( + _: unknown, + args: { issuer: string; contractId?: string } + ) => { + const where: Record = { issuer: args.issuer }; + if (args.contractId) where.contractId = args.contractId; + const rows = await db.attestation.findMany({ - where: { issuer: args.issuer }, + where, select: { isRevoked: true, claimType: true }, }); @@ -83,6 +165,7 @@ export function buildResolvers(db: PrismaClient) { return { issuer: args.issuer, + contractId: args.contractId ?? null, total: rows.length, active: rows.length - revoked, revoked, @@ -90,6 +173,9 @@ export function buildResolvers(db: PrismaClient) { }; }, + /** + * Fetch a multi-sig proposal by ID. + */ proposal: async (_: unknown, args: { id: string }) => { const proposal = await db.multisigProposal.findUnique({ where: { id: args.id }, @@ -97,11 +183,15 @@ export function buildResolvers(db: PrismaClient) { return proposal ? mapProposal(proposal) : null; }, + /** + * List multi-sig proposals, optionally scoped to a contract instance. + */ proposals: async ( _: unknown, - args: { subject?: string; finalized?: boolean } + args: { contractId?: string; subject?: string; finalized?: boolean } ) => { const where: Record = {}; + if (args.contractId) where.contractId = args.contractId; if (args.subject) where.subject = args.subject; if (args.finalized !== undefined) where.finalized = args.finalized; @@ -111,19 +201,31 @@ export function buildResolvers(db: PrismaClient) { }); return rows.map(mapProposal); }, + + /** + * Return the list of contract IDs currently being tracked. + */ + trackedContracts: async () => { + const checkpoints = await db.checkpoint.findMany(); + return checkpoints.map((c) => c.contractId); + }, }, Subscription: { onAttestationCreated: { - subscribe: (_: unknown, args: { subject?: string }) => { + subscribe: ( + _: unknown, + args: { subject?: string; contractId?: string } + ) => { const iter = pubsub.asyncIterableIterator<{ - onAttestationCreated: ReturnType; + onAttestationCreated: MappedAttestation; }>(ATTESTATION_CREATED); - if (!args.subject) return iter; + const { subject, contractId } = args; + + // If no filters, return the raw iterator. + if (!subject && !contractId) return iter; - // Filter by subject when provided - const subject = args.subject; return { [Symbol.asyncIterator]() { return this; @@ -133,28 +235,40 @@ export function buildResolvers(db: PrismaClient) { const result = await iter.next(); if (result.done) return result; const att = result.value?.onAttestationCreated; - if (!att || att.subject === subject) return result; + if (!att) return result; + if (subject && att.subject !== subject) continue; + if (contractId && att.contractId !== contractId) continue; + return result; } }, async return() { - return iter.return?.() ?? { done: true as const, value: undefined }; + return ( + iter.return?.() ?? { done: true as const, value: undefined } + ); }, }; }, - resolve: (payload: { - onAttestationCreated: ReturnType; - }) => payload.onAttestationCreated, + resolve: (payload: { onAttestationCreated: MappedAttestation }) => + payload.onAttestationCreated, }, onAttestationRevoked: { - subscribe: (_: unknown, args: { issuer?: string }) => { + subscribe: ( + _: unknown, + args: { issuer?: string; contractId?: string } + ) => { const iter = pubsub.asyncIterableIterator<{ - onAttestationRevoked: { id: string; issuer: string; revokedAt: string }; + onAttestationRevoked: { + id: string; + issuer: string; + contractId: string; + revokedAt: string; + }; }>(ATTESTATION_REVOKED); - if (!args.issuer) return iter; + const { issuer, contractId } = args; + if (!issuer && !contractId) return iter; - const issuer = args.issuer; return { [Symbol.asyncIterator]() { return this; @@ -164,16 +278,26 @@ export function buildResolvers(db: PrismaClient) { const result = await iter.next(); if (result.done) return result; const data = result.value?.onAttestationRevoked; - if (!data || data.issuer === issuer) return result; + if (!data) return result; + if (issuer && data.issuer !== issuer) continue; + if (contractId && data.contractId !== contractId) continue; + return result; } }, async return() { - return iter.return?.() ?? { done: true as const, value: undefined }; + return ( + iter.return?.() ?? { done: true as const, value: undefined } + ); }, }; }, resolve: (payload: { - onAttestationRevoked: { id: string; issuer: string; revokedAt: string }; + onAttestationRevoked: { + id: string; + issuer: string; + contractId: string; + revokedAt: string; + }; }) => payload.onAttestationRevoked, }, diff --git a/indexer/src/indexer.ts b/indexer/src/indexer.ts index 21efa7af..b31fbb54 100644 --- a/indexer/src/indexer.ts +++ b/indexer/src/indexer.ts @@ -9,54 +9,114 @@ import { } from "./metrics"; import { dispatchWebhooks } from "./webhooks"; -const CONTRACT_ID = process.env.CONTRACT_ID!; +// ── Configuration ───────────────────────────────────────────────────────────── + +/** + * Accept one or more contract IDs to track. + * + * Set a single contract: + * CONTRACT_ID=CXXX... + * + * Set multiple contracts (comma-separated): + * CONTRACT_IDS=CXXX...,CYYY...,CZZZ... + * + * CONTRACT_IDS takes precedence over CONTRACT_ID when both are set. + */ +function resolveContractIds(): string[] { + const multi = process.env.CONTRACT_IDS; + if (multi) { + const ids = multi + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ids.length > 0) return ids; + } + const single = process.env.CONTRACT_ID; + if (single) return [single]; + throw new Error( + "No contract IDs configured. Set CONTRACT_ID or CONTRACT_IDS environment variable." + ); +} + +const CONTRACT_IDS = resolveContractIds(); const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const PAGE_LIMIT = 200; const POLL_MS = 5_000; -const WATCHED = new Set(["created", "revoked", "imported", "bridged", "ms_prop", "ms_sign", "ms_actv"]); +// Ledger sequence to start from when no checkpoint exists. +// Callers can override via GENESIS_LEDGER env var. +const GENESIS_LEDGER = Number(process.env.GENESIS_LEDGER ?? 0); + +const WATCHED = new Set([ + "created", + "revoked", + "imported", + "bridged", + "ms_prop", + "ms_sign", + "ms_actv", +]); -let lastLedger = 0; +// Per-contract last-ledger tracker (in-memory, for metrics). +const lastLedgerByContract = new Map(); export function getLastLedger(): number { - return lastLedger; + return Math.min(...Array.from(lastLedgerByContract.values()), 0); } +// ── Entry point ─────────────────────────────────────────────────────────────── + export async function startIndexer(db: PrismaClient): Promise { const rpc = new SorobanRpc.Server(RPC_URL, { allowHttp: true }); - // ── Backfill ─────────────────────────────────────────────────────────────── - const checkpoint = await db.checkpoint.findUnique({ where: { id: 1 } }); + console.log(`Tracking ${CONTRACT_IDS.length} contract(s): ${CONTRACT_IDS.join(", ")}`); + + // Start an independent indexer loop for each contract ID. + await Promise.all( + CONTRACT_IDS.map((contractId) => runContractIndexer(db, rpc, contractId)) + ); +} + +// ── Per-contract indexer loop ───────────────────────────────────────────────── + +async function runContractIndexer( + db: PrismaClient, + rpc: SorobanRpc.Server, + contractId: string +): Promise { + // Load per-contract checkpoint (ledger keyed by contractId). + const checkpoint = await db.checkpoint.findUnique({ + where: { contractId }, + }); let cursor = checkpoint ? checkpoint.ledger + 1 : GENESIS_LEDGER; const { sequence: tip } = await rpc.getLatestLedger(); if (cursor <= tip) { - console.log(`Backfilling ledgers ${cursor}–${tip}…`); + console.log(`[${contractId}] Backfilling ledgers ${cursor}–${tip}…`); try { - cursor = await processRange(db, rpc, cursor, tip); + cursor = await processRange(db, rpc, contractId, cursor, tip); } catch (err) { - console.error("Error during backfill:", err); - // Continue with live polling even if backfill fails + console.error(`[${contractId}] Error during backfill:`, err); } } - // ── Live polling ─────────────────────────────────────────────────────────── - console.log("Live polling for new events…"); + console.log(`[${contractId}] Live polling for new events…`); while (true) { await sleep(POLL_MS); const { sequence: latest } = await rpc.getLatestLedger(); if (cursor <= latest) { - cursor = await processRange(db, rpc, cursor, latest); + cursor = await processRange(db, rpc, contractId, cursor, latest); indexerLagLedgers.set(latest - cursor); } } } -// ── Core processing ────────────────────────────────────────────────────────── +// ── Core processing ─────────────────────────────────────────────────────────── async function processRange( db: PrismaClient, rpc: SorobanRpc.Server, + contractId: string, from: number, to: number ): Promise { @@ -65,64 +125,62 @@ async function processRange( while (startLedger <= to) { const endLedger = Math.min(startLedger + PAGE_LIMIT - 1, to); - + let lastProcessed = endLedger; + try { const response = await rpc.getEvents({ startLedger, endLedger, - filters: [{ type: "contract", contractIds: [CONTRACT_ID] }], + filters: [{ type: "contract", contractIds: [contractId] }], limit: PAGE_LIMIT, }); for (const ev of response.events) { try { - await handleEvent(db, ev); + await handleEvent(db, contractId, ev); processedCount++; } catch (err) { - console.error(`Error processing event at ledger ${ev.ledger}:`, err); - // Continue processing other events + console.error( + `[${contractId}] Error processing event at ledger ${ev.ledger}:`, + err + ); } } - const lastProcessed = - response.events.length > 0 - ? response.events[response.events.length - 1].ledger - : endLedger; - - startLedger = lastProcessed + 1; - - await db.checkpoint.upsert({ - where: { id: 1 }, - update: { ledger: lastProcessed }, - create: { id: 1, ledger: lastProcessed }, - }); - - if (processedCount % 100 === 0) { - console.log(`Processed ${processedCount} events, checkpoint: ${lastProcessed}`); + if (response.events.length > 0) { + lastProcessed = response.events[response.events.length - 1].ledger; } } catch (err) { - console.error(`Error fetching events from ledger ${startLedger} to ${endLedger}:`, err); - // Retry with exponential backoff - await sleep(1000); + console.error( + `[${contractId}] Error fetching events ${startLedger}–${endLedger}:`, + err + ); + await sleep(1_000); continue; } - const lastProcessed = - response.events.length > 0 - ? response.events[response.events.length - 1].ledger - : Math.min(startLedger + PAGE_LIMIT - 1, to); - - lastLedger = lastProcessed; + lastLedgerByContract.set(contractId, lastProcessed); startLedger = lastProcessed + 1; await db.checkpoint.upsert({ - where: { id: 1 }, + where: { contractId }, update: { ledger: lastProcessed }, - create: { id: 1, ledger: lastProcessed }, + create: { contractId, ledger: lastProcessed }, }); + + if (processedCount > 0 && processedCount % 100 === 0) { + console.log( + `[${contractId}] Processed ${processedCount} events, checkpoint: ${lastProcessed}` + ); + } + } + + if (processedCount > 0) { + console.log( + `[${contractId}] Completed range ${from}–${to}, total events: ${processedCount}` + ); } - console.log(`Completed processing range ${from}–${to}, total events: ${processedCount}`); return to + 1; } @@ -130,6 +188,7 @@ async function processRange( async function handleEvent( db: PrismaClient, + contractId: string, ev: SorobanRpc.Api.EventResponse ): Promise { if (!ev.topic.length) return; @@ -140,37 +199,34 @@ async function handleEvent( eventsProcessedTotal.inc(); const data = scValToNative(ev.value) as unknown[]; - // Handle multi-sig events + // ── multi-sig events ──────────────────────────────────────────────────────── if (topicStr === "ms_prop") { - // data: [proposal_id, proposer, threshold] const proposalId = String(data[0]); const proposer = String(data[1]); const threshold = Number(data[2]); const subject = ev.topic[1] ? String(scValToNative(ev.topic[1])) : ""; - // For now, we'll store basic proposal info. Full details would come from contract state. await db.multisigProposal.upsert({ where: { id: proposalId }, update: {}, create: { id: proposalId, + contractId, subject, proposer, - claimType: "", // Will be updated when we get more info + claimType: "", threshold, signers: [proposer], signatureCount: 1, - expiresAt: BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60), // 7 days + expiresAt: BigInt(Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60), }, }); return; } if (topicStr === "ms_sign") { - // data: [proposal_id, signatures_so_far, threshold] const proposalId = String(data[0]); const signatureCount = Number(data[1]); - await db.multisigProposal.update({ where: { id: proposalId }, data: { signatureCount }, @@ -179,9 +235,7 @@ async function handleEvent( } if (topicStr === "ms_actv") { - // data: [proposal_id, attestation_id] const proposalId = String(data[0]); - await db.multisigProposal.update({ where: { id: proposalId }, data: { finalized: true }, @@ -192,22 +246,26 @@ async function handleEvent( if (topicStr === "revoked") { const attestationId = String(data[0]); - const attestation = await db.attestation.findUnique({ - where: { id: attestationId }, - }); - await db.attestation.updateMany({ - where: { id: attestationId }, + where: { id: attestationId, contractId }, data: { isRevoked: true }, }); revocationsTotal.inc(); - dispatchWebhooks(db, "attestation.revoked", { id: attestationId }).catch(() => {}); + dispatchWebhooks(db, "attestation.revoked", { + id: attestationId, + contractId, + }).catch(() => {}); return; } - // "created" | "imported" | "bridged" + // ── "created" | "imported" | "bridged" ───────────────────────────────────── const subject = ev.topic[1] ? String(scValToNative(ev.topic[1])) : ""; - const [id, issuer, claimType, rawTs] = data as [string, string, string, bigint | number]; + const [id, issuer, claimType, rawTs] = data as [ + string, + string, + string, + bigint | number + ]; const timestamp = BigInt(rawTs); let extra: Record = {}; @@ -224,9 +282,10 @@ async function handleEvent( const attestation = await db.attestation.upsert({ where: { id }, - update: { subject, ...extra }, + update: { subject, contractId, ...extra }, create: { id, + contractId, issuer, subject, claimType, @@ -239,19 +298,19 @@ async function handleEvent( attestationsTotal.inc(); - // Dispatch webhooks for new attestation events dispatchWebhooks(db, `attestation.${topicStr}`, { ...attestation, timestamp: String(attestation.timestamp), - expiration: attestation.expiration != null ? String(attestation.expiration) : null, + expiration: + attestation.expiration != null ? String(attestation.expiration) : null, }).catch(() => {}); - // Publish to GraphQL subscriptions pubsub.publish(ATTESTATION_CREATED, { onAttestationCreated: { ...attestation, timestamp: String(attestation.timestamp), - expiration: attestation.expiration != null ? String(attestation.expiration) : null, + expiration: + attestation.expiration != null ? String(attestation.expiration) : null, createdAt: attestation.createdAt.toISOString(), updatedAt: attestation.updatedAt.toISOString(), }, diff --git a/indexer/src/schema.graphql b/indexer/src/schema.graphql index e3bb7a8b..4ab9f172 100644 --- a/indexer/src/schema.graphql +++ b/indexer/src/schema.graphql @@ -9,6 +9,8 @@ enum Status { type Attestation { id: String! + """The TrustLink contract instance that emitted this attestation.""" + contractId: String! issuer: String! subject: String! claimType: String! @@ -26,6 +28,8 @@ type Attestation { type MultisigProposal { id: String! + """The TrustLink contract instance that emitted this proposal.""" + contractId: String! subject: String! proposer: String! claimType: String! @@ -40,6 +44,8 @@ type MultisigProposal { type IssuerStats { issuer: String! + """Contract instance scope, null means across all tracked contracts.""" + contractId: String total: Int! active: Int! revoked: Int! @@ -68,38 +74,76 @@ type AttestationConnection { } type Query { - """Query attestations with optional filters and pagination""" + """ + Query attestations with optional filters and pagination. + + Pass `contractId` to scope results to a single TrustLink deployment. + Omit it to query across all tracked contracts simultaneously. + """ attestations( - subject: String, - claimType: String, - status: Status, - first: Int, + contractId: String + subject: String + claimType: String + status: Status + first: Int after: String ): AttestationConnection! - """Query attestations by issuer with pagination""" + """ + Query attestations by issuer. + + Pass `contractId` to scope to a single deployment. + """ attestationsByIssuer( - issuer: String!, - first: Int, + issuer: String! + contractId: String + first: Int after: String ): AttestationConnection! - """Get statistics for a specific issuer""" - issuerStats(issuer: String!): IssuerStats! + """ + Get statistics for a specific issuer. + + Pass `contractId` to scope to a single deployment; omit for totals + across all tracked contracts. + """ + issuerStats(issuer: String!, contractId: String): IssuerStats! """Get a multi-sig proposal by ID""" proposal(id: String!): MultisigProposal - """List all multi-sig proposals""" - proposals(subject: String, finalized: Boolean): [MultisigProposal!]! + """ + List multi-sig proposals. + + Pass `contractId` to scope to a single deployment. + """ + proposals( + contractId: String + subject: String + finalized: Boolean + ): [MultisigProposal!]! + + """ + Return the list of contract IDs currently being tracked by this indexer. + Useful for discovery when CONTRACT_IDS contains multiple entries. + """ + trackedContracts: [String!]! } type Subscription { - """Subscribe to new attestation creation events, optionally filtered by subject""" - onAttestationCreated(subject: String): Attestation! + """ + Subscribe to new attestation creation events. + + Filter by `subject`, `contractId`, or both. + """ + onAttestationCreated(subject: String, contractId: String): Attestation! + + """ + Subscribe to attestation revocation events. - """Subscribe to attestation revocation events, optionally filtered by issuer""" - onAttestationRevoked(issuer: String): AttestationRevoked! + Filter by `issuer`, `contractId`, or both. + """ + onAttestationRevoked(issuer: String, contractId: String): AttestationRevoked! """Subscribe to issuer registration events""" onIssuerRegistered: IssuerRegistered! @@ -108,6 +152,7 @@ type Subscription { type AttestationRevoked { id: String! issuer: String! + contractId: String! revokedAt: String! }