Real-time Soroban smart contract monitoring and webhook alert engine for the Stellar network.
Part of the TxWatch ecosystem.
TxWatch sits between the Stellar Horizon REST API and your infrastructure. It polls every contract you configure, evaluates alert rules against each new transaction, and fires a JSON webhook the moment a condition is met — no SDK, no subscriptions, no infrastructure beyond a single Rust binary.
Stellar Network
│
▼
Horizon REST API ← TxWatch polls this
(testnet / mainnet /
futurenet)
│
▼
txwatch-poller ← fetches /accounts/{contract}/transactions
│ fetches /transactions/{hash}/operations
▼
txwatch-rules ← evaluates AlertRules against each transaction
│
▼
txwatch-notifier ← POSTs AlertPayload JSON to your webhook URL
│
▼
Your webhook receiver ← Slack, PagerDuty, custom API, etc.
| Concept | What it means here |
|---|---|
| Stellar | Layer-1 blockchain with fast finality (~5 s) and low fees |
| Soroban | Stellar's smart contract platform (WebAssembly-based) |
| Horizon | The REST API gateway to the Stellar network — TxWatch's data source |
| Contract address | A 56-character string starting with C (e.g. CABC...) |
| XLM | Stellar's native asset; 1 XLM = 10,000,000 stroops |
| Stroop | Smallest unit of XLM (like satoshi for Bitcoin) |
| Paging token | Horizon cursor used to fetch only new transactions since last poll |
| invoke_host_function | The Horizon operation type for a Soroban contract call |
| Endpoint | Purpose |
|---|---|
GET /accounts/{contract_id}/transactions?cursor=…&order=asc |
Fetch new transactions for a contract |
GET /transactions/{hash}/operations |
Fetch operations to extract function name and payment amount |
| Network | Horizon base URL |
|---|---|
| Mainnet | https://horizon.stellar.org |
| Testnet | https://horizon-testnet.stellar.org |
| Futurenet | https://horizon-futurenet.stellar.org |
| Custom / local | Your own horizon_url, e.g. http://localhost:8000 for stellar/quickstart --local |
# 1. Clone
git clone https://github.com/Tx-wats/core
cd core
# 2. Copy and edit the example config (./txwatch.toml is the default path)
cp config/example.toml txwatch.toml
$EDITOR txwatch.toml
# 3. Validate your config
cargo run -p txwatch -- validate
# 4. Send a test webhook to confirm your receiver works
cargo run -p txwatch -- test-webhook --url https://hooks.example.com/my-webhook
# 5. Start watching
cargo run -p txwatch -- watchTo pick up config changes without restarting (and without losing cursors),
send SIGHUP: kill -HUP <pid>. An invalid file is logged and ignored.
Set RUST_LOG=debug for verbose output. This also enables per-contract idle
poll logs — the poller emits "no new transactions" debug messages with the
contract label and current cursor when a poll finds nothing new.
Build the image:
docker build -t txwatch .Run with a config file (the image reads TXWATCH_CONFIG=/config/txwatch.toml):
docker run -v $(pwd)/txwatch.toml:/config/txwatch.toml txwatch watchOr validate your config:
docker run -v $(pwd)/txwatch.toml:/config/txwatch.toml txwatch validateTo watch contracts on a local standalone network, docker compose -f docker-compose.local.yml up
starts stellar/quickstart --local together with TxWatch using config/local.toml.
For local development with webhook testing, see Local Development with Docker Compose in CONTRIBUTING.md.
txwatch [--config <path>] <command>
Commands:
watch Start the polling engine
validate Validate the config file and print a summary
test-webhook --url <URL> Send a test payload to a webhook URL and exit
init Write a starter config (prompts for anything not passed)
completions <shell> Print a completion script (bash, zsh, fish, powershell, elvish)
man Print the txwatch(1) man page
Install completions with e.g. txwatch completions bash > ~/.local/share/bash-completion/completions/txwatch
or txwatch completions zsh > "${fpath[1]}/_txwatch", and the man page with
txwatch man > ~/.local/share/man/man1/txwatch.1. Release archives also ship a
completions-and-man bundle with both pre-generated.
Start a new config with txwatch init --contract-id C... --network testnet --webhook-url https://...
(add --output <path>, default txwatch.toml). Any value not passed as a flag is prompted for.
The generated file is validated before it's written, and an existing file is only replaced with
--force.
--config defaults to config/example.toml.
watch [--once] [--dry-run] Start the polling engine
validate [--format text|json] Validate the config file and print a summary
test-webhook --url Send a test payload to a webhook URL and exit
replay --contract --tx [--send]
Evaluate a contract's rules against one transaction
`replay` fetches a historical transaction and its operations from Horizon, runs the named
contract's rules against it and prints every matched rule with its webhook payload, so rule authors
can check "would my rules have fired for transaction X?". Nothing is sent unless `--send` is given.
`validate --format json` prints the parsed config as a single JSON object (webhook secrets are
redacted to `webhook_secret_set`), or `{"valid": false, "error": "..."}` with exit code 1.
`--config` defaults to `config/example.toml`. `--horizon-url <URL>` overrides the Horizon base URL
for every contract (for example a private Horizon instance).
`watch --once` runs a single poll cycle, delivers any alerts, saves cursors to `cursor_file` (if
configured) and exits, for use from cron, CI jobs or serverless schedulers. It exits `1` if any
contract poll or webhook delivery failed, `0` otherwise. Set `cursor_file` so each run picks up
where the previous one stopped; without it every run starts from `now`.
The config path comes from `--config`, else the `TXWATCH_CONFIG` environment variable, else
`./txwatch.toml`. TxWatch exits with an error if that file does not exist.
---
## Config
See [docs/configuration.md](docs/configuration.md) for the full reference.
```toml
poll_interval_seconds = 10
[[contracts]]
label = "My Escrow Contract"
contract_id = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"
network = "testnet"
webhook_url = "https://hooks.example.com/my-webhook"
[[contracts.rules]]
type = "LargeTransfer"
threshold_xlm = 10000
[[contracts.rules]]
type = "AdminFunctionCalled"
function_names = ["set_admin", "upgrade", "initialize"]
webhook_url = "https://hooks.example.com/critical-webhook"
severity = "critical"
[[contracts.rules]]
type = "TransactionFailed"
enabled = false
[[contracts.rules]]
type = "SourceAccount"
deny = ["GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"]
[[contracts.rules]]
type = "All"
[[contracts.rules.rules]]
type = "FunctionCalled"
function_name = "withdraw"
[[contracts.rules.rules]]
type = "LargeTransfer"
threshold_xlm = 50000
| Rule | Triggers when… |
|---|---|
AnyTransaction |
Any transaction touches the contract |
TransactionFailed |
A transaction fails (successful = false) |
LargeTransfer |
Payment amount ≥ threshold_xlm XLM |
FunctionCalled |
A specific Soroban function is invoked |
AdminFunctionCalled |
Any function in a named list is invoked |
HighFee |
Transaction fee exceeds configured threshold |
SourceAccount |
Transaction source account matches an allow/deny list |
All |
All nested rules match (logical AND) |
Any |
Any nested rule matches (logical OR) |
Not |
Nested rule does not match (logical NOT) |
Every rule entry also supports:
enabled = false— silence a rule without removing it; shown as(disabled)intxwatch validatewebhook_url— per-rule webhook URL override (falls back to the contract's URL)webhook_secret— per-rule webhook secret overrideseverity—info,warning, orcritical; included in the alert payload |HighFee| Transaction fee is greater than or equal tothreshold_stroops(orthreshold_xlm) | |EventEmitted| The transaction emitted a contract event whose first topic is a given symbol |
Any rule can set cooldown_seconds to send at most one alert per window for that rule on that contract;
suppressed matches are reported in suppressed_count on the next alert.
See docs/alert-rules.md for full details.
{
"schema_version": 1,
"alert_id": "a3f1bc20e94d77c1a3f1bc20e94d77c1",
"alert_id": "3f2b9c1d8e7a6b5c4d3e2f1a0b9c8d7e",
"label": "My Escrow Contract",
"contract_id": "CAAA...",
"network": "testnet",
"rule_type": "LargeTransfer",
"rule_triggered": "LargeTransfer(>=10000XLM)",
"transaction_hash": "abc123...",
"function_name": "transfer",
"function_names": ["transfer"],
"amount_xlm": 15000,
"amount_stroops": 150000000000000,
"amount_xlm_decimal": "15000.0000000",
"fee_charged_stroops": 50000,
"source_account": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN",
"severity": "critical",
"timestamp": 1705316096,
"timestamp_iso": "2024-01-15T12:00:00Z",
"horizon_link": "https://horizon-testnet.stellar.org/transactions/abc123...",
"explorer_link": "https://stellar.expert/explorer/testnet/tx/abc123...",
"resolved": false,
"matched_events": [],
"suppressed_count": 0
}Webhook headers:
Content-Type: application/jsonContent-Length: <length of JSON body in bytes>X-TxWatch-Version: <package version>X-TxWatch-Alert-Id: <alert_id>(same value as thealert_idbody field — usable for deduplication without parsing the body)X-TxWatch-Signature: sha256=<hmac>(optional, only whenwebhook_secretis configured — HMAC-SHA256 of the request body)X-TxWatch-Secret: <webhook_secret>(optional, only whenwebhook_secretis configured — the raw secret; verify the signature instead where possible)- Any custom headers from
webhook_headers(e.g.Authorization: Bearer ${TOKEN})
Destinations and formats: a contract can deliver to several receivers at once via
[[contracts.webhooks]], and each destination can use format = "slack", "discord" or
"pagerduty" instead of the JSON above, so Slack, Discord and PagerDuty work without an adapter
service. See Multiple destinations and
Webhook formats.
Fields:
schema_version— integer version of this payload shape (currently1). Additive changes (new optional fields) keep the same version; breaking changes (field removals or renames) bump it. Receivers should use this to detect incompatible changes.alert_id— stable, deterministic identifier derived from(network, contract_id, tx_hash, rule_type, rule_triggered)via SHA-256 prefix (32 hex chars). Identical for every retry of the same alert. Receivers should deduplicate on this value.alert_id— stable ID of this alert (same contract, transaction and rule → same ID); use it to de-duplicate redeliveriesrule_type— stable machine-readable rule variant (e.g."LargeTransfer","HighFee"); use this for programmatic routingrule_triggered— human-readable rule description with parameters (e.g."LargeTransfer(>=10000XLM)"); use this for displayfunction_name— the first invoked Soroban function name, ornullfor non-Soroban transactions.function_names— all invoked Soroban function names in the transaction (may contain multiple entries for multi-op transactions).source_account— the G-address that submitted the transaction; omitted from the payload when not present on the Horizon record.severity— the severity level set on the matching rule ("info","warning", or"critical"); omitted when not configured.amount_xlm— transfer amount in whole XLM (truncated integer, e.g.9999for a 9,999.99 XLM transfer), ornull. Kept for backward compatibility — useamount_xlm_decimalfor precise accounting.amount_stroops— raw transfer amount in stroops (1 XLM = 10,000,000 stroops), ornull.amount_xlm_decimal— transfer amount as a decimal string with 7 fractional digits (e.g."9999.9900000"), ornull. Use this instead ofamount_xlmwhen precision matters.matched_events— forEventEmittedalerts, the matching contract events (contract_id,topics,data, as decodedScValJSON); empty for other rules.suppressed_count— matches of this rule suppressed by itscooldown_secondssince the previous alert;0otherwise.horizon_link— direct Horizon REST API URL for the transaction (e.g.https://horizon-testnet.stellar.org/transactions/<hash>); useful for fetching raw XDR or operation details programmatically.explorer_link— Stellar Expert web explorer URL for the transaction (e.g.https://stellar.expert/explorer/testnet/tx/<hash>); useful for human-readable inspection in a browser.
horizon_linkvsexplorer_link:horizon_linkpoints to the Horizon REST API endpoint and returns raw JSON — useful for programmatic access.explorer_linkpoints to the Stellar Expert web UI — useful for human inspection. Both are always present for every alert regardless of rule type.
txwatch (cli binary)
│
├── txwatch-config TOML parsing · contract ID validation · rule validation
│
└── txwatch-poller Horizon polling loop · cursor tracking · op enrichment
│
├── txwatch-rules AlertRule evaluation · AlertPayload construction
│
└── txwatch-notifier Webhook POST · 3-attempt exponential backoff · tracing logs
| Crate | Responsibility |
|---|---|
txwatch-config |
Parse config.toml into typed structs; validate all fields |
txwatch-rules |
Pure rule evaluation — no I/O, fully unit-testable |
txwatch-notifier |
HTTP webhook delivery with retry; timestamped structured logs |
txwatch-poller |
Horizon REST client; cursor map; per-transaction error isolation |
txwatch (cli) |
clap binary; tracing init; subcommand dispatch |
TxWatch uses tracing spans to correlate work across each poll cycle and webhook delivery.
txwatch-poller::poll_contractis instrumented withcontract,contract_id, andnetworkfields.txwatch-poller::fetch_soroban_detailsis instrumented with the transaction hash.txwatch-notifier::send_webhookcreates a span withcontractandrulefields before sending the webhook request.
Set RUST_LOG=info or a more specific filter to view structured tracing output in the CLI.
Logs are human-readable text by default. For log pipelines (Loki, Datadog, CloudWatch), pass
--log-format json or set TXWATCH_LOG_FORMAT=json to emit one JSON object per line. Event
fields (contract, tx, rule, attempt, ...) stay as JSON fields, and each line includes the
current span and the full span list:
txwatch --log-format json watch
TXWATCH_LOG_FORMAT=json txwatch watchBuild the txwatch binary with the metrics feature and pass --metrics-addr to watch:
cargo build --release -p txwatch --features metrics
./target/release/txwatch --config config.toml watch --metrics-addr 127.0.0.1:9090This serves GET /metrics (Prometheus text format), GET /healthz (process alive) and
GET /readyz (200 once a poll has succeeded recently, else 503). Exported series include
txwatch_transactions_total, txwatch_alerts_total and txwatch_webhook_failures_total
(labelled by contract and network), Horizon request and webhook delivery latency histograms,
per-contract freshness gauges and txwatch_build_info. Without the feature (the default build),
the --metrics-addr flag doesn't exist.
Library users can call txwatch_poller::serve_metrics(addr, shutdown) themselves before
run_with_shutdown, with txwatch-poller's metrics feature enabled.
The endpoint can be scraped by any Prometheus-compatible monitoring stack (Prometheus, Grafana Agent, VictoriaMetrics, etc.).
Example scrape config:
scrape_configs:
- job_name: txwatch
static_configs:
- targets: ['localhost:9090']1. Poller wakes up (every poll_interval_seconds)
2. For each contract:
a. GET /accounts/{contract_id}/transactions?cursor={last_seen}&order=asc
b. For each new transaction:
i. Advance cursor (even if enrichment fails)
ii. GET /transactions/{hash}/operations
→ extract function_name from invoke_host_function ops
→ extract amount_stroops from payment ops
iii. Build EnrichedTransaction
iv. Evaluate all AlertRules → Vec<AlertPayload>
v. POST each AlertPayload to webhook_url (retry up to 3×)
| Resource | URL |
|---|---|
| Testnet Horizon | https://horizon-testnet.stellar.org |
| Stellar Expert (testnet) | https://stellar.expert/explorer/testnet |
| Stellar Laboratory | https://laboratory.stellar.org |
| Friendbot (fund testnet accounts) | https://friendbot.stellar.org |
| Soroban docs | https://developers.stellar.org/docs/build/smart-contracts/overview |
| Repo | Description |
|---|---|
| tx-watch-web | Web dashboard for alert history and contract management |
| tx-watch-contracts | Example Soroban contracts to monitor with TxWatch |
See CONTRIBUTING.md.
- #35: HTTP timeouts for Horizon and webhooks are hard-coded to 15 seconds
- #38: startup_log_fields test helper duplicates production logic instead of testing it
- #24: No way to configure a custom Horizon URL per contract
- #26: Allow a starting cursor or ledger instead of always starting from "now"
- #33: http_connection_verbose is parsed but never used
- #34: http_tcp_keepalive_secs = 0 does not disable keepalive
- #27: A corrupt cursor_file silently resets every contract to "now"
- #28: Ctrl-C waits for webhook retries to finish before shutting down
- #39: Tests sleep in real time for back-offs, slowing the suite by many seconds
- #41: FunctionCalled is case-sensitive while AdminFunctionCalled is case-insensitive