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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ STELLAR_NETWORK=testnet
# When unset (or nothing valid remains after parsing) it falls back to just
# STELLAR_NETWORK, so a single-network node starts one instance of each ingester.
ENABLED_NETWORKS=testnet
# Strict network predicate enforcement on database queries (default: true)
STRICT_NETWORK_CHECK=true

# ─────────────────────────────────────────────────────────────────────────────
# Per-network Stellar configuration
Expand Down Expand Up @@ -201,6 +203,10 @@ FACILITATOR_SECRET_KEY=
# Maximum fee in stroops the facilitator will pay (default 50000).
FACILITATOR_FEE_STROOPS_TESTNET=50000
FACILITATOR_FEE_STROOPS_MAINNET=50000
# Maximum spend ceiling in base units/stroops allowed across a single settlement payload
FACILITATOR_SPEND_CEILING=1000000
# Optional comma-separated list of allowed caller / payTo account addresses for settlement
FACILITATOR_CALLER_ALLOWLIST=

# --- MCP server (npm run mcp:server) ---
# The agent's own x402 payer keys, used to answer a 402 challenge when paid_call
Expand Down
148 changes: 148 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,30 @@
}
}
},
"/venues/status": {
"get": {
"operationId": "getVenuesStatus",
"summary": "Get live health status of all ingestion venues",
"responses": {
"200": {
"description": "Health and heartbeat status for all registered venues",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/price/{assetA}/{assetB}": {
"get": {
"operationId": "getPrice",
Expand Down Expand Up @@ -389,6 +413,48 @@
}
}
},
"/price/{assetA}/{assetB}/reconciliation": {
"get": {
"operationId": "getPriceReconciliation",
"summary": "Cross-venue price reconciliation and divergence monitoring",
"parameters": [
{
"name": "assetA",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "assetB",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/NetworkQuery"
}
],
"responses": {
"200": {
"description": "Price reconciliation comparison across venues",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/pools": {
"get": {
"operationId": "getPools",
Expand Down Expand Up @@ -1626,6 +1692,40 @@
}
}
},
"/blend/rates/{asset}": {
"get": {
"operationId": "getBlendRates",
"summary": "Get Blend lending and borrowing rates for an asset",
"parameters": [
{
"name": "asset",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/NetworkQuery"
}
],
"responses": {
"200": {
"description": "Blend lending market rates",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/discovery/resources": {
"get": {
"operationId": "listDiscoveryResources",
Expand Down Expand Up @@ -1714,6 +1814,54 @@
}
}
},
"/discovery/search": {
"get": {
"operationId": "searchDiscoveryResources",
"summary": "Full-text and metadata search for discoverable resources",
"parameters": [
{
"name": "q",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 20
}
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Search results with relevance ranking and metadata quality",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
}
}
},
"/webhooks": {
"post": {
"operationId": "createWebhook",
Expand Down
94 changes: 94 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/venues/status:
get:
operationId: getVenuesStatus
summary: Get live health status of all ingestion venues
responses:
'200':
description: Health and heartbeat status for all registered venues
content:
application/json:
schema:
type: array
items:
type: object
'429':
$ref: '#/components/responses/TooManyRequests'

/price/{assetA}/{assetB}:
get:
operationId: getPrice
Expand Down Expand Up @@ -251,6 +267,32 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/price/{assetA}/{assetB}/reconciliation:
get:
operationId: getPriceReconciliation
summary: Cross-venue price reconciliation and divergence monitoring
parameters:
- name: assetA
in: path
required: true
schema:
type: string
- name: assetB
in: path
required: true
schema:
type: string
- $ref: '#/components/parameters/NetworkQuery'
responses:
'200':
description: Price reconciliation comparison across venues
content:
application/json:
schema:
type: object
'429':
$ref: '#/components/responses/TooManyRequests'

/pools:
get:
operationId: getPools
Expand Down Expand Up @@ -1046,6 +1088,27 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/blend/rates/{asset}:
get:
operationId: getBlendRates
summary: Get Blend lending and borrowing rates for an asset
parameters:
- name: asset
in: path
required: true
schema:
type: string
- $ref: '#/components/parameters/NetworkQuery'
responses:
'200':
description: Blend lending market rates
content:
application/json:
schema:
type: object
'429':
$ref: '#/components/responses/TooManyRequests'

/discovery/resources:
get:
operationId: listDiscoveryResources
Expand Down Expand Up @@ -1106,6 +1169,37 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/discovery/search:
get:
operationId: searchDiscoveryResources
summary: Full-text and metadata search for discoverable resources
parameters:
- name: q
in: query
required: true
schema:
type: string
- name: limit
in: query
required: false
schema:
type: integer
default: 20
- name: cursor
in: query
required: false
schema:
type: string
responses:
'200':
description: Search results with relevance ranking and metadata quality
content:
application/json:
schema:
type: object
'429':
$ref: '#/components/responses/TooManyRequests'

/webhooks:
post:
operationId: createWebhook
Expand Down
67 changes: 67 additions & 0 deletions src/__tests__/backfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { mockQuery, mockUpsertPricePoints } = vi.hoisted(() => ({
mockQuery: vi.fn(),
mockUpsertPricePoints: vi.fn(),
}))

vi.mock('../db', () => ({
pgPool: { query: mockQuery },
upsertPricePoints: mockUpsertPricePoints,
}))

import { detectGaps, backfillRange } from '../ingest/backfill'

describe('Ingestion Gap Detection and Backfill (#241)', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('detects no gaps when timestamps are continuous within maxIntervalMinutes', async () => {
const t0 = new Date('2026-10-01T00:00:00Z')
const t1 = new Date('2026-10-01T00:30:00Z')
const t2 = new Date('2026-10-01T01:00:00Z')

mockQuery.mockResolvedValueOnce({
rows: [
{ timestamp: t0.toISOString(), source: 'SDEX' },
{ timestamp: t1.toISOString(), source: 'SDEX' },
{ timestamp: t2.toISOString(), source: 'SDEX' },
],
} as any)

const gaps = await detectGaps('XLM/USDC', 'testnet', t0, t2, 60)
expect(gaps.length).toBe(0)
})

it('detects a gap when consecutive timestamps exceed maxIntervalMinutes', async () => {
const t0 = new Date('2026-10-01T00:00:00Z')
const t1 = new Date('2026-10-01T04:00:00Z') // 4 hours gap

mockQuery.mockResolvedValueOnce({
rows: [
{ timestamp: t0.toISOString(), source: 'SDEX' },
{ timestamp: t1.toISOString(), source: 'SDEX' },
],
} as any)

const gaps = await detectGaps('XLM/USDC', 'testnet', t0, t1, 60)
expect(gaps.length).toBe(1)
expect(gaps[0].gapMinutes).toBe(240)
})

it('backfills missing ledger range and inserts price points', async () => {
mockUpsertPricePoints.mockResolvedValueOnce(10)

const summary = await backfillRange('XLM/USDC', 'testnet', 1000, 1100, 'SDEX')
expect(summary.status).toBe('COMPLETED')
expect(summary.pointsBackfilled).toBe(10)
expect(mockUpsertPricePoints).toHaveBeenCalled()
})

it('returns FAILED when toLedger is less than fromLedger', async () => {
const summary = await backfillRange('XLM/USDC', 'testnet', 1100, 1000, 'SDEX')
expect(summary.status).toBe('FAILED')
expect(summary.error).toBeDefined()
})
})
Loading