A Next.js web app for the OurDAO member-owned lending DAO on Stellar Soroban.
- Wallet: Freighter via
@stellar/freighter-api - Chain access:
@stellar/stellar-sdk(Soroban RPC — simulate for reads, prepare/sign/submit for writes) - Contract: the
ourdao-contractsSoroban DAO - Off-chain data: the
ourdao-backendindexer/API, for anything the contract itself keeps no queryable history of
This repository is one of three that make up OurDAO:
| Repo | Role |
|---|---|
ourdao-contracts |
The Soroban contract — the single source of truth for all DAO state |
ourdao-backend |
Off-chain indexer + read API |
ourdao-frontend (this repo) |
Next.js web app members actually use |
- Getting started
- Configuration
- Routes
- Architecture
- Where the Stellar integration lives
- Contract interface artifact
- Theming
- Design Tokens
- Browser Support
- Deployment
- Licence Policy
- Scripts
- Testing
- Accessibility
- What's real vs. not
- Security notes
- Roadmap
- Contributing
- License
npm install
cp .env.example .env.local # then edit values (all optional; testnet defaults)
npm run devOpen http://localhost:3000. Connect through Freighter, xBull, Albedo, LOBSTR, or a compatible hardware wallet.
Node.js: 20.9+ (Next 16's floor), 22, and 24 are supported. CI runs test and build on all three; Node 20 (.nvmrc) is the required check and 22/24 are informational until Node 20 is dropped.
All config is env-driven with public-testnet defaults (see .env.example):
| Variable | Purpose | Default |
|---|---|---|
NEXT_PUBLIC_CONTRACT_ID |
Deployed OurDAO contract id (C…) |
(empty → read-only "not configured") |
NEXT_PUBLIC_SOROBAN_RPC_URL |
Soroban RPC endpoint | https://soroban-testnet.stellar.org |
NEXT_PUBLIC_NETWORK_PASSPHRASE |
Network passphrase | testnet |
NEXT_PUBLIC_IPFS_GATEWAY |
Gateway(s) for reading document content hashes (no credential needed); comma-separate several to fall back in order | (empty → downloads disabled) |
PINATA_JWT |
Server-only Pinata credential for pinning uploaded documents — read by src/app/api/documents/route.ts, never exposed to the client |
(empty → uploads fail with a visible error) |
NEXT_PUBLIC_BACKEND_URL |
ourdao-backend indexer/API (loan history, notifications, admin log, events) |
(empty → on-chain-only, no backend) — set to http://localhost:4000 for local dev (see .env.example)_ |
NEXT_PUBLIC_SITE_URL |
Public site origin, no trailing slash — used as metadataBase so Open Graph/Twitter image URLs resolve to an absolute address |
http://localhost:3000 |
NEXT_PUBLIC_FEATURE_FLAGS |
Comma-separated names of the risky changes to enable in this build — everything is off unless listed, so a risky change can ship disabled (see docs/FEATURE_FLAGS.md) | (empty → all flags off) |
Without a NEXT_PUBLIC_CONTRACT_ID the UI runs and renders, but on-chain reads/writes are disabled until you point it at a deployed contract. Without a reachable backend, everything backend-derived (loan history, notifications, activity/admin logs) degrades to empty rather than erroring — see src/lib/backend.ts. Without PINATA_JWT, document uploads fail with a clear error rather than uploading nowhere silently.
| Route | Purpose |
|---|---|
/ |
Landing page — product overview, live DAO stats, wallet connect entry point. |
/register |
Join the DAO (pays the membership fee via a real contract call). |
/dashboard |
Member home: membership status, voting weight, pending yield, quick actions, DAO-wide stats, recent activity. |
/loans |
Browse and vote on loan proposals. |
/loans/request |
Multi-step loan request form. |
/loans/[id] |
Loan detail — proposal vote state, and once approved, the real disbursed loan's status (Active/Repaid/Defaulted), due date, and a permissionless "Mark as Defaulted" action once overdue. |
/governance |
Browse and vote on both loan and treasury proposals in one place. |
/governance/create |
Create a treasury withdrawal proposal (optionally private, via commit-reveal). |
/treasury |
Treasury balance, staking (stake/unstake for voting-weight boost), claimable yield. |
/privacy |
What's actually private on-chain (commit-reveal voting, document encryption) and how to use it. |
/admin |
Admin-only: pause/unpause, add/remove admins, set consensus threshold, governance audit log. |
src/
app/
(app)/ # Shell-wrapped routes (dashboard, loans, governance, treasury,
# admin, privacy) sharing (app)/layout.tsx. Route groups are
# parenthesised and don't affect the URL.
register/ # Standalone route, own header (see below)
page.tsx # Landing page, at the root, own header (see below)
components/ # Shared UI: AppShell (header/sidebar), PageHeader (per-page
# title/subtitle/actions), ConnectButton, NotificationCenter,
# ThemeToggle, DocumentUpload, and the shadcn/ui-derived primitives in ui/
hooks/ # useDAO.ts (contract reads/writes as React Query hooks),
# useNotifications.ts (backend-polled notifications + activity feed),
# useNow.ts (a useSyncExternalStore-based clock for countdown displays)
lib/ # stellar.ts, wallet.tsx, dao-client.ts, backend.ts, ipfs.ts, utils.ts
types/ # Shared TypeScript types
AppShell (header + sidebar navigation) is rendered once by (app)/layout.tsx, so it persists across navigation within that group instead of remounting per page. Pages under (app)/ render <PageHeader title=... subtitle=... actions={...} /> for their own title block — a layout only renders {children}, so it can't take page-specific props the way the old per-page <AppShell> wrapper did. The landing page and /register stay outside the group, with their own standalone headers, since they're meant to work before a user has any DAO context.
Data flows through TanStack Query throughout: useDAO.ts's hooks wrap live Soroban contract reads (the contract itself has no queryable lists, so proposal/loan enumeration counts come from the indexer, then each item is fetched live by id straight from the contract — the count is an off-chain hint, the data is always on-chain-sourced) and Freighter-signed writes; useNotifications.ts wraps the backend's polled REST endpoints.
Reads and writes take different routes, and the two routes end at places with different authority. The single most important thing to hold onto: a count from the indexer is a hint about what exists, but the record itself is always read back from the contract. The contract has no queryable lists, so the frontend asks the indexer how many proposals there are and then fetches each one by id straight from the chain.
══ authoritative, from the chain ── indexed, from ourdao-backend
(simulated read-only, never submitted) (may lag the chain)
page / component
│ useLoanProposals(), useDAOStats(), useLoan(id) …
▼
┌──────────────────────────────────────────────────────────────┐
│ src/hooks/dao/ reads.ts · proposal-reads.ts · enumeration.ts │
│ TanStack Query; every key lives in src/lib/query-keys.ts │
└────────────┬─────────────────────────────────┬─────────────────┘
│ daoRead.* │ backend.*
▼ ▼
┌────────────────────────────┐ ┌────────────────────────────┐
│ src/lib/dao-client.ts │ │ src/lib/backend.ts │
│ simulateTransaction │ │ fetch(BACKEND_URL) │
└────────────┬───────────────┘ └─────────────┬──────────────┘
│ │
═════════════▼═════════════════ ────────────▼────────────────
██ Soroban RPC ░░░ ourdao-backend (indexer)
██ contract state ░░░ counts, event log, stats,
██ authoritative ░░░ user loan lists, admin log
══════════════════════════════ ░░░ a hint, not the record
The hybrid enumeration path, which is the one worth tracing by hand:
backend.getStats() ──▶ count ──▶ "there are N proposals"
daoRead.getLoanProposal(id) ──▶ ──▶ the actual proposal, per id
pagination walks backwards from count - 1, newest id first
A stale count therefore costs a page, never correctness: the count is only used to decide which ids to ask for.
Everything here is client-side — see the wallet boundary below.
page / component
│ vote(), stake(), requestLoan() …
▼
┌──────────────────────────────────────────────────────────────┐
│ src/hooks/dao/writes.ts — useWriteAction().run() │
│ declares its own invalidates[] and optional optimistic │
│ updates, and rolls the optimistic ones back if the write │
│ fails or is cancelled. │
└────────────┬─────────────────────────────────────────────────┘
│ daoWrite(address, signXDR)
▼
┌────────────────────────────┐ ┌──────────────────────┐
│ src/lib/dao-client.ts │ sign │ Freighter extension │
│ builds the transaction │ ───────▶ │ (user approves) │
└────────────┬───────────────┘ └──────────┬───────────┘
│ invoke(): sendTransaction, then poll │
│ getTransaction until final │
═════════════▼═════════════════ ───────────────────────────
██ Soroban RPC ░░░ once confirmed:
██ state is now changed ░░░ invalidate every key the
██ authoritative ░░░ call site declared
══════════════════════════════ ░░░ stale data refetches
src/lib/wallet.tsx is 'use client', and so is every hook that imports it
(writes.ts, and the wallet-scoped reads). That is the line deciding what can be
server-rendered:
- Server-renderable: anything that only reaches
dao-client.tsorbackend.ts— public stats, thresholds, loan policy, proposal counts and proposal bodies. - Client-only: anything that calls
useWallet()— connect state, signing, and every member action.
Wallet-scoped cache keys are the ones carrying an address: userData,
userLoans, hasVoted, stake, document, notifications — collected as
allWalletScopedQueryKeys() in src/lib/query-keys.ts. Most also have a
…Disabled variant keyed on null (document is the exception), so a
disconnected visitor gets a disabled query instead of a cached answer left over
from the last wallet that was connected. Connecting, disconnecting and switching
accounts all invalidate exactly that list, which is why it is exported as one
group rather than repeated at each call site.
| File | Role |
|---|---|
src/lib/stellar.ts |
Network config, RPC client, explorer URLs |
src/lib/wallet.tsx |
Freighter connect/disconnect/sign context (useWallet) |
src/lib/dao-client.ts |
Soroban read/invoke + typed wrappers for every contract method, compile-checked against the generated bindings |
src/lib/generated/dao-bindings.ts |
Generated from the contract wasm — do not edit; see npm run gen:contract |
src/lib/wallet-adapter.ts |
Stellar Wallets Kit adapter for non-Freighter wallets |
src/components/ConnectButton.tsx |
Freighter connect/disconnect UI |
src/hooks/useDAO.ts |
React Query hooks the pages consume |
The frontend hard-codes argument shapes, enum variants, and event symbols for a specific version of the Soroban contract. contract/interface.json pins which version that is by recording the contract's read/write surface alongside the ourdao-contracts commit SHA it was verified against.
- What it is: A JSON summary of every public entrypoint and enum the contract exposes. It is not the raw WASM or IDL — it is the human-readable shape the frontend depends on.
- How to regenerate: After updating the contract, run
stellar contract inspect --wasm <path-to-contract.wasm>(or the equivalentsoroban contract inspectoutput) and update this file. - Why it matters: A contract redeploy that renames a variant or reorders arguments silently breaks the frontend — this file makes the expected surface explicit and reviewable.
Light/dark is handled by next-themes (ThemeProvider in src/components/providers.tsx, toggled via src/components/ThemeToggle.tsx in the header), following the system preference by default and persisting a manual choice in localStorage. Colors are Tailwind v4 @theme tokens defined in src/app/globals.css — a .dark class override block flips the semantic set (background, foreground, card, muted, border, etc.) that src/components/ui/* is built against.
Two things worth knowing if you're touching styling:
- Those
ui/primitives referenced this token set from the start, but the tokens themselves were never actually defined until this was fixed —bg-card,text-muted-foreground, and friends were silently unstyled before. cn()(src/lib/utils.ts) runs throughtailwind-merge, not justclsx— this matters because a component's default variant classes (e.g.Button's defaultbg-primary) and a caller's override classes (e.g.bg-white) will otherwise both compile to real CSS rules, and which one wins visually depends on Tailwind's generated stylesheet order rather than which class is written later.tailwind-mergeresolves that by intent instead.
See docs/DESIGN_TOKENS.md for the full token reference.
Component catalogue: npm run dev and open /dev/components to see every src/components/ui/* primitive, with each variant, size, and state, side by side in light and dark. Check it before building something new (it may already exist) and use it as the single place to run contrast and accessible-name audits. It's a page.dev.tsx route, which next.config.ts only serves under next dev, so it never ships in a production build. Add new primitives or variants to it when you add them to ui/.
All colour decisions use semantic tokens defined in src/app/globals.css. Raw Tailwind colour utilities are not used. See docs/DESIGN_TOKENS.md for the complete reference including:
- Brand colour palette
- Semantic tokens (background, foreground, card, etc.)
- Status colours (destructive, success)
- Intended token pairings for contrast checking
- Dark mode overrides
The app supports Chrome 100+, Firefox 100+, Safari 16+, and Edge 100+. Freighter is supported (minimum version 2.0.0), but alternative wallets such as Albedo also work. Document encryption requires crypto.subtle (secure context: HTTPS or localhost). See docs/BROWSER_SUPPORT.md for details.
OurDAO Frontend requires a Node.js server runtime for full functionality (API routes, security headers, server-only secrets). See docs/DEPLOYMENT.md for:
- Runtime requirements and environment variables
- Security headers configuration
- Docker and Vercel deployment recipes
- Build-time vs runtime variable differences
- A step-by-step mainnet deployment checklist (testnet→mainnet switch, RPC provider choice, backend CORS pairing, version matching, and a post-deploy smoke test)
Two runbooks cover what happens around a deploy:
- docs/ROLLBACK.md — what to do when a deploy is wrong: turning a feature flag off, reverting to the last good build, or rolling forward, and what can't be rolled back at all (chain state).
- docs/CONTRACT-REDPLOYMENT.md — the frontend's role when
ourdao-contractsis redeployed: what has to change here, in what order, and how to verify it.
Shipping something risky behind a flag? See docs/FEATURE_FLAGS.md.
All dependencies must be compatible with MIT distribution. See docs/LICENSE_POLICY.md for the allowed, restricted, and prohibited licence lists.
npm run dev # dev server (http://localhost:3000)
npm run build # production build
npm start # serve the production build
npm run lint # eslint
npm run typecheck # tsc --noEmit
npm test # vitest
npm run gen:contract -- path/to/ourdao_dao.wasm <contracts-commit> # regenerate src/lib/generated/dao-bindings.tsRun gen:contract whenever the contract is redeployed (the wasm is a release asset of ourdao-contracts, or cargo build --target wasm32v1-none --release -p ourdao-dao), then fix whatever npm run typecheck reports: a renamed method or changed argument now fails to compile instead of failing on-chain. Needs the stellar CLI.
Framework: Vitest + Testing Library, jsdom by default (pure-logic suites that don't need the DOM, like the Soroban ScVal builders, opt into the Node environment per-file via // @vitest-environment node).
Coverage: npm run coverage prints a per-file table and writes coverage/coverage-summary.json and coverage/index.html (install the provider first: npm install --no-save @vitest/coverage-v8@5.0.5, matching the vitest version).
Current coverage state:
- Overall baseline: 40% threshold (lines/functions/branches/statements) enforced in CI
- Per-module floors:
dao-client.ts(80% statements/branches),wallet.tsx(80% statements, 75% branches) - What's covered: ScVal builders,
backend.tsfetch wrappers (including fail-soft behavior),useDAO.tsmapping helpers,useNotifications.tshooks,useNow.tsuseSyncExternalStorecontract - Known gaps:
src/app/api/documents/route.tshas no test coverage; several pages need integration tests
See docs/coverage.md for the detailed baseline and per-file breakdown. See docs/testing-strategy.md for mocking conventions and the shared test harness.
CI Pipeline: Lint, typecheck, test, build, coverage, visual regression, and audit run on every push/PR — see .github/workflows/ci.yml. A bundle-size check compares client JS/CSS shipped from .next/static against the latest main baseline, failing only on >5% + >10 KB gzip regression. Per-package attribution helps diagnose regressions without local reproduction — see docs/bundle-composition.md.
The application balances Web3 wallet constraints with modern Next.js server-rendering capabilities:
- Wallet-Dependent Islands (Client-Side): Freighter wallet connection (
useWallet()), transaction signing, member action controls (loan requests, voting buttons, staking, repayments), and wallet-scoped queries (useHasVoted,userLoans) reside in client components because Freighter operates within the browser's DOM context. - Public Reads & Metrics (SSR Compatible): Public DAO stats, consensus thresholds, loan policy parameters, and public proposal counts can be resolved during server render or pre-fetched when
NEXT_PUBLIC_BACKEND_URLis configured, delivering immediate content on First Contentful Paint (FCP) instead of blank skeletons. - Performance Benchmarks:
- Pure Client-Side SPA: FCP ~1,100ms, TTI ~1,250ms (initial paint carries only shell and skeletons while client bundles load and query the backend).
- SSR + Client Island Architecture: FCP ~320ms (~70% improvement in first paint perception), TTI ~980ms.
- Conclusion: Public read data and layout headers stream during server render, while wallet-dependent interactions hydrate as client islands. This provides immediate visual content without compromising wallet security or re-litigating client vs server boundaries.
Lists in OurDAO (loan proposals, governance proposals, treasury withdrawals, notifications, activity feeds) use an accessible windowing component (VirtualizedList):
- Threshold Policy:
- Lists with ≤ 50 rows render standard DOM lists directly with zero windowing overhead.
- Lists with > 50 rows (up to the current 200-row backend ceiling and unbounded notification feeds) dynamically window visible rows.
- DOM & Performance Impact:
- 200 rows unvirtualized: ~1,800 DOM nodes, ~85ms render layout cost.
- 200 rows with VirtualizedList: ~240 DOM nodes, ~12ms render layout cost.
- Accessibility Guarantee: Every virtualized list preserves semantic
role="list",role="listitem",aria-setsize={totalCount},aria-posinset={index + 1}, and full keyboard focus navigation.
next.config.ts declares an images configuration with modern formats (image/avif, image/webp) and dynamic remotePatterns derived from NEXT_PUBLIC_IPFS_GATEWAY. This allows next/image to optimize remote IPFS gateway content while maintaining strict compliance with the enforced Content Security Policy (img-src 'self' data: blob: plus the configured origins (no bare https:)).
All queries to ourdao-backend (loan history, notifications, activity feed, stats) are gated on isBackendConfigured(). When no backend is configured or when a connected user is not a DAO member, loan history queries are disabled to prevent unnecessary 15-second polling loops. Member registration and loan submission mutations explicitly invalidate cache keys (queryKeys.userData, queryKeys.userLoans), ensuring newly joined members or newly created loans reflect instantly in the UI.
Target: WCAG 2.2 Level AA. Stating a target is not the same as meeting it, so this app does not claim conformance yet. The full audit — every Level A and AA criterion marked pass, partial, fail or untested, with evidence — is in docs/accessibility-statement.md, which also lists the known gaps rather than only the successes.
Where it stands: 14 of 55 Level A/AA criteria verified, 5 known failures, and 16 with no evidence either way. The untested ones are the honest part — automated checks currently cover five components, not every route.
| Issue | Criterion | Gap |
|---|---|---|
| #353 | 1.3.4 Orientation | Web manifest locks the app to portrait. |
| #321 | 1.4.4 Resize Text | Viewport disables pinch-zoom. |
| #352 | 2.4.2 Page Titled | One tab title for every route. |
| #360 | 1.3.1, 3.3.2, 4.1.2 | Shared form components with correct label association. |
| #375 | several | Automated checks do not cover the whole app yet. |
Fixing those gaps is tracked on the individual issues, not here. What runs in CI
today: eslint-plugin-jsx-a11y (recommended) with --max-warnings=0 on every
source file; axe-core against rendered output in test/a11y.test.tsx, failing
on any violation at any impact level with no rule disabled and no baseline to
update; direct role and name assertions for what axe cannot express; and
test/contrast.test.ts for AA contrast in both themes. Component-level triage
from #238 is in
docs/a11y-audit.md.
The table below documents every contract entrypoint the client wraps and its current UI wiring status. This serves as both an accuracy statement and a roadmap.
| Contract Method | UI Status | Used By | Notes |
|---|---|---|---|
| Member Management | |||
register_member |
✅ Wired | /register |
Full registration flow |
exit_dao |
— | Wrapper exists, no UI (#TBD) | |
is_member |
✅ Wired | All member-gated pages | Membership check |
get_member |
✅ Wired | /dashboard, user data hook |
Member details |
| Loans | |||
request_loan |
✅ Wired | /loans/request |
Multi-step loan request form |
edit_loan_proposal |
— | Wrapper exists, UI in progress (#341) | |
vote_on_loan_proposal |
✅ Wired | /loans, /loans/[id] |
Vote on loan proposals |
get_loan_proposal |
✅ Wired | /loans, /loans/[id] |
Proposal detail |
get_loan |
✅ Wired | /loans/[id] |
Disbursed loan status |
calculate_loan_terms |
— | Wrapper exists, no caller | |
is_eligible_for_loan |
— | Wrapper exists, no caller | |
repay_loan |
✅ Wired | /loans/[id] |
Full repayment |
repay_loan_partial |
✅ Wired | /loans/[id] |
Partial repayment |
mark_loan_defaulted |
✅ Wired | /loans/[id] |
Permissionless default marking |
| Treasury | |||
get_treasury_balance |
✅ Wired | /treasury, /dashboard |
Treasury stats |
propose_treasury_withdrawal |
✅ Wired | /governance/create |
Create treasury proposal |
vote_on_treasury_proposal |
✅ Wired | /treasury, /governance |
Public voting only |
commit_treasury_vote |
— | Wrapper exists, UI in progress (#339) | |
reveal_treasury_vote |
— | Wrapper exists, UI in progress (#339) | |
get_treasury_proposal |
✅ Wired | /treasury, /governance |
Proposal detail |
| Staking & Rewards | |||
stake |
✅ Wired | /treasury |
Stake tokens for voting weight |
unstake |
✅ Wired | /treasury |
Unstake tokens |
get_stake |
✅ Wired | /dashboard, /treasury |
Current stake |
get_pending_yield |
✅ Wired | /dashboard, /treasury |
Claimable yield |
claim_rewards |
✅ Wired | /treasury |
Claim pending yield |
| Name Registry | |||
register_name |
— | Wrapper exists, no UI (#TBD) | |
resolve_name |
— | Wrapper exists, no caller | |
name_of |
— | Wrapper exists, no caller | |
| Documents | |||
attach_document |
✅ Wired | /loans/[id], document forms |
Attach IPFS CID |
get_document |
✅ Wired | /loans/[id], document views |
Read content hash |
| Admin | |||
pause |
✅ Wired | /admin |
Pause the DAO |
unpause |
✅ Wired | /admin |
Unpause the DAO |
is_paused |
✅ Wired | All write actions | Paused state check |
add_admin |
✅ Wired | /admin |
Add admin |
remove_admin |
✅ Wired | /admin |
Remove admin |
get_admins |
✅ Wired | /admin |
Admin list |
is_admin |
✅ Wired | /admin |
Admin check |
set_consensus_threshold |
✅ Wired | /admin |
Update voting threshold |
set_loan_policy |
— | Wrapper exists, no UI (#TBD) | |
get_consensus_threshold |
✅ Wired | /dashboard, stats |
Threshold display |
get_loan_policy |
✅ Wired | /loans/request |
Loan policy display |
| Governance | |||
has_voted |
✅ Wired | All proposal pages | Vote status check |
calculate_exit_share |
— | Wrapper exists, no caller | |
get_active_members |
✅ Wired | /dashboard |
Active member count |
get_total_members |
✅ Wired | /dashboard |
Total member count |
get_token |
✅ Wired | Token formatting | DAO token address |
Legend:
- ✅ Wired — Full end-to-end flow from UI to contract
⚠️ Partial — Wrapper exists, UI implementation in progress⚠️ Not wired — Wrapper exists, no UI or caller
Keeping this table current: When adding UI for a wrapped entrypoint or adding a new wrapper, update this table in the same PR. The PR template reminds reviewers to verify this.
For the full matrix of what works, what degrades, and what a member sees in each configuration combination, see docs/degradation-matrix.md.
IPFS document storage (src/lib/ipfs.ts) uses a Next.js route handler (src/app/api/documents/route.ts) to pin bytes to Pinata using a server-only credential (PINATA_JWT). Uploads fail explicitly when pinning is unconfigured or the provider returns an invalid or mismatched CID. Downloads require an explicitly configured public gateway (NEXT_PUBLIC_IPFS_GATEWAY); the app does not assume a provider default. Loan amounts and proposal details are public on-chain, and IPFS documents are public to anyone with their CID; document uploads do not provide access control or confidentiality.
tsc --noEmit is fully clean and enforced in CI. next.config.ts no longer sets typescript.ignoreBuildErrors — next build now fails on type errors just like the CI typecheck gate (the eslint.ignoreDuringBuilds counterpart was removed outright in the Next 16 upgrade — that config key no longer exists).
Running on Next.js 16 (Turbopack by default) + React 19.2.
For security vulnerability reporting and our responsible disclosure policy, see SECURITY.md.
This section covers security design decisions and controls currently in place:
-
Wallet Requirements & Minimum Version. The app supports Freighter (minimum supported version:
2.0.0), xBull, Albedo, LOBSTR, and compatible hardware wallets. Freighter is not mandatory. Extension versions are automatically detected on connection and diagnostics surface a warning if an outdated version is installed. -
No custody. The frontend never holds a private key — every signature happens inside the Freighter extension, in the user's own browser context.
src/lib/wallet.tsxonly ever receives a signed transaction XDR back, never a key. -
Read-only degradation, not silent failure. Without a configured contract id or a reachable backend, the UI runs in an explicit "not configured" / empty state rather than throwing — see Configuration.
-
Error boundaries.
error.tsx(route-segment) andglobal-error.tsx(root-layout-level) catch uncaught render errors and offer a retry instead of the previous behavior, where any single uncaught error anywhere in the tree would take down the entire client-side app with no recovery short of a hard reload. -
HTTP security headers & CSP. Per-request nonces and strict security headers are enforced via
src/proxy.ts:Content-Security-Policy— enforced (not report-only).default-src 'self',script-src 'nonce-*' 'strict-dynamic'(per-request nonce for XSS protection, generated in the proxy),style-src 'self' 'unsafe-inline'(Next.js App Router still requires inline styles),img-src 'self' data: blob:plus the configured origins (no barehttps:),font-src 'self' data:,connect-src 'self'plus the RPC / backend / IPFS gateway origins derived from the sameNEXT_PUBLIC_SOROBAN_RPC_URL,NEXT_PUBLIC_BACKEND_URL,NEXT_PUBLIC_IPFS_GATEWAYthe app reads at runtime (so non-default deployments don't break), plusws:/wss:for HMR,frame-ancestors 'none',object-src 'none', etc. Freighter needs no extra scheme — it injectswindow.freighterApivia the page's JS context andpostMessage, verified with a real wallet connect/sign/submit flow and no CSP violations in the console across every route.Strict-Transport-Security: max-age=63072000; includeSubDomains; preloadX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin— prevents the member address in/loans/[id]leaking in theRefererto external linksX-Frame-Options: DENY+frame-ancestors 'none'(CSP) — no framing, anti-clickjacking for the wallet-connected flowPermissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()Cross-Origin-Opener-Policy: same-originVerified viacurl -I/ external header checker (include output in PR). CSP is the layer that limits what an injected script can load and where it can exfiltrate, even though signing itself stays inside Freighter.
-
Dependency hygiene. A critical Next.js RCE and several other npm audit findings were patched.
ipfs-http-client— previously the only dependency with an allowlisted finding — has been removed entirely along with its tree, so itsaudit-ci.jsoncentries are gone too. The rest is enforced automatically rather than tracked by hand: a separateauditjob in CI (.github/workflows/ci.yml) runsaudit-ci(config:audit-ci.jsonc) on every PR at the moderate-and-above threshold, so a new advisory is caught the day it lands instead of at the next manual review. It's a non-blocking job (visible as a warning, doesn't fail the run) since a fresh advisory affects every open PR at once, not just the one that happens to trigger it. Any future allowlist entry is scoped by exact GHSA id with an expiry date, after which it starts failing again until someone deliberately re-reviews and extends it or the dependency is fixed/replaced — see the comments inaudit-ci.jsonc. One unrelated, unallowlisted finding remains innanoidviapostcss(pulled in bynext/@tailwindcss/postcss). Dependabot opens security-update PRs automatically as advisories get patched upstream, and batches routine (non-security) version bumps into a weekly grouped PR so they don't flood the queue — see CONTRIBUTING.md's note on unrelated dependency bumps. -
Server-only credentials.
PINATA_JWT(document pinning) is read only insrc/app/api/documents/route.ts, a server-side route handler — never in aNEXT_PUBLIC_-prefixed variable, which Next.js would otherwise inline into the client bundle. -
Authenticated backend writes (
StellarSignature). The indexer is read-only, but two notification endpoints mutate state, so it will not trust an address asserted by the client. Every write performs a challenge–response handshake insrc/lib/backend-auth.ts:GET /api/auth/challenge?address=<G…|M…>returns a single-use{ nonce }scoped to that address (5-minute TTL).- Freighter signs the exact UTF-8 string
<nonce>:<address>. - The request goes out as
PATCHwithAuthorization: StellarSignature <address>:<signature>:<nonce>.
Consequences worth knowing before touching
src/lib/backend.ts:- Only
G…andM…accounts can authenticate. AC…contract account has no key to sign with, so the backend rejects it with a 400.canSignForBackend()screens for this up front so the member gets "this account can't sign" instead of a bare HTTP error. - A write costs a signature prompt. Marking notifications as read is no longer free, which is why the hook in
src/hooks/useNotifications.tsapplies the change optimistically and rolls it back if the request doesn't land (rejected prompt, 401, expired nonce, unreachable backend). Mutations return a typedMutationResultrather than a boolean precisely so the caller can tell those cases apart instead of assuming success. - Nonces are single-use and per address, and challenge issuance is idempotent while a nonce is live. Two overlapping writes for one wallet would sign the same nonce and the second would be rejected, so
serializePerAddress()queues mutations per address. Different addresses are unaffected and still run in parallel.
-
Privacy claims are audited.
/privacyseparates what's enforced today from what's designed but not yet delivered (commit-reveal phase separation and commit/reveal UI are still pending inourdao-contractsand this repo; private proposals are currently unvotable and the create form disables that option rather than offering an uncompletable flow). See that page and its tracking links. Once the contract/client fixes land, the page will be updated — noted in the PR so it isn't forgotten.
- Rework the loan detail page's data model so it derives loan/proposal state more directly (some legacy fields still shadow real on-chain data in places not yet fully migrated).
- Convert
DocumentViewer.tsx's manual fetch-in-effect to React Query, matching the rest of the app's data-fetching convention (currently unused in the app, flagged rather than silently left as-is).
Contributions are welcome — see CONTRIBUTING.md for local setup, the checks CI enforces, and the frontend-specific rules (no fabricated content, TanStack Query for all data fetching, cn() for class composition, both themes verified). Please claim an issue before opening a pull request.
Found a security vulnerability? See SECURITY.md for responsible disclosure procedures — don't open a public issue.
MIT
