diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eaaac563..ae634399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,15 @@ jobs: run: npm run build working-directory: backend + - name: OpenAPI spec & API types drift check + run: | + cd backend + npm run codegen:openapi + cd ../frontend + npm run codegen:api-types + cd .. + git diff --exit-code -- backend/swagger/flowfi.openapi.json frontend/src/lib/api-types.generated.ts + - name: Install Rollup Native Binding run: npm install @rollup/rollup-linux-x64-gnu --no-save diff --git a/.github/workflows/deploy-contracts.yml b/.github/workflows/deploy-contracts.yml new file mode 100644 index 00000000..9fbe97d0 --- /dev/null +++ b/.github/workflows/deploy-contracts.yml @@ -0,0 +1,146 @@ +# Contract Deployment Workflow for FlowFi +# +# Compiles the Soroban stream contract to optimized WASM, runs contract tests, +# and deploys + initializes the contract on Stellar Testnet on demand (or on +# Mainnet for release tags). The resulting contract ID is surfaced in the job +# summary and published as a release artifact. +name: Deploy Soroban Contracts + +on: + release: + types: [published] + workflow_dispatch: + inputs: + network: + description: "Target network (testnet|mainnet)" + required: true + default: "testnet" + type: choice + options: + - testnet + - mainnet + +concurrency: + group: ${{ github.workflow }}-${{ inputs.network || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + deploy: + name: Build & Deploy stream_contract + runs-on: ubuntu-latest + environment: ${{ github.event_name == 'release' && 'production' || 'staging' }} + + env: + NETWORK: ${{ inputs.network || (github.event_name == 'release' && 'mainnet' || 'testnet') }} + DEPLOYER_SECRET: ${{ secrets.DEPLOYER_SECRET }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} + TREASURY_ADDRESS: ${{ secrets.TREASURY_ADDRESS }} + FEE_RATE_BPS: ${{ secrets.FEE_RATE_BPS }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspace: "contracts -> target" + + - name: Install Stellar CLI + run: | + curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps + echo "$HOME/.stellar-cli/bin" >> $GITHUB_PATH + + - name: Run Contract Tests + run: cargo test --package stream_contract + working-directory: contracts + + - name: Build & Optimize WASM + run: | + set -euo pipefail + cd contracts + cargo build --target wasm32-unknown-unknown --release + RELEASE_DIR="target/wasm32-unknown-unknown/release" + for w in "$RELEASE_DIR"/stream_contract.wasm; do + stellar contract optimize --wasm "$w" --wasm-out "$RELEASE_DIR/stream_contract.optimized.wasm" + done + ls -la "$RELEASE_DIR"/*.wasm + + - name: Inspect Contract Interface & WASM Size + run: | + set -euo pipefail + WASM=contracts/target/wasm32-unknown-unknown/release/stream_contract.optimized.wasm + stellar contract inspect --wasm "$WASM" + SIZE=$(stat -c%s "$WASM") + echo "Optimized WASM size: $SIZE bytes" + if [ "$SIZE" -ge 65536 ]; then + echo "ERROR: optimized WASM exceeds 64KB budget ($SIZE bytes)" + exit 1 + fi + echo "WASM_WASM_PATH=$WASM" >> $GITHUB_ENV + + - name: Deploy & Initialize Contract + run: ./scripts/deploy.sh --network "$NETWORK" + + - name: Read Deployed Contract ID + id: contract + run: | + set -euo pipefail + CONTRACT_ID=$(jq -r --arg net "$NETWORK" '.[$net].contractId' deployment-info.json) + echo "contract_id=$CONTRACT_ID" >> $GITHUB_OUTPUT + echo "deployment-json=$(jq -c . deployment-info.json)" >> $GITHUB_OUTPUT + + - name: Emit Deployment Summary + if: always() + run: | + { + echo "## Deployment Summary" + echo "" + echo "- **Network**: \`$NETWORK\`" + echo "- **Contract ID**: \`${{ steps.contract.outputs.contract_id }}\`" + echo "- **WASM**: \`${{ env.WASM_WASM_PATH }}\`" + echo "- **Deployment info**: " + echo '```json' + echo "${{ steps.contract.outputs.deployment-json }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Optimized WASM Artifact + uses: actions/upload-artifact@v4 + with: + name: stream-contract-${{ env.NETWORK }} + path: contracts/target/wasm32-unknown-unknown/optimized/*.wasm + if-no-files-found: error + + - name: Upload Deployment Info + uses: actions/upload-artifact@v4 + with: + name: deployment-info-${{ env.NETWORK }} + path: deployment-info.json + if-no-files-found: error + + - name: Commit Deployment Info + if: github.event_name == 'release' + env: + NETWORK: ${{ env.NETWORK }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add deployment-info.json + if git diff --cached --quiet; then + echo "No deployment-info.json changes to commit" + exit 0 + fi + git commit -m "chore(contracts): record $NETWORK contract deployment" + git push \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 38478593..2b66f802 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,8 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "prisma db seed", - "prisma:studio": "prisma studio" + "prisma:studio": "prisma studio", + "codegen:openapi": "tsx scripts/export-openapi.mts" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/backend/scripts/export-openapi.mts b/backend/scripts/export-openapi.mts new file mode 100644 index 00000000..5bf84b5a --- /dev/null +++ b/backend/scripts/export-openapi.mts @@ -0,0 +1,19 @@ +/** + * Export the OpenAPI spec to a committed JSON file so it can be consumed + * without booting the API server (e.g. by `openapi-typescript` codegen and the + * CI drift check). + * + * npm run codegen:openapi + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { swaggerSpec } from '../src/config/swagger.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = join(here, '..', 'swagger'); +const outFile = join(outDir, 'flowfi.openapi.json'); + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outFile, `${JSON.stringify(swaggerSpec, null, 2)}\n`); +console.log(`OpenAPI spec written to ${outFile}`); \ No newline at end of file diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 6aea103c..19c8f0b9 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -2,7 +2,7 @@ import swaggerJsdoc from 'swagger-jsdoc'; const options: swaggerJsdoc.Options = { definition: { - openapi: '3.0.0', + openapi: '3.1.0', info: { title: 'FlowFi API', version: '1.0.0', @@ -259,6 +259,224 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, }, }, }, + StreamListResponse: { + type: 'object', + required: ['data', 'total', 'hasMore', 'limit', 'offset'], + properties: { + data: { + type: 'array', + description: 'Streams matching the filter, sorted and paginated', + items: { $ref: '#/components/schemas/Stream' }, + }, + total: { type: 'integer', description: 'Total number of streams matching the filter' }, + hasMore: { type: 'boolean', description: 'Whether more results are available past this page' }, + limit: { type: 'integer', description: 'Page size applied (capped at MAX_STREAM_PAGE_SIZE)' }, + offset: { type: 'integer', description: 'Number of results skipped' }, + }, + }, + StreamEventListResponse: { + type: 'object', + required: ['data', 'total', 'hasMore'], + properties: { + data: { + type: 'array', + description: 'Events for the stream, sorted by timestamp (tie-broken by id)', + items: { $ref: '#/components/schemas/StreamEvent' }, + }, + total: { type: 'integer', description: 'Total number of events matching the filter' }, + hasMore: { type: 'boolean', description: 'Whether more results are available past this page' }, + }, + }, + EventListResponse: { + type: 'object', + required: ['events', 'total', 'limit', 'offset', 'hasMore'], + properties: { + events: { + type: 'array', + description: 'Reverse-chronological stream events for the wallet', + items: { $ref: '#/components/schemas/StreamEvent' }, + }, + total: { type: 'integer', description: 'Total number of matching events' }, + limit: { type: 'integer', description: 'Page size applied (capped at 200)' }, + offset: { type: 'integer', description: 'Number of events skipped' }, + hasMore: { type: 'boolean', description: 'Whether more results are available past this page' }, + }, + }, + UserEventListResponse: { + type: 'object', + required: ['data', 'total', 'hasMore', 'limit', 'offset'], + properties: { + data: { + type: 'array', + description: 'Events associated with the user, newest first', + items: { $ref: '#/components/schemas/StreamEvent' }, + }, + total: { type: 'integer', description: 'Total number of matching events' }, + hasMore: { type: 'boolean', description: 'Whether more results are available past this page' }, + limit: { type: 'integer', description: 'Page size applied (capped at 200)' }, + offset: { type: 'integer', description: 'Number of events skipped' }, + }, + }, + UserStreamSummary: { + type: 'object', + required: [ + 'address', + 'totalStreamsCreated', + 'totalStreamedOut', + 'totalStreamedIn', + 'currentClaimable', + 'activeOutgoingCount', + 'activeIncomingCount', + ], + properties: { + address: { type: 'string', description: 'Stellar public key' }, + totalStreamsCreated: { type: 'integer', description: 'Number of streams this wallet sent' }, + totalStreamedOut: { type: 'string', description: 'Sum of withdrawn amounts on outgoing streams (i128 as string)' }, + totalStreamedIn: { type: 'string', description: 'Sum of withdrawn amounts on incoming streams (i128 as string)' }, + currentClaimable: { type: 'string', description: 'Total currently claimable across active incoming streams (i128 as string)' }, + activeOutgoingCount: { type: 'integer' }, + activeIncomingCount: { type: 'integer' }, + truncated: { type: 'boolean', description: 'True when the number of streams was capped at MAX_USER_STREAMS per direction', example: false }, + }, + }, + ClaimableResponse: { + type: 'object', + required: ['claimableAmount', 'actionable', 'calculatedAt'], + properties: { + streamId: { type: 'integer', description: 'On-chain stream ID' }, + ratePerSecond: { type: 'string', description: 'Payment rate per second (i128 as string)' }, + depositedAmount: { type: 'string', description: 'Total deposited amount (i128 as string)' }, + withdrawnAmount: { type: 'string', description: 'Total withdrawn amount (i128 as string)' }, + startTime: { type: 'integer', description: 'Stream start time (Unix timestamp)' }, + lastUpdateTime: { type: 'integer', description: 'Last state update time (Unix timestamp)' }, + claimableAmount: { type: 'string', description: 'Amount claimable at the requested time (i128 as string)' }, + actionable: { type: 'boolean', description: 'Whether the claimable amount is positive' }, + calculatedAt: { type: 'integer', description: 'Unix timestamp of the calculation' }, + cached: { type: 'boolean', description: 'Whether the value came from cache or a fresh computation' }, + source: { type: 'string', enum: ['db', 'chain'], description: 'Where the value was computed from' }, + }, + }, + PauseResumeResponse: { + type: 'object', + required: ['success', 'streamId', 'txHash'], + properties: { + success: { type: 'boolean', example: true }, + streamId: { type: 'integer' }, + txHash: { type: 'string', description: 'Stellar transaction hash of the pause/resume simulation' }, + stream: { $ref: '#/components/schemas/Stream' }, + }, + }, + TopUpResponse: { + type: 'object', + required: ['streamId', 'txHash', 'depositedAmount'], + properties: { + streamId: { type: 'integer' }, + txHash: { type: 'string', description: 'Stellar transaction hash' }, + depositedAmount: { type: 'string', description: 'New total deposited amount after the top-up (i128 as string)' }, + }, + }, + WithdrawResponse: { + type: 'object', + required: ['success', 'streamId', 'txHash', 'amount'], + properties: { + success: { type: 'boolean', example: true }, + streamId: { type: 'integer' }, + txHash: { type: 'string', description: 'Stellar transaction hash of the withdrawal' }, + amount: { type: 'string', description: 'Amount withdrawn (i128 as string)' }, + stream: { $ref: '#/components/schemas/Stream' }, + }, + }, + CancelResponse: { + type: 'object', + required: ['txHash', 'status'], + properties: { + txHash: { type: 'string', description: 'Stellar transaction hash of the cancel' }, + status: { type: 'string', enum: ['CANCELLED'], example: 'CANCELLED' }, + }, + }, + AuthChallengeResponse: { + type: 'object', + required: ['nonce', 'expiresAt'], + properties: { + nonce: { type: 'string', description: 'Hex-encoded nonce to sign via a Stellar manage_data operation' }, + expiresAt: { type: 'integer', description: 'Unix timestamp (ms) when the challenge expires (60s)' }, + }, + }, + AuthVerifyResponse: { + type: 'object', + required: ['token', 'expiresIn'], + properties: { + token: { type: 'string', description: 'JWT to use in the Authorization: Bearer header' }, + expiresIn: { type: 'integer', description: 'Token lifetime in seconds (3600)' }, + }, + }, + SseStats: { + type: 'object', + required: ['activeConnections', 'activeIps', 'perIpPeakConnections', 'maxConnections', 'timestamp'], + properties: { + activeConnections: { type: 'integer', example: 42 }, + activeIps: { type: 'integer', example: 8 }, + perIpPeakConnections: { type: 'integer', example: 5 }, + maxConnections: { type: 'integer', example: 10000 }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + WebhookSubscription: { + type: 'object', + required: ['id', 'userAddress', 'targetUrl', 'eventTypes', 'active', 'createdAt'], + properties: { + id: { type: 'string', description: 'Webhook subscription id' }, + userAddress: { type: 'string', description: 'Stellar public key the subscription belongs to' }, + targetUrl: { type: 'string', description: 'HTTPS endpoint receiving the events' }, + eventTypes: { + type: 'array', + items: { type: 'string', enum: ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED', 'FEE_COLLECTED'] }, + }, + active: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + HealthResponse: { + type: 'object', + required: ['status', 'db', 'indexerEnabled', 'uptime', 'checks'], + properties: { + status: { type: 'string', enum: ['ok', 'degraded'], example: 'ok' }, + db: { type: 'string', enum: ['connected', 'disconnected'], example: 'connected' }, + indexerEnabled: { type: 'boolean', description: 'Whether the event indexer is configured' }, + indexerLag: { type: 'integer', nullable: true, description: 'Seconds since last indexer update, or null when no state row exists yet' }, + eventsProcessed: { type: 'integer', description: 'Lifetime count of successfully processed indexer events' }, + eventsFailed: { type: 'integer', description: 'Lifetime count of indexer events that threw during processing' }, + lastErrorAt: { type: 'string', format: 'date-time', nullable: true, description: 'Most recent per-event processing failure' }, + indexerDegraded: { type: 'boolean', description: 'True when recent event-processing failure rate spikes' }, + uptime: { type: 'number', description: 'Server uptime in seconds' }, + checks: { + type: 'object', + description: 'Per-subsystem status breakdown', + properties: { + database: { + type: 'object', + properties: { status: { type: 'string', enum: ['ok', 'down'] } }, + }, + indexer: { + type: 'object', + properties: { + status: { type: 'string', enum: ['ok', 'degraded', 'disabled'] }, + enabled: { type: 'boolean' }, + lagSeconds: { type: 'integer', nullable: true }, + }, + }, + redis: { + type: 'object', + properties: { status: { type: 'string', enum: ['ok', 'unavailable', 'not_configured'] } }, + }, + sorobanRpc: { + type: 'object', + properties: { status: { type: 'string', enum: ['ok', 'down'] } }, + }, + }, + }, + }, + }, Error: { type: 'object', properties: { @@ -272,6 +490,17 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, description: 'Error code', example: 'NOT_FOUND', }, + message: { + type: 'string', + nullable: true, + description: 'Human-readable detail (present on many error responses)', + }, + details: { + type: 'array', + nullable: true, + description: 'Structured validation issues (zod) when the error is a 400', + items: { type: 'object' }, + }, }, }, }, diff --git a/backend/src/routes/health.routes.ts b/backend/src/routes/health.routes.ts index 42b4f32f..9de798cb 100644 --- a/backend/src/routes/health.routes.ts +++ b/backend/src/routes/health.routes.ts @@ -33,75 +33,13 @@ const router = Router(); * content: * application/json: * schema: - * type: object - * properties: - * status: - * type: string - * example: ok - * db: - * type: string - * example: connected - * indexerEnabled: - * type: boolean - * description: Whether the event indexer is configured - * example: true - * indexerLag: - * type: integer - * nullable: true - * description: Seconds since last indexer update, or null when no state row exists yet - * example: 5 - * eventsProcessed: - * type: integer - * description: Lifetime count of successfully processed indexer events - * eventsFailed: - * type: integer - * description: Lifetime count of indexer events that threw during processing - * lastErrorAt: - * type: string - * nullable: true - * description: ISO timestamp of the most recent per-event processing failure - * indexerDegraded: - * type: boolean - * description: True when recent event-processing failure rate indicates a spike - * uptime: - * type: number - * description: Server uptime in seconds - * example: 3600 - * checks: - * type: object - * description: Per-subsystem status breakdown, so callers can tell "DB unreachable" apart from "indexer lagging" instead of inferring it from the top-level status alone. - * properties: - * database: - * type: object - * properties: - * status: - * type: string - * enum: [ok, down] - * indexer: - * type: object - * properties: - * status: - * type: string - * enum: [ok, degraded, disabled] - * enabled: - * type: boolean - * lagSeconds: - * type: integer - * nullable: true - * redis: - * type: object - * properties: - * status: - * type: string - * enum: [ok, unavailable, not_configured] - * sorobanRpc: - * type: object - * properties: - * status: - * type: string - * enum: [ok, down] + * $ref: '#/components/schemas/HealthResponse' * 503: * description: Service is degraded or unhealthy + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/HealthResponse' */ router.get('/', async (_req: Request, res: Response) => { let dbStatus = 'connected'; diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts index 93b1a817..8799eea3 100644 --- a/backend/src/routes/v1/admin.routes.ts +++ b/backend/src/routes/v1/admin.routes.ts @@ -32,6 +32,92 @@ router.use(adminRateLimiter); * responses: * 200: * description: Protocol health metrics + * content: + * application/json: + * schema: + * type: object + * properties: + * total_streams: + * type: integer + * active_streams: + * type: integer + * paused_streams: + * type: integer + * completed_streams: + * type: integer + * cancelled_streams: + * type: integer + * total_volume_streamed: + * type: string + * description: Sum of withdrawn amounts (i128 as string) + * streams: + * type: object + * properties: + * active: { type: integer } + * paused: { type: integer } + * total: { type: integer } + * byStatus: + * type: object + * additionalProperties: { type: integer } + * events: + * type: object + * properties: + * last24h: { type: integer } + * fees: + * type: object + * properties: + * totalFeesCollectedByToken: + * type: object + * additionalProperties: { type: string } + * feesLast24h: + * type: object + * additionalProperties: { type: string } + * sse: + * type: object + * properties: + * activeConnections: { type: integer } + * indexer: + * type: object + * properties: + * lastLedger: { type: integer } + * lagSeconds: { type: integer, nullable: true } + * lastUpdated: { type: string, format: date-time, nullable: true } + * eventsProcessed: { type: integer } + * eventsFailed: { type: integer } + * lastErrorAt: { type: string, nullable: true } + * degraded: { type: boolean } + * cache: + * type: object + * additionalProperties: true + * pgPool: + * type: object + * additionalProperties: true + * uptime: + * type: number + * timestamp: + * type: string + * format: date-time + * calculatedAt: + * type: string + * format: date-time + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - admin access required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ const ADMIN_METRICS_CACHE_KEY = 'admin:metrics'; const ADMIN_METRICS_CACHE_TTL_SECONDS = 60; @@ -216,6 +302,29 @@ router.get('/metrics', async (_req: Request, res: Response) => { * responses: * 200: * description: Indexer status + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - admin access required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/indexer/status', async (req: Request, res: Response) => { try { @@ -246,6 +355,37 @@ router.get('/indexer/status', async (req: Request, res: Response) => { * responses: * 200: * description: Reset successful + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: { type: boolean, example: true } + * lastLedger: { type: integer } + * 400: + * description: Invalid ledger value + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - admin access required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/indexer/reset', async (req: Request, res: Response) => { const ledger = Number(req.body?.ledger); @@ -277,6 +417,38 @@ router.post('/indexer/reset', async (req: Request, res: Response) => { * responses: * 202: * description: Replay started + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: { type: boolean, example: true } + * replayingFrom: { type: integer } + * requestId: { type: string } + * 400: + * description: Invalid from_ledger value + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - admin access required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/indexer/replay', async (req: Request, res: Response) => { const fromLedger = Number(req.query.from_ledger); diff --git a/backend/src/routes/v1/auth.routes.ts b/backend/src/routes/v1/auth.routes.ts index 4d0d25d3..00f1f3b7 100644 --- a/backend/src/routes/v1/auth.routes.ts +++ b/backend/src/routes/v1/auth.routes.ts @@ -23,8 +23,22 @@ const router = Router(); * responses: * 200: * description: Challenge nonce issued + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthChallengeResponse' * 400: * description: Invalid publicKey + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 429: + * description: Too many requests + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/challenge', issueChallenge); @@ -50,8 +64,28 @@ router.post('/challenge', issueChallenge); * responses: * 200: * description: JWT token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthVerifyResponse' + * 400: + * description: Missing publicKey or signedTransaction + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Invalid signature or expired challenge + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 429: + * description: Too many requests + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/verify', verifyChallenge); diff --git a/backend/src/routes/v1/events.routes.ts b/backend/src/routes/v1/events.routes.ts index a29927bb..637c7aaf 100644 --- a/backend/src/routes/v1/events.routes.ts +++ b/backend/src/routes/v1/events.routes.ts @@ -48,7 +48,7 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50; * description: | * Comma-separated list of event types to include. Allowed values: * CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, - * RESUMED, FEE_COLLECTED. + * RESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED. * - in: query * name: limit * required: false @@ -65,6 +65,34 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50; * responses: * 200: * description: Paginated event list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventListResponse' + * 400: + * description: Missing/invalid `address` or invalid `type` filter + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - `address` must match the authenticated wallet + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { @@ -205,13 +233,16 @@ router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunct * example: false * responses: * 200: - * description: SSE connection established + * description: SSE connection established. Events are emitted as `data:` frames of type stream.created, stream.topped_up, stream.withdrawn, stream.cancelled, stream.completed, stream.paused, stream.resumed, fee.collected. * content: * text/event-stream: * schema: * type: string + * description: Server-Sent Events stream; each event carries a JSON payload matching the StreamEvent schema * 400: * description: Invalid subscription parameters + * 401: + * description: Unauthorized - missing or invalid authentication token */ router.get('/subscribe', requireAuth, subscribe); @@ -222,30 +253,34 @@ router.get('/subscribe', requireAuth, subscribe); * tags: * - Events * summary: Get SSE connection statistics - * description: Returns current SSE connection metrics for monitoring + * description: Returns current SSE connection metrics for monitoring (admin only) + * security: + * - adminAuth: [] * responses: * 200: * description: Connection statistics * content: * application/json: * schema: - * type: object - * properties: - * activeConnections: - * type: number - * example: 42 - * activeIps: - * type: number - * example: 8 - * perIpPeakConnections: - * type: number - * example: 5 - * maxConnections: - * type: number - * example: 10000 - * timestamp: - * type: string - * format: date-time + * $ref: '#/components/schemas/SseStats' + * 401: + * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - admin access required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/stats', requireAdmin, (req: Request, res: Response) => { res.json({ diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts index cb6c39eb..cfba0252 100644 --- a/backend/src/routes/v1/stream.routes.ts +++ b/backend/src/routes/v1/stream.routes.ts @@ -24,18 +24,82 @@ const router = Router(); * tags: * - Streams * summary: Create a new payment stream - * description: Creates a new payment stream on the Stellar network. + * description: Creates or reactivates a payment stream record for the authenticated wallet. The authenticated wallet must be the stream sender. * security: * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime] + * properties: + * streamId: + * type: integer + * description: On-chain stream ID + * example: 1 + * sender: + * type: string + * description: Sender Stellar public key — must match the authenticated wallet + * example: "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA" + * recipient: + * type: string + * description: Recipient Stellar public key + * example: "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD" + * tokenAddress: + * type: string + * description: Token contract address + * example: "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE" + * ratePerSecond: + * type: string + * description: Payment rate per second (i128 as string) + * example: "100" + * depositedAmount: + * type: string + * description: Total deposited amount (i128 as string) + * example: "10000" + * startTime: + * type: integer + * description: Stream start time (Unix timestamp) + * example: 1708531200 * responses: * 201: * description: Stream created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Stream' * 400: * description: Invalid input data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - sender does not match the authenticated wallet, or stream is owned by another wallet + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 429: * description: Too Many Requests - rate limit exceeded (10 requests per minute) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/', requireAuth, streamCreationRateLimiter, createStream); @@ -46,7 +110,74 @@ router.post('/', requireAuth, streamCreationRateLimiter, createStream); * tags: * - Streams * summary: List payment streams - * description: Retrieve a list of payment streams with optional filtering. + * description: Retrieve a list of payment streams with optional filtering, sorting, and pagination. + * parameters: + * - in: query + * name: sender + * schema: { type: string } + * description: Filter by sender public key + * - in: query + * name: recipient + * schema: { type: string } + * description: Filter by recipient public key + * - in: query + * name: status + * schema: + * type: string + * enum: [active, cancelled, completed, paused] + * description: Filter by stream status + * - in: query + * name: token + * schema: { type: string } + * description: Filter by token contract address + * - in: query + * name: sort + * schema: + * type: string + * enum: [createdAt, startTime, lastUpdateTime, depositedAmount, endTime] + * default: createdAt + * description: Sort field + * - in: query + * name: order + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * description: Sort order + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * minimum: 1 + * maximum: 100 + * description: Max results per page (capped at 100) + * - in: query + * name: offset + * schema: + * type: integer + * default: 0 + * minimum: 0 + * description: Number of results to skip + * responses: + * 200: + * description: Paginated list of streams + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StreamListResponse' + * 400: + * description: Invalid status or pagination parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/', listStreams); @@ -57,6 +188,32 @@ router.get('/', listStreams); * tags: * - Streams * summary: Get user stream summary + * description: Aggregate dashboard/profile summary for a wallet address. Cached for 30 seconds. + * parameters: + * - in: path + * name: address + * required: true + * schema: { type: string } + * description: Stellar public key + * responses: + * 200: + * description: User stream summary + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserStreamSummary' + * 400: + * description: Address is required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/summary/:address', getUserStreamSummary); @@ -67,6 +224,39 @@ router.get('/summary/:address', getUserStreamSummary); * tags: * - Streams * summary: Get stream details + * description: Returns a single stream. Falls back to live on-chain data when the DB record is missing or stale. + * parameters: + * - in: path + * name: streamId + * required: true + * schema: + * type: integer + * description: On-chain stream ID + * responses: + * 200: + * description: Stream details + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Stream' + * 400: + * description: Invalid streamId parameter + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/:streamId', getStream); @@ -104,9 +294,21 @@ router.get('/:streamId', getStream); * name: eventType * schema: * type: string - * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED] + * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED] * description: Filter events by type * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * description: 1-based page index (offset based). Ignored when `cursor` is set. + * - in: query + * name: cursor + * schema: + * type: string + * description: Event id cursor for stable pagination (hasMore-aware). Ignored when `offset` is set. + * - in: query * name: order * schema: * type: string @@ -115,46 +317,29 @@ router.get('/:streamId', getStream); * description: "Sort order by timestamp (default: desc)" * responses: * 200: - * description: Stream events retrieved successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * data: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * streamId: - * type: integer - * eventType: - * type: string - * transactionHash: - * type: string - * ledgerSequence: - * type: integer - * timestamp: - * type: integer - * metadata: - * type: string - * createdAt: - * type: string - * format: date-time - * total: - * type: integer - * description: Total number of events matching the filter - * hasMore: - * type: boolean - * description: Whether there are more events available + * description: Paginated stream events + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StreamEventListResponse' * 400: * description: Invalid request parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 500: * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/:streamId/events', getStreamEvents); @@ -165,6 +350,45 @@ router.get('/:streamId/events', getStreamEvents); * tags: * - Streams * summary: Get actionable claimable amount for a stream + * description: Returns the amount claimable right now (or at an optional timestamp). Uses a 5s-cached computation, with an on-chain fallback when the record is missing or stale. + * parameters: + * - in: path + * name: streamId + * required: true + * schema: + * type: integer + * description: On-chain stream ID + * - in: query + * name: at + * schema: + * type: integer + * minimum: 0 + * description: Optional Unix timestamp (seconds) to compute the claimable amount at + * responses: + * 200: + * description: Claimable amount + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ClaimableResponse' + * 400: + * description: Invalid streamId or `at` parameter + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get('/:streamId/claimable', getStreamClaimableAmount); @@ -188,14 +412,46 @@ router.get('/:streamId/claimable', getStreamClaimableAmount); * responses: * 200: * description: Stream paused successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PauseResumeResponse' + * 400: + * description: Invalid streamId, or on-chain pause simulation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized - missing or invalid authentication + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 403: * description: Forbidden - caller is not the stream sender + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 409: * description: Conflict - stream already paused or inactive + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/:streamId/pause', requireAuth, pauseStream); @@ -219,14 +475,46 @@ router.post('/:streamId/pause', requireAuth, pauseStream); * responses: * 200: * description: Stream resumed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PauseResumeResponse' + * 400: + * description: Invalid streamId, or on-chain resume simulation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized - missing or invalid authentication + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 403: * description: Forbidden - caller is not the stream sender + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 409: * description: Conflict - stream not paused or inactive + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/:streamId/resume', requireAuth, resumeStream); @@ -250,14 +538,46 @@ router.post('/:streamId/resume', requireAuth, resumeStream); * responses: * 200: * description: Withdrawal submitted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WithdrawResponse' + * 400: + * description: Invalid streamId or contract revert + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized - missing or invalid authentication + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 403: * description: Forbidden - caller is not the stream recipient + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 409: * description: Conflict - no claimable balance available + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); @@ -297,24 +617,107 @@ router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); * content: * application/json: * schema: - * type: object - * properties: - * txHash: - * type: string - * streamId: - * type: integer - * newDepositedAmount: - * type: string + * $ref: '#/components/schemas/TopUpResponse' * 400: * description: Invalid request — amount missing or not a positive integer string + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized - missing or invalid authentication token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 403: * description: Forbidden - caller is not the stream sender + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Conflict - stream inactive or paused + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post('/:streamId/top-up', requireAuth, topUpStreamHandler); + +/** + * @openapi + * /v1/streams/{streamId}/cancel: + * post: + * tags: + * - Streams + * summary: Cancel an active payment stream + * description: Cancels an active payment stream. Only the sender can cancel; accrued tokens go to the recipient and the remainder is refunded to the sender. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: streamId + * required: true + * schema: + * type: integer + * description: On-chain stream ID + * responses: + * 200: + * description: Stream cancelled successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CancelResponse' + * 400: + * description: Invalid streamId or transaction simulation failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized - missing or invalid authentication + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Forbidden - only the sender can cancel + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Stream not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Stream already cancelled or completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any); export default router; diff --git a/backend/src/routes/v1/streams/withdraw.ts b/backend/src/routes/v1/streams/withdraw.ts index b0d6d943..45801099 100644 --- a/backend/src/routes/v1/streams/withdraw.ts +++ b/backend/src/routes/v1/streams/withdraw.ts @@ -29,18 +29,7 @@ import { parseStreamId } from '../../../lib/stream-id.js'; * content: * application/json: * schema: - * type: object - * properties: - * success: - * type: boolean - * streamId: - * type: integer - * txHash: - * type: string - * amount: - * type: string - * stream: - * $ref: '#/components/schemas/Stream' + * $ref: '#/components/schemas/WithdrawResponse' * 400: * description: Invalid streamId or contract revert * 401: @@ -51,6 +40,8 @@ import { parseStreamId } from '../../../lib/stream-id.js'; * description: Stream not found * 409: * description: Conflict - no claimable balance available + * 500: + * description: Internal server error */ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) => { try { diff --git a/backend/src/routes/v1/user.routes.ts b/backend/src/routes/v1/user.routes.ts index 0bb63d66..8c61fcec 100644 --- a/backend/src/routes/v1/user.routes.ts +++ b/backend/src/routes/v1/user.routes.ts @@ -47,6 +47,16 @@ const router = Router(); * $ref: '#/components/schemas/User' * 400: * description: Invalid request body + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * * /v1/users/{publicKey}: * get: @@ -70,6 +80,16 @@ const router = Router(); * $ref: '#/components/schemas/User' * 404: * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * * /v1/users/me: * get: @@ -88,6 +108,16 @@ const router = Router(); * $ref: '#/components/schemas/User' * 401: * description: Unauthorized - invalid or missing token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post("/", registerUser); router.get("/me", requireAuth, getCurrentUser); @@ -117,22 +147,19 @@ router.get("/me", requireAuth, getCurrentUser); * content: * application/json: * schema: - * type: object - * properties: - * address: - * type: string - * totalStreamsCreated: - * type: integer - * totalStreamedOut: - * type: string - * totalStreamedIn: - * type: string - * currentClaimable: - * type: string - * activeOutgoingCount: - * type: integer - * activeIncomingCount: - * type: integer + * $ref: '#/components/schemas/UserStreamSummary' + * 400: + * description: Address is required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get("/:address/summary", getUserStreamSummary); router.get("/:publicKey", getUser); @@ -171,22 +198,25 @@ router.get("/:publicKey", getUser); * content: * application/json: * schema: - * type: object - * properties: - * data: - * type: array - * items: - * $ref: '#/components/schemas/StreamEvent' - * total: - * type: integer - * hasMore: - * type: boolean - * limit: - * type: integer - * offset: - * type: integer + * $ref: '#/components/schemas/UserEventListResponse' + * 400: + * description: Invalid pagination parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' * 404: * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get("/:publicKey/events", getUserEvents); @@ -251,5 +281,21 @@ export default router; * type: object * 400: * description: Invalid parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get("/:address/export", exportTransactions); diff --git a/backend/src/routes/v1/webhook.routes.ts b/backend/src/routes/v1/webhook.routes.ts index 9aa88bd3..1a4b6cb3 100644 --- a/backend/src/routes/v1/webhook.routes.ts +++ b/backend/src/routes/v1/webhook.routes.ts @@ -7,10 +7,11 @@ import * as webhookController from "../../controllers/webhook.controller.js"; const router = Router(); /** - * @swagger - * /api/v1/webhooks: + * @openapi + * /v1/webhooks: * post: * summary: Register a new webhook subscription + * description: Creates a webhook subscription. The returned `secretKey` is only shown once. * tags: [Webhooks] * requestBody: * required: true @@ -25,21 +26,48 @@ const router = Router(); * properties: * userAddress: * type: string + * description: Stellar public key * targetUrl: * type: string + * format: uri * eventTypes: * type: array * items: * type: string + * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED] * responses: * 201: * description: Webhook created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * subscription: + * $ref: '#/components/schemas/WebhookSubscription' + * secretKey: + * type: string + * description: Webhook signing secret (returned only once) + * message: + * type: string + * 400: + * description: Missing required fields + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post("/", webhookController.createWebhook); /** - * @swagger - * /api/v1/webhooks: + * @openapi + * /v1/webhooks: * get: * summary: List all webhooks for authenticated user * tags: [Webhooks] @@ -52,12 +80,33 @@ router.post("/", webhookController.createWebhook); * responses: * 200: * description: List of webhook subscriptions + * content: + * application/json: + * schema: + * type: object + * properties: + * subscriptions: + * type: array + * items: + * $ref: '#/components/schemas/WebhookSubscription' + * 400: + * description: Missing userAddress + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.get("/", webhookController.listWebhooks); /** - * @swagger - * /api/v1/webhooks/{id}: + * @openapi + * /v1/webhooks/{id}: * delete: * summary: Delete a webhook subscription * tags: [Webhooks] @@ -75,12 +124,24 @@ router.get("/", webhookController.listWebhooks); * responses: * 204: * description: Webhook deleted successfully + * 400: + * description: Invalid id or missing userAddress + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.delete("/:id", webhookController.deleteWebhook); /** - * @swagger - * /api/v1/webhooks/{id}/test: + * @openapi + * /v1/webhooks/{id}/test: * post: * summary: Send a test ping to a webhook * tags: [Webhooks] @@ -104,6 +165,29 @@ router.delete("/:id", webhookController.deleteWebhook); * responses: * 200: * description: Test webhook sent + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Test webhook sent + * result: + * type: object + * additionalProperties: true + * 400: + * description: Missing id or userAddress + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' */ router.post("/:id/test", webhookController.testWebhook); diff --git a/backend/swagger/flowfi.openapi.json b/backend/swagger/flowfi.openapi.json new file mode 100644 index 00000000..245d7961 --- /dev/null +++ b/backend/swagger/flowfi.openapi.json @@ -0,0 +1,3507 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "FlowFi API", + "version": "1.0.0", + "description": "API documentation for FlowFi - Real-time payment streaming on Stellar\n\n## Performance & Caching\nThe API implements caching for frequently accessed endpoints, such as claimable amount calculations. \n- **Claimable Cache TTL**: 5 seconds\n- **Invalidation**: Automatically cleared when a withdrawal event occurs.\n\n## Sandbox Mode\n\nFlowFi API supports sandbox mode for testing without affecting production data.\n\n**Enable Sandbox Mode:**\n- Header: `X-Sandbox-Mode: true`\n- Query Parameter: `?sandbox=true`\n\n**Sandbox Features:**\n- Isolated database (separate from production)\n- All responses include `_sandbox` metadata\n- Response headers include `X-Sandbox-Mode: true`\n- Safe for testing and development\n\nSee [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.", + "contact": { + "name": "FlowFi Team", + "url": "https://github.com/LabsCrypt/flowfi" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + } + }, + "servers": [ + { + "url": "http://localhost:3001/v1", + "description": "Development server (v1)" + }, + { + "url": "https://api.flowfi.io/v1", + "description": "Production server (v1)" + } + ], + "tags": [ + { + "name": "Health", + "description": "Health check endpoints" + }, + { + "name": "Users", + "description": "User management endpoints" + }, + { + "name": "Streams", + "description": "Payment stream management endpoints" + }, + { + "name": "Events", + "description": "Stream event tracking endpoints" + }, + { + "name": "Admin", + "description": "Administrative and monitoring endpoints" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JSON Web Token issued by /v1/auth/verify after completing the SEP-10 challenge flow." + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Alias for BearerAuth — used by route-level security annotations." + }, + "adminAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Admin JWT — the token subject must match ADMIN_PUBLIC_KEY." + } + }, + "schemas": { + "User": { + "type": "object", + "required": [ + "id", + "publicKey" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the user", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "publicKey": { + "type": "string", + "description": "Stellar public key (G...)", + "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "User creation timestamp" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp" + } + } + }, + "Stream": { + "type": "object", + "required": [ + "id", + "streamId", + "sender", + "recipient", + "tokenAddress", + "ratePerSecond" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Database UUID" + }, + "streamId": { + "type": "integer", + "description": "On-chain stream ID", + "example": 1 + }, + "sender": { + "type": "string", + "description": "Sender Stellar public key", + "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA" + }, + "recipient": { + "type": "string", + "description": "Recipient Stellar public key", + "example": "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD" + }, + "tokenAddress": { + "type": "string", + "description": "Token contract address", + "example": "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE" + }, + "ratePerSecond": { + "type": "string", + "description": "Payment rate per second (i128 as string)", + "example": "100" + }, + "depositedAmount": { + "type": "string", + "description": "Total deposited amount (i128 as string)", + "example": "10000" + }, + "withdrawnAmount": { + "type": "string", + "description": "Total withdrawn amount (i128 as string)", + "example": "2500" + }, + "startTime": { + "type": "integer", + "description": "Stream start time (Unix timestamp)", + "example": 1708531200 + }, + "lastUpdateTime": { + "type": "integer", + "description": "Last update time (Unix timestamp)", + "example": 1708534800 + }, + "isActive": { + "type": "boolean", + "description": "Stream active status", + "example": true + }, + "isPaused": { + "type": "boolean", + "description": "Whether the stream is currently paused", + "example": false + }, + "pausedAt": { + "type": "integer", + "nullable": true, + "description": "Ledger timestamp when the stream was last paused (Unix), null if not paused", + "example": null + }, + "totalPausedDuration": { + "type": "integer", + "description": "Cumulative seconds the stream has spent paused", + "example": 0 + }, + "endTime": { + "type": "integer", + "nullable": true, + "description": "Ledger timestamp when the stream ended (Unix), null if still active", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "StreamEvent": { + "type": "object", + "required": [ + "id", + "streamId", + "eventType", + "transactionHash", + "ledgerSequence", + "timestamp" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "streamId": { + "type": "integer", + "description": "Reference to stream ID" + }, + "eventType": { + "type": "string", + "enum": [ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED" + ], + "description": "Type of stream event", + "example": "TOPPED_UP" + }, + "amount": { + "type": "string", + "nullable": true, + "description": "Amount involved in event (i128 as string)", + "example": "5000" + }, + "transactionHash": { + "type": "string", + "description": "Stellar transaction hash", + "example": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6" + }, + "ledgerSequence": { + "type": "integer", + "description": "Ledger sequence number", + "example": 12345678 + }, + "timestamp": { + "type": "integer", + "description": "Event timestamp (Unix)", + "example": 1708531200 + }, + "metadata": { + "type": "string", + "nullable": true, + "description": "Additional event data (JSON string)" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "StreamListResponse": { + "type": "object", + "required": [ + "data", + "total", + "hasMore", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "description": "Streams matching the filter, sorted and paginated", + "items": { + "$ref": "#/components/schemas/Stream" + } + }, + "total": { + "type": "integer", + "description": "Total number of streams matching the filter" + }, + "hasMore": { + "type": "boolean", + "description": "Whether more results are available past this page" + }, + "limit": { + "type": "integer", + "description": "Page size applied (capped at MAX_STREAM_PAGE_SIZE)" + }, + "offset": { + "type": "integer", + "description": "Number of results skipped" + } + } + }, + "StreamEventListResponse": { + "type": "object", + "required": [ + "data", + "total", + "hasMore" + ], + "properties": { + "data": { + "type": "array", + "description": "Events for the stream, sorted by timestamp (tie-broken by id)", + "items": { + "$ref": "#/components/schemas/StreamEvent" + } + }, + "total": { + "type": "integer", + "description": "Total number of events matching the filter" + }, + "hasMore": { + "type": "boolean", + "description": "Whether more results are available past this page" + } + } + }, + "EventListResponse": { + "type": "object", + "required": [ + "events", + "total", + "limit", + "offset", + "hasMore" + ], + "properties": { + "events": { + "type": "array", + "description": "Reverse-chronological stream events for the wallet", + "items": { + "$ref": "#/components/schemas/StreamEvent" + } + }, + "total": { + "type": "integer", + "description": "Total number of matching events" + }, + "limit": { + "type": "integer", + "description": "Page size applied (capped at 200)" + }, + "offset": { + "type": "integer", + "description": "Number of events skipped" + }, + "hasMore": { + "type": "boolean", + "description": "Whether more results are available past this page" + } + } + }, + "UserEventListResponse": { + "type": "object", + "required": [ + "data", + "total", + "hasMore", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "description": "Events associated with the user, newest first", + "items": { + "$ref": "#/components/schemas/StreamEvent" + } + }, + "total": { + "type": "integer", + "description": "Total number of matching events" + }, + "hasMore": { + "type": "boolean", + "description": "Whether more results are available past this page" + }, + "limit": { + "type": "integer", + "description": "Page size applied (capped at 200)" + }, + "offset": { + "type": "integer", + "description": "Number of events skipped" + } + } + }, + "UserStreamSummary": { + "type": "object", + "required": [ + "address", + "totalStreamsCreated", + "totalStreamedOut", + "totalStreamedIn", + "currentClaimable", + "activeOutgoingCount", + "activeIncomingCount" + ], + "properties": { + "address": { + "type": "string", + "description": "Stellar public key" + }, + "totalStreamsCreated": { + "type": "integer", + "description": "Number of streams this wallet sent" + }, + "totalStreamedOut": { + "type": "string", + "description": "Sum of withdrawn amounts on outgoing streams (i128 as string)" + }, + "totalStreamedIn": { + "type": "string", + "description": "Sum of withdrawn amounts on incoming streams (i128 as string)" + }, + "currentClaimable": { + "type": "string", + "description": "Total currently claimable across active incoming streams (i128 as string)" + }, + "activeOutgoingCount": { + "type": "integer" + }, + "activeIncomingCount": { + "type": "integer" + }, + "truncated": { + "type": "boolean", + "description": "True when the number of streams was capped at MAX_USER_STREAMS per direction", + "example": false + } + } + }, + "ClaimableResponse": { + "type": "object", + "required": [ + "claimableAmount", + "actionable", + "calculatedAt" + ], + "properties": { + "streamId": { + "type": "integer", + "description": "On-chain stream ID" + }, + "ratePerSecond": { + "type": "string", + "description": "Payment rate per second (i128 as string)" + }, + "depositedAmount": { + "type": "string", + "description": "Total deposited amount (i128 as string)" + }, + "withdrawnAmount": { + "type": "string", + "description": "Total withdrawn amount (i128 as string)" + }, + "startTime": { + "type": "integer", + "description": "Stream start time (Unix timestamp)" + }, + "lastUpdateTime": { + "type": "integer", + "description": "Last state update time (Unix timestamp)" + }, + "claimableAmount": { + "type": "string", + "description": "Amount claimable at the requested time (i128 as string)" + }, + "actionable": { + "type": "boolean", + "description": "Whether the claimable amount is positive" + }, + "calculatedAt": { + "type": "integer", + "description": "Unix timestamp of the calculation" + }, + "cached": { + "type": "boolean", + "description": "Whether the value came from cache or a fresh computation" + }, + "source": { + "type": "string", + "enum": [ + "db", + "chain" + ], + "description": "Where the value was computed from" + } + } + }, + "PauseResumeResponse": { + "type": "object", + "required": [ + "success", + "streamId", + "txHash" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "streamId": { + "type": "integer" + }, + "txHash": { + "type": "string", + "description": "Stellar transaction hash of the pause/resume simulation" + }, + "stream": { + "$ref": "#/components/schemas/Stream" + } + } + }, + "TopUpResponse": { + "type": "object", + "required": [ + "streamId", + "txHash", + "depositedAmount" + ], + "properties": { + "streamId": { + "type": "integer" + }, + "txHash": { + "type": "string", + "description": "Stellar transaction hash" + }, + "depositedAmount": { + "type": "string", + "description": "New total deposited amount after the top-up (i128 as string)" + } + } + }, + "WithdrawResponse": { + "type": "object", + "required": [ + "success", + "streamId", + "txHash", + "amount" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "streamId": { + "type": "integer" + }, + "txHash": { + "type": "string", + "description": "Stellar transaction hash of the withdrawal" + }, + "amount": { + "type": "string", + "description": "Amount withdrawn (i128 as string)" + }, + "stream": { + "$ref": "#/components/schemas/Stream" + } + } + }, + "CancelResponse": { + "type": "object", + "required": [ + "txHash", + "status" + ], + "properties": { + "txHash": { + "type": "string", + "description": "Stellar transaction hash of the cancel" + }, + "status": { + "type": "string", + "enum": [ + "CANCELLED" + ], + "example": "CANCELLED" + } + } + }, + "AuthChallengeResponse": { + "type": "object", + "required": [ + "nonce", + "expiresAt" + ], + "properties": { + "nonce": { + "type": "string", + "description": "Hex-encoded nonce to sign via a Stellar manage_data operation" + }, + "expiresAt": { + "type": "integer", + "description": "Unix timestamp (ms) when the challenge expires (60s)" + } + } + }, + "AuthVerifyResponse": { + "type": "object", + "required": [ + "token", + "expiresIn" + ], + "properties": { + "token": { + "type": "string", + "description": "JWT to use in the Authorization: Bearer header" + }, + "expiresIn": { + "type": "integer", + "description": "Token lifetime in seconds (3600)" + } + } + }, + "SseStats": { + "type": "object", + "required": [ + "activeConnections", + "activeIps", + "perIpPeakConnections", + "maxConnections", + "timestamp" + ], + "properties": { + "activeConnections": { + "type": "integer", + "example": 42 + }, + "activeIps": { + "type": "integer", + "example": 8 + }, + "perIpPeakConnections": { + "type": "integer", + "example": 5 + }, + "maxConnections": { + "type": "integer", + "example": 10000 + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "WebhookSubscription": { + "type": "object", + "required": [ + "id", + "userAddress", + "targetUrl", + "eventTypes", + "active", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Webhook subscription id" + }, + "userAddress": { + "type": "string", + "description": "Stellar public key the subscription belongs to" + }, + "targetUrl": { + "type": "string", + "description": "HTTPS endpoint receiving the events" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED" + ] + } + }, + "active": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "db", + "indexerEnabled", + "uptime", + "checks" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ], + "example": "ok" + }, + "db": { + "type": "string", + "enum": [ + "connected", + "disconnected" + ], + "example": "connected" + }, + "indexerEnabled": { + "type": "boolean", + "description": "Whether the event indexer is configured" + }, + "indexerLag": { + "type": "integer", + "nullable": true, + "description": "Seconds since last indexer update, or null when no state row exists yet" + }, + "eventsProcessed": { + "type": "integer", + "description": "Lifetime count of successfully processed indexer events" + }, + "eventsFailed": { + "type": "integer", + "description": "Lifetime count of indexer events that threw during processing" + }, + "lastErrorAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Most recent per-event processing failure" + }, + "indexerDegraded": { + "type": "boolean", + "description": "True when recent event-processing failure rate spikes" + }, + "uptime": { + "type": "number", + "description": "Server uptime in seconds" + }, + "checks": { + "type": "object", + "description": "Per-subsystem status breakdown", + "properties": { + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "down" + ] + } + } + }, + "indexer": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded", + "disabled" + ] + }, + "enabled": { + "type": "boolean" + }, + "lagSeconds": { + "type": "integer", + "nullable": true + } + } + }, + "redis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "unavailable", + "not_configured" + ] + } + } + }, + "sorobanRpc": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "down" + ] + } + } + } + } + } + } + }, + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message", + "example": "Resource not found" + }, + "code": { + "type": "string", + "description": "Error code", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "nullable": true, + "description": "Human-readable detail (present on many error responses)" + }, + "details": { + "type": "array", + "nullable": true, + "description": "Structured validation issues (zod) when the error is a 400", + "items": { + "type": "object" + } + } + } + } + } + }, + "paths": { + "/": { + "get": { + "tags": [ + "Health" + ], + "summary": "Simple health check", + "description": "Returns a simple message to verify the API is running", + "responses": { + "200": { + "description": "API is running successfully" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "Detailed health check", + "description": "Returns liveness and readiness information.\n**Liveness** (200 vs 503) is determined by DB reachability alone.\n**Indexer lag** is reported in the body for observability but only\nforces a 503 when the indexer is actually enabled\n(`STREAM_CONTRACT_ID` env var set) and its state row is stale\n(lag > 60 s). A cold-started instance with no state row yet, or a\ndeployment with the indexer intentionally disabled, always returns 200\nas long as the DB is reachable.\n**Event-processing failures** are also reported. When the indexer is\nenabled and recent per-event failures spike (≥50% of attempts in the\nlast 5 minutes, with ≥3 samples), the endpoint returns 503 even if\nlag looks healthy (the IndexerState upsert bumps updatedAt every poll).\n", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + }, + "503": { + "description": "Service is degraded or unhealthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/v1/webhooks": { + "post": { + "summary": "Register a new webhook subscription", + "description": "Creates a webhook subscription. The returned `secretKey` is only shown once.", + "tags": [ + "Webhooks" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "userAddress", + "targetUrl", + "eventTypes" + ], + "properties": { + "userAddress": { + "type": "string", + "description": "Stellar public key" + }, + "targetUrl": { + "type": "string", + "format": "uri" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED" + ] + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Webhook created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "subscription": { + "$ref": "#/components/schemas/WebhookSubscription" + }, + "secretKey": { + "type": "string", + "description": "Webhook signing secret (returned only once)" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Missing required fields", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "summary": "List all webhooks for authenticated user", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "in": "query", + "name": "userAddress", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of webhook subscriptions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "subscriptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookSubscription" + } + } + } + } + } + } + }, + "400": { + "description": "Missing userAddress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/webhooks/{id}": { + "delete": { + "summary": "Delete a webhook subscription", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "userAddress", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Webhook deleted successfully" + }, + "400": { + "description": "Invalid id or missing userAddress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/webhooks/{id}/test": { + "post": { + "summary": "Send a test ping to a webhook", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "userAddress" + ], + "properties": { + "userAddress": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Test webhook sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Test webhook sent" + }, + "result": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "400": { + "description": "Missing id or userAddress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users": { + "post": { + "tags": [ + "Users" + ], + "summary": "Register a wallet public key", + "description": "Registers a new Stellar wallet public key or returns the existing user if already registered.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "publicKey" + ], + "properties": { + "publicKey": { + "type": "string", + "description": "Stellar public key (G...)", + "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "User already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "201": { + "description": "User registered successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid request body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users/{publicKey}": { + "get": { + "tags": [ + "Users" + ], + "summary": "Fetch a user by public key", + "description": "Returns user details along with recent sent and received streams.", + "parameters": [ + { + "in": "path", + "name": "publicKey", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key" + } + ], + "responses": { + "200": { + "description": "User found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users/me": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get current authenticated user", + "description": "Returns the currently authenticated user's details (protected endpoint)", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Current user details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users/{address}/summary": { + "get": { + "tags": [ + "Users" + ], + "summary": "Get aggregate stream summary for a user", + "description": "Returns dashboard/profile summary data for a wallet address:\ntotal created streams, total streamed out/in, current claimable across\nactive incoming streams, and active stream counts.\n\nResponse is cached for 30 seconds to reduce DB load.\n", + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key address" + } + ], + "responses": { + "200": { + "description": "User stream summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStreamSummary" + } + } + } + }, + "400": { + "description": "Address is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users/{publicKey}/events": { + "get": { + "tags": [ + "Users" + ], + "summary": "Fetch user activity history", + "description": "Returns a paginated chronological history of all stream events associated with the user.", + "parameters": [ + { + "in": "path", + "name": "publicKey", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 50, + "maximum": 200 + }, + "description": "Maximum number of events to return" + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Number of events to skip for pagination" + } + ], + "responses": { + "200": { + "description": "Paginated list of user events", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserEventListResponse" + } + } + } + }, + "400": { + "description": "Invalid pagination parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/users/{address}/export": { + "get": { + "tags": [ + "Users" + ], + "summary": "Export transaction history for tax and accounting", + "description": "Generates CSV or JSON export of stream transactions for QuickBooks, Xero, CoinTracker, etc.", + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key" + }, + { + "in": "query", + "name": "format", + "schema": { + "type": "string", + "enum": [ + "csv", + "json" + ], + "default": "csv" + }, + "description": "Export format" + }, + { + "in": "query", + "name": "direction", + "schema": { + "type": "string", + "enum": [ + "incoming", + "outgoing", + "all" + ], + "default": "all" + }, + "description": "Filter by transaction direction" + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "Start date (ISO 8601 or Unix timestamp)" + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "End date (ISO 8601 or Unix timestamp)" + }, + { + "in": "query", + "name": "tokenAddress", + "schema": { + "type": "string" + }, + "description": "Filter by specific token contract" + } + ], + "responses": { + "200": { + "description": "Transaction export file", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Create a new payment stream", + "description": "Creates or reactivates a payment stream record for the authenticated wallet. The authenticated wallet must be the stream sender.", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "streamId", + "sender", + "recipient", + "tokenAddress", + "ratePerSecond", + "depositedAmount", + "startTime" + ], + "properties": { + "streamId": { + "type": "integer", + "description": "On-chain stream ID", + "example": 1 + }, + "sender": { + "type": "string", + "description": "Sender Stellar public key — must match the authenticated wallet", + "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA" + }, + "recipient": { + "type": "string", + "description": "Recipient Stellar public key", + "example": "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD" + }, + "tokenAddress": { + "type": "string", + "description": "Token contract address", + "example": "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE" + }, + "ratePerSecond": { + "type": "string", + "description": "Payment rate per second (i128 as string)", + "example": "100" + }, + "depositedAmount": { + "type": "string", + "description": "Total deposited amount (i128 as string)", + "example": "10000" + }, + "startTime": { + "type": "integer", + "description": "Stream start time (Unix timestamp)", + "example": 1708531200 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Stream created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - sender does not match the authenticated wallet, or stream is owned by another wallet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too Many Requests - rate limit exceeded (10 requests per minute)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "tags": [ + "Streams" + ], + "summary": "List payment streams", + "description": "Retrieve a list of payment streams with optional filtering, sorting, and pagination.", + "parameters": [ + { + "in": "query", + "name": "sender", + "schema": { + "type": "string" + }, + "description": "Filter by sender public key" + }, + { + "in": "query", + "name": "recipient", + "schema": { + "type": "string" + }, + "description": "Filter by recipient public key" + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed", + "paused" + ] + }, + "description": "Filter by stream status" + }, + { + "in": "query", + "name": "token", + "schema": { + "type": "string" + }, + "description": "Filter by token contract address" + }, + { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "createdAt", + "startTime", + "lastUpdateTime", + "depositedAmount", + "endTime" + ], + "default": "createdAt" + }, + "description": "Sort field" + }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "description": "Sort order" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + }, + "description": "Max results per page (capped at 100)" + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer", + "default": 0, + "minimum": 0 + }, + "description": "Number of results to skip" + } + ], + "responses": { + "200": { + "description": "Paginated list of streams", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamListResponse" + } + } + } + }, + "400": { + "description": "Invalid status or pagination parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/summary/{address}": { + "get": { + "tags": [ + "Streams" + ], + "summary": "Get user stream summary", + "description": "Aggregate dashboard/profile summary for a wallet address. Cached for 30 seconds.", + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key" + } + ], + "responses": { + "200": { + "description": "User stream summary", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserStreamSummary" + } + } + } + }, + "400": { + "description": "Address is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}": { + "get": { + "tags": [ + "Streams" + ], + "summary": "Get stream details", + "description": "Returns a single stream. Falls back to live on-chain data when the DB record is missing or stale.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "responses": { + "200": { + "description": "Stream details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + } + } + }, + "400": { + "description": "Invalid streamId parameter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/events": { + "get": { + "tags": [ + "Streams" + ], + "summary": "Get stream events", + "description": "Retrieve events for a specific stream with pagination, filtering, and sorting.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "description": "Number of events to return per page (default: 50, max: 200)" + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer", + "default": 0, + "minimum": 0 + }, + "description": "Number of events to skip (default: 0)" + }, + { + "in": "query", + "name": "eventType", + "schema": { + "type": "string", + "enum": [ + "CREATED", + "TOPPED_UP", + "WITHDRAWN", + "CANCELLED", + "COMPLETED", + "PAUSED", + "RESUMED", + "FEE_COLLECTED", + "FEE_CONFIG_UPDATED", + "ADMIN_TRANSFERRED" + ] + }, + "description": "Filter events by type" + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "description": "1-based page index (offset based). Ignored when `cursor` is set." + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Event id cursor for stable pagination (hasMore-aware). Ignored when `offset` is set." + }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "desc" + }, + "description": "Sort order by timestamp (default: desc)" + } + ], + "responses": { + "200": { + "description": "Paginated stream events", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamEventListResponse" + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/claimable": { + "get": { + "tags": [ + "Streams" + ], + "summary": "Get actionable claimable amount for a stream", + "description": "Returns the amount claimable right now (or at an optional timestamp). Uses a 5s-cached computation, with an on-chain fallback when the record is missing or stale.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + }, + { + "in": "query", + "name": "at", + "schema": { + "type": "integer", + "minimum": 0 + }, + "description": "Optional Unix timestamp (seconds) to compute the claimable amount at" + } + ], + "responses": { + "200": { + "description": "Claimable amount", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClaimableResponse" + } + } + } + }, + "400": { + "description": "Invalid streamId or `at` parameter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/pause": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Pause a payment stream", + "description": "Pause an active stream. Only the sender can pause their own stream.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Stream paused successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseResumeResponse" + } + } + } + }, + "400": { + "description": "Invalid streamId, or on-chain pause simulation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - caller is not the stream sender", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict - stream already paused or inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/resume": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Resume a paused payment stream", + "description": "Resume a paused stream. Only the sender can resume their own stream.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Stream resumed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseResumeResponse" + } + } + } + }, + "400": { + "description": "Invalid streamId, or on-chain resume simulation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - caller is not the stream sender", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict - stream not paused or inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/withdraw": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Withdraw claimable balance from a payment stream", + "description": "Withdraws the currently claimable amount. Only the recipient can withdraw.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Withdrawal submitted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithdrawResponse" + } + } + } + }, + "400": { + "description": "Invalid streamId or contract revert", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - caller is not the stream recipient", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict - no claimable balance available", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/top-up": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Top up a payment stream", + "description": "Adds additional funds to an existing active stream. Only the original sender can top up.", + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "string", + "description": "Amount to add to the stream deposit (i128 as string)", + "example": "5000" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Stream topped up successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopUpResponse" + } + } + } + }, + "400": { + "description": "Invalid request — amount missing or not a positive integer string", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - caller is not the stream sender", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflict - stream inactive or paused", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/streams/{streamId}/cancel": { + "post": { + "tags": [ + "Streams" + ], + "summary": "Cancel an active payment stream", + "description": "Cancels an active payment stream on the Stellar network.\nOnly the original sender can cancel the stream.\nAccrued tokens are sent to the recipient, and the remainder is refunded to the sender.\n", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "streamId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "On-chain stream ID" + } + ], + "responses": { + "200": { + "description": "Stream cancelled successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelResponse", + "type": "object", + "properties": { + "txHash": { + "type": "string" + }, + "status": { + "type": "string", + "example": "CANCELLED" + } + } + } + } + } + }, + "400": { + "description": "Invalid streamId or transaction simulation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - only sender can cancel", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Stream not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Stream already cancelled or completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/events": { + "get": { + "tags": [ + "Events" + ], + "summary": "List stream events for a wallet (paginated, filterable)", + "description": "Returns a reverse-chronological list of stream events where the wallet\nwas either the sender or recipient. Supports event-type filtering and\nlimit/offset pagination — used by the frontend activity timeline.\n", + "parameters": [ + { + "in": "query", + "name": "address", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key (G...)" + }, + { + "in": "query", + "name": "type", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated list of event types to include. Allowed values:\nCREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED,\nRESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED.\n" + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 200 + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer", + "default": 0 + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Optional 1-based page index. Ignored when offset is set." + } + ], + "responses": { + "200": { + "description": "Paginated event list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventListResponse" + } + } + } + }, + "400": { + "description": "Missing/invalid `address` or invalid `type` filter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - `address` must match the authenticated wallet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/events/subscribe": { + "get": { + "tags": [ + "Events" + ], + "summary": "Subscribe to real-time stream events", + "description": "Establishes a Server-Sent Events (SSE) connection for real-time updates.\n\n**Reconnection Strategy:**\n- Browser automatically reconnects with exponential backoff\n- Initial retry: 1s, max: 30s\n- Client should implement custom reconnection logic for production\n\n**Event Types:**\n- `stream.created` - New stream created\n- `stream.topped_up` - Stream received additional funds\n- `stream.withdrawn` - Funds withdrawn from stream\n- `stream.cancelled` - Stream cancelled\n- `stream.completed` - Stream completed\n\n**Sandbox Mode:**\n- Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true`\n- Sandbox events are clearly marked with `_sandbox` metadata\n- Sandbox events are isolated from production events\n", + "parameters": [ + { + "in": "header", + "name": "X-Sandbox-Mode", + "schema": { + "type": "string", + "enum": [ + "true", + "1" + ] + }, + "description": "Enable sandbox mode for testing", + "required": false + }, + { + "in": "query", + "name": "sandbox", + "schema": { + "type": "string", + "enum": [ + "true", + "1" + ] + }, + "description": "Enable sandbox mode via query parameter", + "required": false + }, + { + "in": "query", + "name": "streams", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of stream IDs to subscribe to", + "example": [ + "1", + "2" + ] + }, + { + "in": "query", + "name": "users", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Array of user public keys to subscribe to", + "example": [ + "GABC...", + "GDEF..." + ] + }, + { + "in": "query", + "name": "all", + "schema": { + "type": "boolean" + }, + "description": "Subscribe to all events", + "example": false + } + ], + "responses": { + "200": { + "description": "SSE connection established. Events are emitted as `data:` frames of type stream.created, stream.topped_up, stream.withdrawn, stream.cancelled, stream.completed, stream.paused, stream.resumed, fee.collected.", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "description": "Server-Sent Events stream; each event carries a JSON payload matching the StreamEvent schema" + } + } + } + }, + "400": { + "description": "Invalid subscription parameters" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token" + } + } + } + }, + "/v1/events/stats": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get SSE connection statistics", + "description": "Returns current SSE connection metrics for monitoring (admin only)", + "security": [ + { + "adminAuth": [] + } + ], + "responses": { + "200": { + "description": "Connection statistics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SseStats" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/auth/challenge": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Request a sign challenge for wallet authentication", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "publicKey" + ], + "properties": { + "publicKey": { + "type": "string", + "example": "GABC..." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Challenge nonce issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthChallengeResponse" + } + } + } + }, + "400": { + "description": "Invalid publicKey", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/auth/verify": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Verify signed challenge and receive JWT", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "publicKey", + "signedTransaction" + ], + "properties": { + "publicKey": { + "type": "string" + }, + "signedTransaction": { + "type": "string", + "description": "Base64-encoded XDR signed transaction containing the nonce" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "JWT token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthVerifyResponse" + } + } + } + }, + "400": { + "description": "Missing publicKey or signedTransaction", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Invalid signature or expired challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/admin/metrics": { + "get": { + "tags": [ + "Admin" + ], + "summary": "Protocol health metrics", + "security": [ + { + "adminAuth": [] + } + ], + "responses": { + "200": { + "description": "Protocol health metrics", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total_streams": { + "type": "integer" + }, + "active_streams": { + "type": "integer" + }, + "paused_streams": { + "type": "integer" + }, + "completed_streams": { + "type": "integer" + }, + "cancelled_streams": { + "type": "integer" + }, + "total_volume_streamed": { + "type": "string", + "description": "Sum of withdrawn amounts (i128 as string)" + }, + "streams": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "paused": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "byStatus": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, + "events": { + "type": "object", + "properties": { + "last24h": { + "type": "integer" + } + } + }, + "fees": { + "type": "object", + "properties": { + "totalFeesCollectedByToken": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "feesLast24h": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "sse": { + "type": "object", + "properties": { + "activeConnections": { + "type": "integer" + } + } + }, + "indexer": { + "type": "object", + "properties": { + "lastLedger": { + "type": "integer" + }, + "lagSeconds": { + "type": "integer", + "nullable": true + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "eventsProcessed": { + "type": "integer" + }, + "eventsFailed": { + "type": "integer" + }, + "lastErrorAt": { + "type": "string", + "nullable": true + }, + "degraded": { + "type": "boolean" + } + } + }, + "cache": { + "type": "object", + "additionalProperties": true + }, + "pgPool": { + "type": "object", + "additionalProperties": true + }, + "uptime": { + "type": "number" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "calculatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/admin/indexer/status": { + "get": { + "tags": [ + "Admin" + ], + "summary": "Get indexer status", + "security": [ + { + "adminAuth": [] + } + ], + "responses": { + "200": { + "description": "Indexer status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/admin/indexer/reset": { + "post": { + "tags": [ + "Admin" + ], + "summary": "Reset indexer lastProcessedLedger", + "security": [ + { + "adminAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ledger" + ], + "properties": { + "ledger": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Reset successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + }, + "lastLedger": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Invalid ledger value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/admin/indexer/replay": { + "post": { + "tags": [ + "Admin" + ], + "summary": "Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc)", + "security": [ + { + "adminAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "from_ledger", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "202": { + "description": "Replay started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + }, + "replayingFrom": { + "type": "integer" + }, + "requestId": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid from_ledger value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden - admin access required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + } +} diff --git a/contracts/stream_contract/README.md b/contracts/stream_contract/README.md index c3f05421..d97dba86 100644 --- a/contracts/stream_contract/README.md +++ b/contracts/stream_contract/README.md @@ -119,3 +119,31 @@ The Soroban test runner can generate storage snapshots under `test_snapshots/` w 3. Sender may call `top_up_stream`, `pause_stream`, `resume_stream`, or `cancel_stream`. 4. Recipient calls `withdraw` over time until fully drained. 5. Final withdrawal emits `stream_completed`. + +## Automated deployment + +Deployment to Stellar Testnet/Mainnet is automated via the +[`deploy-contracts`](../../.github/workflows/deploy-contracts.yml) GitHub Actions workflow: + +- **Triggers** + - `release` tags (`v*`) published via the GitHub Releases UI → deploys to **Mainnet**. + - Manual `workflow_dispatch` runs (Actions → "Deploy Soroban Contracts" → "Run workflow") → **Testnet** by default. +- **Steps**: installs the Rust `wasm32-unknown-unknown` target and Stellar CLI, runs + `cargo test --package stream_contract`, builds + optimizes the WASM, asserts the + optimized WASM stays under the **64 KB budget** (`stellar contract inspect`), + then deploys and calls `initialize(admin, treasury, fee_rate_bps)` via + [`scripts/deploy.sh`](../../scripts/deploy.sh). +- **Secrets** (repo → Settings → Secrets and variables → Actions): + + | Secret | Description | + |---|---| + | `DEPLOYER_SECRET` | Secret key of the deployer account | + | `ADMIN_ADDRESS` | Admin address passed to `initialize` | + | `TREASURY_ADDRESS` | Fee treasury address passed to `initialize` | + | `FEE_RATE_BPS` | Fee rate in basis points (e.g. `25` = 0.25%) | + +- **Outputs**: the deployed `STREAM_CONTRACT_ID` and deployment details (network, + tx hash, admin, treasury, fee rate) are written to `deployment-info.json`, emitted + in the job summary, uploaded as GitHub artifacts, and committed to the repo on + release deploys so the backend/frontend can pick up `STREAM_CONTRACT_ID`. + The optimized `.wasm` is uploaded as a build artifact. diff --git a/frontend/.gitignore b/frontend/.gitignore index 49a317f1..6592fe7f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -42,5 +42,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# manually-triggered API type codegen output (see src/lib/api-types.ts) -src/lib/api-types.generated.ts +# playwright e2e artifacts +test-results/ +playwright-report/ +e2e/.e2e-certs/ diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 00000000..c567d496 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const CERT_DIR = path.join(__dirname, ".e2e-certs"); +const CERT_PATH = path.join(CERT_DIR, "cert.pem"); +const KEY_PATH = path.join(CERT_DIR, "key.pem"); + +export default function globalSetup() { + if (fs.existsSync(CERT_PATH) && fs.existsSync(KEY_PATH)) { + return; + } + fs.mkdirSync(CERT_DIR, { recursive: true }); + execSync( + [ + "openssl req -x509 -newkey rsa:2048 -nodes", + `-keyout ${KEY_PATH}`, + `-out ${CERT_PATH}`, + "-days 3650", + '-subj "/CN=localhost"', + '-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"', + ].join(" "), + { stdio: "pipe" }, + ); +} \ No newline at end of file diff --git a/frontend/e2e/mocks/api-server.mjs b/frontend/e2e/mocks/api-server.mjs new file mode 100644 index 00000000..94a90774 --- /dev/null +++ b/frontend/e2e/mocks/api-server.mjs @@ -0,0 +1,394 @@ +import http from "node:http"; +import https from "node:https"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { xdr, Keypair, TransactionBuilder, Networks } from "@stellar/stellar-sdk"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CERT_DIR = path.join(__dirname, "..", ".e2e-certs"); +const CERT_PATH = path.join(CERT_DIR, "cert.pem"); +const KEY_PATH = path.join(CERT_DIR, "key.pem"); + +const PORT = Number(process.env.MOCK_API_PORT || 3100); +const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102); +const APP_ORIGIN = process.env.E2E_APP_ORIGIN || "http://localhost:3101"; +const SESSION_PUBLIC_KEY = "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U"; + +const RECIPIENT_PUBLIC_KEY = "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG"; +const USDC_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; + +const RATE_PER_SECOND = "10000000"; // 1 USDC / second (7 decimals) +const DEPOSITED_AMOUNT = "100000000000"; // 10,000 USDC +const WITHDRAW_BATCH = BigInt("100000000"); // 10 USDC per simulated withdrawal + +const accountSequence = ["1"]; + +const nowSec = () => Math.floor(Date.now() / 1000); + +function createStream() { + return { + id: "42", + streamId: 42, + sender: SESSION_PUBLIC_KEY, + recipient: RECIPIENT_PUBLIC_KEY, + tokenAddress: USDC_ADDRESS, + ratePerSecond: RATE_PER_SECOND, + depositedAmount: DEPOSITED_AMOUNT, + withdrawnAmount: "0", + startTime: nowSec() - 15, + lastUpdateTime: nowSec() - 3, + endTime: null, + isActive: true, + isPaused: false, + status: "active", + pausedAt: null, + totalPausedDuration: 0, + createdAt: new Date(Date.now() - 3600000).toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +let stream = createStream(); +const watchers = new Set(); + +function broadcast(eventName, data) { + const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of watchers) { + try { + res.write(payload); + } catch { + watchers.delete(res); + } + } +} + +function corsHeaders() { + return { + "Access-Control-Allow-Origin": APP_ORIGIN, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }; +} + +const sendJson = (res, statusCode, body, extraHeaders = {}) => { + const headers = { "Content-Type": "application/json", ...corsHeaders(), ...extraHeaders }; + res.writeHead(statusCode, headers); + res.end(JSON.stringify(body)); +}; + +const readBody = (req) => + new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); + +// ── XDR factories ──────────────────────────────────────────────────────────── + +function accountEntryXdr(publicKey, seq) { + const kp = Keypair.fromPublicKey(publicKey); + const accountEntry = new xdr.AccountEntry({ + accountId: kp.xdrAccountId(), + balance: xdr.Int64.fromString("0"), + seqNum: new xdr.SequenceNumber(xdr.Int64.fromString(String(seq))), + numSubEntries: 0, + flags: 0, + homeDomain: "", + thresholds: new Uint8Array(4), + signers: [], + ext: new xdr.AccountEntryExt(0), + }); + return xdr.LedgerEntryData.account(accountEntry); +} + +function ledgerKeyXdr(publicKey) { + const kp = Keypair.fromPublicKey(publicKey); + return xdr.LedgerKey.account(new xdr.LedgerKeyAccount({ accountId: kp.xdrPublicKey() })); +} + +function sorobanTransactionDataBase64() { + const footprint = new xdr.LedgerFootprint({ readOnly: [], readWrite: [] }); + const resources = new xdr.SorobanResources({ + footprint, + instructions: 0, + diskReadBytes: 8, + writeBytes: 8, + }); + const data = new xdr.SorobanTransactionData({ + resources, + resourceFee: 0n, + ext: new xdr.SorobanTransactionDataExt(0), + }); + return data.toXDR("base64"); +} + +function scValVoidBase64() { + return xdr.ScVal.scvVoid().toXDR("base64"); +} + +// ── REST / SSE handlers ────────────────────────────────────────────────────── + +function buildEvents() { + const events = [ + { + id: "1", + streamId: 42, + eventType: "CREATED", + timestamp: nowSec() - 3600, + amount: DEPOSITED_AMOUNT, + }, + ]; + if (Number(stream.withdrawnAmount) > 0) { + events.push({ + id: "2", + streamId: 42, + eventType: "WITHDRAWN", + timestamp: nowSec(), + amount: stream.withdrawnAmount, + }); + } + return events; +} + +function handleRest(req, res) { + if (req.method === "OPTIONS") { + res.writeHead(204, corsHeaders()); + return res.end(); + } + + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === "GET" && url.pathname === "/health") { + return sendJson(res, 200, { ok: true }); + } + + if ( + req.method === "GET" && + (url.pathname === "/v1/streams" || url.pathname === "/api/v1/streams") + ) { + return sendJson(res, 200, { data: [stream] }); + } + + const streamDetail = url.pathname.match(/^\/v1\/streams\/(\d+)$/); + if (req.method === "GET" && streamDetail) { + return sendJson(res, 200, stream); + } + + const streamEvents = url.pathname.match(/^\/v1\/streams\/(\d+)\/events$/); + if (req.method === "GET" && streamEvents) { + const all = buildEvents(); + return sendJson(res, 200, { + events: all, + total: all.length, + page: Number(url.searchParams.get("page") || 1), + limit: Number(url.searchParams.get("limit") || 20), + }); + } + + if (req.method === "GET" && url.pathname === "/v1/events/subscribe") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": APP_ORIGIN, + }); + res.write(`retry: 3000\n\n`); + watchers.add(res); + req.on("close", () => watchers.delete(res)); + const heartbeat = setInterval(() => { + try { + res.write(": ping\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 15000); + res.on("close", () => clearInterval(heartbeat)); + return; + } + + const withdrawControl = url.pathname.match(/^\/__e2e\/stream\/(\d+)\/withdraw$/); + if (req.method === "POST" && withdrawControl) { + const current = Number(stream.withdrawnAmount) || 0; + stream = { + ...stream, + withdrawnAmount: (current + Number(WITHDRAW_BATCH)).toString(), + updatedAt: new Date().toISOString(), + lastUpdateTime: nowSec(), + }; + broadcast("stream.withdrawn", { streamId: 42 }); + return sendJson(res, 200, { ok: true, withdrawnAmount: stream.withdrawnAmount }); + } + + return sendJson(res, 404, { error: "not found" }); +} + +// ── Soroban RPC handlers ───────────────────────────────────────────────────── + +function rpcError(id, code, message) { + return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function rpcResult(id, result) { + return JSON.stringify({ jsonrpc: "2.0", id, result }); +} + +let submittedTransactionXdr = null; + +function successTxResultBase64() { + return new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString("0"), + result: xdr.TransactionResultResult.txSuccess([]), + ext: new xdr.TransactionResultExt(0), + }).toXDR("base64"); +} + +function zeroTxMetaBase64() { + return new xdr.TransactionMeta(0, []).toXDR("base64"); +} + +function dummyEnvelopeBase64() { + return new TransactionBuilder( + Keypair.fromPublicKey(SESSION_PUBLIC_KEY), + { fee: "1", networkPassphrase: Networks.TESTNET }, + ) + .setTimeout(30) + .build() + .toXDR("base64"); +} + +async function handleRpc(req, res) { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Access-Control-Allow-Origin", APP_ORIGIN); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + + if (req.method !== "POST") { + res.writeHead(405); + return res.end(rpcError(null, -32600, "method not allowed")); + } + + let body; + try { + body = await readBody(req); + } catch { + res.writeHead(400); + return res.end(JSON.stringify({ error: "invalid json" })); + } + + const { id, method, params } = body; + + try { + switch (method) { + case "getHealth": + return res.end(rpcResult(id, { status: "healthy" })); + + case "getNetwork": + return res.end( + rpcResult(id, { + friendbotUrl: "https://friendbot-futurenet.stellar.org/", + passthroughUrls: {}, + sorobanRpcUrl: "", + }), + ); + + case "getLatestLedger": + return res.end( + rpcResult(id, { + id: "0000000000000000000000000000000000000000000000000000000000000000", + protocolVersion: 22, + sequence: 1000, + }), + ); + + case "getLedgerEntries": { + const keys = Array.isArray(params?.keys) ? params.keys : []; + const entries = keys.length + ? keys.map((keyBase64) => ({ + key: keyBase64, + xdr: accountEntryXdr(SESSION_PUBLIC_KEY, 1).toXDR("base64"), + lastModifiedLedgerSeq: 0, + })) + : []; + return res.end(rpcResult(id, { latestLedger: 1000, entries })); + } + + case "simulateTransaction": + return res.end( + rpcResult(id, { + id: "sim-1", + latestLedger: 1000, + transactionData: sorobanTransactionDataBase64(), + minResourceFee: "0", + cost: { cpuInsns: "0", memBytes: "0" }, + results: [{ auth: [], xdr: scValVoidBase64() }], + events: [], + }), + ); + + case "sendTransaction": + submittedTransactionXdr = params?.transaction ?? null; + return res.end( + rpcResult(id, { + status: "PENDING", + hash: "0000000000000000000000000000000000000000000000000000000000000000", + latestLedger: 1000, + latestLedgerCloseTime: 0, + }), + ); + + case "getTransaction": + return res.end( + rpcResult(id, { + status: "SUCCESS", + latestLedger: 1000, + latestLedgerCloseTime: 0, + ledger: 1000, + applicationOrder: 1, + feeBump: false, + envelopeXdr: + submittedTransactionXdr ?? dummyEnvelopeBase64(), + resultXdr: successTxResultBase64(), + resultMetaXdr: zeroTxMetaBase64(), + }), + ); + + default: + return res.end(rpcError(id, -32601, `method not found: ${method}`)); + } + } catch (err) { + res.end(rpcError(id, -32603, err.message)); + } +} + +// ── Bootstrap ──────────────────────────────────────────────────────────────── + +const server = http.createServer(handleRest); +server.listen(PORT, "0.0.0.0", () => { + console.log(`[mock-api] rest+sse listening on http://localhost:${PORT}`); +}); + +if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) { + console.error("[mock-api] missing TLS certs — run the pw global-setup first (playwright install)"); + process.exit(1); +} + +const rpcServer = https.createServer( + { cert: fs.readFileSync(CERT_PATH), key: fs.readFileSync(KEY_PATH) }, + handleRpc, +); +rpcServer.listen(RPC_PORT, "0.0.0.0", () => { + console.log(`[mock-api] soroban rpc listening on https://localhost:${RPC_PORT}`); +}); \ No newline at end of file diff --git a/frontend/e2e/stream-creation.spec.ts b/frontend/e2e/stream-creation.spec.ts new file mode 100644 index 00000000..4209b7e3 --- /dev/null +++ b/frontend/e2e/stream-creation.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter"; + +test("single-screen /streams/create form validates input and submits a stream", async ({ + page, +}) => { + await mockConnectedWallet(page); + await page.goto("/streams/create"); + + await expect(page.getByRole("heading", { name: "Create New Stream" })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator(".wallet-chip").first()).toBeVisible({ timeout: 30_000 }); + + const amount = page.locator("#create-stream-amount"); + + await amount.fill("0"); + await expect(page.getByText("Amount must be greater than 0")).toBeVisible(); + + await page.locator("#recipient").fill(RECIPIENT_PUBLIC_KEY); + await page.locator("#create-stream-token").selectOption("USDC"); + await amount.fill("10"); + await page.locator("#create-stream-duration").fill("7"); + + await expect(page.getByText("0.00001653 USDC/sec")).toBeVisible(); + + await page.getByRole("button", { name: "Start Streaming" }).click(); + + await expect(page.getByText("Stream created successfully!")).toBeVisible({ + timeout: 20_000, + }); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 }); +}); \ No newline at end of file diff --git a/frontend/e2e/stream-lifecycle.spec.ts b/frontend/e2e/stream-lifecycle.spec.ts new file mode 100644 index 00000000..495c5792 --- /dev/null +++ b/frontend/e2e/stream-lifecycle.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter"; + +const MOCK_API_URL = "http://localhost:3100"; + +test("stream detail page shows stream state and reflects a withdrawal via mock events", async ({ + page, +}) => { + // Connect as the stream RECIPIENT so the Withdraw action is available. + await mockConnectedWallet(page, RECIPIENT_PUBLIC_KEY); + + await page.goto("/streams/42"); + + const withdrawnCard = page.locator(".glass-card", { hasText: "Withdrawn" }).first(); + const claimableCard = page.locator(".glass-card", { hasText: "Claimable" }).first(); + + await expect(withdrawnCard).toContainText("0 USDC", { timeout: 30_000 }); + // Live claimable is capped at the deposited amount by the dashboard contract. + await expect(claimableCard).toContainText("10000 USDC", { timeout: 30_000 }); + + const bump = await page.request.post(`${MOCK_API_URL}/__e2e/stream/42/withdraw`); + expect(bump.ok()).toBeTruthy(); + + await expect(withdrawnCard).toContainText("10 USDC", { timeout: 20_000 }); + + const eventRow = page + .locator("div.flex.items-center.gap-4.py-3", { hasText: "Withdrawn" }) + .first(); + await expect(eventRow).toBeVisible({ timeout: 20_000 }); + + const withdrawnButton = page + .getByRole("button", { name: /Withdraw/, exact: false }) + .first(); + await expect(withdrawnButton).toBeEnabled(); + await withdrawnButton.click(); + + await expect(page.getByText("Withdrawal successful!")).toBeVisible({ + timeout: 30_000, + }); +}); \ No newline at end of file diff --git a/frontend/e2e/utils/freighter.ts b/frontend/e2e/utils/freighter.ts new file mode 100644 index 00000000..330278a6 --- /dev/null +++ b/frontend/e2e/utils/freighter.ts @@ -0,0 +1,121 @@ +import type { Page } from "@playwright/test"; + +export const WALLET_PUBLIC_KEY = + "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U"; +export const RECIPIENT_PUBLIC_KEY = + "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG"; +export const SESSION_STORAGE_KEY = "flowfi.wallet.session.v1"; + +/** + * Injects a window-level mock for the Freighter browser extension using the + * postMessage protocol implemented by @stellar/freighter-api v6 + * (FREIGHTER_EXTERNAL_MSG_REQUEST / FREIGHTER_EXTERNAL_MSG_RESPONSE). + */ +export function freighterInitScript(address: string): string { + return ` + (() => { + const address = ${JSON.stringify(address)}; + + window.freighter = { version: "mock" }; + + const respond = (messageId, payload) => { + window.postMessage( + { + source: "FREIGHTER_EXTERNAL_MSG_RESPONSE", + messagedId: messageId, + extensionName: "FREIGHTER", + apiVersion: 1, + ...payload, + }, + window.location.origin, + ); + }; + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + const data = event.data || {}; + if (data.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST") return; + + switch (data.type) { + case "REQUEST_ACCESS": + case "REQUEST_PUBLIC_KEY": + respond(data.messageId, { publicKey: address, error: undefined }); + break; + case "REQUEST_CONNECTION_STATUS": + respond(data.messageId, { isConnected: true }); + break; + case "REQUEST_ALLOWED_STATUS": + case "SET_ALLOWED_STATUS": + respond(data.messageId, { isAllowed: true }); + break; + case "REQUEST_NETWORK_DETAILS": + respond(data.messageId, { + networkDetails: { + network: "TESTNET", + networkName: "SDF Test Network", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + sorobanRpcUrl: "https://soroban-testnet.stellar.org", + }, + error: undefined, + }); + break; + case "SUBMIT_TRANSACTION": + respond(data.messageId, { + signedTransaction: data.transactionXdr, + signerAddress: address, + error: undefined, + }); + break; + default: + respond(data.messageId, { + error: { code: -2, message: "Unsupported mock request: " + data.type }, + }); + break; + } + }); + })(); + `; +} + +export async function installFreighterMock( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await page.addInitScript(freighterInitScript(address)); +} + +/** + * Seeds a persisted, non-mocked wallet session so pages hydrate straight into + * the connected state without opening the connect modal. + */ +export async function seedWalletSession( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await page.addInitScript( + ({ key, storageKey }) => { + window.localStorage.setItem( + storageKey, + JSON.stringify({ + walletId: "freighter", + walletName: "Freighter", + publicKey: key, + connectedAt: new Date().toISOString(), + network: "Testnet", + mocked: false, + }), + ); + }, + { key: address, storageKey: SESSION_STORAGE_KEY }, + ); +} + +/** Sets up a mocked Freighter extension AND a persisted connected session. */ +export async function mockConnectedWallet( + page: Page, + address: string = WALLET_PUBLIC_KEY, +): Promise { + await installFreighterMock(page, address); + await seedWalletSession(page, address); +} \ No newline at end of file diff --git a/frontend/e2e/wallet-connection.spec.ts b/frontend/e2e/wallet-connection.spec.ts new file mode 100644 index 00000000..27557db0 --- /dev/null +++ b/frontend/e2e/wallet-connection.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; +import { installFreighterMock, WALLET_PUBLIC_KEY } from "./utils/freighter"; + +test("connects a Freighter wallet, shows the account badge, and disconnects", async ({ + page, +}) => { + await installFreighterMock(page); + + await page.goto("/"); + + const connectButton = page.locator(".wallet-connect-btn").first(); + await expect(connectButton).toBeVisible({ timeout: 30_000 }); + await connectButton.click(); + + const dialog = page.getByRole("dialog", { name: "Connect a wallet" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Connect Freighter" }).click(); + + const chip = page.locator(".wallet-chip").first(); + await expect(chip).toBeVisible({ timeout: 15_000 }); + await expect(chip).toContainText(WALLET_PUBLIC_KEY.slice(0, 4)); + + await chip.click(); + await page.getByRole("menuitem", { name: "Disconnect" }).click(); + await expect(page.locator(".wallet-connect-btn").first()).toBeVisible(); +}); \ No newline at end of file diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 31c5ed1c..c90d14b9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,13 @@ +import path from "node:path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // The workspace root lives one level above this directory. Pinning it here + // prevents Turbopack from inferring a wrong root when stray package-lock + // files exist outside the repo (e.g. ~/package-lock.json). + turbopack: { + root: path.join(path.dirname(new URL(import.meta.url).pathname), ".."), + }, // Enable tree-shaking for icon/utility libraries to reduce per-route // bundle sizes (Issue #1254). experimental: { diff --git a/frontend/package.json b/frontend/package.json index 02bb9898..07067e46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "codegen:api-types": "openapi-typescript http://localhost:3001/api-docs.json -o src/lib/api-types.generated.ts" + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "codegen:api-types": "openapi-typescript ../backend/swagger/flowfi.openapi.json -o src/lib/api-types.generated.ts" }, "dependencies": { "@stellar/freighter-api": "^6.0.1", @@ -41,6 +43,7 @@ "happy-dom": "^20.10.3", "jsdom": "^27.0.1", "openapi-typescript": "^7.13.0", + "@playwright/test": "^1.55.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.7" diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..3f75ee65 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from "@playwright/test"; + +const API_PORT = Number(process.env.MOCK_API_PORT || 3100); +const APP_PORT = Number(process.env.E2E_APP_PORT || 3101); +const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102); + +export default defineConfig({ + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI + ? [["list"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + use: { + baseURL: `http://localhost:${APP_PORT}`, + ignoreHTTPSErrors: true, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + ], + webServer: [ + { + command: "node ./e2e/mocks/api-server.mjs", + url: `http://localhost:${API_PORT}/health`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + { + command: "npm run dev -- -p " + APP_PORT, + url: `http://localhost:${APP_PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`, + NEXT_PUBLIC_STELLAR_NETWORK: "TESTNET", + NEXT_PUBLIC_SOROBAN_RPC_URL: `https://localhost:${RPC_PORT}/soroban`, + NEXT_PUBLIC_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", + NEXT_PUBLIC_STREAM_CONTRACT_ID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + }, + }, + ], +}); \ No newline at end of file diff --git a/frontend/src/__tests__/a11y.test.tsx b/frontend/src/__tests__/a11y.test.tsx new file mode 100644 index 00000000..5338f51f --- /dev/null +++ b/frontend/src/__tests__/a11y.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import axe, { type AxeResults } from "axe-core"; + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + wallets: [], + status: "disconnected", + selectedWalletId: null, + errorMessage: null, + connect: vi.fn(), + clearError: vi.fn(), + isConnected: vi.fn().mockResolvedValue({ isConnected: false }), + }), +})); + +vi.mock("@stellar/freighter-api", () => ({ + isConnected: () => Promise.resolve({ isConnected: false }), +})); + +// #1198 — Automated accessibility suite. +// +// Renders the primary interactive surfaces in happy-dom and asserts that axe +// reports zero critical/serious violations. `color-contrast` and any rule that +// depends on real browser layout (canvas-based color computation, native +// widget rendering) is disabled here because happy-dom cannot reproduce +// computed styles/canvas; the visual contrast audit is enforced separately via +// the dark-mode token changes and a browser-based Playwright run. + +import { IncomingStreamCard } from "@/components/streams/IncomingStreamCard"; +import { StreamDetailsModal } from "@/components/dashboard/StreamDetailsModal"; +import { TopUpModal } from "@/components/stream-creation/TopUpModal"; +import { WalletModal } from "@/components/wallet/WalletModal"; +import type { IncomingStreamRecord } from "@/lib/api/streams"; +import type { Stream } from "@/lib/dashboard"; + +const AXE_RULES = (() => { + const disabled: { + [key: string]: { enabled: false }; + } = { + "color-contrast": { enabled: false }, + }; + return disabled; +})(); + +async function assertNoCriticalOrSerious(container: HTMLElement) { + const results: AxeResults = await axe.run(container, { + rules: AXE_RULES, + resultTypes: ["violations"], + }); + const failures = results.violations.filter((v) => + v.impact === "critical" || v.impact === "serious" + ); + expect( + failures.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`), + ).toEqual([]); +} + +const streamRecord: IncomingStreamRecord = { + id: "stream-1", + streamId: 1, + sender: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + senderDisplay: "alice*stellar", + token: "USDC", + tokenAddress: "CAS3FLKZ2N6YUFY66TKSXJQVOTLNOB4IIBW7YHDWQ7M5AGPB2QRUUAAA", + ratePerSecond: 0.5, + deposited: 1000, + withdrawn: 0, + startTime: Math.floor(Date.now() / 1000) - 3600, + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, + isPaused: false, + pausedAt: null, + totalPausedDuration: 0, + status: "Active", +}; + +const mockStream: Stream = { + id: "stream-1", + recipient: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + amount: 1000, + token: "USDC", + status: "Active", + deposited: 1000, + withdrawn: 250, + date: "2026-08-30", + ratePerSecond: 0.5, + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, +}; + +afterEach(() => cleanup()); + +describe("Accessibility (axe-core) — critical & serious violations", () => { + it("IncomingStreamCard is accessible", async () => { + const { container } = render( + {}} + />, + ); + await assertNoCriticalOrSerious(container); + }); + + it("StreamDetailsModal is accessible with focusable content", async () => { + const { container } = render( + {}} + onCancelClick={() => {}} + onTopUpClick={() => {}} + />, + ); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(container.querySelector('[aria-modal="true"]')).not.toBeNull(); + + await assertNoCriticalOrSerious(container); + }); + + it("TopUpModal is accessible", async () => { + const { container } = render( + Promise.resolve()} + onClose={() => {}} + />, + ); + await assertNoCriticalOrSerious(container); + }); + + it("WalletModal is accessible", async () => { + const { container } = render( {}} />); + await assertNoCriticalOrSerious(container); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/live-value.test.tsx b/frontend/src/__tests__/live-value.test.tsx new file mode 100644 index 00000000..2b3c5428 --- /dev/null +++ b/frontend/src/__tests__/live-value.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; +import { LiveValue } from "@/components/ui/LiveValue"; + +afterEach(() => cleanup()); + +describe("LiveValue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders a polite atomic live region with the current value", () => { + const { container } = render(); + const region = container.querySelector('span[aria-live="polite"]'); + expect(region).not.toBeNull(); + expect(region?.getAttribute("aria-atomic")).toBe("true"); + expect(region?.textContent).toBe("Claimable amount 12.5 USDC"); + }); + + it("throttles announcements (at most one per cadence, not one per frame)", () => { + const { container, rerender } = render(); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1"); + + // Rapid value updates before the cadence elapses are not announced. + rerender(); + rerender(); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1"); + + // After the cadence, the latest value is announced. + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001"); + + // An unchanged value does not re-announce. + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001"); + }); + + it("announces the settled value within one cadence after streaming stops", () => { + const { container, rerender } = render(); + + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1.0005"); + }); + + it("provides no live region until a value is announced", () => { + const { container } = render(); + expect(container.querySelector('[aria-live]')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d42f8839..3b1d35f9 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,6 +1,23 @@ @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); +/* + * #1198 — Visible keyboard focus indicator (WCAG 2.1 AA 2.4.7). + * Every interactive element must show a clear focus ring when reached via + * keyboard; component-level custom focus states complement rather than + * replace this baseline. + */ +:focus-visible { + outline: 2px solid var(--accent-secondary); + outline-offset: 2px; + border-radius: 2px; +} + +/* Neutralize the default outline only when a richer ring is provided. */ +:focus-visible:has(.focus-ring) { + outline: none; +} + :root { --background: #020617; --foreground: #f8fafc; diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx index 63879898..b9f5b4fb 100644 --- a/frontend/src/app/streams/[id]/stream-details-content.tsx +++ b/frontend/src/app/streams/[id]/stream-details-content.tsx @@ -6,6 +6,7 @@ import { getApiBaseUrl } from "@/lib/api/_shared"; import { logger } from "@/lib/logger"; import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/Button"; +import { LiveValue } from "@/components/ui/LiveValue"; import toast from "react-hot-toast"; import { useWallet } from "@/context/wallet-context"; import { useStreamEvents } from "@/hooks/useStreamEvents"; @@ -731,6 +732,7 @@ function StatCard({ {value} {live && }

+ {live && } ); } diff --git a/frontend/src/components/dashboard/StreamDetailsModal.tsx b/frontend/src/components/dashboard/StreamDetailsModal.tsx index 999891c2..a09c04a4 100644 --- a/frontend/src/components/dashboard/StreamDetailsModal.tsx +++ b/frontend/src/components/dashboard/StreamDetailsModal.tsx @@ -63,7 +63,8 @@ export const StreamDetailsModal: React.FC = ({ {stream.recipient}