This guide explains how to stream real-time events from the TrustLink Soroban smart contract using Stellar Horizon, build a webhook handler, and set up alerting.
- A running Stellar node or access to a Horizon endpoint
- Local:
http://localhost:8000(see CONTRIBUTING.md for local network setup) - Testnet:
https://horizon-testnet.stellar.org - Mainnet:
https://horizon.stellar.org
- Local:
- The deployed TrustLink contract ID (stored in
.local.contract-idaftermake local-deploy) - Node.js 18+ (for the example webhook handler)
Stellar Horizon exposes a Server-Sent Events (SSE) endpoint that streams contract events in real time.
The primary method for Soroban contract events is the JSON-RPC getEvents call against the Soroban RPC endpoint:
curl -s -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "getEvents",
"params": {
"startLedger": "'"$START_LEDGER"'",
"filters": [
{
"type": "contract",
"contractIds": ["'"$CONTRACT_ID"'"],
"topics": [["*"]]
}
],
"pagination": { "limit": 100 }
}
}'TrustLink events use a topic symbol as their first topic element. You can narrow the stream to specific event types:
| Filter goal | topics value |
|---|---|
| All TrustLink events | [["*"]] |
| Attestation created | [["SymbolVal(created)"]] |
| Attestation revoked | [["SymbolVal(revoked)"]] |
| Issuer registered | [["SymbolVal(iss_reg)"]] |
| Admin transfers | [["SymbolVal(adm_xfer)"]] |
Example — stream only created and revoked events:
curl -s -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "getEvents",
"params": {
"startLedger": "'"$START_LEDGER"'",
"filters": [
{
"type": "contract",
"contractIds": ["'"$CONTRACT_ID"'"],
"topics": [["SymbolVal(created)"]]
},
{
"type": "contract",
"contractIds": ["'"$CONTRACT_ID"'"],
"topics": [["SymbolVal(revoked)"]]
}
],
"pagination": { "limit": 100 }
}
}'For Horizon-based streaming (useful for tracking payments, e.g. fee transfers), open a persistent SSE connection:
GET /accounts/{fee_collector}/effects?cursor=now&order=asc
Accept: text/event-stream
Emitted when a registered issuer creates a new attestation.
| Field | Type | Description |
|---|---|---|
id |
String |
Deterministic attestation ID |
issuer |
Address |
Issuer who created the attestation |
claim_type |
String |
Claim identifier (e.g. KYC_PASSED) |
timestamp |
u64 |
Ledger timestamp at creation |
metadata |
Option<String> |
Optional issuer-supplied metadata |
Topic: ["created", <subject_address>]
Emitted when the admin imports a historical attestation.
| Field | Type | Description |
|---|---|---|
id |
String |
Attestation ID |
issuer |
Address |
Original issuer |
claim_type |
String |
Claim identifier |
timestamp |
u64 |
Original historical timestamp |
expiration |
Option<u64> |
Optional expiration time |
Topic: ["imported", <subject_address>]
Emitted when a bridge contract creates a cross-chain attestation.
| Field | Type | Description |
|---|---|---|
id |
String |
Attestation ID |
issuer |
Address |
Bridge contract address |
claim_type |
String |
Claim identifier |
source_chain |
String |
Origin chain (e.g. ethereum) |
source_tx |
String |
Source transaction reference |
Topic: ["bridged", <subject_address>]
Emitted when an issuer revokes an attestation.
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
ID of revoked attestation |
reason |
Option<String> |
Optional revocation reason |
Topic: ["revoked", <issuer_address>]
Emitted when an issuer renews (extends) an attestation.
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
Attestation ID |
new_expiration |
Option<u64> |
Updated expiration timestamp |
Topic: ["renewed", <issuer_address>]
Emitted when attestation metadata or expiration is updated.
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
Attestation ID |
new_expiration |
Option<u64> |
Updated expiration timestamp |
Topic: ["updated", <issuer_address>]
Emitted when an attestation is detected as expired during a query.
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
Attestation ID |
Topic: ["expired", <subject_address>]
Emitted when another issuer endorses an existing attestation.
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
Attestation ID |
timestamp |
u64 |
Endorsement timestamp |
Topic: ["endorsed", <endorser_address>]
| Field | Type | Description |
|---|---|---|
admin |
Address |
Admin who registered the issuer |
timestamp |
u64 |
Registration timestamp |
Topic: ["iss_reg", <issuer_address>]
| Field | Type | Description |
|---|---|---|
tier |
IssuerTier |
New tier (Basic / Verified / Premium) |
Topic: ["iss_tier", <issuer_address>]
| Field | Type | Description |
|---|---|---|
admin |
Address |
Admin who removed the issuer |
timestamp |
u64 |
Removal timestamp |
Topic: ["iss_rem", <issuer_address>]
| Field | Type | Description |
|---|---|---|
proposal_id |
String |
Proposal identifier |
proposer |
Address |
Address that created the proposal |
threshold |
u32 |
Required signature count |
Topic: ["ms_prop", <subject_address>]
| Field | Type | Description |
|---|---|---|
proposal_id |
String |
Proposal identifier |
signatures_so_far |
u32 |
Current signature count |
threshold |
u32 |
Required signature count |
Topic: ["ms_sign", <signer_address>]
| Field | Type | Description |
|---|---|---|
proposal_id |
String |
Proposal identifier |
attestation_id |
String |
Resulting attestation ID |
Topic: ["ms_actv"]
| Field | Type | Description |
|---|---|---|
admin |
Address |
Initial admin address |
timestamp |
u64 |
Initialization timestamp |
Topic: ["adm_init"]
| Field | Type | Description |
|---|---|---|
old_admin |
Address |
Previous admin |
new_admin |
Address |
New admin |
Topic: ["adm_xfer"]
| Field | Type | Description |
|---|---|---|
description |
String |
Claim type description |
Topic: ["clmtype", <claim_type_string>]
| Field | Type | Description |
|---|---|---|
attestation_id |
String |
Attestation nearing expiration |
expiration |
u64 |
Expiration timestamp |
Topic: ["exp_hook", <subject_address>]
The following service polls Soroban RPC for TrustLink events and forwards them to a configurable webhook URL.
mkdir trustlink-monitor && cd trustlink-monitor
npm init -y
npm install node-fetch@3import fetch from "node-fetch";
// ---------------------------------------------------------------------------
// Configuration — override with environment variables
// ---------------------------------------------------------------------------
const RPC_URL = process.env.RPC_URL || "http://localhost:8000/soroban/rpc";
const CONTRACT_ID = process.env.CONTRACT_ID;
const WEBHOOK_URL = process.env.WEBHOOK_URL; // e.g. https://hooks.slack.com/...
const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || "5000", 10);
if (!CONTRACT_ID) {
console.error("CONTRACT_ID env var is required");
process.exit(1);
}
// ---------------------------------------------------------------------------
// State — track the pagination cursor so we never re-process events
// ---------------------------------------------------------------------------
let cursor = undefined;
let latestLedger = undefined;
/** Fetch the latest ledger sequence from Soroban RPC. */
async function fetchLatestLedger() {
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getLatestLedger",
}),
});
const json = await res.json();
return json.result.sequence;
}
/** Poll Soroban RPC getEvents for new TrustLink contract events. */
async function pollEvents() {
// On first poll, start from the current ledger.
if (!latestLedger) {
latestLedger = await fetchLatestLedger();
}
const params = {
filters: [
{
type: "contract",
contractIds: [CONTRACT_ID],
topics: [["*"]],
},
],
pagination: { limit: 100 },
};
if (cursor) {
params.pagination.cursor = cursor;
} else {
params.startLedger = String(latestLedger);
}
const res = await fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getEvents",
params,
}),
});
const json = await res.json();
if (json.error) {
console.error("RPC error:", json.error);
return;
}
const events = json.result?.events || [];
if (events.length > 0) {
cursor = events[events.length - 1].pagingToken;
}
latestLedger = json.result?.latestLedger ?? latestLedger;
for (const event of events) {
await handleEvent(event);
}
}
// ---------------------------------------------------------------------------
// Event classification helpers
// ---------------------------------------------------------------------------
const HIGH_SEVERITY = new Set(["revoked", "adm_xfer", "iss_rem"]);
const MEDIUM_SEVERITY = new Set([
"created",
"bridged",
"imported",
"iss_reg",
"ms_actv",
]);
function classifyEvent(topicSymbol) {
if (HIGH_SEVERITY.has(topicSymbol)) return "high";
if (MEDIUM_SEVERITY.has(topicSymbol)) return "medium";
return "low";
}
/** Process a single contract event — log it and forward to webhook. */
async function handleEvent(event) {
const topicSymbol = event.topic?.[0] ?? "unknown";
const severity = classifyEvent(topicSymbol);
const payload = {
contractId: CONTRACT_ID,
ledger: event.ledger,
timestamp: new Date().toISOString(),
topic: event.topic,
value: event.value,
severity,
};
console.log(
`[${severity.toUpperCase()}] ${topicSymbol}`,
JSON.stringify(payload, null, 2),
);
if (WEBHOOK_URL) {
try {
await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} catch (err) {
console.error("Webhook delivery failed:", err.message);
}
}
}
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
console.log(`Monitoring TrustLink contract ${CONTRACT_ID}`);
console.log(`RPC: ${RPC_URL} | Poll interval: ${POLL_INTERVAL_MS}ms`);
if (WEBHOOK_URL) console.log(`Webhook: ${WEBHOOK_URL}`);
async function loop() {
while (true) {
try {
await pollEvents();
} catch (err) {
console.error("Poll error:", err.message);
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
}
loop();# Local network
CONTRACT_ID=$(cat .local.contract-id) node monitor.mjs
# With webhook forwarding
CONTRACT_ID=$(cat .local.contract-id) \
WEBHOOK_URL=https://hooks.slack.com/services/T00/B00/xxx \
node monitor.mjs
# Testnet
RPC_URL=https://soroban-testnet.stellar.org \
CONTRACT_ID=CABC...XYZ \
node monitor.mjs| Severity | Events | Recommended Action |
|---|---|---|
| Critical | adm_xfer, iss_rem |
Page on-call immediately — admin control changed or issuer revoked |
| High | revoked |
Alert within minutes — an attestation trust decision was reversed |
| Medium | created, imported, bridged, iss_reg, ms_actv |
Log and notify via Slack/email within the hour |
| Low | renewed, updated, endorsed, clmtype, ms_prop, ms_sign, exp_hook |
Aggregate in dashboards, review daily |
-
Revocation spikes — A sudden increase in
revokedevents may indicate a compromised issuer or policy change. Alert if the count exceeds a rolling threshold (e.g. >10 revocations in 5 minutes). -
Admin transfers (
adm_xfer) — Should be extremely rare. Any occurrence warrants immediate verification. -
Issuer removals (
iss_rem) — Verify that the removal was intentional and that affected attestations are handled. -
Bridge activity (
bridged) — Monitor for unexpected source chains or abnormal volume, which could indicate a bridge compromise. -
Expiration hooks (
exp_hook) — Track these to ensure callback contracts are responding. A backlog of unacknowledged hooks suggests the callback endpoint is down. -
Fee collection — Cross-reference
createdevents with fee token transfer operations on Horizon to confirm fees are reaching the collector.
| Platform | Integration Method |
|---|---|
| Slack | POST event payload to an Incoming Webhook URL |
| PagerDuty | POST critical events to the Events API v2 |
| Grafana | Push metrics to Prometheus via a push-gateway; build dashboards on event counts |
| Datadog | Send events via the Datadog API or DogStatsD |
| Use the webhook handler to relay critical events through an SMTP service |
{
"text": ":rotating_light: *TrustLink Alert — HIGH*",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Event*: `revoked`\n*Attestation*: `abc123...`\n*Issuer*: `GABC...XYZ`\n*Reason*: Compliance review\n*Ledger*: 12345678"
}
}
]
}- Attestations created per hour — baseline for normal activity
- Revocations per hour — spike detection
- Bridge attestations per day per source chain — detect anomalies
- Mean time between
exp_hookand renewal — issuer responsiveness - Active issuers — track
iss_regminusiss_remover time - Multi-sig proposals pending —
ms_propminusms_actvbacklog
- Deploy the monitor as a long-running service (systemd, Docker, or Kubernetes)
- Set
POLL_INTERVAL_MSbased on ledger close time (~5–6 s on mainnet) - Persist the pagination
cursorto disk or a database so restarts do not miss events - Authenticate webhook endpoints (use HMAC signatures or bearer tokens)
- Rate-limit outbound webhook calls to avoid flooding downstream services
- Set up a dead-letter queue for failed webhook deliveries
- Test alerting end-to-end on the local network before enabling on testnet/mainnet