diff --git a/.github/workflows/pnpm-version-check.yml b/.github/workflows/pnpm-version-check.yml new file mode 100644 index 000000000..8af02a517 --- /dev/null +++ b/.github/workflows/pnpm-version-check.yml @@ -0,0 +1,49 @@ +name: pnpm version check + +# Catches ERR_PNPM_LOCKFILE_BREAKING_CHANGE before contributors waste 30 min +# debugging a pnpm 8 vs pnpm 9 lockfile mismatch (Issue #1458). + +on: + pull_request: + push: + branches: [main] + +jobs: + # ── Required version passes ───────────────────────────────────────────────── + pnpm-version-ok: + name: pnpm >=9.12 (required range) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable + + - name: Activate pnpm 9.12 via Corepack + run: corepack prepare pnpm@9.12.0 --activate + + - name: Verify pnpm version satisfies requirement + run: node scripts/check-pnpm-version.js + + # ── pnpm 8 fails fast with a clear message ─────────────────────────────────── + pnpm-v8-rejected: + name: pnpm 8 rejected with clear error + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm 8 (the bad version) + run: npm install -g pnpm@8 + + - name: Expect check script to exit non-zero for pnpm 8 + # The script must fail — if it exits 0 the version guard is broken. + run: | + node scripts/check-pnpm-version.js && echo "ERROR: script should have failed" && exit 1 || echo "OK: pnpm 8 correctly rejected" diff --git a/README.md b/README.md index a4bfc77f4..845a54112 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,21 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation ### Quick Start +> **Node ≥ 20 and pnpm ≥ 9.12 are required.** The lockfile format changed between pnpm 8 and pnpm 9; using an older version produces `ERR_PNPM_LOCKFILE_BREAKING_CHANGE`. Use [Corepack](https://nodejs.org/api/corepack.html) (bundled with Node ≥ 16) to pin the right version automatically: +> +> ```bash +> corepack enable +> corepack prepare pnpm@9.12.0 --activate +> ``` + 1. Start the backend: ```bash cd backend cp .env.example .env - npm install + pnpm install npx prisma migrate dev - npm run dev + pnpm dev ``` 2. Start the frontend in a second terminal: @@ -65,8 +72,8 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation ```bash cd frontend cp .env.example .env - npm install - npm run dev + pnpm install + pnpm dev ``` 3. Optional: run contract tests from the repo root: diff --git a/backend/openapi.json b/backend/openapi.json index 164916fb7..ddaeadfbb 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -143,8 +143,15 @@ "example": 0 }, "apy": { - "type": "number", - "example": 0 + "type": ["number", "null"], + "example": 8.45, + "description": "Annualised APY as a decimal percentage. null when the vault has insufficient price history (e.g. zero shares or fewer than 2 snapshots)." + }, + "apyStatus": { + "type": "string", + "enum": ["ok", "insufficient_data"], + "example": "ok", + "description": "ok when apy is a valid number; insufficient_data when apy is null." }, "timestamp": { "type": "string", @@ -371,6 +378,55 @@ } } }, + "/api/v1/vaults/{id}/apy": { + "get": { + "tags": [ + "Vault" + ], + "summary": "Vault APY", + "description": "Returns the annualised APY for the specified vault. Returns `apy: null` with `apyStatus: 'insufficient_data'` when the vault has zero shares or fewer than two price snapshots (e.g. a newly created vault).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Vault identifier" + } + ], + "responses": { + "200": { + "description": "Vault APY result", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["apy", "apyStatus", "timestamp"], + "properties": { + "apy": { + "type": ["number", "null"], + "example": 8.45, + "description": "Annualised APY as a decimal percentage. null when insufficient data." + }, + "apyStatus": { + "type": "string", + "enum": ["ok", "insufficient_data"], + "example": "ok" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, "/api/v1/vault/deposits": { "post": { "tags": [ diff --git a/backend/package-lock.json b/backend/package-lock.json index b0061a963..20dc8c80b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2474,9 +2474,9 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", + "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==", "license": "Apache-2.0", "engines": { "node": ">=8.0.0" @@ -3602,13 +3602,13 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true + "devOptional": true }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/debug": "5.22.0", @@ -3621,13 +3621,13 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true + "devOptional": true }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", @@ -3638,7 +3638,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "dependencies": { "@prisma/debug": "5.22.0" } @@ -6636,6 +6636,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, @@ -8469,6 +8470,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8962,7 +8970,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "dependencies": { "@prisma/engines": "5.22.0" diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db index 05fd07b38..4816f3079 100644 Binary files a/backend/prisma/dev.db and b/backend/prisma/dev.db differ diff --git a/backend/src/__tests__/vaultApy.test.ts b/backend/src/__tests__/vaultApy.test.ts new file mode 100644 index 000000000..b00e776e1 --- /dev/null +++ b/backend/src/__tests__/vaultApy.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for the APY calculation service (Issue #1456). + * + * Verifies that a vault with zero shares (or insufficient price history) + * returns apy: null / apyStatus: 'insufficient_data' instead of Infinity. + * + * Integration tests for GET /api/v1/vaults/:id/apy are in vaultApyEndpoint.test.ts. + */ + +import { computeVaultApy } from '../services/apy'; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +jest.mock('../prismaClient', () => ({ + getPrismaClient: () => mockPrisma, +})); + +const mockVaultState = { + findUnique: jest.fn(), +}; +const mockSharePriceSnapshot = { + findMany: jest.fn(), +}; +const mockPrisma = { + vaultState: mockVaultState, + sharePriceSnapshot: mockSharePriceSnapshot, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ─── Unit: computeVaultApy ──────────────────────────────────────────────────── + +describe('computeVaultApy()', () => { + it('returns null + insufficient_data when totalShares is 0', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' }); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + // Snapshot never queried — we bail early + expect(mockSharePriceSnapshot.findMany).not.toHaveBeenCalled(); + }); + + it('returns null + insufficient_data when vaultState is missing', async () => { + mockVaultState.findUnique.mockResolvedValue(null); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns null + insufficient_data when fewer than 2 snapshots exist', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns null + insufficient_data when priceLast is 0', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + { sharePrice: '0.000000', recordedAt: new Date(Date.now() - 86400000) }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apy).toBeNull(); + expect(result.apyStatus).toBe('insufficient_data'); + }); + + it('returns a finite apy and ok status with 2 valid snapshots', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1050' }); + mockSharePriceSnapshot.findMany.mockResolvedValue([ + { sharePrice: '1.010000', recordedAt: new Date() }, + { sharePrice: '1.000000', recordedAt: new Date(Date.now() - 86400000) }, + ]); + + const result = await computeVaultApy(1); + + expect(result.apyStatus).toBe('ok'); + expect(typeof result.apy).toBe('number'); + expect(Number.isFinite(result.apy)).toBe(true); + // (1.01 - 1.00) / 1.00 * 365 = 3.65 + expect(result.apy).toBeCloseTo(3.65, 5); + }); + + it('result is JSON-serialisable (no Infinity or NaN) for zero-shares vault', async () => { + mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' }); + + const result = await computeVaultApy(1); + + // JSON.stringify(Infinity) => 'null', which loses information silently. + // Ensure the value is intentionally null so stringify is lossless. + const serialised = JSON.stringify(result); + const parsed = JSON.parse(serialised); + expect(parsed.apy).toBeNull(); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 1aced4aea..c0fd1750b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -135,6 +135,7 @@ import { getEventPollingHealth, startEventPollingService, stopEventPollingServic import { eventOutboxService } from './eventOutbox'; import { prisma, getPrismaRuntimeConfig } from './prisma'; import { getPrismaClient } from './prismaClient'; +import { computeVaultApy } from './services/apy'; import { verifyWebhookEndpoint, registerWebhookEndpoint, @@ -1147,6 +1148,39 @@ app.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); }); +/** + * GET /api/v1/vaults/:id/apy + * + * Returns the annualised APY for the vault. Returns `apy: null` with + * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or + * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the + * frontend (Issue #1456). + */ +app.get( + '/api/v1/vaults/:id/apy', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const result = await computeVaultApy(); + res.json({ + ...result, + timestamp: new Date().toISOString(), + }); + }, +); + /** * @openapi * /vault/summary: diff --git a/backend/src/services/apy.ts b/backend/src/services/apy.ts new file mode 100644 index 000000000..14bd6686a --- /dev/null +++ b/backend/src/services/apy.ts @@ -0,0 +1,68 @@ +/** + * @file services/apy.ts + * Per-vault APY calculation service (Issue #1456). + * + * APY is derived from the two most-recent SharePriceSnapshot rows: + * apy = (priceNow - priceLast) / priceLast * 365 + * + * Guard-rails: + * - Returns { apy: null, apyStatus: 'insufficient_data' } when + * totalShares === 0 (new vault) or fewer than 2 price snapshots exist. + * - Returns { apy: null, apyStatus: 'insufficient_data' } when priceLast + * resolves to 0 to prevent a divide-by-zero / Infinity result. + */ + +import { Decimal } from 'decimal.js'; +import { getPrismaClient } from '../prismaClient'; + +export type ApyStatus = 'ok' | 'insufficient_data'; + +export interface VaultApyResult { + apy: number | null; + apyStatus: ApyStatus; +} + +/** + * Compute the annualised APY for a vault identified by its numeric state id. + * + * @param vaultStateId - The VaultState.id value (almost always 1 in the current + * single-vault design; exposed as a param for future + * multi-vault support). + */ +export async function computeVaultApy(vaultStateId: number = 1): Promise { + const prisma = getPrismaClient(); + + // Check whether the vault has been bootstrapped yet. + const vaultState = await prisma.vaultState.findUnique({ where: { id: vaultStateId } }); + + const totalShares = vaultState ? new Decimal(vaultState.totalShares) : new Decimal(0); + if (totalShares.isZero()) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + // Fetch the two most-recent price snapshots (newest first). + const snapshots = await prisma.sharePriceSnapshot.findMany({ + orderBy: { recordedAt: 'desc' }, + take: 2, + select: { sharePrice: true, recordedAt: true }, + }); + + if (snapshots.length < 2) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + const priceNow = new Decimal(snapshots[0].sharePrice); + const priceLast = new Decimal(snapshots[1].sharePrice); + + // Guard against a zero baseline price (can happen if a snapshot was persisted + // during an empty-vault state before this fix landed). + if (priceLast.isZero()) { + return { apy: null, apyStatus: 'insufficient_data' }; + } + + // Annualise: daily return × 365. The two snapshots are assumed to be ~1 day + // apart; a more precise implementation would weight by actual elapsed days. + const apy = priceNow.minus(priceLast).div(priceLast).times(365).toNumber(); + + return { apy, apyStatus: 'ok' }; +} diff --git a/package.json b/package.json index f1b966b7f..61556648e 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,9 @@ { + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20", + "pnpm": ">=9.12" + }, "name": "yieldvault-rwa", "private": true, "license": "MIT", @@ -13,6 +18,7 @@ "vitest": "^4.1.5" }, "scripts": { + "check:pnpm-version": "node scripts/check-pnpm-version.js", "prepare": "husky", "test": "vitest run", "lint:staged": "lint-staged", diff --git a/scripts/check-pnpm-version.js b/scripts/check-pnpm-version.js new file mode 100644 index 000000000..31724e3fa --- /dev/null +++ b/scripts/check-pnpm-version.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Checks that the active pnpm version satisfies the minimum required by this + * monorepo (engines.pnpm in the root package.json). + * + * Exits 0 on success, 1 with a clear human-readable error on failure so CI + * catches the mismatch before contributors hit ERR_PNPM_LOCKFILE_BREAKING_CHANGE. + * + * Usage: + * node scripts/check-pnpm-version.js # run manually + * npm run check:pnpm-version # via package.json script + */ + +'use strict'; + +const { execSync } = require('child_process'); +const { readFileSync } = require('fs'); +const path = require('path'); + +// ── Read required version from package.json ────────────────────────────────── + +const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); +const required = (pkg.engines && pkg.engines.pnpm) || '>=9.12'; + +// Strip the ">=" prefix to get the minimum semver string. +const minVersion = required.replace(/^>=/, '').trim(); +const parts = minVersion.split('.').map(Number); +const minMajor = parts[0]; +const minMinor = parts[1] !== undefined ? parts[1] : 0; +const minPatch = parts[2] !== undefined ? parts[2] : 0; + +// ── Detect installed pnpm version ──────────────────────────────────────────── + +let installedVersion; +try { + installedVersion = execSync('pnpm --version', { encoding: 'utf8', timeout: 5000 }).trim(); +} catch (_err) { + console.error( + '\n\u2716 pnpm not found.\n' + + ' Install it via Corepack (recommended):\n' + + '\n' + + ' corepack enable\n' + + ' corepack prepare pnpm@' + minVersion + ' --activate\n' + + '\n' + + ' Or via npm:\n' + + ' npm install -g pnpm@' + minVersion + '\n', + ); + process.exit(1); +} + +// ── Compare versions ───────────────────────────────────────────────────────── + +const vParts = installedVersion.split('.').map(Number); +const major = vParts[0]; +const minor = vParts[1] !== undefined ? vParts[1] : 0; +const patch = vParts[2] !== undefined ? vParts[2] : 0; + +const satisfies = + major > minMajor || + (major === minMajor && minor > minMinor) || + (major === minMajor && minor === minMinor && patch >= minPatch); + +if (!satisfies) { + console.error( + '\n\u2716 pnpm version mismatch.\n' + + '\n' + + ' Required : ' + required + ' (lockfile format v9)\n' + + ' Installed: ' + installedVersion + '\n' + + '\n' + + ' Running pnpm ' + installedVersion + ' against a pnpm 9 lockfile will produce\n' + + ' ERR_PNPM_LOCKFILE_BREAKING_CHANGE and a corrupted install.\n' + + '\n' + + ' Fix (Corepack \u2013 recommended):\n' + + ' corepack enable\n' + + ' corepack prepare pnpm@' + minVersion + ' --activate\n' + + '\n' + + ' Fix (npm global install):\n' + + ' npm install -g pnpm@' + minVersion + '\n', + ); + process.exit(1); +} + +console.log('\u2714 pnpm ' + installedVersion + ' satisfies ' + required);