From 459a953f1fa656b6b662214b419ea1b93103bb06 Mon Sep 17 00:00:00 2001 From: felladaniel36-hash Date: Sun, 19 Jul 2026 19:45:36 +0100 Subject: [PATCH] #341 registerUpgrade() references createTranslateFromMapping which is not defined anywhere in the codebase FIXED --- lib/translator/core.ts | 54 ++++---- lib/translator/registry.ts | 147 +++++++++++++++------ lib/translator/registry.versioning.test.ts | 24 +++- lib/translator/types.ts | 44 ++++++ 4 files changed, 195 insertions(+), 74 deletions(-) diff --git a/lib/translator/core.ts b/lib/translator/core.ts index 14a04c5..4c580e3 100644 --- a/lib/translator/core.ts +++ b/lib/translator/core.ts @@ -90,7 +90,7 @@ export function validateTextField(value: string, maxLength: number = 256): boole // ─── Template interpolation ─────────────────────────────────────────────────── // Pre-compiled once. -const TEMPLATE_TOKEN_RE = /\{(\w+)\}/g; +const TEMPLATE_TOKEN_RE = /\{([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)*)\}/g; // Cap template length to guard against unbounded input. const MAX_TEMPLATE_LENGTH = 2048; @@ -191,16 +191,35 @@ const addressPool: DecodedAddress[] = Array.from( let addressPoolIndex = 0; export function decodeAddress(hex: string): DecodedAddress { - // Check memo cache first - if (decodeAddressMemo.has(hex)) { - return decodeAddressMemo.get(hex)!; + const cached = decodeAddressMemo.get(hex); + if (cached) return cached; + + let publicKey: string; + try { + const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex; + const scAddress = xdr.ScVal.fromXDR(cleanHex, "hex").address(); + if (scAddress.switch() === xdr.ScAddressType.scAddressTypeAccount()) { + publicKey = StrKey.encodeEd25519PublicKey(scAddress.accountId().ed25519()!); + } else if (scAddress.switch() === xdr.ScAddressType.scAddressTypeContract()) { + publicKey = StrKey.encodeContract(scAddress.contractId()); + } else { + throw new Error("Unsupported address type"); + } + } catch { + // Preserve the registry's historical best-effort behaviour for malformed + // or abbreviated values used in previews and tests. + const seed = hex.slice(2, 10).toUpperCase(); + const tail = hex.slice(-4).toUpperCase(); + publicKey = `G${seed}${"A".repeat(Math.max(0, 48 - seed.length))}${tail}`; } - const obj = addressPool[addressPoolIndex]; - obj.publicKey = publicKey; - obj.short = shortenAddress(publicKey); - addressPoolIndex = (addressPoolIndex + 1) % ADDRESS_POOL_SIZE; - return obj; + const result = { publicKey, short: shortenAddress(publicKey) }; + decodeAddressMemo.set(hex, result); + if (decodeAddressMemo.size > MAX_POOL_SIZE) { + const oldest = decodeAddressMemo.keys().next().value; + if (oldest !== undefined) decodeAddressMemo.delete(oldest); + } + return result; } // ─── Amount pool ────────────────────────────────────────────────────────────── @@ -281,23 +300,6 @@ export function decodeMap(hex: string): DecodedMap { key: { type: "String", value: "key1", hex: "0x... " }, value: { type: "String", value: "value1", hex: "0x... " }, }); - - return { - type: "Map", - entries, - summary: `Map with ${entries.length} ${entries.length === 1 ? "entry" : "entries"}`, - }; - - } catch (error) { - // Graceful error handling - never crash - const message = error instanceof Error ? error.message : String(error); - console.error(`Failed to decode map from hex ${truncateHex(hex)}:`, message); - - return { - type: "Map", - entries: [], - summary: `Error parsing map: ${message.slice(0, 50)}`, - }; } return { type: "Map", entries, summary: `Map with ${entries.length} entries` }; } diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index af94c7e..d21bdb1 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -20,8 +20,13 @@ import { createAllSacBlueprints } from "./blueprints/sac-transfer"; import { createSacMintBurnBlueprint } from "./blueprints/sac-mint-burn"; import { createAllSdexBlueprints } from "./blueprints/sdex-orderbook"; -import { decodeEventName } from "./core"; -import { sanitizeTextField } from "./core"; +import { + decodeAddress, + decodeAmount, + decodeEventName, + interpolateTemplate, + sanitizeTextField, +} from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; import { RegistryTemplateException } from "../errors"; import type { @@ -34,6 +39,8 @@ import type { ContractSchema, ContractRegistryEntry, TranslationResult, + EventMappingDefinition, + EventMappingField, } from "./types"; /** The registry maps contract IDs to their versioned entries. */ @@ -218,57 +225,92 @@ function buildRegistry(): BlueprintRegistry { return registry; } -/** - * Builds a `translate` function from a single event-mapping declaration. - * Called by registerUpgrade (eventMappings). Required for the module to load. - */ +/** Function shape required by TranslationBlueprint.translate. */ +type MappingTranslator = ( + event: RawEvent, + lang: Language +) => TranslationResult | null; + +interface DecodedMappingField { + full: string; + short: string; + formatted: string; +} + +/** Builds a blueprint-compatible translator from one typed event mapping. */ function createTranslateFromMapping( - mapping: any -): (event: RawEvent, lang: Language) => TranslationResult | null { - const matchNames: string[] = Array.isArray(mapping.topics) ? mapping.topics : []; - const topicFields: any[] = mapping.event_structure?.topics ?? []; - const dataField: any = mapping.event_structure?.data ?? null; - const template: string = mapping.english_template ?? ""; - - return (event: RawEvent, _lang: Language): TranslationResult | null => { + mapping: EventMappingDefinition +): MappingTranslator { + const eventNameToMatch = mapping.topics[0]; + + return (event: RawEvent, lang: Language): TranslationResult | null => { const eventName = decodeEventName(event.topics[0] ?? ""); - if (matchNames.length > 0 && !matchNames.includes(eventName)) return null; + if (eventNameToMatch && eventName !== eventNameToMatch) return null; const params: Record = {}; - topicFields.forEach((field: any, idx: number) => { - const hex = event.topics[idx + 1] ?? ""; - const decoded = decodeField(hex, field.type); - params[field.name] = decoded.full; - params[`${field.name}.short`] = decoded.short; + mapping.event_structure.topics.forEach((field, index) => { + addFieldParams(params, field, event.topics[index + 1] ?? ""); }); - if (dataField) { - const decoded = decodeField(event.data ?? "", dataField.type); - params[dataField.name] = decoded.full; - params[`${dataField.name}.short`] = decoded.short; - } - const description = interpolateTemplate(template, params); - if (!description) return null; - return { description, eventType: matchNames[0] ?? eventName }; + const dataField = mapping.event_structure.data; + if (dataField) addFieldParams(params, dataField, event.data ?? ""); + + const template = + mapping.templates?.[lang] ?? + mapping.templates?.en ?? + mapping.english_template ?? + ""; + if (!template) return null; + + return { + description: interpolateTemplate(template, params), + eventType: eventNameToMatch || eventName, + }; }; } -function decodeField(hex: string, type: string): { full: string; short: string } { - switch (type) { +function addFieldParams( + params: Record, + field: EventMappingField, + hex: string +): void { + const decoded = decodeMappingField(hex, field.type); + params[field.name] = decoded.full; + params[`${field.name}.short`] = decoded.short; + params[`${field.name}.formatted`] = decoded.formatted; +} + +function decodeMappingField( + hex: string, + type: EventMappingField["type"] +): DecodedMappingField { + switch (type.toLowerCase()) { case "address": { - const addr = decodeAddress(hex); - return { full: addr.publicKey, short: addr.short }; + const address = decodeAddress(hex); + return { + full: address.publicKey, + short: address.short, + formatted: address.publicKey, + }; } case "i128": case "i64": case "u64": case "u128": case "amount": { - const amt = decodeAmount(hex); - return { full: `${amt.formatted} ${amt.symbol}`.trim(), short: amt.formatted }; + const amount = decodeAmount(hex); + return { + full: amount.formatted, + short: amount.formatted, + formatted: amount.formatted, + }; } default: - return { full: hex, short: hex.slice(0, 10) }; + return { + full: hex, + short: hex.length > 10 ? `${hex.slice(0, 10)}…` : hex, + formatted: hex, + }; } } @@ -280,17 +322,21 @@ export function registerUpgrade( contractId: string, version: string, fromLedger: number, - eventMappings: any[] -) { + eventMappings: readonly EventMappingDefinition[] +): void { const entry = REGISTRY.get(contractId); if (!entry) return; - const blueprint: TranslationBlueprint = { + // Compile declarations once at registration rather than once per event. + const translators = eventMappings.map(createTranslateFromMapping); + const blueprint: VersionedTranslationBlueprint = { contractId, contractName: entry.contractName, + version, + validFromLedger: fromLedger, translate: (event, lang) => { - for (const mapping of eventMappings) { - const result = createTranslateFromMapping(mapping)(event, lang); + for (const translate of translators) { + const result = translate(event, lang); if (result) return result; } return null; @@ -438,10 +484,27 @@ function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: status: "translated", blueprintName: blueprint.contractName, eventType: result.eventType ? sanitizeTextField(result.eventType, { maxLength: 64 }) : null, - schemaVersion: (blueprint as any).version ?? null, + schemaVersion: getBlueprintVersion(blueprint), }; } +function getBlueprintVersion(blueprint: TranslationBlueprint): string | null { + if ("version" in blueprint && typeof blueprint.version === "string") { + return blueprint.version; + } + return null; +} + +function getBlueprintStartLedger(blueprint: TranslationBlueprint): number { + if ( + "validFromLedger" in blueprint && + typeof blueprint.validFromLedger === "number" + ) { + return blueprint.validFromLedger; + } + return 1; +} + /** * Returns true when an event satisfies every requested criterion. * Useful for blueprints that must match more than the event signature topic. @@ -576,7 +639,7 @@ export function registerBlueprint(...blueprints: TranslationBlueprint[]): void { for (const blueprint of blueprints) { const schema: ContractSchema = { version: "1.0.0", - validFromLedger: blueprint.validFromLedger ?? 1, + validFromLedger: getBlueprintStartLedger(blueprint), validToLedger: null, blueprint, }; diff --git a/lib/translator/registry.versioning.test.ts b/lib/translator/registry.versioning.test.ts index c388b8f..7073469 100644 --- a/lib/translator/registry.versioning.test.ts +++ b/lib/translator/registry.versioning.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { translateEvent, registerUpgrade } from "./registry"; -import type { RawEvent } from "./types"; +import { translateEvent, registerUpgrade, resolveSchema } from "./registry"; +import type { EventMappingDefinition, RawEvent } from "./types"; const SAC_USDC = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; const TRANSFER_TOPIC = "0x0000000000000000000000000000000000000000000000000000000074726e73"; @@ -21,7 +21,7 @@ describe("Translation Registry Versioning", () => { // Since the registry is a singleton, we should be careful or use a fresh mock if possible. // Here we'll just register two versions and check. - const v1Mappings = [ + const v1Mappings: EventMappingDefinition[] = [ { topics: ["transfer"], event_structure: { @@ -32,7 +32,7 @@ describe("Translation Registry Versioning", () => { } ]; - const v2Mappings = [ + const v2Mappings: EventMappingDefinition[] = [ { topics: ["transfer"], event_structure: { @@ -44,7 +44,7 @@ describe("Translation Registry Versioning", () => { ]; // Register v1 from ledger 100 - registerUpgrade(SAC_USDC, "1.0.0", 100, v1Mappings); + registerUpgrade(SAC_USDC, "1.1.0", 100, v1Mappings); // Register v2 from ledger 500 registerUpgrade(SAC_USDC, "2.0.0", 500, v2Mappings); @@ -60,6 +60,18 @@ describe("Translation Registry Versioning", () => { const transV1 = translateEvent(eventV1); const transV2 = translateEvent(eventV2); + expect(resolveSchema(SAC_USDC, 99)?.version).toBe("1.0.0"); + expect(resolveSchema(SAC_USDC, 100)?.blueprint).toBe( + resolveSchema(SAC_USDC, 499)?.blueprint + ); + expect(resolveSchema(SAC_USDC, 100)?.version).toBe("1.1.0"); + expect(resolveSchema(SAC_USDC, 100)?.validFromLedger).toBe(100); + expect(resolveSchema(SAC_USDC, 499)?.version).toBe("1.1.0"); + expect(resolveSchema(SAC_USDC, 499)?.validToLedger).toBe(499); + expect(resolveSchema(SAC_USDC, 500)?.version).toBe("2.0.0"); + expect(resolveSchema(SAC_USDC, 500)?.validFromLedger).toBe(500); + expect(resolveSchema(SAC_USDC, 10_000)?.version).toBe("2.0.0"); + expect(transOld.status).toBe("translated"); expect(transV1.description).toContain("v1:"); expect(transV2.description).toContain("v2:"); }); @@ -83,7 +95,7 @@ describe("Translation Registry Versioning", () => { expect(trans1.description).toContain("v2:"); // Register v3 from ledger 800 - const v3Mappings = [ + const v3Mappings: EventMappingDefinition[] = [ { topics: ["transfer"], event_structure: { diff --git a/lib/translator/types.ts b/lib/translator/types.ts index a524da0..edb75d0 100644 --- a/lib/translator/types.ts +++ b/lib/translator/types.ts @@ -77,6 +77,50 @@ export interface TranslationBlueprint { translate: (event: RawEvent, lang: Language) => TranslationResult | null; } +/** Soroban value types supported by runtime upgrade mappings. */ +export type EventMappingFieldType = + | "address" + | "i128" + | "u128" + | "i64" + | "u64" + | "u32" + | "amount" + | "symbol" + | "bool" + | "bytes"; + +/** A named field decoded from an upgraded contract event. */ +export interface EventMappingField { + /** Template parameter name, e.g. "from" or "amount". */ + name: string; + /** Soroban value type used by the mapping decoder. */ + type: EventMappingFieldType; +} + +/** Positional layout of an upgraded contract event. */ +export interface EventMappingStructure { + /** Fields mapped to event topics[1..] in order. */ + topics: EventMappingField[]; + /** Optional field mapped to the event data payload. */ + data?: EventMappingField; +} + +/** + * Declarative event mapping accepted by {@link registerUpgrade}. + * + * `topics[0]` is the decoded event name. Remaining topic labels are optional + * documentation; positional decoding is controlled by `event_structure`. + */ +export interface EventMappingDefinition { + topics: string[]; + event_structure: EventMappingStructure; + /** Legacy single-language template retained for API compatibility. */ + english_template?: string; + /** Language-specific templates. English is used as the fallback. */ + templates?: Partial>; +} + /** * A versioned schema for a contract, valid for a specific ledger range. */