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
35 changes: 19 additions & 16 deletions apps/indexer/fixtures/contract-event-vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand All @@ -113,40 +114,42 @@
},
"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",
"oracleAddress": ""
}
},
{
"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",
"oracleAddress": ""
}
},
{
"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"
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/indexer/src/ingestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
140 changes: 109 additions & 31 deletions apps/indexer/src/resolutionParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=",
},
Expand All @@ -38,44 +54,88 @@ function makeEvent(overrides: Partial<RawChainEvent> = {}): 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,
};
}

// ─── 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");
expect(r.outcome).toBe("YES");
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");
expect(r.outcome).toBe("YES");
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");
Expand All @@ -94,28 +154,33 @@ 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);
expect((err as ResolutionParseError).message).toContain("MAYBE");
}
});

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);
Expand All @@ -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);
});

Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading