From c5298693a2d07e6850bdc6289c2e2c20d41b098b Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Mon, 29 Jun 2026 23:21:03 +0100 Subject: [PATCH 1/2] Add test for rounded-zero protocol fee --- contracts/stream_contract/src/lib.rs | 1 + contracts/stream_contract/src/test.rs | 34 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) 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(); From 193c7753a9d8bd2aef5d7e99e4c50f826fe4ce57 Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Mon, 29 Jun 2026 23:41:58 +0100 Subject: [PATCH 2/2] Fix frontend CI parse and type errors --- frontend/src/app/streams/create/page.tsx | 259 ------------------ .../components/dashboard/dashboard-view.tsx | 4 - frontend/src/hooks/useIncomingStreams.ts | 10 +- frontend/vitest.config.mts | 2 - 4 files changed, 5 insertions(+), 270 deletions(-) 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. -

- -
-
- - setFormData({ ...formData, recipient: e.target.value })} - required - /> - {recipientError && ( -

{recipientError}

- )} -
- -
-
- - -
-
- - { - 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 && ( -

{amountError}

- )} -
-
- -
- - setFormData({ ...formData, duration: e.target.value })} - required - /> - {durationError && ( -

{durationError}

- )} -
- -
-
- Streaming Rate - - {formData.amount && formData.duration && Number(formData.duration) > 0 - ? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8) - : "0.00000000"} {formData.token}/sec - -
-
- Estimated End Date - - {formData.duration && Number(formData.duration) > 0 - ? new Date(nowTimestamp + Number(formData.duration) * 86400000).toLocaleDateString() - : "—"} - -
-
- - - - {status !== "connected" && ( -

- Please connect your wallet to create a stream. -

- )} -
-
-
- ); } 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'),