From e270f2ae4d780095acc80ff59553ce17bdbfbd58 Mon Sep 17 00:00:00 2001 From: Godsmiracle001 <118224169+Godsmiracle001@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:54:13 +0100 Subject: [PATCH] feat: implement core stream create validation and live streaming UX --- contracts/stream_contract/src/errors.rs | 2 + contracts/stream_contract/src/lib.rs | 16 +++- contracts/stream_contract/src/test.rs | 30 ++++--- ...cel_stream_after_partial_withdrawal.1.json | 7 +- .../test_cancel_stream_refunds_sender.1.json | 78 ++++++++++++++++++- frontend/components/IncomingStreams.tsx | 54 +++++++++++-- .../components/dashboard/dashboard-view.tsx | 13 +++- .../components/stream-creation/AmountStep.tsx | 44 +++++++++-- .../stream-creation/ScheduleStep.tsx | 53 ++++++++++--- .../stream-creation/StreamCreationWizard.tsx | 53 ++++++++++++- frontend/hooks/useStreamingAmount.ts | 70 +++++++++++++++++ frontend/lib/dashboard.ts | 11 ++- frontend/lib/soroban.ts | 65 ++++++++++++++++ frontend/lib/stellar.ts | 10 +++ 14 files changed, 460 insertions(+), 46 deletions(-) create mode 100644 frontend/hooks/useStreamingAmount.ts create mode 100644 frontend/lib/stellar.ts diff --git a/contracts/stream_contract/src/errors.rs b/contracts/stream_contract/src/errors.rs index d29374f..caf98ad 100644 --- a/contracts/stream_contract/src/errors.rs +++ b/contracts/stream_contract/src/errors.rs @@ -25,4 +25,6 @@ pub enum StreamError { NotInitialized = 8, /// Duration supplied to `create_stream` is zero. InvalidDuration = 9, + /// Supplied token address is not a valid token contract. + InvalidTokenAddress = 10, } diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index a103140..515ead9 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -8,7 +8,7 @@ mod types; #[cfg(test)] mod test; -use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, token, vec, Address, Env, InvokeError, Symbol}; use errors::StreamError; use events::{ @@ -113,6 +113,7 @@ impl StreamContract { /// # Errors /// - `InvalidAmount` — `amount` ≤ 0. /// - `InvalidDuration` — `duration` is 0. + /// - `InvalidTokenAddress` — `token_address` is not a token contract. pub fn create_stream( env: Env, sender: Address, @@ -129,6 +130,7 @@ impl StreamContract { if duration == 0 { return Err(StreamError::InvalidDuration); } + Self::validate_token_contract(&env, &token_address)?; let stream_id = next_stream_id(&env); let start_time = env.ledger().timestamp(); @@ -230,6 +232,18 @@ impl StreamContract { // ─── Internal Helpers ───────────────────────────────────────────────────── + /// Ensures the supplied token address implements the Soroban token interface. + fn validate_token_contract(env: &Env, token_address: &Address) -> Result<(), StreamError> { + match env.try_invoke_contract::( + token_address, + &Symbol::new(env, "decimals"), + vec![env], + ) { + Ok(Ok(_)) => Ok(()), + _ => Err(StreamError::InvalidTokenAddress), + } + } + fn calculate_claimable(stream: &Stream, now: u64) -> i128 { let elapsed = now.saturating_sub(stream.last_update_time); diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index ba2279c..b38a016 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -112,10 +112,6 @@ fn test_initialize_rejects_second_call() { let admin = Address::generate(&env); let treasury = Address::generate(&env); - assert_eq!(stream_id1, 1); - assert_eq!(stream_id2, 2); - assert!(client.get_stream(&stream_id1).is_some()); - assert!(client.get_stream(&stream_id2).is_some()); client.initialize(&admin, &treasury, &100); let result = client.try_initialize(&admin, &treasury, &100); assert_eq!(result, Err(Ok(StreamError::AlreadyInitialized))); @@ -269,6 +265,24 @@ fn test_create_stream_rejects_zero_duration() { assert_eq!(result, Err(Ok(StreamError::InvalidDuration))); } +#[test] +fn test_create_stream_rejects_invalid_token_address() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_contract(&env); + + // Account addresses are not token contracts. + let invalid_token = Address::generate(&env); + let result = client.try_create_stream( + &Address::generate(&env), + &Address::generate(&env), + &invalid_token, + &500, + &100, + ); + assert_eq!(result, Err(Ok(StreamError::InvalidTokenAddress))); +} + #[test] fn test_create_stream_emits_event() { let env = Env::default(); @@ -344,10 +358,6 @@ fn test_top_up_rejects_negative_amount() { let client = create_contract(&env); let id = client.create_stream(&sender, &Address::generate(&env), &token, &10_000, &100); - let contract_id = env.register(StreamContract, ()); - let client = StreamContractClient::new(&env, &contract_id); - let token_client = token::Client::new(&env, &token_address); - token_client.approve(&sender, &contract_id, &20_000, &1_000_000); assert_eq!( client.try_top_up_stream(&sender, &id, &-50), Err(Ok(StreamError::InvalidAmount)) @@ -522,10 +532,6 @@ fn test_withdraw_emits_event() { let client = create_contract(&env); let id = client.create_stream(&sender, &recipient, &token, &500, &100); - let contract_id = env.register(StreamContract, ()); - let client = StreamContractClient::new(&env, &contract_id); - let token_client = token::Client::new(&env, &token_address); - token_client.approve(&sender, &contract_id, &20_000, &1_000_000); // Advance time by 100 seconds to allow full withdrawal (500 tokens / 100 seconds = 5 tokens/sec) env.ledger().with_mut(|l| { l.timestamp += 100; diff --git a/contracts/stream_contract/test_snapshots/test/test_cancel_stream_after_partial_withdrawal.1.json b/contracts/stream_contract/test_snapshots/test/test_cancel_stream_after_partial_withdrawal.1.json index 742474d..b2efad3 100644 --- a/contracts/stream_contract/test_snapshots/test/test_cancel_stream_after_partial_withdrawal.1.json +++ b/contracts/stream_contract/test_snapshots/test/test_cancel_stream_after_partial_withdrawal.1.json @@ -154,6 +154,7 @@ ] ], [], + [], [] ], "ledger": { @@ -473,7 +474,7 @@ "val": { "i128": { "hi": 0, - "lo": 200 + "lo": 300 } } } @@ -648,7 +649,7 @@ "val": { "i128": { "hi": 0, - "lo": 200 + "lo": 300 } } }, @@ -721,7 +722,7 @@ "val": { "i128": { "hi": 0, - "lo": 100 + "lo": 0 } } }, diff --git a/contracts/stream_contract/test_snapshots/test/test_cancel_stream_refunds_sender.1.json b/contracts/stream_contract/test_snapshots/test/test_cancel_stream_refunds_sender.1.json index d49cea8..7315f50 100644 --- a/contracts/stream_contract/test_snapshots/test/test_cancel_stream_refunds_sender.1.json +++ b/contracts/stream_contract/test_snapshots/test/test_cancel_stream_refunds_sender.1.json @@ -132,6 +132,7 @@ ], [], [], + [], [] ], "ledger": { @@ -418,7 +419,7 @@ "val": { "i128": { "hi": 0, - "lo": 0 + "lo": 300 } } } @@ -549,6 +550,79 @@ 518400 ] ], + [ + { + "contract_data": { + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CBEPDNVYXQGWB5YUBXKJWYJA7OXTZW5LFLNO5JRRGE6Z6C5OSUZPCCEL", + "key": { + "vec": [ + { + "symbol": "Balance" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "amount" + }, + "val": { + "i128": { + "hi": 0, + "lo": 300 + } + } + }, + { + "key": { + "symbol": "authorized" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "clawback" + }, + "val": { + "bool": false + } + } + ] + } + } + }, + "ext": "v0" + }, + 518400 + ] + ], [ { "contract_data": { @@ -593,7 +667,7 @@ "val": { "i128": { "hi": 0, - "lo": 300 + "lo": 0 } } }, diff --git a/frontend/components/IncomingStreams.tsx b/frontend/components/IncomingStreams.tsx index 55d4fc5..80692e7 100644 --- a/frontend/components/IncomingStreams.tsx +++ b/frontend/components/IncomingStreams.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import type { Stream } from '@/lib/dashboard'; +import { useStreamingAmount } from '@/hooks/useStreamingAmount'; interface IncomingStreamsProps { streams: Stream[]; @@ -9,6 +10,40 @@ interface IncomingStreamsProps { withdrawingStreamId?: string | null; } +function formatTokenAmount(value: number): string { + if (!Number.isFinite(value)) return '0.0000'; + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value); +} + +const ClaimableAmount: React.FC<{ stream: Stream }> = ({ stream }) => { + const claimable = useStreamingAmount({ + deposited: stream.deposited, + withdrawn: stream.withdrawn, + ratePerSecond: stream.ratePerSecond, + lastUpdateTime: stream.lastUpdateTime, + isActive: stream.status === 'Active' && stream.isActive, + }); + + const liveRate = stream.status === 'Active' && stream.ratePerSecond > 0; + + return ( +
+ + {formatTokenAmount(claimable)} {stream.token} + + + {liveRate + ? `+${formatTokenAmount(stream.ratePerSecond)} ${stream.token}/sec` + : 'Stream inactive'} + +
+ ); +}; + const IncomingStreams: React.FC = ({ streams, onWithdraw, @@ -18,7 +53,7 @@ const IncomingStreams: React.FC = ({ const filteredStreams = filter === 'All' ? streams - : streams.filter(s => s.status === filter); + : streams.filter((s) => s.status === filter); const handleFilterChange = (e: React.ChangeEvent) => { setFilter(e.target.value as 'All' | 'Active' | 'Completed' | 'Paused'); @@ -29,7 +64,9 @@ const IncomingStreams: React.FC = ({

Incoming Payment Streams

-

Manage and withdraw from your active incoming streams

+

+ Manage and withdraw from your active incoming streams +

@@ -55,6 +92,7 @@ const IncomingStreams: React.FC = ({ Token Deposited Withdrawn + Claimable Status Actions @@ -62,10 +100,16 @@ const IncomingStreams: React.FC = ({ {filteredStreams.map((stream) => ( - {stream.id} + +
{stream.recipient}
+
Stream #{stream.id}
+ {stream.token} - {stream.deposited} {stream.token} - {stream.withdrawn} {stream.token} + {formatTokenAmount(stream.deposited)} {stream.token} + {formatTokenAmount(stream.withdrawn)} {stream.token} + + + s.id === streamId - ? { ...s, status: "Cancelled" as "Active" | "Completed" | "Paused" } + ? { ...s, status: "Cancelled", isActive: false } : s, ), activeStreamsCount: Math.max(0, prev.activeStreamsCount - 1), @@ -552,6 +553,9 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { status: "Active", deposited: parseFloat(data.amount), withdrawn: 0, + ratePerSecond: 0, + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, }; setSnapshot((prev) => { if (!prev) return prev; @@ -662,7 +666,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { } const recipient = streamForm.recipient.trim(); - if (!/^G[A-Z0-9]{55}$/.test(recipient)) { + if (!isValidStellarPublicKey(recipient)) { setStreamFormMessage({ text: "Recipient must be a valid Stellar public key.", tone: "error", @@ -939,7 +943,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { type="text" value={streamForm.recipient} onChange={(event) => updateStreamForm("recipient", event.target.value)} - placeholder="G... or 0x..." + placeholder="G..." /> @@ -1111,6 +1115,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { setShowWizard(false)} onSubmit={handleCreateStream} + walletPublicKey={session.publicKey} /> )} @@ -1146,4 +1151,4 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { )} ); -} \ No newline at end of file +} diff --git a/frontend/components/stream-creation/AmountStep.tsx b/frontend/components/stream-creation/AmountStep.tsx index 9cafcff..7e9eb50 100644 --- a/frontend/components/stream-creation/AmountStep.tsx +++ b/frontend/components/stream-creation/AmountStep.tsx @@ -6,6 +6,10 @@ interface AmountStepProps { onChange: (value: string) => void; error?: string; token?: string; + availableBalance?: string | null; + isBalanceLoading?: boolean; + balanceError?: string | null; + onSetMax?: () => void; } export const AmountStep: React.FC = ({ @@ -13,6 +17,10 @@ export const AmountStep: React.FC = ({ onChange, error, token, + availableBalance, + isBalanceLoading = false, + balanceError, + onSetMax, }) => { const inputRef = useRef(null); @@ -31,12 +39,31 @@ export const AmountStep: React.FC = ({
- +
+ +
+ {isBalanceLoading ? ( + Loading balance... + ) : availableBalance ? ( + + Balance: {availableBalance} {token} + + ) : null} + +
+
= ({ {error}

)} + {!error && balanceError && ( +

+ {balanceError} +

+ )}
{value && !error && parseFloat(value) > 0 && ( diff --git a/frontend/components/stream-creation/ScheduleStep.tsx b/frontend/components/stream-creation/ScheduleStep.tsx index 84709a0..5e84141 100644 --- a/frontend/components/stream-creation/ScheduleStep.tsx +++ b/frontend/components/stream-creation/ScheduleStep.tsx @@ -36,36 +36,48 @@ export const ScheduleStep: React.FC = ({ durationInputRef.current?.focus(); }, []); - const ratePerSecond = useMemo(() => { - if (!amount || !duration || parseFloat(amount) <= 0 || parseFloat(duration) <= 0) { + const totalSeconds = useMemo(() => { + if (!duration || parseFloat(duration) <= 0) { return null; } - const totalAmount = parseFloat(amount); - let totalSeconds = parseFloat(duration); + let seconds = parseFloat(duration); switch (durationUnit) { case "seconds": break; case "minutes": - totalSeconds *= 60; + seconds *= 60; break; case "hours": - totalSeconds *= 3600; + seconds *= 3600; break; case "days": - totalSeconds *= 86400; + seconds *= 86400; break; case "weeks": - totalSeconds *= 604800; + seconds *= 604800; break; case "months": - totalSeconds *= 2592000; // 30 days + seconds *= 2592000; // 30 days break; } + return seconds; + }, [duration, durationUnit]); + + const ratePerSecond = useMemo(() => { + if (!amount || !duration || parseFloat(amount) <= 0 || parseFloat(duration) <= 0) { + return null; + } + + const totalAmount = parseFloat(amount); + if (!totalSeconds || totalSeconds <= 0) { + return null; + } + return totalAmount / totalSeconds; - }, [amount, duration, durationUnit]); + }, [amount, duration, totalSeconds]); const formattedRate = useMemo(() => { if (!ratePerSecond) return null; @@ -79,6 +91,15 @@ export const ScheduleStep: React.FC = ({ } }, [ratePerSecond, token]); + const ratePerDayPreview = useMemo(() => { + if (!ratePerSecond) return null; + const dailyRate = ratePerSecond * 86400; + if (token === "USDC" || token === "EURC") { + return `$${dailyRate.toFixed(2)} / day`; + } + return `${dailyRate.toFixed(4)} ${token || ""} / day`; + }, [ratePerSecond, token]); + return (
@@ -167,6 +188,14 @@ export const ScheduleStep: React.FC = ({ {amount && duration && ratePerSecond && !error && (
+ {ratePerDayPreview && ( +
+

+ Live Stream Rate Preview +

+

{ratePerDayPreview}

+
+ )}

Stream Summary @@ -186,6 +215,10 @@ export const ScheduleStep: React.FC = ({ Stream Rate:{" "} {formattedRate}

+

+ Rate / Day:{" "} + {ratePerDayPreview} +

diff --git a/frontend/components/stream-creation/StreamCreationWizard.tsx b/frontend/components/stream-creation/StreamCreationWizard.tsx index 2ccf64f..65d3f74 100644 --- a/frontend/components/stream-creation/StreamCreationWizard.tsx +++ b/frontend/components/stream-creation/StreamCreationWizard.tsx @@ -6,6 +6,8 @@ import { RecipientStep } from "./RecipientStep"; import { TokenStep } from "./TokenStep"; import { AmountStep } from "./AmountStep"; import { ScheduleStep } from "./ScheduleStep"; +import { fetchTokenBalanceDisplay } from "@/lib/soroban"; +import { isValidStellarPublicKey } from "@/lib/stellar"; export interface StreamFormData { recipient: string; @@ -18,6 +20,7 @@ export interface StreamFormData { interface StreamCreationWizardProps { onClose: () => void; onSubmit: (data: StreamFormData) => Promise; + walletPublicKey?: string; } const STEPS = ["Recipient", "Token", "Amount", "Schedule"]; @@ -25,6 +28,7 @@ const STEPS = ["Recipient", "Token", "Amount", "Schedule"]; export const StreamCreationWizard: React.FC = ({ onClose, onSubmit, + walletPublicKey, }) => { const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); @@ -36,6 +40,41 @@ export const StreamCreationWizard: React.FC = ({ durationUnit: "days", }); const [errors, setErrors] = useState>>({}); + const [walletBalance, setWalletBalance] = useState(null); + const [walletBalanceLoading, setWalletBalanceLoading] = useState(false); + const [walletBalanceError, setWalletBalanceError] = useState(null); + + React.useEffect(() => { + if (!walletPublicKey || !formData.token) { + setWalletBalance(null); + setWalletBalanceError(null); + setWalletBalanceLoading(false); + return; + } + + let cancelled = false; + setWalletBalanceLoading(true); + setWalletBalanceError(null); + + fetchTokenBalanceDisplay(walletPublicKey, formData.token) + .then((balance) => { + if (cancelled) return; + setWalletBalance(balance); + }) + .catch(() => { + if (cancelled) return; + setWalletBalance(null); + setWalletBalanceError("Unable to fetch wallet balance right now."); + }) + .finally(() => { + if (cancelled) return; + setWalletBalanceLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [walletPublicKey, formData.token]); const updateFormData = (updates: Partial) => { setFormData((prev) => ({ ...prev, ...updates })); @@ -56,7 +95,7 @@ export const StreamCreationWizard: React.FC = ({ case 1: // Recipient if (!formData.recipient.trim()) { newErrors.recipient = "Recipient address is required"; - } else if (!/^G[A-Z0-9]{55}$/.test(formData.recipient.trim())) { + } else if (!isValidStellarPublicKey(formData.recipient.trim())) { newErrors.recipient = "Invalid Stellar public key format"; } break; @@ -72,6 +111,11 @@ export const StreamCreationWizard: React.FC = ({ const amount = parseFloat(formData.amount); if (isNaN(amount) || amount <= 0) { newErrors.amount = "Amount must be a positive number"; + } else if (walletBalance) { + const available = parseFloat(walletBalance); + if (!isNaN(available) && amount > available) { + newErrors.amount = "Amount exceeds wallet balance"; + } } } break; @@ -162,6 +206,13 @@ export const StreamCreationWizard: React.FC = ({ onChange={(value) => updateFormData({ amount: value })} error={errors.amount} token={formData.token} + availableBalance={walletBalance} + isBalanceLoading={walletBalanceLoading} + balanceError={walletBalanceError} + onSetMax={() => { + if (!walletBalance) return; + updateFormData({ amount: walletBalance }); + }} /> ); case 4: diff --git a/frontend/hooks/useStreamingAmount.ts b/frontend/hooks/useStreamingAmount.ts new file mode 100644 index 0000000..f1c70d9 --- /dev/null +++ b/frontend/hooks/useStreamingAmount.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +interface UseStreamingAmountParams { + deposited: number; + withdrawn: number; + ratePerSecond: number; + lastUpdateTime: number; + isActive: boolean; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function useStreamingAmount({ + deposited, + withdrawn, + ratePerSecond, + lastUpdateTime, + isActive, +}: UseStreamingAmountParams) { + const maxClaimable = useMemo( + () => Math.max(deposited - withdrawn, 0), + [deposited, withdrawn], + ); + + const [claimable, setClaimable] = useState(0); + const claimableRef = useRef(0); + + useEffect(() => { + let rafId: number | null = null; + const isStreaming = isActive && ratePerSecond > 0 && maxClaimable > 0; + let lastFrameTime = performance.now(); + + if (isStreaming) { + const elapsedSeconds = Math.max(0, Date.now() / 1000 - lastUpdateTime); + claimableRef.current = clamp(elapsedSeconds * ratePerSecond, 0, maxClaimable); + } else { + claimableRef.current = 0; + } + + const tick = (frameTime: number) => { + const deltaSeconds = Math.max(0, (frameTime - lastFrameTime) / 1000); + lastFrameTime = frameTime; + + const nextClaimable = isStreaming + ? clamp(claimableRef.current + ratePerSecond * deltaSeconds, 0, maxClaimable) + : 0; + + claimableRef.current = nextClaimable; + setClaimable(nextClaimable); + + if (isStreaming && nextClaimable < maxClaimable) { + rafId = requestAnimationFrame(tick); + } + }; + + rafId = requestAnimationFrame(tick); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [isActive, lastUpdateTime, maxClaimable, ratePerSecond]); + + return claimable; +} diff --git a/frontend/lib/dashboard.ts b/frontend/lib/dashboard.ts index ef605e7..7b70eab 100644 --- a/frontend/lib/dashboard.ts +++ b/frontend/lib/dashboard.ts @@ -1,4 +1,4 @@ -import type { BackendStream } from "./api-types"; +import type { BackendStream, BackendStreamEvent } from "./api-types"; export interface ActivityItem { id: string; @@ -14,10 +14,13 @@ export interface Stream { recipient: string; amount: number; token: string; - status: "Active" | "Completed" | "Paused"; + status: "Active" | "Completed" | "Paused" | "Cancelled"; deposited: number; withdrawn: number; date: string; + ratePerSecond: number; + lastUpdateTime: number; + isActive: boolean; } export interface DashboardSnapshot { @@ -100,6 +103,7 @@ async function fetchStreams( function mapBackendStreamToFrontend(s: BackendStream, counterparty: string): Stream { const deposited = toTokenAmount(s.depositedAmount); const withdrawn = toTokenAmount(s.withdrawnAmount); + const ratePerSecond = toTokenAmount(s.ratePerSecond); return { id: s.streamId.toString(), @@ -110,6 +114,9 @@ function mapBackendStreamToFrontend(s: BackendStream, counterparty: string): Str deposited, withdrawn, date: new Date(s.startTime * 1000).toISOString().split("T")[0], + ratePerSecond, + lastUpdateTime: s.lastUpdateTime, + isActive: s.isActive, }; } diff --git a/frontend/lib/soroban.ts b/frontend/lib/soroban.ts index 4deb323..f8d1bb3 100644 --- a/frontend/lib/soroban.ts +++ b/frontend/lib/soroban.ts @@ -84,6 +84,15 @@ export function toBaseUnits(value: string, decimals = 7): bigint { return BigInt(Math.round(parsed * 10 ** decimals)); } +export function fromBaseUnits(value: bigint | string, decimals = 7): string { + const units = typeof value === "bigint" ? value : BigInt(value); + const divisor = BigInt(10) ** BigInt(decimals); + const whole = units / divisor; + const fraction = (units % divisor).toString().padStart(decimals, "0").replace(/0+$/, ""); + + return fraction.length > 0 ? `${whole}.${fraction}` : whole.toString(); +} + export const TOKEN_ADDRESSES: Record = { USDC: process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", XLM: process.env.NEXT_PUBLIC_XLM_ADDRESS ?? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN", @@ -98,6 +107,62 @@ export function getTokenAddress(symbol: string): string { return address; } +export async function fetchTokenBalance( + publicKey: string, + tokenSymbol: string, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sdk: any = await import("@stellar/stellar-sdk"); + const { Address, Contract, TransactionBuilder, BASE_FEE, scValToNative } = sdk; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rpc: any = sdk.rpc ?? sdk.SorobanRpc; + + const tokenAddress = getTokenAddress(tokenSymbol); + const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false }); + const account = await server.getAccount(publicKey); + const tokenContract = new Contract(tokenAddress); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(tokenContract.call("balance", new Address(publicKey).toScVal())) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) { + throw new SorobanCallError(`Failed to fetch token balance: ${simResult.error}`, "NetworkError"); + } + + const rawResult = simResult?.result?.retval; + if (!rawResult) { + throw new SorobanCallError("Token balance query returned no value.", "NetworkError"); + } + + const nativeValue = scValToNative(rawResult); + if (typeof nativeValue === "bigint") { + return nativeValue; + } + if (typeof nativeValue === "number") { + return BigInt(Math.trunc(nativeValue)); + } + if (typeof nativeValue === "string") { + return BigInt(nativeValue); + } + + throw new SorobanCallError("Token balance query returned an invalid value.", "NetworkError"); +} + +export async function fetchTokenBalanceDisplay( + publicKey: string, + tokenSymbol: string, + decimals = 7, +): Promise { + const rawBalance = await fetchTokenBalance(publicKey, tokenSymbol); + return fromBaseUnits(rawBalance, decimals); +} + function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts new file mode 100644 index 0000000..39fcee6 --- /dev/null +++ b/frontend/lib/stellar.ts @@ -0,0 +1,10 @@ +import { StrKey } from "@stellar/stellar-sdk"; + +export function isValidStellarPublicKey(value: string): boolean { + const normalized = value.trim(); + if (!normalized) { + return false; + } + + return StrKey.isValidEd25519PublicKey(normalized); +}