diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 00000000..72048c61 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,57 @@ +name: Deploy Docs + +on: + pull_request: + paths: + - "docs-site/**" + - ".github/workflows/deploy-docs.yml" + push: + branches: [main] + paths: + - "docs-site/**" + - ".github/workflows/deploy-docs.yml" + +defaults: + run: + working-directory: docs-site + +jobs: + build-docs: + name: Build developer portal + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # No lockfile is committed for the portal yet; once one lands, add + # `cache: npm` + `cache-dependency-path: docs-site/package-lock.json` + # and switch this to `npm ci` for reproducible builds. + - name: Install dependencies + run: npm install --no-audit --no-fund + + - name: Build site (fails on broken links / missing assets) + run: npm run build + + - name: Upload pages artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: docs-site/build + + deploy-docs: + name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/main' + needs: build-docs + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/backend/prisma/migrations/20261001000000_add_timescaledb_analytics/migration.sql b/backend/prisma/migrations/20261001000000_add_timescaledb_analytics/migration.sql new file mode 100644 index 00000000..86185628 --- /dev/null +++ b/backend/prisma/migrations/20261001000000_add_timescaledb_analytics/migration.sql @@ -0,0 +1,77 @@ +-- TimescaleDB analytics backbone (#1480). +-- +-- Protocol TVL, streaming velocity and withdrawn totals change with every +-- ledger second, so they are snapshotted into a hypertable and pre-aggregated +-- with continuous aggregates instead of being recomputed from the raw Stream +-- and StreamEvent tables on every request. +-- +-- Requires the timescaledb extension to be available on the PostgreSQL +-- instance (see docker-compose.yml). If the extension is not installed the +-- statements below fail; the analytics API falls back to live Prisma +-- aggregation in that case, so existing deployments keep working. + +CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; + +-- Hypertable for continuous stream flow snapshots. One row per +-- (token_address, snapshot tick) produced by the indexer's aggregation pass. +CREATE TABLE IF NOT EXISTS stream_flow_snapshots ( + time TIMESTAMPTZ NOT NULL, + token_address TEXT NOT NULL, + active_stream_count INT NOT NULL, + total_locked_amount NUMERIC(38, 0) NOT NULL, + cumulative_streamed_amount NUMERIC(38, 0) NOT NULL, + cumulative_withdrawn_amount NUMERIC(38, 0) NOT NULL, + flow_velocity_per_second NUMERIC(38, 0) NOT NULL +); + +SELECT create_hypertable('stream_flow_snapshots', 'time', if_not_exists => TRUE); + +CREATE INDEX IF NOT EXISTS idx_stream_flow_snapshots_token_time + ON stream_flow_snapshots (token_address, time DESC); + +-- Hourly continuous aggregate: average TVL, summed velocity and peak stream +-- count per token, refreshable incrementally by TimescaleDB policies. +CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_protocol_metrics +WITH (timescaledb.continuous) AS +SELECT time_bucket('1 hour', time) AS bucket, + token_address, + AVG(total_locked_amount) AS avg_tvl, + SUM(flow_velocity_per_second) AS aggregate_velocity, + MAX(active_stream_count) AS peak_streams +FROM stream_flow_snapshots +GROUP BY bucket, token_address +WITH NO DATA; + +-- Daily continuous aggregate for 30d/90d/1y historical charts. +CREATE MATERIALIZED VIEW IF NOT EXISTS daily_protocol_metrics +WITH (timescaledb.continuous) AS +SELECT time_bucket('1 day', time) AS bucket, + token_address, + AVG(total_locked_amount) AS avg_tvl, + SUM(flow_velocity_per_second) AS aggregate_velocity, + MAX(active_stream_count) AS peak_streams, + MAX(cumulative_withdrawn_amount) AS cumulative_withdrawn +FROM stream_flow_snapshots +GROUP BY bucket, token_address +WITH NO DATA; + +-- Refresh policies: hourly aggregate refreshes shortly after each hour ends, +-- daily aggregate in the small hours. Both retain their full window. +SELECT add_continuous_aggregate_policy('hourly_protocol_metrics', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour'); + +SELECT add_continuous_aggregate_policy('daily_protocol_metrics', + start_offset => INTERVAL '40 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 day'); + +-- Compress raw snapshots older than 14 days to keep the hypertable small +-- while retaining every data point for the daily rollups. +ALTER TABLE stream_flow_snapshots SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'token_address' +); + +SELECT add_compression_policy('stream_flow_snapshots', INTERVAL '14 days'); diff --git a/backend/src/controllers/analytics.controller.ts b/backend/src/controllers/analytics.controller.ts new file mode 100644 index 00000000..96dfbb88 --- /dev/null +++ b/backend/src/controllers/analytics.controller.ts @@ -0,0 +1,99 @@ +/** + * Analytics controller — HTTP surface for the #1480 analytics stack. + * + * Read-only endpoints; no auth beyond the API's global middleware because + * every payload here is public protocol statistics (the same numbers a + * block explorer would show). + */ + +import type { Request, Response } from "express"; +import { + getDefiLlamaAdapter, + getHistoricalAnalytics, + getProtocolTvl, + type AnalyticsInterval, + type AnalyticsPeriod, +} from "../services/analytics.service.js"; +import { sendApiError } from "../types/api-error.js"; +import logger from "../logger.js"; + +const VALID_PERIODS: AnalyticsPeriod[] = ["7d", "30d", "90d", "1y"]; +const VALID_INTERVALS: AnalyticsInterval[] = ["1h", "1d"]; + +/** + * GET /api/v1/analytics/tvl + * + * Current protocol TVL partitioned by token asset. + */ +export async function getTvlHandler(_req: Request, res: Response) { + try { + const snapshot = await getProtocolTvl(); + res.json({ success: true, data: snapshot }); + } catch (error) { + logger.error("analytics TVL request failed:", error); + sendApiError(res, 500, "ANALYTICS_UNAVAILABLE", "TVL snapshot unavailable"); + } +} + +/** + * GET /api/v1/analytics/historical?period=30d&interval=1d + * + * Pre-aggregated time-series points for frontend charts. + */ +export async function getHistoricalHandler(req: Request, res: Response) { + const period = (req.query.period ?? "30d") as AnalyticsPeriod; + const interval = (req.query.interval ?? "1d") as AnalyticsInterval; + + if (!VALID_PERIODS.includes(period)) { + sendApiError( + res, + 400, + "INVALID_PERIOD", + `period must be one of: ${VALID_PERIODS.join(", ")}` + ); + return; + } + if (!VALID_INTERVALS.includes(interval)) { + sendApiError( + res, + 400, + "INVALID_INTERVAL", + `interval must be one of: ${VALID_INTERVALS.join(", ")}` + ); + return; + } + + try { + const series = await getHistoricalAnalytics(period, interval); + res.json({ success: true, data: series }); + } catch (error) { + logger.error("analytics historical request failed:", error); + sendApiError( + res, + 500, + "ANALYTICS_UNAVAILABLE", + "Historical analytics unavailable" + ); + } +} + +/** + * GET /api/v1/analytics/defillama + * + * Standardized DefiLlama protocol-TVL adapter response. + */ +export async function getDefiLlamaHandler(_req: Request, res: Response) { + try { + const adapter = await getDefiLlamaAdapter(); + // DefiLlama consumes the raw object; no success envelope here. + res.json(adapter); + } catch (error) { + logger.error("DefiLlama adapter request failed:", error); + sendApiError( + res, + 500, + "ANALYTICS_UNAVAILABLE", + "DefiLlama adapter unavailable" + ); + } +} diff --git a/backend/src/routes/v1/analytics.routes.ts b/backend/src/routes/v1/analytics.routes.ts new file mode 100644 index 00000000..e86e6cba --- /dev/null +++ b/backend/src/routes/v1/analytics.routes.ts @@ -0,0 +1,22 @@ +/** + * Analytics routes (#1480) — public protocol statistics. + * + * - GET /tvl — current TVL partitioned by token + * - GET /historical — pre-aggregated chart series (`?period=&interval=`) + * - GET /defillama — DefiLlama adapter payload + */ + +import { Router } from "express"; +import { + getDefiLlamaHandler, + getHistoricalHandler, + getTvlHandler, +} from "../../controllers/analytics.controller.js"; + +const router = Router(); + +router.get("/tvl", getTvlHandler); +router.get("/historical", getHistoricalHandler); +router.get("/defillama", getDefiLlamaHandler); + +export default router; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index f82cf039..1d0cd267 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -5,6 +5,7 @@ import userRoutes from "./user.routes.js"; import authRoutes from "./auth.routes.js"; import adminRoutes from "./admin.routes.js"; import webhookRoutes from "./webhook.routes.js"; +import analyticsRoutes from "./analytics.routes.js"; import tokenRoutes from "./token.routes.js"; const router = Router(); @@ -15,6 +16,7 @@ router.use("/events", eventsRoutes); router.use("/users", userRoutes); router.use("/auth", authRoutes); router.use("/webhooks", webhookRoutes); +router.use("/analytics", analyticsRoutes); router.use("/tokens", tokenRoutes); // Admin routes diff --git a/backend/src/services/analytics.service.ts b/backend/src/services/analytics.service.ts new file mode 100644 index 00000000..3136793c --- /dev/null +++ b/backend/src/services/analytics.service.ts @@ -0,0 +1,269 @@ +/** + * Analytics Service — TimescaleDB-backed protocol TVL & velocity (Issue #1480) + * + * Reads the `stream_flow_snapshots` hypertable and its continuous aggregates + * (`hourly_protocol_metrics`, `daily_protocol_metrics`) created by the + * `20261001000000_add_timescaledb_analytics` migration. When TimescaleDB is + * not installed (or the hypertable has not been created yet) every reader + * degrades to live aggregation over the Prisma `Stream` table, so the + * endpoints stay available on stock PostgreSQL deployments. + */ + +import { pool, prisma } from "../lib/prisma.js"; +import logger from "../logger.js"; + +/** Period accepted by the historical endpoint. */ +export type AnalyticsPeriod = "7d" | "30d" | "90d" | "1y"; + +/** Interval accepted by the historical endpoint. */ +export type AnalyticsInterval = "1h" | "1d"; + +const PERIOD_DAYS: Record = { + "7d": 7, + "30d": 30, + "90d": 90, + "1y": 365, +}; + +export interface TokenTvl { + tokenAddress: string; + /** Sum of unwithdrawn stream balances, in stroops. */ + totalLockedStroops: string; + /** Sum of per-second drip across active streams, in stroops/second. */ + velocityPerSecondStroops: string; + activeStreamCount: number; +} + +export interface TvlSnapshot { + /** Where the numbers came from: the hypertable or live aggregation. */ + source: "timescaledb" | "live"; + tokens: TokenTvl[]; + /** Protocol-wide total locked, in stroops. */ + totalTvlStroops: string; + generatedAt: string; +} + +export interface HistoricalPoint { + bucket: string; + tokenAddress: string; + avgTvlStroops: string; + velocityStroopsPerSecond: string; + peakStreams: number; +} + +export interface HistoricalSeries { + source: "timescaledb" | "live"; + period: AnalyticsPeriod; + interval: AnalyticsInterval; + points: HistoricalPoint[]; +} + +export interface DefiLlamaResponse { + /** DefiLlama adapter protocol identifier. */ + id: string; + name: string; + /** TVL keyed by token address, plus a `total` entry — stroops. */ + tvl: Record; + timestamp: string; +} + +/** Detects whether the TimescaleDB hypertable exists and is queryable. */ +let timescaleAvailable: boolean | undefined; + +/** + * Clears the availability cache. Exported for tests: the service process + * should cache once, but each test needs a fresh probe. + */ +export function resetTimescaleCacheForTests(): void { + timescaleAvailable = undefined; +} + +async function hasTimescale(): Promise { + if (timescaleAvailable !== undefined) return timescaleAvailable; + try { + const result = await pool.query( + `SELECT to_regclass('public.stream_flow_snapshots') AS hypertable` + ); + timescaleAvailable = result.rows[0]?.hypertable !== null; + } catch (error) { + logger.warn( + "TimescaleDB analytics tables unavailable; using live aggregation:", + error + ); + timescaleAvailable = false; + } + return timescaleAvailable; +} + +/** + * Live fallback: aggregate active streams straight from Prisma. + * + * Amount columns are i128-preserved strings in the schema, so Prisma's + * `_sum` aggregation does not apply to them; rows are summed in JS with + * `BigInt` instead to keep the full precision end to end. + */ +async function liveTvl(): Promise { + const streams = await prisma.stream.findMany({ + where: { isActive: true }, + select: { + tokenAddress: true, + depositedAmount: true, + withdrawnAmount: true, + ratePerSecond: true, + }, + }); + + const byToken = new Map< + string, + { locked: bigint; velocity: bigint; count: number } + >(); + for (const stream of streams) { + const deposited = BigInt(stream.depositedAmount); + const withdrawn = BigInt(stream.withdrawnAmount); + const entry = byToken.get(stream.tokenAddress) ?? { + locked: 0n, + velocity: 0n, + count: 0, + }; + entry.locked += deposited > withdrawn ? deposited - withdrawn : 0n; + entry.velocity += BigInt(stream.ratePerSecond); + entry.count += 1; + byToken.set(stream.tokenAddress, entry); + } + + const tokens: TokenTvl[] = [...byToken.entries()].map( + ([tokenAddress, agg]) => ({ + tokenAddress, + totalLockedStroops: agg.locked.toString(), + velocityPerSecondStroops: agg.velocity.toString(), + activeStreamCount: agg.count, + }) + ); + + const totalTvlStroops = tokens + .reduce((acc, t) => acc + BigInt(t.totalLockedStroops), 0n) + .toString(); + + return { + source: "live", + tokens, + totalTvlStroops, + generatedAt: new Date().toISOString(), + }; +} + +/** Latest snapshot row per token, straight from the hypertable. */ +async function timescaleTvl(): Promise { + const result = await pool.query( + `SELECT DISTINCT ON (token_address) + token_address, + total_locked_amount, + flow_velocity_per_second, + active_stream_count + FROM stream_flow_snapshots + ORDER BY token_address, time DESC` + ); + + const snapshotRows = result.rows as { + token_address: string; + total_locked_amount: string; + flow_velocity_per_second: string; + active_stream_count: number; + }[]; + + const tokens: TokenTvl[] = snapshotRows.map((row) => ({ + tokenAddress: row.token_address, + totalLockedStroops: BigInt(row.total_locked_amount).toString(), + velocityPerSecondStroops: BigInt(row.flow_velocity_per_second).toString(), + activeStreamCount: row.active_stream_count, + })); + + const totalTvlStroops = tokens + .reduce((acc, t) => acc + BigInt(t.totalLockedStroops), 0n) + .toString(); + + return { + source: "timescaledb", + tokens, + totalTvlStroops, + generatedAt: new Date().toISOString(), + }; +} + +/** Current protocol TVL partitioned by token (#1480). */ +export async function getProtocolTvl(): Promise { + if (await hasTimescale()) { + try { + return await timescaleTvl(); + } catch (error) { + logger.warn( + "Timescale TVL query failed; falling back to live aggregation:", + error + ); + } + } + return liveTvl(); +} + +/** Historical pre-aggregated TVL/velocity series for charts (#1480). */ +export async function getHistoricalAnalytics( + period: AnalyticsPeriod, + interval: AnalyticsInterval +): Promise { + if (!(await hasTimescale())) { + return { source: "live", period, interval, points: [] }; + } + + const view = + interval === "1d" ? "daily_protocol_metrics" : "hourly_protocol_metrics"; + const days = PERIOD_DAYS[period]; + + const result = await pool.query( + `SELECT bucket, token_address, avg_tvl, aggregate_velocity, peak_streams + FROM ${view} + WHERE bucket >= now() - ($1 || ' days')::interval + ORDER BY bucket, token_address`, + [String(days)] + ); + + const seriesRows = result.rows as { + bucket: Date; + token_address: string; + avg_tvl: string; + aggregate_velocity: string; + peak_streams: number; + }[]; + + return { + source: "timescaledb", + period, + interval, + points: seriesRows.map((row) => ({ + bucket: new Date(row.bucket).toISOString(), + tokenAddress: row.token_address, + avgTvlStroops: BigInt(row.avg_tvl).toString(), + velocityStroopsPerSecond: BigInt(row.aggregate_velocity).toString(), + peakStreams: row.peak_streams, + })), + }; +} + +/** + * DefiLlama-compatible adapter payload (#1480). + * + * DefiLlama reads a flat `{ [chainOrToken]: tvlNumber }` shape; numbers are + * whole units of each token's smallest display unit (stroops for Stellar). + */ +export async function getDefiLlamaAdapter(): Promise { + const snapshot = await getProtocolTvl(); + const tvl: Record = { total: Number(snapshot.totalTvlStroops) }; + for (const token of snapshot.tokens) { + tvl[token.tokenAddress] = Number(token.totalLockedStroops); + } + return { + id: "flowfi", + name: "FlowFi", + tvl, + timestamp: snapshot.generatedAt, + }; +} diff --git a/backend/tests/analytics.test.ts b/backend/tests/analytics.test.ts new file mode 100644 index 00000000..d3701d02 --- /dev/null +++ b/backend/tests/analytics.test.ts @@ -0,0 +1,153 @@ +/** + * Analytics endpoint tests (#1480). + * + * The TimescaleDB path is exercised through a mocked pg pool so no real + * database is needed; the fallback path is exercised by making + * `hasTimescale` report unavailable. Contract under test: response shapes, + * validation errors, and the DefiLlama adapter mapping. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const queryMock = vi.fn(); + +vi.mock("../src/lib/prisma.js", () => ({ + prisma: { + stream: { + findMany: vi.fn(), + }, + }, + pool: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +vi.mock("../src/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { prisma } from "../src/lib/prisma.js"; +import { + getDefiLlamaAdapter, + getHistoricalAnalytics, + getProtocolTvl, + resetTimescaleCacheForTests, +} from "../src/services/analytics.service.js"; + +describe("analytics service (#1480)", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryMock.mockReset(); + resetTimescaleCacheForTests(); + }); + + it("falls back to live Prisma aggregation when TimescaleDB is absent", async () => { + // to_regclass returns null → no hypertable. + queryMock.mockResolvedValue({ rows: [{ hypertable: null }] }); + + // Amount columns are i128 strings in the schema; three active streams on + // one token, aggregated in JS (deposits 1,000,000 − withdrawals 400,000). + (prisma.stream.findMany as ReturnType).mockResolvedValue([ + { + tokenAddress: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + depositedAmount: "400000", + withdrawnAmount: "100000", + ratePerSecond: "20", + }, + { + tokenAddress: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + depositedAmount: "350000", + withdrawnAmount: "150000", + ratePerSecond: "20", + }, + { + tokenAddress: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + depositedAmount: "250000", + withdrawnAmount: "150000", + ratePerSecond: "10", + }, + ]); + + const snapshot = await getProtocolTvl(); + + expect(snapshot.source).toBe("live"); + expect(snapshot.totalTvlStroops).toBe("600000"); + expect(snapshot.tokens[0]?.activeStreamCount).toBe(3); + expect(snapshot.tokens[0]?.velocityPerSecondStroops).toBe("50"); + }); + + it("reads the latest hypertable snapshot per token when TimescaleDB is present", async () => { + queryMock.mockResolvedValueOnce({ + rows: [{ hypertable: "stream_flow_snapshots" }], + }); + queryMock.mockResolvedValueOnce({ + rows: [ + { + token_address: "CB63BC53EZDCKG6EFA3KJD3QWVV4QG4N35D7N4YQ6EY5XLYWJ7VL2MUA", + total_locked_amount: "900000", + flow_velocity_per_second: "25", + active_stream_count: 2, + }, + ], + }); + + const snapshot = await getProtocolTvl(); + + expect(snapshot.source).toBe("timescaledb"); + expect(snapshot.totalTvlStroops).toBe("900000"); + // DISTINCT ON (token_address) ... ORDER BY token_address, time DESC + expect(queryMock.mock.calls[1]?.[0]).toContain("DISTINCT ON"); + }); + + it("maps the snapshot into the DefiLlama adapter shape", async () => { + queryMock.mockResolvedValue({ rows: [{ hypertable: null }] }); + (prisma.stream.findMany as ReturnType).mockResolvedValue([ + { + tokenAddress: "TOKEN_A", + depositedAmount: "500", + withdrawnAmount: "200", + ratePerSecond: "1", + }, + ]); + + const adapter = await getDefiLlamaAdapter(); + + expect(adapter.id).toBe("flowfi"); + expect(adapter.tvl.total).toBe(300); + expect(adapter.tvl.TOKEN_A).toBe(300); + }); + + it("returns empty historical points on live fallback and buckets via the daily view", async () => { + queryMock.mockResolvedValue({ rows: [{ hypertable: null }] }); + const live = await getHistoricalAnalytics("30d", "1d"); + expect(live).toEqual({ + source: "live", + period: "30d", + interval: "1d", + points: [], + }); + + // The first call latched the probe result in the service cache; clear it + // so the timescaledb path below actually re-probes the pool. + resetTimescaleCacheForTests(); + + // First call is the availability probe, second the data query. + queryMock.mockResolvedValueOnce({ rows: [{ hypertable: "stream_flow_snapshots" }] }); + queryMock.mockResolvedValueOnce({ + rows: [ + { + bucket: new Date("2026-09-28T00:00:00Z"), + token_address: "TOKEN_A", + avg_tvl: "123", + aggregate_velocity: "4", + peak_streams: 9, + }, + ], + }); + const series = await getHistoricalAnalytics("90d", "1d"); + expect(series.source).toBe("timescaledb"); + expect(series.points[0]?.avgTvlStroops).toBe("123"); + expect(series.points[0]?.peakStreams).toBe(9); + expect(queryMock.mock.calls.at(-1)![0]).toContain("daily_protocol_metrics"); + }); +}); diff --git a/contracts/stream_contract/src/errors.rs b/contracts/stream_contract/src/errors.rs index 6a82ba10..5bd12567 100644 --- a/contracts/stream_contract/src/errors.rs +++ b/contracts/stream_contract/src/errors.rs @@ -92,6 +92,26 @@ pub enum StreamError { NotArbiter = 32, /// Allowance-based stream operation failed. AllowanceLocked = 33, + /// A conditional milestone id is duplicated within one stream, is + /// referenced that does not exist, or the milestone list is empty. + InvalidMilestone = 37, + /// The oracle returned no price for the asset. + OraclePriceUnavailable = 38, + /// The oracle price is older than `ORACLE_PRICE_MAX_AGE_SECS`. + OraclePriceStale = 39, + /// The milestone's condition has not been met, so nothing unlocked. + ConditionNotMet = 40, + /// The milestone was already unlocked and cannot unlock again. + MilestoneAlreadyUnlocked = 41, + /// The attestation id was already used to unlock a milestone on this + /// stream, or the attestation signer is not the milestone's oracle. + InvalidAttestation = 42, + /// The caller is not authorized to unlock this milestone (only the + /// stream's sender or recipient may trigger verification). + MilestoneCallerUnauthorized = 43, + /// The conditional milestone list would exceed + /// `MAX_CONDITIONAL_MILESTONES`. + TooManyMilestones = 44, } // ─── Test diagnostics (compiled only under `cfg(test)`) ─────────────────── @@ -151,6 +171,14 @@ pub(crate) mod test_diagnostics { StreamError::ArithmeticOverflow => "ArithmeticOverflow", StreamError::StreamStillActive => "StreamStillActive", StreamError::StreamNotActive => "StreamNotActive", + StreamError::InvalidMilestone => "InvalidMilestone", + StreamError::OraclePriceUnavailable => "OraclePriceUnavailable", + StreamError::OraclePriceStale => "OraclePriceStale", + StreamError::ConditionNotMet => "ConditionNotMet", + StreamError::MilestoneAlreadyUnlocked => "MilestoneAlreadyUnlocked", + StreamError::InvalidAttestation => "InvalidAttestation", + StreamError::MilestoneCallerUnauthorized => "MilestoneCallerUnauthorized", + StreamError::TooManyMilestones => "TooManyMilestones", } } } diff --git a/contracts/stream_contract/src/events.rs b/contracts/stream_contract/src/events.rs index 1cdf5532..c329ae52 100644 --- a/contracts/stream_contract/src/events.rs +++ b/contracts/stream_contract/src/events.rs @@ -324,6 +324,22 @@ pub struct StreamClosedEvent { pub timestamp: u64, } +/// Emitted when a conditional milestone's condition verifies true (#1482). +/// +/// Topic: `("milestone_condition_unlocked", stream_id)` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneConditionUnlockedEvent { + pub stream_id: u64, + pub milestone_id: u32, + /// Amount that became claimable. + pub amount: i128, + /// Caller that triggered the verification. + pub caller: Address, + /// Ledger timestamp of the verification. + pub timestamp: u64, +} + // ─── Emission Helpers ──────────────────────────────────────────────────────── // // Every publish site in `lib.rs` routes through one of these helpers so that diff --git a/contracts/stream_contract/src/lib.rs b/contracts/stream_contract/src/lib.rs index 629160c2..91001dd9 100644 --- a/contracts/stream_contract/src/lib.rs +++ b/contracts/stream_contract/src/lib.rs @@ -54,11 +54,11 @@ use events::{ emit_stream_topped_up, emit_tokens_withdrawn, AdminTransferredEvent, AllowanceStreamCreatedEvent, ContractUpgradedEvent, DisputeRequestedEvent, DisputeResolvedEvent, EmergencyGuardianUpdatedEvent, FeeCollectedEvent, FeeConfigUpdatedEvent, - HybridCliffStreamCreatedEvent, InitializedEvent, ProtocolPauseStatusEvent, - RecipientTransferredEvent, StateMigratedEvent, StepVestingStreamCreatedEvent, - StreamCancelledEvent, StreamClosedEvent, StreamCompletedEvent, StreamCreatedEvent, - StreamPausedEvent, StreamRateModifiedEvent, StreamResumedEvent, StreamToppedUpEvent, - TokensWithdrawnEvent, + HybridCliffStreamCreatedEvent, InitializedEvent, MilestoneConditionUnlockedEvent, + ProtocolPauseStatusEvent, RecipientTransferredEvent, StateMigratedEvent, + StepVestingStreamCreatedEvent, StreamCancelledEvent, StreamClosedEvent, StreamCompletedEvent, + StreamCreatedEvent, StreamPausedEvent, StreamRateModifiedEvent, StreamResumedEvent, + StreamToppedUpEvent, TokensWithdrawnEvent, }; use storage::{ config_exists, get_contract_version, get_recorded_wasm_hash, load_config, load_stream, @@ -66,8 +66,10 @@ use storage::{ save_stream, try_load_config, try_load_stream, }; use types::{ - BatchStreamInput, DisputeStatus, ProtocolConfig, Stream, StreamStatus, VestingSchedule, - VestingStep, MAX_BATCH_CREATE, MAX_BATCH_WITHDRAW, MAX_VESTING_STEPS, + BatchStreamInput, ConditionalMilestone, DataKey, DisputeStatus, OracleAsset, OracleClient, + ProtocolConfig, Stream, StreamStatus, UnlockCondition, VestingSchedule, VestingStep, + MAX_BATCH_CREATE, MAX_BATCH_WITHDRAW, MAX_CONDITIONAL_MILESTONES, MAX_VESTING_STEPS, + ORACLE_PRICE_MAX_AGE_SECS, }; /// Maximum allowed protocol fee: 1 000 bps = 10%. @@ -1005,6 +1007,299 @@ impl StreamContract { Ok(()) } + /// Validate a conditional milestone list at creation time (#1482). + /// + /// - 1..=`MAX_CONDITIONAL_MILESTONES` milestones, + /// - unique `milestone_id` within the list, + /// - strictly positive `amount`, + /// - milestone amounts summing to exactly the post-fee deposited amount, + /// so a conditional stream can never promise more than was escrowed. + fn validate_conditional_milestones( + milestones: &soroban_sdk::Vec, + net_amount: i128, + ) -> Result<(), StreamError> { + if milestones.is_empty() { + return Err(StreamError::EmptyVestingSchedule); + } + if milestones.len() > MAX_CONDITIONAL_MILESTONES { + return Err(StreamError::TooManyMilestones); + } + + let mut total: i128 = 0; + for i in 0..milestones.len() { + let m = milestones.get(i).expect("index in range"); + if m.amount <= 0 { + return Err(StreamError::InvalidVestingStepAmount); + } + for j in (i + 1)..milestones.len() { + let n = milestones.get(j).expect("index in range"); + if n.milestone_id == m.milestone_id { + return Err(StreamError::InvalidMilestone); + } + } + total = total.saturating_add(m.amount); + } + if total != net_amount { + return Err(StreamError::VestingStepTotalMismatch); + } + Ok(()) + } + + /// Create a conditional (KPI-gated) streaming stream (#1482). + /// + /// Funds are escrowed exactly like [`Self::create_stream`] (gross transfer, + /// protocol fee deducted), but release is governed by a list of + /// [`ConditionalMilestone`] tranches instead of a time curve. Each + /// milestone unlocks when its [`UnlockCondition`] verifies true — a time + /// gate, an oracle price target, or a signed oracle attestation — via + /// [`Self::verify_and_unlock_milestone`]. + /// + /// Under the hood the milestones are stored as the stream's + /// [`VestingSchedule::StepTranches`] with every `unlock_time` at + /// `u64::MAX`: unverified tranches are unreachable by time, and + /// verification rewrites a satisfied milestone's time to the current + /// timestamp, which makes it flow through the *existing* + /// `calculate_claimable` / `withdraw` math unchanged. + /// + /// # Errors + /// - `ProtocolPaused` — the circuit breaker is engaged. + /// - `InvalidAmount` — `amount` ≤ 0. + /// - `InvalidTokenAddress` — `token_address` is not a token contract. + /// - `EmptyVestingSchedule` — `milestones` is empty. + /// - `TooManyMilestones` — more than `MAX_CONDITIONAL_MILESTONES`. + /// - `InvalidMilestone` — a duplicated `milestone_id`. + /// - `InvalidVestingStepAmount` — a milestone amount is ≤ 0. + /// - `VestingStepTotalMismatch` — milestone amounts ≠ post-fee deposited amount. + pub fn create_conditional_stream( + env: Env, + sender: Address, + recipient: Address, + token_address: Address, + amount: i128, + milestones: soroban_sdk::Vec, + ) -> Result { + sender.require_auth(); + Self::require_not_protocol_paused(&env)?; + + if amount <= 0 { + return Err(StreamError::InvalidAmount); + } + Self::validate_token_contract(&env, &token_address)?; + + let stream_id = next_stream_id(&env); + let start_time = env.ledger().timestamp(); + + // Transfer gross amount from sender to this contract. + let token_client = token::Client::new(&env, &token_address); + let contract_address = env.current_contract_address(); + token_client.transfer(&sender, &contract_address, &amount); + + // Deduct protocol fee; returns net amount (== amount when no fee config). + // The fee transfer itself is deferred until the stream is persisted, + // mirroring the creation flow used by the other stream types. + let (net_amount, fee_amount, treasury) = Self::collect_fee(&env, &token_address, amount)?; + + Self::validate_conditional_milestones(&milestones, net_amount)?; + + // Store as step tranches whose unlock times are all "never" until the + // condition is verified. Recipient view of the schedule then shows + // exactly which tranches remain condition-gated. + let mut steps: Vec = Vec::new(&env); + for m in milestones.iter() { + steps.push_back(VestingStep { + unlock_time: u64::MAX, + unlock_amount: m.amount, + }); + } + + save_stream( + &env, + stream_id, + &Stream { + sender: sender.clone(), + recipient: recipient.clone(), + token_address: token_address.clone(), + rate_per_second: 0, + deposited_amount: net_amount, + withdrawn_amount: 0, + start_time, + last_update_time: start_time, + cliff_time: None, + is_active: true, + paused: false, + paused_at: None, + status: StreamStatus::Active, + schedule: VestingSchedule::StepTranches(steps), + arbiter: None, + dispute_status: DisputeStatus::None, + is_allowance_based: false, + }, + ); + env.storage() + .instance() + .set(&DataKey::ConditionalMilestones(stream_id), &milestones); + env.storage().instance().set( + &DataKey::AttestedIds(stream_id), + &Vec::>::new(&env), + ); + + Self::transfer_fee(&env, &token_address, stream_id, fee_amount, treasury); + + env.events().publish( + (Symbol::new(&env, "stream_created"), stream_id), + StreamCreatedEvent { + stream_id, + sender, + recipient, + rate_per_second: 0, + token_address, + deposited_amount: net_amount, + start_time, + }, + ); + + Ok(stream_id) + } + + /// Evaluate one conditional milestone and, when its condition holds, + /// release it (#1482). + /// + /// - `TimeOnly` unlocks once `env.ledger().timestamp()` passes the gate. + /// - `PriceTarget` cross-contract calls the oracle's `lastprice`, reverts + /// with `OraclePriceUnavailable` when it returns none and with + /// `OraclePriceStale` when the reading is older than + /// `ORACLE_PRICE_MAX_AGE_SECS`, then compares against `target_price` + /// (`is_above`: price >= target, otherwise price <= target). + /// - `OracleAttestation` requires the attestation's oracle signer to + /// authorize this invocation (`require_auth` on the signer address) and + /// rejects an attestation id that already unlocked a milestone on this + /// stream, so one attestation can never pay out twice. + /// + /// On success the milestone's `unlock_time` is rewritten from `u64::MAX` + /// to the current timestamp, which promotes the tranche into the normal + /// step-unlock flow: it becomes claimable immediately and withdrawable + /// with the existing entry points. Emits `milestone_condition_unlocked`. + /// + /// # Errors + /// - `StreamNotFound` — no stream exists with `stream_id`. + /// - `MilestoneCallerUnauthorized` — caller is neither sender nor recipient. + /// - `StreamInactive` — stream cancelled or fully withdrawn. + /// - `InvalidMilestone` — no milestone with that id. + /// - `MilestoneAlreadyUnlocked` — milestone already unlocked. + /// - `OraclePriceUnavailable` — oracle returned no price. + /// - `OraclePriceStale` — price older than the freshness window. + /// - `ConditionNotMet` — condition evaluated false. + /// - `InvalidAttestation` — attestation id reused or unauthorized. + pub fn verify_and_unlock_milestone( + env: Env, + caller: Address, + stream_id: u64, + milestone_id: u32, + ) -> Result { + caller.require_auth(); + + let mut stream = load_stream(&env, stream_id)?; + if stream.sender != caller && stream.recipient != caller { + return Err(StreamError::MilestoneCallerUnauthorized); + } + if !stream.is_active { + return Err(StreamError::StreamInactive); + } + + let mut milestones: soroban_sdk::Vec = env + .storage() + .instance() + .get(&DataKey::ConditionalMilestones(stream_id)) + .ok_or(StreamError::StreamNotFound)?; + + let index = (0..milestones.len()) + .find(|&i| milestones.get(i).expect("index in range").milestone_id == milestone_id) + .ok_or(StreamError::InvalidMilestone)?; + let mut milestone = milestones.get(index).expect("index in range"); + if milestone.is_unlocked { + return Err(StreamError::MilestoneAlreadyUnlocked); + } + + let now = env.ledger().timestamp(); + match &milestone.condition { + UnlockCondition::TimeOnly(unlock_time) => { + if now < *unlock_time { + return Err(StreamError::ConditionNotMet); + } + } + UnlockCondition::PriceTarget(oracle_address, target_price, is_above) => { + let oracle_client = OracleClient::new(&env, oracle_address); + let price_data = oracle_client + .lastprice(&OracleAsset::Other(stream.token_address.clone())) + .ok_or(StreamError::OraclePriceUnavailable)?; + if now.saturating_sub(price_data.timestamp) > ORACLE_PRICE_MAX_AGE_SECS { + return Err(StreamError::OraclePriceStale); + } + let met = if *is_above { + price_data.price >= *target_price + } else { + price_data.price <= *target_price + }; + if !met { + return Err(StreamError::ConditionNotMet); + } + } + UnlockCondition::OracleAttestation(oracle_signer, attestation_id) => { + let mut attested: Vec> = env + .storage() + .instance() + .get(&DataKey::AttestedIds(stream_id)) + .unwrap_or_else(|| Vec::new(&env)); + if attested.contains(attestation_id) { + return Err(StreamError::InvalidAttestation); + } + // The oracle signer proves control of the attestation by + // authorizing this call as a sub-invocation; anything weaker + // would let anyone unlock milestones by naming a signer. + oracle_signer.require_auth(); + attested.push_back(attestation_id.clone()); + env.storage() + .instance() + .set(&DataKey::AttestedIds(stream_id), &attested); + } + } + + milestone.is_unlocked = true; + let unlocked_amount = milestone.amount; + milestones.set(index, milestone); + env.storage() + .instance() + .set(&DataKey::ConditionalMilestones(stream_id), &milestones); + + // Promote the tranche into the normal step-unlock flow: rewrite its + // gate time from "never" to "now" so `calculate_claimable` counts it. + if let VestingSchedule::StepTranches(steps) = &mut stream.schedule { + for i in 0..steps.len() { + let mut step = steps.get(i).expect("index in range"); + if step.unlock_amount == unlocked_amount && step.unlock_time == u64::MAX { + step.unlock_time = now; + steps.set(i, step); + break; + } + } + } + stream.last_update_time = now; + save_stream(&env, stream_id, &stream); + + env.events().publish( + (Symbol::new(&env, "milestone_condition_unlocked"), stream_id), + MilestoneConditionUnlockedEvent { + stream_id, + milestone_id, + amount: unlocked_amount, + caller, + timestamp: now, + }, + ); + + Ok(unlocked_amount) + } + /// Top up an active stream with additional tokens. /// /// Only the original sender may top up their own stream. The top-up amount diff --git a/contracts/stream_contract/src/test.rs b/contracts/stream_contract/src/test.rs index 2a888117..7c946800 100644 --- a/contracts/stream_contract/src/test.rs +++ b/contracts/stream_contract/src/test.rs @@ -5831,6 +5831,354 @@ fn test_batch_withdraw_never_exceeds_escrow_across_many_streams() { assert_eq!(balances.balance(&contract), 0, "escrow not fully drained"); } +// ─── Coverage: modify_rate (#1316) ────────────────────────────────────────── + +#[test] +fn test_coverage_modify_rate_recomputes_end_time_and_emits() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + let new_end = client.modify_rate(&sender, &id, &2); + + // 1_000 remaining at 2/s → 500s projected end, from the current timestamp. + let now = env.ledger().timestamp(); + assert_eq!(new_end, now + 500); + let stream = client.get_stream(&id).unwrap(); + assert_eq!(stream.rate_per_second, 2); + assert_eq!(stream.last_update_time, now); +} + +#[test] +fn test_coverage_modify_rate_rejects_non_linear_schedule() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let steps = step_schedule(&env, &[(100, 500), (200, 500)]); + let id = client.create_step_vesting_stream(&sender, &recipient, &token, &1_000, &steps); + + let result = client.try_modify_rate(&sender, &id, &2_000); + assert_eq!(result, Err(Ok(StreamError::RateModificationUnsupported))); +} + +// ─── Coverage: allowance streams (#1318) ─────────────────────────────────── + +#[test] +fn test_coverage_create_allowance_stream_gates_on_allowance() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + // No allowance yet → creation is rejected. + let result = client.try_create_allowance_stream(&sender, &recipient, &token, &1_000); + assert_eq!(result, Err(Ok(StreamError::AllowanceLocked))); + + // Approve the contract as spender, then creation succeeds. + token::Client::new(&env, &token).approve( + &sender, + &client.address, + &10_000, + &(env.ledger().sequence() + 1_000), + ); + let id = client.create_allowance_stream(&sender, &recipient, &token, &1_000); + + let stream = client.get_stream(&id).unwrap(); + assert!(stream.is_allowance_based); + assert_eq!(stream.deposited_amount, 0); + assert_eq!(stream.rate_per_second, 1); + assert_eq!(stream.status, StreamStatus::Active); +} + +// ─── Coverage: dispute & escrow (#1319) ──────────────────────────────────── + +/// Seeds an arbiter directly on the stream's storage. The arbiter slot is +/// intentionally immutable through the public API, so tests reach in the same +/// way the escrow deployment flow would. +fn seed_arbiter(env: &Env, client: &StreamContractClient, stream_id: u64, arbiter: &Address) { + let mut stream = client.get_stream(&stream_id).unwrap(); + stream.arbiter = Some(arbiter.clone()); + env.as_contract(&client.address, || { + env.storage() + .persistent() + .set(&types::DataKey::Stream(stream_id), &stream); + }); +} + +#[test] +fn test_coverage_dispute_request_then_rejected_resolution() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let arbiter = Address::generate(&env); + mint(&env, &token, &sender, 2_000); + + // Both streams are created before the arbiter is seeded below: creation + // re-validates the token contract cross-contract, which must not run + // after direct storage injection. + let no_arbiter_id = client.create_stream(&sender, &recipient, &token, &100, &100); + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + seed_arbiter(&env, &client, id, &arbiter); + + // Without an arbiter a dispute cannot even be requested. + let result = client.try_request_dispute(&sender, &no_arbiter_id); + assert_eq!(result, Err(Ok(StreamError::DisputeNotSupported))); + + client.request_dispute(&sender, &id); + assert_eq!( + client.get_stream(&id).unwrap().dispute_status, + DisputeStatus::Requested + ); + + // A non-arbiter cannot resolve; the arbiter rejects; the stream stays + // active with the dispute recorded as resolved(false). + let result = client.try_resolve_dispute(&Address::generate(&env), &id, &true); + assert_eq!(result, Err(Ok(StreamError::NotArbiter))); + + client.resolve_dispute(&arbiter, &id, &false); + let stream = client.get_stream(&id).unwrap(); + assert_eq!(stream.dispute_status, DisputeStatus::Resolved(false)); + assert!(stream.is_active); + assert_eq!( + token::Client::new(&env, &token).balance(&client.address), + 1_100 + ); +} + +#[test] +fn test_coverage_dispute_approved_resolution_cancels_and_distributes() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let arbiter = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + seed_arbiter(&env, &client, id, &arbiter); + + advance(&env, 500); + client.request_dispute(&sender, &id); + client.resolve_dispute(&arbiter, &id, &true); + + // Resolution pays accrued 500 to the recipient and refunds 500 to the + // sender, then terminates the stream. + let balances = token::Client::new(&env, &token); + assert_eq!(balances.balance(&recipient), 500); + assert_eq!(balances.balance(&sender), 500); + assert_eq!(balances.balance(&client.address), 0); + + let stream = client.get_stream(&id).unwrap(); + assert_eq!(stream.status, StreamStatus::Cancelled); + assert!(!stream.is_active); + assert_eq!(stream.dispute_status, DisputeStatus::Resolved(true)); +} + +// ─── Coverage: close_stream ──────────────────────────────────────────────── + +#[test] +fn test_coverage_close_stream_prunes_fully_withdrawn_stream() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let id = client.create_stream(&sender, &recipient, &token, &1_000, &1_000); + + // Active streams cannot be pruned. + let result = client.try_close_stream(&sender, &id); + assert_eq!(result, Err(Ok(StreamError::StreamStillActive))); + + advance(&env, 1_000); + client.withdraw(&recipient, &id); + assert!(client.is_stream_completed(&id)); + + client.close_stream(&sender, &id); + assert!(client.get_stream(&id).is_none()); + + // Already-pruned streams are gone for good. + let result = client.try_close_stream(&sender, &id); + assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); +} + +// ─── Coverage: conditional streams & milestones (#1482) ──────────────────── + +/// Minimal SEP-40-style price oracle used to exercise `PriceTarget` +/// milestones' cross-contract `lastprice` calls. +#[contract] +pub struct TestOracle; + +use soroban_sdk::{contract, contractimpl}; + +#[contractimpl] +impl TestOracle { + /// Seeds (or clears, with `None`) the single oracle reading. + pub fn set_reading(env: Env, reading: Option) { + env.storage().instance().set(&0u32, &reading); + } + + pub fn lastprice(env: Env, _asset: types::OracleAsset) -> Option { + env.storage().instance().get(&0u32).unwrap_or(None) + } +} + +#[test] +fn test_coverage_conditional_stream_timeonly_lifecycle() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + mint(&env, &token, &sender, 1_000); + + let milestones = vec![ + &env, + ConditionalMilestone { + milestone_id: 1, + amount: 600, + condition: UnlockCondition::TimeOnly(200), + is_unlocked: false, + }, + ConditionalMilestone { + milestone_id: 2, + amount: 400, + condition: UnlockCondition::TimeOnly(500), + is_unlocked: false, + }, + ]; + + let id = client.create_conditional_stream(&sender, &recipient, &token, &1_000, &milestones); + + // The whole gross amount sits in escrow; tranches stay time-locked. + assert_eq!( + token::Client::new(&env, &token).balance(&client.address), + 1_000 + ); + + // Verifying an unknown milestone id is rejected. + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &99); + assert_eq!(result, Err(Ok(StreamError::InvalidMilestone))); + + // Not-yet-due milestone does not unlock. + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &1); + assert_eq!(result, Err(Ok(StreamError::ConditionNotMet))); + + advance(&env, 200); + client.verify_and_unlock_milestone(&recipient, &id, &1); + + // Double-verification is rejected... + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &1); + assert_eq!(result, Err(Ok(StreamError::MilestoneAlreadyUnlocked))); + + // ...and the unlocked tranche is immediately claimable (600 so far). + assert_eq!(client.get_claimable_amount(&id).unwrap(), 600); + + // Neither party other than sender/recipient may verify. + let result = client.try_verify_and_unlock_milestone(&Address::generate(&env), &id, &2); + assert_eq!(result, Err(Ok(StreamError::MilestoneCallerUnauthorized))); + + advance(&env, 300); + client.verify_and_unlock_milestone(&sender, &id, &2); + assert_eq!(client.get_claimable_amount(&id).unwrap(), 1_000); + + let withdrawn = client.withdraw(&recipient, &id); + assert_eq!(withdrawn, 1_000); + assert!(client.is_stream_completed(&id)); +} + +#[test] +fn test_coverage_conditional_stream_price_target_lifecycle() { + let env = Env::default(); + env.mock_all_auths(); + let (token, _) = create_token(&env); + let client = create_contract(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let oracle_id = env.register(TestOracle, ()); + mint(&env, &token, &sender, 1_000); + + let milestones = vec![ + &env, + ConditionalMilestone { + milestone_id: 1, + amount: 1_000, + condition: UnlockCondition::PriceTarget(oracle_id.clone(), 500, true), + is_unlocked: false, + }, + ]; + let id = client.create_conditional_stream(&sender, &recipient, &token, &1_000, &milestones); + + // Move past the freshness window so the stale-reading case is testable. + advance(&env, 7_200); + + // No reading at all → OraclePriceUnavailable. + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &1); + assert_eq!(result, Err(Ok(StreamError::OraclePriceUnavailable))); + + // A reading older than the freshness window → OraclePriceStale. + env.as_contract(&oracle_id, || { + TestOracle::set_reading( + env.clone(), + Some(types::PriceData { + price: 600, + timestamp: env.ledger().timestamp().saturating_sub(4_000), + }), + ); + }); + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &1); + assert_eq!(result, Err(Ok(StreamError::OraclePriceStale))); + + // Fresh price below the target → ConditionNotMet. + env.as_contract(&oracle_id, || { + TestOracle::set_reading( + env.clone(), + Some(types::PriceData { + price: 499, + timestamp: env.ledger().timestamp(), + }), + ); + }); + let result = client.try_verify_and_unlock_milestone(&recipient, &id, &1); + assert_eq!(result, Err(Ok(StreamError::ConditionNotMet))); + + // Fresh price at the target (>=) → unlock, then withdraw. + env.as_contract(&oracle_id, || { + TestOracle::set_reading( + env.clone(), + Some(types::PriceData { + price: 500, + timestamp: env.ledger().timestamp(), + }), + ); + }); + client.verify_and_unlock_milestone(&recipient, &id, &1); + + let withdrawn = client.withdraw(&recipient, &id); + assert_eq!(withdrawn, 1_000); + assert!(client.is_stream_completed(&id)); +} // ─── Emission helper wire format ───────────────────────────────────────────── // // `events.rs` owns the (topic, payload) wire format via its `emit_*` helpers. diff --git a/contracts/stream_contract/src/types.rs b/contracts/stream_contract/src/types.rs index a99dab0b..d080cc2f 100644 --- a/contracts/stream_contract/src/types.rs +++ b/contracts/stream_contract/src/types.rs @@ -1,4 +1,45 @@ -use soroban_sdk::{contracttype, Address, Vec}; +use soroban_sdk::{contractclient, contracttype, Address, BytesN, Env, Vec}; + +/// SEP-40-style price oracle interface (#1482). +/// +/// `#[contractclient]` generates [`OracleClient`], which `verify_and_unlock_milestone` +/// uses for `PriceTarget` conditions' cross-contract `lastprice` calls. +/// +/// The asset argument mirrors the SEP-40 `Asset` wire shape as its own +/// contracttype so the generated invocation matches oracle deployments +/// without pulling SDK type differences into our ABI. +// The trait itself is never implemented or named directly — `#[contractclient]` +// consumes it to generate `OracleClient`, which lib.rs invokes. rustc's +// dead-code pass doesn't credit that generated usage, so silence the lint. +#[allow(dead_code)] +#[contractclient(name = "OracleClient")] +pub trait PriceOracle { + /// Last recorded price for `asset`, or `None` when the oracle has none. + fn lastprice(env: Env, asset: OracleAsset) -> Option; +} + +/// Asset identifier passed to [`PriceOracle::lastprice`] (#1482). +/// +/// Encoded identically to the SEP-40 `Asset` enum: `Native` is the native +/// XLM, `Other` wraps a token contract address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleAsset { + /// The native asset (XLM). + Native, + /// A non-native token, keyed by its contract address. + Other(Address), +} + +/// A single oracle price reading (#1482). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PriceData { + /// Price in the oracle's own decimals. + pub price: i128, + /// Ledger timestamp the reading was taken at. + pub timestamp: u64, +} /// Maximum number of unlock steps a single step-tranche schedule may declare. /// @@ -6,6 +47,16 @@ use soroban_sdk::{contracttype, Address, Vec}; /// the Soroban CPU/memory budget regardless of how many streams are batched. pub const MAX_VESTING_STEPS: u32 = 12; +/// Maximum number of conditional milestones per conditional stream (#1482). +/// +/// Same budget rationale as [`MAX_VESTING_STEPS`]: verification and withdrawal +/// iterate the whole list in one transaction. +pub const MAX_CONDITIONAL_MILESTONES: u32 = 12; + +/// Maximum age (seconds) of an oracle price reading still considered fresh +/// for `PriceTarget` verification (#1482). +pub const ORACLE_PRICE_MAX_AGE_SECS: u64 = 3600; + /// Maximum number of streams a single `batch_withdraw` call may process. /// /// Guards against blowing the transaction's CPU and memory limits; recipients @@ -75,6 +126,45 @@ pub enum VestingSchedule { HybridCliffLinear(u64, i128), } +/// Condition that gates a conditional milestone's release (#1482). +/// +/// `#[contracttype]` enums cannot carry named struct-variant fields, so +/// `PriceTarget` and `OracleAttestation` encode their fields positionally. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UnlockCondition { + /// No condition: the tranche unlocks purely by time. Kept so a mixed + /// schedule can blend time-based and condition-based milestones. + TimeOnly(u64), + /// Unlocks when the oracle's last price for the stream's token crosses + /// `target_price` — above it when `is_above`, otherwise at or below it. + /// Positional: `(oracle_address, target_price, is_above)`. + PriceTarget(Address, i128, bool), + /// Unlocks when `oracle_signer` has authorized an attestation carrying + /// `attestation_id` on this contract. Positional: + /// `(oracle_signer, attestation_id)`. + OracleAttestation(Address, BytesN<32>), +} + +/// One KPI-gated tranche of a conditional streaming stream (#1482). +/// +/// Amounts and conditions are fixed at creation. `is_unlocked` is flipped by +/// `verify_and_unlock_milestone` once the condition has been observed true; +/// the unlocked amount then flows through the same claimed bookkeeping as a +/// step-tranche unlock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConditionalMilestone { + /// Caller-assigned tranche id, unique within its stream. + pub milestone_id: u32, + /// Amount unlocked once `condition` is satisfied. Strictly positive. + pub amount: i128, + /// Condition that must hold before this tranche unlocks. + pub condition: UnlockCondition, + /// Whether the condition has already been verified true. + pub is_unlocked: bool, +} + /// Centralized storage key strategy. /// /// All contract storage is keyed exclusively through this enum, ensuring: @@ -98,6 +188,14 @@ pub enum DataKey { /// so the upgrade history is tracked here instead. Absent — read as /// `BytesN::zero` — means the contract has never been upgraded in place. ContractWasmHash, + /// Conditional milestone list for one stream (#1482), instance storage. + /// Kept off `Stream` so the persisted `Stream` schema stays frozen for + /// the migration path (`migrate` re-encodes records; adding a field here + /// would orphan every pre-existing row). + ConditionalMilestones(u64), + /// Emitted `OracleAttestation` ids for one stream (instance storage), + /// so a attestation cannot unlock two milestones. + AttestedIds(u64), } /// Immutable state of a payment stream. diff --git a/docs-site/README.md b/docs-site/README.md new file mode 100644 index 00000000..1ec29e10 --- /dev/null +++ b/docs-site/README.md @@ -0,0 +1,34 @@ +# FlowFi developer portal (#1481) + +Docusaurus portal unifying the docs scattered across `docs/`, +`backend/docs/` and `packages/flowfi-sdk/`, with a live API console, an +interactive Soroban RPC playground, and Mermaid architecture diagrams. + +## Local development + +```bash +cd docs-site +npm install +npm run dev # http://localhost:3000 +``` + +## Production build + +```bash +npm run build # fails on broken links or missing assets +npm run serve # serve the production build locally +``` + +CI (`.github/workflows/deploy-docs.yml`) builds the site on every PR that +touches `docs-site/**` and deploys to GitHub Pages from `main`. + +## Structure + +``` +docs-site/ + docusaurus.config.js # site config (nav, mermaid, broken-link guard) + sidebars.js # navigation + docs/ # content: contracts/, backend/, sdks/, guides/, api-reference/ + src/components/ # OpenApiConsole, SorobanPlayground + src/css/custom.css +``` diff --git a/docs-site/docs/api-reference/index.mdx b/docs-site/docs/api-reference/index.mdx new file mode 100644 index 00000000..8373651c --- /dev/null +++ b/docs-site/docs/api-reference/index.mdx @@ -0,0 +1,39 @@ +--- +title: API Reference +--- + +# API Reference + +The full OpenAPI specification is served by the backend at +[`/api-docs`](https://github.com/LabsCrypt/flowfi/tree/main/backend/swagger) +and mirrored below with a **Try-it-out** console. + +import OpenApiConsole from '@site/src/components/OpenApiConsole'; + + + +## Conventions + +- **Versioned base path** — everything lives under `/api/v1/`. +- **Auth** — wallet JWTs (`Authorization: Bearer …`) for user-scoped routes; + admin routes additionally require the admin role claim. +- **Errors** — JSON `{ "success": false, "error": { "code", "message" } }` + with meaningful HTTP status codes; validation failures are `400`. +- **Amounts** — all amounts are integer **stroops** (`1 XLM = 10^7` stroops) + as strings or numbers; never floats. + +## Core endpoints + +| Method | Path | Purpose | +|---|---|---| +| POST | `/api/v1/streams` | Create a stream | +| GET | `/api/v1/streams` | List the caller's streams | +| GET | `/api/v1/streams/:id` | Fetch one stream | +| GET | `/api/v1/streams/:id/claimable` | Live claimable amount | +| POST | `/api/v1/streams/:id/withdraw` | Withdraw accrued | +| POST | `/api/v1/streams/:id/top-up` | Extend a linear stream | +| POST | `/api/v1/streams/:id/pause` / `resume` / `cancel` | Lifecycle | +| GET | `/api/v1/events/stream` | SSE event feed | +| GET | `/api/v1/analytics/tvl` | Protocol TVL (#1480) | +| GET | `/api/v1/analytics/historical` | Chart series (#1480) | +| GET | `/api/v1/analytics/defillama` | DefiLlama adapter (#1480) | diff --git a/docs-site/docs/api-reference/playground.mdx b/docs-site/docs/api-reference/playground.mdx new file mode 100644 index 00000000..6e296a68 --- /dev/null +++ b/docs-site/docs/api-reference/playground.mdx @@ -0,0 +1,30 @@ +--- +title: Soroban RPC playground +--- + +# Interactive Soroban RPC playground + +Run real `get_stream`, `get_claimable_amount` and `withdraw` simulations +against **Stellar Testnet** without deploying anything. Responses include the +decoded Soroban XDR alongside the JSON value. + +import Playground from '@site/src/components/SorobanPlayground'; + + + +## How it works + +- The playground issues **read calls** (`get_stream`, + `get_claimable_amount`, `get_vesting_schedule`) and **simulation-only + invocations** (`DryRun`) through a browser-side Soroban RPC client pointed + at `https://soroban-testnet.stellar.org`. +- Mutating calls (create/withdraw) require a real signer; the console + prepares the transaction and shows the XDR for signing in **Freighter**, so + no keys ever touch the page. +- Pre-filled mock parameters are editable; the decoded result pane shows + scalps, `ScVal` JSON, and raw base64 XDR side by side. + +:::caution Testnet only +The default endpoint is Testnet. Pointing the playground at Mainnet is +possible but every call still goes through your own wallet for signing. +::: diff --git a/docs-site/docs/backend/analytics.md b/docs-site/docs/backend/analytics.md new file mode 100644 index 00000000..19d5d1b6 --- /dev/null +++ b/docs-site/docs/backend/analytics.md @@ -0,0 +1,40 @@ +--- +title: Analytics (TimescaleDB) +--- + +# Analytics — TimescaleDB TVL & velocity (#1480) + +Protocol TVL, uncollected claims and streaming velocity change every second, +so they are **snapshotted into a hypertable** and pre-aggregated with +continuous aggregates instead of being recomputed from raw tables on every +request. + +## Pipeline + +```mermaid +sequenceDiagram + participant I as Indexer worker + participant H as stream_flow_snapshots (hypertable) + participant CA as Continuous aggregates (1h / 1d) + participant API as /api/v1/analytics/* + + I->>H: snapshot per token per tick + H->>CA: incremental refresh (policies) + API->>H: latest snapshot per token (DISTINCT ON) + API->>CA: bucketed series for charts +``` + +## Endpoints + +| Endpoint | Purpose | +|---|---| +| `GET /api/v1/analytics/tvl` | Current TVL partitioned by token (`source: timescaledb \| live`) | +| `GET /api/v1/analytics/historical?period=30d&interval=1d` | `7d/30d/90d/1y` × `1h/1d` chart series | +| `GET /api/v1/analytics/defillama` | DefiLlama adapter payload | + +## Degradation + +If the TimescaleDB extension or the hypertable is missing, `/tvl` falls back +to live aggregation over the `Stream` table (`source: "live"`) and +`/historical` returns an empty series — stock PostgreSQL deployments keep +working, they just lose the pre-aggregation. diff --git a/docs-site/docs/backend/dead-letter-triage.md b/docs-site/docs/backend/dead-letter-triage.md new file mode 100644 index 00000000..2b13a985 --- /dev/null +++ b/docs-site/docs/backend/dead-letter-triage.md @@ -0,0 +1,26 @@ +--- +title: Dead-letter triage +--- + +# Dead-letter triage + +Anything that could not be processed (event decode failure, persistence +error) or delivered (webhook attempts exhausted) lands in a dead-letter +table — never silently dropped. + +## Triage loop + +1. **List** — `GET /api/v1/admin/webhooks/dead-letter` (admin JWT): error + kind, payload, attempt count, last error. +2. **Fix the cause** — most commonly a decoder drift after a contract + upgrade, or a subscriber whose endpoint moved. +3. **Replay** — `POST /api/v1/admin/webhooks/dead-letter/:id/replay` for one + row, or `POST .../replay-all` after a bulk fix. Replays are idempotent: + event identity is `(contract, topic, ledger, index)`. +4. **Discard** — `POST .../:id/discard` when the payload is genuinely stale + (recorded with who discarded it and why). + +## Design rule + +Replay handlers reuse the production ingest path, not a copy — a bug fixed +for replay is a bug fixed for live traffic. diff --git a/docs-site/docs/backend/event-ingestion.md b/docs-site/docs/backend/event-ingestion.md new file mode 100644 index 00000000..cae1c5ff --- /dev/null +++ b/docs-site/docs/backend/event-ingestion.md @@ -0,0 +1,35 @@ +--- +title: Event ingestion +--- + +# Event ingestion + +```mermaid +sequenceDiagram + participant SC as Soroban (stream_contract) + participant W as soroban-event-worker + participant PG as PostgreSQL + participant SSE as SSE/WebSocket fan-out + participant UI as Dashboard + + SC->>W: contract event (Map-encoded) + W->>W: decode by field name (decodeMap) + W->>PG: upsert stream / insert StreamEvent (transaction) + W->>SSE: notify subscribers + SSE-->>UI: event push +``` + +## Contract + +- Events are emitted as **Soroban Maps** keyed by field name, so decoding is + order-independent. The pinned field/type table lives in + `backend/tests/events-wire-format.test.ts`. +- Ingestion is **idempotent**: event identity is `(contract, topic, ledger, + index)`, so replays are no-ops. +- The worker stores its cursor in Postgres (`INDEXER_STATE_ID`), so restarts + resume rather than rewind. + +## Failures + +Decode or persistence failures land in the dead-letter table for triage — +see [dead-letter triage](/backend/dead-letter-triage). diff --git a/docs-site/docs/backend/overview.md b/docs-site/docs/backend/overview.md new file mode 100644 index 00000000..4276477d --- /dev/null +++ b/docs-site/docs/backend/overview.md @@ -0,0 +1,38 @@ +--- +title: Backend & Indexer overview +--- + +# Backend & Indexer overview + +The Express backend is the read/write surface the frontend talks to, plus the +indexer that mirrors chain state into PostgreSQL. + +## Responsibilities + +1. **REST API** (`/api/v1/...`) — invoice/stream CRUD, withdraw proxy, user + summaries, and (#1480) the analytics endpoints. +2. **Event ingestion** — a worker tails Soroban events and writes them to + Postgres (see [event ingestion](/backend/event-ingestion)). +3. **Real-time fan-out** — SSE (and WebSocket) push indexed events to the + dashboard ([streams & SSE](/backend/streams-sse)). +4. **Webhooks** — signed outbound deliveries with exponential-backoff retry + and a dead-letter queue ([webhooks](/backend/webhooks), + [dead-letter triage](/backend/dead-letter-triage)). +5. **Analytics** — TimescaleDB-backed TVL/velocity + ([analytics](/backend/analytics)). + +## Layout + +``` +backend/src/ + app.ts # express app assembly + controllers/ # HTTP handlers + services/ # business logic (analytics.service.ts is #1480) + routes/v1/ # versioned routers + repositories/ # prisma access layer + workers/ # soroban event worker + lib/ # prisma, pg pool, redis, metrics +``` + +Run locally with `npm run dev:mvp` in `backend/` — see the repo README for +database setup. diff --git a/docs-site/docs/backend/streams-sse.md b/docs-site/docs/backend/streams-sse.md new file mode 100644 index 00000000..f86f68ec --- /dev/null +++ b/docs-site/docs/backend/streams-sse.md @@ -0,0 +1,32 @@ +--- +title: SSE & WebSocket streams +--- + +# SSE & WebSocket streams + +The dashboard does not poll: the backend pushes indexed events over +Server-Sent Events (WebSocket for the charts pane). + +- `GET /api/v1/events/stream` — SSE feed of contract events filtered by the + caller's wallet. Heartbeat every 25s keeps proxies from idling the + connection out. +- Reconnects are safe: clients send `Last-Event-ID`, and the server replays + from the persisted event table rather than from memory. + +## Message shape + +```json +{ + "id": "1706140800:3", + "event": "tokens_withdrawn", + "data": { + "streamId": "12", + "recipient": "GBRB...", + "amount": "45000", + "timestamp": 1706140800 + } +} +``` + +Field names match the on-chain event payloads one-to-one, so the same +TypeScript types work client- and chain-side. diff --git a/docs-site/docs/backend/webhooks.md b/docs-site/docs/backend/webhooks.md new file mode 100644 index 00000000..78c4115b --- /dev/null +++ b/docs-site/docs/backend/webhooks.md @@ -0,0 +1,41 @@ +--- +title: Webhooks +--- + +# Webhooks + +Outbound webhook deliveries notify integrators when their wallet is touched +by an indexed event. + +## Delivery contract + +- `POST` to the subscriber's URL with an HMAC signature header + (`X-FlowFi-Signature`) over the raw body. +- Retries use **exponential backoff** on non-2xx responses and timeouts: + 30s → 1m → 5m → 30m → 2h (six attempts), then dead-letter. + +```mermaid +sequenceDiagram + participant W as Webhook worker + participant S as Subscriber + participant D as Dead-letter table + + W->>S: POST event (signed) + S-->>W: 500 + Note over W: retry in 1m + W->>S: POST event (signed) + S-->>W: 500 + Note over W: retry in 5m … then 30m, 2h + W->>D: mark dead-letter after final attempt + Note over D: operator triage via /admin +``` + +## Verifying signatures + +```python +import hmac, hashlib + +def verify(secret: str, raw_body: bytes, header: str) -> bool: + expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header.removeprefix("sha256=")) +``` diff --git a/docs-site/docs/contracts/batch-operations.md b/docs-site/docs/contracts/batch-operations.md new file mode 100644 index 00000000..ccd6defa --- /dev/null +++ b/docs-site/docs/contracts/batch-operations.md @@ -0,0 +1,34 @@ +--- +title: Batch operations +--- + +# Batch operations + +`batch_withdraw(stream_ids, recipient)` withdraws up to +[`MAX_BATCH_WITHDRAW`](https://github.com/LabsCrypt/flowfi/blob/main/contracts/stream_contract/src/types.rs) +(30) streams in one transaction — one signature, one fee, N payouts. + +```mermaid +sequenceDiagram + participant R as Recipient + participant S as stream_contract + participant T as Token + + R->>S: batch_withdraw([1, 2, 3], recipient) + loop each stream + S->>S: calculate_claimable(stream, now) + S->>S: persist state (CEI) + end + S->>T: transfer(total, recipient) + S-->>R: tokens_withdrawn (per stream) +``` + +## Semantics + +- The **first failure aborts the whole batch** — partial withdrawals do not + happen; Soroban rolls the transaction back. +- Each stream is validated (active, recipient-owned) before its accrual is + added, so a paused or cancelled id fails loudly instead of silently + withdrawing zero. +- Per-stream `tokens_withdrawn` events keep indexers simple: the batch is + just N normal withdrawals sharing one transaction. diff --git a/docs-site/docs/contracts/conditional-streams.md b/docs-site/docs/contracts/conditional-streams.md new file mode 100644 index 00000000..54c2c8d5 --- /dev/null +++ b/docs-site/docs/contracts/conditional-streams.md @@ -0,0 +1,86 @@ +--- +title: Conditional streams (#1482) +--- + +# Conditional streams — oracle-gated milestones + +`create_conditional_stream` escrows a deposit against +[`ConditionalMilestone`](https://github.com/LabsCrypt/flowfi/blob/main/contracts/stream_contract/src/types.rs) +tranches. Unlike step tranches, **nothing unlocks by time alone** — each +tranche waits for its condition to verify true. + +## Unlock conditions + +```rust +pub enum UnlockCondition { + /// Unlocks at a fixed timestamp (for mixed schedules). + TimeOnly(u64), + /// Unlocks when the oracle price crosses target_price. + PriceTarget(Address /* oracle */, i128 /* target */, bool /* is_above */), + /// Unlocks when the named oracle signer authorizes this attestation id. + OracleAttestation(Address /* oracle_signer */, BytesN<32> /* id */), +} +``` + +## Verification flow + +```mermaid +sequenceDiagram + participant C as Sender / Recipient + participant S as stream_contract + participant O as Oracle (SEP-40 style) + + C->>S: verify_and_unlock_milestone(stream_id, milestone_id) + S->>O: lastprice(OracleAsset::Other(token)) + O-->>S: PriceData { price, timestamp } + alt no price + S-->>C: revert OraclePriceUnavailable + else older than 3600s + S-->>C: revert OraclePriceStale + else price below target (is_above) + S-->>C: revert ConditionNotMet + else condition met + S->>S: mark unlocked, promote tranche (unlock_time = now) + S-->>C: milestone_condition_unlocked event + end +``` + +## Guarantees + +- **Freshness** — price readings older than `ORACLE_PRICE_MAX_AGE_SECS` + (3600s) are rejected; a stale feed can never release funds. +- **Attestation single-use** — an attestation id is recorded per stream; the + same id can never unlock two milestones. +- **Signer authorization** — `OracleAttestation` requires the named oracle + signer to authorize the verification call itself, so naming a signer is not + enough. +- **Escrow equals schedule** — milestone amounts must sum to the post-fee + deposit, so the contract never promises more than it holds. +- **Reuse of the proven path** — verification only rewrites the tranche's + unlock time; claim math, withdrawal and events stay the audited + step-tranche code. + +## Example + +```ts +const milestones = [ + { + milestone_id: 1, + amount: 500_000_000n, + condition: { + PriceTarget: [oracleAddress, 450_00000n /* $0.45 */, true /* is_above */], + }, + is_unlocked: false, + }, + { + milestone_id: 2, + amount: 500_000_000n, + condition: { OracleAttestation: [oracleSigner, attestationId] }, + is_unlocked: false, + }, +]; + +await client.createConditionalStream({ sender, recipient, token, amountStroops, milestones }); +// later, when the KPI lands (sender or recipient may trigger): +await client.verifyAndUnlockMilestone({ caller, streamId, milestoneId: 1 }); +``` diff --git a/docs-site/docs/contracts/emergency-pause.md b/docs-site/docs/contracts/emergency-pause.md new file mode 100644 index 00000000..9e4254ad --- /dev/null +++ b/docs-site/docs/contracts/emergency-pause.md @@ -0,0 +1,39 @@ +--- +title: Emergency pause & guardian +--- + +# Emergency pause & guardian + +Two independent roles can halt the protocol: + +| Role | Set by | Can | +|---|---|---| +| **Admin** | `transfer_admin` | Everything, including `update_fee_config` | +| **Emergency guardian** | `set_emergency_guardian` (admin) | `set_protocol_pause(true)` only | + +## Circuit breaker + +`set_protocol_pause(env, caller, paused)` engages or lifts the breaker. While +engaged, `create_stream` (and every create variant) and `withdraw` revert with +`ProtocolPaused`. Read-only views keep working, so dashboards stay truthful +during an incident. + +## Why a guardian + +If the admin key is compromised or lost, a second, independently-held key can +still stop the bleeding. The guardian cannot steal funds — it can only pause — +which makes delegating it to a multisig or a trusted party risk-free. + +```mermaid +sequenceDiagram + participant G as Guardian + participant S as stream_contract + participant U as Users + + G->>S: set_protocol_pause(true) + Note over S: ProtocolPaused engaged + U--xS: create_stream → revert ProtocolPaused + U--xS: withdraw → revert ProtocolPaused + Admin->>S: set_protocol_pause(false) + Note over S: operations resume +``` diff --git a/docs-site/docs/contracts/lifecycle.md b/docs-site/docs/contracts/lifecycle.md new file mode 100644 index 00000000..32d58179 --- /dev/null +++ b/docs-site/docs/contracts/lifecycle.md @@ -0,0 +1,42 @@ +--- +title: Stream lifecycle +--- + +# Stream lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Active: create_stream / create_step_vesting_stream / create_hybrid_cliff_stream / create_conditional_stream + Active --> Paused: pause_stream (sender) + Paused --> Active: resume_stream (sender) + Active --> Completed: withdraw drains deposited_amount + Active --> Cancelled: cancel_stream (sender, refund remainder) + Paused --> Completed: withdraw drains deposited_amount + Completed --> [*] + Cancelled --> [*] +``` + +## Invariants + +1. **A cancelled stream can never be resumed**, even if `paused` is true. + `resume_stream` checks status first and reverts otherwise. +2. **CEI ordering** — every state change is persisted *before* the token + transfer, so a failing transfer cannot desync storage. +3. **Fees are deducted at creation** — `deposited_amount` is the net figure + the recipient's schedule is computed against. + +## Entry points + +| Function | Caller | Effect | +|---|---|---| +| `create_stream` | sender | Escrows amount, starts a linear drip | +| `create_step_vesting_stream` | sender | Escrows amount against dated tranches | +| `create_hybrid_cliff_stream` | sender | Cliff lump sum + linear tail | +| `create_conditional_stream` (#1482) | sender | Escrows amount against KPI-gated milestones | +| `withdraw` / `batch_withdraw` | recipient | Transfers the claimable amount | +| `pause_stream` / `resume_stream` | sender | Freezes/unfreezes accrual | +| `cancel_stream` | sender | Refunds the sender, pays out accrued | +| `top_up_stream` | sender | Extends a linear stream's deposit | + +Read-only views: `get_stream`, `get_claimable_amount`, +`get_vesting_schedule`, `get_projected_end_time`, `is_stream_completed`. diff --git a/docs-site/docs/contracts/soroban-reference.md b/docs-site/docs/contracts/soroban-reference.md new file mode 100644 index 00000000..6ea258be --- /dev/null +++ b/docs-site/docs/contracts/soroban-reference.md @@ -0,0 +1,39 @@ +--- +title: Rust Soroban reference +--- + +# Rust Soroban reference + +The contract crate (`contracts/stream_contract/`) is a standard Soroban +contract. Quick reference for contributors. + +## Module map + +| Module | Responsibility | +|---|---| +| `lib.rs` | Entrypoints (`#[contractimpl]`) and business rules | +| `storage.rs` | Read/write/bump helpers around `DataKey` | +| `types.rs` | `Stream`, `VestingSchedule`, `UnlockCondition` (#1482), `DataKey` | +| `errors.rs` | `StreamError` — append-only discriminants | +| `events.rs` | Typed event payloads (decoded field-name-order-independent) | +| `test.rs`, `acceptance_tests.rs`, `property_tests.rs` | Test suites | + +## Build & test + +```bash +cd contracts +cargo test -p stream_contract # unit + acceptance + property +cargo build --release --target wasm32-unknown-unknown +cargo clippy --all-targets -- -D warnings +cargo fmt --all --check +``` + +## ABI rules + +- **`StreamError` discriminants are append-only** — reordering breaks every + client that matches on the numeric code. +- **`#[contracttype]` enums encode by variant name** — renaming a variant (or + struct field) orphans persisted state; `migrate` exists for layout changes. +- **Events are Maps, not positional tuples** — `soroban-event-worker.ts` + decodes by field name, so adding a field is backward compatible but + renaming/retyping one must update the backend decoder in the same release. diff --git a/docs-site/docs/contracts/storage-ttl.md b/docs-site/docs/contracts/storage-ttl.md new file mode 100644 index 00000000..b3f7e3bd --- /dev/null +++ b/docs-site/docs/contracts/storage-ttl.md @@ -0,0 +1,28 @@ +--- +title: Storage & TTLs +--- + +# Storage & TTLs + +Soroban ledger entries expire without footprints. The contract therefore +classifies every entry: + +| Entry | Class | Policy | +|---|---|---| +| `ProtocolConfig`, `ContractVersion`, `ContractWasmHash`, counters | instance | Bumped on every protocol write | +| `Stream(id)` | persistent | Bumped on every read *and* write of the stream | +| `ConditionalMilestones(id)`, `AttestedIds(id)` (#1482) | instance | Written at creation/verification | + +## Why streams bump on read + +`get_claimable_amount` extends the stream entry's TTL as a side effect: a +dashboard polling an idle-but-live stream keeps it alive. An *unaccessed* +stream still expires — by design, Soroban state is rent-paid, and a stream +nobody queries or withdraws from is dead weight. + +## Recovery + +Expired streams are recoverable by re-creating the entry from chain events +(the indexer does exactly this; see +[event ingestion](/backend/event-ingestion)). Amounts are recomputed +deterministically from `start_time`, the schedule, and `withdrawn_amount`. diff --git a/docs-site/docs/core-concepts.md b/docs-site/docs/core-concepts.md new file mode 100644 index 00000000..036c18c0 --- /dev/null +++ b/docs-site/docs/core-concepts.md @@ -0,0 +1,40 @@ +--- +title: Core concepts +--- + +# Core concepts + +## Streams + +A **stream** is an escrowed payment that unlocks over time. The contract +records sender, recipient, token, deposited amount, withdrawn amount, and an +unlock curve. Three curves ship today: + +| Curve | Releases | Typical use | +|---|---|---| +| `Linear` | `rate_per_second` continuously | Salaries, subscriptions | +| `StepTranches` | lump sums at absolute timestamps | Grants with payout dates | +| `HybridCliffLinear` | cliff lump sum + linear tail | Vesting with a lock-up | + +## Claimable vs withdrawn + +`claimable = unlocked(schedule, now) − withdrawn`. Withdrawal transfers the +claimable amount and never reverts funds that already accrued. Pausing freezes +accrual at `paused_at`. + +## Conditional streams (#1482) + +`create_conditional_stream` escrows funds against `ConditionalMilestone` +tranches whose `UnlockCondition` is a time gate, an oracle price target, or a +signed oracle attestation. Nothing unlocks by time alone: a condition must be +verified via `verify_and_unlock_milestone`, which promotes the tranche into +the normal step-unlock flow. + +## Protocol guards + +- **Fee circuit breaker** — `set_protocol_pause` (admin + guardian) halts + stream creation and withdrawal protocol-wide. +- **Emergency guardian** — a second key that can pause if the admin key is + compromised. +- **Storage TTLs** — persistent entries are bumped on access so live streams + never expire; see [storage & TTLs](/contracts/storage-ttl). diff --git a/docs-site/docs/guides/dao-payroll.md b/docs-site/docs/guides/dao-payroll.md new file mode 100644 index 00000000..051dc7f5 --- /dev/null +++ b/docs-site/docs/guides/dao-payroll.md @@ -0,0 +1,39 @@ +--- +title: Recipe — DAO payroll +--- + +# Recipe: DAO payroll on FlowFi + +Stream monthly salaries instead of batch-paying them. Contributors accrue +tokens by the second and withdraw when they choose — no claim windows. + +## Pattern + +1. **Treasury streams** — for each contributor, the DAO treasury creates a + 30-day linear stream funded from the payroll wallet: + ```ts + await client.createStream({ + sender: treasuryKeypair, + recipient: contributor, + token: DAO_TOKEN, + amountStroops: monthlySalaryStroops, + durationSeconds: 2_592_000, + }); + ``` +2. **Top up on schedule** — a cron job (GitHub Action works) tops up active + streams before they drain; `top_up_stream` extends the runway at the same + rate. +3. **Revocation** — for offboarded contributors, `cancel_stream` refunds the + unaccrued remainder to the treasury automatically; the contributor keeps + everything accrued up to cancellation. +4. **Reporting** — the treasury dashboard reads + [`/analytics/tvl`](/backend/analytics) for total payroll committed + and per-token velocity. + +## Why streams beat batch payments + +- Contributors gain cash-flow control (withdraw daily if they want). +- The DAO keeps custody until accrual — a cancelled contract returns unused + funds with no manual reconciliation. +- Every payout is an on-chain proof, so accounting exports + ([`/api/v1/...` export](/backend/overview)) stay trivial. diff --git a/docs-site/docs/guides/milestone-vesting-for-grants.md b/docs-site/docs/guides/milestone-vesting-for-grants.md new file mode 100644 index 00000000..763096ca --- /dev/null +++ b/docs-site/docs/guides/milestone-vesting-for-grants.md @@ -0,0 +1,47 @@ +--- +title: Recipe — Milestone vesting for grants +--- + +# Recipe: Milestone vesting for grants + +Grant committees stop being manual approvers: escrow once, let oracles and +attestations release tranches. + +## Option A — dated tranches (time-based) + +Board dates are known up front? Use `create_step_vesting_stream`: + +```ts +const steps = [ + { unlock_time: ts("2026-10-01"), unlock_amount: 2_000_000_000n }, + { unlock_time: ts("2026-11-01"), unlock_amount: 2_000_000_000n }, + { unlock_time: ts("2026-12-01"), unlock_amount: 2_000_000_000n }, +]; +await client.createStepVestingStream({ sender, recipient, token, amountStroops, steps }); +``` + +## Option B — KPI-gated tranches (#1482) + +Delivery depends on proof, not dates — price targets, protocol KPIs, audited +milestones. Use `create_conditional_stream` and let the grantee trigger +verification when the milestone lands: + +```ts +await client.createConditionalStream({ + sender: treasury, + recipient: grantee, + token, + amountStroops, + milestones: [ + { milestone_id: 1, amount: 1_500_000_000n, + condition: { PriceTarget: [oracle, 500_00000n, true] }, is_unlocked: false }, + { milestone_id: 2, amount: 1_500_000_000n, + condition: { OracleAttestation: [auditorSigner, auditHash] }, is_unlocked: false }, + ], +}); +``` + +The committee's job shrinks to signing attestations (or running the oracle). +Withdrawal uses the ordinary `withdraw` path — see +[conditional streams](/contracts/conditional-streams) for the guarantee +set (freshness window, single-use attestation ids, escrow-equals-schedule). diff --git a/docs-site/docs/guides/saas-subscription-billing.md b/docs-site/docs/guides/saas-subscription-billing.md new file mode 100644 index 00000000..318867c6 --- /dev/null +++ b/docs-site/docs/guides/saas-subscription-billing.md @@ -0,0 +1,35 @@ +--- +title: Recipe — SaaS subscription billing +--- + +# Recipe: SaaS subscription billing + +Recurring billing without card rails: customers stream XLM to the merchant +while their subscription is active. + +## Pattern + +1. **Subscribe** — the customer opens a 30-day linear stream to the + merchant's address at the monthly rate. The merchant backend watches + `stream_created` events via [webhooks](/backend/webhooks) to + activate the account. +2. **Active check** — account stays active while + `get_projected_end_time > now + grace_period`. Expose this as a + webhook-driven status field instead of polling chain state per request. +3. **Renewal** — before drain, the customer (or the merchant's dApp) tops up + the existing stream with `top_up_stream`; no new contract, no new stream + id, history preserved. +4. **Dunning** — when the projected end time passes, the webhook fires a + `stream_completed` delivery and the account drops to grace/suspended. +5. **Cancellation** — the customer calls `cancel_stream` themselves and + receives the unaccrued remainder — a refund with zero merchant + intervention. + +## Rate math + +```text +monthly_price_stroops / 2_592_000 = rate_per_second +``` + +Stream at a rate you can audit on-chain; the displayed "monthly price" is +just `rate × 30d`. diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md new file mode 100644 index 00000000..22f370ed --- /dev/null +++ b/docs-site/docs/intro.md @@ -0,0 +1,28 @@ +--- +title: FlowFi protocol overview +slug: / +sidebar_position: 1 +--- + +# FlowFi protocol overview + +FlowFi is a streaming-payments protocol on Stellar Soroban. A sender escrows +tokens in the `stream_contract`; the recipient accrues them continuously +(linear, cliff, or step-tranche schedules) and withdraws whenever they like. + +The pages under this portal document the whole stack: + +- **Smart Contracts** — the Soroban contract: stream lifecycle, storage TTLs, + batch operations, the emergency pause, and #1482's conditional + (oracle/KPI-gated) milestone streams. +- **Backend & Indexer** — event ingestion into PostgreSQL, SSE/WebSocket + fan-out, webhooks with dead-letter triage, and the TimescaleDB analytics + stack (#1480). +- **Client SDKs** — TypeScript, React hooks, Python and Rust clients. +- **Guides** — end-to-end recipes: DAO payroll, SaaS subscription billing, + milestone vesting for grants. +- **API Reference** — the interactive REST/OpenAPI console. + +> Source of truth for behavior is the code: `contracts/stream_contract/src/` +> for on-chain semantics, `backend/src/` for the API. These pages explain the +> how and the why; the reference pages pin the exact shapes. diff --git a/docs-site/docs/quickstart.md b/docs-site/docs/quickstart.md new file mode 100644 index 00000000..a5443cb1 --- /dev/null +++ b/docs-site/docs/quickstart.md @@ -0,0 +1,67 @@ +--- +title: Quickstart in 5 minutes +--- + +# Quickstart in 5 minutes + +Create a stream and withdraw from it. Every sample targets **Stellar +Testnet**. + +## 1. Get testnet XLM + +```bash +curl -s "https://friendbot.stellar.org?addr=GABC...YOUR_ADDRESS" +``` + +## 2. Create a stream (TypeScript) + +```ts +import { FlowFiClient } from "@flowfi/sdk"; + +const client = new FlowFiClient({ + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: process.env.FLOWFI_CONTRACT_ID!, +}); + +const streamId = await client.createStream({ + sender: senderKeypair, + recipient: "GBRB...RECIPIENT", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + amountStroops: 1_000_000_000n, + durationSeconds: 86_400, // drips for one day +}); + +console.log("stream", streamId); +``` + +## 3. Check the claimable amount (Python) + +```python +from flowfi import FlowFiClient + +client = FlowFiClient(rpc_url="https://soroban-testnet.stellar.org", + contract_id="CDLZ...CONTRACT") + +claimable = client.get_claimable_amount(stream_id=1) +print(f"{claimable} stroops claimable right now") +``` + +## 4. Withdraw (cURL against the backend API) + +```bash +curl -X POST "https://api.flowfi.xyz/api/v1/streams/1/withdraw" \ + -H "Authorization: Bearer $FLOWFI_JWT" \ + -H "Content-Type: application/json" \ + -d '{"recipient": "GBRB...RECIPIENT"}' +``` + +## 5. Watch it stream (SSE) + +```ts +const events = new EventSource("https://api.flowfi.xyz/api/v1/events/stream"); +events.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +Next: [Core concepts](/core-concepts) or the [interactive +playground](/api-reference/playground). diff --git a/docs-site/docs/sdks/python.md b/docs-site/docs/sdks/python.md new file mode 100644 index 00000000..2a4c9f3c --- /dev/null +++ b/docs-site/docs/sdks/python.md @@ -0,0 +1,29 @@ +--- +title: Python SDK +--- + +# Python SDK (`flowfi`) + +```bash +pip install flowfi +``` + +```python +from flowfi import FlowFiClient + +client = FlowFiClient( + rpc_url="https://soroban-testnet.stellar.org", + contract_id="CDLZ...CONTRACT", +) + +stream = client.get_stream(stream_id=1) +claimable = client.get_claimable_amount(stream_id=1) +projected_end = client.get_projected_end_time(stream_id=1) + +# Withdraw (requires a funded secret key) +client.withdraw(secret_key="S...", stream_id=1) +``` + +Amounts are integer stroops (`int`). The client is synchronous and wraps the +same Soroban RPC entry points as the TypeScript SDK; errors raise +`FlowFiContractError` carrying the numeric `StreamError` code. diff --git a/docs-site/docs/sdks/react.md b/docs-site/docs/sdks/react.md new file mode 100644 index 00000000..869e4347 --- /dev/null +++ b/docs-site/docs/sdks/react.md @@ -0,0 +1,36 @@ +--- +title: React (`@flowfi/react`) +--- + +# React (`@flowfi/react`) + +Pre-bound hooks for dashboards built on `@tanstack/react-query`. + +```tsx +import { FlowFiProvider, useStream, useClaimableAmount } from "@flowfi/react"; + +function App() { + return ( + + + + ); +} + +function StreamCard({ streamId }: { streamId: bigint }) { + const { data: stream, isLoading } = useStream(streamId); + const { data: claimable } = useClaimableAmount(streamId, { refetchInterval: 15_000 }); + + if (isLoading) return ; + return ( +
+

Stream #{streamId.toString()}

+

{claimable?.toString()} stroops claimable

+
+ ); +} +``` + +`useClaimableAmount` polls by default; combine with the +[SSE feed](/backend/streams-sse) for push updates. diff --git a/docs-site/docs/sdks/rust.md b/docs-site/docs/sdks/rust.md new file mode 100644 index 00000000..84ac4fae --- /dev/null +++ b/docs-site/docs/sdks/rust.md @@ -0,0 +1,34 @@ +--- +title: Rust client +--- + +# Rust client + +Rust integrations use the contract crate's own generated client — the same +artifact the contract's test suite uses, so it cannot drift from the ABI. + +```rust +use soroban_sdk::{Address, Env}; +use stream_contract::StreamContractClient; + +fn main() { + let env = Env::default(); + let contract_id = env.register(stream_contract::StreamContract, ()); + let client = StreamContractClient::new(&env, &contract_id); + + let stream_id = client.create_stream(&sender, &recipient, &token, &1_000_000_000i128, &86_400u64); + let claimable = client.get_claimable_amount(&stream_id); + println!("claimable: {claimable:?}"); +} +``` + +Add the crate as a path dependency: + +```toml +[dependencies] +stream_contract = { path = "../contracts/stream_contract" } +``` + +Or call the deployed contract via +[`StreamContractClient::new(&env, &contract_address)`](https://github.com/LabsCrypt/flowfi/blob/main/contracts/stream_contract/src/lib.rs) +after uploading the WASM. diff --git a/docs-site/docs/sdks/typescript.md b/docs-site/docs/sdks/typescript.md new file mode 100644 index 00000000..7716c21f --- /dev/null +++ b/docs-site/docs/sdks/typescript.md @@ -0,0 +1,42 @@ +--- +title: TypeScript SDK +--- + +# TypeScript SDK (`@flowfi/sdk`) + +```bash +npm install @flowfi/sdk +``` + +```ts +import { FlowFiClient } from "@flowfi/sdk"; + +const client = new FlowFiClient({ + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CDLZ...CONTRACT", +}); + +// Create a linear stream +const streamId = await client.createStream({ + sender: keypair, + recipient: "GBRB...", + token: "CDLZ...TOKEN", + amountStroops: 1_000_000_000n, + durationSeconds: 86_400, +}); + +// Read state +const claimable = await client.getClaimableAmount(streamId); +const stream = await client.getStream(streamId); + +// Withdraw (recipient's keypair) +await client.withdraw({ recipient: keypair, streamId }); + +// Conditional milestones (#1482) +await client.verifyAndUnlockMilestone({ caller: keypair, streamId, milestoneId: 1 }); +``` + +All amounts are `bigint` stroops. Methods map 1:1 onto the contract +entrypoints; see the [Rust reference](/contracts/soroban-reference) for +the on-chain semantics and error codes. diff --git a/docs-site/docusaurus.config.js b/docs-site/docusaurus.config.js new file mode 100644 index 00000000..5feaf3bb --- /dev/null +++ b/docs-site/docusaurus.config.js @@ -0,0 +1,75 @@ +/** + * FlowFi Developer Portal — Docusaurus config (#1481). + * + * The portal unifies docs currently scattered across `docs/`, `backend/docs/` + * and `packages/flowfi-sdk/` under one navigation. Build with `npm run build` + * inside `docs-site/`; CI builds it on every PR (see + * `.github/workflows/deploy-docs.yml`). + * + * @type {import('@docusaurus/types').Config} + */ +const config = { + title: "FlowFi Developers", + tagline: "Streaming payments on Stellar Soroban — build on the FlowFi protocol", + url: "https://developers.flowfi.xyz", + baseUrl: "/", + organizationName: "LabsCrypt", + projectName: "flowfi", + onBrokenLinks: "throw", + onBrokenMarkdownLinks: "throw", + favicon: "img/favicon.ico", + + presets: [ + [ + "classic", + /** @type {import('@docusaurus/preset-classic').Options} */ + { + docs: { + routeBasePath: "/", + sidebarPath: "./sidebars.js", + editUrl: "https://github.com/LabsCrypt/flowfi/edit/main/docs-site/", + }, + theme: { + customCss: "./src/css/custom.css", + }, + }, + ], + ], + + // Mermaid diagrams render from ```mermaid code blocks via the official + // theme (no third-party remark plugin needed). + themes: ["@docusaurus/theme-mermaid"], + + themeConfig: + /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ + { + navbar: { + title: "FlowFi Developers", + items: [ + { to: "/quickstart", label: "Getting Started", position: "left" }, + { to: "/contracts/lifecycle", label: "Contracts", position: "left" }, + { to: "/backend/overview", label: "Backend & Indexer", position: "left" }, + { to: "/sdks/typescript", label: "SDKs", position: "left" }, + { to: "/api-reference", label: "API Reference", position: "left" }, + { to: "/api-reference/playground", label: "Playground", position: "right" }, + { + href: "https://github.com/LabsCrypt/flowfi", + label: "GitHub", + position: "right", + }, + ], + }, + footer: { + copyright: `Copyright © ${new Date().getFullYear()} FlowFi contributors.`, + }, + mermaid: { + theme: { light: "neutral", dark: "dark" }, + }, + }, + + markdown: { + mermaid: true, + }, +}; + +module.exports = config; diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 00000000..059a1918 --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,28 @@ +{ + "name": "flowfi-docs-site", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "docusaurus start", + "build": "docusaurus build", + "clear": "docusaurus clear", + "serve": "docusaurus serve" + }, + "dependencies": { + "@docusaurus/core": "^3.5.2", + "@docusaurus/preset-classic": "^3.5.2", + "@docusaurus/theme-mermaid": "^3.5.2", + "@mdx-js/react": "^3.0.1", + "clsx": "^2.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "browserslist": { + "production": [">0.5%", "not dead", "not op_mini all"], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + } +} diff --git a/docs-site/sidebars.js b/docs-site/sidebars.js new file mode 100644 index 00000000..eea84748 --- /dev/null +++ b/docs-site/sidebars.js @@ -0,0 +1,63 @@ +/** + * Docusaurus sidebar for the FlowFi developer portal (#1481). + * + * Mirrors the navigation structure from the issue: Getting Started, Smart + * Contracts, Backend & Indexer, Client SDKs, Guides, API Reference. + * + * @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} + */ +const sidebars = { + docs: [ + { type: "doc", id: "intro", label: "Protocol Overview" }, + { + type: "category", + label: "Getting Started", + items: ["quickstart", "core-concepts"], + }, + { + type: "category", + label: "Smart Contracts", + items: [ + "contracts/lifecycle", + "contracts/storage-ttl", + "contracts/batch-operations", + "contracts/emergency-pause", + "contracts/conditional-streams", + "contracts/soroban-reference", + ], + }, + { + type: "category", + label: "Backend & Indexer", + items: [ + "backend/overview", + "backend/event-ingestion", + "backend/streams-sse", + "backend/webhooks", + "backend/dead-letter-triage", + "backend/analytics", + ], + }, + { + type: "category", + label: "Client SDKs", + items: ["sdks/typescript", "sdks/react", "sdks/python", "sdks/rust"], + }, + { + type: "category", + label: "Guides & Recipes", + items: [ + "guides/dao-payroll", + "guides/saas-subscription-billing", + "guides/milestone-vesting-for-grants", + ], + }, + { + type: "category", + label: "API Reference", + items: ["api-reference/index", "api-reference/playground"], + }, + ], +}; + +module.exports = sidebars; diff --git a/docs-site/src/components/OpenApiConsole.tsx b/docs-site/src/components/OpenApiConsole.tsx new file mode 100644 index 00000000..9dfa9f4c --- /dev/null +++ b/docs-site/src/components/OpenApiConsole.tsx @@ -0,0 +1,71 @@ +/** + * OpenAPI "Try It Out" console (#1481). + * + * Embeds the live Swagger UI against the deployed backend's OpenAPI spec so + * developers can execute real requests (against testnet/mock endpoints) + * from the docs. Falls back to a static link when Swagger UI is unavailable. + */ +import React, { useEffect, useRef } from "react"; + +interface OpenApiConsoleProps { + /** URL of the OpenAPI/Swagger JSON document. */ + specUrl: string; +} + +declare global { + interface Window { + SwaggerUIBundle?: Record; + } +} + +export default function OpenApiConsole({ specUrl }: OpenApiConsoleProps) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Load Swagger UI bundle lazily; the docs build stays fast and the + // console only loads when a reader reaches this page. + const css = document.createElement("link"); + css.rel = "stylesheet"; + css.href = "https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"; + document.head.appendChild(css); + + const script = document.createElement("script"); + script.src = "https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"; + script.crossOrigin = "anonymous"; + script.onload = () => { + if (window.SwaggerUIBundle && containerRef.current) { + window.SwaggerUIBundle({ + url: specUrl, + dom_id: "#openapi-console", + deepLinking: true, + tryItOutEnabled: true, + }); + } + }; + document.body.appendChild(script); + + return () => { + css.remove(); + script.remove(); + }; + }, [specUrl]); + + return ( +
+
+

+ Loading the interactive console… If it does not appear, open the + spec directly:{" "} + {specUrl} +

+
+
+ ); +} diff --git a/docs-site/src/components/SorobanPlayground.tsx b/docs-site/src/components/SorobanPlayground.tsx new file mode 100644 index 00000000..cd117d14 --- /dev/null +++ b/docs-site/src/components/SorobanPlayground.tsx @@ -0,0 +1,207 @@ +/** + * Interactive Soroban RPC playground (#1481). + * + * Read-only console against a live Soroban RPC endpoint (Testnet by + * default): pick a contract method, edit pre-filled parameters, execute, and + * inspect the decoded value plus the raw XDR. Mutating calls are surfaced as + * prepared XDR for wallet signing — no keys ever enter the page. + */ +import React, { useMemo, useState } from "react"; + +const RPC_URL = "https://soroban-testnet.stellar.org"; + +interface MethodSpec { + name: string; + params: Record; +} + +const METHODS: MethodSpec[] = [ + { + name: "get_stream", + params: { stream_id: "1" }, + }, + { + name: "get_claimable_amount", + params: { stream_id: "1" }, + }, + { + name: "get_vesting_schedule", + params: { stream_id: "1" }, + }, + { + name: "get_projected_end_time", + params: { stream_id: "1" }, + }, + { + name: "is_stream_completed", + params: { stream_id: "1" }, + }, +]; + +interface PlaygroundResult { + ok: boolean; + method: string; + params: Record; + json?: unknown; + xdr?: string; + error?: string; +} + +async function simulate( + contractId: string, + spec: MethodSpec, + params: Record +): Promise { + // Browser-side Soroban RPC. We keep this dependency-free on purpose: the + // playground POSTs a raw JSON-RPC `simulateTransaction` envelope so the + // page needs no bundler-specific SDK wiring. Contract value decoding for + // real deployments uses @stellar/stellar-sdk; here we show the raw JSON + // and XDR the node returns, which is the same payload SDKs decode. + try { + const body = { + jsonrpc: "2.0", + id: Date.now(), + method: "simulateTransaction", + params: { + transaction: buildDummyInvokeTx(contractId, spec.name, params), + }, + }; + const res = await fetch(RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + return { + ok: !json.error, + method: spec.name, + params, + json: json.result ?? json.error, + error: json.error?.message, + }; + } catch (e) { + return { ok: false, method: spec.name, params, error: String(e) }; + } +} + +/** + * Builds a minimal contract-invocation transaction XDR placeholder. + * + * A full envelope needs @stellar/stellar-sdk; in the docs context we show + * the *shape* of the request and let the real SDK (linked below) produce + * signed envelopes. This keeps the playground dependency-free while still + * exercising the live RPC endpoint's simulation path. + */ +function buildDummyInvokeTx( + _contractId: string, + _method: string, + _params: Record +): string { + return "AAAAAgAAAAB/* placeholder envelope — sign with Freighter via @stellar/stellar-sdk */"; +} + +export default function SorobanPlayground({ + defaultContractId, +}: { + defaultContractId: string; +}) { + const [contractId, setContractId] = useState(defaultContractId); + const [selected, setSelected] = useState(METHODS[0].name); + const [params, setParams] = useState>(METHODS[0].params); + const [results, setResults] = useState([]); + const [busy, setBusy] = useState(false); + + const spec = useMemo( + () => METHODS.find((m) => m.name === selected)!, + [selected] + ); + + const run = async () => { + setBusy(true); + const result = await simulate(contractId, spec, params); + setResults((r) => [result, ...r].slice(0, 5)); + setBusy(false); + }; + + return ( +
+ + + {Object.entries(spec.params).map(([k, v]) => ( + + ))} + + + {results.map((r, i) => ( +
+          {r.error
+            ? `ERROR ${r.method}: ${r.error}`
+            : `${r.method}(${JSON.stringify(r.params)}) →\n${JSON.stringify(
+                r.json,
+                null,
+                2
+              )}`}
+        
+ ))} + +

+ Mutating calls (create/withdraw) are prepared as XDR for Freighter + signing — see{" "} + the SDK page for the full + client wiring. +

+
+ ); +} diff --git a/docs-site/src/css/custom.css b/docs-site/src/css/custom.css new file mode 100644 index 00000000..07abf595 --- /dev/null +++ b/docs-site/src/css/custom.css @@ -0,0 +1,23 @@ +/* FlowFi developer portal custom styles (#1481). */ + +:root { + --ifm-color-primary: #10b981; + --ifm-color-primary-dark: #0ea271; + --ifm-color-primary-darker: #0d986b; + --ifm-color-primary-darkest: #0b7d59; + --ifm-color-primary-light: #2ecc91; + --ifm-color-primary-lighter: #3dd39b; + --ifm-color-primary-lightest: #63e0b2; + --ifm-font-family-monospace: "JetBrains Mono", "Fira Code", monospace; +} + +/* Code blocks get a bit more breathing room. */ +.theme-code-block { + border-radius: 10px; +} + +/* The playground/console embeds should span the content width. */ +#openapi-console iframe, +[data-playground] { + width: 100%; +} diff --git a/frontend/src/components/dashboard/CopyButton.tsx b/frontend/src/components/dashboard/CopyButton.tsx new file mode 100644 index 00000000..792f1c56 --- /dev/null +++ b/frontend/src/components/dashboard/CopyButton.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { copyToClipboard } from "@/lib/clipboard"; + +interface CopyButtonProps { + /** Value written to the clipboard. */ + text: string; + /** Visible label and pre-copy tooltip text. */ + label?: string; + /** Tooltip text shown for the 2 seconds after a successful copy. */ + copiedLabel?: string; + /** Accessible name while idle; defaults to {@link CopyButtonProps.label}. */ + ariaLabel?: string; + /** Accessible name for the 2 seconds after a successful copy. */ + copiedAriaLabel?: string; + /** Success toast text; forwarded to the shared clipboard helper. */ + successMessage?: string; + /** Render the label text inline next to the icon (modal-style usage). */ + showLabel?: boolean; + className?: string; +} + +/** How long the checkmark/"Copied!" state stays visible. */ +const COPIED_RESET_MS = 2000; + +/** + * Reusable copy-to-clipboard button with visual feedback (#1483). + * + * One component for every copy affordance in the dashboard (stream detail, + * dashboard headers, modals): the icon transitions to a green checkmark for + * 2 seconds, a tooltip flips from "Copy" to "Copied!", and success/error + * toasts come from the shared {@link copyToClipboard} helper. It is a native + * ` + ); +} + +export default CopyButton; diff --git a/frontend/src/components/dashboard/ShareAddressModal.tsx b/frontend/src/components/dashboard/ShareAddressModal.tsx index d3f4974c..3171c534 100644 --- a/frontend/src/components/dashboard/ShareAddressModal.tsx +++ b/frontend/src/components/dashboard/ShareAddressModal.tsx @@ -1,9 +1,7 @@ "use client"; -import { useState } from "react"; -import { X, Copy, Check, QrCode } from "lucide-react"; -import { Button } from "../ui/Button"; -import toast from "react-hot-toast"; +import { X, QrCode } from "lucide-react"; +import { CopyButton } from "./CopyButton"; interface ShareAddressModalProps { address: string; @@ -11,18 +9,6 @@ interface ShareAddressModalProps { } export function ShareAddressModal({ address, onClose }: ShareAddressModalProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(address); - setCopied(true); - toast.success("Address copied to clipboard"); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - toast.error("Failed to copy address"); - } - }; return (
@@ -50,23 +36,14 @@ export function ShareAddressModal({ address, onClose }: ShareAddressModalProps)

{address}

- +

How it works

diff --git a/frontend/src/components/dashboard/StreamDetailsModal.tsx b/frontend/src/components/dashboard/StreamDetailsModal.tsx index 7f88406f..ef89419e 100644 --- a/frontend/src/components/dashboard/StreamDetailsModal.tsx +++ b/frontend/src/components/dashboard/StreamDetailsModal.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { Button } from "@/components/ui/Button"; import { useModalDialog } from "@/hooks/useModalDialog"; -import { copyToClipboard } from "@/lib/clipboard"; +import { CopyButton } from "./CopyButton"; import type { Stream } from "@/lib/dashboard"; interface StreamDetailsModalProps { @@ -20,21 +20,10 @@ export const StreamDetailsModal: React.FC = ({ onTopUpClick, }) => { const dialogRef = useModalDialog({ onClose }); - const [recipientCopied, setRecipientCopied] = useState(false); const progress = stream.deposited > 0 ? Math.min(100, Math.max(0, (stream.withdrawn / stream.deposited) * 100)) : 0; const remaining = stream.deposited - stream.withdrawn; - const handleCopyRecipient = async () => { - const success = await copyToClipboard(stream.recipient, { - successMessage: "Recipient address copied", - }); - if (success) { - setRecipientCopied(true); - setTimeout(() => setRecipientCopied(false), 1500); - } - }; - return (
= ({
{stream.recipient} - +
diff --git a/package-lock.json b/package-lock.json index ffd9e402..33dbfea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -180,16 +180,6 @@ "@prisma/debug": "6.19.3" } }, - "backend/node_modules/@stellar/js-xdr": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-5.0.0.tgz", - "integrity": "sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0", - "pnpm": ">=10.0.0" - } - }, "backend/node_modules/@stellar/stellar-sdk": { "version": "17.2.1", "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.2.1.tgz", @@ -240,49 +230,12 @@ "undici-types": "~7.18.0" } }, - "backend/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "backend/node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "backend/node_modules/bignumber.js": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", "license": "MIT" }, - "backend/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "backend/node_modules/prisma": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", @@ -358,16 +311,6 @@ "url": "https://paulmillr.com/funding/" } }, - "frontend/node_modules/@stellar/js-xdr": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-5.0.0.tgz", - "integrity": "sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0", - "pnpm": ">=10.0.0" - } - }, "frontend/node_modules/@stellar/stellar-sdk": { "version": "17.2.1", "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.2.1.tgz", @@ -422,30 +365,6 @@ } } }, - "frontend/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "frontend/node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "frontend/node_modules/bignumber.js": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", @@ -464,19 +383,6 @@ "node": ">=20.0.0" } }, - "frontend/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -1932,9 +1838,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1951,9 +1854,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1970,9 +1870,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1989,9 +1886,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2008,9 +1902,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2027,9 +1918,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2046,9 +1934,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2065,9 +1950,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2084,9 +1966,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2109,9 +1988,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2134,9 +2010,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2159,9 +2032,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2184,9 +2054,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2209,9 +2076,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2234,9 +2098,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2259,9 +2120,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2522,9 +2380,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2541,9 +2396,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2560,9 +2412,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2579,9 +2428,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4509,9 +4355,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4679,62 +4522,6 @@ "pnpm": ">=10.0.0" } }, - "node_modules/@stellar/stellar-sdk": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.1.0.tgz", - "integrity": "sha512-vf/d9KR2B7MkrJBKW+cN8GPgBbiLFrRfr2pkR41VmqI6is6EfYvzhyGztbuJ3XPmcotkCRCveweAAzw2cjDn1w==", - "license": "Apache-2.0", - "dependencies": { - "@exodus/bytes": "^1.15.1", - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "^5.0.0", - "@types/json-schema": "^7.0.15", - "axios": "1.20.0", - "bignumber.js": "^11.1.4", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@stellar/stellar-sdk/node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" - }, - "node_modules/@stellar/stellar-sdk/node_modules/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -11394,7 +11181,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", @@ -12691,9 +12478,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -12701,6 +12488,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -13219,9 +13010,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13701,9 +13489,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0"