Skip to content

Commit 1db6922

Browse files
authored
Merge pull request #211 from Godsmiracle001/feat/issue-197-201-203-streaming-ux-core
feat: implement core stream create validation and live streaming UX
2 parents f224f0a + e270f2a commit 1db6922

14 files changed

Lines changed: 460 additions & 46 deletions

File tree

contracts/stream_contract/src/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ pub enum StreamError {
2525
NotInitialized = 8,
2626
/// Duration supplied to `create_stream` is zero.
2727
InvalidDuration = 9,
28+
/// Supplied token address is not a valid token contract.
29+
InvalidTokenAddress = 10,
2830
}

contracts/stream_contract/src/lib.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod types;
88
#[cfg(test)]
99
mod test;
1010

11-
use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol};
11+
use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError, Symbol};
1212

1313
use errors::StreamError;
1414
use events::{
@@ -113,6 +113,7 @@ impl StreamContract {
113113
/// # Errors
114114
/// - `InvalidAmount` — `amount` ≤ 0.
115115
/// - `InvalidDuration` — `duration` is 0.
116+
/// - `InvalidTokenAddress` — `token_address` is not a token contract.
116117
pub fn create_stream(
117118
env: Env,
118119
sender: Address,
@@ -129,6 +130,7 @@ impl StreamContract {
129130
if duration == 0 {
130131
return Err(StreamError::InvalidDuration);
131132
}
133+
Self::validate_token_contract(&env, &token_address)?;
132134

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

231233
// ─── Internal Helpers ─────────────────────────────────────────────────────
232234

235+
/// Ensures the supplied token address implements the Soroban token interface.
236+
fn validate_token_contract(env: &Env, token_address: &Address) -> Result<(), StreamError> {
237+
match env.try_invoke_contract::<u32, InvokeError>(
238+
token_address,
239+
&Symbol::new(env, "decimals"),
240+
vec![env],
241+
) {
242+
Ok(Ok(_)) => Ok(()),
243+
_ => Err(StreamError::InvalidTokenAddress),
244+
}
245+
}
246+
233247
fn calculate_claimable(stream: &Stream, now: u64) -> i128 {
234248
let elapsed = now.saturating_sub(stream.last_update_time);
235249

contracts/stream_contract/src/test.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,6 @@ fn test_initialize_rejects_second_call() {
112112
let admin = Address::generate(&env);
113113
let treasury = Address::generate(&env);
114114

115-
assert_eq!(stream_id1, 1);
116-
assert_eq!(stream_id2, 2);
117-
assert!(client.get_stream(&stream_id1).is_some());
118-
assert!(client.get_stream(&stream_id2).is_some());
119115
client.initialize(&admin, &treasury, &100);
120116
let result = client.try_initialize(&admin, &treasury, &100);
121117
assert_eq!(result, Err(Ok(StreamError::AlreadyInitialized)));
@@ -269,6 +265,24 @@ fn test_create_stream_rejects_zero_duration() {
269265
assert_eq!(result, Err(Ok(StreamError::InvalidDuration)));
270266
}
271267

268+
#[test]
269+
fn test_create_stream_rejects_invalid_token_address() {
270+
let env = Env::default();
271+
env.mock_all_auths();
272+
let client = create_contract(&env);
273+
274+
// Account addresses are not token contracts.
275+
let invalid_token = Address::generate(&env);
276+
let result = client.try_create_stream(
277+
&Address::generate(&env),
278+
&Address::generate(&env),
279+
&invalid_token,
280+
&500,
281+
&100,
282+
);
283+
assert_eq!(result, Err(Ok(StreamError::InvalidTokenAddress)));
284+
}
285+
272286
#[test]
273287
fn test_create_stream_emits_event() {
274288
let env = Env::default();
@@ -344,10 +358,6 @@ fn test_top_up_rejects_negative_amount() {
344358
let client = create_contract(&env);
345359
let id = client.create_stream(&sender, &Address::generate(&env), &token, &10_000, &100);
346360

347-
let contract_id = env.register(StreamContract, ());
348-
let client = StreamContractClient::new(&env, &contract_id);
349-
let token_client = token::Client::new(&env, &token_address);
350-
token_client.approve(&sender, &contract_id, &20_000, &1_000_000);
351361
assert_eq!(
352362
client.try_top_up_stream(&sender, &id, &-50),
353363
Err(Ok(StreamError::InvalidAmount))
@@ -522,10 +532,6 @@ fn test_withdraw_emits_event() {
522532
let client = create_contract(&env);
523533
let id = client.create_stream(&sender, &recipient, &token, &500, &100);
524534

525-
let contract_id = env.register(StreamContract, ());
526-
let client = StreamContractClient::new(&env, &contract_id);
527-
let token_client = token::Client::new(&env, &token_address);
528-
token_client.approve(&sender, &contract_id, &20_000, &1_000_000);
529535
// Advance time by 100 seconds to allow full withdrawal (500 tokens / 100 seconds = 5 tokens/sec)
530536
env.ledger().with_mut(|l| {
531537
l.timestamp += 100;

contracts/stream_contract/test_snapshots/test/test_cancel_stream_after_partial_withdrawal.1.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
]
155155
],
156156
[],
157+
[],
157158
[]
158159
],
159160
"ledger": {
@@ -473,7 +474,7 @@
473474
"val": {
474475
"i128": {
475476
"hi": 0,
476-
"lo": 200
477+
"lo": 300
477478
}
478479
}
479480
}
@@ -648,7 +649,7 @@
648649
"val": {
649650
"i128": {
650651
"hi": 0,
651-
"lo": 200
652+
"lo": 300
652653
}
653654
}
654655
},
@@ -721,7 +722,7 @@
721722
"val": {
722723
"i128": {
723724
"hi": 0,
724-
"lo": 100
725+
"lo": 0
725726
}
726727
}
727728
},

contracts/stream_contract/test_snapshots/test/test_cancel_stream_refunds_sender.1.json

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
],
133133
[],
134134
[],
135+
[],
135136
[]
136137
],
137138
"ledger": {
@@ -418,7 +419,7 @@
418419
"val": {
419420
"i128": {
420421
"hi": 0,
421-
"lo": 0
422+
"lo": 300
422423
}
423424
}
424425
}
@@ -549,6 +550,79 @@
549550
518400
550551
]
551552
],
553+
[
554+
{
555+
"contract_data": {
556+
"contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL",
557+
"key": {
558+
"vec": [
559+
{
560+
"symbol": "Balance"
561+
},
562+
{
563+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
564+
}
565+
]
566+
},
567+
"durability": "persistent"
568+
}
569+
},
570+
[
571+
{
572+
"last_modified_ledger_seq": 0,
573+
"data": {
574+
"contract_data": {
575+
"ext": "v0",
576+
"contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL",
577+
"key": {
578+
"vec": [
579+
{
580+
"symbol": "Balance"
581+
},
582+
{
583+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
584+
}
585+
]
586+
},
587+
"durability": "persistent",
588+
"val": {
589+
"map": [
590+
{
591+
"key": {
592+
"symbol": "amount"
593+
},
594+
"val": {
595+
"i128": {
596+
"hi": 0,
597+
"lo": 300
598+
}
599+
}
600+
},
601+
{
602+
"key": {
603+
"symbol": "authorized"
604+
},
605+
"val": {
606+
"bool": true
607+
}
608+
},
609+
{
610+
"key": {
611+
"symbol": "clawback"
612+
},
613+
"val": {
614+
"bool": false
615+
}
616+
}
617+
]
618+
}
619+
}
620+
},
621+
"ext": "v0"
622+
},
623+
518400
624+
]
625+
],
552626
[
553627
{
554628
"contract_data": {
@@ -593,7 +667,7 @@
593667
"val": {
594668
"i128": {
595669
"hi": 0,
596-
"lo": 300
670+
"lo": 0
597671
}
598672
}
599673
},

frontend/components/IncomingStreams.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,48 @@
22

33
import React, { useState } from 'react';
44
import type { Stream } from '@/lib/dashboard';
5+
import { useStreamingAmount } from '@/hooks/useStreamingAmount';
56

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

13+
function formatTokenAmount(value: number): string {
14+
if (!Number.isFinite(value)) return '0.0000';
15+
16+
return new Intl.NumberFormat('en-US', {
17+
minimumFractionDigits: 4,
18+
maximumFractionDigits: 4,
19+
}).format(value);
20+
}
21+
22+
const ClaimableAmount: React.FC<{ stream: Stream }> = ({ stream }) => {
23+
const claimable = useStreamingAmount({
24+
deposited: stream.deposited,
25+
withdrawn: stream.withdrawn,
26+
ratePerSecond: stream.ratePerSecond,
27+
lastUpdateTime: stream.lastUpdateTime,
28+
isActive: stream.status === 'Active' && stream.isActive,
29+
});
30+
31+
const liveRate = stream.status === 'Active' && stream.ratePerSecond > 0;
32+
33+
return (
34+
<div className="flex flex-col">
35+
<span className={`font-bold tabular-nums ${liveRate ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-900 dark:text-gray-100'}`}>
36+
{formatTokenAmount(claimable)} {stream.token}
37+
</span>
38+
<span className={`text-xs tabular-nums ${liveRate ? 'text-emerald-500 dark:text-emerald-400' : 'text-gray-400 dark:text-gray-500'}`}>
39+
{liveRate
40+
? `+${formatTokenAmount(stream.ratePerSecond)} ${stream.token}/sec`
41+
: 'Stream inactive'}
42+
</span>
43+
</div>
44+
);
45+
};
46+
1247
const IncomingStreams: React.FC<IncomingStreamsProps> = ({
1348
streams,
1449
onWithdraw,
@@ -18,7 +53,7 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({
1853

1954
const filteredStreams = filter === 'All'
2055
? streams
21-
: streams.filter(s => s.status === filter);
56+
: streams.filter((s) => s.status === filter);
2257

2358
const handleFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
2459
setFilter(e.target.value as 'All' | 'Active' | 'Completed' | 'Paused');
@@ -29,7 +64,9 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({
2964
<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">
3065
<div>
3166
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Incoming Payment Streams</h2>
32-
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage and withdraw from your active incoming streams</p>
67+
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
68+
Manage and withdraw from your active incoming streams
69+
</p>
3370
</div>
3471
<div className="flex items-center gap-2">
3572
<label htmlFor="filter" className="text-sm font-medium text-gray-700 dark:text-gray-300">Filter:</label>
@@ -55,17 +92,24 @@ const IncomingStreams: React.FC<IncomingStreamsProps> = ({
5592
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
5693
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
5794
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
95+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Claimable</th>
5896
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
5997
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
6098
</tr>
6199
</thead>
62100
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
63101
{filteredStreams.map((stream) => (
64102
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
65-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.id}</td>
103+
<td className="px-6 py-4 whitespace-nowrap">
104+
<div className="text-sm text-gray-900 dark:text-gray-100 font-mono">{stream.recipient}</div>
105+
<div className="text-xs text-gray-500 dark:text-gray-400">Stream #{stream.id}</div>
106+
</td>
66107
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.token}</td>
67-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.deposited} {stream.token}</td>
68-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-bold">{stream.withdrawn} {stream.token}</td>
108+
<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>
109+
<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>
110+
<td className="px-6 py-4 whitespace-nowrap text-sm">
111+
<ClaimableAmount stream={stream} />
112+
</td>
69113
<td className="px-6 py-4 whitespace-nowrap text-sm">
70114
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
71115
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :

0 commit comments

Comments
 (0)