| Name | GitHub |
|---|---|
| Hollujay | @Hollujay |
A self-hosted backend for coordinating Stellar multisig transactions.
Stellar supports multisig natively at the protocol level — an account can require signatures from multiple weighted keys, above a threshold, before a transaction is valid. What's missing is coordination: getting a transaction built by one person in front of the other required signers, tracking who has signed, and submitting automatically once the threshold is met.
This service does exactly that, and nothing more:
- Stores a pending transaction under an unguessable, cryptographically random ID.
- Tracks signatures added by signers using their own wallets.
- Submits to the network once the account's real on-chain threshold is met.
It does not hold private keys, does not sign on anyone's behalf, and has no endpoint that lists pending requests.
- Pending transactions are never publicly listable. There is no "browse all
pending requests" endpoint, ever. A pending transaction is only accessible
via its exact, unguessable ID (32 hex chars / 128 bits of CSPRNG entropy, see
src/id.ts). - IDs are never sequential or predictable — never derived from the account address, a timestamp, or anything else guessable.
- No private key ever touches this service. Every signature is produced client-side by a signer's own wallet. This API only ever receives a detached signature to attach to a stored transaction.
- Signer lists and thresholds are resolved LIVE from the network on every
request (
src/verify.ts, viaHorizonAccountGateway) — never from client input, never from a stale cache. A client claiming "I am a signer" is rejected unless the network agrees right now. - Signatures are additive-only. A signer may sign once; an existing signature can never be overwritten (enforced by a database primary key).
See SECURITY.md for the full threat model.
- ❌ List pending requests, even for admin/debug purposes. (Operations visibility is provided by structured logs, not a queryable API.)
- ❌ Store or transmit a private key at any point.
- ❌ Trust a client-supplied signer list, threshold, or account state.
- ❌ Use a sequential or predictable ID scheme — even in development.
- Node.js 22 LTS, TypeScript (strict, no
any) - Fastify (with built-in JSON Schema validation and structured
pinologging) - Postgres for storage
- @stellar/stellar-sdk for XDR parsing, signature verification, and reading account state / submitting via Horizon
src/
index.ts API server entry point (starts HTTP + background jobs)
app.ts Fastify app factory (migrations at boot, route registration)
routes.ts HTTP route registration + JSON Schema validation
create.ts POST /requests
fetch.ts GET /requests/:id (with decoded transaction summary)
sign.ts POST /requests/:id/sign
verify.ts live signer list/threshold resolution + signature verification
submit.ts submission envelope assembly + background submission retry
expire.ts background expiry maintenance
store.ts Postgres pool, migrations, and queries
id.ts unguessable ID generation
summary.ts human-readable transaction decoding
transaction.ts envelope parsing / hashing helpers
horizon.ts Horizon adapters (account state, submission)
config.ts environment configuration
background.ts background job scheduler
migrations/ SQL migrations (applied at boot, tracked in schema_migrations)
test/ integration tests against a real Postgres + fake network gateways
All endpoints return JSON. Errors are { "error": "<message>" } with an
appropriate HTTP status.
Creates a pending multisig coordination request.
Validation performed (in order):
sourceAccountis a plausible Stellar public key (G...).- The XDR parses as a valid Stellar transaction envelope (fee-bumps rejected).
- The transaction's own source matches
sourceAccount. - The account exists on the network and its real signer list/threshold is resolved live. Nothing about the account's multisig setup is accepted from the body.
ttlSeconds, if given, is within[60, MAX_TTL_SECONDS].
// 201 Created
{ "id": "0f8e..." } // 32 hex chars, 128 bits of entropy — the ONLY responseThe frontend constructs the shareable URL (https://your-frontend/requests/<id>)
itself.
Returns everything a signer needs to review before signing:
{
"id": "0f8e...",
"sourceAccount": "G...",
"network": "testnet",
"status": "pending", // "pending" | "submitted" | "expired"
"createdAt": "2026-08-03T...Z",
"expiresAt": "2026-08-10T...Z",
"submittedAt": null,
"summary": { // fully decoded — never "some operations"
"source": "G...",
"fee": "100",
"sequence": "1234567891",
"memo": { "type": "text", "value": "payroll" },
"timeBounds": null,
"operations": [
{ "type": "payment", "description": "Pay 10.0000000 XLM to G...", "details": { "destination": "G...", "amount": "10.0000000", "asset": "XLM" } }
// every operation type is described with its amounts, assets, destinations
],
"signaturesAttachedToEnvelope": 0
},
"signatureState": { // resolved LIVE from the network at read time
"accountStatus": "ok", // "ok" | "not_found" (account merged away/deleted)
"threshold": 2,
"signedWeight": 1,
"thresholdMet": false,
"signers": [
{ "key": "G...", "weight": 1, "signed": true, "signedAt": "2026-08-03T...Z" },
{ "key": "G...", "weight": 1, "signed": false, "signedAt": null }
]
}
}404 is returned both for IDs that never existed and for expired requests —
the two are indistinguishable by design (no information leakage).
Records one signer's detached signature.
// Request body
{
"signerPublicKey": "G...", // required
"signature": "base64...=" // required, base64-encoded 64-byte ed25519 signature
}Checks, in order:
404if the request doesn't exist or has expired (uniform).409if this signer already signed (never overwrites) or the request is already submitted.- The signature must verify cryptographically against the stored
transaction's signature-base hash and this exact key (
400otherwise). - The signer must appear in the account's current live signer list with
weight > 0 (
403otherwise). - If the total recorded weight now meets the account's live threshold, the request is claimed and submitted to the network immediately.
// 200 OK — updated status
{ "status": "submitted" } // or "pending"The signature is the base64-encoded 64-byte ed25519 signature over the
transaction's signature-base hash (SHA-256 of the transaction without its
signature list). With Freighter this is one extra client-side step:
- Ask Freighter to sign the transaction:
signTransaction(transactionXdr, { networkPassphrase })→ returns a signed envelope. - Parse the returned envelope with
@stellar/stellar-sdk(TransactionBuilder.fromXDR), taketransaction.hash()andtransaction.signatures— the newest signature is the detached signature. - POST
{ signerPublicKey, signature }.
Any wallet that exposes raw signing over the transaction hash can post directly.
Checks the database connection. { "status": "ok" }.
| Stage | When | Notes |
|---|---|---|
pending |
Created; awaiting signatures | Default TTL 7 days (DEFAULT_TTL_SECONDS), max 30 days |
submitted |
Threshold met, envelope submitted | Never expires |
expired |
TTL passed while still pending | Soft-expired, retained 30 days (EXPIRED_RETENTION_SECONDS), then hard-deleted; API returns the same 404 as "never existed" |
- The background job runs every 15 minutes (
EXPIRE_JOB_INTERVAL_MS), marks expired requests, hard-deletes expired rows past the retention window, and retries network submission for pending requests whose signatures meet the threshold (up toMAX_SUBMIT_ATTEMPTS, default 5). This recovers from transient Horizon failures. - Threshold used is the account's medium threshold — the default for Stellar operations. (Operations that explicitly set a higher threshold are not auto-detected in v1.)
- Sequence numbers: the transaction is submitted with the sequence number it
was built with. If the account has moved on-chain since then, submission
fails with
tx_bad_seq(logged; the request reverts topendingand retries are bounded). Rebuilding the transaction with a fresh sequence is the fix — a known limitation of coordination services.
All configuration is environment-driven; see .env.example.
| Variable | Default | Meaning |
|---|---|---|
HOST / PORT |
0.0.0.0 / 3000 |
HTTP bind |
DATABASE_URL |
postgres://coordinator:coordinator@localhost:5432/coordinator |
Postgres |
TESTNET_HORIZON_URL |
https://horizon-testnet.stellar.org |
testnet Horizon |
MAINNET_HORIZON_URL |
https://horizon.stellar.org |
mainnet Horizon |
DEFAULT_TTL_SECONDS |
604800 (7 days) |
default request TTL |
MAX_TTL_SECONDS |
2592000 (30 days) |
client TTL cap |
EXPIRE_JOB_INTERVAL_MS |
900000 (15 min) |
background job interval |
EXPIRED_RETENTION_SECONDS |
2592000 (30 days) |
retention before hard delete |
MAX_SUBMIT_ATTEMPTS |
5 |
submission retry cap |
CORS_ORIGIN |
(empty = off) | comma-separated origins for browser frontends |
LOG_LEVEL |
info |
pino log level |
npm install
docker compose up -d postgres # Postgres 16 on :5432
npm run dev # tsx watch, http://localhost:3000Migrations run automatically at boot (idempotent, tracked in
schema_migrations). A manual runner is available: npm run db:migrate.
.env is loaded via Node's native --env-file flag (Node 20.6+ required).
There is no dotenv package in this project. Copy
.env.example to .env and adjust the values; the dev,
start, and db:migrate scripts already pass --env-file=.env, so do not
add a dotenv.config() call or the dotenv dependency. It would be redundant.
This API is deployed on Render's free web service tier. Free instances spin down after 15 minutes of inactivity, so the first request after idle time can take 30-60 seconds to respond while the instance wakes up.
The database runs on Neon's free tier, which also scales to zero after 5 minutes of inactivity. A cold query has a similar wake-up delay for the same reason.
This means the deployed instance is not suitable for latency-sensitive or production use as-is. It is a genuinely free, always-reachable but not always-warm deployment, appropriate for a demo, a low-traffic tool, or evaluation, not for anything time-critical.
Required Render environment variables (see .env.example):
DATABASE_URL=postgres://<user>:<password>@<host>.neon.tech/<database>?sslmode=require
TESTNET_HORIZON_URL=https://horizon-testnet.stellar.org
MAINNET_HORIZON_URL=https://horizon.stellar.org
CORS_ORIGIN= # optional: your frontend origin; comma-separated for multipleDATABASE_URL must point at the Neon connection string with sslmode=require.
The Render Start Command must be node dist/index.js, not npm run start.
The start script assumes a local .env file, which does not exist on Render.
On Render, configuration is injected directly via the dashboard instead.
Tests run against a real Postgres with the Stellar network simulated by fake gateways (no network access needed). Start Postgres, then:
docker run -d --name coordinator-test-pg \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=postgres \
-p 5433:5432 postgres:16-alpine
npm test # vitest (typecheck + lint are also run in CI)
npm run typecheck
npm run lintEach test file gets an isolated throwaway database. In CI the Postgres service
container is configured automatically (.github/workflows/ci.yml).
Apache License 2.0. See LICENSE, CONTRIBUTING.md, and SECURITY.md.