From 7dcfe470978ea8f1c3ffad905e61fc81912303ef Mon Sep 17 00:00:00 2001 From: Marvy Date: Wed, 25 Feb 2026 18:35:49 +0100 Subject: [PATCH] feat: UI flows for creating, topping up, and cancelling streams --- .../components/dashboard/dashboard-view.tsx | 405 +++++++++++++----- .../stream-creation/CancelConfirmModal.tsx | 147 +++++++ .../components/stream-creation/TopUpModal.tsx | 184 ++++++++ frontend/lib/soroban.ts | 260 +++++++++++ frontend/package.json | 6 +- frontend/tsconfig.json | 2 +- 6 files changed, 887 insertions(+), 117 deletions(-) create mode 100644 frontend/components/stream-creation/CancelConfirmModal.tsx create mode 100644 frontend/components/stream-creation/TopUpModal.tsx create mode 100644 frontend/lib/soroban.ts diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx index fbc8636..b3b3e85 100644 --- a/frontend/components/dashboard/dashboard-view.tsx +++ b/frontend/components/dashboard/dashboard-view.tsx @@ -1,15 +1,35 @@ "use client"; + import React from "react"; +import toast from "react-hot-toast"; import { getMockDashboardStats, type DashboardSnapshot, + type Stream, } from "@/lib/dashboard"; import { shortenPublicKey, type WalletSession } from "@/lib/wallet"; +import { + createStream as sorobanCreateStream, + topUpStream as sorobanTopUp, + cancelStream as sorobanCancel, + toBaseUnits, + toDurationSeconds, + getTokenAddress, + toSorobanErrorMessage, +} from "@/lib/soroban"; + import IncomingStreams from "../IncomingStreams"; -import { StreamCreationWizard, type StreamFormData } from "../stream-creation/StreamCreationWizard"; +import { + StreamCreationWizard, + type StreamFormData, +} from "../stream-creation/StreamCreationWizard"; +import { TopUpModal } from "../stream-creation/TopUpModal"; +import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { Button } from "../ui/Button"; +// ─── Types ──────────────────────────────────────────────────────────────────── + interface DashboardViewProps { session: WalletSession; onDisconnect: () => void; @@ -20,6 +40,14 @@ interface SidebarItem { label: string; } +// Modal state: null = closed +type ModalState = + | null + | { type: "topup"; stream: Stream } + | { type: "cancel"; stream: Stream }; + +// ─── Sidebar ────────────────────────────────────────────────────────────────── + const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "overview", label: "Overview" }, { id: "incoming", label: "Incoming" }, @@ -29,6 +57,8 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "settings", label: "Settings" }, ]; +// ─── Formatters ─────────────────────────────────────────────────────────────── + function formatCurrency(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", @@ -39,17 +69,15 @@ function formatCurrency(value: number): string { function formatActivityTime(timestamp: string): string { const date = new Date(timestamp); - - if (Number.isNaN(date.getTime())) { - return timestamp; - } - + if (Number.isNaN(date.getTime())) return timestamp; return new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short", }).format(date); } +// ─── Sub-renders ────────────────────────────────────────────────────────────── + function renderStats(snapshot: DashboardSnapshot) { const items = [ { @@ -91,59 +119,6 @@ function renderStats(snapshot: DashboardSnapshot) { ); } -function renderStreams( - snapshot: DashboardSnapshot, - onTopUp: (id: string) => void, -) { - return ( -
-
-

My Active Streams

- {snapshot.streams.length} total -
- -
- - - - - - - - - - - - {snapshot.streams.map((stream) => ( - - - - - - - - ))} - -
DateRecipientDepositedWithdrawnActions
{stream.date} - {stream.recipient} - - {stream.deposited} {stream.token} - - {stream.withdrawn} {stream.token} - - -
-
-
- ); -} - function renderRecentActivity(snapshot: DashboardSnapshot) { return (
@@ -183,79 +158,253 @@ function renderRecentActivity(snapshot: DashboardSnapshot) { ); } +// ─── Main component ─────────────────────────────────────────────────────────── + export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const [activeTab, setActiveTab] = React.useState("overview"); const [showWizard, setShowWizard] = React.useState(false); - const stats = getMockDashboardStats(session.walletId); - - const handleTopUp = (streamId: string) => { - const amount = prompt(`Enter amount to add to stream ${streamId}:`); - if (amount && !Number.isNaN(parseFloat(amount)) && parseFloat(amount) > 0) { - console.log(`Adding ${amount} funds to stream ${streamId}`); - // TODO: Integrate with Soroban contract's top_up_stream function - alert(`Successfully added ${amount} to stream ${streamId}`); - } + const [modal, setModal] = React.useState(null); + + // In real usage this would be fetched from the chain. + // For now we keep the mock and add local state so UI updates optimistically. + const [snapshot, setSnapshot] = React.useState( + () => getMockDashboardStats(session.walletId), + ); + + // ── Optimistic helpers ────────────────────────────────────────────────────── + + /** Mark a stream as cancelled in local state. */ + const removeStreamLocally = (streamId: string) => { + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + streams: prev.streams.map((s) => + s.id === streamId ? { ...s, status: "Cancelled" as const } : s, + ), + activeStreamsCount: Math.max(0, prev.activeStreamsCount - 1), + }; + }); + }; + + /** Add top-up amount to a stream in local state. */ + const topUpStreamLocally = (streamId: string, amount: number) => { + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + streams: prev.streams.map((s) => + s.id === streamId + ? { ...s, deposited: s.deposited + amount } + : s, + ), + }; + }); + }; + + /** Prepend a new stream to local state after creation. */ + const addStreamLocally = (data: StreamFormData) => { + const newStream: Stream = { + id: `stream-${Date.now()}`, + date: new Date().toISOString().split("T")[0], + recipient: shortenPublicKey(data.recipient), + amount: parseFloat(data.amount), + token: data.token, + status: "Active", + deposited: parseFloat(data.amount), + withdrawn: 0, + }; + setSnapshot((prev) => { + if (!prev) return prev; + return { + ...prev, + streams: [newStream, ...prev.streams], + activeStreamsCount: prev.activeStreamsCount + 1, + }; + }); }; + // ── Contract handlers ─────────────────────────────────────────────────────── + const handleCreateStream = async (data: StreamFormData) => { - console.log("Creating stream with data:", data); - // TODO: Integrate with Soroban contract's create_stream function - // This would involve: - // 1. Converting duration to seconds - // 2. Calling the contract's create_stream function - // 3. Handling the transaction signing - // 4. Waiting for confirmation - - // For now, simulate success - await new Promise((resolve) => setTimeout(resolve, 1500)); - alert(`Stream created successfully!\n\nRecipient: ${data.recipient}\nToken: ${data.token}\nAmount: ${data.amount}\nDuration: ${data.duration} ${data.durationUnit}`); - setShowWizard(false); + const toastId = toast.loading("Creating stream…"); + try { + const durationSecs = toDurationSeconds(data.duration, data.durationUnit); + const amount = toBaseUnits(data.amount); + const tokenAddress = getTokenAddress(data.token); + + await sorobanCreateStream(session, { + recipient: data.recipient, + tokenAddress, + amount, + durationSeconds: durationSecs, + }); + + addStreamLocally(data); + setShowWizard(false); + toast.success("Stream created successfully!", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + // Re-throw so the wizard's isSubmitting state resets properly + throw err; + } }; + const handleTopUpConfirm = async (streamId: string, amountStr: string) => { + const toastId = toast.loading("Topping up stream…"); + try { + const amount = toBaseUnits(amountStr); + await sorobanTopUp(session, { + streamId: BigInt(streamId.replace(/\D/g, "") || "0"), + amount, + }); + + topUpStreamLocally(streamId, parseFloat(amountStr)); + setModal(null); + toast.success("Stream topped up successfully!", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + + const handleCancelConfirm = async (streamId: string) => { + const toastId = toast.loading("Cancelling stream…"); + try { + await sorobanCancel(session, { + streamId: BigInt(streamId.replace(/\D/g, "") || "0"), + }); + + removeStreamLocally(streamId); + setModal(null); + toast.success("Stream cancelled.", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + + // ── Streams table ─────────────────────────────────────────────────────────── + + function renderStreams(snap: DashboardSnapshot) { + const activeStreams = snap.streams.filter((s) => s.status === "Active"); + + return ( +
+
+

My Active Streams

+ {activeStreams.length} active +
+ + {activeStreams.length === 0 ? ( +
+

No active streams. Create one to get started.

+
+ ) : ( +
+ + + + + + + + + + + + {activeStreams.map((stream) => ( + + + + + + + + ))} + +
DateRecipientDepositedWithdrawnActions
{stream.date} + {stream.recipient} + + {stream.deposited} {stream.token} + + {stream.withdrawn} {stream.token} + +
+ {/* Top Up */} + + + {/* Cancel */} + +
+
+
+ )} +
+ ); + } + + // ── Tab content ───────────────────────────────────────────────────────────── + const renderContent = () => { if (activeTab === "incoming") { return
; } if (activeTab === "overview") { - if (!stats) { - return ( -
-

No stream data yet

-

- Your account is connected, but there are no active or historical - stream records available yet. -

-
    -
  • Create your first payment stream
  • -
  • Invite a recipient to start receiving funds
  • -
  • Check back once transactions are confirmed
  • -
-
- -
-
- ); - } + if (!snapshot) { return ( -
- {renderStats(stats)} - {renderStreams(stats, handleTopUp)} - {renderRecentActivity(stats)} +
+

No stream data yet

+

+ Your account is connected, but there are no active or historical + stream records available yet. +

+
    +
  • Create your first payment stream
  • +
  • Invite a recipient to start receiving funds
  • +
  • Check back once transactions are confirmed
  • +
+
+
+
); + } + + return ( +
+ {renderStats(snapshot)} + {renderStreams(snapshot)} + {renderRecentActivity(snapshot)} +
+ ); } - + return ( -
-

Under Construction

-

This tab is currently under development.

-
+
+

Under Construction

+

This tab is currently under development.

+
); }; + // ── Render ─────────────────────────────────────────────────────────────────── + return (
+ {/* Create Stream Wizard */} {showWizard && ( setShowWizard(false)} onSubmit={handleCreateStream} /> )} + + {/* Top Up Modal */} + {modal?.type === "topup" && ( + setModal(null)} + /> + )} + + {/* Cancel Confirmation Modal */} + {modal?.type === "cancel" && ( + setModal(null)} + /> + )} ); } diff --git a/frontend/components/stream-creation/CancelConfirmModal.tsx b/frontend/components/stream-creation/CancelConfirmModal.tsx new file mode 100644 index 0000000..7b53cf3 --- /dev/null +++ b/frontend/components/stream-creation/CancelConfirmModal.tsx @@ -0,0 +1,147 @@ +"use client"; + +/** + * CancelConfirmModal.tsx + * + * Confirmation dialog before cancelling an active stream. + * Clearly communicates the consequences (stream stops, no refund of + * already-withdrawn funds) before the user commits. + */ + +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/Button"; + +interface CancelConfirmModalProps { + streamId: string; + recipient: string; + token: string; + deposited: number; + withdrawn: number; + onConfirm: (streamId: string) => Promise; + onClose: () => void; +} + +export const CancelConfirmModal: React.FC = ({ + streamId, + recipient, + token, + deposited, + withdrawn, + onConfirm, + onClose, +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + // Escape key support + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) onClose(); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose, isSubmitting]); + + const remaining = deposited - withdrawn; + + const handleConfirm = async () => { + setIsSubmitting(true); + try { + await onConfirm(streamId); + } catch { + // Errors are handled upstream (toast in dashboard-view) + setIsSubmitting(false); + } + }; + + return ( +
{ + if (e.target === e.currentTarget && !isSubmitting) onClose(); + }} + > +
+ {/* Header */} +
+ {/* Warning icon */} +
+ + + +
+
+

Cancel Stream?

+

+ This action is permanent and cannot be undone. +

+
+ +
+ + {/* Stream summary */} +
+
+ Stream + {streamId} +
+
+ Recipient + {recipient} +
+
+ Already withdrawn + {withdrawn} {token} +
+
+ Remaining in stream + {remaining} {token} +
+
+ + {/* Consequence note */} +
+ What happens: The stream stops immediately. + The recipient keeps any already-withdrawn funds. Remaining funds + ({remaining} {token}) stay locked in the contract until the recipient withdraws + or the admin resolves them. +
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/components/stream-creation/TopUpModal.tsx b/frontend/components/stream-creation/TopUpModal.tsx new file mode 100644 index 0000000..3065cd5 --- /dev/null +++ b/frontend/components/stream-creation/TopUpModal.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * TopUpModal.tsx + * + * Replaces the prompt() / alert() in dashboard-view.tsx handleTopUp. + * Collects an amount, shows a confirmation summary, and calls onConfirm. + */ + +import React, { useRef, useEffect, useState } from "react"; +import { Button } from "@/components/ui/Button"; + +interface TopUpModalProps { + streamId: string; + token: string; + currentDeposited: number; + onConfirm: (streamId: string, amount: string) => Promise; + onClose: () => void; +} + +export const TopUpModal: React.FC = ({ + streamId, + token, + currentDeposited, + onConfirm, + onClose, +}) => { + const [amount, setAmount] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const inputRef = useRef(null); + + // Auto-focus and Escape key support + useEffect(() => { + inputRef.current?.focus(); + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) onClose(); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose, isSubmitting]); + + const validate = (): boolean => { + const parsed = parseFloat(amount); + if (!amount.trim() || isNaN(parsed) || parsed <= 0) { + setError("Please enter a valid positive amount."); + return false; + } + setError(null); + return true; + }; + + const handleConfirm = async () => { + if (!validate()) return; + setIsSubmitting(true); + try { + await onConfirm(streamId, amount); + // onConfirm is responsible for closing + toasting on success + } catch { + // Errors are handled upstream (toast in dashboard-view) + setIsSubmitting(false); + } + }; + + const parsedAmount = parseFloat(amount); + const newTotal = !isNaN(parsedAmount) && parsedAmount > 0 + ? currentDeposited + parsedAmount + : null; + + return ( +
{ + if (e.target === e.currentTarget && !isSubmitting) onClose(); + }} + > +
+ {/* Header */} +
+

Top Up Stream

+ +
+ + {/* Stream info */} +
+

+ Stream {streamId} +

+

+ Current balance: {currentDeposited} {token} +

+
+ + {/* Amount input */} +
+ +
+ { + setAmount(e.target.value); + if (error) setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") void handleConfirm(); + }} + placeholder="0.00" + disabled={isSubmitting} + className={`w-full px-4 py-3 rounded-lg bg-glass border ${ + error + ? "border-red-500 focus:border-red-500" + : "border-glass-border focus:border-accent" + } focus:outline-none focus:ring-2 focus:ring-accent/50 transition-colors text-foreground placeholder-slate-500 disabled:opacity-50`} + aria-invalid={!!error} + aria-describedby={error ? "topup-error" : undefined} + /> + + {token} + +
+ + {error && ( + + )} +
+ + {/* Preview */} + {newTotal !== null && ( +
+

+ New total:{" "} + + {newTotal.toFixed(2)} {token} + +

+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/lib/soroban.ts b/frontend/lib/soroban.ts new file mode 100644 index 0000000..4deb323 --- /dev/null +++ b/frontend/lib/soroban.ts @@ -0,0 +1,260 @@ +import type { WalletSession } from "@/lib/wallet"; + +const CONTRACT_ID = + process.env.NEXT_PUBLIC_STREAM_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"; + +const SOROBAN_RPC_URL = + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; + +const NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + +const MOCK_DELAY_MS = 1400; + +export interface CreateStreamParams { + recipient: string; + tokenAddress: string; + amount: bigint; + durationSeconds: bigint; +} + +export interface TopUpParams { + streamId: bigint; + amount: bigint; +} + +export interface CancelParams { + streamId: bigint; +} + +export interface WithdrawParams { + streamId: bigint; +} + +export interface SorobanResult { + success: true; + txHash: string; +} + +export class SorobanCallError extends Error { + constructor( + message: string, + public readonly code?: + | "InvalidAmount" + | "StreamNotFound" + | "Unauthorized" + | "StreamInactive" + | "AlreadyInitialized" + | "NotAdmin" + | "InvalidFeeRate" + | "NotInitialized" + | "WalletRejected" + | "NetworkError" + | "Unknown", + ) { + super(message); + this.name = "SorobanCallError"; + } +} + +type DurationUnit = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months"; + +const SECONDS_PER_UNIT: Record = { + seconds: BigInt(1), + minutes: BigInt(60), + hours: BigInt(3600), + days: BigInt(86400), + weeks: BigInt(604800), + months: BigInt(2592000), +}; + +export function toDurationSeconds(value: string, unit: DurationUnit): bigint { + const parsed = parseFloat(value); + if (isNaN(parsed) || parsed <= 0) { + throw new SorobanCallError("Duration must be a positive number.", "InvalidAmount"); + } + return BigInt(Math.round(parsed)) * SECONDS_PER_UNIT[unit]; +} + +export function toBaseUnits(value: string, decimals = 7): bigint { + const parsed = parseFloat(value); + if (isNaN(parsed) || parsed <= 0) { + throw new SorobanCallError("Amount must be a positive number.", "InvalidAmount"); + } + return BigInt(Math.round(parsed * 10 ** decimals)); +} + +export const TOKEN_ADDRESSES: Record = { + USDC: process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + XLM: process.env.NEXT_PUBLIC_XLM_ADDRESS ?? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN", + EURC: process.env.NEXT_PUBLIC_EURC_ADDRESS ?? "CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY", +}; + +export function getTokenAddress(symbol: string): string { + const address = TOKEN_ADDRESSES[symbol.toUpperCase()]; + if (!address) { + throw new SorobanCallError(`Unsupported token: ${symbol}`, "Unknown"); + } + return address; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function mockTxHash(): string { + return Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16), + ).join(""); +} + +async function mockCall(label: string): Promise { + console.info(`[soroban:mock] ${label}`); + await wait(MOCK_DELAY_MS); + return { success: true, txHash: mockTxHash() }; +} + +async function freighterCall( + publicKey: string, + method: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any[], +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sdk: any = await import("@stellar/stellar-sdk"); + const { Contract, TransactionBuilder, BASE_FEE } = sdk; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rpc: any = sdk.rpc ?? sdk.SorobanRpc; + + const { signTransaction } = await import("@stellar/freighter-api"); + + const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false }); + const account = await server.getAccount(publicKey); + const contract = new Contract(CONTRACT_ID); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) { + throw new SorobanCallError(`Simulation failed: ${simResult.error}`, "NetworkError"); + } + + const preparedTx = (rpc.assembleTransaction ?? sdk.assembleTransaction)(tx, simResult).build(); + + const { signedTxXdr, error: signError } = await signTransaction( + preparedTx.toXDR(), + { networkPassphrase: NETWORK_PASSPHRASE }, + ); + + if (signError) { + const msg = typeof signError === "string" ? signError : (signError as Error).message; + if (/reject|cancel|denied/i.test(msg)) { + throw new SorobanCallError("Transaction was rejected in wallet.", "WalletRejected"); + } + throw new SorobanCallError(msg, "Unknown"); + } + + const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE); + const sendResult = await server.sendTransaction(signedTx); + + if (sendResult.status === "ERROR") { + throw new SorobanCallError( + `Transaction failed: ${sendResult.errorResult?.toXDR?.("base64") ?? "unknown error"}`, + "NetworkError", + ); + } + + const txHash = sendResult.hash; + const SUCCESS = rpc.Api?.GetTransactionStatus?.SUCCESS ?? "SUCCESS"; + const FAILED = rpc.Api?.GetTransactionStatus?.FAILED ?? "FAILED"; + + for (let i = 0; i < 20; i++) { + await wait(1000); + const status = await server.getTransaction(txHash); + if (status.status === SUCCESS) return { success: true, txHash }; + if (status.status === FAILED) { + throw new SorobanCallError("Transaction failed on-chain.", "NetworkError"); + } + } + + throw new SorobanCallError("Transaction confirmation timed out.", "NetworkError"); +} + +export function toSorobanErrorMessage(error: unknown): string { + if (error instanceof SorobanCallError) return error.message; + if (error instanceof Error) { + const msg = error.message; + if (/reject|cancel|denied/i.test(msg)) return "Transaction was rejected in your wallet."; + if (/timeout/i.test(msg)) return "Transaction timed out. The network may be congested — please try again."; + if (/insufficient/i.test(msg)) return "Insufficient balance to complete this transaction."; + if (/simulation/i.test(msg)) return "Contract simulation failed. Check your inputs and try again."; + return msg; + } + return "An unexpected error occurred. Please try again."; +} + +export async function createStream( + session: WalletSession, + params: CreateStreamParams, +): Promise { + if (session.mocked) { + return mockCall(`create_stream recipient=${params.recipient} amount=${params.amount} duration=${params.durationSeconds}s`); + } + const { Address, nativeToScVal } = await import("@stellar/stellar-sdk"); + return freighterCall(session.publicKey, "create_stream", [ + new Address(session.publicKey).toScVal(), + new Address(params.recipient).toScVal(), + new Address(params.tokenAddress).toScVal(), + nativeToScVal(params.amount, { type: "i128" }), + nativeToScVal(params.durationSeconds, { type: "u64" }), + ]); +} + +export async function topUpStream( + session: WalletSession, + params: TopUpParams, +): Promise { + if (session.mocked) { + return mockCall(`top_up_stream stream_id=${params.streamId} amount=${params.amount}`); + } + const { Address, nativeToScVal } = await import("@stellar/stellar-sdk"); + return freighterCall(session.publicKey, "top_up_stream", [ + new Address(session.publicKey).toScVal(), + nativeToScVal(params.streamId, { type: "u64" }), + nativeToScVal(params.amount, { type: "i128" }), + ]); +} + +export async function cancelStream( + session: WalletSession, + params: CancelParams, +): Promise { + if (session.mocked) { + return mockCall(`cancel_stream stream_id=${params.streamId}`); + } + const { Address, nativeToScVal } = await import("@stellar/stellar-sdk"); + return freighterCall(session.publicKey, "cancel_stream", [ + new Address(session.publicKey).toScVal(), + nativeToScVal(params.streamId, { type: "u64" }), + ]); +} + +export async function withdrawFromStream( + session: WalletSession, + params: WithdrawParams, +): Promise { + if (session.mocked) { + return mockCall(`withdraw stream_id=${params.streamId}`); + } + const { Address, nativeToScVal } = await import("@stellar/stellar-sdk"); + return freighterCall(session.publicKey, "withdraw", [ + new Address(session.publicKey).toScVal(), + nativeToScVal(params.streamId, { type: "u64" }), + ]); +} diff --git a/frontend/package.json b/frontend/package.json index 95568cb..3b91ee5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^14.5.0", + "lucide-react": "^0.575.0", "next": "16.1.6", "next-themes": "^0.4.6", "react": "19.2.3", - "react-dom": "19.2.3", - "lucide-react": "^0.575.0" + "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3a13f90..15c7b97 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,