Human-readable PERO-J contract events on Stellar. Instead of raw XDR bytes, users see: "Address GABC… swapped 100 USDC → 98.7 XLM on StellarSwap at ledger #4521983."
PERO-J have excellent support for classic assets but poor support for Soroban smart contracts. When a user calls swap on a DEX, explorers show raw XDR bytes — unreadable to anyone. This "black box" experience dampens DeFi, NFT, and web3 growth on Stellar.
PERO-J decodes contract calls on the fly using an ABI-like metadata registry, turning opaque XDR into plain English.
| Before | After |
|---|---|
AAAAA9hZ...[Raw XDR]...== |
Address GABC… swapped 100 USDC → 98.7 XLM on StellarSwap at ledger #4521983 |
┌─────────────────────────────────────────────────────────┐
│ PERO-J RPC / Horizon │
│ (getEvents, getTransaction) │
└────────────────────┬────────────────────────────────────┘
│ poll every 5 s
┌────────────────────▼────────────────────────────────────┐
│ Indexer (Node.js) │
│ • Fetches raw events via PERO-JRpc.getEvents() │
│ • Decodes XDR → human text using ABI registry │
│ • Stores decoded events in PostgreSQL │
│ • Exposes REST API on :3001 │
└────────────────────┬────────────────────────────────────┘
│ REST /api/*
┌────────────────────▼────────────────────────────────────┐
│ React Frontend (Vite + TanStack Query) │
│ • Home: paginated event feed + function filter │
│ • /contract/:id — ABI metadata + event history │
│ • /wallet/:address — wallet transaction history │
│ • /event/:seq — full decoded event detail │
└─────────────────────────────────────────────────────────┘
▲
┌────────────────────┴────────────────────────────────────┐
│ PERO-J Contract (Rust) │
│ • ContractRegistry — stores ABI-like metadata │
│ • EventDecoder — persists decoded events on-chain │
└─────────────────────────────────────────────────────────┘
- Rust +
wasm32-unknown-unknowntarget - Stellar CLI
- Node.js ≥ 20
- PostgreSQL
Just want to explore the running app? You can skip everything below and bring up the full stack (PostgreSQL, Redis, Soroban sandbox, indexer, and frontend — pre-seeded with test markets and simulated users) with a single command using Docker. See the Docker Compose section:
docker compose up --buildThen open http://localhost:5173.
git clone https://github.com/PERO-J
cd PERO-J
cp .env.example .env
# Edit .env with your RPC URL and DATABASE_URL. See [.env.example](.env.example)
# for all supported indexer, database, contract, and SEP-41 settings.make build # compile to WASM
make test # run unit tests
make deploy # deploy to testnet, prints CONTRACT_IDCopy the printed contract ID into .env as EXPLORER_CONTRACT_ID.
The indexer validates NETWORK_PASSPHRASE on startup by querying the RPC endpoint. If the configured passphrase does not match the RPC-reported network, the indexer logs an error and exits with code 1. This prevents mainnet/testnet mix-ups.
make indexer-install
make indexermake frontend-install
make frontend
# Open http://localhost:5173Or run both together:
make install
make devSpin up the entire stack — PostgreSQL, Redis, Soroban local sandbox, indexer API (with hot-reload), and the frontend (with hot-reload), plus pre-seeded test markets and simulated user accounts — with a single command:
docker compose up --buildThat's it. The first run builds the images and pre-applies the database
migrations before the indexer starts; the seed one-shot service then
pre-seeds three test markets (StellarSwap, Blend, Phoenix) and a dozen sample
decoded events for four simulated wallet addresses, so the frontend has data
to show immediately.
Then open:
- Frontend → http://localhost:5173
- Indexer API → http://localhost:3001/health
- Soroban sandbox RPC → http://localhost:8000 (for direct
sorobanCLI use)
| Service | Port | Notes |
|---|---|---|
postgres |
5432 |
PostgreSQL 16; data persisted in the postgres-data volume |
redis |
6379 |
Redis 7 (AOF); provisioned for future caching/rate-limit use |
soroban |
8000 |
stellar/quickstart local network with Soroban RPC enabled |
migrate |
— | One-shot: pre-applies schema migrations before the indexer starts |
indexer |
3001 |
Node.js API + poller; hot-reload via node --watch (source bind-mounted) |
seed |
— | One-shot: pre-seeds test markets + simulated user accounts (idempotent) |
frontend |
5173 |
Vite dev server; hot-reload (source bind-mounted); proxies /api to the indexer |
- The
migrateservice runs the indexer's schema migrations (npm run migrate, which callsdb.init()) and exits 0 before theindexerstarts, so the schema is pre-applied. - The
indexerwaits for PostgreSQL, the Soroban sandbox, andmigrateto be ready, then polls the local sandbox (not testnet) and serves the REST API. - The
frontendruns the Vite dev server with the source bind-mounted for hot-reload. It proxies/api/*to the indexer via theVITE_API_PROXYenvironment variable (defaulthttp://indexer:3001inside the compose network), so no CORS configuration is required. - The
seedservice upserts the ABI fixtures and inserts sample decoded events (only when theeventstable is empty, so re-runningdocker compose updoes not duplicate seed rows).
Defaults work out of the box. Override any value by exporting the variable or
placing it in a .env file next to docker-compose.yml (see .env.example):
| Variable | Default | Description |
|---|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
peroj |
PostgreSQL credentials/database |
DATABASE_URL |
postgres://peroj:peroj@postgres:5432/peroj |
Indexer DB connection string |
DATABASE_POOL_SIZE |
5 |
Indexer Postgres pool size (migrate) / 20 (indexer) |
NETWORK_PASSPHRASE |
Standalone Network ; February 2017 |
Sandbox network passphrase |
START_LEDGER |
1 |
Ledger to start indexing from |
POLL_MS |
5000 |
Indexer poll interval (ms) |
SOROBAN_EXPLORER_CONTRACT_ID |
(empty) | Set to submit decoded events on-chain (optional) |
SOROBAN_EXPLORER_ADMIN_SECRET |
(empty) | Admin secret for on-chain submit_event (optional) |
API_ADMIN_KEY |
(empty) | Required for POST/DELETE /api/contracts |
VITE_API_PROXY |
http://indexer:3001 |
Vite dev proxy target for /api |
Stop the stack with docker compose down (add -v to also drop the database
and Redis volumes).
The seed is idempotent — it only inserts sample events when the events table
is empty. To re-seed from scratch:
docker compose down -v # wipe the postgres volume
docker compose up --build # migrate + seed run again| Function | Description |
|---|---|
init(admin) |
Initialise contract with admin address |
transfer_admin(current_admin, new_admin) |
Transfer admin rights; both parties must sign |
add_indexer(admin, indexer) |
Allowlist a hot wallet as a trusted event submitter (max 20) |
remove_indexer(admin, indexer) |
Revoke a previously allowlisted indexer |
get_indexers() |
List allowlisted indexer addresses |
is_indexer(address) |
Whether an address may submit events (admin or allowlisted) |
register_contract(caller, contract_id, meta) |
Register ABI metadata for a contract |
update_contract(caller, contract_id, meta) |
Update metadata (admin or registrant); emits update |
get_contract(contract_id) |
Fetch contract metadata |
submit_event(...) |
Persist a decoded event (admin or allowlisted indexer) |
get_event(seq) |
Fetch event by sequence number |
get_events(from, limit) |
Paginated event list; limit capped at 200 |
event_count() |
Total stored events |
Events emitted: register, update, decoded, adm_xfr, idx_add, idx_rm.
The update topic lets the indexer invalidate its ABI cache without polling storage.
| Endpoint | Description |
|---|---|
GET /health |
Liveness + lag probe — returns lag_seconds, uptime_seconds, last_ledger. HTTP 200 when healthy, 503 when lag_seconds > LAG_ALERT_THRESHOLD_S (default 30). |
GET /api/events?contract=&fn=&page= |
Paginated event list: { events, total, page, limit } |
GET /api/events/:seq |
Single event |
GET /api/events/:seq/raw |
Raw un-decoded event topics and data: { seq, raw_topics, raw_data, tx_hash } |
GET /api/contracts/:id |
Contract ABI metadata |
GET /api/contracts/:id/events?fn=&page= |
Paginated event list for contract: { events, total, page, limit } |
POST /api/contracts |
Register contract metadata |
DELETE /api/contracts/:id |
Remove contract ABI metadata (requires Authorization: Bearer <API_ADMIN_KEY>) |
GET /api/wallet/:address |
Wallet event history |
GET /api/tokens/:id/volume?decimals= |
24-hour rolling transfer volume for a SEP-41 token. Optional decimals query param (integer 0–38) overrides the on-chain metadata lookup. |
GET /api/v1/oracle/disputes |
Active Oracle disputes with asserted outcomes, disputer addresses, council votes and tallies, deadline, and remaining voting time. Requires ORACLE_CONTRACT_ID; returns HTTP 503 if it is unset. |
POST /api/v1/oracle/resolutions/:submissionId/evidence |
Store immutable raw source evidence for a resolution (requires API_ADMIN_KEY). |
GET /api/v1/oracle/resolutions/:submissionId/evidence |
Publicly retrieve the evidence bundle and its SHA-256 hash for verification. |
The disputes endpoint reads the Oracle contract on each request. Each dispute
includes affirmative_votes, negative_votes, the individual votes, an ISO
8601 deadline, and remaining_seconds / human-readable remaining_time.
The resolution evidence write endpoint is the ingestion hook for the automated
resolution worker. It accepts response_payload (the exact raw response body
as a string), response_status (100–599), response_headers (string-valued
object), and resolved_at (timestamp). Each submission ID can be recorded
once; retries with an identical evidence bundle return the original record,
while different evidence for an existing submission returns HTTP 409. The
database rejects updates, deletes, and truncation of audit records.
The public endpoint returns submission_id, response_payload,
response_status, response_headers, resolved_at, recorded_at, and
evidence_hash. The hash is SHA-256 over the UTF-8 bytes of the JSON
serialization of those evidence fields except recorded_at and
evidence_hash, in this order: submission_id, response_payload,
response_status, response_headers, resolved_at. Header keys are sorted
in ascending ASCII order and represented in the hash input as an array of
[name, value] pairs; the timestamp is normalized to ISO 8601 before hashing.
This lets clients independently reproduce the digest.
When ORACLE_CONTRACT_ID, ORACLE_COUNCIL_ROSTER, and
PAGERDUTY_ROUTING_KEY are configured, the indexer checks active disputes
hourly and sends PagerDuty Events API v2 alerts when a dispute has not reached
quorum after 24, 48, and 60 hours of its 72-hour voting period. The roster is a
comma-separated list of council Stellar addresses. ORACLE_COUNCIL_QUORUM can
set the required number of votes; when omitted, a strict majority of the
configured roster is used. The PagerDuty service associated with the routing
key should use an escalation policy that notifies the council roster. The
monitor is disabled with a startup warning if any required setting is absent.
The events table tracks two distinct sequence numbers:
| Column | Type | Description |
|---|---|---|
seq |
BIGSERIAL (PK) |
Auto-increment database sequence. The canonical identifier used by the REST API and frontend (e.g. GET /api/events/:seq). Always present. |
onchain_seq |
BIGINT (nullable) |
The EventSeq value returned by the on-chain ExplorerContract after a successful submission. NULL when the event was not submitted or the submission failed. |
These two namespaces are independent and will diverge over time — not every database row has a corresponding on-chain submission, and on-chain sequence numbers are scoped to the contract, not the database. Always use seq to reference events in API calls and frontend URLs.
GET /api/tokens/:id/volume returns the 24-hour rolling transfer volume for a SEP-41 token.
| Parameter | Type | Required | Description |
|---|---|---|---|
id (path) |
string | yes | Contract ID of the SEP-41 token |
decimals (query) |
integer 0–38 | no | Override decimal precision. When omitted, decimals are resolved from on-chain metadata / simulation (defaults to 7 if unavailable). |
Example response:
{
"contract_id": "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP",
"window": "24h",
"volume": "1048576.0000000",
"decimals": 7
}Configure an external monitor (UptimeRobot, Better Uptime, or similar) to call
GET /health every 60 seconds and alert when the response is HTTP 503 or
lag_seconds > LAG_ALERT_THRESHOLD_S (default 30). This satisfies ROADMAP Tranche 2 deliverable 2.7 (< 10 s
index lag under normal load, alert threshold 30 s).
Example healthy response:
{
"status": "ok",
"uptime_seconds": 3600,
"lag_seconds": 4,
"last_ledger": 5214892,
"last_indexed_at": "2026-07-25T21:00:00.000Z"
}Example degraded response (HTTP 503):
{
"status": "degraded",
"uptime_seconds": 7200,
"lag_seconds": 142,
"last_ledger": 5214750,
"last_indexed_at": "2026-07-25T20:57:38.000Z"
}Override the alert threshold via the LAG_ALERT_THRESHOLD_S environment variable
(default 30). Non-numeric values fall back to the default of 30.
The decoder recognises SEP-41 token events (transfer, mint, burn) and formats amounts with the correct symbol, alongside classic Stellar assets fetched from Horizon.
- Confirmed gap: StellarExpert and Stellar.expert (the two primary Stellar explorers) show raw XDR bytes for all Soroban contract events as of May 2026 — no human-readable
┌──────────────────────────────────────────────────────────────┐
│ Stellar Network │
│ ┌─────────────────────┐ ┌──────────────────────────────┐ │
│ │ PERO-J RPC │ │ Horizon API │ │
│ │ getEvents() │ │ Classic asset metadata │ │
│ │ getTransaction() │ │ (asset codes, issuers) │ │
│ └──────────┬──────────┘ └──────────────┬───────────────┘ │
└─────────────┼────────────────────────────┼─────────────────┘
│ poll every 5 s │ on-demand
┌─────────────▼────────────────────────────▼─────────────────┐
│ Indexer (Node.js) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ decoder.js │ │
│ │ scValToNative(topic/data) → match ABI registry │ │
│ │ → "Address GA… swapped 100 USDC → 98.7 XLM" │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ ┌──────────────────────▼───────────────────────────────┐ │
│ │ db.js (PostgreSQL) │ │
│ │ events table · contracts table │ │
│ │ indexes on contract_id, function, ledger │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ ┌──────────────────────▼───────────────────────────────┐ │
│ │ api.js (Express REST) │ │
│ │ GET /api/events · GET /api/contracts/:id │ │
│ │ GET /api/wallet/:address · POST /api/contracts │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────┬───────────────────────────────┘
│ REST /api/*
┌─────────────────────────────▼───────────────────────────────┐
│ React Frontend (Vite + TanStack Query) │
│ / — paginated feed, function filter │
│ /contract/:id — ABI metadata + event history │
│ /wallet/:addr — all events for a Stellar address │
│ /event/:seq — full decoded event detail │
└─────────────────────────────────────────────────────────────┘
▲
┌─────────────────────────────┴───────────────────────────────┐
│ PERO-J Contract (Rust) — on-chain source of truth │
│ ContractRegistry register_contract / get_contract │
│ EventDecoder submit_event / get_events / event_count │
└─────────────────────────────────────────────────────────────┘
Data flow for a decoded event:
- PERO-J contract emits an event (e.g.,
swapon StellarSwap) - Indexer fetches it via
SorobanRpc.getEvents() decoder.jscallsscValToNative()on topics/data, looks up registered ABI- Produces human-readable string → stored in PostgreSQL + submitted to on-chain contract
- Frontend queries REST API and displays the decoded event
| Document | Description |
|---|---|
| CHANGELOG.md | Full release history — what changed, what broke, what was added |
| ROADMAP.md | 3-tranche milestone plan (MVP → Testnet → Mainnet) |
| BUDGET.md | Engineering hours and cost breakdown per tranche |
| TEAM.md | Team bios and qualification evidence |
| MANIFEST.md | Full project manifest |
| stellar.toml | SEP-1 compliant network info |
Automated backups protect all decoded event history and registered ABI metadata stored in PostgreSQL.
scripts/backup.sh uses pg_dump to produce a plain-text SQL dump of the soroban_explorer database.
./scripts/backup.shEnvironment variables:
| Variable | Default | Description |
|---|---|---|
PGHOST |
localhost |
PostgreSQL host |
PGPORT |
5432 |
PostgreSQL port |
PGUSER |
user |
PostgreSQL user |
PGDATABASE |
soroban_explorer |
Database name |
PGPASSWORD |
(from env) | PostgreSQL password |
BACKUP_DIR |
./backups |
Directory for dump files |
LOG_FILE |
./logs/backup.log |
Backup log path |
Schedule daily backups at 02:00 UTC:
0 2 * * * /workspaces/PERO-J/scripts/backup.sh >> /var/log/backup.log 2>&1Or deploy with a systemd timer, Docker cron, or your platform's scheduled task scheduler.
To restore a backup into PostgreSQL:
# Stop the indexer to avoid data inconsistency
# Then pipe the dump into psql:
psql -h <host> -U <user> -d <database> -f backups/soroban_explorer_<timestamp>.sqlOr restore to a new database for verification:
createdb -h <host> -U <user> soroban_explorer_restore
psql -h <host> -U <user> -d soroban_explorer_restore -f backups/soroban_explorer_<timestamp>.sqlFor cloud-hosted PostgreSQL, enable automated backups via the managed service:
| Platform | Setting |
|---|---|
| AWS RDS | Enable automated backups in the RDS instance configuration; set backup retention period (recommended: 7+ days). Use snapshots for point-in-time recovery. |
| Google Cloud SQL | Enable automated backups in the instance settings; set backup start time and retention period. Use scheduled exports to Cloud Storage for additional safety. |
| Supabase | Dashboard > Project Settings > Database > Backups. Enable daily automatic backups. |
| Neon | Dashboard > Settings > Branches & Backups. Configure branch protection and auto-backup retention. |
For any cloud provider, also export a pg_dump weekly to object storage (S3, GCS) as an offsite copy.
The indexer ships an optimized multi-stage production image
(indexer/Dockerfile / indexer/Dockerfile.indexer): production-only
dependencies (no dev deps), the transitive sodium-native native package
stripped, docs/tests/source-maps pruned, and a non-root appuser runtime. The
result is a ~140MB uncompressed (~50MB compressed) image that never runs as
root, with the Kubernetes deployment enforcing runAsNonRoot and dropping all
Linux capabilities. See docs/kubernetes-deployment.md
for the hardening details.
PRs welcome. Please open an issue first for large changes.
- Issue #792:
EventPagealready validates theseqparam (isValidSeq) and skips the API request for invalid values; no code change needed. - Issue #793:
CopyButtonalready accepts anariaLabelprop applied asaria-label, and all usages pass labels; no code change needed. - Issue #794:
Skeletonalready supportsvariant="table"and"card"and is used inHome,ContractPageandWalletPage; no code change needed. - Issue #795:
Home.tsxalready debounces the search query (300ms, cleared on unmount) viadebouncedSearchQuery; no code change needed.
- #850: Implement
transfer_adminrequiring both parties to sign
- #745:
init()must be permanently irreversible after first call