Stellar Goal Vault is a lightweight crowdfunding MVP for the Stellar ecosystem.
It includes:
- A React dashboard to create and manage funding campaigns
- A Node.js + Express API backed by SQLite
- A Soroban contract scaffold for on-chain campaign creation, pledges, claims, and refunds
- A seeded contribution backlog you can turn into GitHub issues after publishing
Creators open a campaign with a target amount and deadline.
Contributors can pledge until the deadline:
- If the target is reached, the creator can claim the vault
- If the target is missed, contributors can refund their pledges
This repo is intentionally scoped as an MVP so it is easy to extend with wallet signing, event indexing, and production-grade UX.
Frontend (frontend, port 3000)
- React + Vite dashboard
- Campaign board, detail panel, timeline, and contribution backlog
- Uses
/apiproxy for backend calls - Freighter-backed pledge flow that simulates, signs, submits, and then reconciles local state
Backend (backend, port 3001)
- Express REST API
- SQLite persistence for campaigns, pledges, and event history
- Real-time campaign status derived from current timestamps and stored pledges
- Exposes contract/network config to the frontend and reconciles confirmed pledge hashes
Contract (contracts)
- Soroban Rust scaffold
- Implements
create_campaign,contribute,claim,refund,get_campaign, andget_contribution - Not yet wired into live wallet signing flow in the frontend
Architecture decision records
- Key architecture choices are documented in
adr/. - Mermaid architecture diagrams are documented in
docs/architecture.md. - See
adr/0001-sqlite-off-chain-mvp.mdfor the SQLite off-chain MVP decision. - See
adr/0002-react-express-mvp.mdfor the React + Express + Soroban MVP architecture decision. - See
adr/0003-freighter-wallet-integration.mdfor the Freighter wallet signing approach.
Each campaign stores:
creatortitledescriptionacceptedTokens— one or more Stellar asset codes the campaign acceptsassetCode— first accepted token (backward-compatibility alias)targetAmountpledgedAmountdeadlinetokenBalances— per-token pledge totals (Record<assetCode, amount>)
Campaign states:
openwhen deadline has not passed and target is not yet metfundedwhen pledged amount is at least the target and funds have not been claimedclaimedwhen the creator has claimed a funded vaultfailedwhen deadline has passed without reaching the target
The Soroban contract enforces a minimum contribution per pledge. The default is 100 stroops. This is configurable at deploy time via initialize(admin, min_contribution).
# Deploy with a custom minimum (e.g. 500 stroops)
stellar contract invoke --id $CONTRACT_ID -- initialize \
--admin $ADMIN_ADDRESS \
--min_contribution 500Contributions below the minimum are rejected with "contribution below minimum".
A campaign creator can update the campaign metadata before the deadline:
stellar contract invoke --id $CONTRACT_ID -- update_metadata \
--campaign_id 1 \
--creator $CREATOR_ADDRESS \
--new_metadata "Updated description"The contract emits a MetadataUpdated event containing both the old and new metadata values. The backend event indexer processes this event and updates local state automatically.
Campaigns can accept more than one Stellar asset code. When acceptedTokens contains multiple entries the frontend renders a per-token progress bar beneath the main progress bar, and the pledge form shows a token selector so contributors can choose which asset to pledge.
The backend tracks per-token pledge totals in the tokenBalances field (a Record<assetCode, amount> map built from the pledges table grouped by asset_code). This is returned on every GET /api/campaigns/:id response and on the campaign list.
Contract side: The Soroban contract stores accepted_tokens: Vec<String> on each campaign. contribute() validates that the pledged asset is in the list before recording the pledge.
Frontend side: CampaignCard renders individual <div class="progress-bar"> elements for each accepted token when tokenBalances is present. CampaignDetailPanel conditionally shows a <select> token picker above the amount field when acceptedTokens.length > 1.
Backend side: getCampaignTokenBalances(campaignId) queries the pledges table grouped by asset_code and returns the map. getCampaign() populates campaign.tokenBalances on every read.
Any existing contributor can request a deadline extension:
stellar contract invoke --id $CONTRACT_ID -- request_deadline_extension \
--campaign_id 1 \
--caller $CONTRIBUTOR_ADDRESS \
--new_deadline <unix_timestamp>Other contributors can vote to approve:
stellar contract invoke --id $CONTRACT_ID -- approve_extension \
--campaign_id 1 \
--caller $OTHER_CONTRIBUTORThe extension is applied once more than 50% of unique contributors have approved. Constraints:
- New deadline must be later than the current deadline
- New deadline cannot exceed
MAX_CAMPAIGN_DURATION_SECONDS(180 days) from the campaign creation timestamp - Reverts on claimed or canceled campaigns
Mutation testing is used to verify that the test suite is effective at catching real bugs, not just achieving line coverage.
The backend uses Stryker Mutator targeting the two most critical service files:
backend/src/services/campaignStore.tsbackend/src/services/eventHistory.ts
cd backend
npm run mutationThis will:
- Introduce small code mutations (e.g. flipped comparisons, removed conditions, changed operators) into the source files
- Run the Vitest test suite against each mutant
- Report the mutation score — the percentage of mutants killed by failing tests
An HTML report is generated at backend/reports/mutation/mutation.html and a JSON report at backend/reports/mutation/mutation.json.
| Level | Score |
|---|---|
| High | ≥ 80% |
| Low | ≥ 70% |
| Break | < 65% (CI fails) |
cd backend
npm run mutation:ciOutputs a compact text summary suitable for CI logs without generating the HTML report.
Tests that specifically target surviving mutants live in:
backend/src/services/__tests__/mutation.test.ts
These tests exercise boundary conditions missed by standard coverage — exact equality on deadlines, arithmetic accumulation, boolean flag transitions, and null-coalescing paths.
Base URL:
- Local backend:
http://localhost:3001 - Frontend proxy:
/api
- Service health check
- Response:
{
"service": "stellar-goal-vault-backend",
"status": "ok",
"timestamp": "2026-03-27T21:30:00.000Z",
"uptimeSeconds": 12.345,
"database": {
"status": "up",
"reachable": true
}
}statusisokwhen the API and database probe succeed, otherwisedegradeddatabase.statusisupordownbased on a lightweight SQLite reachability check
- Returns aggregate metrics and totals computed from campaigns and pledges.
- Cached with a 30-second TTL.
- Response:
{
"data": {
"totalCampaigns": 10,
"openCampaigns": 5,
"fundedCampaigns": 3,
"claimedCampaigns": 1,
"failedCampaigns": 1,
"totalPledgeVolume": 50000,
"uniqueContributors": 42
}
}- Returns all campaigns with computed progress
- Query parameters:
q(optional): Search query to filter campaigns by title, creator, or campaign ID (case-insensitive)asset(optional): Filter campaigns by asset code (e.g., USDC, XLM)status(optional): Filter campaigns by status (open, funded, claimed, failed)
- Returns one campaign with pledges and event history
- Returns only pledge information together with pagination metadata.
Query Parameters:
page(optional): Page number (e.g.,?page=1)limit(optional): Results per page (e.g.,?limit=20)
Response Schema:
{
"data": [],
"pagination": {
"total": 0,
"page": 1,
"limit": 20,
"totalPages": 1
}
}- Create a campaign
Request body:
creatortitledescriptionassetCodetargetAmountdeadlinemaxPerContributor(optional): Maximum total pledge amount a single contributor can contribute to this campaign. If not set, no per-contributor limit applies. Can also be set globally viaDEFAULT_MAX_PER_CONTRIBUTORenv variable.
- Add a pledge to a live campaign
Request body:
contributoramount
- Record a confirmed on-chain pledge locally after the Soroban transaction succeeds
Request body:
contributoramounttransactionHashconfirmedAt(optional)
- Claim a funded campaign after deadline
Request body:
creator
- Refund all active pledges from one contributor on a failed campaign
Request body:
contributor
- Fetch local event history for the selected campaign
Returns contributor summary (grouped pledges with refund status).
Response:
{
"data": [
{
"contributor": "GBEZH6T5V7VHUWGMAHVICBFV7WSNULSIHHMV7B2LFNJA6J3XVMT7M2LVY",
"totalPledged": 150.0,
"refundedAmount": 0,
"isFullyRefunded": false
},
{
"contributor": "GABC123...",
"totalPledged": 0,
"refundedAmount": 50.0,
"isFullyRefunded": true
}
]
}Empty campaigns return {"data": []}. Invalid IDs return 404.
- Returns seeded issue ideas for public open-source contribution
Prerequisites:
- Node.js 18+
- npm 9+
- Coverage target: Both
backendandfrontendenforce an 80% lines coverage threshold. - CI will fail with a clear message if coverage drops below this threshold and uploads the HTML report as a build artifact.
- Optional for contract work: Rust + Soroban toolchain
From repo root:
npm run install:all
npm run dev:backend
npm run dev:frontendOpen:
- Frontend:
http://localhost:3000 - Backend:
http://localhost:3001
Build:
npm run buildA docker-compose.override.yml is included for local development. Docker Compose merges it automatically with docker-compose.yml when you run docker compose up, so no extra flags are needed.
What the override does:
- Mounts
./frontend/srcand./backend/srcinto each container so that file changes are reflected immediately without rebuilding the image. - Runs
npm run devfor both services (Vite dev server for the frontend,ts-node-devfor the backend). - Exposes Vite's HMR WebSocket port
24678to the host.
# Build images and start both services with hot-reload
docker compose up --build
# Or in the background
docker compose up --build -dStop and remove containers:
docker compose downThe backend includes a configurable load test script built with autocannon to simulate concurrent campaign reads and pledge writes.
- Start the backend locally:
npm run dev:backend- In a second terminal, run the load test:
cd backend
npm run load:test -- --base-url http://127.0.0.1:3001 --connections 20 --duration 20The script seeds synthetic campaigns first, then runs a mixed workload across:
GET /api/campaignsGET /api/campaigns/:idPOST /api/campaigns/:id/pledges
The console output includes:
- Latency percentiles (
p50,p90,p97.5,p99,max) - Error rate, timeout count, and non-2xx responses
- Average requests per second and throughput
Useful flags:
--connections <number>: concurrent connections--duration <seconds>: test duration--campaigns <number>: number of seed campaigns created before the run--read-weight <number>: relative share of campaign read requests--pledge-weight <number>: relative share of pledge requests--pledge-amount <number>: amount sent in each pledge request--target-amount <number>: target amount assigned to each seed campaign--asset-code <code>: asset code used while seeding campaigns--deadline-hours <hours>: how far into the future seeded campaign deadlines are set
Example validation run:
cd backend
npm run load:test -- --base-url http://127.0.0.1:3001 --duration 5 --connections 5 --campaigns 4 --read-weight 3 --pledge-weight 2Set a funded Stellar testnet secret key and run:
SECRET_KEY="S..." npm run deploy:contractThe script will:
- Build the Soroban contract
- Deploy to Stellar testnet
- Output the contract ID
- Save the ID to
contracts/contract_id.txt
Backend:
PORTdefaults to3001DB_PATHdefaults tobackend/data/campaigns.dbSOROBAN_RPC_URLdefaults to Stellar testnet RPCCONTRACT_IDis required for Freighter pledge signingNETWORK_PASSPHRASEdefaults to Stellar testnetCONTRACT_AMOUNT_DECIMALSdefaults to2and controls display-to-contract unit scaling
Frontend:
VITE_API_URLdefaults to/api
Contract deployment:
SECRET_KEYrequiredNETWORK_PASSPHRASEoptionalRPC_URLoptional
The main contribution issue for this repo is:
Implement Freighter-signed pledge transactions
That issue is already represented in:
backend/src/services/openIssues.tsOPEN_SOURCE_ISSUES.md- The frontend backlog panel
See the FAQ.md for answers to common questions about testnet funding, Freighter setup, contract deployment, database reset, pledge failures, and more.
Please see SECURITY.md for our responsible disclosure policy, supported versions, and reporting instructions.
Please see the Contributing Guide for setup and contribution guidelines. See also CHANGELOG.md for a full history of notable changes across releases.
- Campaign creation is still local-first, so pledges will only simulate successfully for campaign IDs that also exist in the configured contract
- No authentication or rate limiting on write endpoints
- No background indexer for on-chain event sync yet
- Replace mock pledge actions with Freighter + Soroban transactions
- Index on-chain events into SQLite
- Add filters, sorting, and campaign pages
- Add contract tests and backend integration tests
The frontend now includes a theme toggle in the header with icon controls for light/dark mode.
- Toggle between light and dark themes directly in the UI
- Persist selected theme in
localStorageusingstellar-goal-vault-theme - Respect system
prefers-color-schemeon first load when no saved theme exists - Apply dark/light color variants across core UI surfaces (cards, tables, forms, buttons, and controls)