diff --git a/client/src/components/charts/SharePriceChart.tsx b/client/src/components/charts/SharePriceChart.tsx new file mode 100644 index 000000000..a69dc87c2 --- /dev/null +++ b/client/src/components/charts/SharePriceChart.tsx @@ -0,0 +1,258 @@ +import { useEffect, useMemo, useState } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { apiUrl } from "../../lib/api"; + +type TimeRange = "1M" | "3M" | "All"; + +interface SharePricePoint { + date: string; + sharePrice: number; +} + +interface ApiSnapshotPoint { + date?: unknown; + sharePrice?: unknown; +} + +function formatAxisDate(date: string) { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function filterByRange(data: SharePricePoint[], range: TimeRange): SharePricePoint[] { + if (range === "All") return data; + const daysBack = range === "1M" ? 30 : 90; + const latest = new Date(data[data.length - 1]?.date ?? Date.now()); + const threshold = new Date(latest); + threshold.setDate(latest.getDate() - daysBack); + return data.filter((p) => new Date(p.date) >= threshold); +} + +function normalizePoint(raw: ApiSnapshotPoint): SharePricePoint | null { + if (typeof raw.date !== "string") return null; + const parsed = new Date(raw.date); + if (Number.isNaN(parsed.getTime())) return null; + const sharePrice = + typeof raw.sharePrice === "number" ? raw.sharePrice : Number(raw.sharePrice); + if (!Number.isFinite(sharePrice) || sharePrice <= 0) return null; + return { date: raw.date, sharePrice }; +} + +const rangeOptions: TimeRange[] = ["1M", "3M", "All"]; + +interface SharePriceChartProps { + vaultId?: string; +} + +export default function SharePriceChart({ vaultId = "primary-yield-vault" }: SharePriceChartProps) { + const [range, setRange] = useState("3M"); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); + + const loadHistory = async (showLoader = true) => { + if (showLoader) setLoading(true); + try { + setError(null); + const response = await fetch( + apiUrl(`/api/vaults/${encodeURIComponent(vaultId)}/share-price-history?days=365`), + ); + if (!response.ok) { + throw new Error(`Share price history unavailable (${response.status})`); + } + const raw = await response.json(); + const rows = Array.isArray(raw) ? raw : []; + const normalized = rows + .map((row) => normalizePoint(row as ApiSnapshotPoint)) + .filter((p): p is SharePricePoint => p !== null) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + setHistory(normalized); + } catch (err) { + const message = + err instanceof Error ? err.message : "Unable to load share price history"; + setError(message); + setHistory((prev) => (prev.length > 0 ? prev : [])); + } finally { + setLoading(false); + setRetrying(false); + } + }; + + useEffect(() => { + void loadHistory(); + }, [vaultId]); + + const filteredHistory = useMemo(() => filterByRange(history, range), [history, range]); + + const priceMin = useMemo( + () => + filteredHistory.length > 0 + ? Math.min(...filteredHistory.map((p) => p.sharePrice)) + : 0, + [filteredHistory], + ); + const priceMax = useMemo( + () => + filteredHistory.length > 0 + ? Math.max(...filteredHistory.map((p) => p.sharePrice)) + : 0, + [filteredHistory], + ); + const priceDelta = filteredHistory.length >= 2 + ? filteredHistory[filteredHistory.length - 1].sharePrice - + filteredHistory[0].sharePrice + : null; + const priceDeltaPct = + priceDelta !== null && filteredHistory[0].sharePrice > 0 + ? (priceDelta / filteredHistory[0].sharePrice) * 100 + : null; + + return ( +
+
+
+

Share Price History

+

+ Historical vault share price over time. +

+ {priceDeltaPct !== null && ( +

= 0 ? "text-green-400" : "text-red-400" + }`} + > + {priceDeltaPct >= 0 ? "+" : ""} + {priceDeltaPct.toFixed(4)}% over period  ·  min{" "} + {priceMin.toFixed(6)}  ·  max {priceMax.toFixed(6)} +

+ )} +
+ +
+ {rangeOptions.map((option) => ( + + ))} +
+
+ +
+ {loading ? ( +
+

+ Loading share price history… +

+
+
+ ) : error && history.length === 0 ? ( +
+ +

Unable to load share price history

+

{error}

+ +
+ ) : filteredHistory.length === 0 ? ( +
+

No share price snapshots available

+

+ No recorded snapshots for this period. Snapshots are taken daily. +

+
+ ) : ( + + + + + v.toFixed(4)} + stroke="#94a3b8" + tick={{ fill: "#94a3b8", fontSize: 12 }} + axisLine={false} + tickLine={false} + width={62} + /> + [value.toFixed(6), "Share Price"]} + labelFormatter={(label) => + new Date(label).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }) + } + contentStyle={{ + backgroundColor: "rgba(15, 23, 42, 0.94)", + border: "1px solid rgba(148, 163, 184, 0.2)", + borderRadius: "16px", + boxShadow: "0 12px 30px rgba(0, 0, 0, 0.35)", + }} + cursor={{ stroke: "rgba(52, 211, 153, 0.6)", strokeWidth: 1 }} + /> + + + + )} +
+
+ ); +} diff --git a/client/src/components/charts/__tests__/SharePriceChart.test.tsx b/client/src/components/charts/__tests__/SharePriceChart.test.tsx new file mode 100644 index 000000000..50de3f3c4 --- /dev/null +++ b/client/src/components/charts/__tests__/SharePriceChart.test.tsx @@ -0,0 +1,129 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SharePriceChart from "../SharePriceChart"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + LineChart: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + CartesianGrid: () =>
, + XAxis: () =>
, + YAxis: () =>
, + Tooltip: () =>
, + Line: () =>
, +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +function makeSnapshot(date: string, sharePrice: number) { + return { date, sharePrice, vaultId: "primary-yield-vault" }; +} + +function deferred() { + let resolve: (v: unknown) => void = () => {}; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("SharePriceChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading state while data is fetched", async () => { + const d = deferred(); + mockFetch.mockReturnValueOnce(d.promise); + + render(); + expect(screen.getByText(/Loading share price history/i)).toBeInTheDocument(); + + d.resolve({ ok: true, json: async () => [] }); + await screen.findByText(/No share price snapshots available/i); + }); + + it("renders the chart when data is returned", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + makeSnapshot("2026-04-01", 1.0512), + makeSnapshot("2026-04-02", 1.0525), + ], + }); + + render(); + expect(await screen.findByTestId("line-chart")).toBeInTheDocument(); + }); + + it("shows empty state when the API returns an empty array", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }); + + render(); + expect( + await screen.findByText(/No share price snapshots available/i), + ).toBeInTheDocument(); + }); + + it("shows error state on network failure and recovers after retry", async () => { + const user = userEvent.setup(); + + mockFetch + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + ok: true, + json: async () => [makeSnapshot("2026-04-01", 1.05)], + }); + + render(); + expect( + await screen.findByText(/Unable to load share price history/i), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Retry/i })); + expect(await screen.findByTestId("line-chart")).toBeInTheDocument(); + }); + + it("shows error state on non-OK response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + render(); + expect( + await screen.findByText(/Unable to load share price history/i), + ).toBeInTheDocument(); + }); + + it("drops invalid snapshot rows instead of crashing", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { date: "not-a-date", sharePrice: 1.05 }, + { date: null, sharePrice: 1.06 }, + { date: "2026-04-01", sharePrice: -1 }, + { date: "2026-04-02", sharePrice: 1.07 }, + ], + }); + + render(); + expect(await screen.findByTestId("line-chart")).toBeInTheDocument(); + expect( + screen.queryByText(/No share price snapshots available/i), + ).not.toBeInTheDocument(); + }); + + it("renders range selector buttons", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }); + render(); + await screen.findByText(/No share price snapshots available/i); + + expect(screen.getByRole("button", { name: "1M" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "3M" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); + }); +}); diff --git a/contracts/__tests__/generateManifest.test.ts b/contracts/__tests__/generateManifest.test.ts new file mode 100644 index 000000000..6aaef0e17 --- /dev/null +++ b/contracts/__tests__/generateManifest.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +/** + * Tests for generate-manifest.js + * + * We exercise the core validation and manifest-generation logic by calling + * the script as a child process (via execSync) with temp fixtures, so we + * don't need to refactor the CJS script into ESM modules. + */ +import { execSync } from "child_process"; + +const SCRIPT = path.resolve(__dirname, "../scripts/generate-manifest.js"); + +// A valid 56-char Soroban contract ID (starts with C, base32) +const VALID_ID_1 = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const VALID_ID_2 = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC4"; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "manifest-test-")); +} + +function writeDeployed(dir: string, data: Record): string { + const p = path.join(dir, "deployed.json"); + fs.writeFileSync(p, JSON.stringify(data)); + return p; +} + +function runScript(args: string, cwd?: string): { stdout: string; status: number } { + try { + const stdout = execSync(`node "${SCRIPT}" ${args}`, { + encoding: "utf8", + cwd: cwd ?? process.cwd(), + }); + return { stdout, status: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; status?: number }; + return { stdout: e.stdout ?? e.stderr ?? "", status: e.status ?? 1 }; + } +} + +describe("generate-manifest.js", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("generates a valid manifest JSON for well-formed input", () => { + const inputPath = writeDeployed(tmpDir, { + yield_vault: VALID_ID_1, + zap: VALID_ID_2, + }); + const outputPath = path.join(tmpDir, "manifest.json"); + + const result = runScript( + `--input "${inputPath}" --network testnet --output "${outputPath}"`, + ); + + expect(result.status).toBe(0); + expect(fs.existsSync(outputPath)).toBe(true); + + const manifest = JSON.parse(fs.readFileSync(outputPath, "utf8")); + expect(manifest.schemaVersion).toBe("1.0"); + expect(manifest.network).toBe("testnet"); + expect(manifest.contracts.yield_vault).toBe(VALID_ID_1); + expect(manifest.contracts.zap).toBe(VALID_ID_2); + expect(typeof manifest.generatedAt).toBe("string"); + expect(typeof manifest.commitSha).toBe("string"); + }); + + it("rejects an invalid network name", () => { + const inputPath = writeDeployed(tmpDir, { yield_vault: VALID_ID_1 }); + const outputPath = path.join(tmpDir, "manifest.json"); + + const result = runScript( + `--input "${inputPath}" --network badnetwork --output "${outputPath}"`, + ); + + expect(result.status).not.toBe(0); + expect(fs.existsSync(outputPath)).toBe(false); + }); + + it("rejects a malformed contract ID", () => { + const inputPath = writeDeployed(tmpDir, { + yield_vault: "not-a-valid-id", + }); + const outputPath = path.join(tmpDir, "manifest.json"); + + const result = runScript( + `--input "${inputPath}" --network testnet --output "${outputPath}"`, + ); + + expect(result.status).not.toBe(0); + expect(fs.existsSync(outputPath)).toBe(false); + }); + + it("exits with error when input file is missing", () => { + const result = runScript( + `--input "/nonexistent/deployed.json" --network testnet --output "${path.join(tmpDir, "m.json")}"`, + ); + expect(result.status).not.toBe(0); + }); + + it("skips empty-string contract IDs without error", () => { + const inputPath = writeDeployed(tmpDir, { + yield_vault: VALID_ID_1, + zap: "", + }); + const outputPath = path.join(tmpDir, "manifest.json"); + + const result = runScript( + `--input "${inputPath}" --network testnet --output "${outputPath}"`, + ); + + expect(result.status).toBe(0); + const manifest = JSON.parse(fs.readFileSync(outputPath, "utf8")); + expect(manifest.contracts.yield_vault).toBe(VALID_ID_1); + expect(manifest.contracts.zap).toBeUndefined(); + }); + + it("supports all three allowed networks", () => { + for (const network of ["testnet", "mainnet", "local"]) { + const inputPath = writeDeployed(tmpDir, { yield_vault: VALID_ID_1 }); + const outputPath = path.join(tmpDir, `manifest-${network}.json`); + + const result = runScript( + `--input "${inputPath}" --network ${network} --output "${outputPath}"`, + ); + + expect(result.status).toBe(0); + const manifest = JSON.parse(fs.readFileSync(outputPath, "utf8")); + expect(manifest.network).toBe(network); + } + }); + + it("manifest includes generatedAt timestamp in ISO format", () => { + const inputPath = writeDeployed(tmpDir, { yield_vault: VALID_ID_1 }); + const outputPath = path.join(tmpDir, "manifest.json"); + + runScript(`--input "${inputPath}" --network testnet --output "${outputPath}"`); + + const manifest = JSON.parse(fs.readFileSync(outputPath, "utf8")); + expect(() => new Date(manifest.generatedAt).toISOString()).not.toThrow(); + }); +}); diff --git a/contracts/scripts/README.md b/contracts/scripts/README.md index 66f65463d..f247231bd 100644 --- a/contracts/scripts/README.md +++ b/contracts/scripts/README.md @@ -82,6 +82,48 @@ The validator checks for: - Correct address format (Stellar/Soroban `C...` or `G...` addresses). - Duplicate addresses within a single network. +## Deployment Manifest Generator + +After each deployment run, generate a traceable manifest that captures contract IDs, +network, commit SHA, and timestamp: + +```bash +node contracts/scripts/generate-manifest.js \ + --input contracts/scripts/deployed.json \ + --network testnet \ + --output contracts/scripts/deployment-manifest.json +``` + +Options: + +| Flag | Default | Description | +|------|---------|-------------| +| `--input` | `contracts/scripts/deployed.json` | Path to the deployed.json from deploy.sh | +| `--network` | `testnet` | Target network: `testnet`, `mainnet`, or `local` | +| `--output` | `contracts/scripts/deployment-manifest.json` | Output path for the manifest | + +The script validates all contract IDs (must be 56-char Soroban/Stellar addresses starting with `C` or `G`), skips empty entries, and cross-references the output against `contracts/registry.json` to warn if any deployed contract is not yet registered. + +**Sample output** is in `contracts/scripts/deployment-manifest.example.json`. + +**Typical post-deployment workflow:** + +```bash +# 1. Deploy contracts +bash contracts/scripts/deploy.sh + +# 2. Generate the manifest +node contracts/scripts/generate-manifest.js \ + --input contracts/scripts/deployed.json \ + --network testnet + +# 3. Validate the registry is up-to-date +node contracts/scripts/validate-registry.js +``` + +The manifest file (`deployment-manifest.json`) should be committed to the repository +or stored as a CI artifact so that every deployment is fully auditable. + ## Secrets `.env.deploy` is listed in `.gitignore`. Never commit private keys or secret accounts. diff --git a/contracts/scripts/deployment-manifest.example.json b/contracts/scripts/deployment-manifest.example.json new file mode 100644 index 000000000..aa90114b2 --- /dev/null +++ b/contracts/scripts/deployment-manifest.example.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "generatedAt": "2026-05-28T12:00:00.000Z", + "network": "testnet", + "commitSha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "branch": "main", + "contracts": { + "yield_vault": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + "zap": "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBSC4", + "aa_factory": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCSC4", + "liquid_staking": "CDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDSC4", + "emission_controller": "CEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEESC4" + } +} diff --git a/contracts/scripts/generate-manifest.js b/contracts/scripts/generate-manifest.js new file mode 100755 index 000000000..65895a082 --- /dev/null +++ b/contracts/scripts/generate-manifest.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * generate-manifest.js + * + * Generates a deployment manifest after a contract deployment run. + * The manifest captures contract IDs, the target network, the git commit SHA, + * and the deployment timestamp so that deployments are fully traceable. + * + * Usage: + * node contracts/scripts/generate-manifest.js [--input ] \ + * [--network ] \ + * [--output ] + * + * Options: + * --input Path to the deployed.json produced by deploy.sh + * (default: contracts/scripts/deployed.json) + * --network Stellar network name: testnet | mainnet | local + * (default: testnet) + * --output Where to write the manifest JSON + * (default: contracts/scripts/deployment-manifest.json) + * + * Example: + * node contracts/scripts/generate-manifest.js \ + * --input contracts/scripts/deployed.json \ + * --network testnet \ + * --output contracts/scripts/deployment-manifest.json + * + * Sample output: + * See contracts/scripts/deployment-manifest.example.json + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +// --------------------------------------------------------------------------- +// Soroban contract ID validation +// Contract IDs are 56-character base32 strings starting with 'C'. +// Stellar public keys start with 'G'. Both are valid contract identifiers. +// --------------------------------------------------------------------------- +const CONTRACT_ID_RE = /^[CG][A-Z2-7]{55}$/; + +function isValidContractId(value) { + return typeof value === "string" && CONTRACT_ID_RE.test(value); +} + +const ALLOWED_NETWORKS = new Set(["testnet", "mainnet", "local"]); + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- +function parseArgs(argv) { + const args = {}; + for (let i = 2; i < argv.length; i++) { + if (argv[i].startsWith("--") && i + 1 < argv.length) { + const key = argv[i].slice(2); + args[key] = argv[i + 1]; + i++; + } + } + return args; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function getGitCommitSha() { + try { + return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +} + +function getGitBranch() { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +function main() { + const SCRIPTS_DIR = path.join(__dirname); + const CONTRACTS_DIR = path.join(SCRIPTS_DIR, ".."); + + const args = parseArgs(process.argv); + + const inputPath = args.input ?? path.join(SCRIPTS_DIR, "deployed.json"); + const network = args.network ?? "testnet"; + const outputPath = args.output ?? path.join(SCRIPTS_DIR, "deployment-manifest.json"); + + // Validate network + if (!ALLOWED_NETWORKS.has(network)) { + console.error( + `ERROR: --network must be one of: ${[...ALLOWED_NETWORKS].join(", ")}. Got: "${network}"` + ); + process.exit(1); + } + + // Read deployed.json + if (!fs.existsSync(inputPath)) { + console.error(`ERROR: Input file not found: ${inputPath}`); + console.error( + "Run deploy.sh first, or pass --input to specify the deployed.json location." + ); + process.exit(1); + } + + let deployedContracts; + try { + const raw = fs.readFileSync(inputPath, "utf8"); + deployedContracts = JSON.parse(raw); + } catch (err) { + console.error(`ERROR: Failed to parse ${inputPath}: ${err.message}`); + process.exit(1); + } + + if (typeof deployedContracts !== "object" || deployedContracts === null || Array.isArray(deployedContracts)) { + console.error("ERROR: deployed.json must be a JSON object mapping contract names to IDs."); + process.exit(1); + } + + // Validate each contract ID + const validContracts = {}; + const invalidEntries = []; + + for (const [name, contractId] of Object.entries(deployedContracts)) { + if (!contractId || contractId === "") { + // Empty string — contract was not deployed in this run; skip silently. + continue; + } + if (!isValidContractId(contractId)) { + invalidEntries.push({ name, contractId }); + } else { + validContracts[name] = contractId; + } + } + + if (invalidEntries.length > 0) { + console.error("ERROR: The following contract IDs are invalid (must be 56-char base32 starting with C or G):"); + for (const { name, contractId } of invalidEntries) { + console.error(` ${name}: "${contractId}"`); + } + process.exit(1); + } + + if (Object.keys(validContracts).length === 0) { + console.warn("WARNING: No deployed contract IDs found in the input file. Manifest will have an empty contracts map."); + } + + // Read registry.json to cross-reference (best-effort) + let registryNote = null; + const registryPath = path.join(CONTRACTS_DIR, "registry.json"); + if (fs.existsSync(registryPath)) { + try { + const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); + const networkContracts = registry[network] ?? {}; + const missingInRegistry = Object.keys(validContracts).filter( + (name) => !networkContracts[name] + ); + if (missingInRegistry.length > 0) { + registryNote = `The following deployed contracts are not yet in registry.json[${network}]: ${missingInRegistry.join(", ")}. Run deploy.sh to update the registry automatically.`; + console.warn(`WARNING: ${registryNote}`); + } + } catch { + // Non-fatal — just skip registry cross-reference. + } + } + + // Build manifest + const manifest = { + schemaVersion: "1.0", + generatedAt: new Date().toISOString(), + network, + commitSha: getGitCommitSha(), + branch: getGitBranch(), + contracts: validContracts, + ...(registryNote ? { registryNote } : {}), + }; + + // Write output + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + "\n"); + + console.log(`Deployment manifest written to: ${outputPath}`); + console.log(` Network: ${manifest.network}`); + console.log(` Commit: ${manifest.commitSha}`); + console.log(` Branch: ${manifest.branch}`); + console.log(` Contracts: ${Object.keys(manifest.contracts).join(", ") || "(none)"}`); +} + +main(); diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 39b8e1487..321c1bb96 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -228,3 +228,126 @@ If the name differs in your fork, run `gh workflow list` and use the **CI** work - [CONTRIBUTING.md](../CONTRIBUTING.md) - [Contract security checklist](./contract-security-checklist.md) - [Release checklist](./release-checklist.md) + +--- + +## Stellar Wave: claiming issues and closing them + +The **Stellar Wave** is StellarYield's open-source contributor program hosted on +[Drips](https://drips.network). Wave issues carry Drips points (complexity is set +by the maintainer in the Drips dashboard, not by a GitHub label). This section +explains the full lifecycle from claiming to merge. + +### 1. Finding a Wave issue + +Wave issues appear in the GitHub Issues tab. Look for the `stellar-wave` label or +browse the Drips maintainer dashboard for the project. Each issue lists an +**Expected Drips Points** value and a complexity tier (Low / Medium / High). The +points value shown in the issue body is set by the maintainer in the Drips +dashboard—do not attempt to change it by adding or removing GitHub labels, as that +can override the Drips configuration. + +### 2. Claiming an issue + +Before writing any code: + +1. Verify the issue has the `status: available` label (or no active assignee). +2. Open the issue and post a comment using the + [**Claim an Issue**](.github/ISSUE_TEMPLATE/claim_issue.yml) template. + Fill in your GitHub handle, issue type (`Stellar Wave issue`), a brief + planned approach, and an estimated completion date. +3. A maintainer will assign the issue to you (usually within 24 hours) and + change the label to `status: in-progress`. +4. You do **not** need any special permissions to post a claim comment—any + GitHub user can do it. + +> **One active claim per contributor.** Do not claim a second issue while +> another is in progress. If you need to drop an issue, post a comment so a +> maintainer can release it. + +### 3. Staying active: progress updates + +Post a progress update **at least every 7 days** using the template in +[`.github/PROGRESS_UPDATE.md`](../.github/PROGRESS_UPDATE.md). Issues with no +update for 14+ days may be labelled `status: needs-update` and eventually +re-opened for others. + +### 4. Branch and PR naming + +Create a branch from the latest `main` of the **upstream** repository: + +```bash +git fetch upstream +git checkout -b feat/issue--short-description upstream/main +``` + +Examples: + +| Issue | Branch name | +|-------|------------| +| `#559` – contributor guide | `feat/issue-559-wave-contributor-docs` | +| `#611` – correlation middleware | `feat/issue-611-correlation-id-middleware` | + +For PRs that address multiple related issues at once, list all numbers: + +``` +feat/issues-545-549-share-price-chart-and-manifest +``` + +### 5. Linking the issue in your PR + +The PR body **must** include a closing keyword so GitHub (and Drips) can +automatically link and close the issue on merge. Use one of: + +``` +Closes # +Fixes # +Resolves # +``` + +If a single PR closes multiple issues, list each on its own line: + +``` +Closes #545 +Closes #549 +``` + +Place these lines in the **Description** section of the PR body, not only in +commit messages. GitHub only processes closing keywords in the PR body (or a +commit message on the default branch after merge) when the PR targets the same +repository's default branch. + +### 6. PR description checklist + +Use the [PR template](../.github/pull_request_template.md) and make sure to: + +- Fill in every section (Description, Type of Change, Verification Commands). +- Check off `npm run lint` / `npm run test` (frontend/backend) or + `cargo fmt` / `cargo clippy` / `cargo test` (contracts) as appropriate. +- Add UI snapshots (Desktop 1024px+ and Mobile 375px) if the PR touches + React components or CSS; otherwise state **"No visual changes"** explicitly. +- Reference the issue number in the title (e.g. `feat: add correlation ID middleware (#611)`). + +### 7. Review and merge expectations + +- A maintainer will review your PR and may request changes. Address feedback + promptly; PRs with no response to review comments for 7+ days may be closed. +- **Do not** force-push after a review has started—add new commits instead so + the reviewer can see what changed. +- The maintainer merges via squash or merge commit (never rebase from a fork + PR). You do not need to squash your commits yourself. +- Once the PR is merged to `main`, GitHub will automatically close the linked + issue(s). + +### 8. Drips points + +Drips points are managed entirely through the **Drips maintainer dashboard**, not +by GitHub labels. Do **not** add or remove the `stellar-wave` label or change +issue complexity labels yourself—doing so can reset the points value to a Drips +default (typically 100 points) rather than the maintainer-set value. If you +believe the complexity of an issue is mis-classified, leave a comment on the +issue describing your reasoning and a maintainer will review it. + +Points are distributed after the PR is merged and the issue is closed. You do not +need to do anything extra to receive them—Drips tracks the linked issue closure +automatically. diff --git a/server/src/__tests__/correlationId.test.ts b/server/src/__tests__/correlationId.test.ts new file mode 100644 index 000000000..edc9f6e14 --- /dev/null +++ b/server/src/__tests__/correlationId.test.ts @@ -0,0 +1,84 @@ +import express, { Request, Response } from "express"; +import request from "supertest"; +import { + correlationIdMiddleware, + CORRELATION_ID_HEADER, + getCorrelationId, + getRequestId, +} from "../middleware/correlationId"; + +function buildApp() { + const app = express(); + app.use(correlationIdMiddleware); + app.get("/ping", (req: Request, res: Response) => { + res.json({ + correlationId: getCorrelationId(req), + requestId: getRequestId(req), + }); + }); + return app; +} + +describe("correlationIdMiddleware", () => { + const app = buildApp(); + + it("generates a correlation ID when none is supplied", async () => { + const res = await request(app).get("/ping"); + expect(res.status).toBe(200); + expect(res.body.correlationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it("propagates a valid inbound X-Correlation-Id header", async () => { + const id = "trace-12345678-abcd"; + const res = await request(app) + .get("/ping") + .set(CORRELATION_ID_HEADER, id); + expect(res.status).toBe(200); + expect(res.body.correlationId).toBe(id); + expect(res.headers["x-correlation-id"]).toBe(id); + }); + + it("ignores a too-short inbound correlation ID and generates a fresh one", async () => { + const res = await request(app) + .get("/ping") + .set(CORRELATION_ID_HEADER, "short"); + expect(res.body.correlationId).not.toBe("short"); + expect(res.body.correlationId.length).toBeGreaterThanOrEqual(8); + }); + + it("ignores a too-long inbound correlation ID and generates a fresh one", async () => { + const tooLong = "x".repeat(129); + const res = await request(app) + .get("/ping") + .set(CORRELATION_ID_HEADER, tooLong); + expect(res.body.correlationId).not.toBe(tooLong); + }); + + it("echoes correlation ID in response headers", async () => { + const id = "echo-test-abcdef1234"; + const res = await request(app) + .get("/ping") + .set(CORRELATION_ID_HEADER, id); + expect(res.headers["x-correlation-id"]).toBe(id); + }); + + it("always generates a unique per-hop request ID regardless of correlation ID", async () => { + const res1 = await request(app).get("/ping"); + const res2 = await request(app).get("/ping"); + expect(res1.body.requestId).not.toBe(res2.body.requestId); + }); + + it("exposes both IDs in response headers", async () => { + const res = await request(app).get("/ping"); + expect(res.headers["x-correlation-id"]).toBeDefined(); + expect(res.headers["x-request-id"]).toBeDefined(); + }); + + it("attaches IDs to req so downstream handlers can read them", async () => { + const res = await request(app).get("/ping"); + expect(res.body.correlationId).toBeTruthy(); + expect(res.body.requestId).toBeTruthy(); + }); +}); diff --git a/server/src/__tests__/sharePriceHistory.test.ts b/server/src/__tests__/sharePriceHistory.test.ts new file mode 100644 index 000000000..8db51dda1 --- /dev/null +++ b/server/src/__tests__/sharePriceHistory.test.ts @@ -0,0 +1,74 @@ +import request from "supertest"; +import { createApp } from "../app"; + +const app = createApp(); + +describe("GET /api/vaults/:vaultId/share-price-history", () => { + it("returns an array (fixture) when no database is available", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history", + ); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("returns fixture snapshots with expected shape", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history", + ); + expect(res.status).toBe(200); + const body = res.body as Array<{ + date: string; + sharePrice: number; + vaultId: string; + }>; + + if (body.length > 0) { + const first = body[0]; + expect(typeof first.date).toBe("string"); + expect(typeof first.sharePrice).toBe("number"); + expect(first.sharePrice).toBeGreaterThan(0); + expect(typeof first.vaultId).toBe("string"); + } + }); + + it("defaults to 90 days of fixture data", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history", + ); + expect(res.status).toBe(200); + expect((res.body as unknown[]).length).toBeLessThanOrEqual(90); + }); + + it("respects the days query parameter", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history?days=30", + ); + expect(res.status).toBe(200); + expect((res.body as unknown[]).length).toBeLessThanOrEqual(30); + }); + + it("caps days at 365", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history?days=1000", + ); + expect(res.status).toBe(200); + expect((res.body as unknown[]).length).toBeLessThanOrEqual(365); + }); + + it("ignores invalid days param and falls back to default", async () => { + const res = await request(app).get( + "/api/vaults/primary-yield-vault/share-price-history?days=abc", + ); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it("returns data for different vault IDs without erroring", async () => { + const res = await request(app).get( + "/api/vaults/another-vault/share-price-history", + ); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 622463231..cf07661ff 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import { metricsMiddleware, getMetrics } from "./middleware/metrics"; import { auditMiddleware } from "./middleware/audit"; import { sendError } from "./utils/errorResponse"; import { requestContextMiddleware } from "./middleware/requestContext"; +import { correlationIdMiddleware } from "./middleware/correlationId"; import { errorHandler, requestLoggerMiddleware } from "./middleware/requestLogger"; import yieldsRouter from "./routes/yields"; import leaderboardRouter from "./routes/leaderboard"; @@ -45,6 +46,7 @@ import rewardsRouter from "./routes/rewards"; import reliabilityRouter from "./routes/reliability"; import relayerStatusRouter from "./routes/relayerStatus"; import auditReplayRouter from "./routes/auditReplay"; +import sharePriceHistoryRouter from "./routes/sharePriceHistory"; import { createAuthChallenge, verifyAuthChallenge } from "./utils/stellarAuth"; import { getRecommendationTimeline, @@ -91,6 +93,7 @@ export function createApp() { app.use(cors()); app.use(express.json()); app.use(requestContextMiddleware); + app.use(correlationIdMiddleware); app.use(requestLoggerMiddleware); app.use(metricsMiddleware); app.use(auditMiddleware); @@ -136,6 +139,7 @@ export function createApp() { app.use("/api/reliability", reliabilityRouter); app.use("/api/relayer/status", relayerStatusRouter); app.use("/api/audit-replay", auditReplayRouter); + app.use("/api/vaults", sharePriceHistoryRouter); // Legacy JSON metrics (internal tooling) app.get("/api/metrics", getMetrics); diff --git a/server/src/middleware/correlationId.ts b/server/src/middleware/correlationId.ts new file mode 100644 index 000000000..d3e63c044 --- /dev/null +++ b/server/src/middleware/correlationId.ts @@ -0,0 +1,57 @@ +import { NextFunction, Request, Response } from "express"; +import crypto from "crypto"; + +export const CORRELATION_ID_HEADER = "x-correlation-id"; +export const REQUEST_ID_HEADER = "x-request-id"; + +// Validates that an inbound ID is a non-empty string within acceptable bounds. +function normalizeId(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length < 8 || trimmed.length > 128) return null; + return trimmed; +} + +/** + * Attaches correlation and request IDs to every request. + * + * Propagation rules: + * - X-Correlation-ID: forwarded from the client when present and valid; + * otherwise a fresh UUID is generated. + * - X-Request-ID: always generated fresh for each hop so individual + * service legs can be distinguished within the same correlation trace. + * + * Both IDs are echoed back in the response headers and written onto `req` + * so that downstream middleware and route handlers can include them in logs. + */ +export function correlationIdMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const inboundCorrelationId = normalizeId(req.header(CORRELATION_ID_HEADER)); + const correlationId = inboundCorrelationId ?? crypto.randomUUID(); + const requestId = crypto.randomUUID(); + + const ctx = req as unknown as { + correlationId: string; + requestId: string; + }; + ctx.correlationId = correlationId; + ctx.requestId = requestId; + + res.setHeader("X-Correlation-Id", correlationId); + res.setHeader("X-Request-Id", requestId); + + next(); +} + +/** Retrieve the correlation ID attached by correlationIdMiddleware. */ +export function getCorrelationId(req: Request): string | undefined { + return (req as unknown as { correlationId?: string }).correlationId; +} + +/** Retrieve the per-hop request ID attached by correlationIdMiddleware. */ +export function getRequestId(req: Request): string | undefined { + return (req as unknown as { requestId?: string }).requestId; +} diff --git a/server/src/middleware/requestLogger.ts b/server/src/middleware/requestLogger.ts index 81843e80b..c5c2e4c6d 100644 --- a/server/src/middleware/requestLogger.ts +++ b/server/src/middleware/requestLogger.ts @@ -1,11 +1,8 @@ import { NextFunction, Request, Response } from "express"; +import { getCorrelationId, getRequestId } from "./correlationId"; type LogLevel = "info" | "warn" | "error"; -function getRequestId(req: Request): string | undefined { - return (req as unknown as { requestId?: string }).requestId; -} - function log(level: LogLevel, payload: Record): void { const line = JSON.stringify({ ts: new Date().toISOString(), @@ -28,6 +25,7 @@ export function requestLoggerMiddleware( res.on("finish", () => { const durationMs = Date.now() - start; log("info", { + correlationId: getCorrelationId(req), requestId: getRequestId(req), method: req.method, path: req.originalUrl ?? req.path, @@ -45,6 +43,7 @@ export function errorHandler( res: Response, _next: NextFunction, ): void { + const correlationId = getCorrelationId(req); const requestId = getRequestId(req); const error = err instanceof Error @@ -52,6 +51,7 @@ export function errorHandler( : { name: "Error", message: "Unexpected error" }; log("error", { + correlationId, requestId, method: req.method, path: req.originalUrl ?? req.path, @@ -63,6 +63,7 @@ export function errorHandler( res.status(500).json({ error: "Internal server error.", + correlationId, requestId, }); } diff --git a/server/src/routes/sharePriceHistory.ts b/server/src/routes/sharePriceHistory.ts new file mode 100644 index 000000000..8660be886 --- /dev/null +++ b/server/src/routes/sharePriceHistory.ts @@ -0,0 +1,114 @@ +import { Router, Request, Response } from "express"; +import { sendError } from "../utils/errorResponse"; + +type SharePriceHistoryPrismaClient = { + sharePriceSnapshot: { + findMany(args: { + where?: { vaultId?: string }; + orderBy: { snapshotAt: "asc" }; + take?: number; + }): Promise< + Array<{ + vaultId: string; + sharePrice: number; + totalShares: number; + totalAssets: number; + snapshotAt: Date; + }> + >; + }; + $disconnect?: () => Promise; +}; + +async function loadPrismaClient(): Promise { + try { + const prismaModule = (await import("@prisma/client")) as unknown as { + PrismaClient?: new () => SharePriceHistoryPrismaClient; + }; + if (!prismaModule.PrismaClient) return null; + return new prismaModule.PrismaClient(); + } catch { + return null; + } +} + +/** Deterministic fixture used when no database is available. */ +function generateFixtureSnapshots( + vaultId: string, + days = 90, +): Array<{ date: string; sharePrice: number; vaultId: string }> { + const snapshots: Array<{ date: string; sharePrice: number; vaultId: string }> = []; + let price = 1.0; + const now = Date.now(); + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now - i * 24 * 60 * 60 * 1000); + // Deterministic drift: price increases ~0.01% per day with a small sine variation + price = price * (1 + 0.0001 + 0.00005 * Math.sin(i * 0.3)); + snapshots.push({ + date: date.toISOString().split("T")[0], + sharePrice: Math.round(price * 1_000_000) / 1_000_000, + vaultId, + }); + } + + return snapshots; +} + +const sharePriceHistoryRouter = Router(); + +/** + * GET /api/vaults/:vaultId/share-price-history + * + * Returns daily share price snapshots for the requested vault. + * Falls back to a deterministic fixture when the database is unavailable. + * + * Query params: + * days — number of trailing days to return (default: 90, max: 365) + */ +sharePriceHistoryRouter.get( + "/:vaultId/share-price-history", + async (req: Request, res: Response) => { + const { vaultId } = req.params; + + const rawDays = Number(req.query.days ?? 90); + const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 90; + + const prisma = await loadPrismaClient(); + + if (!prisma) { + const fixture = generateFixtureSnapshots(vaultId, days); + res.json(fixture); + return; + } + + try { + const rows = await prisma.sharePriceSnapshot.findMany({ + where: { vaultId }, + orderBy: { snapshotAt: "asc" }, + take: days, + }); + + await prisma.$disconnect?.(); + + if (rows.length === 0) { + res.json([]); + return; + } + + const result = rows.map((row) => ({ + date: row.snapshotAt.toISOString().split("T")[0], + sharePrice: row.sharePrice, + vaultId: row.vaultId, + })); + + res.json(result); + } catch (error) { + await prisma.$disconnect?.().catch(() => undefined); + sendError(res, 500, "SHARE_PRICE_HISTORY_ERROR", "Failed to retrieve share price history."); + void error; + } + }, +); + +export default sharePriceHistoryRouter;