Standalone TypeScript library for detecting all on-chain contract interactions on XDC and EVM-compatible chains.
Combines: events + direct calls + internal calls + transaction tracing into a single unified detection engine.
Framework-agnostic. Zero runtime dependencies on any specific backend. Just detection — you decide what to do with the results.
- XDC Interaction Detector
- Real-time event monitoring — WebSocket push + HTTP polling with automatic deduplication
- Historical block scanning — Query any block range with auto-chunked fetching
- Transaction tracing — Full call trees, state diffs, and balance changes via
debug_traceTransaction - Explorer API integration — Direct & internal transaction collection via XDCScan / Etherscan-compatible APIs
- XDC-first, EVM-compatible — Handles XDC's non-standard ABI encoding,
xdcaddress prefix, and 100-block range limit - Pluggable checkpoints — Memory, file, or custom backends for restart persistence
- Zero framework dependency — Works in any Node.js environment: Express, Fastify, serverless functions, CLI scripts, background workers
npm install @xdc.org/interaction-detectorDependencies: ethers v6, ws — all installed automatically. HTTP calls use Node.js native fetch (requires Node ≥ 18).
import { ContractWatcher } from '@xdc.org/interaction-detector';
const watcher = new ContractWatcher({
// RPC endpoints
rpcUrl: 'https://rpc.xdc.network',
wsUrl: 'wss://rpc.xdc.network/ws', // optional — enables real-time push
chainId: 50, // XDC Mainnet
// Contracts to monitor
contracts: [
{
address: '0x0000000000000000000000000000000000000088',
abi: ['event Vote(address indexed _voter, address indexed _candidate, uint256 _cap)'],
name: 'XDCValidator',
},
],
// Explorer API — enables direct & internal call detection
explorer: {
apiUrl: 'https://api.etherscan.io/v2/api',
apiKey: 'YOUR_ETHERSCAN_API_KEY', // optional — higher rate limits
chainId: 50, // XDC Mainnet
rateLimitPerSec: 5,
},
// Checkpoint — survive restarts
checkpoint: { backend: 'file', path: './checkpoints' },
});
// Decoded contract events (Transfer, Swap, Vote, etc.)
watcher.on('event', event => {
console.log(`${event.name} from ${event.contractName} at block ${event.blockNumber}`);
console.log(' Args:', event.args);
});
// ALL interactions (events + direct + internal + delegate + static calls)
watcher.on('interaction', interaction => {
console.log(`[${interaction.type}] tx ${interaction.txHash} (source: ${interaction.source})`);
});
await watcher.start();Watches one or more contract addresses for all interactions in real-time using three complementary detection paths:
| Path | Method | What it catches | Latency |
|---|---|---|---|
| WebSocket | eth_subscribe("logs") |
Contract events | ~2 seconds |
| HTTP Polling | eth_getLogs |
Contract events (reliable fallback) | 15–30 seconds |
| Explorer API | txlist + txlistinternal |
Direct calls + internal calls | 30–60 seconds |
Creating a watcher:
import { ContractWatcher } from '@xdc.org/interaction-detector';
const watcher = new ContractWatcher({
// ─── Required ───────────────────────────────────────────
rpcUrl: 'https://rpc.xdc.network',
contracts: [
{
address: '0xContractAddress', // 0x or xdc prefix both work
abi: [...], // optional — enables event decoding
name: 'MyDeFiPool', // optional — human label
},
// Watch multiple contracts simultaneously
{ address: '0xAnotherContract', name: 'Governance' },
],
// ─── Optional: WebSocket (real-time push) ───────────────
wsUrl: 'wss://rpc.xdc.network/ws',
chainId: 50, // default: 50 (XDC Mainnet)
// ─── Optional: Explorer API (direct + internal calls) ───
explorer: {
apiUrl: 'https://api.etherscan.io/v2/api', // Etherscan v2 supports XDC
apiKey: 'YOUR_ETHERSCAN_API_KEY', // optional — get higher rate limits
chainId: 50, // XDC Mainnet (required for Etherscan v2)
rateLimitPerSec: 5, // default: 5 req/s
pollIntervalMs: 60_000, // how often to check explorer (default: 60s)
},
// ─── Optional: Polling tuning ───────────────────────────
polling: {
intervalMs: 15_000, // default: 30s
maxBlockRange: 100, // XDC limit — don't change unless using another chain
concurrency: 3, // parallel chunk fetches
},
// ─── Optional: WebSocket tuning ─────────────────────────
ws: {
enabled: true, // default: true
reconnectDelayBaseMs: 5_000,
reconnectDelayMaxMs: 30_000,
maxReconnectAttempts: 20,
heartbeatIntervalMs: 60_000,
heartbeatTimeoutMs: 10_000,
},
// ─── Optional: Checkpoint persistence ───────────────────
checkpoint: {
backend: 'file', // 'memory' | 'file' | 'custom'
path: './checkpoints',
},
// ─── Optional: Fallback RPCs ────────────────────────────
fallbackRpcUrls: ['https://rpc1.xinfin.network'],
// ─── Optional: Log level ────────────────────────────────
logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
});Listening for events:
// ── Decoded contract events ──────────────────────────────────
watcher.on('event', event => {
console.log(event.contract); // '0xcontractaddress'
console.log(event.contractName); // 'MyDeFiPool'
console.log(event.name); // 'Swap'
console.log(event.args); // { sender: '0x...', amount0In: 1000n, ... }
console.log(event.blockNumber); // 75123456
console.log(event.txHash); // '0xabc...'
console.log(event.logIndex); // 3
console.log(event.timestamp); // 1711792800 (Unix seconds)
console.log(event.signature); // 'Swap(address,uint256,uint256,address)'
console.log(event.raw); // { topics: [...], data: '0x...' }
});
// ── ALL interactions (unified type) ──────────────────────────
watcher.on('interaction', interaction => {
console.log(interaction.type); // 'event' | 'direct_call' | 'internal_call' | 'delegate_call' | 'static_call'
console.log(interaction.source); // 'rpc_logs' | 'ws_logs' | 'explorer_txlist' | 'explorer_internal'
console.log(interaction.txHash);
console.log(interaction.from); // sender (when available)
console.log(interaction.to); // contract address
console.log(interaction.methodId); // '0xa9059cbb' (for direct/internal calls)
console.log(interaction.methodName); // 'transfer(address,uint256)' (if ABI found)
console.log(interaction.value); // native value in wei
console.log(interaction.isError); // true if call reverted
});
// ── Raw logs (for contracts without ABI) ─────────────────────
watcher.on('log', log => {
console.log(log.topics[0]); // event signature hash
console.log(log.data); // raw encoded data
});
// ── Lifecycle events ─────────────────────────────────────────
watcher.on('connected', info => {
console.log(`Connected via ${info.type}: ${info.url}`);
});
watcher.on('disconnected', info => {
console.log(`Disconnected: ${info.reason}`);
});
watcher.on('checkpoint', blockNumber => {
console.log(`Checkpoint saved at block ${blockNumber}`);
});
watcher.on('error', err => {
console.error(`Error: ${err.message}`);
});Starting and stopping:
// Start monitoring
await watcher.start();
// ... later, graceful shutdown
await watcher.stop();Deduplication: Events detected by both WebSocket and polling are automatically deduplicated using txHash + logIndex composite keys. This prevents double-counting when both detection paths catch the same event.
Scans a block range for all events and interactions involving a contract. Automatically handles XDC's 100-block eth_getLogs limit with chunked parallel fetching.
Scanning for events:
import { BlockScanner } from '@xdc.org/interaction-detector';
const scanner = new BlockScanner({
rpcUrl: 'https://rpc.xdc.network',
maxBlockRange: 100, // default: 100 (XDC limit)
concurrency: 3, // parallel chunk fetches
logLevel: 'info',
// Optional: explorer for direct + internal tx enrichment
explorer: {
apiUrl: 'https://api.etherscan.io/v2/api',
apiKey: 'YOUR_ETHERSCAN_API_KEY',
chainId: 50,
},
});
// ── Scan for decoded events ──────────────────────────────────
const events = await scanner.getEvents({
address: '0x0000000000000000000000000000000000000088',
abi: [
'event Vote(address indexed _voter, address indexed _candidate, uint256 _cap)',
'event Unvote(address indexed _voter, address indexed _candidate, uint256 _cap)',
],
fromBlock: 75_000_000,
toBlock: 75_100_000,
// Optional: filter by specific event name
eventFilter: 'Vote',
// Optional: progress callback for large scans
onProgress: (processed, total) => {
console.log(`${Math.round((processed / total) * 100)}% complete`);
},
});
// events is DecodedEvent[]
for (const event of events) {
console.log(`[block ${event.blockNumber}] ${event.name}`, event.args);
}Scanning for all interactions (events + explorer txlist + txlistinternal):
const interactions = await scanner.getInteractions({
address: '0x0000000000000000000000000000000000000088',
abi: [...],
fromBlock: 75_000_000,
toBlock: 75_100_000,
});
// interactions is ContractInteraction[]
for (const i of interactions) {
console.log(`[${i.type}] block ${i.blockNumber} tx ${i.txHash}`);
if (i.type === 'event') console.log(' Event:', i.event?.name, i.event?.args);
if (i.type === 'direct_call') console.log(' Method:', i.methodName);
if (i.type === 'internal_call') console.log(' From:', i.from);
}Traces a single transaction to extract the full execution story: call tree, state diffs, balance changes, and all events.
Note: Requires an RPC endpoint with the
debugnamespace enabled. For historical transactions, an archive node is required. For recent/current blocks, a regular full node works.
Full trace:
import { TransactionTracer } from '@xdc.org/interaction-detector';
const tracer = new TransactionTracer({
rpcUrl: 'https://archive-rpc.xdc.network', // must support debug_traceTransaction
timeoutMs: 120_000, // tracing can be slow on complex txs
logLevel: 'info',
});
// Register ABIs for method name decoding in call trees
tracer.registerABI(
'0xContractAddress',
['function swap(uint256,uint256,address,bytes)', 'function transfer(address,uint256)'],
'MyDEX',
);
const result = await tracer.trace('0xTransactionHash');Using the trace result:
// ── Call Tree ────────────────────────────────────────────────
// Nested structure showing every CALL, STATICCALL, DELEGATECALL
console.log(result.callTree.type); // 'CALL'
console.log(result.callTree.from); // '0xSender'
console.log(result.callTree.to); // '0xContract'
console.log(result.callTree.method); // 'swap(uint256,uint256,address,bytes)' (if ABI registered)
console.log(result.callTree.value); // '0x0' (native value)
console.log(result.callTree.calls); // CallTreeNode[] — nested sub-calls
// Example nested call:
// CALL 0xRouter → swap(...)
// CALL 0xPool → mint(...)
// STATICCALL 0xOracle → getPrice()
// CALL 0xTokenA → transfer(...)
// ── State Diffs ──────────────────────────────────────────────
// Storage slot changes (before/after for each modified slot)
for (const diff of result.stateDiffs) {
console.log(`${diff.contract} slot ${diff.slot}`);
console.log(` before: ${diff.before}`);
console.log(` after: ${diff.after}`);
}
// ── Balance Changes ──────────────────────────────────────────
// Native token balance changes per address
for (const change of result.balanceChanges) {
console.log(`${change.address}: ${change.delta} wei (${change.token})`);
}
// ── Events ───────────────────────────────────────────────────
// All events emitted during execution (decoded if ABI registered)
for (const event of result.events) {
console.log(`${event.name} from ${event.contract}`, event.args);
}
// ── Metadata ─────────────────────────────────────────────────
console.log(result.gasUsed); // 234567
console.log(result.involvedContracts); // ['0x...', '0x...', '0x...']
console.log(result.blockNumber); // 75123456Partial traces (lighter weight):
// Just the call tree (no state diffs)
const callTree = await tracer.traceCallTree('0xTxHash');
// Just the state diffs + balance changes (no call tree)
const { stateDiffs, balanceChanges } = await tracer.traceStateDiffs('0xTxHash');Call tree utilities:
import { flattenCallTree, findCallsTo, extractInvolvedContracts } from '@xdc.org/interaction-detector';
// Flatten the nested tree into a linear array
const allCalls = flattenCallTree(result.callTree);
console.log(`Total calls in execution: ${allCalls.length}`);
// Find all calls targeting a specific contract
const tokenCalls = findCallsTo(result.callTree, '0xTokenAddress');
// Get all unique addresses involved
const addresses = extractInvolvedContracts(result.callTree);The LogPoller is used internally by ContractWatcher and BlockScanner, but is also exported for standalone use when you need direct control over eth_getLogs fetching with automatic chunking and concurrency.
import { LogPoller, RpcClient } from '@xdc.org/interaction-detector';
import type { FetchLogsResult } from '@xdc.org/interaction-detector';
const rpc = new RpcClient('https://rpc.xdc.network');
const poller = new LogPoller(
rpc,
['0x0000000000000000000000000000000000000088'], // addresses to monitor
{ maxBlockRange: 100, concurrency: 3 }, // optional PollingConfig
'info', // optional log level
);
// ── Basic usage — returns RawLog[] ──────────────────────────
const logs = await poller.fetchLogs(75_000_000, 75_001_000);
console.log(`Fetched ${logs.length} logs`);
// ── With failure tracking — returns FetchLogsResult ─────────
// Tracks which block ranges failed, so you can retry or alert
const result: FetchLogsResult = await poller.fetchLogs(75_000_000, 75_001_000, { trackFailures: true });
console.log(`Fetched ${result.logs.length} logs`);
if (result.failedRanges.length > 0) {
console.warn('Some ranges failed:');
for (const range of result.failedRanges) {
console.warn(` blocks ${range.from}–${range.to}: ${range.error}`);
}
}FetchLogsResult type:
interface FetchLogsResult {
/** Successfully fetched logs */
logs: RawLog[];
/** Block ranges that failed to fetch (data may be missing for these ranges) */
failedRanges: Array<{ from: number; to: number; error: string }>;
}Note: The default
fetchLogs(from, to)call (without{ trackFailures: true }) returnsRawLog[]directly for backward compatibility. Failed chunks return empty arrays silently — use thetrackFailuresoption when you need visibility into partial failures.
Standalone Etherscan-compatible API client. Works with XDCScan, Etherscan v2, BSCScan, PolygonScan, and any Etherscan-compatible explorer.
import { ExplorerClient } from '@xdc.org/interaction-detector';
const explorer = new ExplorerClient({
apiUrl: 'https://api.etherscan.io/v2/api', // Etherscan v2 — supports XDC + 80 chains
// apiUrl: 'https://xdc.blocksscan.io/api', // XDCScan (alternative)
// apiUrl: 'https://api.bscscan.com/api', // BSCScan
apiKey: 'YOUR_ETHERSCAN_API_KEY', // optional — higher rate limits
chainId: 50, // XDC Mainnet (required for Etherscan v2)
rateLimitPerSec: 5, // built-in token-bucket rate limiter
});Available methods:
// ── Transaction lists ────────────────────────────────────────
// Get external transactions to/from a contract
const txs = await explorer.getTransactions('0xAddress', {
startBlock: 75_000_000,
endBlock: 75_100_000,
sort: 'asc', // or 'desc'
});
// Get internal transactions (CALL, DELEGATECALL from other contracts)
const internalTxs = await explorer.getInternalTransactions('0xAddress', {
startBlock: 75_000_000,
endBlock: 75_100_000,
});
// ── Events ───────────────────────────────────────────────────
// Get event logs with optional topic filter
const logs = await explorer.getLogs('0xAddress', {
fromBlock: 75_000_000,
toBlock: 75_100_000,
topic0: '0xddf252ad...', // optional — filter by event signature
});
// ── Contract ABI ─────────────────────────────────────────────
// Auto-fetch verified contract ABI
const abi = await explorer.getContractABI('0xAddress');
if (abi) {
console.log(`Fetched ABI with ${abi.length} entries`);
}
// ── Token transfers ──────────────────────────────────────────
const transfers = await explorer.getTokenTransfers('0xAddress', {
startBlock: 75_000_000,
endBlock: 75_100_000,
});
// ── Balance ──────────────────────────────────────────────────
const balance = await explorer.getBalance('0xAddress');
// ── Cleanup ──────────────────────────────────────────────────
explorer.destroy(); // Cleans up rate limiter timersExplorer compatibility:
| Explorer | Base URL | Chain | Free Rate Limit |
|---|---|---|---|
| Etherscan v2 ⭐ | https://api.etherscan.io/v2/api |
XDC (50) + 80 chains via chainId | 5 req/s |
| XDCScan | https://xdc.blocksscan.io/api |
XDC Mainnet (50) | 5 req/s |
| BSCScan | https://api.bscscan.com/api |
BSC (56) | 5 req/s |
| PolygonScan | https://api.polygonscan.com/api |
Polygon (137) | 5 req/s |
| Custom | Any Etherscan-compatible URL | Any EVM chain | Configurable |
The decoder handles both standard Solidity ABI encoding and XDC's non-standard encoding (where all params are packed into the data field).
import { AbiRegistry, EventDecoder } from '@xdc.org/interaction-detector';
// ── Register ABIs ────────────────────────────────────────────
const registry = new AbiRegistry();
// Register manually
registry.register(
'0xContractAddress',
[
'event Transfer(address indexed from, address indexed to, uint256 value)',
'event Approval(address indexed owner, address indexed spender, uint256 value)',
],
'MyToken',
);
// Register from a JSON ABI array
registry.register('0xAnotherContract', require('./MyContract.json').abi, 'MyContract');
// Auto-fetch from block explorer (verified contracts only, requires API key)
const explorer = new ExplorerClient({
apiUrl: 'https://api.etherscan.io/v2/api',
apiKey: 'YOUR_ETHERSCAN_API_KEY',
chainId: 50,
});
const success = await registry.registerFromExplorer('0xVerifiedContract', explorer, 'VerifiedToken');
console.log(success ? 'ABI fetched!' : 'Contract not verified');
// ── Query the registry ───────────────────────────────────────
registry.has('0xContractAddress'); // true
registry.getName('0xContractAddress'); // 'MyToken'
registry.getAddresses(); // ['0x...', '0x...']
registry.getEventName('0xddf252ad...'); // 'Transfer' (from any registered contract)
// ── Decode raw logs ──────────────────────────────────────────
const decoder = new EventDecoder(registry);
const decoded = decoder.decode(rawLog);
if (decoded) {
console.log(decoded.name); // 'Transfer'
console.log(decoded.args.from); // '0x...'
console.log(decoded.args.to); // '0x...'
console.log(decoded.args.value); // 1000000000000000000n
}Checkpoints let the watcher resume from where it left off after a restart.
| Backend | Storage | Survives Restart? | Best For |
|---|---|---|---|
memory |
JavaScript Map in RAM |
❌ No | Development, testing |
file |
checkpoints.json on disk |
✅ Yes | Simple production deployments |
custom |
Your own (Redis, SQL, InfluxDB…) | ✅ Yes | Distributed / production-grade |
import { MemoryCheckpoint, FileCheckpoint, createCheckpointBackend } from '@xdc.org/interaction-detector';
// ── Memory (development / testing) ───────────────────────────
// Stores in RAM only — lost when process stops
const memCp = new MemoryCheckpoint();
await memCp.save('watcher-key', 75_000_000);
await memCp.load('watcher-key'); // 75000000
// ── File (simple production) ─────────────────────────────────
// Auto-creates directory and file on first save
const fileCp = new FileCheckpoint('./checkpoints');
await fileCp.save('watcher-key', 75_000_000);
// Persists to ./checkpoints/checkpoints.json (relative to process.cwd())
// Survives process restarts
// ── Factory function ─────────────────────────────────────────
const cp = createCheckpointBackend({ backend: 'file', path: './data' });
const cp2 = createCheckpointBackend({ backend: 'memory' });Implement the CheckpointBackend interface — just two methods:
interface CheckpointBackend {
save(key: string, blockNumber: number): Promise<void>;
load(key: string): Promise<number | null>;
}Redis:
checkpoint: {
backend: 'custom',
custom: {
async save(key: string, blockNumber: number) {
await redis.set(`checkpoint:${key}`, blockNumber);
},
async load(key: string) {
const val = await redis.get(`checkpoint:${key}`);
return val ? parseInt(val) : null;
},
},
},PostgreSQL / MySQL:
checkpoint: {
backend: 'custom',
custom: {
async save(key: string, blockNumber: number) {
await pool.query(
`INSERT INTO checkpoints (key, block_number) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET block_number = $2`,
[key, blockNumber],
);
},
async load(key: string) {
const result = await pool.query(
'SELECT block_number FROM checkpoints WHERE key = $1',
[key],
);
return result.rows[0]?.block_number ?? null;
},
},
},InfluxDB:
checkpoint: {
backend: 'custom',
custom: {
async save(key: string, blockNumber: number) {
await influx.writePoints([{
measurement: 'checkpoints',
tags: { key },
fields: { block_number: blockNumber },
}]);
},
async load(key: string) {
const result = await influx.query(
`SELECT LAST(block_number) FROM checkpoints WHERE key = '${key}'`,
);
return result[0]?.block_number ?? null;
},
},
},Recommendation: Use Redis or SQL for checkpoint storage. InfluxDB is better suited for storing the detected events/interactions as time-series data rather than simple key-value checkpoint state.
import {
// Address utilities
normalizeAddress, // '0xABC...' or 'xdcABC...' → '0xabc...'
toXdcAddress, // '0xabc...' → 'xdcabc...'
toEthAddress, // 'xdcabc...' → '0xabc...'
isAddress, // validate hex address (20 bytes)
addressEqual, // compare addresses (case-insensitive, prefix-aware)
// Formatting (precision-safe for large BigInt values)
formatXDC, // bigint wei → '1.23M XDC' / '456.78K XDC'
formatWei, // bigint wei → '1.23M' (generic, configurable decimals)
parseHexOrDecimal, // '0x100' → 256, '256' → 256
toHex, // 256 → '0x100'
shortAddress, // '0x1234...abcd'
sleep, // await sleep(1000)
// Logger
Logger, // new Logger('MyModule', 'info')
} from '@xdc.org/interaction-detector';Address normalization behavior:
normalizeAddress('0xAbCdEf...'); // → '0xabcdef...' (lowercased)
normalizeAddress('xdcAbCdEf...'); // → '0xabcdef...' (xdc → 0x)
normalizeAddress(''); // → '0x0000000000000000000000000000000000000000' (zero address)Note:
normalizeAddressreturns the zero address for empty or falsy inputs, consistent with EVM conventions. This prevents empty strings from propagating as ghost entries in maps and sets.
Formatting precision: Both formatXDC and formatWei use bigint arithmetic internally, so large amounts (beyond JavaScript's Number.MAX_SAFE_INTEGER) are displayed correctly without loss of precision.
| Option | Type | Default | Description |
|---|---|---|---|
rpcUrl |
string |
required | Primary HTTP RPC URL |
wsUrl |
string |
— | WebSocket RPC URL (enables real-time events) |
contracts |
ContractConfig[] |
required | Contracts to monitor |
explorer |
ExplorerConfig |
— | Block explorer API settings |
polling |
PollingConfig |
— | HTTP polling tuning |
ws |
WsConfig |
— | WebSocket tuning |
checkpoint |
CheckpointConfig |
memory |
Checkpoint persistence |
chainId |
number |
50 |
Chain ID |
fallbackRpcUrls |
string[] |
[] |
Fallback RPC endpoints |
logLevel |
LogLevel |
'info' |
Log verbosity |
| Option | Type | Default | Description |
|---|---|---|---|
address |
string |
required | Contract address (0x or xdc prefix) |
abi |
any[] |
— | ABI for event decoding. Human-readable or JSON format |
name |
string |
— | Human-readable label (shown in decoded events) |
| Option | Type | Default | Description |
|---|---|---|---|
apiUrl |
string |
required | Explorer API base URL |
apiKey |
string |
— | API key for higher rate limits |
chainId |
number |
— | Chain ID (required for Etherscan v2) |
rateLimitPerSec |
number |
5 |
Max requests per second |
pollIntervalMs |
number |
60000 |
How often to poll txlist/txlistinternal |
| Option | Type | Default | Description |
|---|---|---|---|
intervalMs |
number |
30000 |
How often to poll eth_getLogs |
maxBlockRange |
number |
100 |
Max blocks per request (XDC limit = 100) |
concurrency |
number |
3 |
Parallel chunk fetches |
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable WebSocket subscription |
reconnectDelayBaseMs |
number |
5000 |
Base reconnect delay |
reconnectDelayMaxMs |
number |
30000 |
Max reconnect delay (exponential backoff cap) |
maxReconnectAttempts |
number |
20 |
Max attempts before 5-min cooldown |
heartbeatIntervalMs |
number |
60000 |
Heartbeat ping interval |
heartbeatTimeoutMs |
number |
10000 |
Heartbeat response timeout |
| Option | Type | Default | Description |
|---|---|---|---|
backend |
string |
'memory' |
'memory', 'file', or 'custom' |
path |
string |
'./checkpoints' |
Directory for 'file' backend |
custom |
CheckpointBackend |
— | Custom implementation for 'custom' backend |
-
Non-standard ABI encoding: Some XDC system contracts (including XDCValidator at
0x...0088) encode all event parameters in thedatafield with onlytopic[0](the event signature). Standard decoders likeethers.jsfail on these. The library includes an automatic fallback decoder that handles this transparently. -
Block range limit: XDC RPC limits
eth_getLogsto 100 blocks per request. TheLogPollerandBlockScannerautomatically chunk requests. Don't setmaxBlockRangeabove 100 for XDC. -
Address prefix: XDC uses the
xdcprefix instead of0x. All library functions accept both formats. Internally, everything normalizes to lowercase0x. Empty/falsy inputs normalize to the zero address (0x000...000). -
Tracing:
debug_traceTransactionrequires an archive node for historical transactions. For monitoring current blocks in real-time, a standard full node works fine. -
Atomic checkpoints: The
FileCheckpointbackend uses atomic write-then-rename to prevent data corruption on process crash. Checkpoint data is always consistent.
┌──────────────────────────────────────────────────────────┐
│ @xdc.org/interaction-detector │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ ContractWatcher (real-time monitoring) │ │
│ │ ├── WsManager (WebSocket subscription) │ │
│ │ ├── LogPoller* (eth_getLogs fallback) │ │
│ │ ├── ExplorerClient* (txlist + txlistinternal)│ │
│ │ ├── EventDecoder (ABI + XDC fallback) │ │
│ │ └── Checkpoint (pluggable persistence) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ BlockScanner (historical queries) │ │
│ │ ├── LogPoller* (chunked eth_getLogs) │ │
│ │ └── ExplorerClient* (API adapter) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ TransactionTracer (debug_traceTransaction) │ │
│ │ ├── CallTreeParser (callTracer output) │ │
│ │ └── StateDiffParser (prestateTracer output) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────┐ ┌───────────────────────────┐ │
│ │ RPC Client │ │ Utilities │ │
│ │ (retry, timeout, │ │ (address normalize, │ │
│ │ fallback, WS) │ │ format, logger, cache) │ │
│ └────────────────────┘ └───────────────────────────┘ │
│ │
│ * = also exported as standalone classes │
└──────────────────────────────────────────────────────────┘
Detection pipeline:
Events (eth_subscribe + eth_getLogs)
↓ collect tx hashes
XDCScan (txlist + txlistinternal)
↓ merge + deduplicate
ContractInteraction records
↓ optionally
debug_traceTransaction (per tx hash)
↓ parse call tree + state diffs
Full execution story
See the examples/ directory for runnable scripts:
| Example | What it demonstrates |
|---|---|
basic-watcher.ts |
Real-time event monitoring with checkpoint |
historical-scan.ts |
Scanning a block range for events |
trace-transaction.ts |
Deep-diving a single transaction |
full-interaction-detector.ts |
All 3 methods combined |
Run any example with:
npm run build
npx tsx examples/basic-watcher.ts
npx tsx examples/trace-transaction.ts 0xYourTxHash# Install dependencies
npm install
# Compile TypeScript
npm run build
# Run unit tests (114 tests across 11 test files)
npm test
# Watch mode (auto-recompile on save)
npm run devMIT