Charter is a treasury operations layer for Stellar-based organizations. Instead
of a bare multisig wallet, funds are held under a policy contract with defined
budget categories, spend limits, and approval thresholds. This repo is the
application layer: SDK, web dashboard, and event indexer for the contracts in
charter-contract.
| Maintainer | Role | Contact |
|---|---|---|
| @fadesany | Lead — application layer (SDK, web, indexer) | GitHub |
Charter's application layer is three parts that work together against the
charter-contract Soroban contracts:
packages/sdk(@charter/sdk) — TypeScript clients for the factory and treasury contracts. It owns the write path: simulate → prepare → sign (with the user's own wallet) → submit → poll for confirmation.apps/web(@charter/web) — the Next.js 15 (App Router) dashboard. It reads organization, category, and request state from the indexer's REST API and issues every state change through the SDK.indexer/— a Go service that polls Soroban events, folds them into Postgres read models, and serves them over a read-only REST API.
Every state change is a Soroban contract call authorized by the user's own wallet — Charter never custodies funds or keys.
signs & submits tx
Wallet ─────────────────────────────► Soroban ◄──────────┐
▲ (contracts) │ polls
│ connect │ emits events │ getEvents
│ ▼ │
┌──────────────┐ reads (REST) ┌──────────────────────────┴─┐
│ apps/web │ ───────────────► │ indexer/ (Go + Postgres) │
│ (Next.js) │ │ read models over REST │
└──────┬───────┘ └─────────────────────────────┘
│ writes go through
▼
┌──────────────┐
│ packages/sdk │ contract clients: simulate → prepare → sign → send → poll
└──────────────┘
Two data paths, kept strictly separate:
- Writes flow through
packages/sdk— the only path allowed to call state-changing contract methods. The SDK simulates, prepares, hands the XDR to the wallet to sign, submits, and polls for confirmation. The web app never invokes a mutating method directly. - Reads flow from the indexer's REST API. The indexer folds Soroban events into Postgres read models and serves them; the web app reads only from the indexer and never touches Soroban RPC for display data, nor writes to the indexer's database.
Contract amounts are i128. They survive end to end without ever becoming a
float: the indexer stores them as NUMERIC(30,0), the API returns them as
decimal strings, and the web app formats them with bigint-safe helpers
(formatAmount / parseAmount). Token amounts use 7 decimals (TOKEN_DECIMALS).
| Path | What it is |
|---|---|
packages/sdk |
@charter/sdk — factory + treasury contract clients over Soroban RPC |
apps/web |
@charter/web — the Next.js 15 (App Router) frontend |
indexer/ |
Go service: event ingestion into Postgres + read-only REST API |
packages/* and apps/* form an npm workspace (single root lockfile). The
indexer is a standalone Go module (github.com/Ch-rter/app/indexer).
- Node.js ≥ 20 and npm
- Go ≥ 1.26 (for the indexer)
- PostgreSQL reachable via
DATABASE_URL(for the indexer) - A Stellar wallet supported by
stellar-wallets-kit(e.g. Freighter) for signing, on the network your config points at
From the repository root:
npm ci # installs the workspace (sdk + web) from the committed lockfileThe SDK is consumed directly as TypeScript source and transpiled by Next.js — there is no separate build step to run before starting the web app.
The indexer needs a Postgres database and the deployed factory contract id. A
throwaway local database with the credentials from .env.example:
docker run --name charter-pg -e POSTGRES_USER=charter \
-e POSTGRES_PASSWORD=charter -e POSTGRES_DB=charter \
-p 5432:5432 -d postgres:16Then configure and run it:
cd indexer
cp .env.example .env # then fill in FACTORY_CONTRACT_ID
# export the vars however you prefer, e.g. with a dotenv runner, then:
go run .The schema is applied automatically on startup (idempotent), so there is no
separate migration step. The service ingests events in the background and serves
the REST API on PORT (default 8080).
cd apps/web
cp .env.example .env.local # then fill in the deployed contract idPoint NEXT_PUBLIC_INDEXER_API_URL at the indexer (http://localhost:8080 for
local dev). From the repository root:
npm run dev:webThe app starts on http://localhost:3000. Until charter-contract is deployed
you can leave NEXT_PUBLIC_FACTORY_CONTRACT_ID blank and browse the read-only
UI; deploying a treasury or raising a request requires the real id and a funded
wallet.
Both packages ship a committed .env.example; copy it and fill in the blanks.
No secrets are committed.
All web config is public (NEXT_PUBLIC_*), inlined at build time. There is no
localhost fallback for the indexer URL — a missing value fails loudly rather than
silently shipping to production.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SOROBAN_RPC_URL |
Soroban RPC endpoint |
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE |
Network passphrase every tx is signed against |
NEXT_PUBLIC_FACTORY_CONTRACT_ID |
Factory the new-org flow deploys treasuries through |
NEXT_PUBLIC_TREASURY_WASM_HASH |
Treasury wasm hash (informational, shown in the UI) |
NEXT_PUBLIC_INDEXER_API_URL |
Base URL of the indexer REST API |
NEXT_PUBLIC_READ_ONLY_SOURCE_ACCOUNT |
Any account key used only as the source for reads |
Keep
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASEquoted — the semicolon breaks unquoted shell sourcing.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string |
SOROBAN_RPC_URL |
Soroban RPC endpoint the poller reads events from |
NETWORK_PASSPHRASE |
Network passphrase |
FACTORY_CONTRACT_ID |
Factory the treasury watch list is bootstrapped from |
PORT |
REST API port (default 8080) |
POLL_INTERVAL_SECONDS |
How often the ingestion loop polls (default 5) |
Every endpoint is read-only. Amounts are returned as decimal strings.
| Method & path | Returns |
|---|---|
GET /health |
Liveness + database reachability |
GET /orgs |
Every indexed organization, newest first |
GET /orgs/{treasury} |
One organization by treasury address |
GET /orgs/{treasury}/categories |
That treasury's budget categories |
GET /orgs/{treasury}/requests[?status=] |
That treasury's requests (optional filter) |
GET /orgs/{treasury}/requests/{id} |
One request with its approvals |
The status filter accepts Pending, Executed, Rejected, or Cancelled.
Run from the repository root:
| Command | Does |
|---|---|
npm run dev:web |
Start the web app in development |
npm run build:web |
Production build of the web app |
npm run build:sdk |
Type-check the SDK |
npm run typecheck |
Type-check every workspace |
npm run lint |
Lint every workspace |
Indexer (from indexer/): go vet ./..., go build ./..., go run ..
.github/workflows/ci.yml runs on every push and pull request against main:
- Web (lint · typecheck · build) —
npm ci,npm run lint,npm run typecheck,npm run build:web. - Indexer (vet · build) —
go vet ./...andgo build ./...inindexer/.
The two jobs run in parallel.
Contributions are welcome. To keep the two data paths and the money model intact, please follow these conventions.
Finding something to work on. Check the
open issues; issues labeled
good first issue are a sensible starting point.
Branch naming. Branch off main with a type prefix:
feat/<short-desc>, fix/<short-desc>, docs/<short-desc>, or
chore/<short-desc>.
Commit format. Use Conventional Commits
with a scope, e.g. feat(sdk): …, fix(indexer): …, docs(web): ….
Pull request checklist. Before opening a PR, confirm:
-
go vet ./...is clean (for indexer changes) -
npm run build/npm run build:websucceeds (for web/SDK changes) -
npm run lintandnpm run typecheckpass - No
anytypes introduced in TypeScript - Writes still go through
packages/sdk; reads still come from the indexer
See SECURITY.md for how to report a vulnerability.