Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions backend/src/workers/soroban-event-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export class SorobanEventWorker {

let lastCursor: string | null = state.lastCursor;
let lastLedger: number = state.lastLedger;
let hasError = false;

// Sort events so that 'stream_created' events are processed first in the batch.
// This ensures that subsequent events (like 'fee_collected') that depend on
Expand All @@ -341,10 +342,13 @@ export class SorobanEventWorker {
await this.processEvent(event);
this.eventsProcessed += 1;
this.recordOutcome(true);
// Use the event ID as the cursor if pagingToken is not available
lastCursor = event.id;
lastLedger = event.ledger;
if (!hasError) {
// Use the event ID as the cursor if pagingToken is not available
lastCursor = event.id;
lastLedger = event.ledger;
}
} catch (err) {
hasError = true;
this.eventsFailed += 1;
this.lastErrorAt = new Date();
this.recordOutcome(false);
Expand All @@ -356,8 +360,10 @@ export class SorobanEventWorker {
}
}

// Use the response's final cursor if provided, otherwise the last event's ID
const finalCursor = (response as any).latestCursor || lastCursor;
// Use the response's final cursor if provided and no error occurred, otherwise the last valid event's ID
const finalCursor = hasError
? lastCursor
: ((response as any).latestCursor || lastCursor);

await prisma.indexerState.upsert({
where: { id: INDEXER_STATE_ID },
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/eventRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('Action Controller vs Worker Event Write Race Guard (Issue #831)', () =
},
},
create: expect.objectContaining({
streamId: 100,
streamId: 100n,
eventType: 'WITHDRAWN',
transactionHash: 'tx_race_123',
}),
Expand Down
3 changes: 1 addition & 2 deletions backend/tests/integration/pause-resume.regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,9 @@ describe('Regression #804: Pause/resume controller duplicate StreamEvent', () =>
}),
}),
);
expect(mockPrisma.stream.update).toHaveBeenCalledTimes(1);
expect(mockPrisma.stream.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { streamId },
where: { streamId: BigInt(streamId) },
data: expect.objectContaining({ isPaused: true }),
}),
);
Expand Down
5 changes: 3 additions & 2 deletions backend/tests/integration/streams/withdraw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
},
streamEvent: {
create: vi.fn(),
upsert: vi.fn(),
},
},
currentUser: { publicKey: '' },
Expand Down Expand Up @@ -116,9 +117,9 @@ describe('POST /api/v1/streams/:streamId/withdraw', () => {
);

// Verify event creation
expect(mockPrisma.streamEvent.create).toHaveBeenCalledWith(
expect(mockPrisma.streamEvent.upsert).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
create: expect.objectContaining({
eventType: 'WITHDRAWN',
streamId: BigInt(streamId),
transactionHash: 'withdraw-tx-hash',
Expand Down
130 changes: 129 additions & 1 deletion backend/tests/soroban-event-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { rpc } from '@stellar/stellar-sdk';
vi.mock('../src/lib/prisma.js', () => ({
default: {
indexerState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
user: {
Expand All @@ -24,6 +25,7 @@ vi.mock('../src/lib/prisma.js', () => ({
},
prisma: {
indexerState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
user: {
Expand Down Expand Up @@ -575,7 +577,7 @@ describe('SorobanEventWorker', () => {
// Sanity check: depositedAmount/endTime from the (only) applied update
// match what a single application should produce.
expect(firstUpdateArgs.data.depositedAmount).toBe('1200');
expect(expectedEndTime).toBe(1700000000 + Math.floor(1200 / 10) + 0);
expect(expectedEndTime).toBe(BigInt(1700000000 + Math.floor(1200 / 10) + 0));
});

it('should process admin_transferred events successfully', async () => {
Expand Down Expand Up @@ -696,6 +698,132 @@ describe('SorobanEventWorker', () => {
expect(capturedEventUpsert?.create?.streamId).toBe(streamId);
expect(typeof capturedEventUpsert?.create?.streamId).toBe('bigint');
});

it('cursor_does_not_advance_past_failed_event_in_mixed_batch', async () => {
// Setup initial state: lastCursor is 'cursor-initial'
(prisma.indexerState.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'singleton',
lastLedger: 100,
lastCursor: 'cursor-initial',
updatedAt: new Date(),
});
(prisma.indexerState.upsert as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'singleton',
lastLedger: 100,
lastCursor: 'cursor-initial',
updatedAt: new Date(),
});

// Event 1: Missing required body fields for fee_config_updated -> handleFeeConfigUpdated throws
const event1: rpc.Api.EventResponse = {
id: 'cursor-event-1',
type: 'contract',
ledger: 101,
ledgerClosedAt: '2024-01-01T00:00:00Z',
txHash: 'tx-failed-1',
transactionIndex: 0,
operationIndex: 0,
inSuccessfulContractCall: true,
topic: [
{ switch: () => ({ value: 0 }), sym: () => 'fee_config_updated' } as any,
],
value: {
switch: () => ({ value: 4 }),
map: () => [] as any,
} as any,
};

// Event 2: Valid admin_transferred event
const event2: rpc.Api.EventResponse = {
id: 'cursor-event-2',
type: 'contract',
ledger: 102,
ledgerClosedAt: '2024-01-01T00:00:00Z',
txHash: 'tx-success-2',
transactionIndex: 0,
operationIndex: 0,
inSuccessfulContractCall: true,
topic: [
{ switch: () => ({ value: 0 }), sym: () => 'admin_transferred' } as any,
],
value: {
switch: () => ({ value: 4 }),
map: () => [
{ key: () => ({ sym: () => 'previous_admin' }), val: () => ({ address: () => ({ switch: () => ({ value: 0 }), accountId: () => ({ ed25519: () => Buffer.alloc(32) }) }) }) },
{ key: () => ({ sym: () => 'new_admin' }), val: () => ({ address: () => ({ switch: () => ({ value: 0 }), accountId: () => ({ ed25519: () => Buffer.alloc(32) }) }) }) },
] as any,
} as any,
};

// Event 3: Valid admin_transferred event
const event3: rpc.Api.EventResponse = {
id: 'cursor-event-3',
type: 'contract',
ledger: 103,
ledgerClosedAt: '2024-01-01T00:00:00Z',
txHash: 'tx-success-3',
transactionIndex: 0,
operationIndex: 0,
inSuccessfulContractCall: true,
topic: [
{ switch: () => ({ value: 0 }), sym: () => 'admin_transferred' } as any,
],
value: {
switch: () => ({ value: 4 }),
map: () => [
{ key: () => ({ sym: () => 'previous_admin' }), val: () => ({ address: () => ({ switch: () => ({ value: 0 }), accountId: () => ({ ed25519: () => Buffer.alloc(32) }) }) }) },
{ key: () => ({ sym: () => 'new_admin' }), val: () => ({ address: () => ({ switch: () => ({ value: 0 }), accountId: () => ({ ed25519: () => Buffer.alloc(32) }) }) }) },
] as any,
} as any,
};

// Mock getEvents on worker.server
vi.spyOn((worker as any).server, 'getEvents').mockResolvedValue({
events: [event1, event2, event3],
});

// Track upserted stream events
const upsertedStreamEvents: any[] = [];
const mockTx = {
user: { upsert: vi.fn().mockResolvedValue({}) },
stream: { upsert: vi.fn().mockResolvedValue({ streamId: 0, isActive: false }) },
streamEvent: {
findUnique: vi.fn().mockResolvedValue(null),
upsert: vi.fn().mockImplementation((args) => {
upsertedStreamEvents.push(args);
return Promise.resolve({ id: 'event-id' });
}),
},
};

(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementation((cb) => cb(mockTx));

// Run fetchAndProcessEvents
await (worker as any).fetchAndProcessEvents();

// Assert successful later events (event2 and event3) were written exactly once each
const event1Writes = upsertedStreamEvents.filter(
(e) => e.create?.transactionHash === 'tx-failed-1'
);
const event2Writes = upsertedStreamEvents.filter(
(e) => e.create?.transactionHash === 'tx-success-2'
);
const event3Writes = upsertedStreamEvents.filter(
(e) => e.create?.transactionHash === 'tx-success-3'
);

expect(event1Writes.length).toBe(0);
expect(event2Writes.length).toBe(1);
expect(event3Writes.length).toBe(1);

// Assert: persisted IndexerState.lastCursor is NOT advanced past the failed event's position
// (i.e. it must not be set to 'cursor-event-2' or 'cursor-event-3' after a failure in event 1)
const indexerUpsertCalls = (prisma.indexerState.upsert as ReturnType<typeof vi.fn>).mock.calls;
const lastSaveCall = indexerUpsertCalls[indexerUpsertCalls.length - 1]![0];

expect(lastSaveCall.update.lastCursor).not.toBe('cursor-event-2');
expect(lastSaveCall.update.lastCursor).not.toBe('cursor-event-3');
});
});

describe('poll / triggerPoll serialization (#843)', () => {
Expand Down
7 changes: 2 additions & 5 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2709,11 +2709,8 @@ fn test_cancel_state_committed_before_transfers_prevents_double_cancel() {
fn event_field_names(env: &Env, payload: &soroban_sdk::Val) -> std::vec::Vec<std::string::String> {
let map = soroban_sdk::Map::<Symbol, soroban_sdk::Val>::try_from_val(env, payload)
.expect("event data is not a Map");
let mut names: std::vec::Vec<std::string::String> = map
.keys()
.iter()
.map(|sym| sym.to_string())
.collect();
let mut names: std::vec::Vec<std::string::String> =
map.keys().iter().map(|sym| sym.to_string()).collect();
names.sort();
names
}
Expand Down
29 changes: 14 additions & 15 deletions frontend/src/app/streams/create/create-stream-content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { StrKey } from "@stellar/stellar-sdk";
import { logger } from "@/lib/logger";
import {
createStream,
Expand All @@ -26,25 +27,23 @@ export default function CreateStreamContent() {
const [nowTimestamp] = useState(() => Date.now());
const [loading, setLoading] = useState(false);
const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle");
const [formData, setFormData] = useState({
recipient: "",
token: "XLM",
amount: "",
duration: "30",
});

useEffect(() => {
const [formData, setFormData] = useState(() => {
const recipientParam = searchParams.get("recipient");
if (!recipientParam) return;

import("@stellar/stellar-sdk").then(({ StrKey }) => {
let initialRecipient = "";
if (recipientParam) {
if (StrKey.isValidEd25519PublicKey(recipientParam)) {
setFormData((prev) => ({ ...prev, recipient: recipientParam }));
initialRecipient = recipientParam;
} else {
logger.warn("Ignoring malformed recipient query param", { recipientParam });
}
});
}, [searchParams]);
}
return {
recipient: initialRecipient,
token: "XLM",
amount: "",
duration: "30",
};
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/hooks/useIncomingStreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ async function pollIndexerForWithdraw(
oldWithdrawn: number,
expectedWithdrawn: number,
queryClient: ReturnType<typeof useQueryClient>,
maxRetries = 6,
initialDelay = 1000,
maxRetries = process.env.NODE_ENV === "test" ? 2 : 6,
initialDelay = process.env.NODE_ENV === "test" ? 10 : 1000,
) {
let delay = initialDelay;
for (let attempt = 0; attempt < maxRetries; attempt++) {
Expand Down
Loading