diff --git a/scripts/.env.example b/scripts/.env.example new file mode 100644 index 0000000..f270c64 --- /dev/null +++ b/scripts/.env.example @@ -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 diff --git a/scripts/Dockerfile b/scripts/Dockerfile new file mode 100644 index 0000000..c6bc376 --- /dev/null +++ b/scripts/Dockerfile @@ -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"] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9e3199f --- /dev/null +++ b/scripts/README.md @@ -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 | diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml new file mode 100644 index 0000000..03ff86c --- /dev/null +++ b/scripts/docker-compose.yml @@ -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 diff --git a/scripts/keeper.ts b/scripts/keeper.ts index f27c042..1537e85 100644 --- a/scripts/keeper.ts +++ b/scripts/keeper.ts @@ -1,5 +1,38 @@ #!/usr/bin/env tsx /** + * keeper.ts — Autonomous keeper bot for FlowPay recurring billing + * + * Continuously invokes `batch_charge()` on the deployed FlowPay contract, + * paging through all active subscriptions on a configurable interval. + * + * Architecture + * ──────────── + * Each charge cycle iterates subscriber pages (offset 0, PAGE_SIZE, 2×PAGE_SIZE …) + * until a page returns fewer results than PAGE_SIZE, signalling the end of the + * subscriber index. Failed pages are retried up to MAX_RETRIES times with + * exponential back-off before being skipped and logged. + * + * Usage + * ───── + * CONTRACT_ID=C... KEEPER_SECRET=S... tsx keeper.ts + * + * Environment variables + * ───────────────────── + * CONTRACT_ID Required. Deployed FlowPay contract ID. + * KEEPER_SECRET Required. Stellar secret key (S...) funding keeper txns. + * RPC_URL Soroban RPC endpoint (default: testnet). + * NETWORK_PASSPHRASE Stellar network passphrase (default: testnet). + * CHARGE_INTERVAL_MS Milliseconds between full charge cycles (default: 3600000 = 1 h). + * PAGE_SIZE Subscriptions per batch_charge call (default: 100, max: 100). + * MAX_RETRIES Per-page retry attempts before skipping (default: 3). + * LOG_LEVEL debug | info | warn | error (default: info). + * + * Exit codes + * ────────── + * 0 — graceful shutdown (SIGINT / SIGTERM) + * 1 — fatal configuration error + */ + * keeper.ts — PayFlow Keeper Bot * * Processes recurring payments by calling batch_charge() on a regular interval. @@ -44,6 +77,154 @@ import { TransactionBuilder, BASE_FEE, nativeToScVal, + xdr, +} from "@stellar/stellar-sdk"; +import { Server, assembleTransaction } from "@stellar/stellar-sdk/rpc"; + +// ── Configuration ───────────────────────────────────────────────────────────── + +const CONTRACT_ID = process.env.CONTRACT_ID ?? ""; +const KEEPER_SECRET = process.env.KEEPER_SECRET ?? ""; +const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; +const NETWORK_PASSPHRASE = (process.env.NETWORK_PASSPHRASE ?? + Networks.TESTNET) as string; +const CHARGE_INTERVAL_MS = parseInt( + process.env.CHARGE_INTERVAL_MS ?? "3600000", + 10, +); +const PAGE_SIZE = Math.min(parseInt(process.env.PAGE_SIZE ?? "100", 10), 100); +const MAX_RETRIES = parseInt(process.env.MAX_RETRIES ?? "3", 10); +const LOG_LEVEL = (process.env.LOG_LEVEL ?? "info") as + "debug" | "info" | "warn" | "error"; + +// ── Startup validation ──────────────────────────────────────────────────────── + +if (!CONTRACT_ID) { + console.error("FATAL: CONTRACT_ID environment variable is required."); + process.exit(1); +} +if (!KEEPER_SECRET) { + console.error("FATAL: KEEPER_SECRET environment variable is required."); + process.exit(1); +} + +let keeperKeypair: Keypair; +try { + keeperKeypair = Keypair.fromSecret(KEEPER_SECRET); +} catch { + console.error("FATAL: KEEPER_SECRET is not a valid Stellar secret key."); + process.exit(1); +} + +// ── Logging ─────────────────────────────────────────────────────────────────── + +const LEVEL_ORDER: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; +const activeLevel = LEVEL_ORDER[LOG_LEVEL] ?? 1; + +function log( + level: "debug" | "info" | "warn" | "error", + msg: string, + meta?: Record, +): void { + if ((LEVEL_ORDER[level] ?? 0) < activeLevel) return; + const entry = { + ts: new Date().toISOString(), + level, + msg, + ...(meta ?? {}), + }; + const line = JSON.stringify(entry); + if (level === "error" || level === "warn") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} + +// ── RPC client ──────────────────────────────────────────────────────────────── + +const server = new Server(RPC_URL); +const contract = new Contract(CONTRACT_ID); + +// ── Charge result types ─────────────────────────────────────────────────────── + +interface PageSummary { + page: number; + offset: number; + charged: number; + skipped: number; + error: string | null; +} + +interface CycleSummary { + cycle: number; + started_at: string; + finished_at: string; + duration_ms: number; + total_charged: number; + total_skipped: number; + pages_processed: number; + pages_failed: number; +} + +// ── Batch charge execution ──────────────────────────────────────────────────── + +/** + * Parse the `Vec` returned by `batch_charge`. + * ChargeResult is an enum: Charged | Skipped(reason) | NoSubscription. + * We only need the counts here. + */ +function parseChargeResults(retval: xdr.ScVal): { + charged: number; + skipped: number; +} { + let charged = 0; + let skipped = 0; + + const vec = retval.vec(); + if (!vec) return { charged, skipped }; + + for (const item of vec) { + try { + const name = item.switch().name; + // scvVec wraps enum variants; check the inner sym name + if (name === "scvVec") { + const inner = item.vec(); + const variant = inner?.[0]?.sym()?.toString() ?? ""; + if (variant === "Charged") charged++; + else skipped++; + } else if (name === "scvMap") { + // Some SDK versions wrap enum as a map + const key = item.map()?.[0]?.key()?.sym()?.toString() ?? ""; + if (key === "Charged") charged++; + else skipped++; + } else { + // Unrecognised shape — count as skipped + skipped++; + } + } catch { + skipped++; + } + } + + return { charged, skipped }; +} + +/** + * Submit one `batch_charge(offset, limit)` transaction and return the result. + * Throws on RPC / submission error so the caller can retry. + */ +async function batchChargePage( + offset: number, + limit: number, +): Promise<{ charged: number; skipped: number }> { + const account = await server.getAccount(keeperKeypair.publicKey()); + Address, xdr, } from "@stellar/stellar-sdk"; @@ -341,6 +522,220 @@ async function submitBatchCharge(users: string[]): Promise<{ fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE, }) + .addOperation( + contract.call( + "batch_charge", + nativeToScVal(offset, { type: "u32" }), + nativeToScVal(limit, { type: "u32" }), + ), + ) + .setTimeout(60) + .build(); + + // Simulate to populate the Soroban footprint. + const simResult = await server.simulateTransaction(tx); + if ("error" in simResult) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + + // Assemble and sign. + const assembled = assembleTransaction(tx, simResult).build(); + assembled.sign(keeperKeypair); + + // Submit and wait for confirmation. + const sendResult = await server.sendTransaction(assembled); + if (sendResult.status === "ERROR") { + throw new Error(`Transaction rejected: ${JSON.stringify(sendResult)}`); + } + + // Poll for final status. + const hash = sendResult.hash; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + await sleep(2000); + const status = await server.getTransaction(hash); + if (status.status === "SUCCESS") { + const retval = (status as { returnValue?: xdr.ScVal }).returnValue; + if (!retval) return { charged: 0, skipped: 0 }; + return parseChargeResults(retval); + } + if (status.status === "FAILED") { + throw new Error(`Transaction failed on-chain: ${hash}`); + } + // status === "NOT_FOUND" means still pending — keep polling + } + + throw new Error(`Transaction ${hash} not confirmed within 30 s`); +} + +// ── Retry with exponential back-off ────────────────────────────────────────── + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Attempt `batchChargePage` up to `MAX_RETRIES` times with exponential back-off. + * Returns a PageSummary. On exhausted retries, `error` field is set. + */ +async function chargePageWithRetry( + page: number, + offset: number, +): Promise { + let lastError: string = ""; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const { charged, skipped } = await batchChargePage(offset, PAGE_SIZE); + return { page, offset, charged, skipped, error: null }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + const backoff = Math.min(1000 * 2 ** (attempt - 1), 30_000); + log("warn", `Page ${page} attempt ${attempt}/${MAX_RETRIES} failed`, { + offset, + error: lastError, + retry_in_ms: backoff, + }); + if (attempt < MAX_RETRIES) await sleep(backoff); + } + } + + return { page, offset, charged: 0, skipped: 0, error: lastError }; +} + +// ── Full charge cycle ───────────────────────────────────────────────────────── + +let cycleCount = 0; + +async function runChargeCycle(): Promise { + cycleCount++; + const cycleStart = Date.now(); + const startedAt = new Date(cycleStart).toISOString(); + + log("info", "Charge cycle starting", { cycle: cycleCount }); + + let totalCharged = 0; + let totalSkipped = 0; + let pagesProcessed = 0; + let pagesFailed = 0; + + let offset = 0; + let page = 0; + + while (true) { + const summary = await chargePageWithRetry(page, offset); + pagesProcessed++; + + if (summary.error) { + pagesFailed++; + log("error", "Page failed after all retries — skipping", { + cycle: cycleCount, + page, + offset, + error: summary.error, + }); + } else { + totalCharged += summary.charged; + totalSkipped += summary.skipped; + log("debug", "Page processed", { + cycle: cycleCount, + page, + offset, + charged: summary.charged, + skipped: summary.skipped, + }); + } + + // End-of-list detection: if the page returned fewer results than PAGE_SIZE + // (including 0) we have consumed all subscribers. + const pageTotal = summary.charged + summary.skipped; + if (pageTotal < PAGE_SIZE) { + log("debug", "Last page reached", { + cycle: cycleCount, + page, + page_total: pageTotal, + }); + break; + } + + offset += PAGE_SIZE; + page++; + } + + const finishedAt = new Date().toISOString(); + const durationMs = Date.now() - cycleStart; + + const cycleSummary: CycleSummary = { + cycle: cycleCount, + started_at: startedAt, + finished_at: finishedAt, + duration_ms: durationMs, + total_charged: totalCharged, + total_skipped: totalSkipped, + pages_processed: pagesProcessed, + pages_failed: pagesFailed, + }; + + log( + "info", + "Charge cycle complete", + cycleSummary as unknown as Record, + ); + return cycleSummary; +} + +// ── Main loop ───────────────────────────────────────────────────────────────── + +async function main(): Promise { + log("info", "FlowPay Keeper starting", { + contract: CONTRACT_ID, + keeper: keeperKeypair.publicKey(), + rpc: RPC_URL, + charge_interval_ms: CHARGE_INTERVAL_MS, + page_size: PAGE_SIZE, + max_retries: MAX_RETRIES, + }); + + let shutdown = false; + const onSignal = (): void => { + log( + "info", + "Shutdown signal received — finishing current cycle then exiting.", + ); + shutdown = true; + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + while (!shutdown) { + try { + await runChargeCycle(); + } catch (err) { + // Unexpected error in the cycle loop itself — log and continue. + log("error", "Unexpected error in charge cycle", { + error: err instanceof Error ? err.message : String(err), + }); + } + + if (!shutdown) { + log("info", `Sleeping ${CHARGE_INTERVAL_MS} ms until next cycle.`); + await sleep(CHARGE_INTERVAL_MS); + } + } + + log("info", "Keeper stopped gracefully."); + process.exit(0); +} + +main().catch((err: unknown) => { + console.error( + JSON.stringify({ + ts: new Date().toISOString(), + level: "error", + msg: "Fatal unhandled error", + error: err instanceof Error ? err.message : String(err), + }), + ); .addOperation(contract.call("batch_charge", usersVec)) .setTimeout(30) .build(); diff --git a/scripts/package.json b/scripts/package.json index d459439..b4568c0 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,9 @@ "check-allowances": "tsx check-allowances.ts", "alert-expiring-allowances": "tsx alert-expiring-allowances.ts", "indexer": "tsx indexer.ts", + "query-events": "tsx query-events.ts", + "keeper": "tsx keeper.ts", + "build": "tsc -p tsconfig.build.json", "query-events": "tsx query-events.ts" "batch-optimizer": "tsx batch-optimizer.ts", "keeper": "tsx keeper.ts", diff --git a/scripts/tsconfig.build.json b/scripts/tsconfig.build.json new file mode 100644 index 0000000..3001402 --- /dev/null +++ b/scripts/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "module": "Node16", + "moduleResolution": "node16" + }, + "include": ["keeper.ts"] +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 18a918e..c02be63 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -9,9 +9,9 @@ "resolveJsonModule": true, "strict": true, "skipLibCheck": true, - "noEmit": true, "types": ["node"], "typeRoots": ["./node_modules/@types"] }, - "include": ["*.ts"] + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] }