From e4d5e4c58417f2e99761f6349ff056b947a3a7e8 Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Sat, 18 Jul 2026 04:10:51 +0100 Subject: [PATCH 1/4] feat(translator): add Soroswap Router blueprints --- .../__tests__/soroswap-router.test.ts | 77 ++++++++++++ lib/translator/blueprints/soroswap-router.ts | 114 ++++++++++++++++++ lib/translator/registry.ts | 47 +++++--- lib/translator/translations/en.ts | 25 ++++ lib/translator/translations/es.ts | 25 ++++ lib/translator/translations/fr.ts | 25 ++++ lib/translator/translations/zh.ts | 21 ++++ 7 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 lib/translator/__tests__/soroswap-router.test.ts create mode 100644 lib/translator/blueprints/soroswap-router.ts diff --git a/lib/translator/__tests__/soroswap-router.test.ts b/lib/translator/__tests__/soroswap-router.test.ts new file mode 100644 index 0000000..8e39bb3 --- /dev/null +++ b/lib/translator/__tests__/soroswap-router.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { xdr } from "stellar-sdk"; +import { createSoroswapRouterBlueprint } from "../blueprints/soroswap-router"; +import type { RawEvent } from "../types"; + +const ROUTER = "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD"; + +function contract(byte: string) { + return xdr.ScVal.scvAddress( + xdr.ScAddress.scAddressTypeContract(Buffer.from(byte.repeat(64), "hex")) + ); +} + +function i128(value: number) { + return xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: xdr.Int64.fromString("0"), lo: xdr.Uint64.fromString(String(value)) }) + ); +} + +function fixture(name: string, fields: Array<[string, xdr.ScVal]>): RawEvent { + return { + id: `router-${name}`, + contractId: ROUTER, + // Soroswap emits ("SoroswapRouter", symbol_short!(event)); the event symbol is topic[1]. + topics: [ + `0x${xdr.ScVal.scvSymbol("SoroswapRouter").toXDR("hex")}`, + `0x${xdr.ScVal.scvSymbol(name).toXDR("hex")}`, + ], + data: `0x${xdr.ScVal.scvMap(fields.map(([key, val]) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(key), val }))).toXDR("hex")}`, + ledger: 1, + timestamp: 1, + txHash: "a".repeat(64), + }; +} + +const pairFields: Array<[string, xdr.ScVal]> = [ + ["token_a", contract("1")], + ["token_b", contract("2")], + ["pair", contract("3")], + ["amount_a", i128(25_000_000)], + ["amount_b", i128(40_000_000)], + ["liquidity", i128(10_000_000)], + ["to", contract("4")], +]; + +describe("Soroswap Router blueprint", () => { + const blueprint = createSoroswapRouterBlueprint(ROUTER); + + it("translates a realistic swap XDR fixture", () => { + const event = fixture("swap", [ + ["path", xdr.ScVal.scvVec([contract("1"), contract("2")])], + ["amounts", xdr.ScVal.scvVec([i128(15_000_000), i128(23_500_000)])], + ["to", contract("4")], + ]); + const result = blueprint.translate(event, "en"); + expect(result?.eventType).toBe("Swap"); + expect(result?.description).toContain("1.50"); + expect(result?.description).toContain("2.35"); + }); + + it("translates a realistic add_liquidity XDR fixture", () => { + const result = blueprint.translate(fixture("add", pairFields), "en"); + expect(result?.eventType).toBe("Add Liquidity"); + expect(result?.description).toContain("2.50"); + expect(result?.description).toContain("4.00"); + }); + + it("translates a realistic remove_liquidity XDR fixture", () => { + const result = blueprint.translate(fixture("remove", pairFields), "en"); + expect(result?.eventType).toBe("Remove Liquidity"); + expect(result?.description).toContain("burning 1.00 liquidity tokens"); + }); + + it("returns null for unknown router event types", () => { + expect(blueprint.translate(fixture("init", pairFields), "en")).toBeNull(); + }); +}); diff --git a/lib/translator/blueprints/soroswap-router.ts b/lib/translator/blueprints/soroswap-router.ts new file mode 100644 index 0000000..f1dfa74 --- /dev/null +++ b/lib/translator/blueprints/soroswap-router.ts @@ -0,0 +1,114 @@ +/** Translation blueprint for Soroswap Router AMM events. */ +import { StrKey, xdr } from "stellar-sdk"; +import { shortenAddress } from "../core"; +import { getTranslation } from "../translations"; +import type { Language, RawEvent, TranslationBlueprint, TranslationResult } from "../types"; + +type RouterPayload = Record; + +function eventName(topic: string): string | null { + try { + const value = xdr.ScVal.fromXDR(topic.replace(/^0x/, ""), "hex"); + return value.switch().name === "scvSymbol" ? value.sym().toString() : null; + } catch { + return null; + } +} + +function payload(data: string): RouterPayload | null { + try { + const value = xdr.ScVal.fromXDR(data.replace(/^0x/, ""), "hex"); + if (value.switch().name !== "scvMap") return null; + const entries = value.map(); + if (!entries) return null; + const result: RouterPayload = {}; + for (const entry of entries) { + if (entry.key().switch().name === "scvSymbol") { + result[entry.key().sym().toString()] = entry.val(); + } + } + return result; + } catch { + return null; + } +} + +function address(value: xdr.ScVal | undefined): string | null { + if (!value || value.switch().name !== "scvAddress") return null; + const scAddress = value.address(); + if (scAddress.switch().name === "scAddressTypeContract") { + return shortenAddress(StrKey.encodeContract(scAddress.contractId())); + } + if (scAddress.switch().name === "scAddressTypeAccount") { + return shortenAddress(StrKey.encodeEd25519PublicKey(scAddress.accountId().value())); + } + return null; +} + +function amount(value: xdr.ScVal | undefined): string | null { + if (!value || value.switch().name !== "scvI128") return null; + const parts = value.i128(); + const raw = (BigInt(parts.hi().toString()) << BigInt(64)) + BigInt(parts.lo().toString()); + return (Number(raw) / 10_000_000).toFixed(2); +} + +function addresses(value: xdr.ScVal | undefined): string[] | null { + if (!value || value.switch().name !== "scvVec") return null; + const decoded = (value.vec() ?? []).map(address); + return decoded.every((item): item is string => item !== null) ? decoded : null; +} + +function amounts(value: xdr.ScVal | undefined): string[] | null { + if (!value || value.switch().name !== "scvVec") return null; + const decoded = (value.vec() ?? []).map(amount); + return decoded.every((item): item is string => item !== null) ? decoded : null; +} + +function translateRouterEvent(event: RawEvent, lang: Language): TranslationResult | null { + const name = eventName(event.topics[1] ?? event.topics[0] ?? ""); + const fields = payload(event.data); + if (!name || !fields) return null; + const t = getTranslation(lang).soroswap; + + if (name === "swap") { + const path = addresses(fields.path); + const traded = amounts(fields.amounts); + if (!path || path.length < 2 || !traded || traded.length < 2) return null; + return { + description: t.swap(path[0], traded[0], path[path.length - 1], traded[traded.length - 1]), + eventType: t.eventTypes.Swap, + }; + } + + if ( + name === "add" || + name === "add_liquidity" || + name === "remove" || + name === "remove_liquidity" + ) { + const tokenA = address(fields.token_a); + const tokenB = address(fields.token_b); + const amountA = amount(fields.amount_a); + const amountB = amount(fields.amount_b); + const liquidity = amount(fields.liquidity); + if (!tokenA || !tokenB || !amountA || !amountB || !liquidity) return null; + const adding = name === "add" || name === "add_liquidity"; + return { + description: adding + ? t.addLiquidity(tokenA, amountA, tokenB, amountB, liquidity) + : t.removeLiquidity(tokenA, amountA, tokenB, amountB, liquidity), + eventType: adding ? t.eventTypes.AddLiquidity : t.eventTypes.RemoveLiquidity, + }; + } + + return null; +} + +/** Creates a blueprint for a deployed Soroswap Router contract. */ +export function createSoroswapRouterBlueprint(contractId: string): TranslationBlueprint { + return { + contractId, + contractName: "Soroswap Router", + translate: translateRouterEvent, + }; +} diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index 6c4c721..c79d751 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -19,6 +19,7 @@ import { createAllSacBlueprints } from "./blueprints/sac-transfer"; import { createSacMintBurnBlueprint } from "./blueprints/sac-mint-burn"; +import { createSoroswapRouterBlueprint } from "./blueprints/soroswap-router"; import { decodeEventName } from "./core"; import { sanitizeTextField } from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; @@ -45,7 +46,13 @@ const RESOLUTION_CACHE: Map = new Map(); * Interpolates a template string with values from an object. * e.g. "Hello {name}" + { name: "World" } -> "Hello World" */ -export type PersistedRawEvent = RawEvent & Partial>; +export type PersistedRawEvent = RawEvent & + Partial< + Pick< + TranslatedEvent, + "description" | "status" | "blueprintName" | "eventType" | "schemaVersion" + > + >; function hasPersistedTranslation(event: PersistedRawEvent): boolean { return ( @@ -192,6 +199,14 @@ function buildRegistry(): BlueprintRegistry { } } + // Official Soroswap Router deployments (Testnet and Mainnet). + for (const contractId of [ + "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD", + "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH", + ]) { + register(createSoroswapRouterBlueprint(contractId)); + } + return registry; } @@ -251,8 +266,8 @@ function resolveSchema( ledger: number, customBlueprints?: Map ): ContractSchema | null { - // 1. Check Custom (local) blueprints first. - // Custom blueprints are currently not versioned in this implementation, + // 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) { @@ -298,19 +313,23 @@ export function translateEvent( if (!schema) { console.warn(`No translation blueprint found for contract ${event.contractId}`); - + // Try to decode the event using the generic fallback decoder const genericDecoded = decodeGenericEventPayload(event); const description = genericDecoded ? `[Unregistered Contract] ${formatGenericValue(genericDecoded)}` : `[Unknown Event: No blueprint registered for contract ${event.contractId}. Hex Data: ${event.data}]`; - + return { raw: event, description: sanitizeTextField(description, { maxLength: 512 }), status: "cryptic", // Surface the custom contract name (if any) so the UI still has context. - blueprintName: customBlueprints?.get(event.contractId)?.contractName ? sanitizeTextField(customBlueprints.get(event.contractId)!.contractName, { maxLength: 100 }) : "Unregistered Contract", + blueprintName: customBlueprints?.get(event.contractId)?.contractName + ? sanitizeTextField(customBlueprints.get(event.contractId)!.contractName, { + maxLength: 100, + }) + : "Unregistered Contract", eventType: null, schemaVersion: null, }; @@ -333,7 +352,11 @@ export function translateEvent( * Runs a single blueprint against an event, returning a translated event or * null when the blueprint cannot handle it. */ -function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: Language): TranslatedEvent | null { +function applyBlueprint( + event: RawEvent, + blueprint: TranslationBlueprint, + lang: Language +): TranslatedEvent | null { if (blueprint.matches && !blueprint.matches(event)) return null; const result = blueprint.translate(event, lang); @@ -353,10 +376,7 @@ function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: * Returns true when an event satisfies every requested criterion. * Useful for blueprints that must match more than the event signature topic. */ -export function matchesEventCriteria( - event: RawEvent, - criteria: EventMatchCriteria -): boolean { +export function matchesEventCriteria(event: RawEvent, criteria: EventMatchCriteria): boolean { if (criteria.contractId && event.contractId !== criteria.contractId) { return false; } @@ -376,10 +396,7 @@ export function matchesEventCriteria( return false; } - if ( - topicCriteria.decodedName && - decodeEventName(topic) !== topicCriteria.decodedName - ) { + if (topicCriteria.decodedName && decodeEventName(topic) !== topicCriteria.decodedName) { return false; } } diff --git a/lib/translator/translations/en.ts b/lib/translator/translations/en.ts index d84c760..6ea6a76 100644 --- a/lib/translator/translations/en.ts +++ b/lib/translator/translations/en.ts @@ -12,4 +12,29 @@ export const EN_TRANSLATIONS = { Burn: "Burn", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `Swapped ${amountIn} of [${tokenIn}] for ${amountOut} of [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Added ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] (${liquidity} liquidity tokens)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Removed ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] by burning ${liquidity} liquidity tokens`, + eventTypes: { + Swap: "Swap", + AddLiquidity: "Add Liquidity", + RemoveLiquidity: "Remove Liquidity", + }, + }, }; diff --git a/lib/translator/translations/es.ts b/lib/translator/translations/es.ts index 7d6265f..f2a061c 100644 --- a/lib/translator/translations/es.ts +++ b/lib/translator/translations/es.ts @@ -12,4 +12,29 @@ export const ES_TRANSLATIONS = { Burn: "Quema", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `Intercambió ${amountIn} de [${tokenIn}] por ${amountOut} de [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Añadió ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] (${liquidity} tokens de liquidez)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Retiró ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] al quemar ${liquidity} tokens de liquidez`, + eventTypes: { + Swap: "Intercambio", + AddLiquidity: "Añadir liquidez", + RemoveLiquidity: "Retirar liquidez", + }, + }, }; diff --git a/lib/translator/translations/fr.ts b/lib/translator/translations/fr.ts index c1bcfb5..0cb644e 100644 --- a/lib/translator/translations/fr.ts +++ b/lib/translator/translations/fr.ts @@ -12,4 +12,29 @@ export const FR_TRANSLATIONS = { Burn: "Brûlure", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `A échangé ${amountIn} de [${tokenIn}] contre ${amountOut} de [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `A ajouté ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] (${liquidity} jetons de liquidité)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `A retiré ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] en brûlant ${liquidity} jetons de liquidité`, + eventTypes: { + Swap: "Échange", + AddLiquidity: "Ajouter de la liquidité", + RemoveLiquidity: "Retirer de la liquidité", + }, + }, }; diff --git a/lib/translator/translations/zh.ts b/lib/translator/translations/zh.ts index 2531ba1..9a1152b 100644 --- a/lib/translator/translations/zh.ts +++ b/lib/translator/translations/zh.ts @@ -12,4 +12,25 @@ export const ZH_TRANSLATIONS = { Burn: "销毁", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `将 [${tokenIn}] 的 ${amountIn} 兑换为 [${tokenOut}] 的 ${amountOut}`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `添加了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}(${liquidity} 个流动性代币)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `销毁 ${liquidity} 个流动性代币,移除了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}`, + eventTypes: { Swap: "兑换", AddLiquidity: "添加流动性", RemoveLiquidity: "移除流动性" }, + }, }; From a1768cfe388230914628c8aa537b4a7e0804f3fd Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Sat, 18 Jul 2026 04:14:39 +0100 Subject: [PATCH 2/4] Revert "feat(translator): add Soroswap Router blueprints" This reverts commit e4d5e4c58417f2e99761f6349ff056b947a3a7e8. --- .../__tests__/soroswap-router.test.ts | 77 ------------ lib/translator/blueprints/soroswap-router.ts | 114 ------------------ lib/translator/registry.ts | 47 +++----- lib/translator/translations/en.ts | 25 ---- lib/translator/translations/es.ts | 25 ---- lib/translator/translations/fr.ts | 25 ---- lib/translator/translations/zh.ts | 21 ---- 7 files changed, 15 insertions(+), 319 deletions(-) delete mode 100644 lib/translator/__tests__/soroswap-router.test.ts delete mode 100644 lib/translator/blueprints/soroswap-router.ts diff --git a/lib/translator/__tests__/soroswap-router.test.ts b/lib/translator/__tests__/soroswap-router.test.ts deleted file mode 100644 index 8e39bb3..0000000 --- a/lib/translator/__tests__/soroswap-router.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { xdr } from "stellar-sdk"; -import { createSoroswapRouterBlueprint } from "../blueprints/soroswap-router"; -import type { RawEvent } from "../types"; - -const ROUTER = "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD"; - -function contract(byte: string) { - return xdr.ScVal.scvAddress( - xdr.ScAddress.scAddressTypeContract(Buffer.from(byte.repeat(64), "hex")) - ); -} - -function i128(value: number) { - return xdr.ScVal.scvI128( - new xdr.Int128Parts({ hi: xdr.Int64.fromString("0"), lo: xdr.Uint64.fromString(String(value)) }) - ); -} - -function fixture(name: string, fields: Array<[string, xdr.ScVal]>): RawEvent { - return { - id: `router-${name}`, - contractId: ROUTER, - // Soroswap emits ("SoroswapRouter", symbol_short!(event)); the event symbol is topic[1]. - topics: [ - `0x${xdr.ScVal.scvSymbol("SoroswapRouter").toXDR("hex")}`, - `0x${xdr.ScVal.scvSymbol(name).toXDR("hex")}`, - ], - data: `0x${xdr.ScVal.scvMap(fields.map(([key, val]) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(key), val }))).toXDR("hex")}`, - ledger: 1, - timestamp: 1, - txHash: "a".repeat(64), - }; -} - -const pairFields: Array<[string, xdr.ScVal]> = [ - ["token_a", contract("1")], - ["token_b", contract("2")], - ["pair", contract("3")], - ["amount_a", i128(25_000_000)], - ["amount_b", i128(40_000_000)], - ["liquidity", i128(10_000_000)], - ["to", contract("4")], -]; - -describe("Soroswap Router blueprint", () => { - const blueprint = createSoroswapRouterBlueprint(ROUTER); - - it("translates a realistic swap XDR fixture", () => { - const event = fixture("swap", [ - ["path", xdr.ScVal.scvVec([contract("1"), contract("2")])], - ["amounts", xdr.ScVal.scvVec([i128(15_000_000), i128(23_500_000)])], - ["to", contract("4")], - ]); - const result = blueprint.translate(event, "en"); - expect(result?.eventType).toBe("Swap"); - expect(result?.description).toContain("1.50"); - expect(result?.description).toContain("2.35"); - }); - - it("translates a realistic add_liquidity XDR fixture", () => { - const result = blueprint.translate(fixture("add", pairFields), "en"); - expect(result?.eventType).toBe("Add Liquidity"); - expect(result?.description).toContain("2.50"); - expect(result?.description).toContain("4.00"); - }); - - it("translates a realistic remove_liquidity XDR fixture", () => { - const result = blueprint.translate(fixture("remove", pairFields), "en"); - expect(result?.eventType).toBe("Remove Liquidity"); - expect(result?.description).toContain("burning 1.00 liquidity tokens"); - }); - - it("returns null for unknown router event types", () => { - expect(blueprint.translate(fixture("init", pairFields), "en")).toBeNull(); - }); -}); diff --git a/lib/translator/blueprints/soroswap-router.ts b/lib/translator/blueprints/soroswap-router.ts deleted file mode 100644 index f1dfa74..0000000 --- a/lib/translator/blueprints/soroswap-router.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** Translation blueprint for Soroswap Router AMM events. */ -import { StrKey, xdr } from "stellar-sdk"; -import { shortenAddress } from "../core"; -import { getTranslation } from "../translations"; -import type { Language, RawEvent, TranslationBlueprint, TranslationResult } from "../types"; - -type RouterPayload = Record; - -function eventName(topic: string): string | null { - try { - const value = xdr.ScVal.fromXDR(topic.replace(/^0x/, ""), "hex"); - return value.switch().name === "scvSymbol" ? value.sym().toString() : null; - } catch { - return null; - } -} - -function payload(data: string): RouterPayload | null { - try { - const value = xdr.ScVal.fromXDR(data.replace(/^0x/, ""), "hex"); - if (value.switch().name !== "scvMap") return null; - const entries = value.map(); - if (!entries) return null; - const result: RouterPayload = {}; - for (const entry of entries) { - if (entry.key().switch().name === "scvSymbol") { - result[entry.key().sym().toString()] = entry.val(); - } - } - return result; - } catch { - return null; - } -} - -function address(value: xdr.ScVal | undefined): string | null { - if (!value || value.switch().name !== "scvAddress") return null; - const scAddress = value.address(); - if (scAddress.switch().name === "scAddressTypeContract") { - return shortenAddress(StrKey.encodeContract(scAddress.contractId())); - } - if (scAddress.switch().name === "scAddressTypeAccount") { - return shortenAddress(StrKey.encodeEd25519PublicKey(scAddress.accountId().value())); - } - return null; -} - -function amount(value: xdr.ScVal | undefined): string | null { - if (!value || value.switch().name !== "scvI128") return null; - const parts = value.i128(); - const raw = (BigInt(parts.hi().toString()) << BigInt(64)) + BigInt(parts.lo().toString()); - return (Number(raw) / 10_000_000).toFixed(2); -} - -function addresses(value: xdr.ScVal | undefined): string[] | null { - if (!value || value.switch().name !== "scvVec") return null; - const decoded = (value.vec() ?? []).map(address); - return decoded.every((item): item is string => item !== null) ? decoded : null; -} - -function amounts(value: xdr.ScVal | undefined): string[] | null { - if (!value || value.switch().name !== "scvVec") return null; - const decoded = (value.vec() ?? []).map(amount); - return decoded.every((item): item is string => item !== null) ? decoded : null; -} - -function translateRouterEvent(event: RawEvent, lang: Language): TranslationResult | null { - const name = eventName(event.topics[1] ?? event.topics[0] ?? ""); - const fields = payload(event.data); - if (!name || !fields) return null; - const t = getTranslation(lang).soroswap; - - if (name === "swap") { - const path = addresses(fields.path); - const traded = amounts(fields.amounts); - if (!path || path.length < 2 || !traded || traded.length < 2) return null; - return { - description: t.swap(path[0], traded[0], path[path.length - 1], traded[traded.length - 1]), - eventType: t.eventTypes.Swap, - }; - } - - if ( - name === "add" || - name === "add_liquidity" || - name === "remove" || - name === "remove_liquidity" - ) { - const tokenA = address(fields.token_a); - const tokenB = address(fields.token_b); - const amountA = amount(fields.amount_a); - const amountB = amount(fields.amount_b); - const liquidity = amount(fields.liquidity); - if (!tokenA || !tokenB || !amountA || !amountB || !liquidity) return null; - const adding = name === "add" || name === "add_liquidity"; - return { - description: adding - ? t.addLiquidity(tokenA, amountA, tokenB, amountB, liquidity) - : t.removeLiquidity(tokenA, amountA, tokenB, amountB, liquidity), - eventType: adding ? t.eventTypes.AddLiquidity : t.eventTypes.RemoveLiquidity, - }; - } - - return null; -} - -/** Creates a blueprint for a deployed Soroswap Router contract. */ -export function createSoroswapRouterBlueprint(contractId: string): TranslationBlueprint { - return { - contractId, - contractName: "Soroswap Router", - translate: translateRouterEvent, - }; -} diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index c79d751..6c4c721 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -19,7 +19,6 @@ import { createAllSacBlueprints } from "./blueprints/sac-transfer"; import { createSacMintBurnBlueprint } from "./blueprints/sac-mint-burn"; -import { createSoroswapRouterBlueprint } from "./blueprints/soroswap-router"; import { decodeEventName } from "./core"; import { sanitizeTextField } from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; @@ -46,13 +45,7 @@ const RESOLUTION_CACHE: Map = new Map(); * Interpolates a template string with values from an object. * e.g. "Hello {name}" + { name: "World" } -> "Hello World" */ -export type PersistedRawEvent = RawEvent & - Partial< - Pick< - TranslatedEvent, - "description" | "status" | "blueprintName" | "eventType" | "schemaVersion" - > - >; +export type PersistedRawEvent = RawEvent & Partial>; function hasPersistedTranslation(event: PersistedRawEvent): boolean { return ( @@ -199,14 +192,6 @@ function buildRegistry(): BlueprintRegistry { } } - // Official Soroswap Router deployments (Testnet and Mainnet). - for (const contractId of [ - "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD", - "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH", - ]) { - register(createSoroswapRouterBlueprint(contractId)); - } - return registry; } @@ -266,8 +251,8 @@ function resolveSchema( ledger: number, customBlueprints?: Map ): ContractSchema | null { - // 1. Check Custom (local) blueprints first. - // Custom blueprints are currently not versioned in this implementation, + // 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) { @@ -313,23 +298,19 @@ export function translateEvent( if (!schema) { console.warn(`No translation blueprint found for contract ${event.contractId}`); - + // Try to decode the event using the generic fallback decoder const genericDecoded = decodeGenericEventPayload(event); const description = genericDecoded ? `[Unregistered Contract] ${formatGenericValue(genericDecoded)}` : `[Unknown Event: No blueprint registered for contract ${event.contractId}. Hex Data: ${event.data}]`; - + return { raw: event, description: sanitizeTextField(description, { maxLength: 512 }), status: "cryptic", // Surface the custom contract name (if any) so the UI still has context. - blueprintName: customBlueprints?.get(event.contractId)?.contractName - ? sanitizeTextField(customBlueprints.get(event.contractId)!.contractName, { - maxLength: 100, - }) - : "Unregistered Contract", + blueprintName: customBlueprints?.get(event.contractId)?.contractName ? sanitizeTextField(customBlueprints.get(event.contractId)!.contractName, { maxLength: 100 }) : "Unregistered Contract", eventType: null, schemaVersion: null, }; @@ -352,11 +333,7 @@ export function translateEvent( * Runs a single blueprint against an event, returning a translated event or * null when the blueprint cannot handle it. */ -function applyBlueprint( - event: RawEvent, - blueprint: TranslationBlueprint, - lang: Language -): TranslatedEvent | null { +function applyBlueprint(event: RawEvent, blueprint: TranslationBlueprint, lang: Language): TranslatedEvent | null { if (blueprint.matches && !blueprint.matches(event)) return null; const result = blueprint.translate(event, lang); @@ -376,7 +353,10 @@ function applyBlueprint( * Returns true when an event satisfies every requested criterion. * Useful for blueprints that must match more than the event signature topic. */ -export function matchesEventCriteria(event: RawEvent, criteria: EventMatchCriteria): boolean { +export function matchesEventCriteria( + event: RawEvent, + criteria: EventMatchCriteria +): boolean { if (criteria.contractId && event.contractId !== criteria.contractId) { return false; } @@ -396,7 +376,10 @@ export function matchesEventCriteria(event: RawEvent, criteria: EventMatchCriter return false; } - if (topicCriteria.decodedName && decodeEventName(topic) !== topicCriteria.decodedName) { + if ( + topicCriteria.decodedName && + decodeEventName(topic) !== topicCriteria.decodedName + ) { return false; } } diff --git a/lib/translator/translations/en.ts b/lib/translator/translations/en.ts index 6ea6a76..d84c760 100644 --- a/lib/translator/translations/en.ts +++ b/lib/translator/translations/en.ts @@ -12,29 +12,4 @@ export const EN_TRANSLATIONS = { Burn: "Burn", }, }, - soroswap: { - swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => - `Swapped ${amountIn} of [${tokenIn}] for ${amountOut} of [${tokenOut}]`, - addLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `Added ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] (${liquidity} liquidity tokens)`, - removeLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `Removed ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] by burning ${liquidity} liquidity tokens`, - eventTypes: { - Swap: "Swap", - AddLiquidity: "Add Liquidity", - RemoveLiquidity: "Remove Liquidity", - }, - }, }; diff --git a/lib/translator/translations/es.ts b/lib/translator/translations/es.ts index f2a061c..7d6265f 100644 --- a/lib/translator/translations/es.ts +++ b/lib/translator/translations/es.ts @@ -12,29 +12,4 @@ export const ES_TRANSLATIONS = { Burn: "Quema", }, }, - soroswap: { - swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => - `Intercambió ${amountIn} de [${tokenIn}] por ${amountOut} de [${tokenOut}]`, - addLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `Añadió ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] (${liquidity} tokens de liquidez)`, - removeLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `Retiró ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] al quemar ${liquidity} tokens de liquidez`, - eventTypes: { - Swap: "Intercambio", - AddLiquidity: "Añadir liquidez", - RemoveLiquidity: "Retirar liquidez", - }, - }, }; diff --git a/lib/translator/translations/fr.ts b/lib/translator/translations/fr.ts index 0cb644e..c1bcfb5 100644 --- a/lib/translator/translations/fr.ts +++ b/lib/translator/translations/fr.ts @@ -12,29 +12,4 @@ export const FR_TRANSLATIONS = { Burn: "Brûlure", }, }, - soroswap: { - swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => - `A échangé ${amountIn} de [${tokenIn}] contre ${amountOut} de [${tokenOut}]`, - addLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `A ajouté ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] (${liquidity} jetons de liquidité)`, - removeLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `A retiré ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] en brûlant ${liquidity} jetons de liquidité`, - eventTypes: { - Swap: "Échange", - AddLiquidity: "Ajouter de la liquidité", - RemoveLiquidity: "Retirer de la liquidité", - }, - }, }; diff --git a/lib/translator/translations/zh.ts b/lib/translator/translations/zh.ts index 9a1152b..2531ba1 100644 --- a/lib/translator/translations/zh.ts +++ b/lib/translator/translations/zh.ts @@ -12,25 +12,4 @@ export const ZH_TRANSLATIONS = { Burn: "销毁", }, }, - soroswap: { - swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => - `将 [${tokenIn}] 的 ${amountIn} 兑换为 [${tokenOut}] 的 ${amountOut}`, - addLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `添加了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}(${liquidity} 个流动性代币)`, - removeLiquidity: ( - tokenA: string, - amountA: string, - tokenB: string, - amountB: string, - liquidity: string - ) => - `销毁 ${liquidity} 个流动性代币,移除了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}`, - eventTypes: { Swap: "兑换", AddLiquidity: "添加流动性", RemoveLiquidity: "移除流动性" }, - }, }; From 578a28c01c575dd4a944901486dd4925a9205b36 Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Sat, 18 Jul 2026 11:57:33 +0100 Subject: [PATCH 3/4] feat(translator): register Soroswap Router, add translations, and fix registry versioning --- lib/translator/registry.ts | 92 ++++++++++++++++++++++++++----- lib/translator/translations/en.ts | 25 +++++++++ lib/translator/translations/es.ts | 25 +++++++++ lib/translator/translations/fr.ts | 25 +++++++++ lib/translator/translations/zh.ts | 21 +++++++ 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index 87589c4..91d69a4 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -20,8 +20,8 @@ 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 { createSoroswapRouterBlueprint } from "./blueprints/soroswap-router"; +import { decodeAddress, decodeAmount, decodeEventName, sanitizeTextField } from "./core"; import { decodeGenericEventPayload, formatGenericValue } from "./generic-fallback-decoder"; import { RegistryTemplateException } from "../errors"; import type { @@ -204,9 +204,64 @@ function buildRegistry(): BlueprintRegistry { register(blueprint); } + // Official Soroswap Router deployments (Testnet and Mainnet). + for (const contractId of [ + "CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD", + "CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH", + ]) { + register(createSoroswapRouterBlueprint(contractId)); + } + return registry; } +function interpolate(template: string, values: Record): string { + return template.replace(/\{([^}]+)\}/g, (match, key) => { + const [path, format] = key.split("."); + const val = values[path]; + if (val && typeof val === "object" && format) { + return val[format] ?? match; + } + return val ?? match; + }); +} + +function createTranslateFromMapping(mapping: any) { + return (event: RawEvent, lang: Language): TranslationResult | null => { + // 1. Match topics + for (let i = 0; i < mapping.topics.length; i++) { + if (i === 0) { + if (decodeEventName(event.topics[0]) !== mapping.topics[0]) return null; + } + // Future: support matching other topics too + } + + const fields: Record = {}; + + // 2. Extract topics[1..] + mapping.event_structure.topics.forEach((t: any, i: number) => { + const hex = event.topics[i + 1]; + if (!hex) return; + if (t.type === "address") fields[t.name] = decodeAddress(hex); + else if (t.type === "i128") fields[t.name] = decodeAmount(hex); + else fields[t.name] = hex; + }); + + // 3. Extract data + if (mapping.event_structure.data) { + const d = mapping.event_structure.data; + if (d.type === "i128") fields[d.name] = decodeAmount(event.data); + else if (d.type === "address") fields[d.name] = decodeAddress(event.data); + else fields[d.name] = event.data; + } + + return { + description: interpolate(mapping.english_template, fields), + eventType: mapping.topics[0], + }; + }; +} + /** * Dynamically registers a new schema for a contract. * Useful for handling contract upgrades (update_current_contract_wasm) at runtime. @@ -495,20 +550,29 @@ 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; + let entry = REGISTRY.get(blueprint.contractId); + if (!entry) { + entry = { + contractId: blueprint.contractId, + contractName: blueprint.contractName, + schemas: [], + }; + REGISTRY.set(blueprint.contractId, entry); } - const merged: VersionedTranslationBlueprint[] = Array.isArray(existing) - ? [...existing] - : [{ ...existing } as VersionedTranslationBlueprint]; + const fromLedger = (blueprint as VersionedTranslationBlueprint).validFromLedger ?? 0; + const version = (blueprint as VersionedTranslationBlueprint).version ?? "1.0.0"; - merged.push(blueprint as VersionedTranslationBlueprint); - REGISTRY.set( - blueprint.contractId, - merged.sort((a, b) => (b.validFromLedger ?? 0) - (a.validFromLedger ?? 0)) - ); + 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; + } } } diff --git a/lib/translator/translations/en.ts b/lib/translator/translations/en.ts index def5f2e..012c669 100644 --- a/lib/translator/translations/en.ts +++ b/lib/translator/translations/en.ts @@ -25,4 +25,29 @@ export const EN_TRANSLATIONS = { OfferFilled: "Offer Filled", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `Swapped ${amountIn} of [${tokenIn}] for ${amountOut} of [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Added ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] (${liquidity} liquidity tokens)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Removed ${amountA} of [${tokenA}] and ${amountB} of [${tokenB}] by burning ${liquidity} liquidity tokens`, + eventTypes: { + Swap: "Swap", + AddLiquidity: "Add Liquidity", + RemoveLiquidity: "Remove Liquidity", + }, + }, }; diff --git a/lib/translator/translations/es.ts b/lib/translator/translations/es.ts index b2be41d..6789624 100644 --- a/lib/translator/translations/es.ts +++ b/lib/translator/translations/es.ts @@ -25,4 +25,29 @@ export const ES_TRANSLATIONS = { OfferFilled: "Oferta Completada", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `Intercambió ${amountIn} de [${tokenIn}] por ${amountOut} de [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Añadió ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] (${liquidity} tokens de liquidez)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `Retiró ${amountA} de [${tokenA}] y ${amountB} de [${tokenB}] al quemar ${liquidity} tokens de liquidez`, + eventTypes: { + Swap: "Intercambio", + AddLiquidity: "Añadir liquidez", + RemoveLiquidity: "Retirar liquidez", + }, + }, }; diff --git a/lib/translator/translations/fr.ts b/lib/translator/translations/fr.ts index 065a8c9..585b7fa 100644 --- a/lib/translator/translations/fr.ts +++ b/lib/translator/translations/fr.ts @@ -25,4 +25,29 @@ export const FR_TRANSLATIONS = { OfferFilled: "Offre Exécutée", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `A échangé ${amountIn} de [${tokenIn}] contre ${amountOut} de [${tokenOut}]`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `A ajouté ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] (${liquidity} jetons de liquidité)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `A retiré ${amountA} de [${tokenA}] et ${amountB} de [${tokenB}] en brûlant ${liquidity} jetons de liquidité`, + eventTypes: { + Swap: "Échange", + AddLiquidity: "Ajouter de la liquidité", + RemoveLiquidity: "Retirer de la liquidité", + }, + }, }; diff --git a/lib/translator/translations/zh.ts b/lib/translator/translations/zh.ts index 57452aa..94ff330 100644 --- a/lib/translator/translations/zh.ts +++ b/lib/translator/translations/zh.ts @@ -25,4 +25,25 @@ export const ZH_TRANSLATIONS = { OfferFilled: "报价成交", }, }, + soroswap: { + swap: (tokenIn: string, amountIn: string, tokenOut: string, amountOut: string) => + `将 [${tokenIn}] 的 ${amountIn} 兑换为 [${tokenOut}] 的 ${amountOut}`, + addLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `添加了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}(${liquidity} 个流动性代币)`, + removeLiquidity: ( + tokenA: string, + amountA: string, + tokenB: string, + amountB: string, + liquidity: string + ) => + `销毁 ${liquidity} 个流动性代币,移除了 [${tokenA}] 的 ${amountA} 和 [${tokenB}] 的 ${amountB}`, + eventTypes: { Swap: "兑换", AddLiquidity: "添加流动性", RemoveLiquidity: "移除流动性" }, + }, }; From 62b5ae564d34f6dd77dd50d62cf49c4253da762d Mon Sep 17 00:00:00 2001 From: egwujiohaifesinachiperpetual-max Date: Sat, 18 Jul 2026 12:15:55 +0100 Subject: [PATCH 4/4] docs: add local development setup guide to CONTRIBUTING.md --- CONTRIBUTING.md | 72 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ddf49a..2e1aba3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,28 +55,84 @@ Violations can be reported by opening a private GitHub issue or contacting a mai - Node.js >= 18 (we recommend [nvm](https://github.com/nvm-sh/nvm)) - npm >= 9 +- Git (for cloning the repository) -### Install Dependencies +### 1. Clone the Repository + +If you haven't already, fork the repository on GitHub, then clone your fork locally: ```bash -npm install +git clone https://github.com/YOUR_USERNAME/open-audit.git +cd open-audit ``` -### Environment Variables +### 2. Install Dependencies + +Install the required Node.js dependencies: ```bash -cp .env.example .env.local +npm install ``` -The defaults point to Stellar **testnet**, which is safe for development. No changes are required for running tests. +### 3. Environment Configuration -### Start the Dev Server +Copy the example environment configuration file to `.env`: ```bash -npm run dev +cp .env.example .env ``` -The app will be available at [http://localhost:3000](http://localhost:3000). +#### Minimum Required Variables + +For basic local development, the default values in `.env` are pre-configured to connect to the Stellar **testnet**. The minimum required variables are: + +- `NEXT_PUBLIC_HORIZON_URL`: The Horizon REST API endpoint (defaults to `https://horizon-testnet.stellar.org`). +- `NEXT_PUBLIC_SOROBAN_RPC_URL`: The Soroban RPC endpoint (defaults to `https://soroban-testnet.stellar.org`). +- `NEXT_PUBLIC_NETWORK_PASSPHRASE`: The passphrase matching the target network (defaults to `"Test SDF Network ; September 2015"`). +- `NEXT_PUBLIC_NETWORK`: The network identifier (`testnet`, `mainnet`, or `futurenet`, defaults to `testnet`). + +#### Optional Services: PostgreSQL & Redis + +For basic development with the in-memory mock data path, **PostgreSQL and Redis are optional**. +- If `DATABASE_URL` is not configured, the app automatically falls back to the in-memory mock data path. +- If `REDIS_URL` is not configured, the app falls back to an in-process memory cache. + +#### Setting Up PostgreSQL (Optional) + +If you need to test database persistence or work on features requiring the database: +1. Ensure PostgreSQL is running and create a local database: + ```bash + createdb open_audit + ``` +2. Configure the `DATABASE_URL` variable in your `.env` file, for example: + ```env + DATABASE_URL="postgresql://user:password@localhost:5432/open_audit" + ``` +3. Run the database migrations: + ```bash + npm run db:migrate + ``` +4. Seed the database with test data: + ```bash + npm run db:seed + ``` + +### 4. Start the Development Server + +You can run the application in two modes depending on your needs: + +- **Basic Dashboard**: To run the Next.js development server for the frontend dashboard: + ```bash + npm run dev + ``` + The app will be available at [http://localhost:3000](http://localhost:3000). + +- **Full WebSocket Server**: To run the monolithic server which includes both the frontend and WebSocket event streaming capabilities: + ```bash + npm run dev:ws + ``` + The app will be available at [http://localhost:3000](http://localhost:3000). + ---