Track unbonding completions
When an unbonding period completes, the protocol emits an UnbondingCompleted event through the StableSystem precompile (0x0000000000000000000000000000000000009999) via a system transaction. This lets dApps notify users and update balances in real time without running custom indexers or polling REST endpoints.
Prerequisites
- Understanding of System transactions.
- Familiarity with Staking, specifically
undelegateand the unbonding process. - Experience with contract event subscription and filtering using a standard web3 library (e.g. ethers.js v6).
Overview
- Set up the contract instance: create a contract instance for the StableSystem precompile.
- Handle events in your application: subscribe to real-time events or query historical data depending on your application logic.
- Handle connection issues: implement reconnection logic for persistent WebSocket subscriptions.
Step 1: Set up the contract instance
Create a contract instance for the StableSystem precompile using the UnbondingCompleted event ABI.
// config.ts
import { ethers } from "ethers";
export const STABLE_SYSTEM_ADDRESS =
"0x0000000000000000000000000000000000009999";
export const STABLE_SYSTEM_ABI = [
"event UnbondingCompleted(address indexed delegator, address indexed validator, uint64 indexed originBlockHeight, uint256 amount)",
];
export const provider = new ethers.JsonRpcProvider("https://rpc.testnet.stable.xyz");
export const stableSystem = new ethers.Contract(
STABLE_SYSTEM_ADDRESS,
STABLE_SYSTEM_ABI,
provider
);No output. The StableSystem contract instance is ready to query or subscribe.Step 2: Handle events in your application
Subscribe to real-time events, query historical data, or both depending on your application logic.
Real-time subscription
Subscribe to UnbondingCompleted events for real-time notifications when any unbonding completes. Useful for triggering balance updates, sending notifications, or refreshing dashboard statistics.
// subscribeBasic.ts
import { ethers } from "ethers";
import { stableSystem } from "./config";
stableSystem.on("UnbondingCompleted", (delegator, validator, originBlockHeight, amount, event) => {
console.log("Unbonding completed:");
console.log(" Delegator:", delegator);
console.log(" Validator:", validator);
console.log(" Origin block:", originBlockHeight.toString());
console.log(" Amount:", ethers.formatEther(amount), "tokens");
console.log(" Block:", event.log.blockNumber);
console.log(" Tx Hash:", event.log.transactionHash);
});Unbonding completed:
Delegator: 0xabcd...
Validator: 0x1234...
Origin block: 36975999
Amount: 100.0 tokens
Block: 36976000
Tx Hash: 0x12ab...Filter by user
To only receive events for a particular delegator address, use the indexed event parameters to create a filter.
// subscribeByUser.ts
import { ethers } from "ethers";
import { stableSystem } from "./config";
const userAddress = "0xabcd...";
const filter = stableSystem.filters.UnbondingCompleted(userAddress);
stableSystem.on(filter, (delegator, validator, originBlockHeight, amount) => {
console.log("User unbonding completed:", {
delegator,
validator,
originBlockHeight,
amount: ethers.formatEther(amount),
});
});User unbonding completed: {
delegator: "0xabcd...",
validator: "0x1234...",
originBlockHeight: 36975999n,
amount: "100.0"
}Filter by validator
// subscribeByValidator.ts
import { stableSystem } from "./config";
const validatorAddress = "0x1234...";
const validatorFilter = stableSystem.filters.UnbondingCompleted(
null,
validatorAddress
);
stableSystem.on(validatorFilter, (delegator, validator, originBlockHeight, amount) => {
console.log("Validator unbonding completed:", {
delegator,
validator,
originBlockHeight,
amount,
});
});Validator unbonding completed: {
delegator: "0xabcd...",
validator: "0x1234...",
originBlockHeight: 36975999n,
amount: 100000000000000000000n
}Historical query
If your dApp needs to show a history of past unbonding completions, query historical events using event filters with block ranges.
// queryHistory.ts
import { ethers } from "ethers";
import { provider, stableSystem } from "./config";
async function getUnbondingHistory(
userAddress: string,
fromBlock: number,
toBlock: number
) {
const filter = stableSystem.filters.UnbondingCompleted(userAddress);
const events = await stableSystem.queryFilter(filter, fromBlock, toBlock);
return events.map((event) => ({
delegator: event.args.delegator,
validator: event.args.validator,
originBlockHeight: event.args.originBlockHeight,
amount: ethers.formatEther(event.args.amount),
blockNumber: event.blockNumber,
txHash: event.transactionHash,
}));
}
const currentBlock = await provider.getBlockNumber();
const history = await getUnbondingHistory(
"0xabcd...",
currentBlock - 1000,
currentBlock
);
console.log(history);[
{
delegator: "0xabcd...",
validator: "0x1234...",
originBlockHeight: 36975999n,
amount: "100.0",
blockNumber: 36976000,
txHash: "0x12ab..."
}
]Step 3: Handle connection issues
Event subscriptions rely on persistent WebSocket connections. The example below reconnects after errors and clean closures. Before retrying, it removes old listeners and closes the previous provider. It uses the built-in WebSocket in Node.js 22 and modern browsers.
// subscribeWithReconnection.ts
import { ethers } from "ethers";
import { STABLE_SYSTEM_ADDRESS, STABLE_SYSTEM_ABI } from "./config";
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
const WS_URL = "wss://rpc.testnet.stable.xyz";
type Connection = {
socket: WebSocket;
provider: ethers.WebSocketProvider;
stableSystem: ethers.Contract;
};
let activeConnection: Connection | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
function handleUnbonding(
delegator: string,
validator: string,
originBlockHeight: bigint,
amount: bigint
) {
console.log("Unbonding completed:", {
delegator,
validator,
originBlockHeight,
amount,
});
}
async function closeConnection(connection: Connection) {
if (activeConnection === connection) activeConnection = undefined;
try {
await connection.stableSystem.removeAllListeners();
} finally {
await connection.provider.destroy();
}
}
async function scheduleReconnect(connection: Connection, reason: string) {
if (stopped || activeConnection !== connection || reconnectTimer) return;
console.warn(`WebSocket ${reason}; reconnecting.`);
await closeConnection(connection);
if (stopped) return;
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error("Maximum reconnect attempts reached.");
return;
}
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = undefined;
setupEventListener();
}, delay);
}
function setupEventListener() {
const socket = new WebSocket(WS_URL);
const provider = new ethers.WebSocketProvider(socket);
const stableSystem = new ethers.Contract(
STABLE_SYSTEM_ADDRESS,
STABLE_SYSTEM_ABI,
provider
);
const connection = { socket, provider, stableSystem };
activeConnection = connection;
socket.addEventListener("open", () => {
if (activeConnection === connection) {
reconnectAttempts = 0;
console.log("Connected to Stable Testnet WebSocket.");
}
}, { once: true });
socket.addEventListener("close", (event) => {
void scheduleReconnect(connection, `closed with code ${event.code}`);
}, { once: true });
socket.addEventListener("error", () => {
void scheduleReconnect(connection, "reported an error");
}, { once: true });
void stableSystem.on("UnbondingCompleted", handleUnbonding).catch((error) => {
console.error("Subscription failed:", error);
void scheduleReconnect(connection, "subscription failed");
});
}
setupEventListener();
export async function stopEventListener() {
stopped = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (activeConnection) await closeConnection(activeConnection);
}Connected to Stable Testnet WebSocket.Where to go next
- System transactions concept: Understand how protocol-level events reach the EVM.
- Staking module concept: Review the delegation and unbonding flow.
- Staking precompile reference: Look up the methods that trigger the events tracked here.

