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
These are the recommended alert rules for a production TrustLink deployment. Adjust thresholds to match your expected traffic volume.
| Alert name | Condition | Severity | Response time |
|---|---|---|---|
admin_transfer |
Any adm_xfer event |
Critical | Immediate page |
issuer_removed |
Any iss_rem event |
Critical | Immediate page |
revocation_spike |
revoked events > 10 in any 5-minute window |
High | 5 minutes |
bridge_anomaly |
bridged events from an unrecognised source_chain |
High | 5 minutes |
issuer_deregistered_with_active_attestations |
iss_rem where issuer has > 0 active attestations |
High | 15 minutes |
multisig_proposal_expired |
ms_prop with no ms_actv within 7 days |
Medium | Next business day |
expiration_hook_backlog |
exp_hook events > 50 with no corresponding renewal in 24 h |
Medium | 1 hour |
no_events_received |
Zero events from contract in > 30 minutes during business hours | Low | 1 hour |
Threshold tuning: Start with the defaults above, then adjust after observing
one week of baseline traffic. A revocation_spike threshold that fires daily is
too sensitive; one that never fires may be too loose.
What happened: The contract admin address was replaced via adm_xfer.
- Confirm the change was planned — check the deployment log and Slack/email for a scheduled admin rotation.
- If unplanned, treat as a security incident:
- Notify the security lead immediately.
- Freeze all issuer registrations and attestation creation at the application layer (block the UI / API gateway) while investigating.
- Identify the transaction on the explorer and determine which key signed it.
- Follow the incident response plan in
docs/security.md.
- If planned, verify the new admin address matches the expected key and update
DEPLOYMENT.md.
What happened: An issuer was removed from the registry via iss_rem.
- Confirm the removal was intentional — check the admin activity log.
- Identify all active attestations issued by the removed issuer:
stellar contract invoke --id "$CONTRACT_ID" --network mainnet \ -- get_issuer_attestations \ --issuer <ISSUER_ADDRESS> --start 0 --limit 100
- Decide whether existing attestations need to be transferred to a successor
issuer (
transfer_attestation) or left as-is (they remain valid until revoked or expired). - Notify any integrators that relied on attestations from this issuer.
See the dedicated runbook in section 8.
What happened: A bridged event arrived with a source_chain value not in
the expected set.
- Identify the bridge contract address from the event topic.
- Verify it is still in the bridge registry:
stellar contract invoke --id "$CONTRACT_ID" --network mainnet \ -- is_bridge --bridge <BRIDGE_ADDRESS>
- If the bridge is registered but the source chain is unexpected, contact the bridge operator to confirm the event is legitimate.
- If the bridge is not registered, the event should not have been possible — escalate to the security lead as a potential contract bug.
What happened: The event poller has not received any events for > 30 minutes during expected active hours.
- Check that the monitor process is running:
systemctl status trustlink-monitor # or: docker ps | grep trustlink-monitor - Verify RPC connectivity:
curl -s -X POST "$RPC_URL" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' | jq .result.sequence
- Confirm the contract is still active:
stellar contract invoke --id "$CONTRACT_ID" --network mainnet -- health_check - If the RPC node is unresponsive, switch to a backup RPC endpoint and restart the monitor.
A revocation spike (> 10 revocations in 5 minutes) can indicate a compromised issuer, a bulk compliance action, or a bug in an issuer's automation.
Pull the raw revocation events for the last hour:
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(revoked)"]]
}],
"pagination": { "limit": 200 }
}
}' | jq '.result.events | length'The second topic element of a revoked event is the issuer address. Extract
the unique issuers involved:
# Parse issuer addresses from revoked events (requires jq)
curl -s ... | jq '[.result.events[].topic[1]] | unique'- Planned bulk action: Contact the issuer. If they confirm a deliberate compliance sweep (e.g. sanctions list update), document it and close the alert.
- Issuer automation bug: Ask the issuer to pause their automation immediately. Assess whether any incorrectly revoked attestations need to be re-issued.
- Compromised issuer key: Treat as a security incident:
- Remove the issuer immediately:
stellar contract invoke --id "$CONTRACT_ID" --source "$ADMIN_SECRET" \ --network mainnet -- remove_issuer \ --admin "$ADMIN_PUBLIC" --issuer <COMPROMISED_ISSUER>
- Notify affected subjects — their attestations are now revoked and they will need to re-verify with a new issuer.
- Transfer any legitimate attestations to a successor issuer if applicable.
- File an incident report.
- Remove the issuer immediately:
- Record the event in the incident log with: timestamp, issuer, count of revocations, root cause, and resolution.
- Review whether the revocation threshold needs adjusting.
TrustLink enforces per-issuer and per-subject attestation limits
(max_attestations_per_issuer, max_attestations_per_subject). When a limit
is hit, create_attestation returns Error::LimitExceeded (code #10).
Monitor for LimitExceeded errors in your application layer — these will
surface as failed contract invocations, not as contract events. Log every
Error(Contract, #10) response from the RPC.
Proactively check high-volume issuers before they hit the limit:
# Count attestations for a specific issuer
stellar contract invoke --id "$CONTRACT_ID" --network mainnet \
-- get_issuer_attestation_count --issuer <ISSUER_ADDRESS>
# Read current limits
stellar contract invoke --id "$CONTRACT_ID" --network mainnet -- get_limitsAlert when any issuer reaches 80% of max_attestations_per_issuer.
Check global stats for overall growth trends:
stellar contract invoke --id "$CONTRACT_ID" --network mainnet -- get_global_statsOption A — Raise the limit (admin action)
If the limit was set conservatively and the issuer's volume is legitimate:
stellar contract invoke --id "$CONTRACT_ID" --source "$ADMIN_SECRET" \
--network mainnet -- set_limits \
--admin "$ADMIN_PUBLIC" \
--max_attestations_per_issuer 20000 \
--max_attestations_per_subject 200Document the change and the reason in the operations log.
Option B — Revoke stale attestations
If the issuer has a large backlog of expired or superseded attestations, revoke them in batch to free up headroom:
stellar contract invoke --id "$CONTRACT_ID" --source "$ISSUER_SECRET" \
--network mainnet -- revoke_attestations_batch \
--issuer <ISSUER_ADDRESS> \
--attestation_ids '["id1","id2","id3"]'Option C — Distribute across multiple issuers
If a single issuer is handling disproportionate volume, register additional issuer addresses and distribute new attestation creation across them.
- Set a monitoring alert at 80% of each limit.
- Review
get_global_statsweekly during the first month after mainnet launch to establish a baseline growth rate. - Include limit headroom in capacity planning before onboarding high-volume issuers.