Frontend for a self-hosted Stellar multisig coordinator: propose a transaction, share an unguessable link, and let signers approve it with their own wallets. This app never touches private keys.
There is no verified public deployment of this frontend yet. The app is a
static site (see Deployment) meant to be deployed
to a static host and pointed at your own coordinator-api instance;
npm run dev starts a local development server at http://localhost:5173,
which is a dev environment, not a live deployment.
This app does two things, and nothing more:
- Propose — a user pastes a pre-built Stellar transaction, the app posts it to the coordinator-api, and the proposer gets an unguessable link to share with the other required signers.
- Sign — a signer opens that link, reads the transaction described in plain language, connects their own wallet (Freighter), and signs.
This app never handles private keys. All signing happens inside the connected wallet extension; the only thing this app ever sends to the API is a detached signature produced by the wallet.
It is a pure client-side app (Vite + React + TypeScript) — no server, no build-time secrets, deployable to any static host. It consumes the stellarcosigner/coordinator-api backend.
- Build the transaction in the tool of your choice (Stellar Laboratory, the SDK, a CLI) and copy its envelope XDR.
- Paste it here, pick the network, review the plain-language preview (decoded client-side), submit.
- The API creates the coordination request and the app shows a shareable link with a copy button — it does not navigate away, because copying that link is the whole point.
The shareable link looks like:
https://<your-frontend>/requests/<id>#tx=<envelope-xdr>
The <id> is the unguessable 32-hex-char request id returned by the API. The
transaction envelope travels in the URL fragment (#tx=…) — fragments are
never sent to any server — because the coordinator-api deliberately does not
return raw XDR from GET /requests/:id (see
Design decisions).
- The signer opens the request; the page fetches
GET /requests/:idand renders the API-decoded summary in plain language ("Pay 10.5 XLM to G…"), never raw XDR. - It shows the live signature/threshold state from the API: who has signed, who hasn't, weight accumulated vs. the account's real on-chain threshold. The page does not auto-refresh: a signer must click Refresh status to see updates from other signers. This is expected behavior, not a bug.
- Connect Wallet (Freighter), then Sign. Freighter handles the
signing and shows its own confirmation; the app extracts the wallet's
detached signature and POSTs it to
POST /requests/:id/sign. - Submission is shown only after the API confirms it: if the threshold was just met, the API submits to the network and the app displays "Threshold met. Transaction submitted to the network." with a block-explorer link. The app never claims submission on its own.
If the request doesn't exist or has expired, the page says exactly that — "this request doesn't exist or has expired" — never a generic error.
- Private keys never reach this app. All signing happens inside Freighter
(
src/lib/wallet.ts); the only wallet interaction is asking for a public key and a signature over the exact envelope the signer reviewed. - No transaction or signature data is persisted client-side. No
localStorage/sessionStorageof XDR, signatures, or public keys. The transaction payload travels only in the URL fragment (#tx=…), which is never sent to a server. - Request links are bearer-style secrets. Anyone holding a full shareable link (id + fragment) can review, and if a signer, sign, that request. Treat links as sensitive — this is inherent to the coordinator model.
- Signer/threshold state always comes from the API, which resolves it live from the network — this app never trusts or fabricates that state itself.
- The UI never claims submission the API hasn't confirmed. The success
banner only appears after
POST /requests/:id/signreturnsstatus: submitted. - Unsummarizable operations are surfaced, never hidden. If any part of an
XDR can't be decoded into confident plain language (for example Soroban
invokeHostFunctioncalls), the UI says so explicitly instead of showing a partial or guessed description.
See SECURITY.md for the complete threat model.
Proposer flow:
proposer browser
│
▼
coordinator-web
│
│ HTTPS / JSON
▼
coordinator-api ──► Stellar Network (Horizon: resolve signer list/threshold)
│
▼
PostgreSQL (store pending request)
Signer flow:
signer browser
│
├── wallet / Freighter (signing happens here — keys never leave it)
│
▼
coordinator-web
│
│ HTTPS / JSON
▼
coordinator-api ──► Stellar Network (Horizon: verify signer, submit once threshold met)
│
▼
PostgreSQL (record signature)
- coordinator-web is a static, client-only app: it decodes XDR for preview, calls the API, and hands signatures to/from Freighter. It holds no server-side state of its own and never talks to PostgreSQL or Horizon directly.
- coordinator-api (repo) stores requests, resolves signer/threshold state live from Horizon, and submits once the threshold is met.
- The two decode transactions identically: the API decodes for
GET /requests/:id; this app'ssrc/lib/txSummary.tsmirrors that decoder for the Propose-page preview and for tests (see Design decisions).
Pasting a pre-built XDR is the MVP — there is deliberately no transaction-construction UI. Building a full field-by-field transaction builder (assets, trustlines, path payments, claimable balances…) is a large surface with a long tail of edge cases, and it is not what this app is for: the coordinator coordinates signatures, it does not author transactions. Stellar Laboratory and the SDK already do construction well and export the envelope XDR this app expects.
The summary preview on the Propose page decodes the pasted XDR client-side, so the proposer sees exactly what they're about to share before creating the request.
- XDR rides in the URL fragment.
GET /requests/:idreturns a fully decoded summary but not the raw envelope. The shareable link carries the envelope in#tx=…so the signer page can hand it to Freighter without the API ever echoing raw XDR. If someone shares only the bare/requests/<id>URL, the page still renders the full summary and status — it just explains that the payload is missing and signing isn't possible from that link. - The summary is decoded twice, identically. The API decodes for
GET /requests/:id; the frontend'ssrc/lib/txSummary.tsmirrors that decoder for the Propose-page preview and for tests. Descriptions are word-for-word identical (see the sibling repo'ssrc/summary.ts). - Unsummarizable operations are called out, never hidden. Soroban
invokeHostFunctionoperations (and anything unrecognized) render an explicit warning — "this app cannot fully summarize — review the raw XDR before signing" — instead of a silently partial description. - No signed data is stored client-side. No localStorage/sessionStorage of transaction payloads, signatures, or keys. The wallet answers from its own allow-list; the page re-connects silently if the user previously authorized this origin.
| Variable | Required | Default | Meaning |
|---|---|---|---|
VITE_API_URL |
no | http://localhost:3000 |
Base URL of the coordinator-api. Must be set at build time (Vite inlines it) when deploying. |
The API must be configured to allow this frontend's origin: set the API's
CORS_ORIGIN environment variable to your frontend's origin (comma-separated
for several). See the API's README.
If the coordinator-api is deployed on a free tier that spins down when idle (for example Render's free web service), the first request after idle time can take 30-60 seconds to respond. That is expected, not a broken deployment. See the API's README for its deployment details.
npm install
npm run dev # http://localhost:5173 (expects the API on :3000)
npm run typecheck # tsc --noEmit
npm run lint # eslint
npm test # vitest run
npm run build # production build into dist/
npm run preview # serve the production build locallynode >= 22 is required (matches the API).
The app is a static site; npm run build emits dist/. Every static host
needs one extra rule so the client-side routes (/requests/<id>) work on a
hard refresh or direct link. These are deployment instructions — no branch
of this project deploys automatically to any of these hosts.
npm run buildvercel.json (already committed) rewrites all paths to /index.html:
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }Set VITE_API_URL in the project's environment settings, build, deploy.
public/_redirects (already committed) contains:
/* /index.html 200
Set VITE_API_URL in Site settings → Environment variables, then build with
npm run build and publish dist/.
public/404.html (already committed) implements the standard SPA fallback:
GitHub Pages serves it for any unknown path, and it redirects to the app with
the deep link preserved via ?path=… (the #tx=… fragment survives the
redirect). src/main.tsx restores the path before React Router reads the URL.
Set VITE_API_URL to your API origin in the Pages build step, then:
npm run build
npx gh-pages -d dist # or any Pages workflow that publishes dist/src/
main.tsx entry; GitHub Pages deep-link restore
App.tsx shell + routes
pages/
Propose.tsx paste XDR → preview → submit → shareable link
SignRequest.tsx fetch, review, connect wallet, sign, submitted state
lib/
api.ts typed coordinator-api client (types + errors)
wallet.ts Freighter adapter; detached-signature extraction
txSummary.ts XDR → plain-language decoder (mirrors the API)
networks.ts passphrases, block-explorer URLs
config.ts API base URL from VITE_API_URL
format.ts address shortening / date formatting
components/
TransactionSummary.tsx the plain-language review surface
SignerStatus.tsx live signer list + threshold progress
test/
txSummary.test.ts decoder tests (safety-critical)
api.test.ts client tests against a mocked API
wallet.test.ts wallet tests against a mocked Freighter
npm test runs 23 tests. The XDR-summary tests are the most important: they
build real transactions with @stellar/stellar-sdk and assert the exact
plain-language sentences a signer will read. See
CONTRIBUTING.md for how to add coverage when you touch the
decoder.
See CONTRIBUTING.md for ground rules, the development loop, and how the XDR decoder must stay in sync with the API's.
There is no dedicated community channel for this project yet. Use GitHub Issues for bug reports, questions, and feature discussion.
| Name | GitHub |
|---|---|
| Hollujay | @Hollujay |
| ZeePearl56 | @ZeePearl56 |
- stellarcosigner/coordinator-api — the backend this app talks to (routes, security model, README).
- Freighter developer docs — the wallet integration this app uses.
Apache License 2.0. See LICENSE, CONTRIBUTING.md, and SECURITY.md.