diff --git a/.github/workflows/openapi-publish.yml b/.github/workflows/openapi-publish.yml
new file mode 100644
index 0000000..9a6a68e
--- /dev/null
+++ b/.github/workflows/openapi-publish.yml
@@ -0,0 +1,54 @@
+name: OpenAPI publish
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ publish:
+ name: Generate and publish OpenAPI
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ - run: npm ci
+ - run: npm run openapi:gen
+ - run: |
+ mkdir -p ./openapi-site
+ cp openapi.json ./openapi-site/openapi.json
+ cat > ./openapi-site/index.html <<'EOF'
+
+
+
+
+
+ Lens OpenAPI
+
+
+
+
+
+
+
+
+ EOF
+ - uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./openapi-site
+ publish_branch: gh-pages
+ user_name: 'github-actions[bot]'
+ user_email: 'github-actions[bot]@users.noreply.github.com'
diff --git a/Dockerfile b/Dockerfile
index dc1eef4..4f6b66d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Stage 1: Build
-FROM node:20-alpine AS builder
+FROM node:20-slim AS builder
WORKDIR /app
@@ -13,12 +13,12 @@ COPY . .
RUN npm run build
# Stage 2: Run
-FROM node:20-alpine
+FROM node:20-slim
WORKDIR /app
# Install curl for healthcheck
-RUN apk add --no-cache curl
+RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Copy production dependencies
COPY package*.json ./
diff --git a/README.md b/README.md
index 051fe8f..2d6e703 100644
--- a/README.md
+++ b/README.md
@@ -158,7 +158,7 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph
## Documentation
Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md).
-The API specification is available in [OpenAPI 3.0 format](openapi.yaml).
+The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json.
## Examples
diff --git a/docker-compose.yml b/docker-compose.yml
index b62444d..f81b512 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: "3.9"
-
services:
lens:
build: .
@@ -9,6 +7,7 @@ services:
- DATABASE_URL=postgresql://lens:lens@postgres:5432/lens
- REDIS_URL=redis://redis:6379
- WATCHED_PAIRS=XLM:native/USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
+ - REQUIRE_API_KEY=false
depends_on:
postgres:
condition: service_healthy
diff --git a/openapi.json b/openapi.json
new file mode 100644
index 0000000..fcf9018
--- /dev/null
+++ b/openapi.json
@@ -0,0 +1,798 @@
+{
+ "openapi": "3.0.0",
+ "info": {
+ "title": "Lens API",
+ "description": "Unified Stellar price API aggregating SDEX + AMM prices.",
+ "version": "0.1.0",
+ "license": {
+ "name": "MIT",
+ "url": "https://opensource.org/licenses/MIT"
+ }
+ },
+ "servers": [
+ {
+ "url": "http://localhost:4000",
+ "description": "Local development server"
+ }
+ ],
+ "paths": {
+ "/status": {
+ "get": {
+ "operationId": "getStatus",
+ "summary": "Get API status and indexer state",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "API status",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ },
+ "watchedPairs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "lastIndexedLedger": {
+ "type": "integer",
+ "nullable": true
+ },
+ "lastProcessedAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/price/{assetA}/{assetB}": {
+ "get": {
+ "operationId": "getPrice",
+ "summary": "Get aggregated price for a pair",
+ "security": [
+ {
+ "x402": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "assetA",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "XLM"
+ },
+ {
+ "name": "assetB",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "USDC"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Aggregated price data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AggregatedPrice"
+ }
+ }
+ }
+ },
+ "402": {
+ "$ref": "#/components/responses/PaymentRequired"
+ },
+ "404": {
+ "description": "Pair not watched"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/price/{assetA}/{assetB}/route": {
+ "get": {
+ "operationId": "getBestRoute",
+ "summary": "Get best swap route for a pair and amount",
+ "security": [
+ {
+ "x402": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "assetA",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "assetB",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "amount",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "number",
+ "default": 1000
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Best route information",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RouteInfo"
+ }
+ }
+ }
+ },
+ "402": {
+ "$ref": "#/components/responses/PaymentRequired"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/price/{assetA}/{assetB}/history": {
+ "get": {
+ "operationId": "getPriceHistory",
+ "summary": "Get price history buckets",
+ "security": [],
+ "parameters": [
+ {
+ "name": "assetA",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "assetB",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "window",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "1m",
+ "5m",
+ "1h",
+ "24h"
+ ],
+ "default": "1h"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 100,
+ "maximum": 1000
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Price history",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "pairKey": {
+ "type": "string"
+ },
+ "window": {
+ "type": "string"
+ },
+ "buckets": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PriceBucket"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid parameters"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/pools": {
+ "get": {
+ "operationId": "getPools",
+ "summary": "Get AMM liquidity pool reserves and spot prices",
+ "security": [
+ {
+ "x402": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of pools",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "pools": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "pool_id": {
+ "type": "string"
+ },
+ "asset_a": {
+ "type": "string"
+ },
+ "asset_b": {
+ "type": "string"
+ },
+ "reserve_a": {
+ "type": "number"
+ },
+ "reserve_b": {
+ "type": "number"
+ },
+ "spot_price": {
+ "type": "number"
+ },
+ "fee_bp": {
+ "type": "integer"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "402": {
+ "$ref": "#/components/responses/PaymentRequired"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/candles/{assetA}/{assetB}": {
+ "get": {
+ "operationId": "getCandles",
+ "summary": "Get OHLCV candle data",
+ "security": [
+ {
+ "x402": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "assetA",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "assetB",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "interval",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "1m",
+ "5m",
+ "15m",
+ "1h",
+ "4h",
+ "1d"
+ ],
+ "default": "1h"
+ }
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Candle data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "pairKey": {
+ "type": "string"
+ },
+ "interval": {
+ "type": "string"
+ },
+ "from": {
+ "type": "string"
+ },
+ "to": {
+ "type": "string"
+ },
+ "candles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "time": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "open": {
+ "type": "number"
+ },
+ "high": {
+ "type": "number"
+ },
+ "low": {
+ "type": "number"
+ },
+ "close": {
+ "type": "number"
+ },
+ "volume": {
+ "type": "number"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid parameters"
+ },
+ "402": {
+ "$ref": "#/components/responses/PaymentRequired"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ },
+ "/pairs": {
+ "get": {
+ "operationId": "listPairs",
+ "summary": "List all active trading pairs",
+ "security": [],
+ "responses": {
+ "200": {
+ "description": "List of pairs",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "pairs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "pairKey": {
+ "type": "string"
+ },
+ "assetA": {
+ "$ref": "#/components/schemas/Asset"
+ },
+ "assetB": {
+ "$ref": "#/components/schemas/Asset"
+ },
+ "latestPrice": {
+ "type": "number",
+ "nullable": true
+ },
+ "lastUpdated": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ },
+ "post": {
+ "operationId": "addPair",
+ "summary": "Add a new trading pair (Admin only)",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "assetA": {
+ "type": "string",
+ "example": "XLM"
+ },
+ "assetB": {
+ "type": "string",
+ "example": "USDC:GBBD47..."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Pair added"
+ },
+ "400": {
+ "description": "Invalid input"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "409": {
+ "description": "Pair already exists"
+ },
+ "429": {
+ "$ref": "#/components/responses/TooManyRequests"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "x402": {
+ "type": "apiKey",
+ "name": "X-PAYMENT",
+ "in": "header",
+ "description": "Base64 encoded JSON payment proof for x402 micropayments."
+ },
+ "adminAuth": {
+ "type": "apiKey",
+ "name": "X-Admin-Key",
+ "in": "header"
+ }
+ },
+ "responses": {
+ "PaymentRequired": {
+ "description": "Payment Required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "x402Version": {
+ "type": "integer"
+ },
+ "accepts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "scheme": {
+ "type": "string"
+ },
+ "price": {
+ "type": "string"
+ },
+ "network": {
+ "type": "string"
+ },
+ "payTo": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "error": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "TooManyRequests": {
+ "description": "Rate limit exceeded",
+ "headers": {
+ "Retry-After": {
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Seconds to wait before retrying"
+ },
+ "X-RateLimit-Remaining": {
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Remaining requests in current window"
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "statusCode": {
+ "type": "integer"
+ },
+ "error": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "retryAfter": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "schemas": {
+ "Asset": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string"
+ },
+ "issuer": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "AggregatedPrice": {
+ "type": "object",
+ "properties": {
+ "assetA": {
+ "type": "string"
+ },
+ "assetB": {
+ "type": "string"
+ },
+ "pairKey": {
+ "type": "string"
+ },
+ "price": {
+ "type": "number"
+ },
+ "sdexPrice": {
+ "type": "number"
+ },
+ "ammPrice": {
+ "type": "number"
+ },
+ "bestRoute": {
+ "type": "string",
+ "enum": [
+ "SDEX",
+ "AMM",
+ "SPLIT",
+ "UNKNOWN"
+ ]
+ },
+ "volume24h": {
+ "type": "number"
+ },
+ "sdexVolume24h": {
+ "type": "number"
+ },
+ "ammVolume24h": {
+ "type": "number"
+ },
+ "vwap1m": {
+ "type": "number"
+ },
+ "vwap5m": {
+ "type": "number"
+ },
+ "vwap1h": {
+ "type": "number"
+ },
+ "vwap24h": {
+ "type": "number"
+ },
+ "priceChange24h": {
+ "type": "number"
+ },
+ "lastUpdated": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "sources": {
+ "type": "integer"
+ },
+ "confidence": {
+ "type": "string",
+ "enum": [
+ "high",
+ "medium",
+ "low",
+ "unknown"
+ ]
+ },
+ "lastTradeAgeSeconds": {
+ "type": "integer",
+ "nullable": true
+ }
+ }
+ },
+ "RouteInfo": {
+ "type": "object",
+ "properties": {
+ "route": {
+ "type": "string",
+ "enum": [
+ "SDEX",
+ "AMM",
+ "SPLIT",
+ "UNKNOWN"
+ ]
+ },
+ "sdexPrice": {
+ "type": "number"
+ },
+ "ammPrice": {
+ "type": "number"
+ },
+ "estimatedOutput": {
+ "type": "number"
+ },
+ "slippagePct": {
+ "type": "number"
+ },
+ "recommendation": {
+ "type": "string"
+ }
+ }
+ },
+ "PriceBucket": {
+ "type": "object",
+ "properties": {
+ "bucket": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "window": {
+ "type": "string"
+ },
+ "vwap": {
+ "type": "number"
+ },
+ "sdexVwap": {
+ "type": "number",
+ "nullable": true
+ },
+ "ammVwap": {
+ "type": "number",
+ "nullable": true
+ },
+ "volume": {
+ "type": "number"
+ },
+ "tradeCount": {
+ "type": "integer"
+ },
+ "open": {
+ "type": "number",
+ "nullable": true
+ },
+ "close": {
+ "type": "number",
+ "nullable": true
+ },
+ "high": {
+ "type": "number",
+ "nullable": true
+ },
+ "low": {
+ "type": "number",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 418b6e6..c6e3b6e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -34,6 +34,8 @@
"@types/pg": "^8.0.0",
"@types/uuid": "^9.0.0",
"@vitest/coverage-v8": "^4.1.5",
+ "fast-check": "^3.0.0",
+ "js-yaml": "^4.2.0",
"prisma": "^5.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
@@ -352,6 +354,29 @@
"prettier": "^2.7.1"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -2013,7 +2038,6 @@
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2043,7 +2067,6 @@
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
@@ -3149,6 +3172,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@@ -3482,7 +3528,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -3636,7 +3681,6 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
"integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
"license": "MIT",
- "peer": true,
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
@@ -4012,10 +4056,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4949,7 +5003,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -5047,7 +5100,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -5202,7 +5254,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -5290,6 +5341,23 @@
"pump": "^3.0.0"
}
},
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/qlobber": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz",
@@ -6101,7 +6169,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -6198,7 +6265,6 @@
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -6277,7 +6343,6 @@
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
diff --git a/package.json b/package.json
index fe987ea..ea78527 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"build": "node node_modules/prisma/build/index.js generate && tsc",
"start": "node dist/index.js",
"test": "vitest run",
+ "openapi:gen": "tsx scripts/generate-openapi.ts",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset tag",
@@ -43,6 +44,8 @@
"@types/pg": "^8.0.0",
"@types/uuid": "^9.0.0",
"@vitest/coverage-v8": "^4.1.5",
+ "fast-check": "^3.0.0",
+ "js-yaml": "^4.2.0",
"prisma": "^5.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0",
diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts
new file mode 100644
index 0000000..49d4294
--- /dev/null
+++ b/scripts/generate-openapi.ts
@@ -0,0 +1,9 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+import YAML from 'js-yaml'
+
+const sourcePath = new URL('../openapi.yaml', import.meta.url)
+const destinationPath = new URL('../openapi.json', import.meta.url)
+const contents = readFileSync(sourcePath, 'utf8')
+const document = YAML.load(contents)
+writeFileSync(destinationPath, JSON.stringify(document, null, 2) + '\n')
+console.log('Generated openapi.json')
diff --git a/src/alerts/threshold.ts b/src/alerts/threshold.ts
index fa15927..882aa9f 100644
--- a/src/alerts/threshold.ts
+++ b/src/alerts/threshold.ts
@@ -19,11 +19,19 @@ export interface ThresholdAlertPayload {
timestamp: string
}
+function isThresholdDirection(value: string): value is ThresholdDirection {
+ return value === 'above' || value === 'below'
+}
+
export function crossesThreshold(
- subscription: Pick,
+ subscription: Pick & { direction: string },
previousPrice: number,
currentPrice: number,
): boolean {
+ if (!isThresholdDirection(subscription.direction)) {
+ throw new Error(`Unsupported threshold direction: ${subscription.direction}`)
+ }
+
if (subscription.direction === 'above') {
return previousPrice < subscription.threshold && currentPrice >= subscription.threshold
}
@@ -32,12 +40,16 @@ export function crossesThreshold(
}
export function buildThresholdAlertPayload(
- subscription: Pick,
+ subscription: Pick & { direction: string },
assetA: string,
assetB: string,
price: number,
timestamp = new Date().toISOString(),
): ThresholdAlertPayload {
+ if (!isThresholdDirection(subscription.direction)) {
+ throw new Error(`Unsupported threshold direction: ${subscription.direction}`)
+ }
+
return {
assetA,
assetB,
diff --git a/src/index.ts b/src/index.ts
index 17c5bf4..cb8d58b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,10 @@
import 'dotenv/config'
import { execSync } from 'child_process'
import Fastify from 'fastify'
+
+if (!process.env.DIRECT_DATABASE_URL && process.env.DATABASE_URL) {
+ process.env.DIRECT_DATABASE_URL = process.env.DATABASE_URL
+}
import cors from '@fastify/cors'
import compress from '@fastify/compress'
import rateLimit from '@fastify/rate-limit'
diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts
new file mode 100644
index 0000000..a842e61
--- /dev/null
+++ b/tests/aggregator.property.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import fc from 'fast-check'
+import { getBestRoute } from '../src/aggregator/bestRoute'
+import { pgPool } from '../src/db'
+import * as StellarSdk from '@stellar/stellar-sdk'
+
+vi.mock('../src/db', () => ({
+ pgPool: {
+ query: vi.fn(),
+ },
+}))
+
+vi.mock('@stellar/stellar-sdk', () => {
+ const callFn = vi.fn()
+ return {
+ Horizon: {
+ Server: vi.fn(function () {
+ return {
+ strictSendPaths: vi.fn().mockReturnThis(),
+ call: callFn,
+ }
+ }),
+ },
+ Asset: Object.assign(
+ vi.fn(function (code: string, issuer: string | null) {
+ return { code, issuer }
+ }),
+ { native: vi.fn(() => 'native') }
+ ),
+ __mockCall: callFn,
+ }
+})
+
+describe('Price aggregator property tests', () => {
+ const assetA = { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }
+ const assetB = { code: 'XLM', issuer: null }
+ const pairKey = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM'
+ const mockQuery = vi.mocked(pgPool.query)
+ const mockCall = (StellarSdk as any).__mockCall as ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('produces valid route results for random venue prices', async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }),
+ fc.float({ min: 0, max: 2000, noNaN: true, noDefaultInfinity: true, noNegativeZero: true }),
+ fc.integer({ min: 1, max: 20000 }),
+ async (sdexPrice, ammPrice, amount) => {
+ const feeBp = 30
+ const fee = 1 - feeBp / 10000
+
+ if (sdexPrice <= 0) {
+ mockCall.mockResolvedValue({ records: [] })
+ } else {
+ mockCall.mockResolvedValue({ records: [{ destination_amount: String(sdexPrice * amount) }] })
+ }
+
+ if (ammPrice <= 0) {
+ mockQuery.mockResolvedValue({ rows: [] } as any)
+ } else {
+ const reserveA = 10000
+ const reserveB = String((ammPrice * (reserveA + amount * fee)) / fee)
+ mockQuery.mockResolvedValue({ rows: [{ reserve_a: String(reserveA), reserve_b: reserveB, fee_bp: String(feeBp) }] } as any)
+ }
+
+ if (sdexPrice === 0 && ammPrice === 0) {
+ await expect(getBestRoute(assetA, assetB, pairKey, amount)).rejects.toThrow('No pricing data available')
+ return
+ }
+
+ const result = await getBestRoute(assetA, assetB, pairKey, amount)
+ expect(result.sdexPrice).toBeGreaterThanOrEqual(0)
+ expect(result.ammPrice).toBeGreaterThanOrEqual(0)
+ expect(['SDEX', 'AMM', 'SPLIT', 'UNKNOWN']).toContain(result.route)
+ expect(result.estimatedOutput).toBeGreaterThanOrEqual(0)
+ expect(result.slippagePct).toBeGreaterThanOrEqual(0)
+ expect(result.slippagePct).toBeCloseTo(0, 6)
+
+ if (sdexPrice === 0) {
+ expect(result.route).toBe('AMM')
+ expect(result.estimatedOutput).toBeCloseTo(ammPrice * amount, 6)
+ }
+ if (ammPrice === 0) {
+ expect(result.route).toBe('SDEX')
+ expect(result.estimatedOutput).toBeCloseTo(sdexPrice * amount, 6)
+ }
+ if (sdexPrice > 0 && ammPrice > 0) {
+ const diff = Math.abs(sdexPrice - ammPrice) / Math.max(sdexPrice, ammPrice)
+ if (diff < 0.001) {
+ if (amount > 10000) {
+ expect(result.route).toBe('SPLIT')
+ } else {
+ expect(['SDEX', 'AMM']).toContain(result.route)
+ }
+ } else if (sdexPrice > ammPrice) {
+ expect(result.route).toBe('SDEX')
+ } else {
+ expect(result.route).toBe('AMM')
+ }
+ expect(result.estimatedOutput).toBeCloseTo(Math.max(sdexPrice, ammPrice) * amount, 6)
+ }
+ }
+ ),
+ { numRuns: 10000 }
+ )
+ })
+})
diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts
new file mode 100644
index 0000000..d277d47
--- /dev/null
+++ b/tests/integration/e2e.test.ts
@@ -0,0 +1,85 @@
+import { beforeAll, afterAll, describe, it, expect } from 'vitest'
+import { execSync } from 'node:child_process'
+
+const COMPOSE_FILE = 'docker-compose.yml'
+const API_URL = 'http://127.0.0.1:3002'
+const PAIR_KEY = 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5/XLM'
+
+function run(command: string) {
+ execSync(command, { stdio: 'inherit' })
+}
+
+function runCapture(command: string) {
+ return execSync(command, { encoding: 'utf8' }).trim()
+}
+
+async function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+async function waitForDbRows(pairKey: string, poolId: string, timeoutMs = 30_000) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ try {
+ const priceCount = parseInt(runCapture(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -t -A -c "SELECT count(*) FROM price_points WHERE pair_key = '${pairKey}' AND source = 'AMM' AND pool_id = '${poolId}'"`), 10)
+ const poolCount = parseInt(runCapture(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -t -A -c "SELECT count(*) FROM pool_snapshots WHERE pool_id = '${poolId}' AND id = 'test-snapshot-1'"`), 10)
+ if (priceCount >= 1 && poolCount >= 1) return
+ } catch {
+ // retry until timeout
+ }
+ await sleep(1000)
+ }
+ throw new Error('Test rows were not visible in the database before querying Lens')
+}
+
+function clearPriceCache(pairKey: string) {
+ run(`docker compose -f ${COMPOSE_FILE} exec -T redis redis-cli DEL "lens:price:${pairKey}"`)
+}
+
+async function waitForService(timeoutMs = 120_000) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`${API_URL}/status`)
+ if (response.ok) return
+ } catch {
+ // retry
+ }
+ await sleep(2000)
+ }
+ throw new Error('Lens service did not become healthy in time')
+}
+
+describe('Docker Compose end-to-end ingest → query', () => {
+ beforeAll(async () => {
+ run(`docker compose -f ${COMPOSE_FILE} down --volumes --remove-orphans`)
+ run(`docker compose -f ${COMPOSE_FILE} up -d --build`)
+ await waitForService()
+
+ run(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -c "INSERT INTO price_points (id, asset_a, asset_b, pair_key, source, pool_id, price, base_volume, counter_volume, ledger, timestamp) VALUES ('test-price-point-1', 'XLM', 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', '${PAIR_KEY}', 'AMM', 'test-pool-1', '1.2345', '1000', '1234.5', 12345, now())"`)
+ run(`docker compose -f ${COMPOSE_FILE} exec -T postgres psql -U lens -d lens -c "INSERT INTO pool_snapshots (id, pool_id, asset_a, asset_b, reserve_a, reserve_b, spot_price, total_shares, fee_bp, ledger, timestamp) VALUES ('test-snapshot-1', 'test-pool-1', 'XLM', 'USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', '10000', '12345', '1.2345', '100000', 30, 12345, now())"`)
+ await waitForDbRows(PAIR_KEY, 'test-pool-1')
+ clearPriceCache(PAIR_KEY)
+ await sleep(2000)
+ }, 180000)
+
+ afterAll(() => {
+ run(`docker compose -f ${COMPOSE_FILE} down --volumes --remove-orphans`)
+ })
+
+ it('starts Lens, ingests test market data, and returns query results', async () => {
+ const response = await fetch(`${API_URL}/price/XLM/USDC`)
+ const body = await response.json()
+ console.log('E2E RESPONSE', { status: response.status, body })
+
+ expect(response.ok).toBe(true)
+ expect(body).toHaveProperty('assetA', 'XLM')
+ expect(body).toHaveProperty('assetB', 'USDC')
+ expect(body).toHaveProperty('pairKey', expect.stringContaining('USDC'))
+ expect(body).toHaveProperty('price')
+ expect(body.price).toBeGreaterThan(0)
+ expect(body).toHaveProperty('ammPrice')
+ expect(body.ammPrice).toBeGreaterThan(0)
+ expect(['SDEX', 'AMM', 'SPLIT', 'UNKNOWN']).toContain(body.bestRoute)
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index c474d10..1761797 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,7 +4,7 @@ export default defineConfig({
test: {
environment: 'node',
globals: true,
- include: ['src/**/*.test.ts'],
+ include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
exclude: ['dist/**', 'node_modules/**'],
},
})
\ No newline at end of file