Skip to content

Commit 43244bb

Browse files
authored
Merge pull request #252 from ussyalfaks/feat/stream-pause-resume-completion-tests
2 parents 21e960e + dc6b31c commit 43244bb

10 files changed

Lines changed: 885 additions & 21 deletions

backend/src/services/soroban-indexer.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ interface RpcResponse {
2222
};
2323
}
2424

25-
type IndexedEventType = 'CREATED' | 'CANCELLED' | 'WITHDRAWN';
25+
type IndexedEventType = 'CREATED' | 'CANCELLED' | 'WITHDRAWN' | 'COMPLETED';
2626

2727
const RPC_URL = process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org';
2828
const POLL_MS = Number(process.env.SOROBAN_INDEXER_POLL_MS ?? 15000);
@@ -112,6 +112,7 @@ export class SorobanIndexerService {
112112
if (firstTopic.includes('stream_created')) return 'CREATED';
113113
if (firstTopic.includes('stream_cancelled')) return 'CANCELLED';
114114
if (firstTopic.includes('tokens_withdrawn')) return 'WITHDRAWN';
115+
if (firstTopic.includes('stream_completed')) return 'COMPLETED';
115116
return null;
116117
}
117118

@@ -222,6 +223,11 @@ export class SorobanIndexerService {
222223
},
223224
});
224225
}
226+
} else if (eventType === 'COMPLETED') {
227+
await prisma.stream.updateMany({
228+
where: { streamId },
229+
data: { isActive: false, lastUpdateTime: timestamp },
230+
});
225231
}
226232

227233
await prisma.streamEvent.create({

backend/src/workers/soroban-event-worker.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,9 @@ export class SorobanEventWorker {
238238
case 'stream_cancelled':
239239
await this.handleStreamCancelled(event, topic1);
240240
break;
241+
case 'stream_completed':
242+
await this.handleStreamCompleted(event, topic1);
243+
break;
241244
default:
242245
// Unrecognised event — ignore silently.
243246
break;
@@ -483,6 +486,54 @@ export class SorobanEventWorker {
483486
timestamp,
484487
});
485488
}
489+
490+
private async handleStreamCompleted(
491+
event: rpc.Api.EventResponse,
492+
streamIdTopic: xdr.ScVal,
493+
): Promise<void> {
494+
const streamId = Number(decodeU64(streamIdTopic));
495+
const body = decodeMap(event.value);
496+
497+
if (!body['recipient'] || !body['total_withdrawn']) {
498+
throw new Error(`StreamCompleted #${streamId}: missing body fields`);
499+
}
500+
501+
const recipient = decodeAddress(body['recipient']);
502+
const totalWithdrawn = decodeI128(body['total_withdrawn']);
503+
const timestamp = Math.floor(Date.now() / 1000);
504+
505+
await prisma.$transaction(async (tx: any) => {
506+
await tx.stream.update({
507+
where: { streamId },
508+
data: {
509+
isActive: false,
510+
withdrawnAmount: totalWithdrawn,
511+
lastUpdateTime: timestamp,
512+
},
513+
});
514+
515+
await tx.streamEvent.create({
516+
data: {
517+
streamId,
518+
eventType: 'COMPLETED',
519+
amount: totalWithdrawn,
520+
transactionHash: event.txHash,
521+
ledgerSequence: event.ledger,
522+
timestamp,
523+
metadata: JSON.stringify({ recipient }),
524+
},
525+
});
526+
});
527+
528+
sseService.broadcastToStream(String(streamId), 'stream.completed', {
529+
streamId,
530+
recipient,
531+
totalWithdrawn,
532+
transactionHash: event.txHash,
533+
ledger: event.ledger,
534+
timestamp,
535+
});
536+
}
486537
}
487538

488539
export const sorobanEventWorker = new SorobanEventWorker();

contracts/stream_contract/src/events.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,36 @@ pub struct FeeCollectedEvent {
6969
pub fee_amount: i128,
7070
pub token: Address,
7171
}
72+
73+
/// Emitted when a sender pauses an active stream.
74+
///
75+
/// Topic: `("stream_paused", stream_id)`
76+
#[contracttype]
77+
#[derive(Clone, Debug, Eq, PartialEq)]
78+
pub struct StreamPausedEvent {
79+
pub stream_id: u64,
80+
pub sender: Address,
81+
pub paused_at: u64,
82+
}
83+
84+
/// Emitted when a sender resumes a paused stream.
85+
///
86+
/// Topic: `("stream_resumed", stream_id)`
87+
#[contracttype]
88+
#[derive(Clone, Debug, Eq, PartialEq)]
89+
pub struct StreamResumedEvent {
90+
pub stream_id: u64,
91+
pub sender: Address,
92+
pub new_end_time: u64,
93+
}
94+
95+
/// Emitted when a stream is fully drained on the final withdrawal.
96+
///
97+
/// Topic: `("stream_completed", stream_id)`
98+
#[contracttype]
99+
#[derive(Clone, Debug, Eq, PartialEq)]
100+
pub struct StreamCompletedEvent {
101+
pub stream_id: u64,
102+
pub recipient: Address,
103+
pub total_withdrawn: i128,
104+
}

contracts/stream_contract/src/lib.rs

Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError,
1212

1313
use errors::StreamError;
1414
use events::{
15-
FeeCollectedEvent, StreamCancelledEvent, StreamCreatedEvent, StreamToppedUpEvent,
16-
TokensWithdrawnEvent,
15+
FeeCollectedEvent, StreamCancelledEvent, StreamCompletedEvent, StreamCreatedEvent,
16+
StreamPausedEvent, StreamResumedEvent, StreamToppedUpEvent, TokensWithdrawnEvent,
1717
};
1818
use storage::{
1919
config_exists, load_config, load_stream, next_stream_id, save_config, save_stream,
2020
try_load_config, try_load_stream,
2121
};
22-
use types::{ProtocolConfig, Stream};
22+
use types::{ProtocolConfig, Stream, StreamStatus};
2323

2424
/// Maximum allowed protocol fee: 1 000 bps = 10%.
2525
const MAX_FEE_RATE_BPS: u32 = 1_000;
@@ -157,6 +157,9 @@ impl StreamContract {
157157
start_time,
158158
last_update_time: start_time,
159159
is_active: true,
160+
paused: false,
161+
paused_at: None,
162+
status: StreamStatus::Active,
160163
},
161164
);
162165

@@ -248,17 +251,15 @@ impl StreamContract {
248251

249252
/// Calculate the claimable amount for a stream at a given timestamp.
250253
///
251-
/// This helper computes how many tokens have been streamed since the last
252-
/// update, capped at the remaining balance to prevent over-withdrawal.
253-
///
254-
/// # Arguments
255-
/// * `stream` - The stream to calculate claimable amount for
256-
/// * `now` - Current ledger timestamp
257-
///
258-
/// # Returns
259-
/// The amount of tokens that can be claimed, never exceeding remaining balance
254+
/// Excludes any time the stream was paused. If the stream is currently
255+
/// paused, accrual stops at `paused_at`.
260256
fn calculate_claimable(stream: &Stream, now: u64) -> i128 {
261-
let elapsed = now.saturating_sub(stream.last_update_time);
257+
let effective_now = if stream.paused {
258+
stream.paused_at.unwrap_or(stream.last_update_time)
259+
} else {
260+
now
261+
};
262+
let elapsed = effective_now.saturating_sub(stream.last_update_time);
262263

263264
let streamed = (elapsed as i128)
264265
.checked_mul(stream.rate_per_second)
@@ -315,9 +316,10 @@ impl StreamContract {
315316
stream.withdrawn_amount += amount;
316317
stream.last_update_time = now;
317318

318-
// Mark stream as inactive if fully drained
319+
// Mark stream as inactive and completed if fully drained
319320
if stream.withdrawn_amount >= stream.deposited_amount {
320321
stream.is_active = false;
322+
stream.status = StreamStatus::Completed;
321323
}
322324
}
323325

@@ -342,8 +344,11 @@ impl StreamContract {
342344
return Err(StreamError::Unauthorized);
343345
}
344346

345-
// Validate stream is active
347+
// Validate stream is active and not paused
346348
Self::validate_stream_active(&stream)?;
349+
if stream.paused {
350+
return Err(StreamError::StreamInactive);
351+
}
347352

348353
let now = env.ledger().timestamp();
349354
let claimable = Self::calculate_claimable(&stream, now);
@@ -355,19 +360,31 @@ impl StreamContract {
355360
// Use helper function to transfer tokens and update state
356361
Self::transfer_and_update_stream(&env, &mut stream, &recipient, claimable, now);
357362

363+
let completed = stream.status == StreamStatus::Completed;
358364
save_stream(&env, stream_id, &stream);
359365

360-
// Emit withdrawal event
361366
env.events().publish(
362367
(Symbol::new(&env, "tokens_withdrawn"), stream_id),
363368
TokensWithdrawnEvent {
364369
stream_id,
365-
recipient,
370+
recipient: recipient.clone(),
366371
amount: claimable,
367372
timestamp: stream.last_update_time,
368373
},
369374
);
370375

376+
// Emit COMPLETED event on final withdrawal
377+
if completed {
378+
env.events().publish(
379+
(Symbol::new(&env, "stream_completed"), stream_id),
380+
StreamCompletedEvent {
381+
stream_id,
382+
recipient,
383+
total_withdrawn: stream.withdrawn_amount,
384+
},
385+
);
386+
}
387+
371388
Ok(claimable)
372389
}
373390

@@ -413,6 +430,7 @@ impl StreamContract {
413430

414431
// Mark stream as inactive
415432
stream.is_active = false;
433+
stream.status = StreamStatus::Cancelled;
416434
stream.last_update_time = now;
417435

418436
let recipient = stream.recipient.clone();
@@ -435,13 +453,98 @@ impl StreamContract {
435453
Ok(())
436454
}
437455

456+
/// Pause an active stream. Only the sender may pause.
457+
///
458+
/// # Errors
459+
/// - `StreamNotFound` — no stream exists with `stream_id`.
460+
/// - `Unauthorized` — caller is not the stream's sender.
461+
/// - `StreamInactive` — stream is already inactive.
462+
pub fn pause_stream(env: Env, sender: Address, stream_id: u64) -> Result<(), StreamError> {
463+
sender.require_auth();
464+
465+
let mut stream = load_stream(&env, stream_id)?;
466+
Self::validate_stream_ownership(&stream, &sender)?;
467+
Self::validate_stream_active(&stream)?;
468+
469+
if stream.paused {
470+
return Err(StreamError::StreamInactive);
471+
}
472+
473+
let now = env.ledger().timestamp();
474+
stream.paused = true;
475+
stream.paused_at = Some(now);
476+
stream.status = StreamStatus::Paused;
477+
save_stream(&env, stream_id, &stream);
478+
479+
env.events().publish(
480+
(Symbol::new(&env, "stream_paused"), stream_id),
481+
StreamPausedEvent { stream_id, sender, paused_at: now },
482+
);
483+
484+
Ok(())
485+
}
486+
487+
/// Resume a paused stream. Adjusts `end_time` by the pause duration.
488+
///
489+
/// The `last_update_time` is advanced to `now` so that accrual resumes
490+
/// from the current moment, effectively extending the stream by the
491+
/// duration it was paused.
492+
///
493+
/// # Errors
494+
/// - `StreamNotFound` — no stream exists with `stream_id`.
495+
/// - `Unauthorized` — caller is not the stream's sender.
496+
/// - `StreamInactive` — stream is not paused (already active or cancelled).
497+
pub fn resume_stream(env: Env, sender: Address, stream_id: u64) -> Result<u64, StreamError> {
498+
sender.require_auth();
499+
500+
let mut stream = load_stream(&env, stream_id)?;
501+
Self::validate_stream_ownership(&stream, &sender)?;
502+
503+
if !stream.paused {
504+
return Err(StreamError::StreamInactive);
505+
}
506+
507+
let now = env.ledger().timestamp();
508+
let paused_at = stream.paused_at.unwrap_or(now);
509+
let pause_duration = now.saturating_sub(paused_at);
510+
511+
// Advance last_update_time by pause duration so accrual resumes from now.
512+
stream.last_update_time = stream.last_update_time.saturating_add(pause_duration);
513+
// new_end_time represents when the stream will fully drain from now.
514+
let remaining = stream.deposited_amount.saturating_sub(stream.withdrawn_amount);
515+
let new_end_time = if stream.rate_per_second > 0 {
516+
now + (remaining / stream.rate_per_second) as u64
517+
} else {
518+
now
519+
};
520+
521+
stream.paused = false;
522+
stream.paused_at = None;
523+
stream.status = StreamStatus::Active;
524+
save_stream(&env, stream_id, &stream);
525+
526+
env.events().publish(
527+
(Symbol::new(&env, "stream_resumed"), stream_id),
528+
StreamResumedEvent { stream_id, sender, new_end_time },
529+
);
530+
531+
Ok(new_end_time)
532+
}
533+
438534
// ─── Read-only Queries ────────────────────────────────────────────────────
439535

440536
/// Returns the stream record for `stream_id`, or `None` if it does not exist.
441537
pub fn get_stream(env: Env, stream_id: u64) -> Option<Stream> {
442538
try_load_stream(&env, stream_id)
443539
}
444540

541+
/// Returns `true` if the stream exists and has status `Completed`.
542+
pub fn is_stream_completed(env: Env, stream_id: u64) -> bool {
543+
try_load_stream(&env, stream_id)
544+
.map(|s| s.status == StreamStatus::Completed)
545+
.unwrap_or(false)
546+
}
547+
445548
/// Get the current claimable amount for a stream without modifying state.
446549
///
447550
/// This is a read-only query that calculates how many tokens the recipient

0 commit comments

Comments
 (0)