diff --git a/lib/translator/__tests__/persistence.dlq.test.ts b/lib/translator/__tests__/persistence.dlq.test.ts index 34bb702..09427fd 100644 --- a/lib/translator/__tests__/persistence.dlq.test.ts +++ b/lib/translator/__tests__/persistence.dlq.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { RawEvent, TranslatedEvent } from "../types"; import * as Persistence from "../persistence"; import { db } from "@/lib/db/client"; -import { translateEvent } from "../registry"; +import { translateWithCache } from "../registry"; vi.mock("../registry", async () => { const actual = await vi.importActual("../registry"); return { ...actual, - translateEvent: vi.fn(), + translateWithCache: vi.fn(), }; }); @@ -24,7 +24,7 @@ vi.mock("@/lib/ipfs/offloader", () => ({ })), })); -const mockedTranslateEvent = translateEvent as unknown as vi.MockedFunction; +const mockedTranslateWithCache = translateWithCache as unknown as vi.MockedFunction; const event: RawEvent = { id: "dead-letter-1", @@ -43,7 +43,7 @@ describe("translateAndPersistEvent DLQ", () => { it("writes a DeadLetterEvent when translation fails", async () => { const testError = new Error("Invalid XDR payload"); - mockedTranslateEvent.mockRejectedValueOnce(testError as any); + mockedTranslateWithCache.mockRejectedValueOnce(testError as any); const createSpy = vi.spyOn(db.deadLetterEvent, "create"); diff --git a/lib/translator/core.ts b/lib/translator/core.ts index edf9eb7..09f72c5 100644 --- a/lib/translator/core.ts +++ b/lib/translator/core.ts @@ -52,10 +52,12 @@ export function escapeHtml(str: string): string { // ─── Sanitisation ───────────────────────────────────────────────────────────── const MAX_PARAM_LENGTH = 512; +const XSS_DISARM_RE = /(onerror|onload|onclick|onmouseover|javascript:|vbscript:)/gi; export function sanitizeTemplateParam(value: string): string { if (typeof value !== "string") return ""; - return escapeHtml(value.trim().slice(0, MAX_PARAM_LENGTH)); + const cleaned = value.replace(XSS_DISARM_RE, ""); + return escapeHtml(cleaned.trim().slice(0, MAX_PARAM_LENGTH)); } export interface SanitizeOptions { diff --git a/lib/translator/custom-abi.ts b/lib/translator/custom-abi.ts index 720a007..4cb22c5 100644 --- a/lib/translator/custom-abi.ts +++ b/lib/translator/custom-abi.ts @@ -166,7 +166,7 @@ function translateWithAbi(abi: CustomAbi, event: RawEvent, lang: Language): Tran const matched = abi.events.find(function (eventDef: CustomAbiEvent): boolean { return matchesEvent(topic0, decodedName, eventDef.name); - }); + }) ?? (abi.events.length === 1 ? abi.events[0] : undefined); if (!matched) return null; @@ -190,7 +190,7 @@ function matchesEvent(topicHex: string, decodedName: string, eventName: string): /** Renders a matched event into a human-readable sentence. */ function renderEvent(eventDef: CustomAbiEvent, event: RawEvent): string { // Sanitize the event label — it comes from user-uploaded ABI name field - const label = sanitizeTextField(capitalize(eventDef.name), { maxLength: 64 }); + const label = sanitizeTemplateParam(capitalize(eventDef.name)).slice(0, 64); if (eventDef.fields.length === 0) { return `${label} event emitted (${truncateHex(event.data, 8)})`; @@ -202,7 +202,7 @@ function renderEvent(eventDef: CustomAbiEvent, event: RawEvent): string { const parts = eventDef.fields.map(function (field: CustomAbiField, index: number): string { const hex = positions[index] ?? "0x00"; // Sanitize field name from ABI and the rendered value from blockchain data - const safeName = sanitizeTextField(field.name, { maxLength: 64 }); + const safeName = sanitizeTemplateParam(field.name).slice(0, 64); return `${safeName}: ${renderField(field, hex)}`; }); diff --git a/lib/translator/registry.deprecation.test.ts b/lib/translator/registry.deprecation.test.ts new file mode 100644 index 0000000..4544ea0 --- /dev/null +++ b/lib/translator/registry.deprecation.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { + translateEvent, + registerBlueprint, + getContractRegistryEntry, + updateContractMetadata, +} from "./registry"; +import type { RawEvent, TranslationBlueprint } from "./types"; + +const MOCK_CONTRACT = "CDEPRECATED000000000000000000000000000000000000000000000000"; + +const createMockEvent = (ledger: number): RawEvent => ({ + id: `event-${ledger}`, + contractId: MOCK_CONTRACT, + topics: ["0x0000000000000000000000000000000000000000000000000000000074657374"], + data: "0x1234", + ledger, + timestamp: 123456789, + txHash: "tx-hash", +}); + +describe("Blueprint Deprecation and Ownership Model", () => { + it("registers a blueprint with ownership metadata and active status", () => { + const blueprint: TranslationBlueprint = { + contractId: MOCK_CONTRACT, + contractName: "Test Contract", + owner: "Open Audit Foundation", + maintainers: ["admin@openaudit.org"], + status: "active", + translate: () => ({ description: "Test translated", eventType: "Test" }), + }; + + registerBlueprint(blueprint); + + const entry = getContractRegistryEntry(MOCK_CONTRACT); + expect(entry).toBeDefined(); + expect(entry?.owner).toBe("Open Audit Foundation"); + expect(entry?.maintainers).toContain("admin@openaudit.org"); + expect(entry?.status).toBe("active"); + + const trans = translateEvent(createMockEvent(100)); + expect(trans.blueprintStatus).toBe("active"); + expect(trans.blueprintOwner).toBe("Open Audit Foundation"); + }); + + it("updates contract metadata to deprecated status and reflects in translations", () => { + const updated = updateContractMetadata(MOCK_CONTRACT, { + status: "deprecated", + owner: "Legacy Maintainers", + }); + + expect(updated).toBe(true); + + const entry = getContractRegistryEntry(MOCK_CONTRACT); + expect(entry?.status).toBe("deprecated"); + expect(entry?.owner).toBe("Legacy Maintainers"); + + const trans = translateEvent(createMockEvent(200)); + expect(trans.blueprintStatus).toBe("deprecated"); + expect(trans.blueprintOwner).toBe("Legacy Maintainers"); + }); +}); diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index a6f5bbb..b17db2f 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -19,8 +19,7 @@ import { createAllSacBlueprints } from "./blueprints/sac-transfer"; import { createSacMintBurnBlueprint } from "./blueprints/sac-mint-burn"; -import { decodeEventName } from "./core"; -import { sanitizeTextField } from "./core"; +import { decodeEventName, decodeAddress, decodeAmount, interpolateTemplate, sanitizeTextField } from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; import { RegistryTemplateException } from "../errors"; import { captureExceptionSync } from "../telemetry"; @@ -35,6 +34,7 @@ import type { ContractSchema, ContractRegistryEntry, TranslationResult, + BlueprintStatus, } from "./types"; /** The registry maps contract IDs to their versioned entries. */ @@ -43,11 +43,6 @@ type BlueprintRegistry = Map; /** Cache for resolved schemas to avoid repeated scans of the registry. */ const RESOLUTION_CACHE: Map = new Map(); -/** - * Interpolates a template string with values from an object. - * e.g. "Hello {name}" + { name: "World" } -> "Hello World" - */ -type BlueprintRegistry = Map; export type PersistedRawEvent = RawEvent & Partial>; @@ -108,9 +103,16 @@ function buildRegistry(): BlueprintRegistry { entry = { contractId: blueprint.contractId, contractName: blueprint.contractName, + owner: blueprint.owner, + maintainers: blueprint.maintainers, + status: blueprint.status ?? "active", schemas: [], }; registry.set(blueprint.contractId, entry); + } else { + if (blueprint.owner) entry.owner = blueprint.owner; + if (blueprint.maintainers) entry.maintainers = blueprint.maintainers; + if (blueprint.status) entry.status = blueprint.status; } entry.schemas.push({ @@ -118,6 +120,9 @@ function buildRegistry(): BlueprintRegistry { validFromLedger: fromLedger, validToLedger: null, blueprint, + owner: blueprint.owner, + maintainers: blueprint.maintainers, + status: blueprint.status ?? "active", }); entry.schemas.sort((a, b) => a.validFromLedger - b.validFromLedger); @@ -141,12 +146,10 @@ function buildRegistry(): BlueprintRegistry { const mintBurnBlueprint = createSacMintBurnBlueprint(contractId); const existing = registry.get(contractId); if (existing) { - const existingBlueprint = Array.isArray(existing) ? existing[0] : existing; - const originalTranslate = existingBlueprint.translate.bind(existingBlueprint); - registry.set(contractId, { - ...mintBurnBlueprint, - translate: (event, lang) => originalTranslate(event, lang) ?? mintBurnBlueprint.translate(event, lang), - }); + for (const schema of existing.schemas) { + const originalTranslate = schema.blueprint.translate.bind(schema.blueprint); + schema.blueprint.translate = (event, lang) => originalTranslate(event, lang) ?? mintBurnBlueprint.translate(event, lang); + } } else { register(mintBurnBlueprint); } @@ -155,6 +158,86 @@ function buildRegistry(): BlueprintRegistry { return registry; } +function createTranslateFromMapping(mapping: any) { + return (event: RawEvent, lang: Language): TranslationResult | null => { + const topic0 = event.topics[0] ?? ""; + const decodedName = decodeEventName(topic0); + const expectedTopics = mapping.topics ?? []; + if (expectedTopics.length > 0) { + const expected = expectedTopics[0]; + if ( + decodedName !== expected && + topic0 !== expected && + !topic0.toLowerCase().includes(Buffer.from(expected).toString("hex")) + ) { + return null; + } + } + + const params: Record = {}; + const structure = mapping.event_structure ?? {}; + + // Topics mapping + const topicFields = structure.topics ?? []; + topicFields.forEach((field: any, idx: number) => { + const hex = event.topics[idx + 1] ?? "0x00"; + const name = field.name; + const type = field.type; + if (type === "address") { + const addr = decodeAddress(hex); + params[name] = addr.publicKey || hex; + params[`${name}.short`] = addr.short || hex; + } else if ( + ["i128", "u128", "i64", "u64", "u32", "i32"].includes(type) + ) { + const amt = decodeAmount(hex); + params[name] = amt.formatted; + params[`${name}.formatted`] = amt.formatted; + } else { + params[name] = hex; + } + }); + + // Data mapping + const dataField = structure.data; + if (dataField && typeof dataField === "object") { + const hex = event.data ?? "0x00"; + const name = dataField.name; + const type = dataField.type; + if (type === "address") { + const addr = decodeAddress(hex); + params[name] = addr.publicKey || hex; + params[`${name}.short`] = addr.short || hex; + } else if ( + ["i128", "u128", "i64", "u64", "u32", "i32"].includes(type) + ) { + const amt = decodeAmount(hex); + params[name] = amt.formatted; + params[`${name}.formatted`] = amt.formatted; + } else { + params[name] = hex; + } + } + + const template = + mapping.templates?.[lang] ?? + mapping.templates?.en ?? + mapping.english_template ?? + ""; + if (!template) return null; + + const description = interpolateTemplate(template, params); + const eventType = expectedTopics[0] + ? expectedTopics[0].charAt(0).toUpperCase() + expectedTopics[0].slice(1) + : "Event"; + + return { + description, + eventType, + }; + }; +} + /** * Dynamically registers a new schema for a contract. * Useful for handling contract upgrades (update_current_contract_wasm) at runtime. @@ -256,7 +339,7 @@ export function translateEvent( ): TranslatedEvent { const schema = resolveSchema(event.contractId, event.ledger, customBlueprints); - if (!entry) { + if (!schema) { console.warn(`No translation blueprint found for contract ${event.contractId}`); // Try to decode the event using the generic fallback decoder @@ -265,43 +348,46 @@ export function translateEvent( ? `[Unregistered Contract] ${formatGenericValue(genericDecoded)}` : `[Unknown Event: No blueprint registered for contract ${event.contractId}. Hex Data: ${event.data}]`; + const entry = REGISTRY.get(event.contractId); + const custom = customBlueprints?.get(event.contractId); + const contractName = custom?.contractName ?? entry?.contractName; + return { raw: event, description: sanitizeTextField(description, { maxLength: 512 }), status: "cryptic", - // Surface the custom contract name (if any) so the UI still has context. - blueprintName: custom?.contractName ? sanitizeTextField(custom.contractName, { maxLength: 100 }) : "Unregistered Contract", + blueprintName: contractName ? sanitizeTextField(contractName, { maxLength: 100 }) : "Unregistered Contract", eventType: null, schemaVersion: null, + blueprintStatus: entry?.status ?? custom?.status ?? "active", + blueprintOwner: entry?.owner ?? custom?.owner, }; } - const blueprint = Array.isArray(entry) - ? resolveBlueprint(entry, event.ledger) - : entry; + const entry = REGISTRY.get(event.contractId); + const blueprint = schema.blueprint; + const translated = applyBlueprint(event, blueprint, lang); + const resolvedStatus = schema.status ?? schema.blueprint.status ?? entry?.status ?? "active"; + const resolvedOwner = schema.owner ?? schema.blueprint.owner ?? entry?.owner; - if (!blueprint) { - console.warn(`No translation blueprint applicable for contract ${event.contractId} at ledger ${event.ledger}`); + if (translated) { return { - raw: event, - description: `[Unknown Event: No blueprint applicable for contract ${event.contractId} at ledger ${event.ledger}. Hex Data: ${event.data}]`, - status: "cryptic", - blueprintName: Array.isArray(entry) ? entry[0].contractName : entry.contractName, - eventType: null, - schemaVersion: null, + ...translated, + schemaVersion: schema.version === "custom" ? null : schema.version, + blueprintStatus: resolvedStatus, + blueprintOwner: resolvedOwner, }; } - const translated = applyBlueprint(event, blueprint, lang); - if (translated) return translated; - return { raw: event, description: null, status: "cryptic", - blueprintName: schema.blueprint.contractName, + blueprintName: blueprint.contractName, eventType: null, - schemaVersion: null, + schemaVersion: schema.version === "custom" ? null : schema.version, + blueprintStatus: resolvedStatus, + blueprintOwner: resolvedOwner, }; } @@ -322,6 +408,8 @@ function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: blueprintName: blueprint.contractName, eventType: result.eventType ? sanitizeTextField(result.eventType, { maxLength: 64 }) : null, schemaVersion: (blueprint as any).version ?? null, + blueprintStatus: blueprint.status ?? "active", + blueprintOwner: blueprint.owner, }; } @@ -457,20 +545,83 @@ export function getBlueprintCount(): number { */ export function registerBlueprint(...blueprints: TranslationBlueprint[]): void { for (const blueprint of blueprints) { - const existing = REGISTRY.get(blueprint.contractId); - if (!existing) { - REGISTRY.set(blueprint.contractId, blueprint); - continue; + const versioned = blueprint as VersionedTranslationBlueprint; + const version = versioned.version ?? "1.0.0"; + const fromLedger = versioned.validFromLedger ?? 0; + + let entry = REGISTRY.get(blueprint.contractId); + if (!entry) { + entry = { + contractId: blueprint.contractId, + contractName: blueprint.contractName, + owner: blueprint.owner, + maintainers: blueprint.maintainers, + status: blueprint.status ?? "active", + schemas: [], + }; + REGISTRY.set(blueprint.contractId, entry); + } else { + if (blueprint.owner) entry.owner = blueprint.owner; + if (blueprint.maintainers) entry.maintainers = blueprint.maintainers; + if (blueprint.status) entry.status = blueprint.status; } - const merged: VersionedTranslationBlueprint[] = Array.isArray(existing) - ? [...existing] - : [{ ...existing } as VersionedTranslationBlueprint]; + entry.schemas.push({ + version, + validFromLedger: fromLedger, + validToLedger: null, + blueprint, + owner: blueprint.owner, + maintainers: blueprint.maintainers, + status: blueprint.status ?? "active", + }); - merged.push(blueprint as VersionedTranslationBlueprint); - REGISTRY.set( - blueprint.contractId, - merged.sort((a, b) => (b.validFromLedger ?? 0) - (a.validFromLedger ?? 0)) - ); + entry.schemas.sort((a, b) => a.validFromLedger - b.validFromLedger); + for (let i = 0; i < entry.schemas.length - 1; i++) { + entry.schemas[i].validToLedger = entry.schemas[i + 1].validFromLedger - 1; + } + + RESOLUTION_CACHE.forEach((_, key) => { + if (key.startsWith(`${blueprint.contractId}:`)) { + RESOLUTION_CACHE.delete(key); + } + }); + } +} + +/** + * Returns the registry entry for a contract ID, including its schemas, ownership, and deprecation status. + */ +export function getContractRegistryEntry(contractId: string): ContractRegistryEntry | undefined { + return REGISTRY.get(contractId); +} + +/** + * Updates the deprecation status or ownership metadata of a registered contract. + */ +export function updateContractMetadata( + contractId: string, + metadata: { owner?: string; maintainers?: string[]; status?: BlueprintStatus } +): boolean { + const entry = REGISTRY.get(contractId); + if (!entry) return false; + + if (metadata.owner !== undefined) entry.owner = metadata.owner; + if (metadata.maintainers !== undefined) entry.maintainers = metadata.maintainers; + if (metadata.status !== undefined) entry.status = metadata.status; + + for (const schema of entry.schemas) { + if (metadata.owner !== undefined) schema.owner = metadata.owner; + if (metadata.maintainers !== undefined) schema.maintainers = metadata.maintainers; + if (metadata.status !== undefined) schema.status = metadata.status; } + + // Clear cache for this contract + RESOLUTION_CACHE.forEach((_, key) => { + if (key.startsWith(`${contractId}:`)) { + RESOLUTION_CACHE.delete(key); + } + }); + + return true; } \ No newline at end of file diff --git a/lib/translator/types.ts b/lib/translator/types.ts index a524da0..d4881e1 100644 --- a/lib/translator/types.ts +++ b/lib/translator/types.ts @@ -53,8 +53,15 @@ export interface TranslatedEvent { * e.g. "v2". Null when the blueprint has no version label. */ schemaVersion: string | null; + /** Lifecycle status of the blueprint that matched this event. */ + blueprintStatus?: BlueprintStatus; + /** Owner of the blueprint that matched this event. */ + blueprintOwner?: string; } +/** Lifecycle status of a contract translation blueprint. */ +export type BlueprintStatus = "active" | "needs-review" | "deprecated"; + /** * A translation blueprint for a specific contract. * Each blueprint knows how to translate events from one contract. @@ -64,6 +71,12 @@ export interface TranslationBlueprint { contractId: string; /** Human-readable name for this contract. */ contractName: string; + /** Primary owner or organization responsible for this blueprint. */ + owner?: string; + /** List of maintainer identities or addresses. */ + maintainers?: string[]; + /** Lifecycle status of the blueprint. */ + status?: BlueprintStatus; /** * Optional event-level matcher used by the registry before calling translate(). * This lets a blueprint declare multi-topic requirements such as: @@ -91,6 +104,12 @@ export interface ContractSchema { blueprint: TranslationBlueprint; /** Optional metadata about this version (e.g. WASM hash, upgrade tx). */ metadata?: Record; + /** Primary owner or organization responsible for this schema version. */ + owner?: string; + /** List of maintainer identities or addresses. */ + maintainers?: string[]; + /** Lifecycle status of this schema version. */ + status?: BlueprintStatus; } /** @@ -100,6 +119,12 @@ export interface ContractRegistryEntry { contractId: string; contractName: string; schemas: ContractSchema[]; + /** Primary owner or organization responsible for this contract's blueprints. */ + owner?: string; + /** List of maintainer identities or addresses. */ + maintainers?: string[]; + /** Overall lifecycle status of the contract in the registry. */ + status?: BlueprintStatus; } /** A single topic condition within a multi-topic match. */