diff --git a/apps/indexer/fixtures/contract-event-vectors.json b/apps/indexer/fixtures/contract-event-vectors.json index 57f9f44..c3fa765 100644 --- a/apps/indexer/fixtures/contract-event-vectors.json +++ b/apps/indexer/fixtures/contract-event-vectors.json @@ -99,10 +99,11 @@ "discriminator": { "topicIndex": 0, "scvType": "ScvSymbol", - "value": "market_resolved", - "xdr": "AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA" + "value": "market_resolved_event", + "xdr": "AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA=", + "note": "Soroban's #[contractevent] macro snake_cases the struct name including its 'Event' suffix — see contracts/market/src/events.rs." }, - "payloadShape": "ScvVec (3-tuple) [canonical on-chain] | ScvMap [legacy]", + "payloadShape": "topic[1]=ScvU32 market_id, value=ScvMap{outcome, resolved_at} [real on-chain] | ScvVec 3-tuple [legacy] | ScvMap [legacy]", "fields": { "tuple[0] market_id": "ScvU32 → number → string", "tuple[1] outcome": "ScvBool → true='YES' / false='NO'", @@ -113,9 +114,9 @@ }, "vectors": [ { - "label": "tuple YES — market 42", - "topicsXdr": ["AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA"], - "decodedNative": [42, true, "1700000000"], + "label": "real on-chain shape YES — market 42", + "topicsXdr": ["AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA=", "AAAAAwAAACo="], + "valueXdr": "AAAAEQAAAAEAAAACAAAADwAAAAdvdXRjb21lAAAAAAAAAAABAAAADwAAAAtyZXNvbHZlZF9hdAAAAAAFAAAAAGVT8QA=", "expectedNormalized": { "marketId": "42", "outcome": "YES", @@ -123,8 +124,9 @@ } }, { - "label": "tuple NO — market 7", - "decodedNative": [7, false, "99"], + "label": "real on-chain shape NO — market 7", + "topicsXdr": ["AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA=", "AAAAAwAAAAc="], + "valueXdr": "AAAAEQAAAAEAAAACAAAADwAAAAdvdXRjb21lAAAAAAAAAAAAAAAADwAAAAtyZXNvbHZlZF9hdAAAAAAFAAAAAGS7WoA=", "expectedNormalized": { "marketId": "7", "outcome": "NO", @@ -132,21 +134,22 @@ } }, { - "label": "ScvMap YES — legacy shape with oracle", - "topicsXdr": ["AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA"], - "valueXdr": "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAA1lFUwAAAAAPAAAABm9yYWNsZQAAAAAADwAAAApHT1JBQ0xFMTIzAAA=", + "label": "legacy tuple YES — market 42", + "topicsXdr": ["AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA="], + "decodedNative": [42, true, "1700000000"], "expectedNormalized": { - "marketId": "market-xyz", + "marketId": "42", "outcome": "YES", - "oracleAddress": "GORACLE123" + "oracleAddress": "" } }, { - "label": "ScvMap NO — legacy shape with oracle", - "valueXdr": "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAAk5PAAAAAAAPAAAABm9yYWNsZQAAAAAADwAAAApHT1JBQ0xFMTIzAAA=", + "label": "legacy ScvMap YES — shape with oracle", + "topicsXdr": ["AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA="], + "valueXdr": "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAA1lFUwAAAAAPAAAABm9yYWNsZQAAAAAADwAAAApHT1JBQ0xFMTIzAAA=", "expectedNormalized": { "marketId": "market-xyz", - "outcome": "NO", + "outcome": "YES", "oracleAddress": "GORACLE123" } } diff --git a/apps/indexer/src/ingestion.test.ts b/apps/indexer/src/ingestion.test.ts index 7567eb0..359be0c 100644 --- a/apps/indexer/src/ingestion.test.ts +++ b/apps/indexer/src/ingestion.test.ts @@ -9,7 +9,7 @@ import type { RawChainEvent } from "./types.js"; import { nativeToScVal } from "@stellar/stellar-sdk"; const TRADE_TOPIC = "AAAADwAAAA50cmFkZV9leGVjdXRlZAAA"; -const RESOLUTION_TOPIC = "AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA"; +const RESOLUTION_TOPIC = "AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA="; function makeTradeEvent(id: string): RawChainEvent { const valueXdr = nativeToScVal({ diff --git a/apps/indexer/src/resolutionParser.test.ts b/apps/indexer/src/resolutionParser.test.ts index fef4375..7fe1698 100644 --- a/apps/indexer/src/resolutionParser.test.ts +++ b/apps/indexer/src/resolutionParser.test.ts @@ -8,23 +8,39 @@ import { ResolutionParseError } from "./types.js"; import type { RawChainEvent } from "./types.js"; // ─── Real XDR fixtures ─────────────────────────────────────────────────────── +// +// Soroban's #[contractevent] macro snake-cases the event struct name +// including its "Event" suffix, so MarketResolvedEvent publishes under +// "market_resolved_event" (contracts/market/src/events.rs), with +// topics: [market_resolved_event, market_id: u32] and +// value (ScvMap): { outcome: bool, resolved_at: u64 }. const XDR = { topic: { - marketResolved: "AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA", + marketResolvedEvent: "AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA=", tradeExecuted: "AAAADwAAAA50cmFkZV9leGVjdXRlZAAA", }, + marketId: { + 42: "AAAAAwAAACo=", + 7: "AAAAAwAAAAc=", + }, value: { - // market_id=market-xyz, outcome=YES, oracle=GORACLE123 + // outcome=true, resolved_at=1700000000 (real on-chain shape) + realYes: + "AAAAEQAAAAEAAAACAAAADwAAAAdvdXRjb21lAAAAAAAAAAABAAAADwAAAAtyZXNvbHZlZF9hdAAAAAAFAAAAAGVT8QA=", + // outcome=false, resolved_at=1690000000 + realNo: + "AAAAEQAAAAEAAAACAAAADwAAAAdvdXRjb21lAAAAAAAAAAAAAAAADwAAAAtyZXNvbHZlZF9hdAAAAAAFAAAAAGS7WoA=", + // legacy ScvMap: market_id=market-xyz, outcome=YES, oracle=GORACLE123 resolvedYes: "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAA1lFUwAAAAAPAAAABm9yYWNsZQAAAAAADwAAAApHT1JBQ0xFMTIzAAA=", - // outcome=NO + // legacy ScvMap: outcome=NO resolvedNo: "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAAk5PAAAAAAAPAAAABm9yYWNsZQAAAAAADwAAAApHT1JBQ0xFMTIzAAA=", - // outcome=MAYBE (unknown) + // legacy ScvMap: outcome=MAYBE (unknown) unknownOutcome: "AAAAEQAAAAEAAAADAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAABU1BWUJFAAAAAAAADwAAAAZvcmFjbGUAAAAAAA8AAAAKR09SQUNMRTEyMwAA", - // oracle field omitted + // legacy ScvMap: oracle field omitted missingOracle: "AAAAEQAAAAEAAAACAAAADwAAAAltYXJrZXRfaWQAAAAAAAAPAAAACm1hcmtldC14eXoAAAAAAA8AAAAHb3V0Y29tZQAAAAAPAAAAA1lFUwA=", }, @@ -38,8 +54,8 @@ function makeEvent(overrides: Partial = {}): RawChainEvent { contractId: "CRESOLUTION", type: "contract", pagingToken: "token-res-1", - valueXdr: XDR.value.resolvedYes, - topicsXdr: [XDR.topic.marketResolved], + valueXdr: XDR.value.realYes, + topicsXdr: [XDR.topic.marketResolvedEvent, XDR.marketId[42]], ...overrides, }; } @@ -47,10 +63,41 @@ function makeEvent(overrides: Partial = {}): RawChainEvent { // ─── parseResolutionEvent ──────────────────────────────────────────────────── describe("parseResolutionEvent", () => { - it("parses on-chain tuple payload (market_id, outcome, resolved_at)", () => { + it("parses the real on-chain shape: market_id from topic, outcome+resolved_at from value", () => { + const r = parseResolutionEvent(makeEvent()); + + expect(r.marketId).toBe("42"); + expect(r.outcome).toBe("YES"); + expect(r.oracleAddress).toBe(""); + }); + + it("parses the real on-chain shape with NO outcome and a different market_id", () => { + const r = parseResolutionEvent( + makeEvent({ + topicsXdr: [XDR.topic.marketResolvedEvent, XDR.marketId[7]], + valueXdr: XDR.value.realNo, + }) + ); + expect(r.marketId).toBe("7"); + expect(r.outcome).toBe("NO"); + }); + + it("throws ResolutionParseError when the market_id topic is missing", () => { + expect(() => + parseResolutionEvent( + makeEvent({ topicsXdr: [XDR.topic.marketResolvedEvent] }) + ) + ).toThrow(ResolutionParseError); + }); + + it("parses legacy on-chain tuple payload (market_id, outcome, resolved_at)", () => { const tupleXdr = nativeToScVal([42, true, 1_700_000_000n]).toXDR("base64"); const r = parseResolutionEvent( - makeEvent({ valueXdr: tupleXdr, id: "evt-tuple" }) + makeEvent({ + valueXdr: tupleXdr, + topicsXdr: [XDR.topic.marketResolvedEvent], + id: "evt-tuple", + }) ); expect(r.marketId).toBe("42"); @@ -58,14 +105,24 @@ describe("parseResolutionEvent", () => { expect(r.oracleAddress).toBe(""); }); - it("parses tuple NO outcome as boolean false", () => { + it("parses legacy tuple NO outcome as boolean false", () => { const tupleXdr = nativeToScVal([7, false, 99n]).toXDR("base64"); - const r = parseResolutionEvent(makeEvent({ valueXdr: tupleXdr })); + const r = parseResolutionEvent( + makeEvent({ + valueXdr: tupleXdr, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) + ); expect(r.outcome).toBe("NO"); }); - it("parses a YES resolution correctly", () => { - const r = parseResolutionEvent(makeEvent()); + it("parses a legacy ScvMap YES resolution correctly", () => { + const r = parseResolutionEvent( + makeEvent({ + valueXdr: XDR.value.resolvedYes, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) + ); expect(r.eventId).toBe("evt-res-1"); expect(r.marketId).toBe("market-xyz"); @@ -73,9 +130,12 @@ describe("parseResolutionEvent", () => { expect(r.oracleAddress).toBe("GORACLE123"); }); - it("parses a NO resolution correctly", () => { + it("parses a legacy ScvMap NO resolution correctly", () => { const r = parseResolutionEvent( - makeEvent({ valueXdr: XDR.value.resolvedNo }) + makeEvent({ + valueXdr: XDR.value.resolvedNo, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) ); expect(r.outcome).toBe("NO"); expect(r.marketId).toBe("market-xyz"); @@ -94,20 +154,25 @@ describe("parseResolutionEvent", () => { expect(r.contractId).toBe("CXYZ"); }); - it("links the record to the correct marketId", () => { - const r = parseResolutionEvent(makeEvent()); - expect(r.marketId).toBe("market-xyz"); - }); - - it("throws ResolutionParseError for unknown outcome value", () => { + it("throws ResolutionParseError for unknown outcome value (legacy ScvMap)", () => { expect(() => - parseResolutionEvent(makeEvent({ valueXdr: XDR.value.unknownOutcome })) + parseResolutionEvent( + makeEvent({ + valueXdr: XDR.value.unknownOutcome, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) + ) ).toThrow(ResolutionParseError); }); it("unknown outcome error message names the bad value", () => { try { - parseResolutionEvent(makeEvent({ valueXdr: XDR.value.unknownOutcome })); + parseResolutionEvent( + makeEvent({ + valueXdr: XDR.value.unknownOutcome, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) + ); expect.fail("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ResolutionParseError); @@ -115,7 +180,7 @@ describe("parseResolutionEvent", () => { } }); - it("throws ResolutionParseError when topic is not market_resolved", () => { + it("throws ResolutionParseError when topic is not market_resolved_event", () => { expect(() => parseResolutionEvent(makeEvent({ topicsXdr: [XDR.topic.tradeExecuted] })) ).toThrow(ResolutionParseError); @@ -133,9 +198,14 @@ describe("parseResolutionEvent", () => { ).toThrow(ResolutionParseError); }); - it("throws ResolutionParseError when oracle field is missing", () => { + it("throws ResolutionParseError when oracle field is missing (legacy ScvMap)", () => { expect(() => - parseResolutionEvent(makeEvent({ valueXdr: XDR.value.missingOracle })) + parseResolutionEvent( + makeEvent({ + valueXdr: XDR.value.missingOracle, + topicsXdr: [XDR.topic.marketResolvedEvent], + }) + ) ).toThrow(ResolutionParseError); }); @@ -154,8 +224,12 @@ describe("parseResolutionEvent", () => { describe("parseResolutionEvents", () => { it("parses multiple valid resolution events", () => { const events = [ - makeEvent({ id: "e1", valueXdr: XDR.value.resolvedYes }), - makeEvent({ id: "e2", valueXdr: XDR.value.resolvedNo }), + makeEvent({ id: "e1", valueXdr: XDR.value.realYes }), + makeEvent({ + id: "e2", + topicsXdr: [XDR.topic.marketResolvedEvent, XDR.marketId[7]], + valueXdr: XDR.value.realNo, + }), ]; const { resolutions, errors } = parseResolutionEvents(events); expect(resolutions).toHaveLength(2); @@ -176,9 +250,13 @@ describe("parseResolutionEvents", () => { it("collects errors without dropping other resolutions", () => { const events = [ - makeEvent({ id: "e1", valueXdr: XDR.value.resolvedYes }), - makeEvent({ id: "e2", valueXdr: XDR.value.unknownOutcome }), - makeEvent({ id: "e3", valueXdr: XDR.value.resolvedNo }), + makeEvent({ id: "e1", valueXdr: XDR.value.realYes }), + makeEvent({ id: "e2", topicsXdr: [XDR.topic.marketResolvedEvent] }), + makeEvent({ + id: "e3", + topicsXdr: [XDR.topic.marketResolvedEvent, XDR.marketId[7]], + valueXdr: XDR.value.realNo, + }), ]; const { resolutions, errors } = parseResolutionEvents(events); expect(resolutions).toHaveLength(2); diff --git a/apps/indexer/src/resolutionParser.ts b/apps/indexer/src/resolutionParser.ts index 32300fe..768eea3 100644 --- a/apps/indexer/src/resolutionParser.ts +++ b/apps/indexer/src/resolutionParser.ts @@ -6,7 +6,13 @@ import type { } from "./types.js"; import { ResolutionParseError } from "./types.js"; -const RESOLUTION_EVENT_TOPIC = "market_resolved"; +/** + * Soroban's #[contractevent] macro derives the topic symbol by snake_casing + * the event struct name, including its literal "Event" suffix — so + * MarketResolvedEvent (contracts/market/src/events.rs) publishes under + * "market_resolved_event", not "market_resolved". + */ +const RESOLUTION_EVENT_TOPIC = "market_resolved_event"; function decodeScVal(xdrBase64: string): unknown { const val = xdr.ScVal.fromXDR(xdrBase64, "base64"); @@ -52,12 +58,30 @@ interface ResolutionPayload { oracleAddress: string; } +function marketIdFromTopic(topicsXdr: string[], eventId: string): string { + if (topicsXdr.length < 2) { + throw new ResolutionParseError("Missing market_id topic", eventId); + } + try { + return String(decodeScVal(topicsXdr[1])); + } catch (err) { + throw new ResolutionParseError( + "Failed to decode market_id topic XDR", + eventId, + err + ); + } +} + /** - * Supports both legacy ScvMap payloads ({ market_id, outcome, oracle }) - * and the on-chain tuple (market_id: u32, outcome: bool, resolved_at: u64). + * Supports three payload shapes: + * - Real on-chain (topics[1]=market_id: u32, value=ScvMap{outcome, resolved_at}) + * - Legacy ScvVec tuple (value=[market_id, outcome, resolved_at]) + * - Legacy ScvMap (value={ market_id, outcome, oracle }) */ function parseResolutionPayload( decoded: unknown, + topicsXdr: string[], eventId: string ): ResolutionPayload { if (Array.isArray(decoded)) { @@ -75,11 +99,7 @@ function parseResolutionPayload( }; } - if ( - typeof decoded !== "object" || - decoded === null || - Array.isArray(decoded) - ) { + if (typeof decoded !== "object" || decoded === null) { throw new ResolutionParseError( "Event value is not an ScvMap or tuple", eventId @@ -88,20 +108,33 @@ function parseResolutionPayload( const map = decoded as Record; + if ("market_id" in map) { + // Legacy ScvMap payload: market_id, outcome, and oracle all in the value. + const oracleAddress = map.oracle != null ? String(map.oracle) : ""; + if (oracleAddress === "") { + throw new ResolutionParseError('Missing field "oracle"', eventId); + } + return { + marketId: String(field(map, "market_id", eventId)), + outcome: toResolutionOutcome(field(map, "outcome", eventId), eventId), + oracleAddress, + }; + } + + // Real on-chain shape: MarketResolvedEvent { #[topic] market_id: u32, + // outcome: bool, resolved_at: u64 }. market_id arrives via topics[1], not + // the value map. The contract does not publish an oracle address on this + // event, so oracleAddress is left empty pending reconciliation. return { - marketId: String(field(map, "market_id", eventId)), + marketId: marketIdFromTopic(topicsXdr, eventId), outcome: toResolutionOutcome(field(map, "outcome", eventId), eventId), - oracleAddress: map.oracle != null ? String(map.oracle) : "", + oracleAddress: "", }; } /** * Parse a single RawChainEvent into a NormalizedResolution. * - * Contract event value may be: - * - ScvMap: market_id, outcome (YES/NO), oracle - * - Tuple: (market_id: u32, outcome: bool, resolved_at: u64) - * * @throws ResolutionParseError if the event is not a resolution event or payload is malformed */ export function parseResolutionEvent( @@ -125,11 +158,7 @@ export function parseResolutionEvent( ); } - const payload = parseResolutionPayload(decoded, event.id); - - if (payload.oracleAddress === "" && !Array.isArray(decoded)) { - throw new ResolutionParseError('Missing field "oracle"', event.id); - } + const payload = parseResolutionPayload(decoded, event.topicsXdr, event.id); return { eventId: event.id, diff --git a/docs/indexer-event-mapping.md b/docs/indexer-event-mapping.md index 9811e31..c1410c7 100644 --- a/docs/indexer-event-mapping.md +++ b/docs/indexer-event-mapping.md @@ -57,9 +57,9 @@ All events share the same topic encoding: **topic[0] = ScvSymbol** carrying the ## 3. `market_resolved` -**Topic XDR:** `AAAADwAAAA9tYXJrZXRfcmVzb2x2ZWQA` +**Topic XDR:** `AAAADwAAABVtYXJrZXRfcmVzb2x2ZWRfZXZlbnQAAAA=` (`market_resolved_event`) -**Payload — canonical (on-chain tuple):** ScvVec 3-tuple: +**Payload — real on-chain shape:** `MarketResolvedEvent` (`contracts/market/src/events.rs`) publishes `market_id` as `topics[1]` and `{ outcome, resolved_at }` as the value: | Index | ScvType | Native type | Notes | | ----- | ------- | ----------- | -------------------------------------------- | @@ -67,9 +67,11 @@ All events share the same topic encoding: **topic[0] = ScvSymbol** carrying the | `[1]` | ScvBool | `boolean` | `true` → `"YES"`, `false` → `"NO"` | | `[2]` | ScvU64 | `bigint` | Unix timestamp of resolution (informational) | -`oracleAddress` is set to `""` for tuple payloads. `batchWriter` substitutes the Stellar null account (`GAAAAAA…AWHF`) when writing to `ResolutionCandidate.operatorAddress`. +The contract does not publish an oracle address on this event, so `oracleAddress` is `""`. `batchWriter` substitutes the Stellar null account (`GAAAAAA…AWHF`) when writing to `ResolutionCandidate.operatorAddress`. -**Payload — legacy (ScvMap):** Keys `market_id` (ScvSymbol), `outcome` (ScvSymbol `"YES"`/`"NO"`), `oracle` (ScvSymbol). `oracle` is required on this path; its absence throws `ResolutionParseError`. +**Payload — legacy ScvVec 3-tuple:** `[market_id: u32, outcome: bool, resolved_at: u64]` all inside the value (no second topic). Same field semantics as above; `oracleAddress` is also `""`. + +**Payload — legacy ScvMap:** Keys `market_id` (ScvSymbol), `outcome` (ScvSymbol `"YES"`/`"NO"`), `oracle` (ScvSymbol), all inside the value. `oracle` is required on this path; its absence throws `ResolutionParseError`. **DB write:** `ResolutionCandidate` row with `status = "PROPOSED"`, `source = "chain:market_resolved:{contractId}"`.