Skip to content
Merged
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
9 changes: 6 additions & 3 deletions .github/workflows/validate-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci
- name: Install dependencies
run: npm ci

- run: node scripts/validate-registry.js
- name: Validate registry
run: npx ts-node scripts/validate-registry.ts
33 changes: 27 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,37 @@ The Translation Engine is the heart of Open-Audit. It takes raw XDR events and c
**Architecture:**

#### Translation Registry (`registry.ts`)
The central lookup table that maps contract IDs to their blueprints:
The central lookup table that maps contract IDs to their historical versioned schemas:
```typescript
Map<ContractID, TranslationBlueprint>
Map<ContractID, ContractRegistryEntry>

interface ContractRegistryEntry {
contractId: string;
contractName: string;
schemas: ContractSchema[];
}

interface ContractSchema {
version: string;
validFromLedger: number;
validToLedger: number | null;
blueprint: TranslationBlueprint;
}
```

When an event arrives:
1. Look up contract ID in registry
2. If found, call the blueprint's `translate()` function
3. Return translated event with human-readable description
4. If not found, mark as "cryptic"
1. Look up contract ID in registry to find its `ContractRegistryEntry`.
2. Search the `schemas` array for a version matching the event's `ledger` sequence:
`ledger >= validFromLedger && (validToLedger === null || ledger <= validToLedger)`
3. If a matching schema is found, call its blueprint's `translate()` function.
4. Return translated event with human-readable description.
5. If not found or translation fails, mark as "cryptic".

**Caching Strategy:**
To optimize lookups, a `RESOLUTION_CACHE` maps `contractId:ledger` to the resolved `ContractSchema`, preventing repeated scans of the version list for hot contracts.

**Contract Upgrades:**
The system supports dynamic upgrades via `registerUpgrade()`. When a contract is upgraded (e.g., via `update_current_contract_wasm`), a new schema can be registered with a starting ledger, ensuring that historical events continue to decode with the old schema while new events use the updated format.

#### Translation Blueprints (`blueprints/`)
Each contract gets its own blueprint — a file that knows how to decode that contract's events.
Expand Down
13 changes: 1 addition & 12 deletions lib/translator/registry.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,24 @@
"required": ["contract_id", "topics", "event_structure", "templates"],
"additionalProperties": false,
"properties": {
"contract_id": {
"type": "string",
"description": "The Soroban contract address this entry applies to",
"minLength": 1
},
"topics": {
"type": "array",
"description": "Ordered list of event topic names (the first is the event discriminant)",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
"type": "string"
}
},
"event_structure": {
"type": "object",
"description": "Describes how the raw event's topics and data map to named, typed fields",
"required": ["topics"],
"additionalProperties": false,
"properties": {
"topics": {
"type": "array",
"description": "Fields extracted from event topics[1..] (topic[0] is the event name)",
"items": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
Expand All @@ -58,9 +49,7 @@
},
"data": {
"type": "object",
"description": "The field extracted from the event data payload (if any)",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
Expand Down
169 changes: 124 additions & 45 deletions lib/translator/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,20 @@ import type {
TranslationBlueprint,
VersionedTranslationBlueprint,
Language,
ContractSchema,
ContractRegistryEntry,
TranslationResult,
} from "./types";

/** The registry maps contract IDs to their versioned entries. */
type BlueprintRegistry = Map<string, ContractRegistryEntry>;

/** Cache for resolved schemas to avoid repeated scans of the registry. */
const RESOLUTION_CACHE: Map<string, ContractSchema> = new Map();

/**
* The registry maps contract IDs to an array of versioned blueprints,
* sorted descending by validFromLedger so the newest schema is tried first.
* Interpolates a template string with values from an object.
* e.g. "Hello {name}" + { name: "World" } -> "Hello World"
*/
type BlueprintRegistry = Map<string, TranslationBlueprint | VersionedTranslationBlueprint[]>;

Expand Down Expand Up @@ -92,15 +101,36 @@ export async function translateWithCache(
function buildRegistry(): BlueprintRegistry {
const registry: BlueprintRegistry = new Map();

// Stellar Asset Contract — Transfer events
// Note: These must come AFTER mint/burn to take precedence (Map overwrites)
// Or we need a unified blueprint that handles all SAC event types
/** Helper to add or merge a blueprint into the registry with versioning. */
function register(blueprint: TranslationBlueprint, version = "1.0.0", fromLedger = 0) {
let entry = registry.get(blueprint.contractId);
if (!entry) {
entry = {
contractId: blueprint.contractId,
contractName: blueprint.contractName,
schemas: [],
};
registry.set(blueprint.contractId, entry);
}

entry.schemas.push({
version,
validFromLedger: fromLedger,
validToLedger: null,
blueprint,
});

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;
}
}

// 1. Load Hardcoded Blueprints
for (const blueprint of createAllSacBlueprints()) {
registry.set(blueprint.contractId, blueprint);
register(blueprint);
}

// Stellar Asset Contract — Mint/Burn events
// Register mint/burn handlers - they check event type internally
const mintBurnContracts = [
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
"CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
Expand All @@ -116,66 +146,115 @@ function buildRegistry(): BlueprintRegistry {
registry.set(contractId, {
...mintBurnBlueprint,
translate: (event, lang) => originalTranslate(event, lang) ?? mintBurnBlueprint.translate(event, lang),
});
};
} else {
registry.set(contractId, mintBurnBlueprint);
register(mintBurnBlueprint);
}
}

// TODO: Add Soroswap Router blueprint (see good-first-issues.json GFI-003)
// TODO: Add Blend Protocol blueprint
// TODO: Add Phoenix DEX blueprint

return registry;
}

/**
* Dynamically registers a new schema for a contract.
* Useful for handling contract upgrades (update_current_contract_wasm) at runtime.
*/
export function registerUpgrade(
contractId: string,
version: string,
fromLedger: number,
eventMappings: any[]
) {
const entry = REGISTRY.get(contractId);
if (!entry) return;

const blueprint: TranslationBlueprint = {
contractId,
contractName: entry.contractName,
translate: (event, lang) => {
for (const mapping of eventMappings) {
const result = createTranslateFromMapping(mapping)(event, lang);
if (result) return result;
}
return null;
},
};

entry.schemas.push({
version,
validFromLedger: fromLedger,
validToLedger: null,
blueprint,
});

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;
}

// Clear cache for this contract to force re-resolution
RESOLUTION_CACHE.forEach((_, key) => {
if (key.startsWith(`${contractId}:`)) {
RESOLUTION_CACHE.delete(key);
}
});
}

/** Singleton registry instance. */
const REGISTRY: BlueprintRegistry = buildRegistry();

/**
* Selects the correct versioned blueprint for an event by finding the newest
* schema whose validFromLedger is less than or equal to the event's ledger.
*
* Blueprints are pre-sorted descending by validFromLedger, so the first match
* is always the most recent applicable version.
* Resolves the correct schema version for a given contract and ledger.
*/
function resolveBlueprint(
blueprints: VersionedTranslationBlueprint[],
ledger: number
): VersionedTranslationBlueprint | null {
for (const blueprint of blueprints) {
if ((blueprint.validFromLedger ?? 0) <= ledger) {
return blueprint;
}
function resolveSchema(
contractId: string,
ledger: number,
customBlueprints?: Map<string, TranslationBlueprint>
): ContractSchema | null {
// 1. Check Custom (local) blueprints first.
// Custom blueprints are currently not versioned in this implementation,
// but we treat them as "always valid" for the current session.
const custom = customBlueprints?.get(contractId);
if (custom) {
return {
version: "custom",
validFromLedger: 0,
validToLedger: null,
blueprint: custom,
};
}

// 2. Check cache
const cacheKey = `${contractId}:${ledger}`;
const cached = RESOLUTION_CACHE.get(cacheKey);
if (cached) return cached;

// 3. Look up in global registry
const entry = REGISTRY.get(contractId);
if (!entry) return null;

// 4. Find matching ledger window
const schema = entry.schemas.find(
(s) => ledger >= s.validFromLedger && (s.validToLedger === null || ledger <= s.validToLedger)
);

if (schema) {
RESOLUTION_CACHE.set(cacheKey, schema);
return schema;
}

return null;
}

/**
* Translates a single raw Soroban event into a human-readable TranslatedEvent.
*
* Lookup order:
* 1. The caller-supplied `customBlueprints` map (e.g. user-uploaded ABIs from
* localStorage). These take precedence so developers can translate their
* own contracts before they are merged into the global registry.
* 2. The global REGISTRY of community blueprints.
*
* If neither produces a translation, the event is marked as "cryptic".
*/
export function translateEvent(
event: RawEvent,
customBlueprints?: Map<string, TranslationBlueprint>,
lang: Language = "en"
): TranslatedEvent {
// 1. Custom (local) blueprints win when they can translate the event.
const custom = customBlueprints?.get(event.contractId);
if (custom) {
const translated = applyBlueprint(event, custom, lang);
if (translated) return translated;
}

// 2. Fall back to the global community registry.
const entry = REGISTRY.get(event.contractId);
const schema = resolveSchema(event.contractId, event.ledger, customBlueprints);

if (!entry) {
console.warn(`No translation blueprint found for contract ${event.contractId}`);
Expand Down Expand Up @@ -220,7 +299,7 @@ export function translateEvent(
raw: event,
description: null,
status: "cryptic",
blueprintName: blueprint.contractName ? sanitizeTextField(blueprint.contractName, { maxLength: 100 }) : null,
blueprintName: schema.blueprint.contractName,
eventType: null,
schemaVersion: null,
};
Expand Down
Loading