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
1 change: 1 addition & 0 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ impl StreamContract {
/// emits a `fee_collected` event, and returns the net amount.
///
/// If no protocol config exists or the fee rate is 0, returns `amount` unchanged.
/// If fee calculation truncates to 0, no transfer/event occurs and `amount` is unchanged.
/// Time complexity: O(1).
fn collect_fee(env: &Env, token_address: &Address, amount: i128, stream_id: u64) -> i128 {
match try_load_config(env) {
Expand Down
34 changes: 34 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,40 @@ fn test_no_fee_event_when_fee_rate_is_zero() {
);
}

#[test]
fn test_no_fee_transfer_or_event_when_fee_rounds_to_zero() {
let env = Env::default();
env.mock_all_auths();
let (token, _) = create_token(&env);
let sender = Address::generate(&env);
let admin = Address::generate(&env);
let treasury = Address::generate(&env);
mint(&env, &token, &sender, 1_000);

let client = create_contract(&env);
let token_client = token::Client::new(&env, &token);

// Non-zero fee rate, but tiny amount => fee rounds down to 0:
// 1 * 200 / 10_000 = 0
client.initialize(&admin, &treasury, &200);
let id = client.create_stream(&sender, &Address::generate(&env), &token, &1, &1);

assert_eq!(token_client.balance(&treasury), 0);

let s = client.get_stream(&id).unwrap();
assert_eq!(s.deposited_amount, 1);

let events = env.events().all();
let fee_event = events.iter().find(|e| {
Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap()
== Symbol::new(&env, "fee_collected")
});
assert!(
fee_event.is_none(),
"fee_collected must not fire when rounded fee is 0"
);
}

#[test]
fn test_no_fee_without_protocol_config() {
let env = Env::default();
Expand Down
259 changes: 0 additions & 259 deletions frontend/src/app/streams/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,263 +8,4 @@ export const metadata: Metadata = {

export default function CreateStreamPage() {
return <CreateStreamContent />;
import React, { useState } from "react";
import {
createStream,
toBaseUnits,
toDurationSeconds,
getTokenAddress,
toSorobanErrorMessage,
TOKEN_ADDRESSES
} from "@/lib/soroban";
import { hasValidPrecision, validateAmountInput } from "@/utils/amount";
import { isValidStellarPublicKey } from "@/lib/stellar";
import { toast } from "react-hot-toast";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useWallet } from "@/context/wallet-context";

const TOKEN_DECIMALS = 7;

export default function CreateStreamPage() {
const { status, session } = useWallet();
const router = useRouter();
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", // days
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (status !== "connected" || !session) {
toast.error("Please connect your wallet first.");
return;
}

// Validate recipient
if (!formData.recipient.trim()) {
toast.error("Recipient address is required");
return;
}
if (!isValidStellarPublicKey(formData.recipient)) {
toast.error("Invalid Stellar public key format");
return;
}

// Validate amount
const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS);
if (validationError) {
toast.error(validationError);
return;
}

// Validate duration
const durationNum = parseFloat(formData.duration);
if (isNaN(durationNum) || durationNum <= 0) {
toast.error("Duration must be a positive number");
return;
}

setLoading(true);
setTxState("signing");

try {
const amountBigInt = toBaseUnits(formData.amount);
const durationBigInt = toDurationSeconds(formData.duration, "days");
const tokenAddress = getTokenAddress(formData.token);

const result = await createStream(session, {
recipient: formData.recipient,
tokenAddress,
amount: amountBigInt,
durationSeconds: durationBigInt,
});

if (result.success) {
setTxState("confirming");
toast.success("Stream created successfully!");
// Small delay to allow indexer to catch up
setTimeout(() => {
router.push("/dashboard");
}, 2000);
}
} catch (error) {
console.error("Stream creation failed:", error);
toast.error(toSorobanErrorMessage(error));
} finally {
setLoading(false);
setTxState("idle");
}
};

const getButtonText = () => {
if (!loading) return "Start Streaming";
switch (txState) {
case "signing": return "Confirm in Wallet...";
case "submitted": return "Submitting to Network...";
case "confirming": return "Finalizing Stream...";
default: return "Processing...";
}
};

// Inline validation feedback for the amount field. validateAmountInput
// returns an error message when invalid and null when valid. Only show it
// once the user has typed something β€” the empty case is handled on submit.
const amountError = formData.amount
? validateAmountInput(formData.amount, TOKEN_DECIMALS)
: null;

const recipientError = formData.recipient
? (!isValidStellarPublicKey(formData.recipient) ? "Invalid Stellar public key format" : null)
: null;

const durationError = formData.duration
? (isNaN(Number(formData.duration)) || Number(formData.duration) <= 0
? "Duration must be a positive number"
: null)
: null;

return (
<div className="container mx-auto max-w-2xl px-4 py-12">
<Link
href="/dashboard"
className="mb-8 inline-flex items-center text-sm font-medium text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
</Link>

<div className="glass-card rounded-3xl border-slate-800 p-8">
<h1 className="mb-2 text-3xl font-bold">Create New Stream</h1>
<p className="mb-8 text-slate-400">
Set up a real-time payment stream to any Stellar address.
</p>

<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Recipient Address
</label>
<input
type="text"
placeholder="G..."
className={`w-full rounded-xl border ${
recipientError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.recipient}
onChange={(e) => setFormData({ ...formData, recipient: e.target.value })}
required
/>
{recipientError && (
<p className="text-xs text-red-400 mt-1">{recipientError}</p>
)}
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Token
</label>
<select
className="w-full rounded-xl border border-slate-800 bg-slate-900/50 p-4 outline-none focus:border-accent transition-colors appearance-none"
value={formData.token}
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
>
{Object.keys(TOKEN_ADDRESSES).map((symbol) => (
<option key={symbol} value={symbol}>
{symbol}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Total Amount
</label>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
className={`w-full rounded-xl border ${
amountError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.amount}
onChange={(e) => {
const newValue = e.target.value;
// Only allow valid number characters and check precision
if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) {
if (hasValidPrecision(newValue, TOKEN_DECIMALS)) {
setFormData({ ...formData, amount: newValue });
}
}
}}
required
/>
{amountError && (
<p className="text-xs text-red-400 mt-1">{amountError}</p>
)}
</div>
</div>

<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">
Duration (Days)
</label>
<input
type="number"
placeholder="30"
className={`w-full rounded-xl border ${
durationError ? "border-red-500 focus:border-red-500" : "border-slate-800 focus:border-accent"
} bg-slate-900/50 p-4 outline-none transition-colors`}
value={formData.duration}
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
required
/>
{durationError && (
<p className="text-xs text-red-400 mt-1">{durationError}</p>
)}
</div>

<div className="rounded-2xl bg-accent/5 p-6 space-y-4">
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Streaming Rate</span>
<span className="font-mono font-medium text-accent">
{formData.amount && formData.duration && Number(formData.duration) > 0
? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8)
: "0.00000000"} {formData.token}/sec
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-slate-400">Estimated End Date</span>
<span className="font-medium">
{formData.duration && Number(formData.duration) > 0
? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString()
: "β€”"}
</span>
</div>
</div>

<button
type="submit"
disabled={loading || status !== "connected" || !!amountError || !!recipientError || !!durationError}
className="w-full rounded-xl bg-accent py-4 text-lg font-bold text-background transition-all hover:opacity-90 disabled:opacity-50 active:scale-[0.98]"
>
{getButtonText()}
</button>

{status !== "connected" && (
<p className="text-center text-sm text-red-400">
Please connect your wallet to create a stream.
</p>
)}
</form>
</div>
</div>
);
}
4 changes: 0 additions & 4 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,6 @@ function SkeletonCard({ className = "" }: { className?: string }) {
className={`rounded-2xl ${className}`}
aria-hidden="true"
/>
>
{/* shimmer sweep */}
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent" />
</div>
);
}

Expand Down
10 changes: 5 additions & 5 deletions frontend/src/hooks/useIncomingStreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ export function useWithdrawIncomingStream(

return { previousStreams, expectedWithdrawn };
},
onSuccess: async (result, stream, _variables, context) => {
onSuccess: async (result, stream, onMutateResult) => {
if (publicKey) {
const targetWithdrawn = context?.expectedWithdrawn ?? stream.withdrawn;
const targetWithdrawn = onMutateResult?.expectedWithdrawn ?? stream.withdrawn;
// Start polling in the background without blocking the mutation
pollIndexerForWithdraw(
publicKey,
Expand All @@ -107,11 +107,11 @@ export function useWithdrawIncomingStream(

await options?.onSuccess?.(result, stream);
},
onError: (error, stream, context) => {
if (publicKey && context?.previousStreams) {
onError: (error, stream, onMutateResult) => {
if (publicKey && onMutateResult?.previousStreams) {
queryClient.setQueryData(
incomingStreamsQueryKey(publicKey),
context.previousStreams,
onMutateResult.previousStreams,
);
}
options?.onError?.(error, stream);
Expand Down
2 changes: 0 additions & 2 deletions frontend/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
Expand Down
Loading