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
84 changes: 83 additions & 1 deletion apps/indexer/src/collateralDepositedParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ describe("parseCollateralDepositedEvent", () => {
});

it("handles large i128 amounts without precision loss", () => {
const big = 9_999_999_999_999_999_999n;
// Largest value that still fits the Decimal(20,8) column
// amountRawToDecimal backs (999_999_999_999 * 10^7).
const big = 9_999_999_999_990_000_000n;
const d = parseCollateralDepositedEvent(
makeEvent({ valueXdr: makeDepositValueXdr("GABC", 1, big) })
);
Expand Down Expand Up @@ -203,3 +205,83 @@ describe("parseCollateralDepositedEvents", () => {
);
});
});

describe("collateral amount scale validation", () => {
// Gap this covers: the parser previously accepted any i128 amount as-is,
// with no check against the 7-decimal / Decimal(20,8) scale the rest of
// the system (amountRawToDecimal, CollateralDeposit columns) assumes for
// it — so a wrongly-scaled or out-of-range amount would pass the parser
// silently and only surface later, far from the event that caused it.
// These tests fail against the pre-fix parser, which has no such check.

it("rejects an amount that exceeds the Decimal(20,8) collateral range", () => {
const tooLarge = 10_000_000_000_000_000_000n; // just over MAX_RAW
expect(() =>
parseCollateralDepositedEvent(
makeEvent({ valueXdr: makeDepositValueXdr("GABC", 1, tooLarge) })
)
).toThrow(CollateralDepositedParseError);
});

it("rejects a zero amount", () => {
expect(() =>
parseCollateralDepositedEvent(
makeEvent({ valueXdr: makeDepositValueXdr("GABC", 1, 0n) })
)
).toThrow(CollateralDepositedParseError);
});

it("rejects a negative amount", () => {
expect(() =>
parseCollateralDepositedEvent(
makeEvent({ valueXdr: makeDepositValueXdr("GABC", 1, -500n) })
)
).toThrow(CollateralDepositedParseError);
});

it("emits invalid_collateral_scale metric when an amount fails validation", () => {
const telemetry: Telemetry = {
record: vi.fn(),
startSpan: vi.fn(() => ({ end: vi.fn() })),
};
const events = [
makeEvent({
id: "evt-bad-scale",
valueXdr: makeDepositValueXdr("GABC", 1, -1n),
}),
];
const { deposits, errors } = parseCollateralDepositedEvents(events, {
telemetry,
});
expect(deposits).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(telemetry.record).toHaveBeenCalledWith(
"indexer.parser.invalid_collateral_scale",
1,
expect.objectContaining({
parser: "collateral_deposited",
eventId: "evt-bad-scale",
})
);
});

it("rejects a plain-number amount in production but allows it in dev", () => {
// Simulate a non-i128 decode path by building the tuple with a JS
// number amount instead of a bigint (nativeToScVal picks i32/i64 for
// small numbers, not i128 — this is the "wrong width" shape).
const numberAmountXdr = nativeToScVal(["GABC", 1, 500]).toXDR("base64");

expect(() =>
parseCollateralDepositedEvent(
makeEvent({ valueXdr: numberAmountXdr }),
{ nodeEnv: "production" }
)
).toThrow(CollateralDepositedParseError);

const d = parseCollateralDepositedEvent(
makeEvent({ valueXdr: numberAmountXdr }),
{ nodeEnv: "development" }
);
expect(d.amountRaw).toBe(500n);
});
});
85 changes: 78 additions & 7 deletions apps/indexer/src/collateralDepositedParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ import type { RawChainEvent } from "./types.js";
import { CollateralDepositedParseError } from "./types.js";
import { safeStringify } from "./safeJson.js";
import type { Telemetry } from "./telemetry.js";
import { amountRawToDecimal } from "./decimalUtils.js";

const COLLATERAL_DEPOSITED_TOPIC = "collateral_deposited";

function isProductionEnv(nodeEnv: string): boolean {
return nodeEnv === "production";
}

function decodeScVal(xdrBase64: string): unknown {
return scValToNative(xdr.ScVal.fromXDR(xdrBase64, "base64"));
}
Expand Down Expand Up @@ -48,10 +53,29 @@ export interface NormalizedCollateralDeposit {
amountRaw: bigint;
}

function toBigInt(value: unknown, fieldName: string, eventId: string): bigint {
function toBigInt(
value: unknown,
fieldName: string,
eventId: string,
nodeEnv: string
): bigint {
if (typeof value === "bigint") return value;
if (typeof value === "number" && Number.isInteger(value))
if (typeof value === "number" && Number.isInteger(value)) {
// scValToNative always decodes an i128 ScVal (the contract's collateral
// amount type) to a bigint, never a plain number. Seeing a number here
// means the value arrived through a non-i128 path — most likely a
// fixture or upstream decoder using the wrong scale/width — which is
// exactly the "wrong scale vs contract 7 decimals" failure mode this
// guards against. Only tolerate it outside production.
if (isProductionEnv(nodeEnv)) {
throw new CollateralDepositedParseError(
`Field "${fieldName}" decoded as a plain number, not an i128 bigint — ` +
"refusing to guess the on-chain scale in production",
eventId
);
}
return BigInt(value);
}
if (typeof value === "string") {
try {
return BigInt(value);
Expand All @@ -65,16 +89,53 @@ function toBigInt(value: unknown, fieldName: string, eventId: string): bigint {
);
}

/**
* Validate `amountRaw` against the same 7-decimal / Decimal(20,8) bounds
* `decimalUtils.amountRawToDecimal` enforces everywhere else collateral
* amounts are read. Without this, a value the parser happily returns as a
* bigint could still be silently out of the scale the rest of the system
* (UserPosition/CollateralDeposit Decimal columns) assumes for it, and the
* mismatch would only surface later — e.g. as a DB error or, worse, a
* silently truncated amount — far from the event that caused it.
*/
function validateCollateralScale(amountRaw: bigint, eventId: string): void {
if (amountRaw <= 0n) {
throw new CollateralDepositedParseError(
`Field "amount" must be a positive i128, got ${amountRaw}`,
eventId
);
}
try {
amountRawToDecimal(amountRaw);
} catch (err) {
throw new CollateralDepositedParseError(
`Field "amount" (${amountRaw}) is out of range for the 7-decimal ` +
`collateral scale: ${err instanceof Error ? err.message : String(err)}`,
eventId
);
}
}

/**
* Parse a single RawChainEvent into a NormalizedCollateralDeposit.
*
* Expected on-chain value: Vec [ account: str, market_id: u32, amount: i128 ]
*
* @throws CollateralDepositedParseError on wrong topic or malformed payload.
* @throws CollateralDepositedParseError on wrong topic, malformed payload,
* or an amount that fails the 7-decimal collateral scale validation.
*/
export interface ParseCollateralDepositedOptions {
telemetry?: Telemetry;
/** Defaults to `process.env.NODE_ENV`; override in tests only. */
nodeEnv?: string;
}

export function parseCollateralDepositedEvent(
event: RawChainEvent
event: RawChainEvent,
options?: ParseCollateralDepositedOptions
): NormalizedCollateralDeposit {
const nodeEnv = options?.nodeEnv ?? process.env.NODE_ENV ?? "development";

if (!isCollateralDepositedEvent(event.topicsXdr)) {
throw new CollateralDepositedParseError(
`Event topic is not "${COLLATERAL_DEPOSITED_TOPIC}"`,
Expand Down Expand Up @@ -109,14 +170,17 @@ export function parseCollateralDepositedEvent(
);
}

const amountRaw = toBigInt(amount, "amount", event.id, nodeEnv);
validateCollateralScale(amountRaw, event.id);

return {
eventId: event.id,
ledger: event.ledger,
ledgerClosedAt: event.ledgerClosedAt,
contractId: event.contractId,
account,
marketId: String(marketId),
amountRaw: toBigInt(amount, "amount", event.id),
amountRaw,
};
}

Expand All @@ -125,14 +189,15 @@ export function parseCollateralDepositedEvent(
*/
export function parseCollateralDepositedEvents(
events: RawChainEvent[],
options?: { telemetry?: Telemetry }
options?: ParseCollateralDepositedOptions
): {
deposits: NormalizedCollateralDeposit[];
errors: CollateralDepositedParseError[];
} {
const deposits: NormalizedCollateralDeposit[] = [];
const errors: CollateralDepositedParseError[] = [];
const telemetry = options?.telemetry;
const nodeEnv = options?.nodeEnv ?? process.env.NODE_ENV ?? "development";

for (const event of events) {
if (!isCollateralDepositedEvent(event.topicsXdr)) {
Expand All @@ -145,8 +210,14 @@ export function parseCollateralDepositedEvents(
continue;
}
try {
deposits.push(parseCollateralDepositedEvent(event));
deposits.push(parseCollateralDepositedEvent(event, { telemetry, nodeEnv }));
} catch (err) {
telemetry?.record("indexer.parser.invalid_collateral_scale", 1, {
parser: "collateral_deposited",
eventId: event.id,
contractId: event.contractId,
ledger: String(event.ledger),
});
errors.push(
err instanceof CollateralDepositedParseError
? err
Expand Down
80 changes: 80 additions & 0 deletions apps/indexer/src/resolutionParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,83 @@ describe("parseResolutionEvents", () => {
);
});
});

describe("production fail-fast on legacy resolution shapes", () => {
// Gap this covers: legacy dev-stub payload shapes (ScvVec tuple, legacy
// ScvMap without topics[1]=market_id) were previously accepted silently
// in every environment, including production, which meant a contract
// regression or topic drift produced ResolutionCandidate rows with a
// blank oracleAddress instead of failing loudly. These tests fail
// against the pre-fix parser (which has no `nodeEnv` gate at all).

it("rejects a legacy ScvVec tuple payload in production", () => {
const tupleXdr = nativeToScVal([7, false, 99n]).toXDR("base64");
expect(() =>
parseResolutionEvent(
makeEvent({
valueXdr: tupleXdr,
topicsXdr: [XDR.topic.marketResolvedEvent],
}),
{ nodeEnv: "production" }
)
).toThrow(ResolutionParseError);
});

it("rejects a legacy ScvMap payload in production", () => {
expect(() =>
parseResolutionEvent(
makeEvent({
valueXdr: XDR.value.resolvedYes,
topicsXdr: [XDR.topic.marketResolvedEvent],
}),
{ nodeEnv: "production" }
)
).toThrow(ResolutionParseError);
});

it("still parses the canonical on-chain shape in production", () => {
const r = parseResolutionEvent(makeEvent({ valueXdr: XDR.value.realYes }), {
nodeEnv: "production",
});
expect(r.outcome).toBe("YES");
});

it("still allows legacy shapes outside production (local stubs)", () => {
const r = parseResolutionEvent(
makeEvent({
valueXdr: XDR.value.resolvedYes,
topicsXdr: [XDR.topic.marketResolvedEvent],
}),
{ nodeEnv: "development" }
);
expect(r.outcome).toBe("YES");
});

it("emits a legacy_shape_rejected metric with correlation ids when rejecting in production", () => {
const telemetry: Telemetry = {
record: vi.fn(),
startSpan: vi.fn(() => ({ end: vi.fn() })),
};
const events = [
makeEvent({
id: "evt-legacy-1",
valueXdr: XDR.value.resolvedYes,
topicsXdr: [XDR.topic.marketResolvedEvent],
}),
];
const { resolutions, errors } = parseResolutionEvents(events, {
telemetry,
nodeEnv: "production",
});
expect(resolutions).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(telemetry.record).toHaveBeenCalledWith(
"indexer.parser.legacy_shape_rejected",
1,
expect.objectContaining({
parser: "resolution",
eventId: "evt-legacy-1",
})
);
});
});
Loading