How data and transactions actually move through conduit-app — as the code exists today, not as
it's meant to look eventually. See the root README.md for pages, styling rules,
and setup.
Page (client component, e.g. app/stream/[id]/page.tsx)
│
│ useEffect on mount / dependency change
▼
lib/stream.ts or lib/factory.ts (per-contract wrapper: withdraw, cancel, streamsBySender, ...)
│
│ encodes args as xdr.ScVal, picks a method name
▼
lib/soroban.ts
│
├─ simulateReadOnly(source, contractId, method, args) — reads, no signing
│
└─ invokeContract(source, contractId, method, args, signTx) — mutations
│
│ build tx → simulate → assemble → signTx(xdr) → submit → poll for receipt
▼
contexts/WalletContext.tsx → Soroban RPC → DripFactory / DripStream contract
Most pages own their own data fetching via useState + useEffect(() => { load...() }, [deps])
directly against the lib/*.ts wrappers. A shared @tanstack/react-query client
(lib/queryClient.ts) is also wired in: components/ReactQueryProvider.tsx wraps the app in a
QueryClientProvider, and lib/queryClient.ts exports a refreshStreamData() helper used to
invalidate cached stream data after mutations (e.g. bulk-withdraw on the dashboard).
Stream status (active/paused/ended/cancelled) and progress percentage are re-derived from
the raw StreamInfo on every render. deriveStatus is defined per-page in
app/dashboard/page.tsx and shared via lib/stream-compare.ts for app/streams/page.tsx.
The one thing that doesn't go through this chain is RateTicker: it takes the
withdrawable/ratePerSecond values already fetched by the page and extrapolates the display
value client-side every 100ms (balance + elapsed * rate). It never calls the contract — the
real value is only re-synced when the parent page re-fetches.
let serverInstance: SorobanRpc.Server | undefined;
function getServer(): SorobanRpc.Server {
if (!serverInstance) serverInstance = new SorobanRpc.Server(RPC_URL, { ... });
return serverInstance;
}Every page that touches the chain is a 'use client' component, so in principle the RPC client
is only ever needed in the browser. But Next.js's build step still statically prerenders these
pages' initial HTML on the server, which means it evaluates their module graph — including
lib/soroban.ts — during next build, before real env vars are configured. Constructing the
SorobanRpc.Server eagerly at module scope crashed every production build (it rejects a
non-https:// / empty RPC URL). Making it a lazily-constructed singleton means the constructor
only runs the first time a page actually calls invokeContract/simulateReadOnly, which never
happens during static generation.
contexts/WalletContext.tsx provides the shape (publicKey, connected, connect,
disconnect, signTx) the rest of the app depends on, and it's a real @stellar/freighter-api
integration, not a stub:
connect()calls Freighter'srequestAccess()to obtain the user's actual connected public key, persists the session, and watches for account/network changes.signTx(xdr)calls Freighter'ssignTransaction(xdr, ...)and returns a genuinely signed XDR.- This app is intentionally Freighter-only rather than a multi-wallet kit — see the comment at
the top of
WalletContext.tsxfor why@creit.tech/stellar-wallets-kitwas evaluated and rejected (its dependency tree pulls in ~300 extra packages for wallet support this app doesn't use).
Practical effect: invokeContract()'s pipeline (build → simulate → signTx → submit) runs
end-to-end with a real signature, for both read-only paths (simulateReadOnly — balances,
stream lists, /streams, /dashboard) and every mutating action (withdraw, cancel, pause,
resume, top-up, clawback, create) against testnet today. signTx/connect also route through
the Semaphore + Mutex concurrency controls documented at the top of WalletContext.tsx — worth
reading before adding a new mutation, since they determine how concurrent calls queue and how
disconnect() aborts in-flight ones.
| Page | Composes |
|---|---|
/streams |
StreamCard (one per stream, sender + recipient tabs) |
/stream/[id] |
StreamTimeline (progress viz) + StreamFlowPipe (animated flow) + StreamActions (role-gated buttons) + WithdrawButton + RateTicker |
/dashboard |
StreamCard + aggregate totals computed inline from all fetched streams |
/create |
Form built directly with react-hook-form + zod; includes template presets, cliff-date validation, and recipient balance check |
| every page | Navbar (via root layout) → ConnectButton → useWallet() |
StreamActions gates which buttons render by role (isSender/isRecipient) and by status
(isActive/isPaused/canAct), and funnels every mutating call through a local run(name, fn)
helper that tracks a single pending state so only one action can be in flight at a time.
Tests run via npm test (Vitest, jsdom environment). Coverage is now spread across the
codebase — not just lib/format.ts. The current test inventory:
| Area | Files |
|---|---|
| Pure utilities | lib/format.test.ts, lib/stream-compare.test.ts |
| UI components | components/ui/Button.test.tsx, components/ui/Badge.test.tsx, components/ThemeToggle.test.tsx, components/TokenSelector.test.tsx |
| Shared components | components/__tests__/ (ErrorBoundary, NetworkTroubleBanner, OfflineIndicator, etc.) |
| Pages | app/dashboard/__tests__/, app/streams/__tests__/, app/stream/[id]/__tests__/, app/create/__tests__/, app/profile/__tests__/, app/settings/__tests__/, app/streams/compare/__tests__/, app/__tests__/ (not-found, error) |
| Contexts | contexts/__tests__/ |
Component and page tests use React's createRoot + act directly (no React Testing Library),
with vi.mock for Next.js primitives (next/link, next/navigation), child components, and
lib/ wrappers. Tests that exercise data-fetching pages mock the factory and stream functions
and drive state through resolved/rejected Promises.
lib/soroban.ts, lib/stream.ts, and lib/factory.ts remain untested at unit level — they
require mocked Soroban RPC responses and are better covered by integration tests against a
local RPC node.