Monitoring and alerting for Soroban smart contracts. Point SoroBeacon at one or more contracts on Stellar, define rules ("this event fired", "an emitted value crossed a threshold"), and get alerts on Discord, Slack, Telegram, email, or any webhook β with a small dashboard to manage monitors and review alert history.
Stellar has no good open-source way to watch a contract and get notified when something happens on it. SoroBeacon aims to be that missing public good for the Soroban ecosystem: a clean, well-tested core that is deliberately easy to extend with new rule types and notification channels.
Stellar RPC ββgetEventsβββΆ poller βββΆ decoder βββΆ rules engine βββΆ alerts βββΆ dispatcher βββΆ channels
β β β
βββ ingest_state ββββββββ Postgres βββββ΄βββββ delivery_attempts βββ
- The poller calls the RPC's
getEventsfor every contract watched by an enabled monitor (batched across filters to respect RPC caps), following the pagination cursor and resuming from a checkpoint after restarts. Soroban RPCs only retain events for ~1β7 days, so SoroBeacon polls continuously and alerts in near-real-time. - The decoder turns event topics/values into plain Go values, preferring
the RPC's
xdrFormat: "json"output and falling back to decoding base64 XDRScVals locally (via the maintainedgithub.com/stellar/go-stellar-sdk, which supersedes the deprecatedgithub.com/stellar/go). - The rules engine runs every enabled rule of every monitor watching the
event's contract. Matches create alerts, deduplicated on
(rule_id, event_id)so a rule can never fire twice for the same event. - The dispatcher fans each alert out to the monitor's channels with retries and exponential backoff, recording every delivery attempt.
git clone <this repo> && cd sorobeacon
docker compose up --build -d
open http://localhost:8080 # dashboardThat starts Postgres and SoroBeacon against the Stellar testnet RPC. Migrations run automatically on startup.
Running without Docker:
cp .env.example .env # edit DATABASE_URL
make build
set -a; . ./.env; set +a; ./bin/sorobeaconAll configuration comes from environment variables:
| Variable | Default | Description |
|---|---|---|
RPC_URL |
https://soroban-testnet.stellar.org |
Stellar RPC endpoint (set a mainnet URL here) |
DATABASE_URL |
(required) | Postgres connection string |
POLL_INTERVAL |
5s |
How often to poll getEvents (min 1s) |
HTTP_ADDR |
:8080 |
API + dashboard listen address |
LOG_LEVEL |
info |
debug | info | warn | error |
Channel secrets (webhook URLs, bot tokens, SMTP credentials) live in each
channel's config JSON in the database. They are never logged and never
returned by the API. Encrypting them at rest is an open contributor issue.
β οΈ The API and dashboard have no authentication in the MVP. Run them on a trusted network or behind a reverse proxy that adds auth.
All endpoints are under /api/v1.
# Create a monitor watching one or more contracts
curl -s -X POST localhost:8080/api/v1/monitors -d '{
"name": "My token",
"contract_ids": ["CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"],
"channel_ids": [1]
}'
curl -s localhost:8080/api/v1/monitors # list (add ?enabled=true)
curl -s localhost:8080/api/v1/monitors/1 # get one
curl -s -X PATCH localhost:8080/api/v1/monitors/1 -d '{"enabled": false}'
curl -s -X DELETE localhost:8080/api/v1/monitors/1PATCH accepts any subset of name, contract_ids, enabled,
channel_ids; channel_ids replaces the monitor's channel attachments.
Two rule types ship with the MVP:
event_emitted β match on event name (the first topic, by Soroban
convention) and/or exact topic values:
curl -s -X POST localhost:8080/api/v1/monitors/1/rules -d '{
"type": "event_emitted",
"params": {
"event_name": "transfer",
"topic_equals": {"1": "GDW6...SENDER"}
}
}'value_threshold β numeric comparison on the event's decoded value.
value_path is a dot path into the value (map keys / array indexes); omit it
when the value itself is the number. Use a string threshold for integers
beyond 53 bits:
curl -s -X POST localhost:8080/api/v1/monitors/1/rules -d '{
"type": "value_threshold",
"params": {
"event_name": "transfer",
"value_path": "amount",
"comparison": "gt",
"threshold": "1000000000"
}
}'comparison is one of gt, gte, lt, lte, eq, neq.
curl -s localhost:8080/api/v1/monitors/1/rules
curl -s -X PATCH localhost:8080/api/v1/monitors/1/rules/2 -d '{"enabled": false}'
curl -s -X DELETE localhost:8080/api/v1/monitors/1/rules/2Five channel types ship with the MVP. config is validated on create/update
and never returned in responses.
# Discord
curl -s -X POST localhost:8080/api/v1/channels -d '{
"name": "ops-discord", "type": "discord",
"config": {"webhook_url": "https://discord.com/api/webhooks/..."}
}'
# Slack: {"webhook_url": "https://hooks.slack.com/services/..."}
# Telegram: {"bot_token": "123:abc", "chat_id": "-1001234567890"}
# Email: {"host": "smtp.example.com", "port": 587, "username": "u",
# "password": "p", "from": "beacon@example.com", "to": ["ops@example.com"]}
# Webhook: {"url": "https://example.com/hook", "secret": "shared-secret"}
curl -s localhost:8080/api/v1/channels
curl -s -X PATCH localhost:8080/api/v1/channels/1 -d '{"enabled": false}'
curl -s -X DELETE localhost:8080/api/v1/channels/1
# Send a test alert through a channel
curl -s -X POST localhost:8080/api/v1/channels/1/testGeneric webhook deliveries carry an X-SoroBeacon-Signature header: the hex
HMAC-SHA256 of the request body under your secret.
curl -s 'localhost:8080/api/v1/alerts?monitor_id=1&from=2026-07-01T00:00:00Z&limit=20'
curl -s 'localhost:8080/api/v1/alerts?cursor=42' # keyset pagination (next_cursor)
curl -s localhost:8080/api/v1/alerts/7/deliveries # delivery attempts for one alert
curl -s localhost:8080/api/v1/health
curl -s localhost:8080/api/v1/statsmake build # go build -> bin/sorobeacon
make test # unit tests (store integration tests skip without a DB)
make test-db # all tests against the compose Postgres
make lint # golangci-lint
make up / down # docker composeLayout:
cmd/sorobeacon wiring + graceful shutdown
internal/config env config
internal/stellar RPC client (getEvents/getLatestLedger/getHealth) + ScVal decoder
internal/store Postgres (pgx) + embedded golang-migrate migrations
internal/rules RuleEvaluator interface + event_emitted, value_threshold
internal/notify Notifier interface + 5 channels + retrying dispatcher
internal/poller ingest loop: poll -> decode -> match -> alert -> dispatch
internal/api chi JSON API
internal/web html/template + htmx dashboard
Implement notify.Notifier and register a constructor β that's it:
// internal/notify/matrix.go
func NewMatrix(config json.RawMessage) (notify.Notifier, error) { ... }
// register it in DefaultFactory (internal/notify/notify.go):
f.Register("matrix", NewMatrix)Validate config in the constructor (the API calls it to reject bad channels
early), keep secrets out of error messages, and add a test. See
internal/notify/slack.go for the smallest complete example.
Implement rules.RuleEvaluator (an Evaluate + a Validate method) and
register it in rules.NewRegistry:
// internal/rules/cooldown.go
type Cooldown struct{}
func (Cooldown) Validate(params json.RawMessage) error { ... }
func (Cooldown) Evaluate(ctx context.Context, ev *stellar.DecodedEvent, params json.RawMessage) (bool, error) { ... }
// register it in NewRegistry (internal/rules/rules.go):
r.Register("cooldown", Cooldown{})Decoded events use a small value vocabulary (nil, bool, string,
*big.Int, []byte, []any, map[string]any); stellar.Canon,
stellar.ToBigFloat and stellar.Lookup are the helpers rules build on.
- Secret encryption at rest for
channels.config - API authentication
- More rule types (rate/frequency, absence-of-event, aggregation windows)
- More channels (Matrix, PagerDuty, ntfy, ...)
- A richer SPA dashboard (the current one is intentionally minimal)
- Contract-spec-aware event decoding (named fields instead of raw topics)
Apache-2.0 β see LICENSE.