Skip to content
Open
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
32 changes: 31 additions & 1 deletion lib/translator/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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;
/**
Expand Down Expand Up @@ -172,6 +172,17 @@ function makeDecodedAddress(publicKey: string): DecodedAddress {
* parsed as an address, so callers always receive a usable G-prefixed string.
*/
export function decodeAddress(hex: string): DecodedAddress {

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()!);

// Check memo cache first.
const cached = decodeAddressMemo.get(hex);
if (cached) return cached;
Expand All @@ -183,11 +194,27 @@ export function decodeAddress(hex: string): DecodedAddress {
let publicKey: string;
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 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);

result = makeDecodedAddress(publicKey);
} catch {
// Fallback to a deterministic placeholder when parsing fails.
Expand All @@ -207,6 +234,7 @@ export function decodeAddress(hex: string): DecodedAddress {
decodedAddressPool.push(removed);
}
}

}
return result;
}
Expand Down Expand Up @@ -313,13 +341,15 @@ export function decodeMap(hex: string): DecodedMap {
const value = decodeScValInternal(entry.val());
return { key, value };
});

return {
type: "Map",
entries: decodedEntries,
summary: `Map with ${decodedEntries.length} ${decodedEntries.length === 1 ? "entry" : "entries"}`,
};
} catch {
return { type: "Map", entries: [], summary: "Invalid map data" };

}
}

Expand Down
147 changes: 107 additions & 40 deletions lib/translator/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
import { createAllSacBlueprints } from "./blueprints/sac-transfer";
import { createSacMintBurnBlueprint } from "./blueprints/sac-mint-burn";
import { createAllSdexBlueprints } from "./blueprints/sdex-orderbook";

import {
decodeAddress,
decodeAmount,
decodeEventName,
interpolateTemplate,
sanitizeTextField,

import { createAllSoroswapRouterBlueprints } from "./blueprints/soroswap-router";
import { createAllBlendPoolBlueprints } from "./blueprints/blend-pool";
import {
Expand All @@ -33,6 +41,7 @@ import {
decodeAddress,
decodeAmount,
interpolateTemplate,

} from "./core";
import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder";
import { getTranslation } from "./translations";
Expand All @@ -47,6 +56,8 @@ import type {
ContractSchema,
ContractRegistryEntry,
TranslationResult,
EventMappingDefinition,
EventMappingField,
} from "./types";

/** The registry maps contract IDs to their versioned entries. */
Expand Down Expand Up @@ -241,57 +252,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<string, string> = {};
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<string, string>,
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,
};
}
}

Expand All @@ -303,17 +349,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;
Expand Down Expand Up @@ -468,10 +518,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.
Expand Down Expand Up @@ -609,7 +676,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,
};
Expand Down
24 changes: 18 additions & 6 deletions lib/translator/registry.versioning.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand All @@ -32,7 +32,7 @@ describe("Translation Registry Versioning", () => {
}
];

const v2Mappings = [
const v2Mappings: EventMappingDefinition[] = [
{
topics: ["transfer"],
event_structure: {
Expand All @@ -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);

Expand All @@ -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:");
});
Expand All @@ -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: {
Expand Down
Loading