Skip to content
Merged
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
2 changes: 2 additions & 0 deletions contracts/stream_contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ pub enum StreamError {
NotInitialized = 8,
/// Duration supplied to `create_stream` is zero.
InvalidDuration = 9,
/// Supplied token address is not a valid token contract.
InvalidTokenAddress = 10,
}
16 changes: 15 additions & 1 deletion contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod types;
#[cfg(test)]
mod test;

use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol};
use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError, Symbol};

use errors::StreamError;
use events::{
Expand Down Expand Up @@ -113,6 +113,7 @@ impl StreamContract {
/// # Errors
/// - `InvalidAmount` — `amount` ≤ 0.
/// - `InvalidDuration` — `duration` is 0.
/// - `InvalidTokenAddress` — `token_address` is not a token contract.
pub fn create_stream(
env: Env,
sender: Address,
Expand All @@ -129,6 +130,7 @@ impl StreamContract {
if duration == 0 {
return Err(StreamError::InvalidDuration);
}
Self::validate_token_contract(&env, &token_address)?;

let stream_id = next_stream_id(&env);
let start_time = env.ledger().timestamp();
Expand Down Expand Up @@ -230,6 +232,18 @@ impl StreamContract {

// ─── Internal Helpers ─────────────────────────────────────────────────────

/// Ensures the supplied token address implements the Soroban token interface.
fn validate_token_contract(env: &Env, token_address: &Address) -> Result<(), StreamError> {
match env.try_invoke_contract::<u32, InvokeError>(
token_address,
&Symbol::new(env, "decimals"),
vec![env],
) {
Ok(Ok(_)) => Ok(()),
_ => Err(StreamError::InvalidTokenAddress),
}
}

fn calculate_claimable(stream: &Stream, now: u64) -> i128 {
let elapsed = now.saturating_sub(stream.last_update_time);

Expand Down
30 changes: 18 additions & 12 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,6 @@ fn test_initialize_rejects_second_call() {
let admin = Address::generate(&env);
let treasury = Address::generate(&env);

assert_eq!(stream_id1, 1);
assert_eq!(stream_id2, 2);
assert!(client.get_stream(&stream_id1).is_some());
assert!(client.get_stream(&stream_id2).is_some());
client.initialize(&admin, &treasury, &100);
let result = client.try_initialize(&admin, &treasury, &100);
assert_eq!(result, Err(Ok(StreamError::AlreadyInitialized)));
Expand Down Expand Up @@ -269,6 +265,24 @@ fn test_create_stream_rejects_zero_duration() {
assert_eq!(result, Err(Ok(StreamError::InvalidDuration)));
}

#[test]
fn test_create_stream_rejects_invalid_token_address() {
let env = Env::default();
env.mock_all_auths();
let client = create_contract(&env);

// Account addresses are not token contracts.
let invalid_token = Address::generate(&env);
let result = client.try_create_stream(
&Address::generate(&env),
&Address::generate(&env),
&invalid_token,
&500,
&100,
);
assert_eq!(result, Err(Ok(StreamError::InvalidTokenAddress)));
}

#[test]
fn test_create_stream_emits_event() {
let env = Env::default();
Expand Down Expand Up @@ -344,10 +358,6 @@ fn test_top_up_rejects_negative_amount() {
let client = create_contract(&env);
let id = client.create_stream(&sender, &Address::generate(&env), &token, &10_000, &100);

let contract_id = env.register(StreamContract, ());
let client = StreamContractClient::new(&env, &contract_id);
let token_client = token::Client::new(&env, &token_address);
token_client.approve(&sender, &contract_id, &20_000, &1_000_000);
assert_eq!(
client.try_top_up_stream(&sender, &id, &-50),
Err(Ok(StreamError::InvalidAmount))
Expand Down Expand Up @@ -522,10 +532,6 @@ fn test_withdraw_emits_event() {
let client = create_contract(&env);
let id = client.create_stream(&sender, &recipient, &token, &500, &100);

let contract_id = env.register(StreamContract, ());
let client = StreamContractClient::new(&env, &contract_id);
let token_client = token::Client::new(&env, &token_address);
token_client.approve(&sender, &contract_id, &20_000, &1_000_000);
// Advance time by 100 seconds to allow full withdrawal (500 tokens / 100 seconds = 5 tokens/sec)
env.ledger().with_mut(|l| {
l.timestamp += 100;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
]
],
[],
[],
[]
],
"ledger": {
Expand Down Expand Up @@ -473,7 +474,7 @@
"val": {
"i128": {
"hi": 0,
"lo": 200
"lo": 300
}
}
}
Expand Down Expand Up @@ -648,7 +649,7 @@
"val": {
"i128": {
"hi": 0,
"lo": 200
"lo": 300
}
}
},
Expand Down Expand Up @@ -721,7 +722,7 @@
"val": {
"i128": {
"hi": 0,
"lo": 100
"lo": 0
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
],
[],
[],
[],
[]
],
"ledger": {
Expand Down Expand Up @@ -418,7 +419,7 @@
"val": {
"i128": {
"hi": 0,
"lo": 0
"lo": 300
}
}
}
Expand Down Expand Up @@ -549,6 +550,79 @@
518400
]
],
[
{
"contract_data": {
"contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL",
"key": {
"vec": [
{
"symbol": "Balance"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
}
]
},
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL",
"key": {
"vec": [
{
"symbol": "Balance"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
}
]
},
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "amount"
},
"val": {
"i128": {
"hi": 0,
"lo": 300
}
}
},
{
"key": {
"symbol": "authorized"
},
"val": {
"bool": true
}
},
{
"key": {
"symbol": "clawback"
},
"val": {
"bool": false
}
}
]
}
}
},
"ext": "v0"
},
518400
]
],
[
{
"contract_data": {
Expand Down Expand Up @@ -593,7 +667,7 @@
"val": {
"i128": {
"hi": 0,
"lo": 300
"lo": 0
}
}
},
Expand Down
54 changes: 49 additions & 5 deletions frontend/components/IncomingStreams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,48 @@

import React, { useState } from 'react';
import type { Stream } from '@/lib/dashboard';
import { useStreamingAmount } from '@/hooks/useStreamingAmount';

interface IncomingStreamsProps {
streams: Stream[];
onWithdraw: (stream: Stream) => Promise<void>;
withdrawingStreamId?: string | null;
}

function formatTokenAmount(value: number): string {
if (!Number.isFinite(value)) return '0.0000';

return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
}).format(value);
}

const ClaimableAmount: React.FC<{ stream: Stream }> = ({ stream }) => {
const claimable = useStreamingAmount({
deposited: stream.deposited,
withdrawn: stream.withdrawn,
ratePerSecond: stream.ratePerSecond,
lastUpdateTime: stream.lastUpdateTime,
isActive: stream.status === 'Active' && stream.isActive,
});

const liveRate = stream.status === 'Active' && stream.ratePerSecond > 0;

return (
<div className="flex flex-col">
<span className={`font-bold tabular-nums ${liveRate ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-900 dark:text-gray-100'}`}>
{formatTokenAmount(claimable)} {stream.token}
</span>
<span className={`text-xs tabular-nums ${liveRate ? 'text-emerald-500 dark:text-emerald-400' : 'text-gray-400 dark:text-gray-500'}`}>
{liveRate
? `+${formatTokenAmount(stream.ratePerSecond)} ${stream.token}/sec`
: 'Stream inactive'}
</span>
</div>
);
};

const IncomingStreams: React.FC<IncomingStreamsProps> = ({
streams,
onWithdraw,
Expand All @@ -18,7 +53,7 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({

const filteredStreams = filter === 'All'
? streams
: streams.filter(s => s.status === filter);
: streams.filter((s) => s.status === filter);

const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setFilter(e.target.value as 'All' | 'Active' | 'Completed' | 'Paused');
Expand All @@ -29,7 +64,9 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({
<div className="p-6 border-b border-white/20 dark:border-white/10 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Incoming Payment Streams</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage and withdraw from your active incoming streams</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage and withdraw from your active incoming streams
</p>
</div>
<div className="flex items-center gap-2">
<label htmlFor="filter" className="text-sm font-medium text-gray-700 dark:text-gray-300">Filter:</label>
Expand All @@ -55,17 +92,24 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Claimable</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredStreams.map((stream) => (
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.id}</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-gray-100 font-mono">{stream.recipient}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Stream #{stream.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.deposited} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-bold">{stream.withdrawn} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 tabular-nums">{formatTokenAmount(stream.deposited)} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-bold tabular-nums">{formatTokenAmount(stream.withdrawn)} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<ClaimableAmount stream={stream} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
Expand Down
Loading
Loading