diff --git a/AGENTS.md b/AGENTS.md
index f34485f2..37cc6ed6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,6 +8,26 @@ pnpm monorepo with three packages:
- `indexer-envio/` — Envio HyperIndex indexer for Celo v3 FPMM pools
- `ui-dashboard/` — Next.js 16 + Plotly.js monitoring dashboard
+## Operating Rule (read this before opening PRs)
+
+> **Any PR that adds or changes stateful data flow across layers must ship with explicit invariants, degraded-mode behavior, and interaction tests before opening.**
+
+This repo has already paid the tax for learning this the hard way.
+
+If your change touches any combination of:
+
+- Envio schema/entities
+- event handlers / entity writers
+- generated types / GraphQL queries / dashboard types
+- paginated or sortable UI state
+- partial failure behavior (missing counts, stale RPC, missing txHash, etc.)
+
+then you are expected to run the dedicated PR checklist before opening or updating the PR:
+
+- **Checklist:** `docs/pr-checklists/stateful-data-ui.md`
+
+Do not rely on PR review to finish the design. Reviews should catch misses, not define the invariants for the first time.
+
## Quick Commands
```bash
@@ -165,6 +185,10 @@ pnpm indexer:codegen # Validates Envio can parse handler entry point + module
pnpm --filter @mento-protocol/ui-dashboard test:coverage
```
+Before pushing any cross-layer or stateful UI change, also read and apply:
+
+- **`docs/pr-checklists/stateful-data-ui.md`**
+
**Common traps:**
- `codespell` flags short variable names that match common abbreviations (e.g. a two-letter loop var that looks like a misspelling). Use descriptive names like `netData` to avoid this.
diff --git a/docs/pr-checklists/stateful-data-ui.md b/docs/pr-checklists/stateful-data-ui.md
new file mode 100644
index 00000000..4e887a52
--- /dev/null
+++ b/docs/pr-checklists/stateful-data-ui.md
@@ -0,0 +1,205 @@
+# Stateful Data + UI PR Checklist
+
+Use this checklist for any PR that changes stateful data flow across layers.
+
+## Operating rule
+
+> **Any PR that adds or changes stateful data flow across layers must ship with explicit invariants, degraded-mode behavior, and interaction tests before opening.**
+
+If the change touches any combination of:
+
+- Envio schema/entities
+- event handlers / entity writers
+- generated types / GraphQL queries / dashboard types
+- paginated, sortable, filterable, or searchable UI state
+- partial failure behavior (count query failure, stale RPC, missing metadata, old rows after schema rollout)
+
+then this checklist is mandatory.
+
+---
+
+## 1. Define invariants first
+
+Write down the rules the system must obey before coding.
+
+Examples:
+
+- Every event/snapshot entity must persist `txHash`
+- Charts must not depend on paginated table slices
+- Paginated tables must have deterministic ordering
+- Aggregate-query failure must degrade visibly, not silently
+- Client-side search over large datasets must be bounded and disclosed
+
+If you cannot state the invariant in one sentence, the design is not ready.
+
+---
+
+## 2. Cross-layer audit
+
+For every new field / changed field / changed behavior, walk the full path:
+
+### Schema / source of truth
+
+- [ ] `schema.graphql` updated if entity shape changed
+- [ ] field names/types/nullability are intentional
+- [ ] backward-compatibility / rollout behavior considered for old rows
+
+### Writers
+
+- [ ] every entity constructor / writer is updated consistently
+- [ ] all event handlers that produce the entity were checked, not just the obvious one
+- [ ] generated/codegen artifacts refreshed where applicable
+
+### Readers
+
+- [ ] GraphQL queries updated
+- [ ] dashboard/runtime types updated
+- [ ] derived formatting / rendering logic updated
+- [ ] search/sort/filter fields updated intentionally
+
+### Tests
+
+- [ ] producer/indexer tests updated
+- [ ] consumer/UI tests updated
+- [ ] fixtures reflect the new schema reality
+
+If one layer is missing, stop and fix it before opening the PR.
+
+---
+
+## 3. Stateful table rubric
+
+If the PR touches a table with pagination, sort, filter, search, or linked charts, answer all of these explicitly.
+
+### Sorting
+
+- [ ] Is sorting server-side, client-side, or hybrid?
+- [ ] Are page boundaries deterministic for non-unique sort fields?
+- [ ] Is there a unique tiebreaker (`id`, tx hash, composite key, etc.)?
+- [ ] Do headers expose sort state accessibly (`aria-sort`)?
+
+### Pagination
+
+- [ ] What determines total row count?
+- [ ] What happens when count/aggregate fails?
+- [ ] Does pagination remain usable after transient failure?
+- [ ] Are controls actual buttons with `type="button"`?
+
+### Search / filtering
+
+- [ ] Does search operate on current page, fetched window, or full dataset?
+- [ ] Is that behavior documented in code comments and PR notes?
+- [ ] If bounded, is the cap explicit and user-visible?
+- [ ] If unbounded, can the backend/query path actually support it?
+
+### Coupled visualizations
+
+- [ ] Do charts use dedicated queries instead of inheriting paginated/sorted table state?
+- [ ] If not, is that coupling intentional and documented?
+
+### URL / local state
+
+- [ ] Is table state URL-backed or intentionally local?
+- [ ] If local-only, is that explicitly called out as an intentional scope decision?
+
+---
+
+## 4. Degraded-mode checklist
+
+For each non-happy path, decide the behavior explicitly.
+
+- [ ] count query fails
+- [ ] chart query fails
+- [ ] some rows predate a new schema field
+- [ ] RPC-derived metadata is missing
+- [ ] total dataset is much larger than the current happy-path sample
+- [ ] search term matches data outside the currently fetched window
+- [ ] empty state vs loading state vs partial-data state are distinct
+
+The key question:
+
+> What will the user see, and will they understand that the data is partial or degraded?
+
+Silent degradation is not acceptable.
+
+---
+
+## 5. Required test matrix
+
+For nontrivial stateful data/UI changes, tests must cover all 3 buckets:
+
+### Happy path
+
+- [ ] normal render / query wiring
+- [ ] new field is displayed/used correctly
+
+### State transition
+
+- [ ] sort toggle changes query/order state
+- [ ] page transition changes offset/page state
+- [ ] search input resets/updates the right state
+- [ ] links/actions resolve to the expected target
+
+### Failure / degraded mode
+
+- [ ] count error fallback
+- [ ] capped search behavior
+- [ ] missing field / legacy row behavior
+- [ ] user-visible warning or fallback state
+
+If the risky behavior is interactive, a static markup assertion is not enough.
+
+---
+
+## 6. PR description requirements
+
+Before opening the PR, include these sections:
+
+### What this PR changes
+
+Short factual summary.
+
+### Invariants
+
+List the system rules this PR relies on or introduces.
+
+### Degraded behavior
+
+What happens on count/query/RPC failure, old rows, large datasets, etc.
+
+### Intentional non-goals
+
+Examples:
+
+- URL-backed sort/page state deferred
+- full server-side search deferred
+- abstraction cleanup out of scope
+
+This prevents reviews from repeatedly rediscovering scope boundaries.
+
+---
+
+## 7. Repo-specific lessons already paid for
+
+These are not theoretical.
+
+- New UI fields must not assume schema support without verifying all writers.
+- Shared presentational components should forward DOM props unless intentionally constrained.
+- Count fallback must preserve prior total, not collapse to current page length.
+- Search behavior must be bounded and disclosed when not truly global.
+- Charts and tables should usually be decoupled.
+- Cross-layer features need both indexer and UI regression coverage.
+
+---
+
+## 8. Final pre-PR questions
+
+If you answer “no” to any of these, do not open yet.
+
+- [ ] Could another engineer explain the invariants from the PR description alone?
+- [ ] Would a transient backend failure produce a sensible UI instead of a misleading one?
+- [ ] Are the largest-cardinality paths still bounded?
+- [ ] Do tests prove behavior, not just markup?
+- [ ] Did review stop being the place where design gets finished?
+
+If not, one more local pass is cheaper than three more review rounds.
diff --git a/indexer-envio/AGENTS.md b/indexer-envio/AGENTS.md
index d400f433..630f8462 100644
--- a/indexer-envio/AGENTS.md
+++ b/indexer-envio/AGENTS.md
@@ -4,6 +4,14 @@
Envio HyperIndex indexer for Mento v3 FPMM (Fixed Product Market Maker) pools on Celo + Monad (multichain).
+## Before Opening PRs
+
+If your indexer change propagates into Hasura/UI behavior — schema changes, entity additions, new fields on existing entities, degraded RPC/error handling, or any stateful dashboard behavior fed by indexer data — read and apply:
+
+- `../docs/pr-checklists/stateful-data-ui.md`
+
+This is mandatory for cross-layer/stateful data work. Do not assume the UI/query layer will “just catch up” later.
+
## Key Files
- `config.multichain.mainnet.yaml` — **Default** mainnet config (Celo + Monad)
diff --git a/indexer-envio/schema.graphql b/indexer-envio/schema.graphql
index 9837f7ad..f502eb9e 100644
--- a/indexer-envio/schema.graphql
+++ b/indexer-envio/schema.graphql
@@ -60,6 +60,7 @@ type OracleSnapshot @index(fields: ["poolId", "timestamp"]) {
rebalanceThreshold: Int!
source: String!
blockNumber: BigInt!
+ txHash: String!
}
type PoolSnapshot @index(fields: ["poolId", "timestamp"]) {
diff --git a/indexer-envio/src/handlers/fpmm.ts b/indexer-envio/src/handlers/fpmm.ts
index b06e01cb..dce873f5 100644
--- a/indexer-envio/src/handlers/fpmm.ts
+++ b/indexer-envio/src/handlers/fpmm.ts
@@ -532,6 +532,7 @@ FPMM.UpdateReserves.handler(async ({ event, context }) => {
rebalanceThreshold: pool.rebalanceThreshold,
source: "update_reserves",
blockNumber,
+ txHash: event.transaction.hash,
};
context.OracleSnapshot.set(snapshot);
}
@@ -623,6 +624,7 @@ FPMM.Rebalanced.handler(async ({ event, context }) => {
rebalanceThreshold: pool.rebalanceThreshold,
source: "rebalanced",
blockNumber,
+ txHash: event.transaction.hash,
};
context.OracleSnapshot.set(snapshot);
}
diff --git a/indexer-envio/src/handlers/sortedOracles.ts b/indexer-envio/src/handlers/sortedOracles.ts
index 833bd597..ab34123a 100644
--- a/indexer-envio/src/handlers/sortedOracles.ts
+++ b/indexer-envio/src/handlers/sortedOracles.ts
@@ -77,6 +77,7 @@ SortedOracles.OracleReported.handler(async ({ event, context }) => {
rebalanceThreshold: existing.rebalanceThreshold,
source: "oracle_reported",
blockNumber,
+ txHash: event.transaction.hash,
};
context.OracleSnapshot.set(snapshot);
}
@@ -140,6 +141,7 @@ SortedOracles.MedianUpdated.handler(async ({ event, context }) => {
rebalanceThreshold: existing.rebalanceThreshold,
source: "oracle_median_updated",
blockNumber,
+ txHash: event.transaction.hash,
};
context.OracleSnapshot.set(snapshot);
}
diff --git a/indexer-envio/test/Test.ts b/indexer-envio/test/Test.ts
index 3741ad78..c4ef0cbf 100644
--- a/indexer-envio/test/Test.ts
+++ b/indexer-envio/test/Test.ts
@@ -401,6 +401,7 @@ type OracleSnapshotEntity = {
rebalanceThreshold: number;
source: string;
blockNumber: bigint;
+ txHash: string;
};
type LiquidityPositionEntity = {
@@ -923,6 +924,11 @@ describe("Envio Celo indexer handlers", () => {
7,
"MedianUpdated snapshot preserves the DB-seeded reporter count",
);
+ assert.equal(
+ snapshot.txHash,
+ pool.oracleTxHash,
+ "MedianUpdated snapshot should persist the triggering tx hash",
+ );
});
// ---------------------------------------------------------------------------
@@ -996,6 +1002,11 @@ describe("Envio Celo indexer handlers", () => {
"Snapshot priceDifference must match pool priceDifference",
);
assert.equal(snapshot!.source, "oracle_reported");
+ assert.equal(
+ snapshot!.txHash,
+ pool.oracleTxHash,
+ "OracleReported snapshot should persist the triggering tx hash",
+ );
});
it("MedianUpdated: stores priceDifference computed from event oracle + existing reserves", async () => {
@@ -1056,6 +1067,11 @@ describe("Envio Celo indexer handlers", () => {
"Snapshot priceDifference must match pool priceDifference",
);
assert.equal(snapshot!.source, "oracle_median_updated");
+ assert.equal(
+ snapshot!.txHash,
+ pool.oracleTxHash,
+ "MedianUpdated snapshot should persist the triggering tx hash",
+ );
});
// ---------------------------------------------------------------------------
@@ -1374,6 +1390,8 @@ describe("Envio Celo indexer handlers", () => {
source: "fpmm_update_reserves",
});
+ const UPDATE_TX_HASH =
+ "0x000000000000000000000000000000000000000000000000000000000000b001";
const updateEvent = FPMM.UpdateReserves.createMockEvent({
reserve0: 40_000_000_000_000_000_000_000n,
reserve1: 60_000_000_000_000_000_000_000n,
@@ -1382,6 +1400,7 @@ describe("Envio Celo indexer handlers", () => {
logIndex: 11,
srcAddress: POOL_ADDR,
block: { number: 801, timestamp: 1_700_006_100 },
+ transaction: { hash: UPDATE_TX_HASH },
},
});
mockDb = await FPMM.UpdateReserves.processEvent({
@@ -1397,6 +1416,22 @@ describe("Envio Celo indexer handlers", () => {
CONTRACT_PRICE_DIFF,
`expected contract priceDifference ${CONTRACT_PRICE_DIFF} bps, got ${pool.priceDifference}`,
);
+
+ // OracleSnapshot must be written with the triggering tx hash
+ const snapshotId = `${42220}_${801}_${11}`;
+ const snapshot = mockDb.entities.OracleSnapshot.get(snapshotId) as
+ | OracleSnapshotEntity
+ | undefined;
+ assert.ok(
+ snapshot,
+ "UpdateReserves snapshot must be written when rebalancingState is available",
+ );
+ assert.equal(
+ snapshot!.txHash,
+ UPDATE_TX_HASH,
+ "UpdateReserves snapshot must carry the triggering tx hash",
+ );
+ assert.equal(snapshot!.source, "update_reserves");
});
// ---------------------------------------------------------------------------
@@ -1554,6 +1589,8 @@ describe("Envio Celo indexer handlers", () => {
source: "fpmm_update_reserves",
});
+ const REBALANCED_TX_HASH =
+ "0x000000000000000000000000000000000000000000000000000000000000b002";
const rebalancedEvent = FPMM.Rebalanced.createMockEvent({
sender: "0x0000000000000000000000000000000000000099",
priceDifferenceBefore: 3333n,
@@ -1563,6 +1600,7 @@ describe("Envio Celo indexer handlers", () => {
logIndex: 11,
srcAddress: POOL_ADDR,
block: { number: 901, timestamp: 1_700_007_100 },
+ transaction: { hash: REBALANCED_TX_HASH },
},
});
mockDb = await FPMM.Rebalanced.processEvent({
@@ -1579,6 +1617,22 @@ describe("Envio Celo indexer handlers", () => {
`expected event priceDifference ${EVENT_PRICE_DIFF} bps, got ${pool.priceDifference} (RPC was ${RPC_PRICE_DIFF})`,
);
assert.equal(pool.rebalanceCount, 1);
+
+ // OracleSnapshot must be written with the triggering tx hash
+ const snapshotId = `${42220}_${901}_${11}`;
+ const snapshot = mockDb.entities.OracleSnapshot.get(snapshotId) as
+ | OracleSnapshotEntity
+ | undefined;
+ assert.ok(
+ snapshot,
+ "Rebalanced snapshot must be written when rebalancingState is available",
+ );
+ assert.equal(
+ snapshot!.txHash,
+ REBALANCED_TX_HASH,
+ "Rebalanced snapshot must carry the triggering tx hash",
+ );
+ assert.equal(snapshot!.source, "rebalanced");
});
// ---------------------------------------------------------------------------
diff --git a/ui-dashboard/AGENTS.md b/ui-dashboard/AGENTS.md
index 8d863ee0..e2334743 100644
--- a/ui-dashboard/AGENTS.md
+++ b/ui-dashboard/AGENTS.md
@@ -4,6 +4,14 @@
Next.js 16 monitoring dashboard for Mento v3 pools. Displays real-time pool data (reserves, swaps, mints, burns) using Plotly.js charts, sourced from Hasura GraphQL.
+## Before Opening PRs
+
+If your dashboard change touches stateful data flow — pagination, sort, search, charts tied to table state, GraphQL shape changes, degraded/error behavior, or any indexer→query→UI field path — read and apply:
+
+- `../docs/pr-checklists/stateful-data-ui.md`
+
+This is mandatory for cross-layer/stateful UI work. The checklist exists because this repo repeatedly burned review cycles on exactly these failure modes.
+
## Key Files
- `src/app/` — Next.js App Router pages and layouts
diff --git a/ui-dashboard/src/app/pool/[poolId]/page.test.tsx b/ui-dashboard/src/app/pool/[poolId]/page.test.tsx
index 87d06bfb..80cf2da7 100644
--- a/ui-dashboard/src/app/pool/[poolId]/page.test.tsx
+++ b/ui-dashboard/src/app/pool/[poolId]/page.test.tsx
@@ -15,6 +15,8 @@ import type {
} from "@/lib/types";
import {
ORACLE_SNAPSHOTS,
+ ORACLE_SNAPSHOTS_CHART,
+ ORACLE_SNAPSHOTS_COUNT,
POOL_DEPLOYMENT,
POOL_DETAIL_WITH_HEALTH,
POOL_LIQUIDITY,
@@ -138,8 +140,24 @@ vi.mock("@/components/table", () => ({
Table: ({ children }: { children: React.ReactNode }) => (