Skip to content

Latest commit

 

History

History
129 lines (103 loc) · 7.73 KB

architecture.md

File metadata and controls

129 lines (103 loc) · 7.73 KB

Architecture

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.


Request flow

  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.


Why lib/soroban.ts's RPC client is a lazy singleton

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.


Wallet integration — read this before building on top of mutations

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's requestAccess() to obtain the user's actual connected public key, persists the session, and watches for account/network changes.
  • signTx(xdr) calls Freighter's signTransaction(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.tsx for why @creit.tech/stellar-wallets-kit was 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.


Component composition

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.


Testing

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.