Soroban smart contracts for on-chain alert configuration storage and watcher registry.
Part of the Tx-wats organization.
| Contract | Description |
|---|---|
| Alert Registry | Stores alert configs on-chain keyed by contract address |
| Watcher Registry | Stores authorized watcher node addresses |
# Install Rust + Soroban target
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
# Build
cargo build --release --target wasm32-unknown-unknown
# Test
cargo test
# Generate TypeScript bindings
make bindings-allTypeScript bindings for the AlertRegistry contract live in bindings/alert-registry/ — see its README for usage examples.
Bindings are published to npm as @tx-wat/alert-registry-bindings by the
publish-bindings workflow when a GitHub release is
tagged. Until the first tagged release, generate them locally:
make bindings-alertflowchart TD
subgraph Stellar["Stellar Network (on-chain)"]
AR["AlertRegistry\n─────────────\nstores alert configs\nkeyed by contract address"]
WR["WatcherRegistry\n─────────────\nstores authorized\nwatcher addresses"]
end
subgraph OffChain["Off-chain (tx-watch-core)"]
W["Watcher Node\n─────────────\npolls Horizon\nmatches rules\nfires webhooks"]
end
Owner["Owner"] -->|"register_alert / update_alert"| AR
Admin["Admin"] -->|"register_watcher / remove_watcher"| WR
W -->|"is_watcher_authorized(watcher)"| WR
W -->|"get_alerts_for_contract(target)"| AR
W -->|"get_alerts_for_contract(querier, target)"| AR
AR -->|"is_watcher_authorized(querier)\n(on-chain, when gating enabled)"| WR
Horizon["Horizon API"] -->|"GET /accounts/{id}/transactions"| W
W -->|"POST webhook URL"| Endpoint["Downstream\nIntegration"]
AlertRegistry's call intoWatcherRegistry(viaassert_watcher_if_configured) is optional: it only fires on gated read queries once an admin has pointedAlertRegistryat aWatcherRegistrycontract withset_watcher_registry. Until then,AlertRegistrynever callsWatcherRegistryand reads stay open.
Data flow:
- An owner registers an alert in
AlertRegistry— specifying the target contract, rules, and a hashed webhook URL. - Authorized watcher nodes are recorded in
WatcherRegistryby an admin. - A watcher node polls Horizon for transaction activity, fetches matching alert configs from
AlertRegistry, and checks whether any rule matches. - On a match the watcher fires the configured webhook so downstream integrations can react.
- If
AlertRegistryhas been configured with aWatcherRegistryaddress (optional, viaset_watcher_registry), each gated read from step 3 makes its own on-chain cross-contract call intoWatcherRegistry::is_watcher_authorizedbefore returning data — independent of the watcher node's own off-chainis_watcher_authorizedcheck in step 2.
The system is centered around three data-flow steps:
- An owner registers an alert in
AlertRegistrywith the contract address, labels, webhook hash, and rules. - Authorized watcher nodes poll Horizon for transaction activity, then check the stored alert definitions in
AlertRegistryto determine whether a watched contract event matches. - When a match is found, the watcher fires the configured webhook so downstream integrations can react.
This keeps alert configuration on-chain while letting watcher nodes perform off-chain polling and delivery.
# Testnet
rpc_url = "https://soroban-testnet.stellar.org"
passphrase = "Test SDF Network ; September 2015"
horizon_url = "https://horizon-testnet.stellar.org"
# Mainnet
rpc_url = "https://mainnet.stellar.validationcloud.io/v1/<API_KEY>"
passphrase = "Public Global Stellar Network ; September 2015"
horizon_url = "https://horizon.stellar.org"Below is a consolidated cheatsheet with copy-pasteable stellar contract invoke examples for every public function across both contracts, grouped to mirror the reference documentation in docs/alert-registry.md and docs/watcher-registry.md.
Admin & Configuration:
# Initialize contract admin (one-time)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- initialize \
--admin <ADMIN_ADDRESS>
# Transfer admin role (AlertRegistry has a single admin; takes effect immediately)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- transfer_admin \
--admin <ADMIN_ADDRESS> \
--new_admin <NEW_ADMIN_ADDRESS>
# Get current admin
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_admin
# Set per-owner alert limit (0 = unlimited)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- set_per_owner_alert_limit \
--admin <ADMIN_ADDRESS> \
--limit 10
# Get configured per-owner alert limit
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_per_owner_alert_limit
# Configure Watcher Registry address for read gating
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- set_watcher_registry \
--admin <ADMIN_ADDRESS> \
--watcher_registry <WATCHER_REGISTRY_CONTRACT_ID>
# Get configured Watcher Registry address
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_watcher_registry
# Check if watcher gating is enabled
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- is_watcher_gating_enabledAlert Mutations:
# Register a new alert config
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- register_alert \
--owner <OWNER_ADDRESS> \
--target_contract <WATCHED_CONTRACT_ADDRESS> \
--label "My Alert" \
--webhook_hash "<sha256-of-webhook-url>" \
--rules '["rule:transfer","rule:mint"]'
# Update alert rules and active flag
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- update_alert \
--caller <OWNER_ADDRESS> \
--config_id 1 \
--rules '["rule:transfer"]' \
--active true
# Update alert label
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- update_label \
--caller <OWNER_ADDRESS> \
--config_id 1 \
--label "Updated Alert Label"
# Update webhook hash directly
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- update_webhook \
--caller <OWNER_ADDRESS> \
--config_id 1 \
--webhook_hash "<new-sha256-hash>"
# Propose new webhook hash (step 1 of 2-step rotation)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- propose_webhook \
--caller <OWNER_ADDRESS> \
--config_id 1 \
--webhook_hash "<staged-sha256-hash>"
# Confirm new webhook hash (step 2 of 2-step rotation)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- confirm_webhook \
--caller <OWNER_ADDRESS> \
--config_id 1
# Renew alert TTL (owner-authenticated, preserves updated_at)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- renew_alert_ttl \
--caller <OWNER_ADDRESS> \
--config_id 1
# Bump alert TTL (unauthenticated / keeper service)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <CALLER_IDENTITY> \
--network testnet \
-- bump_alert \
--config_id 1 \
--ttl 535680
# Update target contract for an alert
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- update_target_contract \
--caller <OWNER_ADDRESS> \
--config_id 1 \
--new_target <NEW_WATCHED_CONTRACT_ADDRESS>
# Deactivate all alerts owned by caller
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- deactivate_all_alerts \
--caller <OWNER_ADDRESS>
# Remove an alert (owner only)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <OWNER_IDENTITY> \
--network testnet \
-- remove_alert \
--caller <OWNER_ADDRESS> \
--config_id 1
# Remove an alert by admin (admin only)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- remove_alert_by_admin \
--admin <ADMIN_ADDRESS> \
--config_id 1Alert Queries & Inspection:
The alert-content reads (get_alert, get_alert_active, get_alert_owner,
get_alerts_for_contract, get_active_alerts_for_contract,
get_alerts_by_owner, get_contract_alerts_paginated and
get_alerts_by_owner_paginated) take a querier address as their first
argument. It is always required, but it is only checked once an admin has
enabled watcher-gating with set_watcher_registry; from then on querier
must be a registered watcher or the call fails with NotAWatcher.
# Retrieve a single alert config by ID
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alert \
--querier <QUERIER_ADDRESS> \
--config_id 1
# Read only the owner of an alert
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alert_owner \
--querier <QUERIER_ADDRESS> \
--config_id 1
# Check if an alert is active (lightweight read)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alert_active \
--querier <QUERIER_ADDRESS> \
--config_id 1
# Query all alerts for a contract
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alerts_for_contract \
--querier <QUERIER_ADDRESS> \
--target_contract <WATCHED_CONTRACT_ADDRESS>
# Query active alerts for a contract
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_active_alerts_for_contract \
--querier <QUERIER_ADDRESS> \
--target_contract <WATCHED_CONTRACT_ADDRESS>
# Query all alerts owned by an address
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alerts_by_owner \
--querier <QUERIER_ADDRESS> \
--owner <OWNER_ADDRESS>
# Query alerts for a contract with pagination
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_contract_alerts_paginated \
--querier <QUERIER_ADDRESS> \
--target_contract <WATCHED_CONTRACT_ADDRESS> \
--offset 0 \
--limit 10
# Query alerts by owner with pagination
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alerts_by_owner_paginated \
--querier <QUERIER_ADDRESS> \
--owner <OWNER_ADDRESS> \
--offset 0 \
--limit 10
# Query alerts modified since ledger timestamp (incremental sync)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alerts_modified_since \
--since 1700000000 \
--offset 0 \
--limit 50
# Query alerts modified since monotonic ledger sequence (unambiguous sync)
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alerts_modified_since_ledger \
--since_ledger 123450
# Get total cumulative alert count
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_alert_count
# Get active alert count for an owner
stellar contract invoke \
--id <ALERT_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_active_alert_count \
--owner <OWNER_ADDRESS>Admin & Governance:
# Initialize registry with initial admin (one-time)
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- initialize \
--admin <ADMIN_ADDRESS>
# Add a co-admin
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- add_admin \
--caller <ADMIN_ADDRESS> \
--new_admin <NEW_ADMIN_ADDRESS>
# Remove an admin
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- remove_admin \
--caller <ADMIN_ADDRESS> \
--target_admin <ADMIN_TO_REMOVE_ADDRESS>
# Transfer the admin role — step 1 of 2: an existing admin proposes the new admin.
# Nothing changes until the proposed address accepts.
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- propose_admin_transfer \
--admin <ADMIN_ADDRESS> \
--new_admin <NEW_ADMIN_ADDRESS>
# Transfer the admin role — step 2 of 2: the proposed admin accepts with their own key.
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <NEW_ADMIN_IDENTITY> \
--network testnet \
-- accept_admin_transfer \
--new_admin <NEW_ADMIN_ADDRESS>
# Cancel a pending admin transfer (any admin)
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- cancel_admin_transfer \
--admin <ADMIN_ADDRESS>
# Get primary admin address
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_admin
# Get all current admin addresses
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_adminsWatcher Management:
# Register an authorized watcher
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- register_watcher \
--admin <ADMIN_ADDRESS> \
--watcher <WATCHER_ADDRESS>
# Remove an authorized watcher
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- remove_watcher \
--admin <ADMIN_ADDRESS> \
--watcher <WATCHER_ADDRESS>
# Replace an existing watcher with a new address
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- replace_watcher \
--admin <ADMIN_ADDRESS> \
--old_watcher <OLD_WATCHER_ADDRESS> \
--new_watcher <NEW_WATCHER_ADDRESS>
# Clear all authorized watchers (bulk deauthorization)
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--source <ADMIN_IDENTITY> \
--network testnet \
-- clear_all_watchers \
--admin <ADMIN_ADDRESS>Watcher Queries:
# Check if an address is an authorized watcher
# (`is_authorized` is a deprecated alias kept only for backwards compatibility)
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- is_watcher_authorized \
--watcher <WATCHER_ADDRESS>
# Check authorization (deprecated alias, scheduled for removal in v0.3.0)
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- is_authorized \
--watcher <WATCHER_ADDRESS>
# Get all authorized watcher addresses
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_watchers
# Get total count of authorized watchers
stellar contract invoke \
--id <WATCHER_REGISTRY_CONTRACT_ID> \
--network testnet \
-- get_watcher_countimport {
Contract,
SorobanRpc,
TransactionBuilder,
Networks,
BASE_FEE,
nativeToScVal,
Address,
} from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contract = new Contract("<ALERT_REGISTRY_CONTRACT_ID>");
// Build a register_alert transaction
const account = await server.getAccount(ownerKeypair.publicKey());
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(
contract.call(
"register_alert",
new Address(ownerKeypair.publicKey()).toScVal(), // owner
new Address("<WATCHED_CONTRACT_ADDRESS>").toScVal(), // target_contract
nativeToScVal("My Alert", { type: "string" }), // label
nativeToScVal(Buffer.from("<sha256-hex-of-webhook-url>", "hex")), // webhook_hash (BytesN<32>)
nativeToScVal(["rule:transfer", "rule:mint"], { type: "array", element: { type: "string" } }), // rules
)
)
.setTimeout(30)
.build();
const preparedTx = await server.prepareTransaction(tx);
preparedTx.sign(ownerKeypair);
const result = await server.sendTransaction(preparedTx);
console.log("Transaction hash:", result.hash);import {
Contract,
SorobanRpc,
TransactionBuilder,
Networks,
BASE_FEE,
Address,
} from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contract = new Contract("<WATCHER_REGISTRY_CONTRACT_ID>");
// Initialize the registry (one-time, admin only)
const account = await server.getAccount(adminKeypair.publicKey());
const initTx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(
contract.call(
"initialize",
new Address(adminKeypair.publicKey()).toScVal(), // admin
)
)
.setTimeout(30)
.build();
const preparedInit = await server.prepareTransaction(initTx);
preparedInit.sign(adminKeypair);
await server.sendTransaction(preparedInit);
// Register a watcher node (admin only)
const account2 = await server.getAccount(adminKeypair.publicKey());
const registerTx = new TransactionBuilder(account2, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(
contract.call(
"register_watcher",
new Address(adminKeypair.publicKey()).toScVal(), // admin
new Address("<WATCHER_NODE_ADDRESS>").toScVal(), // watcher
)
)
.setTimeout(30)
.build();
const preparedRegister = await server.prepareTransaction(registerTx);
preparedRegister.sign(adminKeypair);
await server.sendTransaction(preparedRegister);
// Check if an address is an authorized watcher (read-only, no signature needed)
const account3 = await server.getAccount(adminKeypair.publicKey());
const checkTx = new TransactionBuilder(account3, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(
contract.call(
"is_watcher_authorized",
new Address("<WATCHER_NODE_ADDRESS>").toScVal(), // watcher
)
)
.setTimeout(30)
.build();
const result = await server.simulateTransaction(checkTx);
console.log("Is authorized:", result.result?.retval); // SCV_BOOLuse soroban_sdk::{Address, Env, String, Vec};
// In a cross-contract call context:
let alert_registry = AlertRegistryClient::new(&env, &alert_registry_id);
let config_id = alert_registry.register_alert(
&owner,
&target_contract,
&String::from_str(&env, "My Alert"),
&String::from_str(&env, "<webhook-hash>"),
&rules,
);Re-entrancy safety: Soroban executes contract calls atomically and prevents classic callback-based re-entrancy within the same transaction. The registry contracts only mutate local storage after
require_auth()succeeds, and they do not invoke external contracts during state updates.
All mutating functions require Stellar auth signatures:
Owner signs → register_alert / update_alert / remove_alert
Admin signs → register_watcher / remove_watcher / propose_admin_transfer (WatcherRegistry)
transfer_admin (AlertRegistry)
New admin signs → accept_admin_transfer (WatcherRegistry)
Stellar's require_auth() enforces this at the protocol level — no custom signature verification needed.
Both contracts emit Soroban events for every state change; the full catalogue of topics and payloads is in docs/events.md. Watchers can also poll Horizon's transaction endpoint:
GET https://horizon-testnet.stellar.org/accounts/<CONTRACT_ID>/transactions
TypeScript bindings for WatcherRegistry are published to npm and generated
automatically from the compiled WASM on every release using
stellar contract bindings typescript.
npm install @tx-wat/watcher-registry @stellar/stellar-sdkNote: the npm packages are published by CI on the first tagged release. Until then, generate the bindings locally with
make bindings-allfrom the repository root.
import { Client, networks } from "@tx-wat/watcher-registry";
const client = new Client({
contractId: networks.testnet.contractId,
networkPassphrase: networks.testnet.networkPassphrase,
rpcUrl: networks.testnet.rpcUrl,
});
const authorized = await client.is_watcher_authorized({ watcher: "GABC...XYZ" });
console.log(authorized.result); // true | falseSee bindings/watcher-registry/README.md for the full API reference and usage examples.
See DEPLOYMENTS.md.
- Alert Registry function reference
- Watcher Registry function reference
- Upgrade guide
- Compatibility Matrix
- Ecosystem submission guide
| Contract Tag / Version | Soroban SDK | npm Bindings | Core Engine | Web Dashboard | Status |
|---|---|---|---|---|---|
v0.1.0 |
22.0.0 | ^0.1.0 |
^0.1.0 |
^0.1.0 |
Stable |
v0.2.0 (main) |
22.0.0 | ^0.2.0 |
^0.2.0 |
^0.2.0 |
Active Development |
See docs/compatibility.md for the detailed compatibility matrix, policy, and release checklist.
See CONTRIBUTING.md.
- Core engine: https://github.com/Tx-wat/stellar-txwatch-core
- Web dashboard: https://github.com/Tx-wat/stellar-txwatch-web
MIT
- #197: Gated Reads Trust an Unauthenticated
querierParameter
- #198:
get_alerts_modified_sinceBypasses Watcher-Gating