From 5d39798e219c0e5e5a217a9606cbb4210280297f Mon Sep 17 00:00:00 2001 From: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:33:29 +0200 Subject: [PATCH 1/4] fix(symbolConverter): do not register colliding outcome slugs Templated outcome markets share their question, outcome and side names, so several distinct markets slugify to the same string and the last one loaded silently won the lookup. Collect outcome slugs first and register only the unique ones, so getAssetId returns undefined for an ambiguous slug instead of the wrong market. Closes #196 Signed-off-by: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> --- src/utils/_symbolConverter.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/_symbolConverter.ts b/src/utils/_symbolConverter.ts index 764da539..e403946e 100644 --- a/src/utils/_symbolConverter.ts +++ b/src/utils/_symbolConverter.ts @@ -212,6 +212,12 @@ export class SymbolConverter { // Fallback outcomes are not tradable on their own and have no public slug const fallbackOutcomes = new Set(outcomeMetaData.questions.map((question) => question.fallbackOutcome)); + // Markets deployed from a template share their question, outcome and side names, so several + // tradable sides slugify to the same string. Collect the slugs first and register only the + // unique ones, so a lookup returns `undefined` instead of whichever market was loaded last. + const slugToAssetId = new Map(); + const ambiguousSlugs = new Set(); + outcomeMetaData.outcomes.forEach((outcome) => { if (fallbackOutcomes.has(outcome.outcome)) return; const question = questionByOutcome.get(outcome.outcome); @@ -220,11 +226,21 @@ export class SymbolConverter { const slug = this._outcomeSlug(outcome, sideSpec.name, question); if (!slug) return; - this._nameToAssetId.set(slug, 100000000 + 10 * outcome.outcome + sideIdx); - // Outcome markets are absent from szDecimals metadata; they are all 5 - this._nameToSzDecimals.set(slug, 5); + if (slugToAssetId.has(slug)) { + ambiguousSlugs.add(slug); + return; + } + slugToAssetId.set(slug, 100000000 + 10 * outcome.outcome + sideIdx); }); }); + + slugToAssetId.forEach((assetId, slug) => { + if (ambiguousSlugs.has(slug)) return; + + this._nameToAssetId.set(slug, assetId); + // Outcome markets are absent from szDecimals metadata; they are all 5 + this._nameToSzDecimals.set(slug, 5); + }); } /** From 336f421ee6d79aebb4a4bad1eeaa5ee72f7fdcb2 Mon Sep 17 00:00:00 2001 From: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:34:41 +0200 Subject: [PATCH 2/4] test(symbolConverter): cover templated outcome slug collisions Adds an offline fixture with two templated markets that slugify identically and asserts that the ambiguous slug is not registered while unique ones still are. Closes #196 Signed-off-by: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> --- tests/utils/symbolConverter.test.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/utils/symbolConverter.test.ts b/tests/utils/symbolConverter.test.ts index 46a73d7c..8a9b257e 100644 --- a/tests/utils/symbolConverter.test.ts +++ b/tests/utils/symbolConverter.test.ts @@ -148,6 +148,53 @@ const OUTCOME_META: OutcomeMetaResponse = { ], }; +/** + * An `outcomeMeta` response modeled on templated mainnet markets. Their display names are template + * ids rather than the market's own name, so distinct markets slugify to the same string. + */ +const TEMPLATED_OUTCOME_META: OutcomeMetaResponse = { + outcomes: [ + { + outcome: 300, + name: "template:binaryPrice", + description: "perp:BTC|threshold:61720|time:20260608-0600", + sideSpecs: [{ name: "template:Yes" }, { name: "template:No" }], + quoteToken: "USDC", + }, + { + outcome: 301, + name: "template:binaryPrice", + description: "perp:ETH|threshold:2400|time:20260608-0600", + sideSpecs: [{ name: "template:Yes" }, { name: "template:No" }], + quoteToken: "USDC", + }, + { + outcome: 302, + name: "Uniquely Named Market", + description: "", + sideSpecs: [{ name: "Yes" }, { name: "No" }], + quoteToken: "USDC", + }, + { + outcome: 303, + name: "Fallback", + description: "", + sideSpecs: [{ name: "Yes" }, { name: "No" }], + quoteToken: "USDC", + }, + ], + questions: [ + { + question: 40, + name: "Weekly Price Markets", + description: "", + fallbackOutcome: 303, + namedOutcomes: [302], + settledNamedOutcomes: [], + }, + ], +}; + // ============================================================ // Tests // ============================================================ @@ -297,3 +344,21 @@ Deno.test("SymbolConverter outcome markets", async (t) => { assertEquals(converter.getSzDecimals("2026-world-cup-champion-argentina-yes"), 5); }); }); + +Deno.test("SymbolConverter templated outcome markets", async (t) => { + const converter = await SymbolConverter.create({ transport: createOutcomeTransport(TEMPLATED_OUTCOME_META) }); + + await t.step("ambiguous slugs are not registered", () => { + // Outcomes 300 and 301 are different markets that slugify identically, so neither may win the + // lookup: before this was handled, the id of whichever loaded last was returned for both. + assertEquals(converter.getAssetId("templatebinaryprice-templateyes"), undefined); + assertEquals(converter.getAssetId("templatebinaryprice-templateno"), undefined); + assertEquals(converter.getSzDecimals("templatebinaryprice-templateyes"), undefined); + }); + + await t.step("unique slugs are still registered", () => { + assertEquals(converter.getAssetId("weekly-price-markets-uniquely-named-market-yes"), 100003020); + assertEquals(converter.getAssetId("weekly-price-markets-uniquely-named-market-no"), 100003021); + assertEquals(converter.getSzDecimals("weekly-price-markets-uniquely-named-market-yes"), 5); + }); +}); From 93f025b980ce00b8dce939cafa9754676697f65e Mon Sep 17 00:00:00 2001 From: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:29:05 +0200 Subject: [PATCH 3/4] refactor(symbolConverter): unregister ambiguous outcome slugs after the loop Same behaviour as before: a slug produced by more than one outcome side is left unregistered. Registration now stays as on main and the ambiguous slugs are removed afterwards, so the szDecimals lines touched by #195 are left alone and the two changes merge cleanly in either order. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> --- src/utils/_symbolConverter.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/utils/_symbolConverter.ts b/src/utils/_symbolConverter.ts index e403946e..cc5a88ea 100644 --- a/src/utils/_symbolConverter.ts +++ b/src/utils/_symbolConverter.ts @@ -213,9 +213,9 @@ export class SymbolConverter { const fallbackOutcomes = new Set(outcomeMetaData.questions.map((question) => question.fallbackOutcome)); // Markets deployed from a template share their question, outcome and side names, so several - // tradable sides slugify to the same string. Collect the slugs first and register only the - // unique ones, so a lookup returns `undefined` instead of whichever market was loaded last. - const slugToAssetId = new Map(); + // tradable sides can slugify to the same string. Such a slug is unregistered below, so a lookup + // returns `undefined` instead of whichever market was loaded last. + const seenSlugs = new Set(); const ambiguousSlugs = new Set(); outcomeMetaData.outcomes.forEach((outcome) => { @@ -225,21 +225,18 @@ export class SymbolConverter { outcome.sideSpecs.forEach((sideSpec, sideIdx) => { const slug = this._outcomeSlug(outcome, sideSpec.name, question); if (!slug) return; + if (seenSlugs.has(slug)) ambiguousSlugs.add(slug); + seenSlugs.add(slug); - if (slugToAssetId.has(slug)) { - ambiguousSlugs.add(slug); - return; - } - slugToAssetId.set(slug, 100000000 + 10 * outcome.outcome + sideIdx); + this._nameToAssetId.set(slug, 100000000 + 10 * outcome.outcome + sideIdx); + // Outcome markets are absent from szDecimals metadata; they are all 5 + this._nameToSzDecimals.set(slug, 5); }); }); - slugToAssetId.forEach((assetId, slug) => { - if (ambiguousSlugs.has(slug)) return; - - this._nameToAssetId.set(slug, assetId); - // Outcome markets are absent from szDecimals metadata; they are all 5 - this._nameToSzDecimals.set(slug, 5); + ambiguousSlugs.forEach((slug) => { + this._nameToAssetId.delete(slug); + this._nameToSzDecimals.delete(slug); }); } From 0537cef33ada9d12c8607ece351869db1d5aca84 Mon Sep 17 00:00:00 2001 From: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:29:28 +0200 Subject: [PATCH 4/4] test(symbolConverter): do not pin the outcome szDecimals value The collision test only needs to know that a unique slug is still registered. Checking for a numeric szDecimals instead of 5 keeps it valid if #195 changes that value. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Julien Kervarrec <114134889+JulienKervarrec@users.noreply.github.com> --- tests/utils/symbolConverter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/symbolConverter.test.ts b/tests/utils/symbolConverter.test.ts index 8a9b257e..7cf3e151 100644 --- a/tests/utils/symbolConverter.test.ts +++ b/tests/utils/symbolConverter.test.ts @@ -359,6 +359,6 @@ Deno.test("SymbolConverter templated outcome markets", async (t) => { await t.step("unique slugs are still registered", () => { assertEquals(converter.getAssetId("weekly-price-markets-uniquely-named-market-yes"), 100003020); assertEquals(converter.getAssetId("weekly-price-markets-uniquely-named-market-no"), 100003021); - assertEquals(converter.getSzDecimals("weekly-price-markets-uniquely-named-market-yes"), 5); + assertEquals(typeof converter.getSzDecimals("weekly-price-markets-uniquely-named-market-yes"), "number"); }); });