From 83f32df57739c7f1db705a5232b57870d5d4efef Mon Sep 17 00:00:00 2001 From: feyishola Date: Sun, 21 Jun 2026 23:34:28 +0100 Subject: [PATCH] Rewrote the three affected methods in the compiled JS to match the TS source --- .../__tests__/event-replay.unit.spec.ts | 317 ++++++++++++++++++ ...soroban-event-indexer.service.unit.spec.ts | 6 +- .../soroban-event.parser.unit.spec.ts | 85 +++++ .../src/ingestion/admin-event.repository.ts | 1 + .../src/ingestion/escrow-event.repository.js | 1 + .../src/ingestion/escrow-event.repository.ts | 1 + app/backend/src/ingestion/event-schema.js | 18 +- app/backend/src/ingestion/event-schema.ts | 18 +- .../src/ingestion/privacy-event.repository.ts | 1 + .../soroban-event-indexer.service.js | 227 +++++-------- .../src/ingestion/soroban-event.parser.js | 19 ++ .../src/ingestion/soroban-event.parser.ts | 34 ++ .../src/ingestion/stealth-event.repository.ts | 1 + .../ingestion/types/contract-event.types.ts | 8 + app/contract/contracts/Folder/src/events.rs | 111 +++++- app/contract/contracts/Folder/src/metadata.rs | 22 +- app/contract/contracts/Folder/src/test.rs | 3 + 17 files changed, 698 insertions(+), 175 deletions(-) create mode 100644 app/backend/src/ingestion/__tests__/event-replay.unit.spec.ts diff --git a/app/backend/src/ingestion/__tests__/event-replay.unit.spec.ts b/app/backend/src/ingestion/__tests__/event-replay.unit.spec.ts new file mode 100644 index 000000000..1161dbd3e --- /dev/null +++ b/app/backend/src/ingestion/__tests__/event-replay.unit.spec.ts @@ -0,0 +1,317 @@ +/** + * Tests for deterministic contract event replay metadata. + * + * Validates that: + * 1. Duplicate event deliveries are handled idempotently via ON CONFLICT DO NOTHING. + * 2. Out-of-order event deliveries are processed correctly using paging_token ordering. + * 3. The `contractLedgerSequence` field enables cross-validation between the + * contract-reported ledger and the Horizon-reported ledger. + * 4. Repository upserts are safe to call multiple times with the same event. + */ + +import { xdr, nativeToScVal } from "@stellar/stellar-sdk"; +import { SorobanEventParser, RawHorizonContractEvent } from "../soroban-event.parser"; +import { RustAcademy_EVENT_SCHEMA_VERSION, RustAcademy_EVENT_TOPICS } from "../event-schema"; +import type { EscrowDepositedEvent } from "../types/contract-event.types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function symVal(s: string): xdr.ScVal { + return xdr.ScVal.scvSymbol(s); +} + +function addressVal(pubkey: string): xdr.ScVal { + return nativeToScVal(pubkey); +} + +function bytesVal(hex: string): xdr.ScVal { + return xdr.ScVal.scvBytes(Buffer.from(hex, "hex")); +} + +function mapVal(entries: Record): xdr.ScVal { + const mapEntries = Object.entries(entries).map( + ([k, v]) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(k), val: v }), + ); + return xdr.ScVal.scvMap(mapEntries); +} + +function makeEscrowDepositedRaw( + ledger: number, + pagingToken: string, + txHash: string, + contractLedger?: number, +): RawHorizonContractEvent { + const topics = [ + symVal(RustAcademy_EVENT_TOPICS.escrow), + symVal("EscrowDeposited"), + bytesVal("deadbeef".repeat(8)), + addressVal("GDQERHRWJYV7JHRP5V7DWJVI6Y5ABZP3YRH7DKYJRBEGJQKE6IQEOSY2"), + ]; + const data = mapVal({ + amount_due: nativeToScVal(5_000_000n, { type: "i128" }), + amount_paid: nativeToScVal(5_000_000n, { type: "i128" }), + expires_at: nativeToScVal(1800000000n, { type: "u64" }), + ledger_sequence: nativeToScVal(contractLedger ?? ledger, { type: "u32" }), + schema_version: nativeToScVal(RustAcademy_EVENT_SCHEMA_VERSION, { type: "u32" }), + timestamp: nativeToScVal(1700000000n, { type: "u64" }), + token: addressVal("CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"), + }); + return { + id: pagingToken, + paging_token: pagingToken, + transaction_hash: txHash, + ledger, + created_at: "2026-01-01T00:00:00Z", + contract_id: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + type: "contract", + topic: topics.map((v) => v.toXDR("base64")), + value: { xdr: data.toXDR("base64") }, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("Event Replay – Replay Metadata Extraction", () => { + let parser: SorobanEventParser; + + beforeEach(() => { + parser = new SorobanEventParser(); + }); + + it("includes contractLedgerSequence in parsed event when ledger_sequence is present", () => { + const raw = makeEscrowDepositedRaw(100, "100-1", "txabc", 100); + const result = parser.parse(raw); + + expect(result).not.toBeNull(); + expect(result!.contractLedgerSequence).toBe(100); + expect(result!.ledgerSequence).toBe(100); + }); + + it("contractLedgerSequence matches the paging_token ledger component for valid events", () => { + const ledger = 250; + const raw = makeEscrowDepositedRaw(ledger, `${ledger}-3`, "txdef", ledger); + const result = parser.parse(raw); + + expect(result).not.toBeNull(); + expect(result!.contractLedgerSequence).toBe(ledger); + expect(result!.ledgerSequence).toBe(ledger); + // paging_token encodes {ledger}-{event_index} + expect(result!.pagingToken.startsWith(String(ledger))).toBe(true); + }); + + it("deduplication key components are all present in the parsed event", () => { + const raw = makeEscrowDepositedRaw(100, "100-2", "txghi", 100); + const result = parser.parse(raw) as EscrowDepositedEvent | null; + + expect(result).not.toBeNull(); + // All three components of the stable dedup key must be populated + expect(result!.txHash).toBe("txghi"); + expect(result!.pagingToken).toBe("100-2"); + expect(result!.contractLedgerSequence).toBeDefined(); + }); +}); + +describe("Event Replay – Duplicate Delivery Detection", () => { + let parser: SorobanEventParser; + + beforeEach(() => { + parser = new SorobanEventParser(); + }); + + it("parsing the same raw event twice produces identical output (idempotent parsing)", () => { + const raw = makeEscrowDepositedRaw(100, "100-1", "tx-dup", 100); + + const first = parser.parse(raw); + const second = parser.parse(raw); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + + // Every field must be identical — deterministic parsing is the precondition + // for safe database-level deduplication via ON CONFLICT DO NOTHING. + expect(first!.txHash).toBe(second!.txHash); + expect(first!.pagingToken).toBe(second!.pagingToken); + expect(first!.ledgerSequence).toBe(second!.ledgerSequence); + expect(first!.contractLedgerSequence).toBe(second!.contractLedgerSequence); + expect(first!.contractTimestamp).toBe(second!.contractTimestamp); + expect(first!.schemaVersion).toBe(second!.schemaVersion); + expect(first!.eventType).toBe(second!.eventType); + }); + + it("same event with different paging_tokens (Horizon shard re-delivery) produces same identity fields", () => { + // Simulate Horizon delivering the same on-chain event via two cursor paths. + // The tx_hash and contract_ledger_sequence stay the same; paging_token may differ. + const event1 = makeEscrowDepositedRaw(100, "100-1", "tx-same", 100); + // Horizon re-delivery from a different shard cursor has the same ledger/tx but different token + const event2 = { ...event1, paging_token: "100-1-alt" }; + + const result1 = parser.parse(event1); + const result2 = parser.parse(event2); + + expect(result1).not.toBeNull(); + expect(result2).not.toBeNull(); + + // Core identity (used by ON CONFLICT constraints) is identical + expect(result1!.txHash).toBe(result2!.txHash); + expect(result1!.contractLedgerSequence).toBe(result2!.contractLedgerSequence); + expect(result1!.eventType).toBe(result2!.eventType); + + // The contract-reported ledger matches both (no mismatch) + expect(result1!.contractLedgerSequence).toBe(100); + expect(result2!.contractLedgerSequence).toBe(100); + }); + + it("repository upsert logic: ON CONFLICT columns cover all identity dimensions", () => { + // Verify the deduplication key used by escrow-event.repository + // includes tx_hash + commitment + event_type, all of which are derivable + // from the parsed event. + const raw = makeEscrowDepositedRaw(100, "100-1", "tx-repo", 100); + const event = parser.parse(raw) as EscrowDepositedEvent | null; + + expect(event).not.toBeNull(); + expect(event!.txHash).toBeDefined(); // tx_hash + expect(event!.commitment).toBeDefined(); // commitment (escrow_id) + expect(event!.eventType).toBe("EscrowDeposited"); // event_type + // bonus: contract-provided ledger for cross-validation + expect(event!.contractLedgerSequence).toBe(100); + }); +}); + +describe("Event Replay – Out-of-Order Delivery", () => { + let parser: SorobanEventParser; + + beforeEach(() => { + parser = new SorobanEventParser(); + }); + + it("processes events from a later ledger before an earlier ledger without errors", () => { + // Simulate events arriving out of ledger order (ledger 105 before 100). + const late = makeEscrowDepositedRaw(105, "105-1", "tx-late", 105); + const early = makeEscrowDepositedRaw(100, "100-1", "tx-early", 100); + + const resultLate = parser.parse(late); + const resultEarly = parser.parse(early); + + expect(resultLate).not.toBeNull(); + expect(resultEarly).not.toBeNull(); + + // Each event carries its own ledger identity – they remain distinct + expect(resultLate!.ledgerSequence).toBe(105); + expect(resultLate!.contractLedgerSequence).toBe(105); + expect(resultEarly!.ledgerSequence).toBe(100); + expect(resultEarly!.contractLedgerSequence).toBe(100); + }); + + it("paging_token provides a stable total ordering for out-of-order events", () => { + const events = [ + makeEscrowDepositedRaw(103, "103-1", "tx-103", 103), + makeEscrowDepositedRaw(101, "101-2", "tx-101", 101), + makeEscrowDepositedRaw(102, "102-1", "tx-102", 102), + ]; + + const parsed = events.map((e) => parser.parse(e)!); + expect(parsed.every(Boolean)).toBe(true); + + // Sort by paging_token to recover ledger order + const ordered = [...parsed].sort((a, b) => + a.pagingToken.localeCompare(b.pagingToken), + ); + + expect(ordered[0].ledgerSequence).toBe(101); + expect(ordered[1].ledgerSequence).toBe(102); + expect(ordered[2].ledgerSequence).toBe(103); + }); + + it("out-of-order events still carry valid contractLedgerSequence for cross-validation", () => { + const outOfOrder = [ + makeEscrowDepositedRaw(200, "200-1", "tx-200", 200), + makeEscrowDepositedRaw(150, "150-1", "tx-150", 150), + makeEscrowDepositedRaw(175, "175-1", "tx-175", 175), + ]; + + for (const raw of outOfOrder) { + const parsed = parser.parse(raw); + expect(parsed).not.toBeNull(); + // Each event's contractLedgerSequence must match its own Horizon ledger + expect(parsed!.contractLedgerSequence).toBe(parsed!.ledgerSequence); + } + }); +}); + +describe("Event Replay – Idempotent Ingestion (Repository Layer)", () => { + it("upsertEvent called twice with the same event calls the DB upsert twice but ON CONFLICT ensures single write", async () => { + // Mock the supabase client to simulate ON CONFLICT DO NOTHING behavior. + let insertCount = 0; + const mockUpsert = jest.fn().mockImplementation(() => { + insertCount++; + // ON CONFLICT DO NOTHING: second call succeeds but inserts nothing + return Promise.resolve({ error: null, data: null, count: insertCount === 1 ? 1 : 0 }); + }); + + const mockClient = { + from: jest.fn().mockReturnValue({ upsert: mockUpsert }), + }; + + // Import the repository dynamically to allow constructor injection + const { EscrowEventRepository } = await import("../escrow-event.repository"); + const repo = new EscrowEventRepository({ + getClient: () => mockClient, + } as never); + + const parser = new SorobanEventParser(); + const raw = makeEscrowDepositedRaw(100, "100-1", "tx-idem", 100); + const event = parser.parse(raw) as EscrowDepositedEvent; + expect(event).not.toBeNull(); + + // First delivery + await repo.upsertEvent(event); + expect(mockUpsert).toHaveBeenCalledTimes(1); + + // Simulated duplicate delivery (same event, same paging_token) + await repo.upsertEvent(event); + expect(mockUpsert).toHaveBeenCalledTimes(2); + + // The upsert was always called with ignoreDuplicates: true + expect(mockUpsert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ tx_hash: "tx-idem", event_type: "EscrowDeposited" }), + expect.objectContaining({ ignoreDuplicates: true }), + ); + expect(mockUpsert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ tx_hash: "tx-idem", event_type: "EscrowDeposited" }), + expect.objectContaining({ ignoreDuplicates: true }), + ); + }); + + it("upsertEvent persists contract_ledger_sequence alongside tx_hash and paging_token", async () => { + const capturedRow: Record[] = []; + const mockUpsert = jest.fn().mockImplementation((row: Record) => { + capturedRow.push(row); + return Promise.resolve({ error: null }); + }); + + const mockClient = { + from: jest.fn().mockReturnValue({ upsert: mockUpsert }), + }; + + const { EscrowEventRepository } = await import("../escrow-event.repository"); + const repo = new EscrowEventRepository({ + getClient: () => mockClient, + } as never); + + const parser = new SorobanEventParser(); + const raw = makeEscrowDepositedRaw(120, "120-2", "tx-meta", 120); + const event = parser.parse(raw) as EscrowDepositedEvent; + + await repo.upsertEvent(event); + + expect(capturedRow[0]).toMatchObject({ + tx_hash: "tx-meta", + ledger_sequence: 120, + paging_token: "120-2", + contract_ledger_sequence: 120, + event_type: "EscrowDeposited", + }); + }); +}); diff --git a/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts b/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts index 2f11174e5..4e89a553e 100644 --- a/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts +++ b/app/backend/src/ingestion/__tests__/soroban-event-indexer.service.unit.spec.ts @@ -111,7 +111,7 @@ describe("SorobanEventIndexerService - Resiliency & Hardening", () => { _embedded: { records: recordsPage1 }, _links: { next: { href: "https://horizon.stellar.org/contract_events?cursor=100-1" } }, }), - } as Response); + } as unknown as Response); await service.indexLedgerRange(CONTRACT_ID, 100, 105, undefined, false); @@ -143,7 +143,7 @@ describe("SorobanEventIndexerService - Resiliency & Hardening", () => { _embedded: { records: recordsPage2 }, _links: {}, }), - } as Response); + } as unknown as Response); const recoveryResult = await service.indexLedgerRange(CONTRACT_ID, 100, 105, undefined, false); expect(recoveryResult.processed).toBe(1); @@ -170,7 +170,7 @@ describe("SorobanEventIndexerService - Resiliency & Hardening", () => { formData: jest.fn(), text: jest.fn(), json: async () => ({ _embedded: { records }, _links: {} }), - } as Response); + } as unknown as Response); await service.indexLedgerRange(CONTRACT_ID, 100, 105, { previousContractId: prevContract, diff --git a/app/backend/src/ingestion/__tests__/soroban-event.parser.unit.spec.ts b/app/backend/src/ingestion/__tests__/soroban-event.parser.unit.spec.ts index 7f57178ce..289a8f09d 100644 --- a/app/backend/src/ingestion/__tests__/soroban-event.parser.unit.spec.ts +++ b/app/backend/src/ingestion/__tests__/soroban-event.parser.unit.spec.ts @@ -231,6 +231,7 @@ describe("SorobanEventParser", () => { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -244,7 +245,91 @@ describe("SorobanEventParser", () => { )) { expect(contract.payloadKeys).toEqual([...contract.payloadKeys].sort()); expect(contract.compatibleVersions).toContain(contract.schemaVersion); + // All v2+ schemas carry the replay metadata field + expect(contract.payloadKeys).toContain("ledger_sequence"); } }); }); + + describe("replay metadata extraction", () => { + it("extracts contractLedgerSequence when ledger_sequence is in payload", () => { + const topics = [ + symVal(RustAcademy_EVENT_TOPICS.escrow), + symVal("EscrowDeposited"), + bytesVal(COMMITMENT_HEX), + addressVal(OWNER), + ]; + const data = mapVal({ + amount_due: nativeToScVal(5_000_000n, { type: "i128" }), + amount_paid: nativeToScVal(5_000_000n, { type: "i128" }), + expires_at: nativeToScVal(1800000000n, { type: "u64" }), + ledger_sequence: nativeToScVal(42, { type: "u32" }), + schema_version: nativeToScVal(RustAcademy_EVENT_SCHEMA_VERSION, { type: "u32" }), + timestamp: nativeToScVal(1700000000n, { type: "u64" }), + token: addressVal(TOKEN), + }); + + const result = parser.parse(makeRaw(topics, data, { ledger: 42 })); + expect(result).not.toBeNull(); + expect(result!.contractLedgerSequence).toBe(42); + }); + + it("sets contractLedgerSequence to undefined for legacy events without the field", () => { + const topics = [ + symVal("EscrowDeposited"), + bytesVal(COMMITMENT_HEX), + addressVal(OWNER), + ]; + const data = mapVal({ + token: addressVal(TOKEN), + amount: nativeToScVal(5_000_000n, { type: "i128" }), + expires_at: nativeToScVal(1800000000n, { type: "u64" }), + timestamp: nativeToScVal(1700000000n, { type: "u64" }), + // no ledger_sequence field (legacy v1 event) + }); + + const result = parser.parse(makeRaw(topics, data)); + expect(result).not.toBeNull(); + expect(result!.contractLedgerSequence).toBeUndefined(); + }); + + it("still parses the event but warns when contract ledger_sequence mismatches Horizon ledger", () => { + const warnSpy = jest.spyOn( + (parser as unknown as { logger: { warn: jest.Mock } }).logger, + "warn", + ); + + const topics = [ + symVal(RustAcademy_EVENT_TOPICS.escrow), + symVal("EscrowDeposited"), + bytesVal(COMMITMENT_HEX), + addressVal(OWNER), + ]; + const data = mapVal({ + amount_due: nativeToScVal(1_000n, { type: "i128" }), + amount_paid: nativeToScVal(1_000n, { type: "i128" }), + expires_at: nativeToScVal(9999999n, { type: "u64" }), + // contract says ledger 99 but Horizon reports ledger 100 (mismatch) + ledger_sequence: nativeToScVal(99, { type: "u32" }), + schema_version: nativeToScVal(RustAcademy_EVENT_SCHEMA_VERSION, { type: "u32" }), + timestamp: nativeToScVal(1700000000n, { type: "u64" }), + token: addressVal(TOKEN), + }); + + // Horizon ledger = 100, contract says 99 + const result = parser.parse(makeRaw(topics, data, { ledger: 100 })); + + // Event must still be returned (mismatch is advisory, not fatal) + expect(result).not.toBeNull(); + expect(result!.contractLedgerSequence).toBe(99); + expect(result!.ledgerSequence).toBe(100); + + // Mismatch must be logged for downstream monitoring + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Replay metadata mismatch"), + ); + + warnSpy.mockRestore(); + }); + }); }); diff --git a/app/backend/src/ingestion/admin-event.repository.ts b/app/backend/src/ingestion/admin-event.repository.ts index c1d25a545..d965637a5 100644 --- a/app/backend/src/ingestion/admin-event.repository.ts +++ b/app/backend/src/ingestion/admin-event.repository.ts @@ -27,6 +27,7 @@ export class AdminEventRepository { tx_hash: event.txHash, ledger_sequence: event.ledgerSequence, paging_token: event.pagingToken, + contract_ledger_sequence: event.contractLedgerSequence ?? null, }, { onConflict: "tx_hash,event_type", ignoreDuplicates: true }, ); diff --git a/app/backend/src/ingestion/escrow-event.repository.js b/app/backend/src/ingestion/escrow-event.repository.js index c8cd157fa..bdba63007 100644 --- a/app/backend/src/ingestion/escrow-event.repository.js +++ b/app/backend/src/ingestion/escrow-event.repository.js @@ -112,6 +112,7 @@ var EscrowEventRepository = function () { tx_hash: event.txHash, ledger_sequence: event.ledgerSequence, paging_token: event.pagingToken, + contract_ledger_sequence: event.contractLedgerSequence !== undefined ? event.contractLedgerSequence : null, expires_at: event.eventType === "EscrowDeposited" ? new Date(Number(event.expiresAt) * 1000).toISOString() : null, diff --git a/app/backend/src/ingestion/escrow-event.repository.ts b/app/backend/src/ingestion/escrow-event.repository.ts index c5f72a401..e9280a283 100644 --- a/app/backend/src/ingestion/escrow-event.repository.ts +++ b/app/backend/src/ingestion/escrow-event.repository.ts @@ -33,6 +33,7 @@ export class EscrowEventRepository { tx_hash: event.txHash, ledger_sequence: event.ledgerSequence, paging_token: event.pagingToken, + contract_ledger_sequence: event.contractLedgerSequence ?? null, expires_at: event.eventType === "EscrowDeposited" ? new Date( diff --git a/app/backend/src/ingestion/event-schema.js b/app/backend/src/ingestion/event-schema.js index f1435324d..a214c8e30 100644 --- a/app/backend/src/ingestion/event-schema.js +++ b/app/backend/src/ingestion/event-schema.js @@ -9,6 +9,8 @@ exports.RustAcademy_EVENT_TOPICS = { privacy: "TOPIC_PRIVACY", stealth: "TOPIC_STEALTH", }; +// payloadKeys are sorted alphabetically. +// "ledger_sequence" ('l') sorts after 'f*' / 'e*' keys and before 'p*' / 'r*' / 's*' keys. exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { EscrowDeposited: { topic: exports.RustAcademy_EVENT_TOPICS.escrow, @@ -18,6 +20,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -29,7 +32,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.escrow, eventName: "EscrowWithdrawn", indexedFields: ["escrow_id", "owner"], - payloadKeys: ["amount", "fee", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "fee", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -37,7 +40,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.escrow, eventName: "EscrowRefunded", indexedFields: ["escrow_id", "owner"], - payloadKeys: ["amount", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -45,7 +48,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.privacy, eventName: "PrivacyToggled", indexedFields: ["owner"], - payloadKeys: ["enabled", "schema_version", "timestamp"], + payloadKeys: ["enabled", "ledger_sequence", "schema_version", "timestamp"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -53,7 +56,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.admin, eventName: "ContractPaused", indexedFields: ["admin"], - payloadKeys: ["paused", "schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "paused", "schema_version", "timestamp"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -61,7 +64,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.admin, eventName: "AdminChanged", indexedFields: ["old_admin", "new_admin"], - payloadKeys: ["schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "schema_version", "timestamp"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -69,7 +72,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.admin, eventName: "ContractUpgraded", indexedFields: ["new_wasm_hash", "admin"], - payloadKeys: ["schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "schema_version", "timestamp"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [exports.RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -81,6 +84,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -92,7 +96,7 @@ exports.RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: exports.RustAcademy_EVENT_TOPICS.stealth, eventName: "StealthWithdrawn", indexedFields: ["stealth_address", "recipient"], - payloadKeys: ["amount", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: exports.RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [exports.RustAcademy_EVENT_SCHEMA_VERSION], }, diff --git a/app/backend/src/ingestion/event-schema.ts b/app/backend/src/ingestion/event-schema.ts index 61d6e7a78..f649d2eab 100644 --- a/app/backend/src/ingestion/event-schema.ts +++ b/app/backend/src/ingestion/event-schema.ts @@ -20,6 +20,8 @@ export interface EventSchemaContract { compatibleVersions: readonly number[]; } +// payloadKeys are sorted alphabetically. +// "ledger_sequence" ('l') sorts after 'f*' / 'e*' keys and before 'p*' / 'r*' / 's*' keys. export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { EscrowDeposited: { topic: RustAcademy_EVENT_TOPICS.escrow, @@ -29,6 +31,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -40,7 +43,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.escrow, eventName: "EscrowWithdrawn", indexedFields: ["escrow_id", "owner"], - payloadKeys: ["amount", "fee", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "fee", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -48,7 +51,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.escrow, eventName: "EscrowRefunded", indexedFields: ["escrow_id", "owner"], - payloadKeys: ["amount", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -56,7 +59,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.privacy, eventName: "PrivacyToggled", indexedFields: ["owner"], - payloadKeys: ["enabled", "schema_version", "timestamp"], + payloadKeys: ["enabled", "ledger_sequence", "schema_version", "timestamp"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -64,7 +67,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.admin, eventName: "ContractPaused", indexedFields: ["admin"], - payloadKeys: ["paused", "schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "paused", "schema_version", "timestamp"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -72,7 +75,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.admin, eventName: "AdminChanged", indexedFields: ["old_admin", "new_admin"], - payloadKeys: ["schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "schema_version", "timestamp"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [1, RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -80,7 +83,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.admin, eventName: "ContractUpgraded", indexedFields: ["new_wasm_hash", "admin"], - payloadKeys: ["schema_version", "timestamp"], + payloadKeys: ["ledger_sequence", "schema_version", "timestamp"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [RustAcademy_EVENT_SCHEMA_VERSION], }, @@ -92,6 +95,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -103,7 +107,7 @@ export const RustAcademy_EVENT_SCHEMA_CONTRACTS = { topic: RustAcademy_EVENT_TOPICS.stealth, eventName: "StealthWithdrawn", indexedFields: ["stealth_address", "recipient"], - payloadKeys: ["amount", "schema_version", "timestamp", "token"], + payloadKeys: ["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schemaVersion: RustAcademy_EVENT_SCHEMA_VERSION, compatibleVersions: [RustAcademy_EVENT_SCHEMA_VERSION], }, diff --git a/app/backend/src/ingestion/privacy-event.repository.ts b/app/backend/src/ingestion/privacy-event.repository.ts index fe37ba5be..b90f5f2f2 100644 --- a/app/backend/src/ingestion/privacy-event.repository.ts +++ b/app/backend/src/ingestion/privacy-event.repository.ts @@ -21,6 +21,7 @@ export class PrivacyEventRepository { tx_hash: event.txHash, ledger_sequence: event.ledgerSequence, paging_token: event.pagingToken, + contract_ledger_sequence: event.contractLedgerSequence ?? null, }, { onConflict: "tx_hash,event_type,owner", ignoreDuplicates: true }, ); diff --git a/app/backend/src/ingestion/soroban-event-indexer.service.js b/app/backend/src/ingestion/soroban-event-indexer.service.js index b23b66df9..5a7aa6e7e 100644 --- a/app/backend/src/ingestion/soroban-event-indexer.service.js +++ b/app/backend/src/ingestion/soroban-event-indexer.service.js @@ -132,126 +132,96 @@ var SorobanEventIndexerService = function () { * full range (reconciliation mode). Idempotency prevents * duplicate records. */ - SorobanEventIndexerService_1.prototype.indexLedgerRange = function (contractId_1, fromLedger_1, toLedger_1, dualReadConfig_1) { - return __awaiter(this, arguments, void 0, function (contractId, fromLedger, toLedger, dualReadConfig, force) { - var effectiveFrom, _a, inDualReadWindow, logSuffix, processed, persisted, skippedUnknownSchema, previousResult, currentResult; - var _b; - if (force === void 0) { force = false; } - return __generator(this, function (_c) { - switch (_c.label) { - case 0: - if (!force) return [3 /*break*/, 1]; - _a = fromLedger; - return [3 /*break*/, 3]; - case 1: return [4 /*yield*/, this.resolveStartLedger(contractId, fromLedger)]; - case 2: - _a = _c.sent(); - _c.label = 3; - case 3: - effectiveFrom = _a; - if (effectiveFrom > toLedger) { - this.logger.log("Contract ".concat(contractId, ": ledger range [").concat(effectiveFrom, ", ").concat(toLedger, "] already indexed; skipping.")); - return [2 /*return*/, { - fromLedger: fromLedger, - toLedger: toLedger, - processed: 0, - persisted: 0, - skippedUnknownSchema: 0, - }]; - } - inDualReadWindow = this.isInDualReadWindow(effectiveFrom, dualReadConfig); - logSuffix = inDualReadWindow ? " (dual-read mode)" : ""; - this.logger.log("Indexing contract ".concat(contractId, " ledgers [").concat(effectiveFrom, ", ").concat(toLedger, "]").concat(force ? " (force reindex)" : "").concat(logSuffix)); - processed = 0; - persisted = 0; - skippedUnknownSchema = 0; - if (!(inDualReadWindow && (dualReadConfig === null || dualReadConfig === void 0 ? void 0 : dualReadConfig.previousContractId))) return [3 /*break*/, 5]; - return [4 /*yield*/, this.indexContractWithCursor(dualReadConfig.previousContractId, effectiveFrom, (_b = dualReadConfig.effectiveLedger) !== null && _b !== void 0 ? _b : toLedger, undefined)]; - case 4: - previousResult = _c.sent(); - processed += previousResult.processed; - persisted += previousResult.persisted; - skippedUnknownSchema += previousResult.skippedUnknownSchema; - _c.label = 5; - case 5: return [4 /*yield*/, this.indexContractWithCursor(contractId, effectiveFrom, toLedger, undefined)]; - case 6: - currentResult = _c.sent(); - processed += currentResult.processed; - persisted += currentResult.persisted; - skippedUnknownSchema += currentResult.skippedUnknownSchema; - this.logger.log("Indexed contract ".concat(contractId, " [").concat(effectiveFrom, ", ").concat(toLedger, "]: ") + - "processed=".concat(processed, " persisted=").concat(persisted, " skippedUnknownSchema=").concat(skippedUnknownSchema)); - return [2 /*return*/, { - fromLedger: effectiveFrom, - toLedger: toLedger, - processed: processed, - persisted: persisted, - skippedUnknownSchema: skippedUnknownSchema, - }]; + SorobanEventIndexerService_1.prototype.indexLedgerRange = async function (contractId, fromLedger, toLedger, dualReadConfig, force) { + if (force === void 0) { force = false; } + var network = this.config.network; + var processed = 0; + var persisted = 0; + var skippedUnknownSchema = 0; + var inDualReadWindow = this.isInDualReadWindow(fromLedger, dualReadConfig); + if (inDualReadWindow && (dualReadConfig === null || dualReadConfig === void 0 ? void 0 : dualReadConfig.previousContractId)) { + var _a; + var prevResult = await this.runIndexingEngine( + dualReadConfig.previousContractId, + fromLedger, + (_a = dualReadConfig.effectiveLedger) !== null && _a !== void 0 ? _a : toLedger, + network, + "dual-read-previous", + force + ); + processed += prevResult.processed; + persisted += prevResult.persisted; + skippedUnknownSchema += prevResult.skippedUnknownSchema; + } + var currentMode = inDualReadWindow ? "dual-read-current" : "normal"; + var currentResult = await this.runIndexingEngine(contractId, fromLedger, toLedger, network, currentMode, force); + processed += currentResult.processed; + persisted += currentResult.persisted; + skippedUnknownSchema += currentResult.skippedUnknownSchema; + return { fromLedger: fromLedger, toLedger: toLedger, processed: processed, persisted: persisted, skippedUnknownSchema: skippedUnknownSchema }; + }; + SorobanEventIndexerService_1.prototype.runIndexingEngine = async function (contractId, fromLedger, toLedger, network, mode, force) { + var currentCursor = null; + var startLedgerValue = fromLedger; + if (!force) { + var checkpoint = await this.checkpointRepo.getCheckpoint(contractId, network, mode); + if (checkpoint) { + if (checkpoint.lastLedger >= toLedger && !checkpoint.pagingToken) { + this.logger.log("Range [".concat(fromLedger, ", ").concat(toLedger, "] already fully indexed for stream ").concat(mode, ".")); + return { processed: 0, persisted: 0, skippedUnknownSchema: 0 }; } - }); - }); + startLedgerValue = checkpoint.lastLedger; + currentCursor = checkpoint.pagingToken; + } + } + return this.indexContractWithCursor(contractId, startLedgerValue, toLedger, network, mode, currentCursor); }; - SorobanEventIndexerService_1.prototype.indexContractWithCursor = function (contractId, fromLedger, toLedger, cursor) { - return __awaiter(this, void 0, void 0, function () { - var processed, persisted, skippedUnknownSchema, nextCursor, _a, records, returnedCursor, _i, records_1, raw, event_1, lastRecord; - return __generator(this, function (_b) { - switch (_b.label) { - case 0: - processed = 0; - persisted = 0; - skippedUnknownSchema = 0; - nextCursor = cursor; - _b.label = 1; - case 1: - if (!true) return [3 /*break*/, 9]; - return [4 /*yield*/, this.fetchPage(contractId, fromLedger, toLedger, nextCursor)]; - case 2: - _a = _b.sent(), records = _a.records, returnedCursor = _a.nextCursor; - if (records.length === 0) - return [3 /*break*/, 9]; - _i = 0, records_1 = records; - _b.label = 3; - case 3: - if (!(_i < records_1.length)) return [3 /*break*/, 6]; - raw = records_1[_i]; - processed++; - event_1 = this.parser.parse(raw); - if (!event_1) { - skippedUnknownSchema++; - return [3 /*break*/, 5]; - } - return [4 /*yield*/, this.persistEvent(event_1)]; - case 4: - _b.sent(); - persisted++; - this.eventEmitter.emit("stellar.".concat(event_1.eventType), event_1); - _b.label = 5; - case 5: - _i++; - return [3 /*break*/, 3]; - case 6: - lastRecord = records[records.length - 1]; - if (!lastRecord) return [3 /*break*/, 8]; - return [4 /*yield*/, this.checkpointRepo.saveLastLedger(contractId, lastRecord.ledger)]; - case 7: - _b.sent(); - _b.label = 8; - case 8: - if (!returnedCursor || records.length < PAGE_LIMIT) - return [3 /*break*/, 9]; - nextCursor = returnedCursor; - return [3 /*break*/, 1]; - case 9: - // Final checkpoint - return [4 /*yield*/, this.checkpointRepo.saveLastLedger(contractId, toLedger)]; - case 10: - // Final checkpoint - _b.sent(); - return [2 /*return*/, { processed: processed, persisted: persisted, skippedUnknownSchema: skippedUnknownSchema }]; + SorobanEventIndexerService_1.prototype.indexContractWithCursor = async function (contractId, fromLedger, toLedger, network, mode, cursor) { + var processed = 0; + var persisted = 0; + var skippedUnknownSchema = 0; + var nextCursor = cursor || undefined; + while (true) { + var _a = await this.fetchPage(contractId, fromLedger, toLedger, nextCursor); + var records = _a.records; + var returnedCursor = _a.nextCursor; + if (records.length === 0) { + await this.checkpointRepo.saveCheckpoint({ + contractId: contractId, + network: network, + mode: mode, + lastLedger: toLedger, + pagingToken: null, + }); + break; + } + for (var _i = 0; _i < records.length; _i++) { + var raw = records[_i]; + processed++; + var event_1 = this.parser.parse(raw); + if (!event_1) { + skippedUnknownSchema++; + continue; } - }); - }); + await this.persistEvent(event_1); + persisted++; + this.eventEmitter.emit("stellar.".concat(event_1.eventType), event_1); + } + var lastRecord = records[records.length - 1]; + if (lastRecord) { + nextCursor = returnedCursor; + await this.checkpointRepo.saveCheckpoint({ + contractId: contractId, + network: network, + mode: mode, + lastLedger: lastRecord.ledger, + pagingToken: nextCursor || null, + }); + } + if (!returnedCursor || records.length < PAGE_LIMIT) break; + nextCursor = returnedCursor; + } + return { processed: processed, persisted: persisted, skippedUnknownSchema: skippedUnknownSchema }; }; SorobanEventIndexerService_1.prototype.isInDualReadWindow = function (currentLedger, config) { if (!(config === null || config === void 0 ? void 0 : config.previousContractId) || !(config === null || config === void 0 ? void 0 : config.effectiveLedger)) { @@ -259,29 +229,6 @@ var SorobanEventIndexerService = function () { } return currentLedger < config.effectiveLedger; }; - // --------------------------------------------------------------------------- - // Private helpers - // --------------------------------------------------------------------------- - /** - * Returns the ledger to start from, taking the stored checkpoint into account. - * If a checkpoint exists and is ahead of `fromLedger`, we resume from checkpoint+1. - */ - SorobanEventIndexerService_1.prototype.resolveStartLedger = function (contractId, fromLedger) { - return __awaiter(this, void 0, void 0, function () { - var last; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, this.checkpointRepo.getLastLedger(contractId)]; - case 1: - last = _a.sent(); - if (last !== null && last >= fromLedger) { - return [2 /*return*/, last + 1]; - } - return [2 /*return*/, fromLedger]; - } - }); - }); - }; /** * Fetches one page of contract events from Horizon for the given ledger range. * Uses the `start_ledger` + `end_ledger` query params (Horizon v2 API). diff --git a/app/backend/src/ingestion/soroban-event.parser.js b/app/backend/src/ingestion/soroban-event.parser.js index 8f3dfde03..1b3e96024 100644 --- a/app/backend/src/ingestion/soroban-event.parser.js +++ b/app/backend/src/ingestion/soroban-event.parser.js @@ -61,6 +61,12 @@ var SorobanEventParser = /** @class */ (function () { this.logger.warn("Unsupported ".concat(layout.eventName, " schema version ").concat(schemaVersion)); return null; } + var contractLedgerSequence = this.extractLedgerSequenceFromData(dataVal); + if (contractLedgerSequence !== undefined && contractLedgerSequence !== raw.ledger) { + this.logger.warn("Replay metadata mismatch for ".concat(layout.eventName, " paging_token=").concat(raw.paging_token, ": ") + + "contract_ledger_sequence=".concat(contractLedgerSequence, " but Horizon ledger=").concat(raw.ledger, ". ") + + "Event will still be parsed; investigate potential replay tampering."); + } var base = { schemaVersion: schemaVersion, topicNamespace: layout.topicNamespace, @@ -68,6 +74,7 @@ var SorobanEventParser = /** @class */ (function () { ledgerSequence: raw.ledger, pagingToken: raw.paging_token, contractTimestamp: this.extractTimestampFromData(dataVal), + contractLedgerSequence: contractLedgerSequence, }; switch (layout.eventName) { case "EscrowDeposited": @@ -249,6 +256,18 @@ var SorobanEventParser = /** @class */ (function () { } return 0n; }; + SorobanEventParser.prototype.extractLedgerSequenceFromData = function (data) { + try { + var map = this.dataToMap(data); + if (map["ledger_sequence"]) { + return Number((0, stellar_sdk_1.scValToNative)(map["ledger_sequence"])); + } + } + catch (_a) { + // Optional field — absent in legacy v1 events + } + return undefined; + }; return SorobanEventParser; }()); exports.SorobanEventParser = SorobanEventParser; diff --git a/app/backend/src/ingestion/soroban-event.parser.ts b/app/backend/src/ingestion/soroban-event.parser.ts index ee6f6fea7..7f55f0d64 100644 --- a/app/backend/src/ingestion/soroban-event.parser.ts +++ b/app/backend/src/ingestion/soroban-event.parser.ts @@ -106,6 +106,18 @@ export class SorobanEventParser { return null; } + const contractLedgerSequence = this.extractLedgerSequenceFromData(dataVal); + if ( + contractLedgerSequence !== undefined && + contractLedgerSequence !== raw.ledger + ) { + this.logger.warn( + `Replay metadata mismatch for ${layout.eventName} paging_token=${raw.paging_token}: ` + + `contract_ledger_sequence=${contractLedgerSequence} but Horizon ledger=${raw.ledger}. ` + + `Event will still be parsed; investigate potential replay tampering.`, + ); + } + const base = { schemaVersion, topicNamespace: layout.topicNamespace, @@ -113,6 +125,7 @@ export class SorobanEventParser { ledgerSequence: raw.ledger, pagingToken: raw.paging_token, contractTimestamp: this.extractTimestampFromData(dataVal), + contractLedgerSequence, }; switch (layout.eventName) { @@ -512,4 +525,25 @@ export class SorobanEventParser { } return 0n; } + + /** + * Extracts the `ledger_sequence` replay metadata field from the event payload. + * + * This field is emitted by the contract via `env.ledger().sequence()` and lets + * the backend cross-validate the contract-reported ledger against the + * Horizon-reported ledger for tamper / mis-routing detection. + * + * Returns `undefined` for legacy v1 events that pre-date this field. + */ + private extractLedgerSequenceFromData(data: xdr.ScVal): number | undefined { + try { + const map = this.dataToMap(data); + if (map["ledger_sequence"]) { + return Number(scValToNative(map["ledger_sequence"])); + } + } catch { + // Optional field — absent in legacy v1 events + } + return undefined; + } } diff --git a/app/backend/src/ingestion/stealth-event.repository.ts b/app/backend/src/ingestion/stealth-event.repository.ts index 9e047d32a..348e9b854 100644 --- a/app/backend/src/ingestion/stealth-event.repository.ts +++ b/app/backend/src/ingestion/stealth-event.repository.ts @@ -32,6 +32,7 @@ export class StealthEventRepository { tx_hash: event.txHash, ledger_sequence: event.ledgerSequence, paging_token: event.pagingToken, + contract_ledger_sequence: event.contractLedgerSequence ?? null, }, { onConflict: "tx_hash,event_type,stealth_address", ignoreDuplicates: true }, ); diff --git a/app/backend/src/ingestion/types/contract-event.types.ts b/app/backend/src/ingestion/types/contract-event.types.ts index f4d490e94..d5941d479 100644 --- a/app/backend/src/ingestion/types/contract-event.types.ts +++ b/app/backend/src/ingestion/types/contract-event.types.ts @@ -23,6 +23,14 @@ export interface BaseContractEvent { ledgerSequence: number; pagingToken: string; contractTimestamp: bigint; + /** + * Ledger sequence reported by the contract itself (from `env.ledger().sequence()`). + * Present in v2+ events that include the `ledger_sequence` payload field. + * Backends SHOULD validate this matches the Horizon-reported `ledgerSequence` to + * detect tampered or mis-routed event payloads. Use together with `txHash` and + * `pagingToken` as a complete, stable deduplication key for replay safety. + */ + contractLedgerSequence?: number; } export interface EscrowDepositedEvent extends BaseContractEvent { diff --git a/app/contract/contracts/Folder/src/events.rs b/app/contract/contracts/Folder/src/events.rs index 05f8558a8..155191760 100644 --- a/app/contract/contracts/Folder/src/events.rs +++ b/app/contract/contracts/Folder/src/events.rs @@ -41,12 +41,23 @@ pub struct EventCompatibility { pub compatible_versions: &'static [u32], } +/// Deterministic replay metadata fields present in every v2+ event payload. +/// +/// The `ledger_sequence` field (from `env.ledger().sequence()`) enables +/// backend indexers to cross-validate the contract-reported ledger against +/// the Horizon-reported ledger, and together with `tx_hash` and `paging_token` +/// forms a complete, stable deduplication key for any event delivery. +#[allow(dead_code)] +pub const EVENT_REPLAY_FIELDS: &[&str] = &["ledger_sequence", "schema_version", "timestamp"]; + +// payload_keys are sorted alphabetically. "ledger_sequence" sorts between +// 'f*' keys and 's*' keys, i.e. after "fee*"/"from_version" and before "paused"/"recipient"/"schema_version". #[allow(dead_code)] pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "AdminChanged", topics: &[EVENT_TOPIC_ADMIN, "AdminChanged", "old_admin", "new_admin"], - payload_keys: &["schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -58,6 +69,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ "arbiter", ], payload_keys: &[ + "ledger_sequence", "resolve_for_owner", "schema_version", "threshold", @@ -69,7 +81,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "ContractMigrated", topics: &[EVENT_TOPIC_ADMIN, "ContractMigrated", "admin"], - payload_keys: &["from_version", "schema_version", "timestamp", "to_version"], + payload_keys: &["from_version", "ledger_sequence", "schema_version", "timestamp", "to_version"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -78,6 +90,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ payload_keys: &[ "contract_version", "event_schema_version", + "ledger_sequence", "paused", "schema_version", "timestamp", @@ -87,7 +100,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "ContractPaused", topics: &[EVENT_TOPIC_ADMIN, "ContractPaused", "admin"], - payload_keys: &["paused", "schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "paused", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -98,7 +111,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ "new_wasm_hash", "admin", ], - payload_keys: &["schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -111,6 +124,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ ], payload_keys: &[ "amount", + "ledger_sequence", "schema_version", "threshold", "timestamp", @@ -128,6 +142,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ payload_keys: &[ "action", "expires_at", + "ledger_sequence", "schema_version", "timestamp", ], @@ -143,6 +158,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ ], payload_keys: &[ "amount", + "ledger_sequence", "recipient", "schema_version", "timestamp", @@ -152,19 +168,19 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "DisputeExpiryActionSet", topics: &[EVENT_TOPIC_ADMIN, "DisputeExpiryActionSet"], - payload_keys: &["action", "schema_version", "timestamp"], + payload_keys: &["action", "ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "DisputeTimeoutConfigSet", topics: &[EVENT_TOPIC_ADMIN, "DisputeTimeoutConfigSet"], - payload_keys: &["schema_version", "timeout_secs", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timeout_secs", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "EmergencyModeActivated", topics: &[EVENT_TOPIC_ADMIN, "EmergencyModeActivated", "admin"], - payload_keys: &["schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -179,6 +195,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -192,6 +209,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token", @@ -201,37 +219,37 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "EscrowDisputed", topics: &[EVENT_TOPIC_ESCROW, "EscrowDisputed", "escrow_id", "arbiter"], - payload_keys: &["schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "EscrowFinalized", topics: &[EVENT_TOPIC_ESCROW, "EscrowFinalized", "escrow_id", "owner"], - payload_keys: &["schema_version", "timestamp", "token", "total_amount"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp", "token", "total_amount"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "EscrowRefunded", topics: &[EVENT_TOPIC_ESCROW, "EscrowRefunded", "escrow_id", "owner"], - payload_keys: &["amount", "schema_version", "timestamp", "token"], + payload_keys: &["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "EscrowWithdrawn", topics: &[EVENT_TOPIC_ESCROW, "EscrowWithdrawn", "escrow_id", "owner"], - payload_keys: &["amount", "fee", "schema_version", "timestamp", "token"], + payload_keys: &["amount", "fee", "ledger_sequence", "schema_version", "timestamp", "token"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "FeeCollectorRotated", topics: &[EVENT_TOPIC_ADMIN, "FeeCollectorRotated", "new_collector"], - payload_keys: &["rotation_index", "schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "rotation_index", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "FeeConfigChanged", topics: &[EVENT_TOPIC_ADMIN, "FeeConfigChanged"], - payload_keys: &["fee_bps", "schema_version", "timestamp"], + payload_keys: &["fee_bps", "ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -240,6 +258,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ payload_keys: &[ "amount_due", "amount_paid", + "ledger_sequence", "payment_amount", "schema_version", "timestamp", @@ -250,19 +269,19 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ EventSchema { name: "PerAssetFeeSet", topics: &[EVENT_TOPIC_ADMIN, "PerAssetFeeSet", "token"], - payload_keys: &["arbiter_bps", "fee_bps", "schema_version", "timestamp"], + payload_keys: &["arbiter_bps", "fee_bps", "ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "PlatformWalletChanged", topics: &[EVENT_TOPIC_ADMIN, "PlatformWalletChanged", "wallet"], - payload_keys: &["schema_version", "timestamp"], + payload_keys: &["ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { name: "PrivacyToggled", topics: &[EVENT_TOPIC_PRIVACY, "PrivacyToggled", "owner"], - payload_keys: &["enabled", "schema_version", "timestamp"], + payload_keys: &["enabled", "ledger_sequence", "schema_version", "timestamp"], schema_version: EVENT_SCHEMA_VERSION, }, EventSchema { @@ -273,7 +292,7 @@ pub const EVENT_SCHEMAS: &[EventSchema] = &[ "stealth_address", "recipient", ], - payload_keys: &["amount", "schema_version", "timestamp", "token"], + payload_keys: &["amount", "ledger_sequence", "schema_version", "timestamp", "token"], schema_version: EVENT_SCHEMA_VERSION, }, ]; @@ -333,6 +352,7 @@ pub struct EmergencyModeActivatedEvent { #[topic] pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -340,6 +360,7 @@ pub(crate) fn publish_emergency_mode_activated(env: &Env, admin: Address) { EmergencyModeActivatedEvent { admin, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -352,6 +373,7 @@ pub struct PrivacyToggledEvent { pub owner: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub enabled: bool, pub timestamp: u64, } @@ -366,6 +388,7 @@ pub struct EscrowWithdrawnEvent { pub owner: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub amount: i128, pub fee: i128, @@ -386,6 +409,7 @@ pub struct EscrowDepositedEvent { pub owner: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub amount_due: i128, pub amount_paid: i128, @@ -397,6 +421,7 @@ pub(crate) fn publish_privacy_toggled(env: &Env, owner: Address, enabled: bool) PrivacyToggledEvent { owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), enabled, timestamp: env.ledger().timestamp(), } @@ -411,6 +436,7 @@ pub struct ContractInitializedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub contract_version: u32, pub event_schema_version: u32, pub paused: bool, @@ -428,6 +454,7 @@ pub(crate) fn publish_contract_initialized( ContractInitializedEvent { admin, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), contract_version, event_schema_version, paused, @@ -444,6 +471,7 @@ pub struct ContractPausedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub paused: bool, pub timestamp: u64, } @@ -453,6 +481,7 @@ pub(crate) fn publish_contract_paused(env: &Env, admin: Address, paused: bool) { ContractPausedEvent { admin, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), paused, timestamp: env.ledger().timestamp(), } @@ -470,6 +499,7 @@ pub struct AdminChangedEvent { pub new_admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -479,6 +509,7 @@ pub(crate) fn publish_admin_changed(env: &Env, old_admin: Address, new_admin: Ad old_admin, new_admin, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -494,6 +525,7 @@ pub struct ContractUpgradedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -504,6 +536,7 @@ pub struct UpgradeStartedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub old_version: u32, pub new_version: u32, pub new_wasm_hash: BytesN<32>, @@ -519,6 +552,7 @@ pub struct UpgradeCompletedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub old_version: u32, pub new_version: u32, pub timestamp: u64, @@ -529,6 +563,7 @@ pub(crate) fn publish_contract_upgraded(env: &Env, new_wasm_hash: BytesN<32>, ad new_wasm_hash, admin: admin.clone(), schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -546,6 +581,7 @@ pub(crate) fn publish_upgrade_started( UpgradeStartedEvent { admin: admin.clone(), schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), old_version, new_version, new_wasm_hash, @@ -565,6 +601,7 @@ pub(crate) fn publish_upgrade_completed( UpgradeCompletedEvent { admin: admin.clone(), schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), old_version, new_version, timestamp: env.ledger().timestamp(), @@ -579,6 +616,7 @@ pub struct ContractMigratedEvent { pub admin: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub from_version: u32, pub to_version: u32, pub timestamp: u64, @@ -593,6 +631,7 @@ pub(crate) fn publish_contract_migrated( ContractMigratedEvent { admin: admin.clone(), schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), from_version, to_version, timestamp: env.ledger().timestamp(), @@ -616,6 +655,7 @@ pub(crate) fn publish_escrow_withdrawn( escrow_id: commitment, owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, amount, fee, @@ -641,6 +681,7 @@ pub(crate) fn publish_escrow_deposited( escrow_id: commitment, owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, amount_due, amount_paid, @@ -660,6 +701,7 @@ pub struct EscrowRefundedEvent { pub owner: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub amount: i128, pub timestamp: u64, @@ -675,6 +717,7 @@ pub struct PartialPaymentEvent { pub payer: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub payment_amount: i128, pub amount_paid: i128, @@ -692,6 +735,7 @@ pub struct EscrowFinalizedEvent { pub owner: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub total_amount: i128, pub timestamp: u64, @@ -707,6 +751,7 @@ pub struct EscrowDisputedEvent { pub arbiter: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -715,6 +760,7 @@ pub(crate) fn publish_escrow_disputed(env: &Env, commitment: BytesN<32>, arbiter escrow_id: commitment, arbiter, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -731,6 +777,7 @@ pub(crate) fn publish_escrow_refunded( escrow_id: commitment, owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, amount, timestamp: env.ledger().timestamp(), @@ -750,6 +797,7 @@ pub struct AuxIndicesCleanedEvent { #[topic] pub escrow_id: BytesN<32>, pub schema_version: u32, + pub ledger_sequence: u32, /// Number of auxiliary index entries removed during cleanup. pub indices_removed: u32, pub timestamp: u64, @@ -763,6 +811,7 @@ pub(crate) fn publish_aux_indices_cleaned( AuxIndicesCleanedEvent { escrow_id: commitment, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), indices_removed, timestamp: env.ledger().timestamp(), } @@ -782,6 +831,7 @@ pub(crate) fn publish_partial_payment( escrow_id: commitment, payer, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, payment_amount, amount_paid, @@ -802,6 +852,7 @@ pub(crate) fn publish_escrow_finalized( escrow_id: commitment, owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, total_amount, timestamp: env.ledger().timestamp(), @@ -825,6 +876,7 @@ pub struct EphemeralKeyRegisteredEvent { pub eph_pub: BytesN<32>, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub amount_due: i128, pub amount_paid: i128, @@ -845,6 +897,7 @@ pub(crate) fn publish_ephemeral_key_registered( stealth_address, eph_pub, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, amount_due, amount_paid, @@ -866,6 +919,7 @@ pub struct StealthWithdrawnEvent { pub recipient: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub token: Address, pub amount: i128, pub timestamp: u64, @@ -882,6 +936,7 @@ pub(crate) fn publish_stealth_withdrawn( stealth_address, recipient, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), token, amount, timestamp: env.ledger().timestamp(), @@ -898,6 +953,7 @@ pub struct StealthEscrowCleanedEvent { #[topic] pub stealth_address: BytesN<32>, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -905,6 +961,7 @@ pub(crate) fn publish_stealth_escrow_cleaned(env: &Env, stealth_address: BytesN< StealthEscrowCleanedEvent { stealth_address, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -914,6 +971,7 @@ pub(crate) fn publish_stealth_escrow_cleaned(env: &Env, stealth_address: BytesN< #[derive(Clone, Debug, Eq, PartialEq)] pub struct FeeConfigChangedEvent { pub schema_version: u32, + pub ledger_sequence: u32, pub fee_bps: u32, pub timestamp: u64, } @@ -921,6 +979,7 @@ pub struct FeeConfigChangedEvent { pub(crate) fn publish_fee_config_changed(env: &Env, fee_bps: u32) { FeeConfigChangedEvent { schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), fee_bps, timestamp: env.ledger().timestamp(), } @@ -933,6 +992,7 @@ pub struct PlatformWalletChangedEvent { #[topic] pub wallet: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -940,6 +1000,7 @@ pub(crate) fn publish_platform_wallet_changed(env: &Env, wallet: Address) { PlatformWalletChangedEvent { wallet, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -959,6 +1020,7 @@ pub struct ArbiterVoteCastEvent { pub arbiter: Address, pub schema_version: u32, + pub ledger_sequence: u32, pub resolve_for_owner: bool, pub vote_count: u32, pub threshold: u32, @@ -977,6 +1039,7 @@ pub(crate) fn publish_arbiter_vote_cast( escrow_id: commitment, arbiter, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), resolve_for_owner, vote_count, threshold, @@ -995,6 +1058,7 @@ pub struct DisputeResolvedEvent { pub resolved_for_owner: bool, pub schema_version: u32, + pub ledger_sequence: u32, pub total_votes: u32, pub threshold: u32, pub amount: i128, @@ -1013,6 +1077,7 @@ pub(crate) fn publish_dispute_resolved( escrow_id: commitment, resolved_for_owner, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), total_votes, threshold, amount, @@ -1041,6 +1106,7 @@ pub struct DisputeTimeoutSetEvent { pub action: Symbol, pub expires_at: u64, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1055,6 +1121,7 @@ pub(crate) fn publish_dispute_timeout_set( action: dispute_action_symbol(env, action), expires_at, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -1072,6 +1139,7 @@ pub struct DisputeAutoResolvedEvent { pub recipient: Address, pub amount: i128, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1088,6 +1156,7 @@ pub(crate) fn publish_dispute_auto_resolved( recipient, amount, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -1098,6 +1167,7 @@ pub(crate) fn publish_dispute_auto_resolved( pub struct DisputeExpiryActionSetEvent { pub action: Symbol, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1108,6 +1178,7 @@ pub(crate) fn publish_dispute_expiry_action_set( DisputeExpiryActionSetEvent { action: dispute_action_symbol(env, action), schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -1118,6 +1189,7 @@ pub(crate) fn publish_dispute_expiry_action_set( pub struct DisputeTimeoutConfigSetEvent { pub timeout_secs: u64, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1125,6 +1197,7 @@ pub(crate) fn publish_dispute_timeout_config_set(env: &Env, timeout_secs: u64) { DisputeTimeoutConfigSetEvent { timeout_secs, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -1139,6 +1212,7 @@ pub struct FeeCollectorRotatedEvent { pub new_collector: Address, pub rotation_index: u32, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1151,6 +1225,7 @@ pub(crate) fn publish_fee_collector_rotated( new_collector, rotation_index, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); @@ -1170,6 +1245,7 @@ pub struct PerAssetFeeSetEvent { pub collector_fee_numerator: u32, pub collector_fee_denominator: u32, pub schema_version: u32, + pub ledger_sequence: u32, pub timestamp: u64, } @@ -1193,6 +1269,7 @@ pub(crate) fn publish_per_asset_fee_set( collector_fee_numerator: collector_fee.numerator, collector_fee_denominator: collector_fee.denominator, schema_version: EVENT_SCHEMA_VERSION, + ledger_sequence: env.ledger().sequence(), timestamp: env.ledger().timestamp(), } .publish(env); diff --git a/app/contract/contracts/Folder/src/metadata.rs b/app/contract/contracts/Folder/src/metadata.rs index f3eafc963..cf0bde121 100644 --- a/app/contract/contracts/Folder/src/metadata.rs +++ b/app/contract/contracts/Folder/src/metadata.rs @@ -5,7 +5,7 @@ use crate::{ admin, - events::EVENT_SCHEMA_VERSION, + events::{EVENT_REPLAY_FIELDS, EVENT_SCHEMA_VERSION}, storage::{ self, CURRENT_CONTRACT_VERSION, LEGACY_CONTRACT_VERSION, }, @@ -149,3 +149,23 @@ pub fn pause_flags(env: &Env) -> u64 { let key = storage::DataKey::PauseFlags; env.storage().persistent().get(&key).unwrap_or(0) } + +/// Return the canonical replay metadata field names present in every v2+ event payload. +/// +/// Backends ingesting contract events MUST record all of these fields alongside +/// the Horizon-provided `transaction_hash` and `paging_token` to form a complete +/// deduplication key that survives repeated or out-of-order event deliveries: +/// +/// - `ledger_sequence`: the contract-reported ledger at emission time; backends +/// SHOULD cross-validate this against the Horizon-reported ledger to detect +/// tampered or mis-routed event payloads. +/// - `schema_version`: the event encoding version; parsers use this to select +/// the correct decoder. +/// - `timestamp`: the ledger close time in seconds since UNIX epoch. +pub fn event_replay_fields(env: &Env) -> Vec { + let mut fields = Vec::new(env); + for field in EVENT_REPLAY_FIELDS { + fields.push_back(Symbol::new(env, field)); + } + fields +} diff --git a/app/contract/contracts/Folder/src/test.rs b/app/contract/contracts/Folder/src/test.rs index bb91bffd2..4a5e50894 100644 --- a/app/contract/contracts/Folder/src/test.rs +++ b/app/contract/contracts/Folder/src/test.rs @@ -408,6 +408,7 @@ fn test_event_schema_catalog_locks_canonical_topics_and_payloads() { "amount_due", "amount_paid", "expires_at", + "ledger_sequence", "schema_version", "timestamp", "token" @@ -837,6 +838,8 @@ fn test_event_snapshot_escrow_deposited_schema() { assert!(data_map.get(Symbol::new(&env, "amount_paid")).is_some()); assert!(data_map.get(Symbol::new(&env, "expires_at")).is_some()); assert!(data_map.get(Symbol::new(&env, "timestamp")).is_some()); + // Replay metadata: contract-reported ledger sequence for deduplication. + assert!(data_map.get(Symbol::new(&env, "ledger_sequence")).is_some()); } #[test]