From 00accecb7ac62b49dd52e97138faef529f6e3927 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:31:28 +0530 Subject: [PATCH] fix(indexer): validate bme price attribute as dec string A non-numeric price in a bme coin attribute previously flowed unchecked into the numeric(38,18) ledger columns, aborting the whole batch transaction instead of degrading to a parse warning like every other malformed attribute. Also documents that widening the scanned bme event-type set shifts replay ordinals. --- .../chain-indexer/src/bme/bme-deriver.spec.ts | 78 +++++++++++++++++++ apps/chain-indexer/src/bme/bme-deriver.ts | 12 ++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/chain-indexer/src/bme/bme-deriver.spec.ts b/apps/chain-indexer/src/bme/bme-deriver.spec.ts index f6611ae2ff..5578c1d783 100644 --- a/apps/chain-indexer/src/bme/bme-deriver.spec.ts +++ b/apps/chain-indexer/src/bme/bme-deriver.spec.ts @@ -158,6 +158,30 @@ describe("deriveBmeChanges", () => { ]); }); + it("derives a mint status change from unquoted legacy attributes", () => { + const changes = deriveBmeChanges( + block({ + blockEvents: [ + event(STATUS_CHANGE_EVENT_TYPE, { + previous_status: "mint_status_healthy", + new_status: "mint_status_warning", + collateral_ratio: "1.75" + }) + ] + }) + ); + + expect(changes.warnings).toEqual([]); + expect(changes.changes).toEqual([ + expect.objectContaining({ + kind: "mintStatusChange", + previousStatus: "mint_status_healthy", + newStatus: "mint_status_warning", + collateralRatio: "1.75" + }) + ]); + }); + it("rejects a status change with a value outside the known mint statuses", () => { const changes = deriveBmeChanges( block({ @@ -208,6 +232,27 @@ describe("deriveBmeChanges", () => { ]); }); + it("drops a canceled record with a malformed coins_to_burn attribute", () => { + const changes = deriveBmeChanges( + block({ + blockEvents: [ + event(CANCELED_EVENT_TYPE, { + id: '{"denom":"uakt","to_denom":"uact","source":"bme","height":12000,"sequence":5}', + cancel_reason: '"insufficient_funds"', + owner: '"akash1owner"', + to: '"akash1dest"', + coins_to_burn: "not-json", + denom_to_mint: '"uact"' + }) + ] + }) + ); + + expect(changes.changes).toEqual([]); + expect(changes.warnings).toHaveLength(1); + expect(changes.warnings[0]).toContain(CANCELED_EVENT_TYPE); + }); + it("assigns ordinals across transaction events then block events in scan order", () => { const changes = deriveBmeChanges( block({ @@ -281,6 +326,39 @@ describe("deriveBmeChanges", () => { expect(changes.warnings).toHaveLength(1); }); + it("drops an executed event whose price is not a Dec string", () => { + const changes = deriveBmeChanges( + block({ + blockEvents: [ + event(EXECUTED_EVENT_TYPE, { + ...executedAttributes({ sequence: 1 }), + burned: '{"coin":{"denom":"uakt","amount":"1000000"},"price":"not-a-dec"}' + }) + ] + }) + ); + + expect(changes.changes).toEqual([]); + expect(changes.warnings).toHaveLength(1); + expect(changes.warnings[0]).toContain(EXECUTED_EVENT_TYPE); + }); + + it("treats a missing price as null on an otherwise valid coin", () => { + const changes = deriveBmeChanges( + block({ + blockEvents: [ + event(EXECUTED_EVENT_TYPE, { + ...executedAttributes({ sequence: 1 }), + burned: '{"coin":{"denom":"uakt","amount":"1000000"}}' + }) + ] + }) + ); + + expect(changes.warnings).toEqual([]); + expect(changes.changes).toEqual([expect.objectContaining({ burned: { denom: "uakt", amount: "1000000", price: null } })]); + }); + it("ignores vault funded and unrelated events", () => { const changes = deriveBmeChanges( block({ diff --git a/apps/chain-indexer/src/bme/bme-deriver.ts b/apps/chain-indexer/src/bme/bme-deriver.ts index c8213d2263..7e49cdd10e 100644 --- a/apps/chain-indexer/src/bme/bme-deriver.ts +++ b/apps/chain-indexer/src/bme/bme-deriver.ts @@ -66,8 +66,10 @@ type ParsedChange = { change: BmeChangeBody } | { error: string }; * mint status transitions and canceled records. Events of failed transactions are skipped; in practice * BME fires in the EndBlocker, but tx events are scanned too since the natural keys dedupe either way. * `ordinal` counts every BME-typed event in scan order — including ones that fail to parse — so a later - * parser fix replays with stable ordinals. Parse failures land in `warnings` for the writer to log; - * a malformed event must not halt the block. + * parser fix replays with stable ordinals. That stability only holds while the scanned event-type set is + * fixed: adding a BME event type shifts every later ordinal on replay, duplicating `bme_status_changes` + * rows unless previously derived rows are wiped first. Parse failures land in `warnings` for the writer + * to log; a malformed event must not halt the block. */ export function deriveBmeChanges(block: DecodedBlock): BmeBlockChanges { const changes: BmeChange[] = []; @@ -223,7 +225,11 @@ function parseCoinPrice(raw: string | undefined): BmeCoinPrice | null | undefine if (!coin) { return undefined; } - return { ...coin, price: asString(record?.price) }; + const price = asString(record?.price); + if (price !== null && !isDecString(price)) { + return undefined; + } + return { ...coin, price }; } function parseCoin(raw: string | undefined): BmeCoin | null | undefined {