Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ test_snapshots/
.vercel
indexer/events.ndjson
indexer/state.json
indexer/dist/
indexer/.env
*.tsbuildinfo
200 changes: 200 additions & 0 deletions docs/indexer-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Indexer Design

Event-sourced accounting service for the Tributary splitter contract.

## Architecture

```
Soroban RPC ──► Ingestion Worker ──► Postgres (events) ──► Projection Builders
│ │
│ ┌───────┘
│ ▼
│ Projection Tables
│ (splits, balances,
│ payouts, earnings)
│ │
│ ▼
│ Query API (Hono)
└──► Reconciliation Job
On-chain RPC calls
Webhook alerts
```

The append-only `events` table is the source of truth for all projections. Dropping all projection tables and replaying the event log reproduces identical state.

## Event Schema

### events table

| Column | Type | Notes |
|---|---|---|
| `id` | `BIGSERIAL` | Global sequence number |
| `event_id` | `TEXT` | Stellar event unique id (UNIQUE) |
| `ledger` | `BIGINT` | Ledger the event appeared in |
| `ledger_closed_at` | `TIMESTAMPTZ` | Ledger close timestamp |
| `tx_hash` | `TEXT` | Transaction hash |
| `type` | `TEXT` | One of the six event types |
| `split_id` | `BIGINT` | Topic-keyed split id |
| `payload` | `JSONB` | Typed payload per event |
| `reverted` | `BOOLEAN` | Set TRUE on reorg |
| `ingested_at` | `TIMESTAMPTZ` | When the row was written |

### Event types and payloads

**SplitCreated**
```json
{
"creator": "G...",
"recipients": [{"type": "Account", "address": "G..."}],
"shares": [6000, 4000]
}
```

**SplitPaid**
```json
{
"token": "G...",
"amount": "100000",
"legs": [{"recipient": "G...", "share_bps": 6000, "leg_amount": "60000"}]
}
```
`legs` is null until #258 (per-recipient payout legs) is implemented.

**Deposited**
```json
{
"token": "G...",
"amount": "50000",
"source": "unknown"
}
```
`source` is "unknown" until #267 (deposit vs routing) distinguishes external deposits from nested-split routing.

**Distributed**
```json
{
"token": "G...",
"amount": "50000",
"legs": [{"recipient": "G...", "share_bps": 10000, "leg_amount": "50000"}]
}
```

**SplitUpdated**
```json
{
"recipients": [{"type": "Account", "address": "G..."}],
"shares": [10000]
}
```

**ControlTransferred**
```json
{
"new_controller": "G..."
}
```
`new_controller` is null when locking the split.

## Projection Tables

### splits
Current state of each split.

| Column | Type |
|---|---|
| `id` | `BIGINT` (PK) |
| `creator` | `TEXT` |
| `recipients` | `JSONB` |
| `shares` | `JSONB` |
| `controller` | `TEXT` (nullable) |
| `created_at` | `TIMESTAMPTZ` |
| `updated_at` | `TIMESTAMPTZ` |

### split_balances
Escrow balance per split and token.

| Column | Type |
|---|---|
| `split_id` | `BIGINT` (composite PK) |
| `token` | `TEXT` (composite PK) |
| `balance` | `NUMERIC` |
| `updated_at` | `TIMESTAMPTZ` |

### payout_history
One row per recipient per payout event.

| Column | Type |
|---|---|
| `id` | `BIGSERIAL` (PK) |
| `split_id` | `BIGINT` |
| `token` | `TEXT` |
| `total_amount` | `NUMERIC` |
| `recipient` | `TEXT` |
| `share_bps` | `INT` |
| `leg_amount` | `NUMERIC` |
| `tx_hash` | `TEXT` |
| `ledger` | `BIGINT` |
| `timestamp` | `TIMESTAMPTZ` |

### recipient_earnings
Aggregate earnings per recipient address.

| Column | Type |
|---|---|
| `address` | `TEXT` (PK) |
| `token` | `TEXT` |
| `total_earned` | `NUMERIC` |
| `payout_count` | `INT` |
| `last_payout_at` | `TIMESTAMPTZ` (nullable) |

## Reconciliation

Periodic job that proves projected balances equal on-chain state.

1. Query all `(split_id, token)` pairs from `split_balances` where `balance != 0`
2. Call `balance(split_id, token)` on-chain via Soroban RPC
3. Compare values
4. On mismatch, binary-search the event log to find the first divergent event
5. POST to `RECONCILIATION_WEBHOOK_URL` with drift details
6. Update `/health` status

### Drift detection

When a mismatch is found, the reconciliation job replays events for the affected split from the beginning, tracking the simulated balance. The first event where the simulated balance diverges from the projected balance is reported as the root cause.

## Reorg Handling

- Cursor tracks `(ledger, event_id)` pairs in `ingestion_state`
- On each poll, if the RPC cursor rewinds past a previously ingested ledger:
- All events from affected ledgers are marked `reverted = TRUE`
- Projection state is rolled back for reverted events
- Events are re-ingested from the rewound point
- `event_id` UNIQUE constraint prevents duplicate ingestion

## Exactly-Once Ingestion

- `event_id` UNIQUE constraint on the events table
- `INSERT ... ON CONFLICT (event_id) DO NOTHING`
- Cursor stored in `ingestion_state` table, updated after each successful batch

## Rebuild

Dropping all projection tables and replaying the event log produces identical state. This is used for:
- Initial setup
- Schema changes to projection tables
- Recovery from corruption
- Testing

## Dependencies on Contract Changes

| Issue | Impact | Mitigation |
|---|---|---|
| #258 (per-recipient payout legs) | `payout_history` and `recipient_earnings` are incomplete | Build with nullable `legs`; enrichment is a one-function change |
| #267 (deposit vs routing) | Cannot distinguish external deposits from nested routing | `source` column defaults to "unknown"; retroactively classify when #267 lands |
| #80 (reorg handling) | Reorg safety depends on idempotent re-scans | UNIQUE constraint + cursor tracking provides the foundation |
| #81 (restart durability) | Cursor must survive restarts | Solved by storing cursor in Postgres instead of state.json |
9 changes: 9 additions & 0 deletions indexer/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
dist
.git
.env
.env.*
*.tsbuildinfo
events.ndjson
state.json
__tests__
37 changes: 12 additions & 25 deletions indexer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
FROM node:22-alpine

FROM node:20-alpine AS builder
WORKDIR /app

# Copy package files and install dependencies
COPY package*.json ./
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Copy application source code
COPY index.mjs export-csv.mjs ./

# Create data directory and set ownership to node user
RUN mkdir -p /app/data && chown -R node:node /app

# Switch to non-root user
USER node

# Default environment variables optimized for containerized usage
ENV RPC_URL="https://soroban-testnet.stellar.org" \
CONTRACT_ID="CCZXVZUQIZT673QF6ZGLI5AJLEPWUFWVYOPIOJNLNIOO5NI27V4JGJUU" \
OUT="/app/data/events.ndjson" \
STATE="/app/data/state.json" \
POLL_MS="10000"

# Volume for persisted state and output files
VOLUME ["/app/data"]

CMD ["node", "index.mjs"]
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
35 changes: 35 additions & 0 deletions indexer/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: tributary
POSTGRES_USER: tributary
POSTGRES_PASSWORD: tributary
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tributary"]
interval: 5s
timeout: 5s
retries: 5

indexer:
build: .
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://tributary:tributary@postgres:5432/tributary
RPC_URL: https://soroban-testnet.stellar.org
CONTRACT_ID: CCZXVZUQIZT673QF6ZGLI5AJLEPWUFWVYOPIOJNLNIOO5NI27V4JGJUU
POLL_MS: "10000"
RECONCILE_INTERVAL_MS: "60000"
RECONCILIATION_WEBHOOK_URL: ""
PORT: "3000"
ports:
- "3000:3000"

volumes:
pgdata:
Loading
Loading