diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs
index eaa35296..941dce68 100644
--- a/contracts/stream_contract/src/lib.rs
+++ b/contracts/stream_contract/src/lib.rs
@@ -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) {
diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs
index de0e75fe..95099fa3 100644
--- a/contracts/stream_contract/src/test.rs
+++ b/contracts/stream_contract/src/test.rs
@@ -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();
diff --git a/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx
index f1c7cfd9..66164f65 100644
--- a/frontend/src/app/streams/create/page.tsx
+++ b/frontend/src/app/streams/create/page.tsx
@@ -8,263 +8,4 @@ export const metadata: Metadata = {
export default function CreateStreamPage() {
return ;
-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 (
-
-
-
- Back to Dashboard
-
-
-
-
Create New Stream
-
- Set up a real-time payment stream to any Stellar address.
-
-
-
-
-
- );
}
diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx
index 9247431c..8f59c5a2 100644
--- a/frontend/src/components/dashboard/dashboard-view.tsx
+++ b/frontend/src/components/dashboard/dashboard-view.tsx
@@ -112,10 +112,6 @@ function SkeletonCard({ className = "" }: { className?: string }) {
className={`rounded-2xl ${className}`}
aria-hidden="true"
/>
- >
- {/* shimmer sweep */}
-
-
);
}
diff --git a/frontend/src/hooks/useIncomingStreams.ts b/frontend/src/hooks/useIncomingStreams.ts
index dd10e078..9025a2c5 100644
--- a/frontend/src/hooks/useIncomingStreams.ts
+++ b/frontend/src/hooks/useIncomingStreams.ts
@@ -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,
@@ -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);
diff --git a/frontend/vitest.config.mts b/frontend/vitest.config.mts
index 2e942715..d9406a75 100644
--- a/frontend/vitest.config.mts
+++ b/frontend/vitest.config.mts
@@ -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'),