From 5cdf7ec47830903973520467dad60b09dd1dc1bb Mon Sep 17 00:00:00 2001 From: Francisco Campos Date: Tue, 25 Aug 2026 21:24:49 -0600 Subject: [PATCH 1/2] fix(indexer): use indexed exact-match participant columns instead of ILIKE on JSON (#889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Participant queries (queryEventsByPublicKey, queryEventsByType, getEventStats) previously scanned the serialized payload with payload::text ILIKE '%%' — a full-table scan that also produced false matches when the key appeared in unrelated fields (e.g. a memo). - Rewrite all three query helpers to match exactly on from_addr/to_addr - Add migration 028: composite index idx_events_participants (from_addr, to_addr) for fast lookups (cross-dialect) - Keep the in-memory fallback store in sync (exact column match) - Add 13 tests covering exact-participant filtering and rejection of memo substring false positives Closes #889 --- .../eventIndexer-column-queries.test.js | 247 ++++++++++++++++++ .../028_events_participants_index.js | 31 +++ backend/src/services/eventIndexer.js | 62 +++-- 3 files changed, 321 insertions(+), 19 deletions(-) create mode 100644 backend/__tests__/eventIndexer-column-queries.test.js create mode 100644 backend/migrations/028_events_participants_index.js diff --git a/backend/__tests__/eventIndexer-column-queries.test.js b/backend/__tests__/eventIndexer-column-queries.test.js new file mode 100644 index 00000000..473974e9 --- /dev/null +++ b/backend/__tests__/eventIndexer-column-queries.test.js @@ -0,0 +1,247 @@ +/* eslint-env jest */ +/** + * __tests__/eventIndexer-column-queries.test.js + * Tests for issue #889 — participant queries use indexed exact-match columns + * (from_addr / to_addr) instead of ILIKE on serialized JSON. + * + * Verifies: + * - Exact participant filtering returns only the user's events + * - A public key string appearing in a memo/payload does NOT produce false matches + * - queryEventsByPublicKey returns only events where the key is from_addr or to_addr + * - queryEventsByType returns only events matching both participant AND event type + * - getEventStats counts events only for the specified participant + */ + +"use strict"; + +// Do NOT set DATABASE_URL — we test the in-memory fallback path +process.env.CONTRACT_ID = "TESTCONTRACT123456789012345678901234567890"; + +const eventIndexer = require("../src/services/eventIndexer"); + +// ─── Test data ─────────────────────────────────────────────────────────────── + +const ALICE = "GB2JLUHNVHL64FKADLJVH5TMUWTS6P5BS4Y3WJT6KU7FRXBFQM5PGGVV"; +const BOB = "GABCDEFHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ12345"; +const MEMO_TEXT = "Thanks for the tip!"; // does NOT contain a public key + +// An event where ALICE sent a tip to BOB +const aliceToBobTip = { + event_type: "tip", + contract_id: "CDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + ledger_sequence: 100, + emitted_at: "2025-01-01T00:00:00Z", + from_addr: ALICE, + to_addr: BOB, + payload: { + from: ALICE, + to: BOB, + amount: "1000", + memo: MEMO_TEXT, + }, +}; + +// An event where BOB sent a receipt to ALICE +const bobToAliceReceipt = { + event_type: "receipt", + contract_id: "CDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + ledger_sequence: 101, + emitted_at: "2025-01-01T00:00:01Z", + from_addr: BOB, + to_addr: ALICE, + payload: { + from: BOB, + to: ALICE, + amount: "500", + }, +}; + +// An event that is completely unrelated to ALICE or BOB +const unrelatedEvent = { + event_type: "tip", + contract_id: "CDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + ledger_sequence: 99, + emitted_at: "2025-01-01T00:00:00Z", + from_addr: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to_addr: "GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + payload: { + from: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to: "GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + amount: "999", + }, +}; + +// An event whose memo field contains ALICE's public key as a substring. +// This tests the false-positive scenario: ALICE should NOT appear in results +// for a query that searches by participant, because she is neither from_addr +// nor to_addr. +const memoContainsAliceKey = { + event_type: "receipt", + contract_id: "CDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + ledger_sequence: 102, + emitted_at: "2025-01-01T00:00:02Z", + from_addr: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to_addr: "GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + payload: { + from: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to: "GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + amount: "200", + memo: `Payment forwarded from ${ALICE} via batch`, + }, +}; + +// An event where ALICE is the recipient (to_addr) only +const toAliceOnly = { + event_type: "escrow_claim", + contract_id: "CDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ123456", + ledger_sequence: 103, + emitted_at: "2025-01-01T00:00:03Z", + from_addr: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to_addr: ALICE, + payload: { + from: "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + to: ALICE, + amount: "300", + }, +}; + +const ALL_EVENTS = [ + aliceToBobTip, + bobToAliceReceipt, + unrelatedEvent, + memoContainsAliceKey, + toAliceOnly, +]; + +// ─── Setup / teardown ──────────────────────────────────────────────────────── + +beforeEach(() => { + eventIndexer._resetForTest(); + eventIndexer._seedForTest(ALL_EVENTS); +}); + +afterAll(() => { + eventIndexer._resetForTest(); +}); + +// ─── queryEventsByPublicKey ─────────────────────────────────────────────────── + +describe("queryEventsByPublicKey — exact column match (issue #889)", () => { + it("returns only events where ALICE is from_addr or to_addr", async () => { + const { events, total } = await eventIndexer.queryEventsByPublicKey(ALICE); + + // ALICE appears in: aliceToBobTip (from), bobToAliceReceipt (to), + // toAliceOnly (to). NOT in unrelatedEvent or memoContainsAliceKey. + expect(total).toBe(3); + const ids = events.map((e) => e.ledger_sequence); + expect(ids).toContain(100); // aliceToBobTip + expect(ids).toContain(101); // bobToAliceReceipt + expect(ids).toContain(103); // toAliceOnly + expect(ids).not.toContain(99); // unrelatedEvent + expect(ids).not.toContain(102); // memoContainsAliceKey + }); + + it("does NOT return events where ALICE only appears in a memo/payload field", async () => { + const { events } = await eventIndexer.queryEventsByPublicKey(ALICE); + const ledgerSequences = events.map((e) => e.ledger_sequence); + // The event at ledger 102 has ALICE's key in the memo — must not match + expect(ledgerSequences).not.toContain(102); + }); + + it("returns events for BOB as well", async () => { + const { events, total } = await eventIndexer.queryEventsByPublicKey(BOB); + + // BOB appears in: aliceToBobTip (to), bobToAliceReceipt (from) + expect(total).toBe(2); + const ids = events.map((e) => e.ledger_sequence); + expect(ids).toContain(100); // aliceToBobTip + expect(ids).toContain(101); // bobToAliceReceipt + }); + + it("returns empty for a completely unknown public key", async () => { + const UNKNOWN = "G123456789012345678901234567890123456789012345678901234"; + const { events, total } = await eventIndexer.queryEventsByPublicKey(UNKNOWN); + expect(total).toBe(0); + expect(events).toEqual([]); + }); + + it("returns events in descending ledger order", async () => { + const { events } = await eventIndexer.queryEventsByPublicKey(ALICE); + const ledgers = events.map((e) => e.ledger_sequence); + // Should be descending: 103, 101, 100 + expect(ledgers).toEqual([103, 101, 100]); + }); +}); + +// ─── queryEventsByType ──────────────────────────────────────────────────────── + +describe("queryEventsByType — exact column match (issue #889)", () => { + it("returns only tip events where ALICE is a participant", async () => { + const { events, total } = await eventIndexer.queryEventsByType(ALICE, "tip"); + + // Only aliceToBobTip matches: event_type=tip AND ALICE is participant + expect(total).toBe(1); + expect(events[0].ledger_sequence).toBe(100); + }); + + it("returns only receipt events where ALICE is a participant", async () => { + const { events, total } = await eventIndexer.queryEventsByType(ALICE, "receipt"); + + // Only bobToAliceReceipt matches (ALICE is to_addr) + expect(total).toBe(1); + expect(events[0].ledger_sequence).toBe(101); + }); + + it("does NOT return receipt events where ALICE appears only in a memo", async () => { + const { events } = await eventIndexer.queryEventsByType(ALICE, "receipt"); + const ledgers = events.map((e) => e.ledger_sequence); + // ledger 102 (memoContainsAliceKey) should NOT appear + expect(ledgers).not.toContain(102); + }); + + it("returns 0 for an event type ALICE has no events for", async () => { + const { events, total } = await eventIndexer.queryEventsByType(ALICE, "stream_open"); + expect(total).toBe(0); + expect(events).toEqual([]); + }); +}); + +// ─── getEventStats ──────────────────────────────────────────────────────────── + +describe("getEventStats — exact column match (issue #889)", () => { + it("returns correct breakdown for ALICE", async () => { + const stats = await eventIndexer.getEventStats(ALICE); + const map = Object.fromEntries(stats.map((s) => [s.event_type, s.count])); + + // ALICE is participant in: tip(1), receipt(1), escrow_claim(1) + expect(map.tip).toBe(1); + expect(map.receipt).toBe(1); + expect(map.escrow_claim).toBe(1); + // Not in unrelatedEvent or memoContainsAliceKey + expect(Object.values(map).reduce((a, b) => a + b, 0)).toBe(3); + }); + + it("does NOT count events where the key only appears in a memo", async () => { + const stats = await eventIndexer.getEventStats(ALICE); + const totalEvents = stats.reduce((sum, s) => sum + s.count, 0); + // There are 5 total events, but only 3 involve ALICE as participant. + // The memo-containing event (ledger 102) must not inflate the count. + expect(totalEvents).toBe(3); + }); + + it("returns correct breakdown for BOB", async () => { + const stats = await eventIndexer.getEventStats(BOB); + const map = Object.fromEntries(stats.map((s) => [s.event_type, s.count])); + + // BOB is participant in: tip(1), receipt(1) + expect(map.tip).toBe(1); + expect(map.receipt).toBe(1); + expect(Object.values(map).reduce((a, b) => a + b, 0)).toBe(2); + }); + + it("returns empty array for unknown public key", async () => { + const UNKNOWN = "G123456789012345678901234567890123456789012345678901234"; + const stats = await eventIndexer.getEventStats(UNKNOWN); + expect(stats).toEqual([]); + }); +}); diff --git a/backend/migrations/028_events_participants_index.js b/backend/migrations/028_events_participants_index.js new file mode 100644 index 00000000..f37f7722 --- /dev/null +++ b/backend/migrations/028_events_participants_index.js @@ -0,0 +1,31 @@ +/** + * Migration 028: Add composite index on (from_addr, to_addr) for participant queries. + * + * The event indexer's query helpers (queryEventsByPublicKey, queryEventsByType, + * getEventStats) will be rewritten to match participants via exact column + * lookups instead of ILIKE on the serialized JSON payload. This composite + * index makes those lookups fast and eliminates the full-table scan. + */ + +exports.up = async function (knex) { + const client = knex.client.config.client; + + if (client === "pg" || client === "postgresql") { + await knex.schema.raw( + `CREATE INDEX IF NOT EXISTS idx_events_participants + ON contract_events (from_addr, to_addr)`, + ); + } else { + // SQLite — composite index works the same way. + await knex.schema.raw( + `CREATE INDEX IF NOT EXISTS idx_events_participants + ON contract_events (from_addr, to_addr)`, + ); + } +}; + +exports.down = async function (knex) { + await knex.schema.raw( + `DROP INDEX IF EXISTS idx_events_participants`, + ); +}; diff --git a/backend/src/services/eventIndexer.js b/backend/src/services/eventIndexer.js index 7493cc2e..d49658d2 100644 --- a/backend/src/services/eventIndexer.js +++ b/backend/src/services/eventIndexer.js @@ -13,6 +13,8 @@ * stellarService.js). * - When DATABASE_URL is not set the indexer stores events in an in-memory * buffer so the API remains functional in CI / dev without PostgreSQL. + * The in-memory fallback uses exact column match on from_addr/to_addr + * (best-effort — no index, but correct results). * * Event types emitted by the contract (see lib.rs): * init, admin_transfer, paused, unpaused, pauser_set, upgraded, @@ -461,7 +463,11 @@ function isRunning() { /** * Query events filtered by event type for a given public key. * - * @param {string} publicKey - Stellar public key (GÔǪ) + * Uses exact-match on from_addr / to_addr columns (indexed) instead of + * ILIKE on the serialized JSON payload — avoids full-table scans and + * eliminates false matches from substring collisions. + * + * @param {string} publicKey - Stellar public key (G…) * @param {string} eventType - event type to filter by * @param {{ limit?: number, offset?: number, since?: string }} options * @returns {Promise<{ events: Array, total: number }>} @@ -470,8 +476,8 @@ async function queryEventsByType(publicKey, eventType, { limit = 20, offset = 0, const pool = getPgPool(); if (pool) { - let where = `payload::text ILIKE $1 AND event_type = $2`; - const params = [`%${publicKey}%`, eventType]; + let where = `(from_addr = $1 OR to_addr = $2) AND event_type = $3`; + const params = [publicKey, publicKey, eventType]; if (since) { where += ` AND emitted_at >= $${params.length + 1}`; @@ -501,10 +507,11 @@ async function queryEventsByType(publicKey, eventType, { limit = 20, offset = 0, }; } - // In-memory fallback - let filtered = memoryStore.filter((ev) => { - const payloadStr = JSON.stringify(ev.payload).toLowerCase(); - const match = payloadStr.includes(publicKey.toLowerCase()) && ev.event_type === eventType; + // In-memory fallback (best-effort substring match — see module header) + const filtered = memoryStore.filter((ev) => { + const match = + (ev.from_addr === publicKey || ev.to_addr === publicKey) && + ev.event_type === eventType; if (!match) return false; if (since && new Date(ev.emitted_at) < new Date(since)) return false; return true; @@ -522,8 +529,9 @@ async function queryEventsByType(publicKey, eventType, { limit = 20, offset = 0, /** * Query events where a given public key appears as a participant. * - * Looks for the public key in payload->>'from' or payload->>'to', - * or nested within payload.topics and payload.data fields. + * Uses exact-match on from_addr / to_addr columns (indexed) instead of + * ILIKE on the serialized JSON payload — avoids full-table scans and + * eliminates false matches from substring collisions. * * @param {string} publicKey - Stellar public key (G…) * @param {{ limit?: number, offset?: number }} options @@ -533,8 +541,8 @@ async function queryEventsByPublicKey(publicKey, { limit = 20, offset = 0, since const pool = getPgPool(); if (pool) { - let where = `payload::text ILIKE $1`; - const params = [`%${publicKey}%`]; + let where = `from_addr = $1 OR to_addr = $2`; + const params = [publicKey, publicKey]; if (since) { where += ` AND emitted_at >= $${params.length + 1}`; @@ -564,10 +572,9 @@ async function queryEventsByPublicKey(publicKey, { limit = 20, offset = 0, since }; } - // In-memory fallback + // In-memory fallback (best-effort — exact match on parsed columns) const filtered = memoryStore.filter((ev) => { - const payloadStr = JSON.stringify(ev.payload).toLowerCase(); - return payloadStr.includes(publicKey.toLowerCase()); + return ev.from_addr === publicKey || ev.to_addr === publicKey; }); const paged = filtered @@ -582,6 +589,9 @@ async function queryEventsByPublicKey(publicKey, { limit = 20, offset = 0, since /** * Get aggregate counts grouped by event type for a given public key. * + * Uses exact-match on from_addr / to_addr columns (indexed) instead of + * ILIKE on the serialized JSON payload. + * * @param {string} publicKey * @returns {Promise>} */ @@ -592,19 +602,18 @@ async function getEventStats(publicKey) { const result = await pool.query( `SELECT event_type, COUNT(*) AS count FROM contract_events - WHERE payload::text ILIKE $1 + WHERE from_addr = $1 OR to_addr = $2 GROUP BY event_type ORDER BY count DESC`, - [`%${publicKey}%`], + [publicKey, publicKey], ); return result.rows; } - // In-memory fallback + // In-memory fallback (best-effort — exact match on parsed columns) const counts = {}; for (const ev of memoryStore) { - const payloadStr = JSON.stringify(ev.payload).toLowerCase(); - if (payloadStr.includes(publicKey.toLowerCase())) { + if (ev.from_addr === publicKey || ev.to_addr === publicKey) { counts[ev.event_type] = (counts[ev.event_type] || 0) + 1; } } @@ -659,4 +668,19 @@ module.exports = { pgPool = null; } }, + + /** + * Seed events directly into the in-memory store for testing. + * Each entry should have from_addr, to_addr, event_type, payload, etc. + * @param {Array} events + */ + _seedForTest: (events) => { + for (const ev of events) { + memoryStore.push({ + id: memoryIdCounter++, + ...ev, + created_at: ev.created_at || new Date().toISOString(), + }); + } + }, }; From 86005ed2d2b96731acddee9b9a9dcd394bde5a61 Mon Sep 17 00:00:00 2001 From: Francisco Campos Date: Tue, 25 Aug 2026 21:48:02 -0600 Subject: [PATCH 2/2] ci: fix invalid use of secrets context in step-level if expressions GitHub Actions does not allow the `secrets` context inside step-level `if:` conditionals, which made contract-type-check.yml fail workflow validation. Expose TESTNET_CONTRACT_ID as a job-level env var and gate the steps on `env.*` instead. --- .github/workflows/contract-type-check.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/contract-type-check.yml b/.github/workflows/contract-type-check.yml index fb45e25d..a8df40dc 100644 --- a/.github/workflows/contract-type-check.yml +++ b/.github/workflows/contract-type-check.yml @@ -11,6 +11,10 @@ jobs: name: Contract Bindings runs-on: ubuntu-latest continue-on-error: true + # The `secrets` context is not allowed inside step-level `if:` expressions, + # so expose the secret as an env var here and gate steps on `env.*` below. + env: + TESTNET_CONTRACT_ID: ${{ secrets.TESTNET_CONTRACT_ID }} defaults: run: working-directory: frontend @@ -34,15 +38,13 @@ jobs: key: ${{ runner.os }}-cargo-bindings-${{ hashFiles('contracts/finchippay-contract/Cargo.lock') }} - run: cargo install --locked stellar-cli - name: Generate bindings - if: ${{ secrets.TESTNET_CONTRACT_ID != '' }} + if: ${{ env.TESTNET_CONTRACT_ID != '' }} run: bash ../scripts/gen-contract-bindings.sh - env: - CONTRACT_ID: ${{ secrets.TESTNET_CONTRACT_ID }} - name: Check drift - if: ${{ secrets.TESTNET_CONTRACT_ID != '' }} + if: ${{ env.TESTNET_CONTRACT_ID != '' }} run: | git diff --exit-code frontend/lib/contract-bindings/ \ || (echo "ERROR: Generated contract bindings differ. Run scripts/gen-contract-bindings.sh and commit." && exit 1) - name: Skip (no CONTRACT_ID) - if: ${{ secrets.TESTNET_CONTRACT_ID == '' }} + if: ${{ env.TESTNET_CONTRACT_ID == '' }} run: echo "::notice::Skipping - TESTNET_CONTRACT_ID not set."