# Current env values (must match mainnet)
echo "STELLAR_NETWORK=$STELLAR_NETWORK"
echo "STELLAR_RPC_URL=$STELLAR_RPC_URL"
echo "VAULT_CONTRACT_ID=$VAULT_CONTRACT_ID"| Variable | Mainnet value |
|---|---|
STELLAR_NETWORK |
mainnet |
STELLAR_RPC_URL |
https://soroban-mainnet.stellar.org |
| Network passphrase | Public Global Stellar Network ; September 2015 |
NODE_ENV |
production |
Contract IDs, token addresses, and the STELLAR_AGENT_SECRET_KEY must be mainnet instances. A testnet key on mainnet will sign invalid operations.
# 1. Confirm network in env matches deployment context
grep STELLAR_NETWORK .env | grep -q mainnet || echo "WARN: not mainnet"
# 2. Verify RPC connection returns mainnet ledger
curl -s -X POST "$STELLAR_RPC_URL" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' | \
jq '.result.sequence'
# 3. Confirm agent key controls the vault on this network
# (validate via a read-only contract call — getVaultInfo or similar)
# 4. Verify Prisma migration status matches schema
npx prisma migrate statusOn startup, src/config/env.ts validates:
STELLAR_NETWORK∈ {mainnet, testnet, futurenet}STELLAR_AGENT_SECRET_KEYstarts withS, length 56WALLET_ENCRYPTION_KEYis exactly 64 hex chars- All required env vars are set (throws if missing)
The GET /health/ready endpoint reports three subsystems: database, eventListener, agentLoop. All must be ready: true before the load balancer marks the instance healthy.
| Secret | Source | Purpose | Rotation |
|---|---|---|---|
STELLAR_AGENT_SECRET_KEY |
env var | Signs Soroban contract calls (rebalance, update total assets) | On key compromise or quarterly |
WALLET_ENCRYPTION_KEY |
env var | AES-256-GCM key encrypting custodial wallet secrets in custodial_wallets table |
Coordinated re-encryption migration |
JWT_SEED |
env var | Signs session JWTs | Every 90 days (invalidates all sessions) |
DATABASE_URL |
env var/env file | PostgreSQL connection | DB password rotation per provider policy |
- Generate new Stellar keypair:
stellar keys generate neurowealth-agent-v2 # or via SDK - Fund the new public key with XLM on mainnet.
- If the vault contract maintains an operator allowlist, update it to include the new key.
- Set
STELLAR_AGENT_SECRET_KEYin your secret manager to the new secret. - Redeploy all instances (rolling update).
- Verify agent loop health:
GET /health/ready→agentLoop: ready. - Keep the old key funded for 30 days in case a rollback is needed.
- Drain and discard the old key after the rollback window.
- Provision
WALLET_ENCRYPTION_KEY_NEWin the secret manager alongside the current key. - Run a one-off migration script that:
- Reads every row from
custodial_wallets - Decrypts
encryptedSecretwith the old key - Re-encrypts with the new key
- Writes back the new
encryptedSecret,iv,authTag
- Reads every row from
- Swap the env var to the new key.
- Verify a sample of users can still sign operations.
- Remove the old key from the secret store.
Losing WALLET_ENCRYPTION_KEY permanently destroys all custodial wallet keys.
- Backup: Regular DB snapshots preserve encrypted key material.
- Audit: The
custodial_walletstable stores (publicKey,encryptedSecret,iv,authTag) — never plaintext secrets. - Disaster: If the DB is restored from a backup, the encryption key at backup time must still be available.
- Never commit secrets to git. Use
.env.exampleas a template. - Production: AWS Secrets Manager / HashiCorp Vault with access audit logging.
- CI/CD: GitHub Environments secrets, injected as env vars in deploy workflows.
- Local dev:
.envfile (gitignored).
src/stellar/client.ts creates a single rpc.Server(STELLAR_RPC_URL) singleton. There is no built-in automatic failover. A mainnet RPC outage halts event ingestion and agent operations.
Configure a single URL that routes across multiple RPC providers:
STELLAR_RPC_URL=https://soroban-mainnet.stellar.org
Replace this with a load balancer or provider that pools:
https://soroban-mainnet.stellar.org(SDF)https://mainnet.sorobanrpc.com(public)https://rpc.stellar.org/mainnet(alternative)
If you need resilience without a LB, wrap getRpcServer() to fall back:
const RPC_URLS = [
'https://soroban-mainnet.stellar.org',
'https://mainnet.sorobanrpc.com',
]
let currentIndex = 0
export function getRpcServer(): rpc.Server {
// Returns current server; call rotateRpc() on failure
if (!rpcServer) rpcServer = new rpc.Server(RPC_URLS[currentIndex])
return rpcServer
}
export function rotateRpc(): void {
currentIndex = (currentIndex + 1) % RPC_URLS.length
rpcServer = new rpc.Server(RPC_URLS[currentIndex])
logger.warn(`[RPC] Failed over to ${RPC_URLS[currentIndex]}`)
}Wire rotateRpc() into error handlers in fetchEvents and submitTransaction.
| Symptom | Action |
|---|---|
fetchEvents fails with connection error |
Rotate RPC URL (manual or automated) |
sendTransaction hangs or times out |
Rotate RPC; retry tx via getTransaction |
| Persistent RPC failures | Switch to backup RPC provider entirely |
| All known RPCs down | Pause event listener; set agentLoop to degraded; page on-call |
# Check latest ledger via RPC
curl -s -X POST "$STELLAR_RPC_URL" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' | \
jq '.result.sequence'
# Monitor via /metrics
curl -s http://localhost:3001/metrics | grep cursor_lag| Metric | Type | Description |
|---|---|---|
cursor_lag_ledgers |
Gauge | latest_ledger - last_processed_ledger |
last_processed_ledger |
Gauge | Last ledger successfully processed |
events_processed_total |
Counter | Events processed, labelled by type and status |
Alert rules are defined in docs/OBSERVABILITY.md and deployed to Prometheus.
| Severity | Lag | Action |
|---|---|---|
| Info | > 10 ledgers | Note — may be normal during low traffic |
| Warning | > 50 ledgers for 5 min | Investigate within 1 hour |
| Critical | > 100 ledgers for 2 min | Page immediately |
# 1. Check current lag
curl -s http://localhost:3001/metrics | grep cursor_lag
# 2. Check last processed ledger in DB
psql "$DATABASE_URL" -c "SELECT * FROM event_cursors WHERE \"contractId\" = '$VAULT_CONTRACT_ID';"
# 3. Check listener logs for errors
grep "Event Listener" /var/log/app/*.log | tail -50
# 4. Check RPC connectivity
curl -s -X POST "$STELLAR_RPC_URL" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' | \
jq '.result.sequence'
# 5. Check for backpressure (DLQ growth)
curl -s http://localhost:3001/metrics | grep dlq_size
# 6. Check database connection pool
psql "$DATABASE_URL" -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active';"| Cause | Signal | Fix |
|---|---|---|
| RPC outage | fetchEvents errors in logs |
Rotate RPC endpoint (see §3) |
| DB slow / locked | High db_operation_duration_seconds |
Check locks, pool size, index usage |
| Schema validation failures | DLQ growth, event_validation errors |
Inspect DLQ, fix event format or validator |
| Listener crashed | cursor_lag rising, agent_loop_status == 0 |
Container restart, check OOM killer |
| Network partition | RPC timeouts | Check DNS, firewall, egress rules |
# If lag < 1000 ledgers — automatic backfill runs on restart
# If lag > 1000 ledgers — manual backfill recommended via admin endpoint
# Manual backfill (from a specific ledger)
# Restart the service; backfill runs automatically up to latest
# If auto-backfill is too slow, consider:
# 1. Stop the listener
# 2. Update event_cursors to an earlier ledger
# 3. Restart the listener to trigger backfill
psql "$DATABASE_URL" -c "UPDATE event_cursors SET \"lastProcessedLedger\" = $EARLIER_LEDGER WHERE \"contractId\" = '$VAULT_CONTRACT_ID';"Events that fail processing (validation error, DB error, missing user) are stored in the dead_letter_events table with status PENDING. The DLQ module (src/stellar/dlq.ts) manages retries through three admin API endpoints.
# Via admin API (requires ADMIN_API_TOKEN)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
http://localhost:3001/api/admin/dlq/inspect | jq
# Filter by status
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://localhost:3001/api/admin/dlq/inspect?status=PENDING" | jq
# Via direct DB query
psql "$DATABASE_URL" -c "
SELECT id, \"eventType\", \"txHash\", ledger, status, \"retryCount\", error, \"createdAt\"
FROM dead_letter_events
ORDER BY \"createdAt\" DESC
LIMIT 50;
"curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dryRun": true}' \
http://localhost:3001/api/admin/dlq/retry | jqDry run simulates the retry loop without persisting status changes.
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
http://localhost:3001/api/admin/dlq/retry | jqReturns:
{
"resolved": 5,
"failed": 2,
"totalRemaining": 2
}If an event cannot be processed (e.g. user deleted), manually resolve it:
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": "uuid-of-event"}' \
http://localhost:3001/api/admin/dlq/resolve | jqretryAll()processes allPENDINGandRETRIEDevents sequentially.- Success → status set to
RESOLVED, count +1. - Failure → status set to
RETRIED, count +1, logged. - There is no automatic scheduled retry. All retries are manual via the admin API.
- When DLQ size reaches 50, a critical log line is emitted and the Prometheus
dlq_sizegauge crosses the critical threshold.
| Event type | Common failure | Retry likely? | Notes |
|---|---|---|---|
deposit |
User not found | No until user exists | Resolve after user registers |
deposit |
Schema validation | Depends | Fix validator or event source |
withdraw |
Position not found | No | May indicate data integrity issue |
rebalance |
DB constraint | Yes | Transient — retry typically succeeds |
| Any | RPC/DB timeout | Yes | Transient — retry typically succeeds |
Prisma migrations are forward-only — there is no built-in down. Every migration
in prisma/migrations/<name>/ therefore ships a hand-written rollback.sql
alongside its migration.sql. CI enforces this via
scripts/check-migration-rollback.sh.
Roll back when a freshly deployed migration is itself the problem (broken schema, failed constraint, performance regression). If the application code is the problem, prefer redeploying the previous app version over a schema rollback.
WARNING: Rollbacks can be destructive —
DROP TABLE/DROP COLUMNdiscard data. Confirm a recent backup/snapshot exists before proceeding. Some rollbacks are flagged partially irreversible inside theirrollback.sql(e.g.20260617000000_fix_agent_log_attributioncannot restoreNOT NULLif system-generated rows with a nulluserIdexist).
# 1. Take / confirm a database snapshot first.
# 2. Identify the migration to reverse (most recent applied is the usual target)
npx prisma migrate status
# 3. Run the rollback for that migration (applies rollback.sql, then marks it
# rolled back in _prisma_migrations and runs a health check).
DATABASE_URL=$DATABASE_URL bash scripts/rollback-migration.sh <migration-name>
# Optionally verify the live app afterwards:
HEALTHCHECK_URL=http://localhost:3001/health/ready \
DATABASE_URL=$DATABASE_URL bash scripts/rollback-migration.sh <migration-name>
# 4. Re-deploy the previous application version if the schema change was paired
# with code changes.After a successful rollback the migration is marked rolled_back_at in Prisma's
history, so a later prisma migrate deploy (with a fixed migration) re-applies it.
Every new migration PR must add a rollback.sql that reverses its migration.sql:
drop what it created, recreate what it dropped, and document any irreversible
steps as comments. The Migration rollback check workflow blocks merge otherwise.
| Tier | Role | Responsibility | Contact |
|---|---|---|---|
| T1 | On-call engineer | Triage, restart, DLQ retry, RPC rotation | PagerDuty / Opsgenie |
| T2 | Backend lead | Code fix, data reconciliation, migration rollback | Slack @backend-lead |
| T3 | Engineering manager | Stakeholder comms, post-mortem, priority decisions | Slack @eng-mgr |
| T4 | Security officer | Key compromise, wallet recovery, audit | Slack @sec-officer |
| Channel | Purpose |
|---|---|
#neurowealth-alerts |
Prometheus alert notifications |
#neurowealth-incidents |
Incident coordination thread |
| PagerDuty | T1 on-call escalation |
Email: [email protected] |
Backup contact for critical outages |
| Severity | Definition | Response time | Escalation |
|---|---|---|---|
| SEV1 | Event processing halted, funds at risk, data loss | < 15 min | T1 → T2 → T3 |
| SEV2 | Lag > 100 ledgers, DLQ > 50, agent loop degraded | < 1 hour | T1 → T2 |
| SEV3 | Lag > 50 ledgers, DLQ > 20, elevated error rate | < 8 hours | T1 |
| SEV4 | Minor anomalies, informational alerts | Next business day | None |
- Root cause identified and documented
- Fix deployed (or rollback executed)
- DLQ resolved and lag cleared
- Alert thresholds adjusted if needed
- Post-mortem filed in
docs/post-mortems/ - Runbook updated with lessons learned
# Health
curl http://localhost:3001/health/live
curl http://localhost:3001/health/ready
curl http://localhost:3001/health
# Metrics
curl http://localhost:3001/metrics | grep -E "(cursor_lag|dlq_size|events_processed|agent_loop)"
# DLQ inspect
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
http://localhost:3001/api/admin/dlq/inspect | jq '. | length'
# DLQ retry (dry run)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dryRun": true}' \
http://localhost:3001/api/admin/dlq/retry
# DLQ retry (live)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
http://localhost:3001/api/admin/dlq/retry
# DB — cursor status
psql "$DATABASE_URL" -c "SELECT * FROM event_cursors;"
# DB — DLQ count by status
psql "$DATABASE_URL" -c "
SELECT status, count(*) FROM dead_letter_events GROUP BY status;
"
# DB — recent processed events
psql "$DATABASE_URL" -c "
SELECT \"eventType\", ledger, \"txHash\", \"createdAt\"
FROM processed_events
ORDER BY ledger DESC LIMIT 10;
"Sponsored reserves move the XLM cost from user to sponsor accounts (STELLAR_SPONSOR_KEYS). Monitor GET /api/v1/admin/reserves (admin-scoped, audit-logged) for outstandingXlm and perSponsor[].availableXlm. Alert SponsorLowXlm fires when any sponsor < 10 XLM for 5m.
Top-up:
# Check
curl -H "Authorization: Bearer $ADMIN_API_TOKEN" http://localhost:3001/api/v1/admin/reserves | jq
# Fund sponsor from treasury/ops hot wallet via Stellar Laboratory or
stellar account fund --destination <sponsorPublicKey> --amount 100 --network public
# Verify
curl -s http://localhost:3001/metrics | grep sponsor_available_xlm
psql "$DATABASE_URL" -c "SELECT \"sponsorAccount\", count(*), sum(\"xlmReserved\") FROM reserve_sponsorships WHERE status='ACTIVE' GROUP BY \"sponsorAccount\";"No auto top-up — operational runbook only. Reconciliation job (reserveReconciliation hourly) flags drift where on-chain sponsor ≠ ledger.