Skip to content
Merged
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
37 changes: 37 additions & 0 deletions scripts/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ── Required ──────────────────────────────────────────────────────────────────

# Deployed FlowPay contract ID (starts with C, 56 characters)
CONTRACT_ID=

# Stellar secret key for the keeper account (starts with S, 56 characters)
# This account pays transaction fees. Fund it with at least 10 XLM.
KEEPER_SECRET=

# ── Network ───────────────────────────────────────────────────────────────────

# Soroban RPC endpoint
# Testnet (default): https://soroban-testnet.stellar.org
# Mainnet: https://soroban-mainnet.stellar.org (or your own node)
RPC_URL=https://soroban-testnet.stellar.org

# Stellar network passphrase
# Testnet (default): Test SDF Network ; September 2015
# Mainnet: Public Global Stellar Network ; September 2015
NETWORK_PASSPHRASE=Test SDF Network ; September 2015

# ── Keeper tuning ─────────────────────────────────────────────────────────────

# Milliseconds between full charge cycles (default: 3600000 = 1 hour)
CHARGE_INTERVAL_MS=3600000

# Number of subscriptions processed per batch_charge call (max: 100)
PAGE_SIZE=100

# Per-page retry attempts before skipping a failed page (default: 3)
MAX_RETRIES=3

# ── Observability ─────────────────────────────────────────────────────────────

# Log verbosity: debug | info | warn | error (default: info)
# Use "debug" for local development; "info" or "warn" in production.
LOG_LEVEL=info
56 changes: 56 additions & 0 deletions scripts/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ── Stage 1: build ────────────────────────────────────────────────────────────
# Compile keeper.ts to dist/keeper.js inside a full Node image so we have
# access to the TypeScript compiler without shipping it in the final image.
FROM node:20-alpine AS builder

WORKDIR /build

# Copy manifests first so Docker can cache the npm install layer independently
# of source changes.
COPY package.json package-lock.json ./

# Install all dependencies (including devDependencies for tsc).
# --frozen-lockfile ensures reproducible installs.
RUN npm ci --frozen-lockfile

# Copy source files needed for the keeper build.
COPY tsconfig.json tsconfig.build.json ./
COPY keeper.ts ./

# Compile. Output lands in /build/dist/keeper.js
RUN npm run build

# ── Stage 2: runtime ──────────────────────────────────────────────────────────
# Minimal image: only the compiled JS and production dependencies.
FROM node:20-alpine AS runtime

# Security: run as the built-in non-root "node" user.
USER node

WORKDIR /app

# Copy manifests and install production dependencies only.
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci --frozen-lockfile --omit=dev

# Copy the compiled output from the builder stage.
COPY --chown=node:node --from=builder /build/dist ./dist

# All configuration is supplied via environment variables at runtime.
# See .env.example for the full list.
ENV NODE_ENV=production

# Emit unhandled rejection warnings as errors so the container exits non-zero
# on unexpected failures instead of hanging.
ENV NODE_OPTIONS=--unhandled-rejections=throw

# Health check: call the RPC getHealth endpoint.
# Requires HEALTH_CHECK_URL to be set (or falls back to RPC_URL/getHealth).
# The container orchestrator can override this with its own probe.
HEALTHCHECK --interval=60s --timeout=10s --start-period=15s --retries=3 \
CMD wget -qO- "${RPC_URL:-https://soroban-testnet.stellar.org}" \
--post-data='{"jsonrpc":"2.0","id":1,"method":"getHealth"}' \
--header='Content-Type: application/json' \
| grep -q '"status":"healthy"' || exit 1

CMD ["node", "dist/keeper.js"]
248 changes: 248 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# PayFlow Scripts

Operational scripts for the FlowPay recurring-billing contract. All scripts are
written in TypeScript and executed with [tsx](https://github.com/privatenumber/tsx)
(no compile step needed for local use).

## Prerequisites

- Node.js 20+
- `npm install` inside this directory

```bash
cd scripts
npm install
```

---

## Scripts

| Script | Purpose |
| ------------------------------ | --------------------------------------------------------- |
| `keeper.ts` | Autonomous keeper — calls `batch_charge` on a schedule |
| `watch-events.ts` | Real-time contract event monitor |
| `check-allowances.ts` | Audit subscriber token allowances |
| `alert-expiring-allowances.ts` | Alert on allowances expiring within a configurable window |
| `indexer.ts` | Persist contract events to SQLite |
| `query-events.ts` | Query the SQLite event database |
| `health-check.ts` | Contract responsiveness check |
| `subscription-snapshot.ts` | Snapshot all subscription states |
| `daily-revenue-summary.ts` | Daily revenue report |
| `export-merchant-report.ts` | Per-merchant activity report |

---

## Keeper

The keeper bot pages through every active subscription and invokes
`batch_charge(offset, limit)` until all pages are processed, then sleeps until
the next cycle.

### Run locally

```bash
CONTRACT_ID=C... \
KEEPER_SECRET=S... \
tsx keeper.ts
```

Optional variables (all have defaults):

| Variable | Default | Description |
| -------------------- | ------------------ | ----------------------------------------------- |
| `RPC_URL` | testnet RPC | Soroban RPC endpoint |
| `NETWORK_PASSPHRASE` | testnet passphrase | Stellar network passphrase |
| `CHARGE_INTERVAL_MS` | `3600000` (1 h) | Sleep between full charge cycles |
| `PAGE_SIZE` | `100` | Subscriptions per `batch_charge` call (max 100) |
| `MAX_RETRIES` | `3` | Per-page retries before skipping |
| `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` |

---

## Docker

### 1. Configure environment

Copy the example env file and fill in the required values:

```bash
cp .env.example .env
# edit .env — set CONTRACT_ID and KEEPER_SECRET at minimum
```

The `.env` file is loaded by Docker Compose at runtime and is **never baked
into the image**.

### 2. Build the image

```bash
# From the scripts/ directory:
docker build -t payflow-keeper .
```

The build uses two stages:

1. **builder** — installs all dependencies and compiles `keeper.ts` → `dist/keeper.js`
2. **runtime** — copies only `dist/` and production dependencies into a slim
`node:20-alpine` image running as the non-root `node` user

### 3. Run with Docker Compose

```bash
docker compose up -d
```

To follow logs:

```bash
docker compose logs -f keeper
```

To stop:

```bash
docker compose down
```

### 4. Run with plain `docker run`

```bash
docker run --rm \
--env-file .env \
--name payflow-keeper \
payflow-keeper
```

### 5. Smoke test

After the container starts, check that it logged a successful startup line:

```bash
docker compose logs keeper | grep '"msg":"FlowPay Keeper starting"'
```

A healthy keeper emits a JSON log line like:

```json
{
"ts": "2026-01-01T00:00:00.000Z",
"level": "info",
"msg": "FlowPay Keeper starting",
"contract": "C...",
"keeper": "G...",
"rpc": "https://...",
"charge_interval_ms": 3600000,
"page_size": 100,
"max_retries": 3
}
```

### Docker image details

| Property | Value |
| -------------- | ---------------------------------------- |
| Base image | `node:20-alpine` |
| Run user | `node` (non-root, UID 1000) |
| Entrypoint | `node dist/keeper.js` |
| Health check | `wget` → RPC `getHealth` (60 s interval) |
| Restart policy | `unless-stopped` |
| Log driver | `json-file` (10 MB × 5 files) |
| Graceful stop | 60 s before SIGKILL |

---

## Event Indexer

Persists contract events to a local SQLite database (`data/events.db`).
Resumes from the last indexed ledger on restart.

```bash
CONTRACT_ID=C... tsx indexer.ts
```

Optional variables:

| Variable | Default | Description |
| ------------------ | ---------------- | ------------------------- |
| `RPC_URL` | testnet RPC | Soroban RPC endpoint |
| `DATA_DIR` | `data` | Directory for `events.db` |
| `DB_FILE` | `data/events.db` | Full path override |
| `POLL_INTERVAL_MS` | `10000` (10 s) | Polling interval |
| `START_LEDGER` | latest ledger | First-run start ledger |
| `LOG_LEVEL` | `info` | Log verbosity |

### Query stored events

```bash
# Most recent 20 events
tsx query-events.ts --recent --pretty

# All events for a subscriber
tsx query-events.ts --address GXYZ... --pretty

# Events of a specific type
tsx query-events.ts --type charged --pretty

# Events in a ledger range
tsx query-events.ts --ledger 500000 --to 510000
```

---

## Other Scripts

### check-allowances

Audit whether subscriber allowances cover their next charge:

```bash
CONTRACT_ID=C... tsx check-allowances.ts --file subscribers.txt
CONTRACT_ID=C... tsx check-allowances.ts GXYZ... GABC...
CONTRACT_ID=C... tsx check-allowances.ts --json --file subscribers.txt
```

### alert-expiring-allowances

Alert on allowances expiring within a configurable ledger window (default 17280 ≈ 24 h):

```bash
CONTRACT_ID=C... tsx alert-expiring-allowances.ts --file subscribers.txt
CONTRACT_ID=C... WEBHOOK_URL=https://hooks.example.com tsx alert-expiring-allowances.ts --file subscribers.txt
CONTRACT_ID=C... tsx alert-expiring-allowances.ts --dry-run --file subscribers.txt
```

Exits with code `1` if any allowances are expiring soon.

### health-check

Verify the contract is responsive (suitable for cron or Docker `HEALTHCHECK`):

```bash
CONTRACT_ID=C... tsx health-check.ts
# exit 0 = healthy, exit 1 = unhealthy
```

---

## Environment variable reference

All scripts read configuration from environment variables. The full set used
across all scripts:

| Variable | Used by | Description |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------ |
| `CONTRACT_ID` | all | Deployed FlowPay contract ID |
| `RPC_URL` | all | Soroban RPC endpoint |
| `NETWORK_PASSPHRASE` | keeper, check-allowances | Stellar network passphrase |
| `KEEPER_SECRET` | keeper | Stellar secret key (S…) for signing transactions |
| `CHARGE_INTERVAL_MS` | keeper | Sleep between charge cycles |
| `PAGE_SIZE` | keeper | Subscriptions per batch_charge page |
| `MAX_RETRIES` | keeper | Per-page retry limit |
| `WEBHOOK_URL` | alert-expiring-allowances, alert-failed-charges | Webhook POST target |
| `ALERT_WINDOW_LEDGERS` | alert-expiring-allowances | Expiry alert threshold |
| `DATA_DIR` | indexer, query-events | SQLite database directory |
| `DB_FILE` | indexer, query-events | SQLite database path override |
| `POLL_INTERVAL_MS` | indexer | Event polling interval |
| `START_LEDGER` | indexer | First-run start ledger |
| `LOG_LEVEL` | keeper, indexer | Log verbosity |
49 changes: 49 additions & 0 deletions scripts/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
services:
keeper:
build:
context: .
dockerfile: Dockerfile
target: runtime
image: payflow-keeper:latest
container_name: payflow-keeper

# Load all keeper configuration from a local .env file.
# Copy scripts/.env.example to scripts/.env and fill in the values.
# The .env file is never baked into the image.
env_file:
- .env

# Restart the container automatically unless it was explicitly stopped.
restart: unless-stopped

# Resource limits — keeper is lightweight; cap to avoid runaway memory on
# unexpected SDK bugs.
deploy:
resources:
limits:
cpus: "0.50"
memory: 256M
reservations:
cpus: "0.05"
memory: 64M

# Write structured JSON logs to a rotating file alongside stdout so they
# survive container restarts without a dedicated log shipper.
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"

# Named volume for the SQLite event DB written by indexer.ts (optional).
# Remove this section if you are not running the indexer in the same stack.
volumes:
- keeper-data:/app/data

# Graceful shutdown: give the keeper up to 60 s to finish its current
# charge page before Docker sends SIGKILL.
stop_grace_period: 60s

volumes:
keeper-data:
driver: local
Loading
Loading