diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 3713c23a3..5246172d5 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -78,13 +78,7 @@ jobs: run: npm run ci:governance - name: Verify OpenAPI documentation - run: | - npm run generate:openapi - if [ -n "$(git status --porcelain openapi.json)" ]; then - echo "❌ openapi.json is out of date. Run 'npm run generate:openapi' locally and commit the changes." - git diff openapi.json - exit 1 - fi + run: npm run check:openapi - name: Check API contract schema snapshots run: npm run snapshots:check diff --git a/backend/openapi.json b/backend/openapi.json index 617156ff5..cb8899594 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "YieldVault Stellar RWA API", "version": "1.0.0", - "description": "API documentation for the YieldVault Stellar RWA backend. The API exposes vault deposits/withdrawals, portfolio reads, referrals, webhooks, and operational admin endpoints. All responses use the standard error envelope `{ status, message, error: { code, message, correlationId } }`.\n\n**Rate limiting:** Global limiter is 100 requests / 15 minutes per IP; authenticated API endpoints are limited to 30 requests / minute per API key. Exceeding a limit returns `429` with the standard error envelope and a `retryAfter` detail.", + "description": "API documentation for the YieldVault Stellar RWA backend. The API exposes vault deposits/withdrawals, portfolio reads, referrals, webhooks, and operational admin endpoints. Errors use the standard envelope `{ error, status, code, message, retryable, correlationId }`.\n\n**Rate limiting:** Global limiter is 100 requests / 15 minutes per IP; authenticated API endpoints are limited to 30 requests / minute per API key. Exceeding a limit returns `429` with the standard error envelope and a `retryAfter` detail.", "license": { "name": "MIT", "url": "https://spdx.org/licenses/MIT.html" @@ -442,145 +442,86 @@ "tags": [ "Vault" ], - "summary": "List vaults with a cached total count", - "description": "Send Authorization: ApiKey . Non-admin keys are restricted to their tenant; admin keys may filter tenantId or list all tenants. Excludes soft-deleted vaults. Rows are fresh; totals use a process-local 500-entry LRU for five seconds, invalidated on successful Prisma vault writes. Also available at /api/vaults and /vaults. Rate limited: global 100 req / 15 min per IP; authenticated APIs 30 req / min per key. `429` responses follow the standard error envelope with a `retryAfter` detail.", - "security": [ - { - "apiKeyAuth": [] - } - ], + "summary": "List vaults", + "description": "Paginated listing of active (non-deleted) vaults. `limit` is capped at 50 and `page` at 1000; exceeding `limit` fails fast with `400` / `LIMIT_EXCEEDED` instead of silently clamping, so a client that asks for an oversized page is never handed a response that looks complete. Rate limited: global 100 req / 15 min per IP; authenticated APIs 30 req / min per key. `429` responses follow the standard error envelope with a `retryAfter` detail.", "parameters": [ { - "name": "tenantId", - "in": "query", - "schema": { - "type": "string", - "minLength": 1 - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - } - }, - { - "name": "page", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "default": 1 - } + "$ref": "#/components/parameters/pageSize" }, { - "name": "sortBy", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "createdAt", - "updatedAt", - "id", - "aum" - ], - "default": "createdAt" - } + "$ref": "#/components/parameters/pageNumber" }, { - "name": "sortOrder", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - } + "$ref": "#/components/parameters/correlationId" } ], "responses": { "200": { - "description": "Paginated vault records (offset pagination only)", - "headers": { - "X-Cache": { - "description": "Whether the total count was reused", + "description": "One page of vaults", + "content": { + "application/json": { "schema": { - "type": "string", - "enum": [ - "HIT", - "MISS" - ] + "$ref": "#/components/schemas/VaultListResponse" + }, + "example": { + "data": [ + { + "id": "0f1b3a2c-6d4e-4a1b-9f0e-2c5d7e8b9a01", + "aum": 1250000, + "tvlUsd": "1250000.00", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z" + } + ], + "pagination": { + "count": 1, + "limit": 20, + "total": 1, + "nextCursor": null, + "prevCursor": null, + "currentPage": 1, + "totalPages": 1, + "hasNextPage": false, + "hasPrevPage": false + }, + "timestamp": "2026-01-01T00:00:00.000Z" } } - }, + } + }, + "400": { + "description": "`limit` above the published ceiling", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "tenantId": { - "type": "string" - }, - "aum": { - "type": "number" - }, - "tvlUsd": { - "type": [ - "string", - "null" - ] - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "deletedAt": { - "type": "null" - } - } - } - }, - "pagination": { - "$ref": "#/components/schemas/PaginationMeta" - }, - "timestamp": { - "type": "string", - "format": "date-time" - } + "$ref": "#/components/schemas/ErrorEnvelope" + }, + "example": { + "error": "Bad Request", + "status": 400, + "code": "LIMIT_EXCEEDED", + "message": "limit must not exceed 50 (received 100000).", + "retryable": false, + "details": { + "field": "limit", + "requested": 100000, + "maxLimit": 50, + "defaultLimit": 20, + "maxPage": 1000 } } } } }, - "400": { - "description": "Invalid query or unsupported cursor pagination" - }, - "401": { - "description": "Missing or invalid API key" - }, - "403": { - "description": "Missing or mismatched authenticated tenant" - }, - "500": { - "description": "Database query failed" + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } } } } @@ -813,95 +754,6 @@ } } }, - "/api/v1/vaults": { - "get": { - "tags": [ - "Vault" - ], - "summary": "List vaults", - "description": "Paginated listing of active (non-deleted) vaults. `limit` is capped at 50 and `page` at 1000; exceeding `limit` fails fast with `400` / `LIMIT_EXCEEDED` instead of silently clamping, so a client that asks for an oversized page is never handed a response that looks complete. Rate limited: global 100 req / 15 min per IP; authenticated APIs 30 req / min per key. `429` responses follow the standard error envelope with a `retryAfter` detail.", - "parameters": [ - { - "$ref": "#/components/parameters/pageSize" - }, - { - "$ref": "#/components/parameters/pageNumber" - }, - { - "$ref": "#/components/parameters/correlationId" - } - ], - "responses": { - "200": { - "description": "One page of vaults", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VaultListResponse" - }, - "example": { - "data": [ - { - "id": "0f1b3a2c-6d4e-4a1b-9f0e-2c5d7e8b9a01", - "aum": 1250000, - "tvlUsd": "1250000.00", - "createdAt": "2026-01-01T00:00:00.000Z", - "updatedAt": "2026-01-02T00:00:00.000Z" - } - ], - "pagination": { - "count": 1, - "limit": 20, - "total": 1, - "nextCursor": null, - "prevCursor": null, - "currentPage": 1, - "totalPages": 1, - "hasNextPage": false, - "hasPrevPage": false - }, - "timestamp": "2026-01-01T00:00:00.000Z" - } - } - } - }, - "400": { - "description": "`limit` above the published ceiling", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" - }, - "example": { - "error": "Bad Request", - "status": 400, - "code": "LIMIT_EXCEEDED", - "message": "limit must not exceed 50 (received 100000).", - "retryable": false, - "details": { - "field": "limit", - "requested": 100000, - "maxLimit": 50, - "defaultLimit": 20, - "maxPage": 1000 - } - } - } - } - }, - "429": { - "description": "Rate limited", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" - } - } - } - } - } - } - }, "/api/v1/vault/deposits": { "post": { "tags": [ @@ -1014,6 +866,17 @@ "description": "Strategy" } } + }, + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/vault/strategy", + "responses": { + "200": { + "description": "Successful response" + } + } } }, "/api/transactions": { @@ -1027,7 +890,6 @@ { "name": "cursor", "in": "query", - "description": "Preferred pagination. Opaque cursor in the form `_` returned as `meta.nextCursor`. Rows are ordered by `timestamp` desc then `id` desc and filtered to `(timestamp, id) < cursor`, so newly inserted transactions do not cause skipped or duplicated rows.", "schema": { "type": "string" } @@ -1035,8 +897,6 @@ { "name": "page", "in": "query", - "deprecated": true, - "description": "Deprecated offset-based pagination (`skip: (page-1)*limit`). Kept for backward compatibility only; may skip or duplicate rows when new transactions are inserted between fetches. Use `cursor` instead.", "schema": { "type": "integer" } @@ -1059,9 +919,7 @@ "meta": { "count": 0, "total": 0, - "hasNextPage": false, - "nextCursor": null, - "prevCursor": null + "hasNextPage": false } } } @@ -1281,6 +1139,2235 @@ } } } + }, + "/admin/latency-status": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/latency-status", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/sla/registry": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/sla/registry", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/rate-limits": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/rate-limits", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/maintenance/status": { + "get": { + "tags": [ + "maintenance" + ], + "summary": "GET /maintenance/status", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/docs/openapi.json": { + "get": { + "tags": [ + "docs" + ], + "summary": "GET /docs/openapi.json", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/deposits/v2": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/vault/deposits/v2", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/strategy/cooldown": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/strategy/cooldown", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/gasless-deposits": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/vault/gasless-deposits", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/receipts": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/receipts", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/wallet-aliases/link": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/wallet-aliases/link", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/wallet-aliases/resolve": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/wallet-aliases/resolve", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/wallet-aliases/{canonicalId}": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/wallet-aliases/{canonicalId}", + "parameters": [ + { + "name": "canonicalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/referrals/{wallet}": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/referrals/{wallet}", + "parameters": [ + { + "name": "wallet", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/referrals/code/{wallet}": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/referrals/code/{wallet}", + "parameters": [ + { + "name": "wallet", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/transactions": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/transactions", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/audit-logs": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/audit-logs", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/admin/diagnostics": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/admin/diagnostics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/transactions/export": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/transactions/export", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/portfolio/holdings": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/portfolio/holdings", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/history": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/history", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/apy/history": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/apy/history", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/auth/nonce": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/auth/nonce", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/auth/login", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/auth/refresh", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/webhooks": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/webhooks", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/webhooks", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/auth/logout", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/auth/logout-all": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/auth/logout-all", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/versions": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/versions", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/vault/transactions/export": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/vault/transactions/export", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/vaults": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/vaults", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/vaults": { + "get": { + "tags": [ + "vaults" + ], + "summary": "GET /vaults", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "POST /auth/logout", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/auth/logout-all": { + "post": { + "tags": [ + "auth" + ], + "summary": "POST /auth/logout-all", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/vault/summary": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/vault/summary", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/vault/metrics": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/vault/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/vault/apy": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/vault/apy", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/webhooks/verify": { + "post": { + "tags": [ + "webhooks" + ], + "summary": "POST /webhooks/verify", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vaults/{id}/health": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vaults/{id}/health", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v2/vaults/{id}/health": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v2/vaults/{id}/health", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/metrics": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/vault/apy": { + "get": { + "tags": [ + "api" + ], + "summary": "GET /api/v1/vault/apy", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/apy/backfill": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/apy/backfill", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/maintenance": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/maintenance", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/maintenance", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/maintenance/windows": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/maintenance/windows", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/maintenance/windows", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/maintenance/windows/{windowId}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/maintenance/windows/{windowId}", + "parameters": [ + { + "name": "windowId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/config-changes": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/config-changes", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/feature-flags/overrides": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/feature-flags/overrides", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/feature-flags/overrides", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/feature-flags/overrides/{id}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/feature-flags/overrides/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/cache/stats": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/cache/stats", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/cache/redis-status": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/cache/redis-status", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/cache": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/cache", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/cache/invalidate": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/cache/invalidate", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/events/replay": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/events/replay", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawal-limits/override": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/withdrawal-limits/override", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawal-limits/audit": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/withdrawal-limits/audit", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/emails/queue": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/emails/queue", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/emails/replay/{id}": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/emails/replay/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/allowlist/add": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/allowlist/add", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/allowlist/remove": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/allowlist/remove", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wallet-aliases": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/wallet-aliases", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wallet-aliases/{canonicalId}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/wallet-aliases/{canonicalId}", + "parameters": [ + { + "name": "canonicalId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/impersonate/sessions": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/impersonate/sessions", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/impersonate/sessions", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/impersonate/sessions/{id}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/impersonate/sessions/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/impersonate/{wallet}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/impersonate/{wallet}", + "parameters": [ + { + "name": "wallet", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/receipts": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/receipts", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/receipts/{id}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/receipts/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/receipts/{id}/verify": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/receipts/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/api-keys/register": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/api-keys/register", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/api-keys/rotate": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/api-keys/rotate", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/api-keys/revoke": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/api-keys/revoke", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/api-keys/audit-events": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/api-keys/audit-events", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/{id}/verify": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/webhooks/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/{id}": { + "patch": { + "tags": [ + "admin" + ], + "summary": "PATCH /admin/webhooks/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/webhooks/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/{id}/restore": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/webhooks/{id}/restore", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/dead-letter": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/webhooks/dead-letter", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/dead-letter/{id}/retry": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/webhooks/dead-letter/{id}/retry", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/deliveries": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/webhooks/deliveries", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/api/v1/webhooks/verify": { + "post": { + "tags": [ + "api" + ], + "summary": "POST /api/v1/webhooks/verify", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/audit/logs": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/audit/logs", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/jobs": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/exports/jobs", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/jobs/{id}/verify": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/exports/jobs/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/exports/bulk", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk/jobs": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/exports/bulk/jobs", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk/jobs/{id}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/exports/bulk/jobs/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk/jobs/{id}/cancel": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/exports/bulk/jobs/{id}/cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/exports/bulk/artifacts/{artifactId}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/exports/bulk/artifacts/{artifactId}", + "parameters": [ + { + "name": "artifactId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/prisma/config": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/prisma/config", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/monitor": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/jobs/monitor", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/jobs/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/transactions/backfill": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/transactions/backfill", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/transactions/backfill", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/transactions/backfill/{jobId}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/transactions/backfill/{jobId}", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/governance/snapshots": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/governance/snapshots", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/governance/snapshots/export": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/governance/snapshots/export", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/reports/exports": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/reports/exports", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/reports/exports/manifests": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/reports/exports/manifests", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/reports/exports/manifests/{id}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/reports/exports/manifests/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/reports/exports/manifests/{id}/verify": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/reports/exports/manifests/{id}/verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dashboard": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/jobs/dashboard", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/jobs/dead-letters", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/{id}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/jobs/dead-letters/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/jobs/dead-letters/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/{id}/retry": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/jobs/dead-letters/{id}/retry", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/{id}/resolve": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/jobs/dead-letters/{id}/resolve", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/bulk-retry": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/jobs/dead-letters/bulk-retry", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/bulk-discard": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/jobs/dead-letters/bulk-discard", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/jobs/dead-letters/process": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/jobs/dead-letters/process", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/idempotency/keys": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/idempotency/keys", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/idempotency/keys", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/idempotency/keys/{key}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/idempotency/keys/{key}", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/idempotency/retention/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/idempotency/retention/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/idempotency/retention/cleanup": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/idempotency/retention/cleanup", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/idempotency/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/idempotency/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/deduplication/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/webhooks/deduplication/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/deduplication/entries": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/webhooks/deduplication/entries", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/deduplication/{eventId}": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/webhooks/deduplication/{eventId}", + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/webhooks/deduplication": { + "delete": { + "tags": [ + "admin" + ], + "summary": "DELETE /admin/webhooks/deduplication", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/analytics/wallet-activity/heatmap": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/analytics/wallet-activity/heatmap", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/health/probes": { + "get": { + "tags": [ + "health" + ], + "summary": "GET /health/probes", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wal/entries": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/wal/entries", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wal/entries/{id}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/wal/entries/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wal/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/wal/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/wal/pending": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/wal/pending", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens/permissions": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/scoped-tokens/permissions", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/scoped-tokens", + "responses": { + "200": { + "description": "Successful response" + } + } + }, + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/scoped-tokens", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens/{keyId}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/scoped-tokens/{keyId}", + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens/{keyId}/rotations": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/scoped-tokens/{keyId}/rotations", + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens/{keyId}/rotate": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/scoped-tokens/{keyId}/rotate", + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/scoped-tokens/{keyId}/revoke": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/scoped-tokens/{keyId}/revoke", + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/withdrawals/recovery", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery/metrics": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/withdrawals/recovery/metrics", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery/{sagaId}": { + "get": { + "tags": [ + "admin" + ], + "summary": "GET /admin/withdrawals/recovery/{sagaId}", + "parameters": [ + { + "name": "sagaId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery/{sagaId}/resume": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/withdrawals/recovery/{sagaId}/resume", + "parameters": [ + { + "name": "sagaId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery/{sagaId}/resolve": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/withdrawals/recovery/{sagaId}/resolve", + "parameters": [ + { + "name": "sagaId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/withdrawals/recovery/sweep": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/withdrawals/recovery/sweep", + "responses": { + "200": { + "description": "Successful response" + } + } + } + }, + "/admin/vaults": { + "post": { + "tags": [ + "admin" + ], + "summary": "POST /admin/vaults", + "responses": { + "200": { + "description": "Successful response" + } + } + } } } } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index bdac45755..b33381090 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "yieldvault-backend", + "name": "yieldviault-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "yieldvault-backend", + "name": "yieldviault-backend", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -21,8 +21,9 @@ "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", "@yieldvault/api-schemas": "file:../packages/api-schemas", + "compression": "^1.7.4", "cors": "^2.8.6", - "decimal.js": "^10.4.3", + "decimal.js": "^10.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", "express-async-errors": "^3.1.1", @@ -46,13 +47,14 @@ "@opentelemetry/resources": "^1.23.0", "@opentelemetry/sdk-node": "^0.50.0", "@opentelemetry/semantic-conventions": "^1.23.0", + "@types/compression": "^1.7.4", "@types/cors": "^2.8.19", "@types/express": "^4.17.17", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/pg": "^8.20.0", "@types/supertest": "^2.0.12", - "@types/swagger-jsdoc": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", @@ -4079,6 +4081,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5420,6 +5433,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6646,6 +6718,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8453,6 +8526,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index 334bf26c0..1ba14f021 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "format": "prettier --write src", "audit": "npm audit", "generate:openapi": "tsx scripts/generate-openapi.ts", + "check:openapi": "tsx scripts/generate-openapi.ts --check --fail-on-drift", "ci:governance": "npm run lint && npm run test && node scripts/check-migrations.js && npm run check:migrations:canary && npm run snapshots:check && node scripts/check-adrs.js", "check:migrations:canary": "tsx scripts/canary-migration-check.ts", "snapshots:write": "tsx scripts/check-schema-snapshots.ts --write", @@ -60,7 +61,7 @@ "@prisma/instrumentation": "^7.8.0", "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", - "@yieldviault/api-schemas": "file:../packages/api-schemas", + "@yieldvault/api-schemas": "file:../packages/api-schemas", "compression": "^1.7.4", "cors": "^2.8.6", "decimal.js": "^10.6.0", diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index cc92344ad..ff5eddbc1 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -8,29 +8,92 @@ const defaultOutputPath = path.resolve(__dirname, '../openapi.json'); type Routable = Parameters[0]; /** - * Builds the spec, folds in every route reachable from `app` (recursing through - * nested routers) that the hand-written definition does not document, and - * writes it to `outputPath`. + * Builds the spec and folds in every route reachable from `app` (recursing + * through nested routers) that the hand-written definition does not document. + * Always LF, matching the repository's `.gitattributes` (issue #1374). */ -export function generateOpenApi(app?: Routable, outputPath = defaultOutputPath) { +export function buildOpenApi(app?: Routable) { const spec = JSON.parse(JSON.stringify(specs)); const added = app ? mergeDiscoveredRoutes(spec, collectRoutes(app)) : []; + return { spec, added, content: JSON.stringify(spec, null, 2) }; +} - const content = JSON.stringify(spec, null, 2); - const eol = fs.existsSync(outputPath) && fs.readFileSync(outputPath, 'utf8').includes('\r\n') ? '\r\n' : '\n'; - fs.writeFileSync(outputPath, content.replace(/\n/g, eol), 'utf8'); - return { spec, added }; +/** Builds the spec and writes it to `outputPath`. */ +export function generateOpenApi(app?: Routable, outputPath = defaultOutputPath) { + const result = buildOpenApi(app); + fs.writeFileSync(outputPath, result.content, 'utf8'); + return result; } -async function main() { - console.log('Generating openapi.json...'); +/** + * Lists `METHOD path` operations that differ between the committed spec and a + * freshly generated one: missing from the file (`+`), stale in the file (`-`), + * or changed (`~`). + */ +export function diffOpenApi(committed: string, generated: string): string[] { + const operations = (raw: string): Map => { + const out = new Map(); + let paths: Record> = {}; + try { + paths = JSON.parse(raw).paths ?? {}; + } catch { + return out; + } + for (const [route, item] of Object.entries(paths)) { + for (const [method, op] of Object.entries(item)) { + out.set(`${method.toUpperCase()} ${route}`, JSON.stringify(op)); + } + } + return out; + }; + + const before = operations(committed); + const after = operations(generated); + const drift: string[] = []; + for (const [key, op] of after) { + if (!before.has(key)) drift.push(`+ ${key}`); + else if (before.get(key) !== op) drift.push(`~ ${key}`); + } + for (const key of before.keys()) { + if (!after.has(key)) drift.push(`- ${key}`); + } + if (!drift.length && committed !== generated) drift.push('~ (non-path sections)'); + return drift; +} + +async function main(argv = process.argv.slice(2)) { + const check = argv.includes('--check'); + const failOnDrift = argv.includes('--fail-on-drift'); + process.env.NODE_ENV ??= 'test'; let app: Routable | undefined; try { app = (await import('../src/index')).default as unknown as Routable; } catch (error) { + if (failOnDrift) { + console.error('❌ Could not load the Express app to discover routes:', error); + process.exit(1); + } console.warn('⚠️ Could not load the Express app; routes will NOT be auto-discovered:', error); } + + if (check) { + const { content } = buildOpenApi(app); + const committed = fs.existsSync(defaultOutputPath) + ? fs.readFileSync(defaultOutputPath, 'utf8').replace(/\r\n/g, '\n') + : ''; + const drift = diffOpenApi(committed, content); + if (!drift.length) { + console.log('✅ openapi.json is up to date with the routes wired in src/index.ts'); + process.exit(0); + } + console.error(`❌ openapi.json has drifted from the router (${drift.length} operation(s)):`); + for (const line of drift) console.error(` ${line}`); + console.error("Run 'npm run generate:openapi' and commit openapi.json."); + process.exit(failOnDrift ? 1 : 0); + } + + console.log('Generating openapi.json...'); const { added } = generateOpenApi(app); if (added.length) { console.log(`Added ${added.length} undocumented route(s) discovered from the router stack.`); @@ -44,17 +107,4 @@ if (require.main === module) { console.error('❌ Failed to generate openapi.json:', error); process.exit(1); }); -try { - console.log('Generating openapi.json...'); - // Always emit LF, matching the repository's `.gitattributes` (`* text=auto eol=lf`). - // Previously the generator preserved whatever EOL the existing file happened to use, - // which made output platform-dependent: on Windows a CRLF working-tree copy was kept - // as CRLF, so regenerating a byte-identical spec showed up as a phantom `M` change - // (see issue #1374). - const content = JSON.stringify(specs, null, 2); - fs.writeFileSync(outputPath, content, 'utf8'); - console.log(`✅ openapi.json generated successfully at ${outputPath}`); -} catch (error) { - console.error('❌ Failed to generate openapi.json:', error); - process.exit(1); } diff --git a/backend/src/__tests__/openapiDrift.test.ts b/backend/src/__tests__/openapiDrift.test.ts new file mode 100644 index 000000000..5365868e5 --- /dev/null +++ b/backend/src/__tests__/openapiDrift.test.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; +import app from '../index'; +import { buildOpenApi, diffOpenApi } from '../../scripts/generate-openapi'; + +const committedPath = path.join(__dirname, '..', '..', 'openapi.json'); + +describe('openapi.json drift (issue #1544)', () => { + const generated = buildOpenApi(app as never); + + it('matches the spec regenerated from the routes wired in index.ts', () => { + const committed = fs.readFileSync(committedPath, 'utf8').replace(/\r\n/g, '\n'); + expect(diffOpenApi(committed, generated.content)).toEqual([]); + }); + + it.each([ + ['post', '/admin/exports/bulk'], + ['get', '/admin/exports/bulk/jobs'], + ['post', '/admin/impersonate/sessions'], + ['get', '/admin/impersonate/{wallet}'], + ['post', '/admin/events/replay'], + ['get', '/api/v1/admin/diagnostics'], + ])('documents %s %s', (method, route) => { + expect(generated.spec.paths[route]?.[method]).toBeDefined(); + }); + + it('documents every route reachable from the app', () => { + const operations = Object.values(generated.spec.paths as Record).reduce( + (n, item) => n + Object.keys(item).length, + 0, + ); + expect(operations).toBeGreaterThan(100); + }); +}); + +describe('diffOpenApi', () => { + const spec = (paths: Record>) => + JSON.stringify({ openapi: '3.1.0', paths }, null, 2); + + it('reports added, removed and changed operations', () => { + const committed = spec({ '/a': { get: { summary: 'a' } }, '/gone': { get: {} } }); + const generated = spec({ '/a': { get: { summary: 'changed' } }, '/new': { post: {} } }); + expect(diffOpenApi(committed, generated).sort()).toEqual( + ['+ POST /new', '- GET /gone', '~ GET /a'].sort(), + ); + }); + + it('reports nothing for an identical spec', () => { + const same = spec({ '/a': { get: {} } }); + expect(diffOpenApi(same, same)).toEqual([]); + }); +}); diff --git a/backend/src/__tests__/vaultsListLimits.test.ts b/backend/src/__tests__/vaultsListLimits.test.ts index 9611473f0..01a0744e6 100644 --- a/backend/src/__tests__/vaultsListLimits.test.ts +++ b/backend/src/__tests__/vaultsListLimits.test.ts @@ -22,6 +22,7 @@ import { } from '../middleware/paginationGuard'; import { DEFAULT_PAGINATION_CONFIG, parsePaginationQuery } from '../pagination'; import { specs } from '../swagger'; +import { buildOpenApi } from '../../scripts/generate-openapi'; import { VaultListResponseSchema, validateResponseAgainstSchema } from '../apiContractSnapshots'; import { prisma } from '../prisma'; import * as fs from 'fs'; @@ -327,7 +328,9 @@ describe('Issue #1430 — GET /api/v1/vaults pagination limits', () => { fs.readFileSync(path.join(__dirname, '..', '..', 'openapi.json'), 'utf8'), ) as typeof specs; - expect(JSON.stringify(committed)).toBe(JSON.stringify(specs)); + // The committed file is the spec source plus every route discovered from + // the app's router stack (see scripts/generate-openapi.ts). + expect(committed).toEqual(buildOpenApi(app as never).spec); }); }); }); diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 7b7fe922a..6be86ab88 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -15,6 +15,7 @@ */ import crypto from 'crypto'; +import { prisma } from './prisma'; import { logger } from './middleware/structuredLogging'; import { emitTransactionEvent, @@ -170,14 +171,9 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { - if (!prisma.eventOutbox) { - throw new Error('prisma.eventOutbox is not initialized. Ensure Prisma schema includes eventOutbox model.'); - } - assertEventOutboxModelAvailable(); const now = new Date(); const vaultId = input.vaultId ?? input.aggregateId; - const record = await prisma.eventOutbox.create({ const record = await getEventOutboxDelegate().create({ data: { id: `obx-${crypto.randomUUID()}`, @@ -222,8 +218,6 @@ class EventOutboxService { // We select the head-of-line event per vault to enforce strict // per-vault ordering (a vault's next event is only processed after // its predecessor has been relayed or dead-lettered). - const candidates = await prisma.eventOutbox.findMany({ - // 1. Find eligible entries: pending or failed entries whose lock is expired const candidates = await getEventOutboxDelegate().findMany({ where: { status: { in: ['pending', 'failed'] }, @@ -265,8 +259,6 @@ class EventOutboxService { // 2. Lock the claimed entries by updating lockedAt/lockedBy in bulk const candidateIds = orderedCandidates.map((e) => e.id); - await prisma.eventOutbox.updateMany({ - const candidateIds = candidates.map((e) => e.id); await getEventOutboxDelegate().updateMany({ where: { id: { in: candidateIds }, @@ -318,12 +310,11 @@ class EventOutboxService { payload, ); - await getEventOutboxDelegate().update({ // Do not write the mark-as-sent update after abort; the event will be // re-relayed after restart (at-least-once delivery). if (signal?.aborted) break; - await prisma.eventOutbox.update({ + await getEventOutboxDelegate().update({ where: { id: entry.id }, data: { status: 'relayed', @@ -488,7 +479,6 @@ class EventOutboxService { */ async replayOnStartup(): Promise { assertEventOutboxModelAvailable(); - const pendingCount = await prisma.eventOutbox.count({ const pendingCount = await getEventOutboxDelegate().count({ where: { status: { in: ['pending', 'failed'] } }, }); diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index cf111e0d6..fcc20199c 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -22,13 +22,9 @@ */ import crypto from 'crypto'; -import NodeCache from 'node-cache'; import { prisma } from './prisma'; -import { redisClientManager } from './rateLimiter'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; -import NodeCache from 'node-cache'; -import { redisClientManager } from './rateLimiter'; /** * The replay cache used by money-moving operations (deposits, withdrawals, @@ -396,306 +392,6 @@ declare global { } } -// ─── Error Classes ────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -// ─── Internal Types ───────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -// ─── Redis key prefix ─────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Fingerprint helper ───────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - -// ─── IdempotencyStore ─────────────────────────────────────────────────── - -export class IdempotencyStore { - private readonly completedResponses: NodeCache; - private readonly pendingResponses = new Map>(); - - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.completedResponses = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.completedResponses.on('expired', (_key: string, _value: unknown) => { - this._evictions++; - }); - } - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - let completed = await this.redisGet(key); - if (!completed) { - completed = this.completedResponses.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - await this.redisSet(key, completed); - this.completedResponses.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - const metadata: IdempotencyKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - await this.redisSet(key, stored); - this.completedResponses.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.completedResponses.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.completedResponses.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - async deleteKey(key: string): Promise { - const deletedLocal = this.completedResponses.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - clear(): void { - const count = this.completedResponses.keys().length + this.pendingResponses.size; - this._evictions += count; - this.completedResponses.flushAll(); - this.pendingResponses.clear(); - } - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.completedResponses.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.completedResponses.keys()) { - const entry = this.completedResponses.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.completedResponses.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - } -} - -// ─── Singleton ────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - // ─── Cleanup ──────────────────────────────────────────────────────────────── /** @@ -739,384 +435,3 @@ export function startIdempotencyCleanupTask(intervalMs = 3600000): NodeJS.Timer }); }, intervalMs); } - -// ─── In-flight store (Redis + NodeCache) ───────────────────────────────────── -// -// Issue #811: multi-instance deployments lost idempotency guarantees on pod -// recycle because responses were stored only in-process. This revision -// persists completed responses to Redis using SET … EX so all replicas share -// the same store, falling back to NodeCache when Redis is unavailable -// (fail-open, with a warning). -// -// The Prisma helpers above (enforceIdempotency / *IdempotencyRecord) are the -// newer, DB-backed design but are not yet wired into any route. The mutation -// endpoints (deposit/withdrawal/transfer), the admin key routes and the -// retention sweeper all still call the store below, so both surfaces live here -// until those call sites are migrated. Restoring this store is what keeps -// `idempotencyStore`, `IdempotencyStore` and `IdempotencyConflictError` -// resolvable for those consumers. -// ─── Response Store (Redis + NodeCache) ───────────────────────────────────── -// -// Shared response store used by vault endpoints and the transfer orchestrator. -// Completed responses are persisted to Redis (when available) with a NodeCache -// in-process fallback. Retention sweeps live in idempotencyRetention.ts. - -// ─── Public Types ───────────────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -/** Metadata attached to every idempotency key entry. */ -export interface IdempotencyEntryMetadata { -export interface IdempotencyStoreKeyMetadata { - /** ISO-8601 timestamp when the key was first stored. */ - createdAt: string; - /** ISO-8601 timestamp of the most recent access (read or write). */ - lastAccessedAt: string; - /** Number of times this key has been replayed (returned cached result). */ - replayCount: number; - /** Current state of the entry. */ - status: 'pending' | 'completed'; -} - -/** Summary returned by GET /admin/idempotency/keys. */ -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyEntryMetadata; - metadata: IdempotencyStoreKeyMetadata; -} - -/** Snapshot of store-wide observability counters. */ -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyEntryMetadata; -// ─── Internal Types ─────────────────────────────────────────────────────────── - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyStoreKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyEntryMetadata; -} - - metadata: IdempotencyStoreKeyMetadata; -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Redis key prefix ───────────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Store ──────────────────────────────────────────────────────────────────── - -export class IdempotencyStore { - /** Fallback in-process store used when Redis is unavailable. */ - private readonly localCache: NodeCache; - private readonly pendingResponses = new Map>(); - - // Observability counters - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.localCache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.localCache.on('expired', () => { this._evictions++; }); - } - - // ─── Redis helpers ───────────────────────────────────────────────────────── - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - // ─── Core execute ────────────────────────────────────────────────────────── - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - // 1. Check Redis first, then local cache - let completed = await this.redisGet(key); - if (!completed) { - completed = this.localCache.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - // Refresh in both backends; errors are non-fatal - await this.redisSet(key, completed); - this.localCache.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - // 2. Currently in-flight - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - // 3. First execution - const metadata: IdempotencyEntryMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - const metadata: IdempotencyStoreKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - // Persist to Redis (primary) and local cache (fallback/fast-path) - await this.redisSet(key, stored); - this.localCache.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - // ─── Inspection ──────────────────────────────────────────────────────────── - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.localCache.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.localCache.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - // ─── Targeted deletion ───────────────────────────────────────────────────── - - async deleteKey(key: string): Promise { - const deletedLocal = this.localCache.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - // ─── Global clear (admin only) ───────────────────────────────────────────── - - clear(): void { - const count = this.localCache.keys().length + this.pendingResponses.size; - this._evictions += count; - this.localCache.flushAll(); - this.pendingResponses.clear(); - // Note: Redis keys are prefixed with REDIS_PREFIX; a full Redis FLUSHDB is intentionally - // not issued here to avoid clearing unrelated keys. Use deleteKey() per-key when needed. - } - - // ─── Observability ───────────────────────────────────────────────────────── - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.localCache.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - // ─── Retention cleanup ───────────────────────────────────────────────────── - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - /** - * Removes entries older than `retentionMs` from the local cache and Redis. - * With `dryRun`, only counts the entries that would be removed: nothing is - * deleted and the eviction counter is left untouched (Issue #1375). - */ - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.localCache.keys()) { - const entry = this.localCache.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.localCache.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; - } -} - -// ─── Singleton ──────────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - -// ─── Fingerprint helper ─────────────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - diff --git a/backend/src/index.ts b/backend/src/index.ts index 0cf813306..22150059c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1003,6 +1003,7 @@ apiV1.use('/wallet-aliases', walletAliasRouter); apiV1.use('/referrals', referralRouter); apiV1.use('/transactions', transactionRouter); apiV1.use('/audit-logs', auditLogRouter); +apiV1.get('/admin/diagnostics', validateApiKey, diagnosticsBundleHandler); apiV1.use('/', listRouter); // Register vault data cache invalidation hooks diff --git a/backend/src/jobs/outboxPoller.ts b/backend/src/jobs/outboxPoller.ts index d89aeaaef..67b332d14 100644 --- a/backend/src/jobs/outboxPoller.ts +++ b/backend/src/jobs/outboxPoller.ts @@ -50,7 +50,8 @@ export function resolveVaultId(event: EventOutbox): string { // Malformed payloads: fall back to the aggregate id below. } - return event.aggregateId || GLOBAL_VAULT_KEY:} + return event.aggregateId || GLOBAL_VAULT_KEY; +} /** * Poller that delivers outbox events while preserving per-vault ordering. diff --git a/backend/src/middleware/apiError.ts b/backend/src/middleware/apiError.ts index 8c397b3b0..8ef150e60 100644 --- a/backend/src/middleware/apiError.ts +++ b/backend/src/middleware/apiError.ts @@ -79,21 +79,6 @@ export function buildApiErrorBody(options: BuildApiErrorBodyOptions): ApiErrorBo ...(options.correlationId ? { correlationId: options.correlationId } : {}), ...(options.traceId ? { traceId: options.traceId } : {}), }; - /** - * Short, stable headline for the failure. Part of the published error - * contract (see `ErrorEnvelope` in openapi.json) alongside `message`. - */ - summary?: string; - /** - * Field-level failures, surfaced at the top level for clients that read - * `errors` directly. `details` carries the same payload for the generic - * envelope. - */ - errors?: unknown[]; - /** Request path, echoed on routing failures so clients can log it directly. */ - path?: string; - summary?: string; - errors?: unknown; } export function sendApiError( diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index cdb3d5cd9..656da9796 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -1 +1,421 @@ -aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBjcnlwdG8gZnJvbSAnY3J5cHRvJzsKaW1wb3J0IHsgcHJpc21hIH0gZnJvbSAnLi4vcHJpc21hJzsKaW1wb3J0IHsgc2VuZEFwaUVycm9yIH0gZnJvbSAnLi9hcGlFcnJvcic7CgpleHBvcnQgdHlwZSBBcGlLZXlSb2xlID0gJ3ZpZXdlcicgfCAnb3BlcmF0b3InIHwgJ2FkbWluJyB8ICdzdXBlci1hZG1pbic7CgppbnRlcmZhY2UgQXBpS2V5TWV0YWRhdGEgewogIHJvbGU6IEFwaUtleVJvbGU7CiAgY3JlYXRlZEF0OiBEYXRlOwogIHJvdGF0ZWRBdD86IERhdGU7CiAgcmV2b2tlZEF0PzogRGF0ZTsKICB0ZW5hbnRJZDogc3RyaW5nOwogIHNjb3Blczogc3RyaW5nW107CiAgZXhwaXJlc0F0PzogRGF0ZSB8IG51bGw7CiAgbGFzdFVzZWRBdD86IERhdGUgfCBudWxsOwp9CgpkZWNsYXJlIGdsb2JhbCB7CiAgbmFtZXNwYWNlIEV4cHJlc3MgewogICAgaW50ZXJmYWNlIFJlcXVlc3QgewogICAgICBhdXRoQXBpS2V5SGFzaD86IHN0cmluZzsKICAgICAgYXV0aEFwaUtleVJvbGU/OiBBcGlLZXlSb2xlOwogICAgICBhdXRoQXBpS2V5VGVuYW50SWQ/OiBzdHJpbmc7CiAgICAgIGF1dGhBcGlLZXlTY29wZXM/OiBzdHJpbmdbXTsKICAgICAgYXV0aEFwaUtleUxhc3RVc2VkQXQ/OiBEYXRlIHwgbnVsbDsKICAgICAgYXV0aEFwaUtleUxlZ2FjeT86IGJvb2xlYW47CiAgICB9CiAgfQp9Cgpjb25zdCBST0xFX1BSRUNFREVOQ0U6IFJlY29yZDxBcGlLZXlSb2xlLCBudW1iZXI+ID0gewogIHZpZXdlcjogMCwKICBvcGVyYXRvcjogMSwKICBhZG1pbjogMiwKICAnc3VwZXItYWRtaW4nOiAzLAp9OwoKZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUFwaUtleVJvbGUocmF3OiB1bmtub3duKTogQXBpS2V5Um9sZSB8IG51bGwgewogIGlmICh0eXBlb2YgcmF3ICE9PSAnc3RyaW5nJykgewogICAgcmV0dXJuIG51bGw7CiAgfQogIGNvbnN0IHZhbHVlID0gcmF3LnRyaW0oKS50b0xvd2VyQ2FzZSgpOwogIGlmICh2YWx1ZSA9PT0gJ3ZpZXdlcicgfHwgdmFsdWUgPT09ICdvcGVyYXRvcicgfHwgdmFsdWUgPT09ICdhZG1pbicgfHwgdmFsdWUgPT09ICdzdXBlci1hZG1pbicpIHsKICAgIHJldHVybiB2YWx1ZSBhcyBBcGlLZXlSb2xlOwogIH0KICByZXR1cm4gbnVsbDsKfQoKZnVuY3Rpb24gcGFyc2VTY29wZXMocmF3OiB1bmtub3duKTogc3RyaW5nW10gewogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHJldHVybiByYXcgYXMgc3RyaW5nW107CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnKSB7CiAgICB0cnkgewogICAgICBjb25zdCBwYXJzZWQgPSBKU09OLnBhcnNlKHJhdyk7CiAgICAgIHJldHVybiBBcnJheS5pc0FycmF5KHBhcnNlZCkgPyBwYXJzZWQgOiBbXTsKICAgIH0gY2F0Y2ggewogICAgICByZXR1cm4gW107CiAgICB9CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGludGVyZmFjZSBQZXJzaXN0ZWRBcGlLZXlTdGF0dXMgewogIGlzQWN0aXZlOiBib29sZWFuOwogIGRlbGV0ZWRBdD86IERhdGUgfCBudWxsOwogIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogIHRlbmFudD86IHsgZGVsZXRlZEF0PzogRGF0ZSB8IG51bGwgfTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGlzUGVyc2lzdGVkQXBpS2V5VXNhYmxlKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBhcGlLZXkgaXMgUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmlzQWN0aXZlICYmICFhcGlLZXkuZGVsZXRlZEF0ICYmICFhcGlLZXkudGVuYW50Py5kZWxldGVkQXQpOwp9CgpleHBvcnQgZnVuY3Rpb24gaXNQZXJzaXN0ZWRBcGlLZXlFeHBpcmVkKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBib29sZWFuIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmV4cGlyZXNBdCAmJiBhcGlLZXkuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcTogUmVxdWVzdCk6IHN0cmluZyB8IG51bGwgewogIGNvbnN0IGZyb21QYXJhbXMgPSByZXEucGFyYW1zPy50ZW5hbnRJZDsKICBpZiAodHlwZW9mIGZyb21QYXJhbXMgPT09ICdzdHJpbmcnICYmIGZyb21QYXJhbXMudHJpbSgpKSB7CiAgICByZXR1cm4gZnJvbVBhcmFtcy50cmltKCk7CiAgfQogIGNvbnN0IGZyb21RdWVyeSA9IHJlcS5xdWVyeT8udGVuYW50SWQ7CiAgaWYgKHR5cGVvZiBmcm9tUXVlcnkgPT09ICdzdHJpbmcnICYmIGZyb21RdWVyeS50cmltKCkpIHsKICAgIHJldHVybiBmcm9tUXVlcnkudHJpbSgpOwogIH0KICBjb25zdCBmcm9tSGVhZGVyID0gcmVxLmdldD8uKCd4LXRlbmFudC1pZCcpOwogIGlmICh0eXBlb2YgZnJvbUhlYWRlciA9PT0gJ3N0cmluZycgJiYgZnJvbUhlYWRlci50cmltKCkpIHsKICAgIHJldHVybiBmcm9tSGVhZGVyLnRyaW0oKTsKICB9CiAgcmV0dXJuIG51bGw7Cn0KCmV4cG9ydCBmdW5jdGlvbiBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxOiBSZXF1ZXN0KTogc3RyaW5nW10gewogIGNvbnN0IHJhdyA9IChyZXEgYXMgUmVxdWVzdCAmIHsgcmVxdWlyZWRTY29wZXM/OiB1bmtub3duIH0pLnJlcXVpcmVkU2NvcGVzOwogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHsKICAgIHJldHVybiByYXcuZmlsdGVyKChzKTogcyBpcyBzdHJpbmcgPT4gdHlwZW9mIHMgPT09ICdzdHJpbmcnKTsKICB9CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnICYmIHJhdy50cmltKCkpIHsKICAgIHJldHVybiByYXcuc3BsaXQoJywnKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbik7CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGhhc1JlcXVpcmVkU2NvcGVzKGF2YWlsYWJsZTogc3RyaW5nW10sIHJlcXVpcmVkOiBzdHJpbmdbXSk6IGJvb2xlYW4gewogIGlmIChyZXF1aXJlZC5sZW5ndGggPT09IDApIHJldHVybiB0cnVlOwogIGlmIChhdmFpbGFibGUuaW5jbHVkZXMoJyonKSkgcmV0dXJuIHRydWU7CiAgcmV0dXJuIHJlcXVpcmVkLmV2ZXJ5KChzY29wZSkgPT4gYXZhaWxhYmxlLmluY2x1ZGVzKHNjb3BlKSk7Cn0KCmZ1bmN0aW9uIHNlbmRVbmF1dGhvcml6ZWQoCiAgcmVxOiBSZXF1ZXN0LAogIHJlczogUmVzcG9uc2UsCiAgY29kZTogc3RyaW5nLAogIG1lc3NhZ2U6IHN0cmluZywKICBzY2hlbWU6IHN0cmluZyA9ICdCZWFyZXInCik6IHZvaWQgewogIHJlcy5zZXRIZWFkZXIoJ1dXVy1BdXRoZW50aWNhdGUnLCBzY2hlbWUpOwogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlLAogICAgbWVzc2FnZSwKICAgIHJldHJhYmxlOiBmYWxzZSwKICB9KTsKfQoKY29uc3QgTEVHQUNZX0VOVl9LRVkgPSBwcm9jZXNzLmVudi5BUElfS0VZOwpjb25zdCBMRUdBQ1lfRU5WX1RFTkFOVCA9IHByb2Nlc3MuZW52LkFQSV9LRVlfVEVOQU5UX0lEIHx8ICdkZWZhdWx0JzsKY29uc3QgTEVHQUNZX0VOVl9TQ09QRVMgPSAocHJvY2Vzcy5lbnYuQVBJX0tFWV9TQ09QRVMgfHwgJyonKQogIC5zcGxpdCgnLCcpCiAgLm1hcCgocykgPT4gcy50cmltKCkpCiAgLmZpbHRlcihCb29sZWFuKTsKCmZ1bmN0aW9uIGNoZWNrTGVnYWN5RW52S2V5KHByb3ZpZGVkS2V5OiBzdHJpbmcpOiBib29sZWFuIHsKICBpZiAoIUxFR0FDWV9FTlZfS0VZKSByZXR1cm4gZmFsc2U7CiAgY29uc3QgYSA9IEJ1ZmZlci5mcm9tKHByb3ZpZGVkS2V5KTsKICBjb25zdCBiID0gQnVmZmVyLmZyb20oTEVHQUNZX0VOVl9LRVkpOwogIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTsKICByZXR1cm4gY3J5cHRvLnRpbWluZ1NhZmVFcXVhbChhLCBiKTsKfQoKZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZhbGlkYXRlQXBpS2V5KAogIHJlcTogUmVxdWVzdCwKICByZXM6IFJlc3BvbnNlLAogIG5leHQ6IE5leHRGdW5jdGlvbgopOiBQcm9taXNlPHZvaWQ+IHsKICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmdldD8uKCdBdXRob3JpemF0aW9uJykgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBhdXRoSGVhZGVyLm1hdGNoKC9eQXBpS2V5XHMrKC4rKSQvaSk7CiAgaWYgKCFtYXRjaCkgewogICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgIHN0YXR1czogNDAxLAogICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX01JU1NJTkcnLAogICAgICBtZXNzYWdlOiAnTWlzc2luZyBvciBpbnZhbGlkIEFQSSBrZXknLAogICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgfSk7CiAgICByZXR1cm47CiAgfQogIGNvbnN0IHByb3ZpZGVkS2V5ID0gbWF0Y2hbMV07CiAgY29uc3QgaGFzaGVkID0gY3J5cHRvLmNyZWF0ZUhhc2goJ3NoYTI1NicpLnVwZGF0ZShwcm92aWRlZEtleSkuZGlnZXN0KCdoZXgnKTsKICBjb25zdCByZXF1ZXN0ZWRUZW5hbnRJZCA9IGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcSk7CiAgY29uc3QgcmVxdWlyZWRTY29wZXMgPSBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxKTsKCiAgLy8gVHJ5IFByaXNtYSBmaXJzdDsgZmFsbCBiYWNrIHRvIGluLW1lbW9yeSBzdG9yZSB3aGVuIHRoZSB0YWJsZSBkb2Vzbid0IGV4aXN0CiAgLy8gKGUuZy4gaW4gdGVzdHMgd2l0aCBhbiB1bi1taWdyYXRlZCBTUUxpdGUgZGF0YWJhc2UpLgogIGxldCBhcGlLZXk6IHsKICAgIGlkPzogc3RyaW5nOwogICAgaGFzaGVkS2V5OiBzdHJpbmc7CiAgICByb2xlOiBzdHJpbmc7CiAgICB0ZW5hbnRJZDogc3RyaW5nOwogICAgc2NvcGVzOiB1bmtub3duOwogICAgaXNBY3RpdmU6IGJvb2xlYW47CiAgICBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbDsKICAgIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogICAgdGVuYW50PzogeyBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbCB9OwogIH0gfCBudWxsID0gbnVsbDsKICB0cnkgewogICAgYXBpS2V5ID0gYXdhaXQgcHJpc21hLmFwaUtleS5maW5kVW5pcXVlKHsKICAgICAgd2hlcmU6IHsgaGFzaGVkS2V5OiBoYXNoZWQgfSwKICAgICAgaW5jbHVkZTogeyB0ZW5hbnQ6IHsgc2VsZWN0OiB7IGRlbGV0ZWRBdDogdHJ1ZSB9IH0gfSwKICAgIH0pOwogIH0gY2F0Y2ggKGVycikgewogICAgLy8gT25seSBmYWxsIGJhY2sgd2hlbiB0aGUgQXBpS2V5IHRhYmxlIGhhc24ndCBiZWVuIGNyZWF0ZWQgKHRlc3RzIC8gZnJlc2ggREIpLgogICAgY29uc3QgaXNN aXNzaW5nVGFibGUgPQogICAgICBlcnIgaW5zdGFuY2VvZiBFcnJvciAmJgogICAgICAoZXJyLm1lc3NhZ2UuaW5jbHVkZXMoJ2RvZXMgbm90IGV4aXN0IGluIHRoZSBjdXJyZW50IGRhdGFiYXNlJykgfHwKICAgICAgICAoZXJyIGFzIHsgY29kZT86IHN0cmluZyB9KS5jb2RlID09PSAnUDIwMjEnKTsKICAgIGlmICghaXNN aXNzaW5nVGFibGUpIHRocm93IGVycjsKICB9CgogIGlmIChhcGlLZXkpIHsKICAgIGlmICghaXNQZXJzaXN0ZWRBcGlLZXlVc2FibGUoYXBpS2V5KSkgewogICAgICAvLyBBIHBlcnNpc3RlZC1idXQtZGlzYWJsZWQgY3JlZGVudGlhbCBtdXN0IGZhaWwgY2xvc2VkLiBOZXZlciBsZXQgaXQgZmFsbAogICAgICAvLyB0aHJvdWdoIHRvIHRoZSBsZWdhY3kgaW4tbWVtb3J5IHN0b3JlIHdoZXJlIGFuIG9sZCBkdXBsaWNhdGUgY291bGQgbGl2ZS4KICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCBBUEkga2V5JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmIChpc1BlcnNpc3RlZEFwaUtleUV4cGlyZWQoYXBpS2V5KSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKHJlcSwgcmVzLCAnQVBJX0tFWV9FWFBJUkVEJywgJ0FQSSBrZXkgaGFzIGV4cGlyZWQnKTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIC8vIFRlbmFudCBzY29waW5nOiBpZiB0aGUgcmVxdWVzdCB0YXJnZXRzIGEgdGVuYW50LCB0aGUga2V5IG11c3QgYmVsb25nIHRvIGl0LgogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBhcGlLZXkudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGNvbnN0IGF2YWlsYWJsZVNjb3BlcyA9IHBhcnNlU2NvcGVzKGFwaUtleS5zY29wZXMpOwogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhhdmFpbGFibGVTY29wZXMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CgogICAgcmVxLmF1dGhBcGlLZXlIYXNoID0gYXBpS2V5Lmhhc2hlZEtleTsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IGFwaUtleS5yb2xlIGFzIEFwaUtleVJvbGU7CiAgICByZXEuYXV0aEFwaUtleVRlbmFudElkID0gYXBpS2V5LnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBhdmFpbGFibGVTY29wZXM7CiAgICByZXEuYXV0aEFwaUtleUxlZ2FjeSA9IGZhbHNlOwoKICAgIC8vIEJlc3QtZWZmb3J0IGxhc3QtdXNlZCB0cmFja2luZyBmb3IgYXVkaXQuIE5ldmVyIGZhaWwgYXV0aCBiZWNhdXNlIG9mIGl0LgogICAgdHJ5IHsKICAgICAgY29uc3Qgbm93ID0gbmV3IERhdGUoKTsKICAgICAgYXdhaXQgcHJpc21hLmFwaUtleS51cGRhdGUoewogICAgICAgIHdoZXJlOiB7IGhhc2hlZEtleTogYXBpS2V5Lmhhc2hlZEtleSB9LAogICAgICAgIGRhdGE6IHsgbGFzdFVzZWRBdDogbm93IH0sCiAgICAgIH0pOwogICAgICByZXEuYXV0aEFwaUtleUxhc3RVc2VkQXQgPSBub3c7CiAgICB9IGNhdGNoIHsKICAgICAgLy8gaWdub3JlIGxhc3QtdXNlZCB1cGRhdGUgZmFpbHVyZXMKICAgIH0KCiAgICBuZXh0KCk7CiAgICByZXR1cm47CiAgfQoKICAvLyBGYWxsYmFjazogY2hlY2sgdGhlIGluLW1lbW9yeSBzdG9yZSAodXNlZCBieSB0ZXN0cyBhbmQgbGVnYWN5IGNhbGxlcnMpCiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoZWQpOwogIGlmIChtZXRhICYmICFtZXRhLnJldm9rZWRBdCkgewogICAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQocmVxLCByZXMsICdBUElfS0VZX0VYUElSRUQnLCAnQVBJIGtleSBoYXMgZXhwaXJlZCcpOwogICAgICByZXR1cm47CiAgICB9CiAgICBpZiAocmVxdWVzdGVkVGVuYW50SWQgJiYgcmVxdWVzdGVkVGVuYW50SWQgIT09IG1ldGEudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhtZXRhLnNjb3BlcywgcmVxdWlyZWRTY29wZXMpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQoCiAgICAgICAgcmVxLAogICAgICAgIHJlcywKICAgICAgICAnU0NPUEVfSU5TVUZGSUNJRU5UJywKICAgICAgICAnQVBJIGtleSBtaXNzZXMgcmVxdWlyZWQgc2NvcGUocyknLAogICAgICAgICdCZWFyZXIgZXJyb3I9Imluc3VmZmljaWVudF9zY29wZSInCiAgICAgICk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIHJlcS5hdXRoQXBpS2V5SGFzaCA9IGhhc2hlZDsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IG1ldGEucm9sZTsKICAgIHJlcS5hdXRoQXBpS2V5VGVuYW50SWQgPSBtZXRhLnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBtZXRhLnNjb3BlczsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gZmFsc2U7CiAgICBtZXRhLmxhc3RVc2VkQXQgPSBuZXcgRGF0ZSgpOwogICAgcmVxLmF1dGhBcGlLZXlMYXN0VXNlZEF0ID0gbWV0YS5sYXN0VXNlZEF0OwogICAgbmV4dCgpOwogICAgcmV0dXJuOwogIH0KCiAgLy8gTGVnYWN5IGZhbGxiYWNrOiBzaW5nbGUgZ2xvYmFsIGVudiB2YXIgQVBJX0tFWS4gRGVwcmVjYXRlZC4KICBpZiAoY2hlY2tMZWdhY3lFbnZLZXkocHJvdmlkZWRLZXkpKSB7CiAgICBjb25zb2xlLndhcm4oCiAgICAgICdbZGVwcmVjYXRpb25dIEFQSV9LRVkgZW52IHZhciBmYWxsYmFjayBpcyBkZXByZWNhdGVkLiBNaWdyYXRlIHRvIHBlci10ZW5hbnQgQVBJIGtleXMgaW4gdGhlIGFwaUtleSB0YWJsZS4nCiAgICApOwogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBMRUdBQ1lfRU5WX1RFTkFOVCkgewogICAgICBzZW5kQXBpRXJyb3IocmVxLCByZXMsIHsKICAgICAgICBzdGF0dXM6IDQwMSwKICAgICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgICAgIG1lc3NhZ2U6ICdBUElrZXkgaXMgbm90IHZhbGlkIGZvciB0aGlzIHRlbmFudCcsCiAgICAgICAgcmV0cnlhYmxlOiBmYWxzZSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIGlmICghaGFzUmVxdWlyZWRTY29wZXMoTEVHQUNZX0VOVl9TQ09QRVMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CiAgICByZXEuYXV0aEFwaUtleUhhc2ggPSBoYXNoZWQ7CiAgICByZXEuYXV0aEFwaUtleVJvbGUgPSAnYWRtaW4nOwogICAgcmVxLmF1dGhBcGlLZXlUZW5hbnRJZCA9IExFR0FDWV9FTlZfVEVOQU5UOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBMRUdBQ1lfRU5WX1NDT1BFUzsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gdHJ1ZTsKICAgIG5leHQoKTsKICAgIHJldHVybjsKICB9CgogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgbWVzc2FnZTogJ0ludmFsaWQgQVBJIGtleScsCiAgICByZXRyeWFibGU6IGZhbHNlLAogIH0pOwp9CgpleHBvcnQgZnVuY3Rpb24gaGFzaEFwaUtleShrZXk6IHN0cmluZyk6IHN0cmluZyB7CiAgcmV0dXJuIGNyeXB0by5jcmVhdGVIYXNoKCdzaGEyNTYnKS51cGRhdGUoa2V5KS5kaWdlc3QoJ2hleCcpOwp9CgovLyBMZWdhY3kgaGVscGVycyByZXRhaW5lZCBmb3IgY29tcGF0aWJpbGl0eSDigJMgb3BlcmF0ZSBvbiBpbi1tZW1vcnkgbWFwIG9ubHkuCmNvbnN0IElOX01FTU9SWV9LRVlTID0gbmV3IE1hcDxzdHJpbmcsIEFwaUtleU1ldGFkYXRhPigpOwoKZXhwb3J0IGZ1bmN0aW9uIHJlZ2lzdGVyQXBpS2V5KAogIGtleTogc3RyaW5nLAogIG9wdGlvbnM/OiB7IHJvbGU/OiBBcGlLZXlSb2xlOyB0ZW5hbnRJZD86IHN0cmluZzsgc2NvcGVzPzogc3RyaW5nW107IGV4cGlyZXNBdD86IERhdGUgfCBudWxsIH0KKTogc3RyaW5nIHsKICBjb25zdCBoYXNoID0gaGFzaEFwaUtleShrZXkpOwogIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCB7CiAgICByb2xlOiBvcHRpb25zPy5yb2xlIHx8ICdhZG1pbicsCiAgICBjcmVhdGVkQXQ6IG5ldyBEYXRlKCksCiAgICB0ZW5hbnRJZDogb3B0aW9ucz8udGVuYW50SWQgfHwgJ3Vua25vd24nLAogICAgc2NvcGVzOiBvcHRpb25zPy5zY29wZXMgfHwgW10sCiAgICBleHBpcmVzQXQ6IG9wdGlvbnM/LmV4cGlyZXNBdCA/PyBudWxsLAogIH0pOwogIHJldHVybiBoYXNoOwp9CgpleHBvcnQgZnVuY3Rpb24gcmV2b2tlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKG1ldGEpIHsKICAgIG1ldGEucmV2b2tlZEF0ID0gbmV3IERhdGUoKTsKICAgIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCBtZXRhKTsKICB9CiAgcmV0dXJuIElOX01FTU9SWV9LRVlTLmRlbGV0ZShoYXNoKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIHJvdGF0ZUFwaUtleSgKICBvbGRIYXNoOiBzdHJpbmcsCiAgbmV3S2V5OiBzdHJpbmcsCiAgb3B0aW9uczogeyByb2xlPzogQXBpS2V5Um9sZTsgdGVuYW50SWQ/OiBzdHJpbmc7IHNjb3Blcz86IHN0cmluZ1tdIH0gPSB7fQopOiBzdHJpbmcgfCBudWxsIHsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KG9sZEhhc2gpOwogIGlmICghbWV0YSkgcmV0dXJuIG51bGw7CiAgSU5fTUVNT1JZX0tFWVMuZGVsZXRlKG9sZEhhc2gpOwogIGNvbnN0IG5ld0hhc2ggPSBoYXNoQXBpS2V5KG5ld0tleSk7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KG5ld0hhc2gsIHsKICAgIHJvbGU6IG9wdGlvbnMucm9sZSB8fCBtZXRhLnJvbGUsCiAgICBjcmVhdGVkQXQ6IG1ldGEuY3JlYXRlZEF0LAogICAgcm90YXRlZEF0OiBuZXcgRGF0ZSgpLAogICAgdGVuYW50SWQ6IG9wdGlvbnMudGVuYW50SWQgfHwgbWV0YS50ZW5hbnRJZCwKICAgIHNjb3Blczogb3B0aW9ucy5zY29wZXMgfHwgbWV0YS5zY29wZXMsCiAgICBleHBpcmVzQXQ6IG1ldGEuZXhwaXJlc0F0ID8/IG51bGwsCiAgfSk7CiAgcmV0dXJuIG5ld0hhc2g7Cn0KCmV4cG9ydCBmdW5jdGlvbiByZXN0b3JlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKCFtZXRhKSByZXR1cm4gZmFsc2U7CiAgbWV0YS5yZXZva2VkQXQgPSB1bmRlZmluZWQ7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KGhhc2gsIG1ldGEpOwogIHJldHVybiB0cnVlOwp9CgpleHBvcnQgZnVuY3Rpb24gZ2V0QXBpS2V5TWV0YWRhdGEoaGFzaDogc3RyaW5nKSB7CiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoKTsKICBpZiAoIW1ldGEpIHJldHVybiBudWxsOwogIHJldHVybiB7CiAgICBoYXNoLAogICAgcm9sZTogbWV0YS5yb2xlLAogICAgY3JlYXRlZEF0OiBtZXRhLmNyZWF0ZWRBdC50b0lTT1N0cmluZygpLAogICAgLi4uKG1ldGEucm90YXRlZEF0ID8geyByb3RhdGVkQXQ6IG1ldGEucm90YXRlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICAgIC4uLihtZXRhLnJldm9rZWRBdCA/IHsgcmV2b2tlZEF0OiBtZXRhLnJldm9rZWRBdC50b0lTT1N0cmluZygpIH0gOiB7fSksCiAgICB0ZW5hbnRJZDogbWV0YS50ZW5hbnRJZCwKICAgIHNjb3BlczogbWV0YS5zY29wZXMsCiAgICAuLi4obWV0YS5leHBpcmVzQXQgPyB7IGV4cGlyZXNBdDogbWV0YS5leHBpcmVzQXQudG9JU09TdHJpbmcoKSB9IDoge30pLAogICAgLi4uKG1ldGEubGFzdFVzZWRBdCA/IHsgbGFzdFVzZWRBdDogbWV0YS5sYXN0VXNlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICB9Owp9CgpleHBvcnQgZnVuY3Rpb24gYXV0aGVudGljYXRlQXBpS2V5VmFsdWUodmFsdWU6IHN0cmluZykgewogIGNvbnN0IGhhc2ggPSBoYXNoQXBpS2V5KHZhbHVlKTsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KGhhc2gpOwogIGlmICghbWV0YSB8fCBtZXRhLnJldm9rZWRBdCkgcmV0dXJuIG51bGw7CiAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSByZXR1cm4gbnVsbDsKICByZXR1cm4geyBoYXNoLCByb2xlOiBtZXRhLnJvbGUsIHRlbmFudElkOiBtZXRhLnRlbmFudElkLCBzY29wZXM6IG1ldGEuc2NvcGVzIH07Cn0KCmV4cG9ydCBmdW5jdGlvbiBoYXNSZXF1aXJlZEFwaUtleVJvbGUocmVxOiBSZXF1ZXN0LCByZXF1aXJlZFJvbGU6IEFwaUtleVJvbGUpOiBib29sZWFuIHsKICBjb25zdCBjdXJyZW50Um9sZSA9IHJlcS5hdXRoQXBpS2V5Um9sZSB8fCAnYWRtaW4nOwogIHJldHVybiBST0xFX1BSRUNFREVOQ0VbY3VycmVudFJvbGVdID49IFJPTEVfUFJFQ0VERU5DRVtyZXF1aXJlZFJvbGVdOwp9Cg== \ No newline at end of file +import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { prisma } from '../prisma'; +import { sendApiError } from './apiError'; + +export type ApiKeyRole = 'viewer' | 'operator' | 'admin' | 'super-admin'; + +interface ApiKeyMetadata { + role: ApiKeyRole; + createdAt: Date; + rotatedAt?: Date; + revokedAt?: Date; + tenantId: string; + scopes: string[]; + expiresAt?: Date | null; + lastUsedAt?: Date | null; +} + +declare global { + namespace Express { + interface Request { + authApiKeyHash?: string; + authApiKeyRole?: ApiKeyRole; + authApiKeyTenantId?: string; + authApiKeyScopes?: string[]; + authApiKeyLastUsedAt?: Date | null; + authApiKeyLegacy?: boolean; + } + } +} + +const ROLE_PRECEDENCE: Record = { + viewer: 0, + operator: 1, + admin: 2, + 'super-admin': 3, +}; + +export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim().toLowerCase(); + if (value === 'viewer' || value === 'operator' || value === 'admin' || value === 'super-admin') { + return value as ApiKeyRole; + } + return null; +} + +function parseScopes(raw: unknown): string[] { + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +export interface PersistedApiKeyStatus { + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; +} + +export function isPersistedApiKeyUsable( + apiKey: PersistedApiKeyStatus | null +): apiKey is PersistedApiKeyStatus { + return Boolean(apiKey && apiKey.isActive && !apiKey.deletedAt && !apiKey.tenant?.deletedAt); +} + +export function isPersistedApiKeyExpired( + apiKey: PersistedApiKeyStatus | null +): boolean { + return Boolean(apiKey && apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()); +} + +export function extractTenantIdFromRequest(req: Request): string | null { + const fromParams = req.params?.tenantId; + if (typeof fromParams === 'string' && fromParams.trim()) { + return fromParams.trim(); + } + const fromQuery = req.query?.tenantId; + if (typeof fromQuery === 'string' && fromQuery.trim()) { + return fromQuery.trim(); + } + const fromHeader = req.get?.('x-tenant-id'); + if (typeof fromHeader === 'string' && fromHeader.trim()) { + return fromHeader.trim(); + } + return null; +} + +export function extractRequiredScopes(req: Request): string[] { + const raw = (req as Request & { requiredScopes?: unknown }).requiredScopes; + if (Array.isArray(raw)) { + return raw.filter((s): s is string => typeof s === 'string'); + } + if (typeof raw === 'string' && raw.trim()) { + return raw.split(',').map((s) => s.trim()).filter(Boolean); + } + return []; +} + +export function hasRequiredScopes(available: string[], required: string[]): boolean { + if (required.length === 0) return true; + if (available.includes('*')) return true; + return required.every((scope) => available.includes(scope)); +} + +function sendUnauthorized( + req: Request, + res: Response, + code: string, + message: string, + scheme: string = 'Bearer' +): void { + res.setHeader('WWW-Authenticate', scheme); + sendApiError(req, res, { + status: 401, + code, + message, + retrable: false, + }); +} + +const LEGACY_ENV_KEY = process.env.API_KEY; +const LEGACY_ENV_TENANT = process.env.API_KEY_TENANT_ID || 'default'; +const LEGACY_ENV_SCOPES = (process.env.API_KEY_SCOPES || '*') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +function checkLegacyEnvKey(providedKey: string): boolean { + if (!LEGACY_ENV_KEY) return false; + const a = Buffer.from(providedKey); + const b = Buffer.from(LEGACY_ENV_KEY); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +export async function validateApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const authHeader = req.get?.('Authorization') || ''; + const match = authHeader.match(/^ApiKey\s+(.+)$/i); + if (!match) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_MISSING', + message: 'Missing or invalid API key', + retryable: false, + }); + return; + } + const providedKey = match[1]; + const hashed = crypto.createHash('sha256').update(providedKey).digest('hex'); + const requestedTenantId = extractTenantIdFromRequest(req); + const requiredScopes = extractRequiredScopes(req); + + // Try Prisma first; fall back to in-memory store when the table doesn't exist + // (e.g. in tests with an un-migrated SQLite database). + let apiKey: { + id?: string; + hashedKey: string; + role: string; + tenantId: string; + scopes: unknown; + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; + } | null = null; + try { + apiKey = await prisma.apiKey.findUnique({ + where: { hashedKey: hashed }, + include: { tenant: { select: { deletedAt: true } } }, + }); + } catch (err) { + // Only fall back when the ApiKey table hasn't been created (tests / fresh DB). + const isMissingTable = + err instanceof Error && + (err.message.includes('does not exist in the current database') || + (err as { code?: string }).code === 'P2021'); + if (!isMissingTable) throw err; + } + + if (apiKey) { + if (!isPersistedApiKeyUsable(apiKey)) { + // A persisted-but-disabled credential must fail closed. Never let it fall + // through to the legacy in-memory store where an old duplicate could live. + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); + return; + } + + if (isPersistedApiKeyExpired(apiKey)) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + + // Tenant scoping: if the request targets a tenant, the key must belong to it. + if (requestedTenantId && requestedTenantId !== apiKey.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + + const availableScopes = parseScopes(apiKey.scopes); + if (!hasRequiredScopes(availableScopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + + req.authApiKeyHash = apiKey.hashedKey; + req.authApiKeyRole = apiKey.role as ApiKeyRole; + req.authApiKeyTenantId = apiKey.tenantId; + req.authApiKeyScopes = availableScopes; + req.authApiKeyLegacy = false; + + // Best-effort last-used tracking for audit. Never fail auth because of it. + try { + const now = new Date(); + await prisma.apiKey.update({ + where: { hashedKey: apiKey.hashedKey }, + data: { lastUsedAt: now }, + }); + req.authApiKeyLastUsedAt = now; + } catch { + // ignore last-used update failures + } + + next(); + return; + } + + // Fallback: check the in-memory store (used by tests and legacy callers) + const meta = IN_MEMORY_KEYS.get(hashed); + if (meta && !meta.revokedAt) { + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + if (requestedTenantId && requestedTenantId !== meta.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(meta.scopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = meta.role; + req.authApiKeyTenantId = meta.tenantId; + req.authApiKeyScopes = meta.scopes; + req.authApiKeyLegacy = false; + meta.lastUsedAt = new Date(); + req.authApiKeyLastUsedAt = meta.lastUsedAt; + next(); + return; + } + + // Legacy fallback: single global env var API_KEY. Deprecated. + if (checkLegacyEnvKey(providedKey)) { + console.warn( + '[deprecation] API_KEY env var fallback is deprecated. Migrate to per-tenant API keys in the apiKey table.' + ); + if (requestedTenantId && requestedTenantId !== LEGACY_ENV_TENANT) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'APIkey is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(LEGACY_ENV_SCOPES, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = 'admin'; + req.authApiKeyTenantId = LEGACY_ENV_TENANT; + req.authApiKeyScopes = LEGACY_ENV_SCOPES; + req.authApiKeyLegacy = true; + next(); + return; + } + + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); +} + +export function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex'); +} + +// Legacy helpers retained for compatibility – operate on in-memory map only. +const IN_MEMORY_KEYS = new Map(); + +export function registerApiKey( + key: string, + options?: { role?: ApiKeyRole; tenantId?: string; scopes?: string[]; expiresAt?: Date | null } +): string { + const hash = hashApiKey(key); + IN_MEMORY_KEYS.set(hash, { + role: options?.role || 'admin', + createdAt: new Date(), + tenantId: options?.tenantId || 'unknown', + scopes: options?.scopes || [], + expiresAt: options?.expiresAt ?? null, + }); + return hash; +} + +export function revokeApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (meta) { + meta.revokedAt = new Date(); + IN_MEMORY_KEYS.set(hash, meta); + } + return IN_MEMORY_KEYS.delete(hash); +} + +export function rotateApiKey( + oldHash: string, + newKey: string, + options: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } = {} +): string | null { + const meta = IN_MEMORY_KEYS.get(oldHash); + if (!meta) return null; + IN_MEMORY_KEYS.delete(oldHash); + const newHash = hashApiKey(newKey); + IN_MEMORY_KEYS.set(newHash, { + role: options.role || meta.role, + createdAt: meta.createdAt, + rotatedAt: new Date(), + tenantId: options.tenantId || meta.tenantId, + scopes: options.scopes || meta.scopes, + expiresAt: meta.expiresAt ?? null, + }); + return newHash; +} + +export function restoreApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return false; + meta.revokedAt = undefined; + IN_MEMORY_KEYS.set(hash, meta); + return true; +} + +export function getApiKeyMetadata(hash: string) { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return null; + return { + hash, + role: meta.role, + createdAt: meta.createdAt.toISOString(), + ...(meta.rotatedAt ? { rotatedAt: meta.rotatedAt.toISOString() } : {}), + ...(meta.revokedAt ? { revokedAt: meta.revokedAt.toISOString() } : {}), + tenantId: meta.tenantId, + scopes: meta.scopes, + ...(meta.expiresAt ? { expiresAt: meta.expiresAt.toISOString() } : {}), + ...(meta.lastUsedAt ? { lastUsedAt: meta.lastUsedAt.toISOString() } : {}), + }; +} + +export function authenticateApiKeyValue(value: string) { + const hash = hashApiKey(value); + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta || meta.revokedAt) return null; + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) return null; + return { hash, role: meta.role, tenantId: meta.tenantId, scopes: meta.scopes }; +} + +export function hasRequiredApiKeyRole(req: Request, requiredRole: ApiKeyRole): boolean { + const currentRole = req.authApiKeyRole || 'admin'; + return ROLE_PRECEDENCE[currentRole] >= ROLE_PRECEDENCE[requiredRole]; +} diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index 51d4b1ee9..7440d7fdd 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -209,8 +209,10 @@ export function parsePaginationQuery( query.cursor = req.query.cursor; } - // Parse page (1-based, clamped — never rejected) + // Parse page (1-based, clamped to 1..maxPage — never rejected, Issue #1430) if (req.query.page !== undefined) { + const page = clampPageNumber(parseInt(req.query.page as string, 10)) ?? 1; + query.page = Math.min(page, mergedConfig.maxPage); const parsedPage = parseInt(req.query.page as string, 10); if (!Number.isNaN(parsedPage) && parsedPage > 0) { query.page = Math.min(parsedPage, mergedConfig.maxPage); diff --git a/backend/src/routes/vaults.ts b/backend/src/routes/vaults.ts index de9854efa..fb9c376f4 100644 --- a/backend/src/routes/vaults.ts +++ b/backend/src/routes/vaults.ts @@ -152,79 +152,5 @@ router.get( }); }, ); -import { Router, Request, Response, NextFunction } from 'express'; -import type { Prisma } from '@prisma/client'; -import { getPrismaClient } from '../prismaClient'; -import { validateApiKey } from '../middleware/apiKeyAuth'; -import { - createPaginatedResponse, - createPaginationEnvelope, - parsePaginationQuery, -} from '../pagination'; -import { buildVaultFilterHash, vaultCountCache } from '../vaultCountCache'; - -const router = Router(); - -/** GET /vaults: fresh page rows with a five-second cached, tenant-scoped total. */ -router.get('/', validateApiKey, async (req: Request, res: Response, next: NextFunction) => { - try { - const rawTenant = req.query.tenantId; - if (rawTenant !== undefined && (typeof rawTenant !== 'string' || !rawTenant.trim())) { - res.status(400).json({ error: 'tenantId must be a non-empty string' }); - return; - } - const isAdmin = ['admin', 'super-admin'].includes(req.authApiKeyRole || ''); - const tenantId = isAdmin ? (rawTenant as string | undefined) : req.authApiKeyTenantId; - if ( - !isAdmin && - (!tenantId || tenantId === 'unknown' || (rawTenant && rawTenant !== tenantId)) - ) { - res.status(403).json({ error: 'Vault access requires a matching authenticated tenant' }); - return; - } - - const where: Prisma.VaultWhereInput = { deletedAt: null, ...(tenantId ? { tenantId } : {}) }; - const query = parsePaginationQuery(req, { defaultSortBy: 'createdAt' }); - const limit = query.limit || 20; - const page = query.page || 1; - const sortBy = query.sortBy || 'createdAt'; - if (!['createdAt', 'updatedAt', 'id', 'aum'].includes(sortBy) || query.cursor) { - res - .status(400) - .json({ error: 'Use page pagination and sortBy createdAt, updatedAt, id or aum' }); - return; - } - const skip = (page - 1) * limit; - if (!Number.isSafeInteger(skip) || skip > 2147483647) { - res.status(400).json({ error: 'Page offset is too large' }); - return; - } - const prisma = getPrismaClient(); - const orderBy: Prisma.VaultOrderByWithRelationInput[] = [{ [sortBy]: query.sortOrder }]; - if (sortBy !== 'id') orderBy.push({ id: query.sortOrder }); - const [data, count] = await Promise.all([ - prisma.vault.findMany({ where, skip, take: limit, orderBy }), - vaultCountCache.get(buildVaultFilterHash(where), () => prisma.vault.count({ where })), - ]); - res.setHeader('X-Cache', count.status); - res.setHeader('Cache-Control', 'no-store'); - res.json( - createPaginatedResponse( - data, - createPaginationEnvelope({ - count: data.length, - limit, - total: count.total, - currentPage: page, - totalPages: Math.max(1, Math.ceil(count.total / limit)), - hasNextPage: skip + data.length < count.total, - hasPrevPage: page > 1, - }) - ) - ); - } catch (error) { - next(error); - } -}); export default router; diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 0f3f7db24..fc29dc015 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -11,7 +11,6 @@ import { NodeSDK, type NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import * as otelResources from '@opentelemetry/resources'; -import { Resource } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; @@ -84,7 +83,6 @@ export function initTracing(): void { sdk = new NodeSDK({ resource: buildResource({ - resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME, [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', }), diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 1f33f8ddb..5cd0d4f57 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -933,7 +933,6 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { req.query.limit === undefined ? undefined : parseInt(req.query.limit as string, 10), RECEIPTS_PAGINATION_CONFIG ); - const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 100); const cursor = req.query.cursor as string | undefined; const where = wallet ? { user: wallet } : {}; diff --git a/contracts/bridge-compat/src/lib.rs b/contracts/bridge-compat/src/lib.rs index be995acf2..0b95c9be1 100644 --- a/contracts/bridge-compat/src/lib.rs +++ b/contracts/bridge-compat/src/lib.rs @@ -186,6 +186,7 @@ enum DataKey { Limits, DefaultProvider, TransferNonceFor(Address, u32), + MinTransfer, PendingRemoval(u32), } @@ -202,6 +203,8 @@ const STELLAR_CHAIN_ID: u32 = 0; const BPS_DENOMINATOR: i128 = 10_000; const STORAGE_TTL_THRESHOLD: u32 = 10_000; const STORAGE_TTL_EXTENSION: u32 = 100_000; +/// Default minimum transfer amount when none is configured. +const DEFAULT_MIN_TRANSFER: i128 = 1; /// Delay between queueing and executing a provider removal. pub const PROVIDER_REMOVAL_DELAY: u64 = 86_400; @@ -447,6 +450,27 @@ impl BridgeCompat { Ok(()) } + /// Set the minimum amount accepted by `transfer_out` / `estimate_transfer`. + /// Admin-only; must be positive. + pub fn set_min_transfer(env: Env, min_transfer: i128) -> Result<(), BridgeError> { + Self::require_admin(&env)?; + if min_transfer <= 0 { + return Err(BridgeError::InvalidAmount); + } + env.storage() + .instance() + .set(&DataKey::MinTransfer, &min_transfer); + Ok(()) + } + + /// Returns the minimum transfer amount. + pub fn min_transfer(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::MinTransfer) + .unwrap_or(DEFAULT_MIN_TRANSFER) + } + /// Returns the current transfer limits. pub fn transfer_limits(env: Env) -> TransferLimits { env.storage() @@ -479,9 +503,7 @@ impl BridgeCompat { if !provider.supported_chains.contains(&dest_chain) { return Err(BridgeError::ProviderNotFound); } - if amount <= 0 || amount > provider.max_transfer { - return Err(BridgeError::InvalidAmount); - } + Self::check_amount_bounds(&env, &provider, amount)?; let fee_amount = amount .checked_mul(provider.fee_bps) @@ -528,9 +550,7 @@ impl BridgeCompat { if !provider.supported_chains.contains(&dest_chain) { return Err(BridgeError::ProviderNotFound); } - if amount <= 0 { - return Err(BridgeError::InvalidAmount); - } + Self::check_amount_bounds(&env, &provider, amount)?; // Check per-transfer limit let limits: TransferLimits = @@ -549,10 +569,10 @@ impl BridgeCompat { // Check epoch volume Self::check_epoch_volume(&env, amount, &limits)?; - // Check token balance + // Check the sender can fund the transfer let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); let token_client = soroban_sdk::token::Client::new(&env, &token_addr); - let balance = token_client.balance(&env.current_contract_address()); + let balance = token_client.balance(&sender); if balance < amount { return Err(BridgeError::InsufficientBalance); } @@ -742,6 +762,19 @@ impl BridgeCompat { Ok(admin) } + /// Enforces `min_transfer <= amount <= provider.max_transfer`. + fn check_amount_bounds( + env: &Env, + provider: &BridgeProvider, + amount: i128, + ) -> Result<(), BridgeError> { + let min_transfer = Self::min_transfer(env.clone()); + if amount < min_transfer || amount > provider.max_transfer { + return Err(BridgeError::InvalidAmount); + } + Ok(()) + } + fn get_provider(env: &Env, provider_id: u32) -> Result { let key = DataKey::Provider(provider_id); if let Some(provider) = env.storage().persistent().get(&key) { @@ -1303,6 +1336,15 @@ mod tests { assert_eq!(client.transfer(&tx_id).unwrap().provider_id, provider_id); } + // ── Issue #1528: sender balance + amount bounds in transfer_out ───────── + + fn provider_with_max(client: &BridgeCompatClient, env: &Env, max_transfer: i128) -> u32 { + client.register_provider( + &String::from_str(env, "p"), + &BridgeProviderKind::Custom, + &Address::generate(env), + &0, + &max_transfer, // ── Provider removal / fallback routing / endpoint invocation ─────────── fn register(client: &BridgeCompatClient, env: &Env, endpoint: &Address) -> u32 { @@ -1317,6 +1359,77 @@ mod tests { } #[test] + fn test_transfer_out_rejects_amount_above_provider_max() { + let (env, client, _admin, token_addr, _id) = setup_with_token(); + let pid = provider_with_max(&client, &env, 1_000); + let sender = Address::generate(&env); + soroban_sdk::token::StellarAssetClient::new(&env, &token_addr).mint(&sender, &10_000); + + let res = client.try_transfer_out(&sender, &Bytes::new(&env), &1_001, &1, &Some(pid)); + assert_eq!(res, Err(Ok(BridgeError::InvalidAmount))); + assert_eq!( + client.try_estimate_transfer(&1_001, &1, &Some(pid)), + Err(Ok(BridgeError::InvalidAmount)) + ); + + // Exactly max_transfer is accepted. + client.transfer_out(&sender, &Bytes::new(&env), &1_000, &1, &Some(pid)); + } + + #[test] + fn test_transfer_out_rejects_amount_below_min_transfer() { + let (env, client, _admin, token_addr, _id) = setup_with_token(); + let pid = provider_with_max(&client, &env, 1_000); + let sender = Address::generate(&env); + soroban_sdk::token::StellarAssetClient::new(&env, &token_addr).mint(&sender, &10_000); + + assert_eq!(client.min_transfer(), 1); + assert_eq!( + client.try_transfer_out(&sender, &Bytes::new(&env), &0, &1, &Some(pid)), + Err(Ok(BridgeError::InvalidAmount)) + ); + + client.set_min_transfer(&100); + assert_eq!( + client.try_transfer_out(&sender, &Bytes::new(&env), &99, &1, &Some(pid)), + Err(Ok(BridgeError::InvalidAmount)) + ); + assert_eq!( + client.try_estimate_transfer(&99, &1, &Some(pid)), + Err(Ok(BridgeError::InvalidAmount)) + ); + client.transfer_out(&sender, &Bytes::new(&env), &100, &1, &Some(pid)); + + assert_eq!( + client.try_set_min_transfer(&0), + Err(Ok(BridgeError::InvalidAmount)) + ); + } + + #[test] + fn test_transfer_out_checks_sender_balance_not_contract_balance() { + let (env, client, _admin, token_addr, contract_id) = setup_with_token(); + let pid = provider_with_max(&client, &env, 1_000_000); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + let token = soroban_sdk::token::Client::new(&env, &token_addr); + let sender = Address::generate(&env); + token_admin.mint(&sender, &500); + // Contract liquidity must not let an underfunded sender pass the check. + token_admin.mint(&contract_id, &1_000_000); + + let res = client.try_transfer_out(&sender, &Bytes::new(&env), &501, &1, &Some(pid)); + assert_eq!(res, Err(Ok(BridgeError::InsufficientBalance))); + assert_eq!(token.balance(&sender), 500); + + // A funded sender succeeds even when the contract holds nothing. + let (env2, client2, _admin2, token_addr2, contract_id2) = setup_with_token(); + let pid2 = provider_with_max(&client2, &env2, 1_000_000); + let sender2 = Address::generate(&env2); + soroban_sdk::token::StellarAssetClient::new(&env2, &token_addr2).mint(&sender2, &500); + client2.transfer_out(&sender2, &Bytes::new(&env2), &500, &1, &Some(pid2)); + let token2 = soroban_sdk::token::Client::new(&env2, &token_addr2); + assert_eq!(token2.balance(&sender2), 0); + assert_eq!(token2.balance(&contract_id2), 500); fn test_transfer_out_invokes_provider_endpoint() { let (env, client, _admin, token_addr, contract_id) = setup_with_token(); let endpoint = mock_endpoint(&env, false); diff --git a/contracts/vault/docs/STRATEGY_VALIDATION.md b/contracts/vault/docs/STRATEGY_VALIDATION.md index 4906c02e7..3df48659a 100644 --- a/contracts/vault/docs/STRATEGY_VALIDATION.md +++ b/contracts/vault/docs/STRATEGY_VALIDATION.md @@ -219,11 +219,14 @@ fn deposit_to_strategy( ## Error Codes +`VaultError` is capped at 50 variants by the Soroban spec, so validation +reuses existing codes rather than defining dedicated ones: + | Error | Cause | Recovery | |-------|-------|----------| -| `InvalidStrategyResponse` | Response violates validation rules | Retry or pause vault | -| `StrategyValueOverflow` | Value exceeds safe bounds | Manual intervention | -| `DecimalConversionOverflow` | Precision conversion would overflow | Change decimal handling | +| `InvalidAmount` (3) | Response violates validation rules (negative value, bad deposit/withdrawal delta, zero price, decimals > 30, zero strategy address) | Retry or pause vault | +| `MathOverflow` (14) | Value exceeds `MAX_STRATEGY_VALUE`, or a decimal conversion would overflow | Manual intervention | +| `SlippageExceeded` (15) | Rounding loss above the allowed basis points | Change decimal handling | ## Testing Strategy diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index a56bce7a1..0198dd537 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -127,8 +127,9 @@ pub use risk_limits::ProtocolRiskLimits; use crate::strategy::StrategyClient; use crate::strategy_registration::{STATE_ACTIVE, STATE_PENDING, STATE_RETIRED}; use crate::upgrade::{ - get_admin, get_pending_admin, get_storage_version, is_initialized, set_admin, set_initialized, - set_pending_admin, set_storage_version, + get_admin, get_implementation, get_pending_admin, get_pending_upgrade, get_storage_version, + is_initialized, set_admin, set_implementation, set_initialized, set_pending_admin, + set_pending_upgrade, set_storage_version, PendingUpgrade, MIN_UPGRADE_DELAY_SECS, }; use crate::whitelist::SecureWhitelist; use soroban_sdk::{ @@ -532,14 +533,71 @@ impl YieldVault { Ok(()) } - /// Upgrades the contract code to a new WASM hash. + /// Queues an upgrade to `new_wasm_hash`, replacing any queued one. /// Only the Admin can call this. - pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + /// + /// The upgrade becomes executable after the larger of + /// [`upgrade::MIN_UPGRADE_DELAY_SECS`] and the sensitive-parameter timelock. + /// Emits `upg_prop` with `(new_wasm_hash, eta)` and returns the eta. + pub fn propose_upgrade(env: Env, new_wasm_hash: BytesN<32>) -> u64 { let admin = get_admin(&env).expect("Admin not set"); admin.require_auth(); + let delay = Self::sensitive_timelock_delay(env.clone()).max(MIN_UPGRADE_DELAY_SECS); + let eta = timelock::compute_eta(&env, delay); + set_pending_upgrade( + &env, + &Some(PendingUpgrade { + wasm_hash: new_wasm_hash.clone(), + eta, + }), + ); + env.events() + .publish((symbol_short!("upg_prop"), admin), (new_wasm_hash, eta)); + eta + } + + /// Cancels the queued upgrade. Only the Admin can call this. + /// Emits `upg_cncl` with the cancelled WASM hash. + pub fn cancel_upgrade(env: Env) -> Result<(), VaultError> { + let admin = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let pending = get_pending_upgrade(&env).ok_or(VaultError::NoPendingWithdrawal)?; + set_pending_upgrade(&env, &None); + env.events() + .publish((symbol_short!("upg_cncl"), admin), pending.wasm_hash); + Ok(()) + } + + /// Returns the queued upgrade, if any. + pub fn pending_upgrade(env: Env) -> Option { + get_pending_upgrade(&env) + } + + /// Returns the WASM hash recorded in the implementation slot by the last upgrade. + pub fn implementation(env: Env) -> Option> { + get_implementation(&env) + } + + /// Executes the queued upgrade once its timelock has elapsed. + /// Only the Admin can call this. + /// + /// Runs storage migrations, swaps the WASM, then writes and verifies the + /// implementation slot. Emits `upgraded` with `(previous, new_wasm_hash)`. + /// + /// # Errors + /// * `VaultError::NoPendingWithdrawal` - nothing is queued. + /// * `VaultError::TimelockNotExpired` - the eta has not been reached. + pub fn execute_upgrade(env: Env) -> Result<(), VaultError> { + let admin = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let pending = get_pending_upgrade(&env).ok_or(VaultError::NoPendingWithdrawal)?; + Self::assert_timelock_ready(&env, pending.eta)?; + let pre_version = get_storage_version(&env); - Self::run_storage_migration(&env, STORAGE_VERSION).expect("storage migration failed"); + Self::run_storage_migration(&env, STORAGE_VERSION)?; // Checkpoint: storage version must equal STORAGE_VERSION after migration. let post_version = get_storage_version(&env); assert_eq!( @@ -555,7 +613,22 @@ impl YieldVault { post_version ); - env.deployer().update_current_contract_wasm(new_wasm_hash); + set_pending_upgrade(&env, &None); + Self::apply_upgrade(&env, &admin, pending.wasm_hash); + Ok(()) + } + + /// Swaps the contract WASM, records it in the implementation slot and + /// emits `upgraded` with `(previous, new_wasm_hash)`. + fn apply_upgrade(env: &Env, caller: &Address, new_wasm_hash: BytesN<32>) { + let previous = get_implementation(env); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + set_implementation(env, &new_wasm_hash); + env.events().publish( + (symbol_short!("upgraded"), caller.clone()), + (previous, new_wasm_hash), + ); } /// Runs storage schema migrations up to the requested target version. @@ -1128,7 +1201,7 @@ impl YieldVault { emergency::EmergencyActionKind::ForceUpgrade => { let hash = proposal.wasm_hash.clone().expect("wasm hash required"); - env.deployer().update_current_contract_wasm(hash); + Self::apply_upgrade(&env, &confirmer, hash); } } diff --git a/contracts/vault/src/proxy_tests.rs b/contracts/vault/src/proxy_tests.rs index 99c01866e..4ab464735 100644 --- a/contracts/vault/src/proxy_tests.rs +++ b/contracts/vault/src/proxy_tests.rs @@ -1,6 +1,10 @@ use super::*; use crate::upgrade::{get_admin, is_initialized}; -use soroban_sdk::{testutils::Address as _, Address, Env, String as SorobanString}; +use crate::upgrade::{PendingUpgrade, IMPLEMENTATION_SLOT, MIN_UPGRADE_DELAY_SECS}; +use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; +use soroban_sdk::{ + symbol_short, Address, BytesN, Env, IntoVal, String as SorobanString, TryFromVal, +}; #[test] fn test_proxy_initialization_guard() { @@ -43,7 +47,9 @@ fn test_proxy_upgrade_authorization() { let wasm_bytes = soroban_sdk::Bytes::new(&env); let new_wasm_hash = env.deployer().upload_contract_wasm(wasm_bytes); - vault.upgrade(&new_wasm_hash); + let eta = vault.propose_upgrade(&new_wasm_hash); + env.ledger().set_timestamp(eta); + vault.execute_upgrade(); } #[test] @@ -230,8 +236,10 @@ fn test_upgrade_storage_version_checkpoint() { let wasm_bytes = soroban_sdk::Bytes::new(&env); let new_wasm_hash = env.deployer().upload_contract_wasm(wasm_bytes); - // upgrade() must preserve the storage version checkpoint. - vault.upgrade(&new_wasm_hash); + // execute_upgrade() must preserve the storage version checkpoint. + let eta = vault.propose_upgrade(&new_wasm_hash); + env.ledger().set_timestamp(eta); + vault.execute_upgrade(); assert_eq!(vault.storage_version(), 3); } @@ -290,6 +298,140 @@ fn test_migrate_storage_downgrade_rejected() { assert_eq!(vault.storage_version(), 3); } +// ─── Timelocked upgrade + implementation slot (issue #1543) ──────────────── + +fn setup_upgrade(env: &Env) -> (YieldVaultClient<'_>, Address, BytesN<32>) { + env.mock_all_auths(); + let admin = Address::generate(env); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(env, &vault_id); + vault.initialize(&admin, &Address::generate(env)); + let hash = env + .deployer() + .upload_contract_wasm(soroban_sdk::Bytes::new(env)); + (vault, admin, hash) +} + +#[test] +fn test_upgrade_cannot_execute_before_timelock() { + let env = Env::default(); + env.ledger().set_timestamp(1_000); + let (vault, _admin, hash) = setup_upgrade(&env); + + assert_eq!( + vault.try_execute_upgrade(), + Err(Ok(VaultError::NoPendingWithdrawal)) + ); + + let eta = vault.propose_upgrade(&hash); + assert_eq!(eta, 1_000 + MIN_UPGRADE_DELAY_SECS); + assert_eq!( + vault.pending_upgrade(), + Some(PendingUpgrade { + wasm_hash: hash.clone(), + eta + }) + ); + + env.ledger().set_timestamp(eta - 1); + assert_eq!( + vault.try_execute_upgrade(), + Err(Ok(VaultError::TimelockNotExpired)) + ); + assert_eq!(vault.implementation(), None); +} + +#[test] +fn test_upgrade_delay_follows_longer_sensitive_timelock() { + let env = Env::default(); + let (vault, _admin, hash) = setup_upgrade(&env); + let longer = MIN_UPGRADE_DELAY_SECS * 2; + vault.set_sensitive_timelock_delay(&longer); + + let eta = vault.propose_upgrade(&hash); + assert_eq!(eta, env.ledger().timestamp() + longer); +} + +#[test] +fn test_execute_upgrade_writes_and_verifies_implementation_slot() { + let env = Env::default(); + let (vault, admin, hash) = setup_upgrade(&env); + + let eta = vault.propose_upgrade(&hash); + env.ledger().set_timestamp(eta); + vault.execute_upgrade(); + + let events = env.events().all(); + assert!(events.iter().any(|(contract, topics, data)| { + contract == vault.address + && topics == (symbol_short!("upgraded"), admin.clone()).into_val(&env) + && <(Option>, BytesN<32>)>::try_from_val(&env, &data) + == Ok((None, hash.clone())) + })); + assert_eq!(vault.implementation(), Some(hash.clone())); + assert_eq!(vault.pending_upgrade(), None); + env.as_contract(&vault.address, || { + let slot: Option> = env + .storage() + .instance() + .get(&BytesN::from_array(&env, &IMPLEMENTATION_SLOT)); + assert_eq!(slot, Some(hash.clone())); + }); + + // The queued upgrade is consumed: it cannot be replayed. + assert_eq!( + vault.try_execute_upgrade(), + Err(Ok(VaultError::NoPendingWithdrawal)) + ); +} + +#[test] +fn test_propose_and_cancel_upgrade_emit_events() { + let env = Env::default(); + let (vault, admin, hash) = setup_upgrade(&env); + + let eta = vault.propose_upgrade(&hash); + let proposed = env.events().all(); + assert!(proposed.iter().any(|(contract, topics, data)| { + contract == vault.address + && topics == (symbol_short!("upg_prop"), admin.clone()).into_val(&env) + && <(BytesN<32>, u64)>::try_from_val(&env, &data) == Ok((hash.clone(), eta)) + })); + + vault.cancel_upgrade(); + let cancelled = env.events().all(); + assert!(cancelled.iter().any(|(contract, topics, data)| { + contract == vault.address + && topics == (symbol_short!("upg_cncl"), admin.clone()).into_val(&env) + && BytesN::<32>::try_from_val(&env, &data) == Ok(hash.clone()) + })); + assert_eq!(vault.pending_upgrade(), None); + + env.ledger().set_timestamp(eta); + assert_eq!( + vault.try_execute_upgrade(), + Err(Ok(VaultError::NoPendingWithdrawal)) + ); + assert_eq!( + vault.try_cancel_upgrade(), + Err(Ok(VaultError::NoPendingWithdrawal)) + ); +} + +#[test] +fn test_upgrade_entrypoints_require_admin_auth() { + let env = Env::default(); + let (vault, _admin, hash) = setup_upgrade(&env); + let eta = vault.propose_upgrade(&hash); + env.ledger().set_timestamp(eta); + env.set_auths(&[]); + + assert!(vault.try_propose_upgrade(&hash).is_err()); + assert!(vault.try_execute_upgrade().is_err()); + assert!(vault.try_cancel_upgrade().is_err()); + assert_eq!(vault.implementation(), None); +} + fn generate_storage_fingerprint(env: &Env) -> &str { // In a real script, this would iterate over storage or check specific critical keys // For the unit test, we just verify the ones we care about. diff --git a/contracts/vault/src/rounding_consistency.rs b/contracts/vault/src/rounding_consistency.rs index a94114585..dd72ff30c 100644 --- a/contracts/vault/src/rounding_consistency.rs +++ b/contracts/vault/src/rounding_consistency.rs @@ -358,4 +358,76 @@ mod tests { let result = RoundingPolicy::calculate_basis_points_amount(1_000_000, 10_001); assert!(result.is_err()); } + + // ── Exact error per validation path (issue #1526) ────────────────────── + + #[test] + fn test_floor_division_validated_error_paths() { + assert_eq!( + RoundingPolicy::floor_division_validated(10, 0, 100), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + RoundingPolicy::floor_division_validated(10, 3, -1), + Err(VaultError::InvalidAmount) + ); + // 1_999 / 1_000 leaves 999 (~4997 bps) of the numerator behind. + assert_eq!( + RoundingPolicy::floor_division_validated(1_999, 1_000, 100), + Err(VaultError::SlippageExceeded) + ); + assert_eq!( + RoundingPolicy::floor_division_validated(i128::MIN, -1, 100), + Err(VaultError::MathOverflow) + ); + assert_eq!( + RoundingPolicy::floor_division_validated(10_001, 10_000, 100), + Ok(1) + ); + } + + #[test] + fn test_convert_decimals_overflow_paths() { + // 10^39 does not fit in i128. + assert_eq!( + RoundingPolicy::convert_decimals(1, 0, 39), + Err(VaultError::MathOverflow) + ); + assert_eq!( + RoundingPolicy::convert_decimals(1, 39, 0), + Err(VaultError::MathOverflow) + ); + assert_eq!( + RoundingPolicy::convert_decimals(i128::MAX, 6, 18), + Err(VaultError::MathOverflow) + ); + } + + #[test] + fn test_validate_rounding_loss_error_paths() { + assert_eq!( + RoundingPolicy::validate_rounding_loss(1, 10_000, -1), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + RoundingPolicy::validate_rounding_loss(200, 10_000, 100), + Err(VaultError::SlippageExceeded) + ); + } + + #[test] + fn test_calculate_basis_points_amount_error_paths() { + assert_eq!( + RoundingPolicy::calculate_basis_points_amount(-1, 500), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + RoundingPolicy::calculate_basis_points_amount(1_000, 10_001), + Err(VaultError::InvalidFeeBps) + ); + assert_eq!( + RoundingPolicy::calculate_basis_points_amount(1_000, -1), + Err(VaultError::InvalidFeeBps) + ); + } } diff --git a/contracts/vault/src/strategy_validation.rs b/contracts/vault/src/strategy_validation.rs index ec05a8513..4ab31698e 100644 --- a/contracts/vault/src/strategy_validation.rs +++ b/contracts/vault/src/strategy_validation.rs @@ -4,12 +4,15 @@ //! cannot break vault logic through adversarial or corrupted payloads. use crate::VaultError; -use soroban_sdk::{xdr::ScAddress, Address, Env, TryFromVal}; +use soroban_sdk::{Address, Env}; /// Maximum allowed strategy total value to prevent overflow and unreasonable responses. /// Set conservatively to catch malicious responses while allowing legitimate vaults. pub const MAX_STRATEGY_VALUE: i128 = i128::MAX / 2; +/// Strkey of the all-zero contract address (`ScAddress::Contract([0; 32])`). +pub const ZERO_CONTRACT_STRKEY: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + /// Strategy response validator providing comprehensive payload validation. pub struct StrategyValidator; @@ -154,13 +157,9 @@ impl StrategyValidator { // - Does it have the strategy interface? // For now, basic non-zero check. // - // The previous `Address::from_string(&String::new(_env))` could not - // build the SDK `String` type at all. Building the all-zero contract - // address from its `ScAddress` avoids strkey decoding, which rejects - // an empty string outright. - let zero = - Address::try_from_val(env, &ScAddress::Contract(soroban_sdk::xdr::Hash([0u8; 32]))) - .map_err(|_| VaultError::InvalidAmount)?; + // `Address: TryFromVal` is host-only (not available on + // wasm), so the all-zero contract address is parsed from its strkey. + let zero = Address::from_str(env, ZERO_CONTRACT_STRKEY); if strategy == &zero { return Err(VaultError::InvalidAmount); } @@ -267,4 +266,72 @@ mod tests { let result = StrategyValidator::validate_price_response(-1_000_000, 6); assert!(result.is_err()); } + + // ── Exact error per validation path (issue #1526) ────────────────────── + + #[test] + fn test_validation_error_paths_return_expected_variants() { + assert_eq!( + StrategyValidator::validate_total_value(-1), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_total_value(MAX_STRATEGY_VALUE + 1), + Err(VaultError::MathOverflow) + ); + assert_eq!( + StrategyValidator::validate_deposit_result(0, 10, 10), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_deposit_result(1, 10, 9), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_withdrawal_result(0, 10, 9), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_withdrawal_result(1, 10, 11), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_decimals(31), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_price_response(0, 6), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_price_response(MAX_STRATEGY_VALUE + 1, 6), + Err(VaultError::MathOverflow) + ); + assert_eq!( + StrategyValidator::validate_price_response(1, 31), + Err(VaultError::InvalidAmount) + ); + } + + #[test] + fn test_validate_strategy_address_rejects_zero_contract() { + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{xdr::ScAddress, TryFromVal}; + + let env = Env::default(); + let zero = Address::try_from_val( + &env, + &ScAddress::Contract(soroban_sdk::xdr::Hash([0u8; 32])), + ) + .unwrap(); + assert_eq!(Address::from_str(&env, ZERO_CONTRACT_STRKEY), zero); + assert_eq!( + StrategyValidator::validate_strategy_address(&env, &zero), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + StrategyValidator::validate_strategy_address(&env, &Address::generate(&env)), + Ok(()) + ); + } } diff --git a/contracts/vault/src/upgrade.rs b/contracts/vault/src/upgrade.rs index 873cab571..d177f802b 100644 --- a/contracts/vault/src/upgrade.rs +++ b/contracts/vault/src/upgrade.rs @@ -17,6 +17,21 @@ pub enum ProxyDataKey { PendingAdmin = 3, /// Monotonic storage schema version used by migration hooks. StorageVersion = 4, + /// Queued WASM upgrade awaiting its timelock ([`PendingUpgrade`]). + PendingUpgrade = 5, +} + +/// Minimum delay between `propose_upgrade` and `execute_upgrade`. The +/// sensitive-parameter timelock applies instead when it is configured longer. +pub const MIN_UPGRADE_DELAY_SECS: u64 = 86_400; + +/// A queued WASM upgrade. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingUpgrade { + pub wasm_hash: BytesN<32>, + /// Ledger timestamp at/after which the upgrade may be executed. + pub eta: u64, } /// Constant for the implementation slot using a non-overlapping hash. @@ -55,14 +70,40 @@ pub fn set_pending_admin(env: &Env, admin: &Option
) { } } +/// Current implementation WASM hash, read from the EIP-1967 style +/// [`IMPLEMENTATION_SLOT`]. pub fn get_implementation(env: &Env) -> Option> { - env.storage().instance().get(&ProxyDataKey::Implementation) + env.storage() + .instance() + .get(&BytesN::from_array(env, &IMPLEMENTATION_SLOT)) } +/// Writes `wasm_hash` to [`IMPLEMENTATION_SLOT`] and reads it back, panicking +/// if the slot does not hold the expected hash afterwards. pub fn set_implementation(env: &Env, wasm_hash: &BytesN<32>) { env.storage() .instance() - .set(&ProxyDataKey::Implementation, wasm_hash); + .set(&BytesN::from_array(env, &IMPLEMENTATION_SLOT), wasm_hash); + assert!( + get_implementation(env).as_ref() == Some(wasm_hash), + "implementation slot verification failed" + ); +} + +pub fn get_pending_upgrade(env: &Env) -> Option { + env.storage().instance().get(&ProxyDataKey::PendingUpgrade) +} + +pub fn set_pending_upgrade(env: &Env, pending: &Option) { + if let Some(pending) = pending { + env.storage() + .instance() + .set(&ProxyDataKey::PendingUpgrade, pending); + } else { + env.storage() + .instance() + .remove(&ProxyDataKey::PendingUpgrade); + } } pub fn is_initialized(env: &Env) -> bool { diff --git a/package.json b/package.json index 7f9c206b5..23e8ff2bc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,5 @@ }, "resolutions": { "lodash": "^4.17.21" - }, - + } }