diff --git a/README.md b/README.md index e8b4659..9f1e1d8 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,18 @@ You'll sign two transactions: one `approve` on the token contract, then `create_ --- +## Architecture Decision Records + +Key design choices are documented in [`docs/adr/`](./docs/adr/README.md). Start there if you're wondering "why was it done this way?" before changing something fundamental. + +--- + +## Security + +Found a vulnerability? Please read our [Security Policy](./SECURITY.md) before disclosing. We prefer private disclosure via [GitHub Security Advisories](https://github.com/FlowwStar/FlowStar/security/advisories/new). + +--- + ## License MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1034311 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,80 @@ +# Security Policy + +## Scope + +The following are **in scope** for vulnerability reports: + +- **Smart contract** (`contracts/streaming/`) — logic errors, authorization bypasses, fund-loss vectors, integer overflow/underflow +- **Frontend** (`app/`, `components/`, `hooks/`, `lib/`) — XSS, CSRF, wallet-key exposure, data leakage +- **CI/CD pipeline** — supply-chain attacks, secret exposure in workflows + +The following are **out of scope**: + +- Vulnerabilities in third-party dependencies (report to their maintainers directly) +- The Stellar network or Soroban runtime itself +- Issues requiring physical access to a user's device +- Social engineering attacks + +--- + +## Reporting a Vulnerability + +**Preferred:** Use [GitHub Security Advisories](https://github.com/FlowwStar/FlowStar/security/advisories/new) to open a private advisory. This keeps the report confidential until a fix is ready. + +**Email fallback:** For urgent critical issues, email the maintainers at the address listed on the GitHub profile. Include: +1. A clear description of the vulnerability +2. Steps to reproduce or a proof-of-concept +3. Affected component(s) and version/commit hash +4. Your assessment of impact and severity + +Please **do not** open a public GitHub issue for security vulnerabilities. + +--- + +## Response Timeline + +| Stage | Target | +|---|---| +| Acknowledgment | Within 48 hours | +| Triage & severity classification | Within 1 week | +| Fix (Critical) | Within 72 hours of triage | +| Fix (High) | Within 1 week of triage | +| Fix (Medium) | Within 2 weeks of triage | +| Fix (Low) | Next scheduled release | + +We will keep you updated throughout the process and credit you in the release notes unless you prefer to remain anonymous. + +--- + +## Severity Classification + +We use a simplified severity scale: + +| Severity | Description | +|---|---| +| **Critical** | Direct loss of user funds, private key exposure, or complete contract takeover | +| **High** | Unauthorized access to user data, bypass of core authorization checks | +| **Medium** | Denial of service for individual users, data integrity issues without fund loss | +| **Low** | Minor information disclosure, cosmetic security issues | + +--- + +## Safe Harbor + +FlowStar is committed to working with security researchers. If you discover a vulnerability and report it responsibly under this policy: + +- We will not pursue legal action against you +- We will not refer you to law enforcement +- We will work with you to understand and resolve the issue quickly + +We ask that you: + +- Give us reasonable time to respond before public disclosure +- Avoid accessing or modifying user data beyond what is necessary to demonstrate the vulnerability +- Do not perform denial-of-service attacks or disrupt live services + +--- + +## Bug Bounty + +There is no formal bug bounty program at this time. We will publicly credit researchers who report valid vulnerabilities (unless anonymity is requested). diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 5fcf96c..9690aea 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import type { Metadata } from 'next' import { Navbar } from '@/components/layout/navbar' +import { Breadcrumb } from '@/components/layout/breadcrumb' import { MockModeBanner } from '@/components/layout/mock-mode-banner' import { PageErrorBoundary } from '@/components/error-boundary/page-error-boundary' @@ -11,6 +12,23 @@ export const metadata: Metadata = { export default function AppLayout({ children }: { children: ReactNode }) { return ( +
+ + Skip to content + + + +
+ {children} +
+ +
{ - const down = (e: KeyboardEvent) => { - // Don't fire when user is typing in a form input - const tag = (e.target as HTMLElement).tagName; - if (['INPUT','TEXTAREA','SELECT'].includes(tag)) return; - - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); setOpen(o => !o); - } - if ((e.metaKey || e.ctrlKey) && e.key === '/') { - e.preventDefault(); setShowHelp(o => !o); - } - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault(); router.push('/stream/create'); - } - if (e.key === 'Escape') { - setOpen(false); setShowHelp(false); - } - }; - - // G-then-key chords - let gPressed = false; - const chord = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName; - if (['INPUT','TEXTAREA','SELECT'].includes(tag)) return; - if (e.key === 'g') { gPressed = true; setTimeout(() => { gPressed = false; }, 1000); return; } - if (gPressed) { - if (e.key === 'd') router.push('/dashboard'); - if (e.key === 'c') router.push('/stream/create'); - gPressed = false; - } - }; - - window.addEventListener('keydown', down); - window.addEventListener('keydown', chord); - return () => { - window.removeEventListener('keydown', down); - window.removeEventListener('keydown', chord); - }; - }, [router]); - - return ( - <> - {/* Command Palette */} - {open && ( -
setOpen(false)}> -
e.stopPropagation()} - className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden"> - - - - - No results found. - - {ACTIONS.map(action => ( - { router.push(action.href); setOpen(false); }} - className="flex justify-between items-center px-3 py-2 rounded - cursor-pointer hover:bg-gray-100 text-sm"> - {action.label} - {action.shortcut && ( - - {action.shortcut} - - )} - - ))} - - -
-
- )} - - {/* Shortcut Help Overlay */} - {showHelp && ( -
setShowHelp(false)}> -
e.stopPropagation()}> -

Keyboard Shortcuts

- - - {[ - ['Cmd/Ctrl + K', 'Open command palette'], - ['Cmd/Ctrl + N', 'Create new stream'], - ['Cmd/Ctrl + /', 'Toggle this help'], - ['G then D', 'Go to dashboard'], - ['G then C', 'Go to create stream'], - ['Esc', 'Close dialogs'], - ].map(([key, desc]) => ( - - - - - ))} - -
- {key} - {desc}
-
-
- )} - - ); -} \ No newline at end of file +'use client' + +import { Command } from 'cmdk' +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' + +const ACTIONS = [ + { id: 'dashboard', label: 'Go to Dashboard', shortcut: 'G D', href: '/app' }, + { id: 'create', label: 'Create New Stream', shortcut: 'G C', href: '/app/create' }, + { id: 'streams', label: 'View All Streams', shortcut: '', href: '/app/streams' }, + { id: 'analytics', label: 'Analytics', shortcut: '', href: '/app/analytics' }, + { id: 'help', label: 'Keyboard Shortcut Help', shortcut: 'Ctrl+/', href: '#help' }, +] + +function FocusTrap({ + children, + onEscape, +}: { + children: React.ReactNode + onEscape: () => void +}) { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + const focusable = () => + Array.from( + el.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ) + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + onEscape() + return + } + if (e.key !== 'Tab') return + const nodes = focusable() + if (nodes.length === 0) return + const first = nodes[0] + const last = nodes[nodes.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + + el.addEventListener('keydown', handleKeyDown) + return () => el.removeEventListener('keydown', handleKeyDown) + }, [onEscape]) + + return
{children}
+} + +export function CommandPalette() { + const [open, setOpen] = useState(false) + const [showHelp, setShowHelp] = useState(false) + const router = useRouter() + const triggerRef = useRef(null) + + function closeAll() { + setOpen(false) + setShowHelp(false) + ;(triggerRef.current as HTMLElement | null)?.focus() + } + + useEffect(() => { + const down = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return + + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + triggerRef.current = document.activeElement as HTMLElement + setOpen((o) => !o) + } + if ((e.metaKey || e.ctrlKey) && e.key === '/') { + e.preventDefault() + triggerRef.current = document.activeElement as HTMLElement + setShowHelp((o) => !o) + } + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + e.preventDefault() + router.push('/app/create') + } + } + + let gPressed = false + const chord = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return + if (e.key === 'g') { + gPressed = true + setTimeout(() => { + gPressed = false + }, 1000) + return + } + if (gPressed) { + if (e.key === 'd') router.push('/app') + if (e.key === 'c') router.push('/app/create') + gPressed = false + } + } + + window.addEventListener('keydown', down) + window.addEventListener('keydown', chord) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keydown', chord) + } + }, [router]) + + return ( + <> + {open && ( + + )} + + {showHelp && ( + + )} + + ) +} diff --git a/components/NetworkMismatchModal.tsx b/components/NetworkMismatchModal.tsx index bea6ee0..d19425b 100644 --- a/components/NetworkMismatchModal.tsx +++ b/components/NetworkMismatchModal.tsx @@ -1,47 +1,102 @@ -import { getNetworkName, APP_NETWORK } from '@/lib/network'; - -interface Props { - walletNetwork: string; - onDismiss: () => void; -} - -export function NetworkMismatchModal({ walletNetwork, onDismiss }: Props) { - return ( -
-
-

- ⚠️ Wrong Network -

-

- Your wallet is connected to{' '} - {walletNetwork}. - FlowStar requires{' '} - {getNetworkName(APP_NETWORK)}. -

-

- Please open Freighter, go to Settings → Network, and switch to{' '} - {getNetworkName(APP_NETWORK)}. -

-
- - href="https://docs.freighter.app/docs/guide/usingFreighter#network-settings" - target="_blank" rel="noopener noreferrer" - className="flex-1 text-center text-sm bg-blue-600 text-white - rounded-lg py-2.5 hover:bg-blue-700 transition-colors" - > - Freighter Network Guide - - -
-
-
- ); -} \ No newline at end of file +'use client' + +import { useEffect, useRef } from 'react' +import { getNetworkName, APP_NETWORK } from '@/lib/network' + +interface Props { + walletNetwork: string + onDismiss: () => void +} + +export function NetworkMismatchModal({ walletNetwork, onDismiss }: Props) { + const dismissRef = useRef(null) + const linkRef = useRef(null) + const containerRef = useRef(null) + + // Auto-focus dismiss button on mount, restore on close + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null + dismissRef.current?.focus() + return () => { + previouslyFocused?.focus() + } + }, []) + + // Escape key closes modal + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + onDismiss() + return + } + // Focus trap: cycle Tab within modal + if (e.key === 'Tab' && containerRef.current) { + const focusable = Array.from( + containerRef.current.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onDismiss]) + + return ( +
+
+

+ ⚠️ Wrong Network +

+

+ Your wallet is connected to {walletNetwork}. FlowStar + requires {getNetworkName(APP_NETWORK)}. +

+

+ Please open Freighter, go to Settings → Network, and switch to{' '} + {getNetworkName(APP_NETWORK)}. +

+ +
+
+ ) +} diff --git a/components/layout/breadcrumb.tsx b/components/layout/breadcrumb.tsx new file mode 100644 index 0000000..a995f89 --- /dev/null +++ b/components/layout/breadcrumb.tsx @@ -0,0 +1,84 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { ChevronRight } from 'lucide-react' + +const SEGMENT_LABELS: Record = { + app: 'Dashboard', + streams: 'Streams', + create: 'Create Stream', + analytics: 'Analytics', + stream: 'Stream', + batch: 'Batch', +} + +function buildCrumbs(pathname: string) { + const segments = pathname.split('/').filter(Boolean) + const crumbs: { label: string; href: string }[] = [] + let path = '' + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + path += `/${seg}` + + // Skip the bare "app" segment — Dashboard link comes from /app + if (seg === 'app' && i === 0) { + crumbs.push({ label: 'Dashboard', href: '/app' }) + continue + } + + const label = SEGMENT_LABELS[seg] ?? `Stream #${seg}` + crumbs.push({ label, href: path }) + } + + return crumbs +} + +export function Breadcrumb() { + const pathname = usePathname() + + // Only render on nested pages (not the dashboard root itself) + if (pathname === '/app') return null + + const crumbs = buildCrumbs(pathname) + if (crumbs.length <= 1) return null + + return ( + + ) +} diff --git a/components/layout/notification-bell.tsx b/components/layout/notification-bell.tsx index 4f330e7..62b2e0e 100644 --- a/components/layout/notification-bell.tsx +++ b/components/layout/notification-bell.tsx @@ -41,6 +41,7 @@ export function NotificationBell() { const { notifications, unreadCount, markAllRead, clearAll } = useNotifications(address) const [open, setOpen] = useState(false) const ref = useRef(null) + const triggerRef = useRef(null) useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -48,8 +49,20 @@ export function NotificationBell() { setOpen(false) } } - if (open) document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + setOpen(false) + triggerRef.current?.focus() + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + } + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + } }, [open]) function handleToggle() { @@ -62,8 +75,11 @@ export function NotificationBell() { return (
{open && ( -
+

Notifications

{notifications.length > 0 && ( diff --git a/docs/adr/ADR-000-template.md b/docs/adr/ADR-000-template.md new file mode 100644 index 0000000..0b36dbd --- /dev/null +++ b/docs/adr/ADR-000-template.md @@ -0,0 +1,17 @@ +# ADR-000: Title + +## Status + +Proposed | Accepted | Superseded by ADR-NNN | Deprecated + +## Context + +What is the problem or situation that required a decision? Describe the forces at play — technical constraints, product requirements, team knowledge, timing pressures. + +## Decision + +What did we decide to do? + +## Consequences + +What are the trade-offs? What becomes easier? What becomes harder? What risks does this introduce? diff --git a/docs/adr/ADR-001-persistent-storage.md b/docs/adr/ADR-001-persistent-storage.md new file mode 100644 index 0000000..885b39e --- /dev/null +++ b/docs/adr/ADR-001-persistent-storage.md @@ -0,0 +1,19 @@ +# ADR-001: Persistent vs Instance Storage for Streams + +## Status + +Accepted + +## Context + +Soroban contracts have two storage tiers: **instance storage** (tied to the contract's own ledger entry, evicted when the contract is evicted) and **persistent storage** (independent ledger entries per key, with their own TTL). Stream data needs to survive for months or years — a vesting stream might run for 4 years. Instance storage TTLs are bound to the contract's own lifetime, which makes long-lived per-stream data fragile: if the contract entry's TTL lapses between interactions the entire instance is evicted, wiping all streams simultaneously. + +## Decision + +All per-stream data (`DataKey::Stream(stream_id)`, `DataKey::SentStreams(address)`, `DataKey::ReceivedStreams(address)`) is stored in **persistent storage** with TTL extensions on every write. The contract extends TTLs by approximately 30 days on each `create_stream`, `withdraw`, and `cancel` call. + +## Consequences + +- **Easier:** Streams survive indefinitely as long as they see at least one interaction roughly every 30 days, or until explicit archival/restore is implemented. +- **Harder:** Each stream is a separate ledger entry, so cross-stream queries (e.g., "get all streams for address") require maintaining an index (`SentStreams`/`ReceivedStreams` lists) rather than a single table scan. +- **Risk:** Very old, idle streams may still be evicted if no interaction occurs before the TTL expires. A future archival/restore flow should address this for long-running streams. diff --git a/docs/adr/ADR-002-client-side-unlock-calculation.md b/docs/adr/ADR-002-client-side-unlock-calculation.md new file mode 100644 index 0000000..4cd4538 --- /dev/null +++ b/docs/adr/ADR-002-client-side-unlock-calculation.md @@ -0,0 +1,24 @@ +# ADR-002: Client-Side Unlock Calculation + +## Status + +Accepted + +## Context + +The UI shows a live "unlocked so far" counter that updates every second. Two approaches were considered: + +1. **Poll the contract** — call `get_withdrawable` on every tick via RPC. +2. **Calculate client-side** — read stream parameters once, then compute `unlocked = cliffAmount + elapsed × amountPerSecond` locally in JavaScript on every tick. + +Polling every second would generate one RPC call per second per open stream detail page. Stellar's Soroban RPC is rate-limited and adds 200–600 ms of latency per call, making a smooth per-second counter impossible via polling. + +## Decision + +Unlock math runs **client-side** in `lib/stream-utils.ts`. The frontend fetches the stream record once (or on explicit refresh) and derives the current unlocked amount using `Date.now()` as the clock. The contract is only queried at action time (withdraw/cancel) and on page load. + +## Consequences + +- **Easier:** Smooth, real-time counter with zero additional RPC load. Works offline or under poor connectivity. +- **Harder:** The client clock can drift from the ledger's close time. In practice the drift is small (< 1 ledger close interval ≈ 5 s), and the contract is authoritative at transaction time anyway. +- **Risk:** If a user's system clock is significantly wrong, the displayed amount may differ from the contract's result. The withdraw button always shows the contract-confirmed amount after the transaction. diff --git a/docs/adr/ADR-003-mock-mode.md b/docs/adr/ADR-003-mock-mode.md new file mode 100644 index 0000000..dc3759e --- /dev/null +++ b/docs/adr/ADR-003-mock-mode.md @@ -0,0 +1,21 @@ +# ADR-003: Mock Mode for Development + +## Status + +Accepted + +## Context + +Developing against the live Soroban testnet requires a funded Freighter wallet, real RPC calls (200–600 ms latency), and occasional testnet outages. This slows down UI iteration and makes it impossible to work without an internet connection or a wallet extension installed. Early contributors reported spending more time managing testnet state than building features. + +## Decision + +The app supports a **mock contract layer** controlled by the `USE_MOCK` flag in `lib/contract.ts`. When enabled, all contract calls are intercepted and served from an in-memory store in `lib/mock-data.ts`. The mock layer implements the same TypeScript interface as the real contract integration so the rest of the app is unaware of the substitution. + +Mock mode is the default in development (`NODE_ENV === 'development'`) and is always disabled in production builds. + +## Consequences + +- **Easier:** UI work and component development require no wallet, no RPC, and no funded account. New contributors can run `npm run dev` and immediately see realistic stream data. +- **Harder:** Mock data can diverge from real contract behavior. Bugs that only surface with real RPC (serialization errors, fee estimation, auth failures) won't be caught in mock mode. +- **How to apply:** Always run at least one end-to-end test on testnet before merging changes to contract integration code. Mock mode is for UI iteration only. diff --git a/docs/adr/ADR-004-polling-vs-websocket.md b/docs/adr/ADR-004-polling-vs-websocket.md new file mode 100644 index 0000000..3fdf917 --- /dev/null +++ b/docs/adr/ADR-004-polling-vs-websocket.md @@ -0,0 +1,24 @@ +# ADR-004: Polling vs WebSocket for Updates + +## Status + +Accepted + +## Context + +The dashboard needs to reflect stream state changes (new streams, withdrawals, cancellations) without requiring a manual page refresh. Two approaches were considered: + +1. **WebSocket / server-sent events** — the server pushes updates to the client in real time. +2. **Adaptive polling** — the client periodically re-fetches stream data from the contract via RPC. + +Stellar does not expose a WebSocket stream for contract state at the Soroban RPC level (as of the time this decision was made). Building a server-side event relay would require additional infrastructure (a persistent server process, a database to track stream IDs per user) that is out of scope for a client-side-only deployment on Vercel/Netlify. + +## Decision + +The app uses **adaptive polling**: stream lists refresh every 30 seconds, and stream detail pages refresh every 10 seconds. The interval tightens to 5 seconds immediately after a user action (create/withdraw/cancel) to surface the confirmation quickly, then backs off. + +## Consequences + +- **Easier:** No server infrastructure required. The app deploys as a static Next.js export. Polling logic lives entirely in `hooks/use-streams.ts`. +- **Harder:** Updates are not instantaneous — there is up to a 10–30 second lag before the UI reflects another user's action on the same stream (e.g., sender cancels while recipient is viewing the stream). +- **Risk:** Heavy polling from many simultaneous users could hit Soroban RPC rate limits. If this becomes an issue, the polling interval should be increased or a caching proxy introduced. diff --git a/docs/adr/ADR-005-bigint-token-amounts.md b/docs/adr/ADR-005-bigint-token-amounts.md new file mode 100644 index 0000000..4951755 --- /dev/null +++ b/docs/adr/ADR-005-bigint-token-amounts.md @@ -0,0 +1,19 @@ +# ADR-005: BigInt for Token Amounts + +## Status + +Accepted + +## Context + +Soroban contracts represent token amounts as `i128` (signed 128-bit integer) in stroops (1 XLM = 10,000,000 stroops). JavaScript's `number` type is a 64-bit IEEE 754 float, which can only represent integers exactly up to 2^53 − 1 (≈ 9 × 10^15). A stream of 1 billion XLM expressed in stroops is 10^16, which exceeds the safe integer range and would introduce silent rounding errors. + +## Decision + +All token amounts throughout the codebase use JavaScript's native **`bigint`** type. The Stellar JS SDK returns `i128` values as `bigint`. Formatting for display (e.g., "1,234.56 XLM") is done by explicit division with controlled decimal rounding in `lib/stream-utils.ts` — never by converting to `number` first. + +## Consequences + +- **Easier:** No precision loss for any realistic token amount. The math matches the contract exactly. +- **Harder:** `bigint` and `number` cannot be mixed in arithmetic — all numeric operations in the stream layer must use `bigint` literals and explicit conversions. UI code must call formatter functions rather than doing inline math. +- **Risk:** Developer error (accidentally casting to `number` via `parseInt` or `parseFloat`) can silently reintroduce precision loss. Code review should flag any `Number(amount)` or `+amount` cast applied to a token amount variable. diff --git a/docs/adr/ADR-006-freighter-wallet-strategy.md b/docs/adr/ADR-006-freighter-wallet-strategy.md new file mode 100644 index 0000000..e292b1f --- /dev/null +++ b/docs/adr/ADR-006-freighter-wallet-strategy.md @@ -0,0 +1,21 @@ +# ADR-006: Freighter-Only Wallet Strategy + +## Status + +Accepted + +## Context + +Stellar has several wallet options: Freighter (browser extension), LOBSTR, xBull, Albedo (web-based signer), and WalletConnect-compatible mobile wallets. Supporting all of them from day one requires integrating the Stellar Wallets Kit (SWK) or writing multiple adapter layers, and each wallet has different API surfaces for signing XDR envelopes. + +The immediate goal was to ship a working DeFi primitive on Soroban testnet. Breadth of wallet support was deferred to reduce scope. + +## Decision + +The initial release supports **Freighter only** via `@stellar/freighter-api`. The wallet provider (`components/providers/wallet-provider.tsx`) is structured around a `WALLET_OPTIONS` array to make adding new wallets straightforward — each option is an object with `id`, `name`, `detail`, and a `connect` function. + +## Consequences + +- **Easier:** Single integration path, well-documented API, and Freighter is the most widely used Stellar browser wallet among developers. +- **Harder:** Users without Freighter cannot use the app. Mobile users are blocked entirely. +- **Roadmap:** The next wallet to add is xBull (also browser extension, similar API). After that, Albedo enables users without a browser extension. WalletConnect would unlock mobile. Each can be added as a new entry in `WALLET_OPTIONS` without changing the rest of the app. diff --git a/docs/adr/ADR-007-integer-division-dust.md b/docs/adr/ADR-007-integer-division-dust.md new file mode 100644 index 0000000..1f4eafd --- /dev/null +++ b/docs/adr/ADR-007-integer-division-dust.md @@ -0,0 +1,24 @@ +# ADR-007: Integer Division Dust Handling + +## Status + +Accepted + +## Context + +The contract computes `amount_per_second = deposited_amount / duration_seconds` using integer division. For a stream of 100 XLM (1,000,000,000 stroops) over 86,400 seconds (1 day), the exact rate is 11,574.074… stroops/second. Integer division truncates to 11,574. Over 86,400 seconds that releases 999,993,600 stroops — leaving 6,400 stroops (0.00064 XLM) permanently locked in the contract as **dust**. + +Options considered: +1. **Refund dust to sender at creation** — requires the contract to call back into the token contract at creation time, adding complexity and a second token transfer. +2. **Credit dust to recipient on final withdraw** — requires tracking whether a full-duration withdraw has occurred. +3. **Accept dust** — document the behavior and bound the maximum loss. + +## Decision + +**Accept the dust.** The maximum dust per stream is `duration_seconds - 1` stroops, which for even a 10-year stream (315,360,000 seconds) is less than 0.032 XLM. This is well within acceptable rounding tolerance for any realistic stream amount. The behavior is documented in contract comments. + +## Consequences + +- **Easier:** No additional contract logic or extra token transfers. The contract remains simple and auditable. +- **Harder:** Power users streaming very small amounts over very long durations will see a slightly smaller final withdrawal than expected. The frontend should display a "~" on estimated amounts to signal this. +- **Bounded risk:** The maximum dust is always `< duration_seconds` stroops — approximately 1 stroop per second of stream duration — which is negligible compared to any meaningful stream amount. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..3b4544d --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,30 @@ +# Architecture Decision Records + +This directory captures the significant technical decisions made in FlowStar — what was decided, why, and what the trade-offs are. Use these records to understand the reasoning behind the current design before changing it. + +## Index + +| ADR | Title | Status | +|---|---|---| +| [ADR-000](./ADR-000-template.md) | ADR Template | Template | +| [ADR-001](./ADR-001-persistent-storage.md) | Persistent vs Instance Storage for Streams | Accepted | +| [ADR-002](./ADR-002-client-side-unlock-calculation.md) | Client-Side Unlock Calculation | Accepted | +| [ADR-003](./ADR-003-mock-mode.md) | Mock Mode for Development | Accepted | +| [ADR-004](./ADR-004-polling-vs-websocket.md) | Polling vs WebSocket for Updates | Accepted | +| [ADR-005](./ADR-005-bigint-token-amounts.md) | BigInt for Token Amounts | Accepted | +| [ADR-006](./ADR-006-freighter-wallet-strategy.md) | Freighter-Only Wallet Strategy | Accepted | +| [ADR-007](./ADR-007-integer-division-dust.md) | Integer Division Dust Handling | Accepted | + +## How to add a new ADR + +1. Copy `ADR-000-template.md` to `ADR-NNN-short-title.md` +2. Fill in all sections +3. Add a row to the index above +4. Link the ADR from the relevant code or PR for context + +## Statuses + +- **Proposed** — under discussion, not yet decided +- **Accepted** — the current approach +- **Superseded by ADR-NNN** — replaced by a later decision +- **Deprecated** — no longer relevant