From 744118b724a10bb70042e13d7bab42079a29505c Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Tue, 1 Sep 2026 02:26:39 -0500 Subject: [PATCH 1/4] Add daily-reload locks via Sablier Lockup Tranched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hourly/daily presets stream linearly — claimable grows every second and the interval is display-only. Real casino-style reloads need discrete drops: nothing claimable between them, then the day's chunk lands at once. The deployed v2.0 Lockup contract already exposes createWithDurationsLT (selector verified in Base mainnet bytecode); the app just never called it. - schedule.ts: computeTranches (even split, dust folds into the last tranche), sablierNetDeposit (replicates the contract's UD60x18 floor broker-fee math — tranche amounts must sum to it exactly or the create reverts), secondsUntilTimeOfDay (first drop anchors to the user's local time-of-day, rolling a day when closer than 15 minutes) - abis.ts: createWithDurationsLT entry - create page: fourth schedule tab (Daily reload) with drop-count and time-of-day inputs; both lock call sites consolidated into one fireLock so LL/LT branching can't drift; preview/calculator/confirm/success surfaces speak in drops with zero-between-drops copy per the honest- wording rule from the 2026-07-28 packet Verified beyond unit tests via eth_simulateV1 against the deployed Base Sepolia contract: faucet -> approve -> createWithDurationsLT succeeds with exact sums (fee-less and 0.5%-broker paths, 12 dusty amount/day combos), and reverts when the sum is off by one unit. Solana is excluded deliberately: the deployed SolSab program exposes only Lockup Linear instructions and upstream has no tranched work. --- packages/app/src/app/create/page.tsx | 324 +++++++++++++++++++++----- packages/app/src/config/abis.ts | 37 +++ packages/app/src/lib/schedule.test.ts | 117 ++++++++++ packages/app/src/lib/schedule.ts | 75 ++++++ 4 files changed, 492 insertions(+), 61 deletions(-) diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index b19c8c6..bb305d7 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -41,6 +41,11 @@ import { toDatetimeLocalValue, computeFee, computeMaxDeposit, + RELOAD_INTERVAL_SECONDS, + RELOAD_DAY_OPTIONS, + secondsUntilTimeOfDay, + sablierNetDeposit, + computeTranches, } from "@/lib/schedule"; type PresetKey = keyof typeof PRESETS; @@ -73,7 +78,11 @@ function parseStreamIdFromReceipt( } type Step = "schedule" | "confirm" | "approve" | "lock" | "success"; -type CustomMode = "reloads" | "lockUntil"; +type CustomMode = "reloads" | "dailyReload" | "lockUntil"; +// How the lock is written on-chain: linear/lockUntil use createWithDurationsLL, +// dailyReload uses createWithDurationsLT (discrete tranches — nothing claimable +// between drops). +type ScheduleKind = "linear" | "lockUntil" | "dailyReload"; // Sensible default for the lock-until picker: 7 days from "now" at the // minute the page rendered. Captured once per mount so the input doesn't @@ -247,6 +256,10 @@ function CreateLockInner() { const [customInterval, setCustomInterval] = useState(3600); // 1hr default claim interval (display only) const [customMode, setCustomMode] = useState("reloads"); const [lockUntilInput, setLockUntilInput] = useState(defaultLockUntilValue); + // Daily reload (tranched) inputs: how many daily drops, and what local + // time of day they land. + const [reloadDays, setReloadDays] = useState(7); + const [reloadTime, setReloadTime] = useState("18:00"); // Tick "now" once every 30s so the lock-until duration preview // ("18d 3h from now") doesn't go stale while the form is open. We don't // need per-second precision — the user is picking a target in days/hours. @@ -310,20 +323,27 @@ function CreateLockInner() { // Derived schedule values const schedule = useMemo<{ + kind: ScheduleKind; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; label: string; targetMs: number | null; + /** dailyReload only: seconds until the first drop, and drop count. */ + firstDelaySeconds: number; + reloadCount: number; }>(() => { if (selectedPreset !== "custom") { const p = PRESETS[selectedPreset]; return { + kind: "linear", cliffSeconds: p.cliffSeconds, totalSeconds: p.totalSeconds, isLumpSum: p.isLumpSum, label: p.label, targetMs: null, + firstDelaySeconds: 0, + reloadCount: 0, }; } if (customMode === "lockUntil") { @@ -331,34 +351,69 @@ function CreateLockInner() { // so the action button stays disabled until they pick a real date. if (!lockUntilParsed) { return { + kind: "lockUntil", cliffSeconds: 0, totalSeconds: 0, isLumpSum: true, label: "Lock until …", targetMs: null, + firstDelaySeconds: 0, + reloadCount: 0, }; } const total = lockUntilParsed.durationSeconds; // Sablier requires cliff < total strictly. The one-second gap is // negligible vs the unlock date; the user sees a clean lump-sum. return { + kind: "lockUntil", cliffSeconds: total - 1, totalSeconds: total, isLumpSum: true, label: `Lock until ${formatTargetDate(lockUntilParsed.targetMs)}`, targetMs: lockUntilParsed.targetMs, + firstDelaySeconds: 0, + reloadCount: 0, + }; + } + if (customMode === "dailyReload") { + const firstDelay = secondsUntilTimeOfDay(reloadTime, nowMs); + // Malformed time input → totalSeconds = 0 keeps `canProceed` false. + if (firstDelay === null) { + return { + kind: "dailyReload", + cliffSeconds: 0, + totalSeconds: 0, + isLumpSum: false, + label: "Daily Reload", + targetMs: null, + firstDelaySeconds: 0, + reloadCount: 0, + }; + } + return { + kind: "dailyReload", + cliffSeconds: 0, + totalSeconds: firstDelay + (reloadDays - 1) * RELOAD_INTERVAL_SECONDS, + isLumpSum: false, + label: `Daily Reload × ${reloadDays}`, + targetMs: nowMs + firstDelay * 1000, + firstDelaySeconds: firstDelay, + reloadCount: reloadDays, }; } const cliffEqualsTotal = customCliff === customTotal && customCliff > 0; return { + kind: "linear", cliffSeconds: customCliff, // Sablier requires cliff < total strictly; add 1s so linear stream amount is non-zero totalSeconds: cliffEqualsTotal ? customTotal + 1 : customTotal, isLumpSum: false, // Never set unlockCliff=totalAmount — Sablier rejects zero linear stream amount label: "Custom Reloads", targetMs: null, + firstDelaySeconds: 0, + reloadCount: 0, }; - }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed]); + }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed, reloadDays, reloadTime, nowMs]); // Amount parsing const depositAmount = useMemo(() => { @@ -378,6 +433,22 @@ function CreateLockInner() { const unlockStart = BigInt(0); const unlockCliff = schedule.isLumpSum ? depositAmount : BigInt(0); + // Tranche list for daily-reload locks. Amounts must sum to exactly the + // net deposit Sablier computes after the broker cut, or the create reverts + // on-chain — sablierNetDeposit replicates that floor math. + const tranches = useMemo( + () => + schedule.kind === "dailyReload" && totalAmount > BigInt(0) + ? computeTranches( + sablierNetDeposit(totalAmount, brokerFee), + schedule.firstDelaySeconds, + RELOAD_INTERVAL_SECONDS, + schedule.reloadCount, + ) + : null, + [schedule, totalAmount, brokerFee], + ); + // Read USDC allowance const { data: allowance, refetch: refetchAllowance } = useReadContract({ address: usdcAddress, @@ -437,6 +508,8 @@ function CreateLockInner() { setCustomTotal(3600); setCustomMode("reloads"); setLockUntilInput(defaultLockUntilValue()); + setReloadDays(7); + setReloadTime("18:00"); setAmountInput(""); setStep("schedule"); setConfirmed(false); @@ -498,28 +571,39 @@ function CreateLockInner() { }); }, [writeApprove, address, resetApproveWrite, usdcAddress, sablierLockup]); - const handleLock = useCallback(() => { - if (!address || lockInFlightRef.current) return; - lockInFlightRef.current = true; - // Clear stale success/error state from any previous lock so the - // post-confirm effect doesn't fire against leftover data. - resetLockWrite(); - setStep("lock"); + // The single place lock txs are written. Both entry points (direct Lock + // click and the approve→lock auto-progress) call this so the LL/LT + // branching can't drift between them. + const fireLock = useCallback(() => { + if (!address) return; + const params = { + sender: address as Address, + recipient: address as Address, + totalAmount, + token: usdcAddress, + cancelable: false, + transferable: false, + shape: "RipGuard", + broker: { account: treasury, fee: brokerFee }, + }; + if (schedule.kind === "dailyReload") { + // canProceed already blocks invalid tranche lists; this guard is for + // the async priming path where state may have shifted. + if (!tranches) return; + writeLock({ + address: sablierLockup, + abi: sablierLockupAbi, + functionName: "createWithDurationsLT", + args: [params, tranches], + }); + return; + } writeLock({ address: sablierLockup, abi: sablierLockupAbi, functionName: "createWithDurationsLL", args: [ - { - sender: address as Address, - recipient: address as Address, - totalAmount, - token: usdcAddress, - cancelable: false, - transferable: false, - shape: "RipGuard", - broker: { account: treasury, fee: brokerFee }, - }, + params, { start: unlockStart, cliff: unlockCliff }, { cliff: schedule.cliffSeconds, total: schedule.totalSeconds }, ], @@ -528,16 +612,26 @@ function CreateLockInner() { writeLock, totalAmount, schedule, + tranches, unlockStart, unlockCliff, address, - resetLockWrite, sablierLockup, usdcAddress, treasury, brokerFee, ]); + const handleLock = useCallback(() => { + if (!address || lockInFlightRef.current) return; + lockInFlightRef.current = true; + // Clear stale success/error state from any previous lock so the + // post-confirm effect doesn't fire against leftover data. + resetLockWrite(); + setStep("lock"); + fireLock(); + }, [address, resetLockWrite, fireLock]); + // Auto-advance from approve to lock after approval confirms. We skip // re-opening the confirm dialog because (a) the user already consented // when they clicked Approve USDC, and (b) bouncing through the dialog @@ -586,42 +680,17 @@ function CreateLockInner() { primingTimeoutRef.current = null; setIsPrimingLock(false); lockInFlightRef.current = true; - writeLock({ - address: sablierLockup, - abi: sablierLockupAbi, - functionName: "createWithDurationsLL", - args: [ - { - sender: address as Address, - recipient: address as Address, - totalAmount, - token: usdcAddress, - cancelable: false, - transferable: false, - shape: "RipGuard", - broker: { account: treasury, fee: brokerFee }, - }, - { start: unlockStart, cliff: unlockCliff }, - { cliff: schedule.cliffSeconds, total: schedule.totalSeconds }, - ], - }); + fireLock(); }, 1500); }, [ isApproveConfirmed, step, address, totalAmount, - schedule, - unlockStart, - unlockCliff, - writeLock, + fireLock, refetchAllowance, resetLockWrite, toast, - sablierLockup, - usdcAddress, - treasury, - brokerFee, usdcDecimals, ]); @@ -738,7 +807,13 @@ function CreateLockInner() { const meetsMinimum = depositAmount >= minDeposit; const canProceed = - depositAmount > 0 && meetsMinimum && schedule.totalSeconds > 0 && isConnected; + depositAmount > 0 && + meetsMinimum && + schedule.totalSeconds > 0 && + // Daily reload needs a valid tranche list (well-formed time, every + // tranche amount > 0) before the lock can be reviewed. + (schedule.kind !== "dailyReload" || tranches !== null) && + isConnected; const isValidForm = canProceed && hasEnoughBalance; @@ -901,13 +976,14 @@ function CreateLockInner() {
Reload - {/* Three-tab control: Presets · Custom · Lock until. - Flattened to the top level so the two custom modes are - first-class — no tabs nested inside the builder. */} + {/* Four-tab control: Presets · Custom · Daily reload · + Lock until. Flattened to the top level so the custom + modes are first-class — no tabs nested inside the + builder. */}
+
+ ) : customMode === "dailyReload" ? ( +
+
+ +
+ + +
+
+
+ + setReloadTime(e.target.value)} + className={`w-full bg-background border border-line rounded-lg px-4 py-3 text-sm tabular focus:outline-none focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-2 focus:border-cyan/50 transition-colors ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`} + /> +
+ {schedule.targetMs !== null ? ( +

+ First drop{" "} + + {formatTargetDate(schedule.targetMs)} + + , then every 24 hours — {reloadDays} drops total. + Between drops, the claimable balance is zero. + Enforced on-chain, not just displayed. +

+ ) : ( +

+ Pick a drop time. +

+ )} +

+ The real daily reload. Unlike steady reloads, + nothing drips between drops — the chunk lands all + at once, on schedule. +

+
) : ( <> {/* Total duration */} @@ -1147,10 +1298,12 @@ function CreateLockInner() {
Payout @@ -1160,6 +1313,14 @@ function CreateLockInner() { totalSeconds={schedule.totalSeconds} cliffSeconds={schedule.cliffSeconds} intervalSeconds={selectedPreset === "custom" ? customInterval : 3600} + reload={ + schedule.kind === "dailyReload" + ? { + firstDelaySeconds: schedule.firstDelaySeconds, + count: schedule.reloadCount, + } + : null + } usdcDecimals={usdcDecimals} /> )} @@ -1216,7 +1377,11 @@ function CreateLockInner() { customMode === "lockUntil" && !lockUntilParsed ? "Pick a date at least 1 hour out" - : "Review the lock"} + : selectedPreset === "custom" && + customMode === "dailyReload" && + !tranches + ? "Pick a drop time" + : "Review the lock"} )} @@ -1336,26 +1501,35 @@ function VestingCalculator({ totalSeconds, cliffSeconds, intervalSeconds, + reload = null, usdcDecimals, }: { depositAmount: bigint; totalSeconds: number; cliffSeconds: number; intervalSeconds: number; + /** Tranched daily-reload schedule — exact drops, not synthetic checkpoints. */ + reload?: { firstDelaySeconds: number; count: number } | null; usdcDecimals: number; }) { const vestSeconds = totalSeconds - cliffSeconds; - const totalIntervals = vestSeconds > 0 ? Math.floor(vestSeconds / intervalSeconds) : 0; + const totalIntervals = reload + ? reload.count + : vestSeconds > 0 ? Math.floor(vestSeconds / intervalSeconds) : 0; const perInterval = totalIntervals > 0 ? Number(formatUnits(depositAmount, usdcDecimals)) / totalIntervals : 0; - const intervalLabel = ALL_INTERVALS.find((i) => i.seconds === intervalSeconds)?.label ?? formatDuration(intervalSeconds); + const intervalLabel = reload + ? "day" + : ALL_INTERVALS.find((i) => i.seconds === intervalSeconds)?.label ?? formatDuration(intervalSeconds); const pctPerInterval = totalIntervals > 0 ? (100 / totalIntervals) : 0; // Show first few intervals as a mini schedule const previewCount = Math.min(totalIntervals, 5); const previewRows = Array.from({ length: previewCount }, (_, i) => { - const elapsed = cliffSeconds + (i + 1) * intervalSeconds; + const elapsed = reload + ? reload.firstDelaySeconds + i * RELOAD_INTERVAL_SECONDS + : cliffSeconds + (i + 1) * intervalSeconds; const cumulative = perInterval * (i + 1); return { elapsed, cumulative, payout: perInterval }; }); @@ -1368,7 +1542,8 @@ function VestingCalculator({ ${perInterval.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- every {intervalLabel} ({pctPerInterval.toFixed(1)}%) · {totalIntervals} reloads over {formatDuration(totalSeconds)} + every {intervalLabel} ({pctPerInterval.toFixed(1)}%) · {totalIntervals}{" "} + {reload ? "drops" : "reloads"} over {formatDuration(totalSeconds)}
@@ -1393,7 +1568,7 @@ function VestingCalculator({ ))} {totalIntervals > previewCount && (
- … {totalIntervals - previewCount} more reloads + … {totalIntervals - previewCount} more {reload ? "drops" : "reloads"}
)} @@ -1403,17 +1578,21 @@ function VestingCalculator({ } function TimelinePreview({ + kind, cliffSeconds, totalSeconds, isLumpSum, targetMs, + reloadCount, depositAmount, usdcDecimals, }: { + kind: ScheduleKind; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; targetMs: number | null; + reloadCount: number; depositAmount: bigint; usdcDecimals: number; }) { @@ -1458,7 +1637,13 @@ function TimelinePreview({ {/* Description */}

- {isLockUntil ? ( + {kind === "dailyReload" && targetMs !== null ? ( + <> + {formatUnits(depositAmount, usdcDecimals)} USDC lands in{" "} + {reloadCount} equal daily drops, first on{" "} + {formatTargetDate(targetMs)}. Zero claimable between drops. + + ) : isLockUntil ? ( <> {formatUnits(depositAmount, usdcDecimals)} USDC unlocks in one drop on {formatTargetDate(targetMs)}. @@ -1501,11 +1686,13 @@ function ConfirmDialog({ onCancel, }: { schedule: { + kind: ScheduleKind; label: string; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; targetMs: number | null; + reloadCount: number; }; depositAmount: bigint; fee: bigint; @@ -1605,7 +1792,20 @@ function ConfirmDialog({ Total from wallet {formatUnits(totalAmount, usdcDecimals)} USDC - {schedule.targetMs !== null ? ( + {schedule.kind === "dailyReload" && schedule.targetMs !== null ? ( + <> +

+ First drop + {formatTargetDate(schedule.targetMs)} +
+
+ Drops + + {schedule.reloadCount} × every 24h + +
+ + ) : schedule.targetMs !== null ? (
Unlocks on {formatTargetDate(schedule.targetMs)} @@ -1711,8 +1911,10 @@ function SuccessView({ // directly in render is impure (react-hooks/purity) and would drift the // displayed end date across re-renders. const [now] = useState(() => Math.floor(Date.now() / 1000)); + // For daily reloads targetMs is the FIRST drop, not the end — only + // lock-until's targetMs marks the end of the lock. const endDate = - schedule.targetMs !== null + schedule.isLumpSum && schedule.targetMs !== null ? new Date(schedule.targetMs) : new Date((now + schedule.totalSeconds) * 1000); diff --git a/packages/app/src/config/abis.ts b/packages/app/src/config/abis.ts index a34b452..ade0406 100644 --- a/packages/app/src/config/abis.ts +++ b/packages/app/src/config/abis.ts @@ -144,4 +144,41 @@ export const sablierLockupAbi = [ outputs: [{ name: "streamId", type: "uint256" }], stateMutability: "payable", }, + { + type: "function", + name: "createWithDurationsLT", + inputs: [ + { + name: "params", + type: "tuple", + components: [ + { name: "sender", type: "address" }, + { name: "recipient", type: "address" }, + { name: "totalAmount", type: "uint128" }, + { name: "token", type: "address" }, + { name: "cancelable", type: "bool" }, + { name: "transferable", type: "bool" }, + { name: "shape", type: "string" }, + { + name: "broker", + type: "tuple", + components: [ + { name: "account", type: "address" }, + { name: "fee", type: "uint256" }, + ], + }, + ], + }, + { + name: "tranchesWithDuration", + type: "tuple[]", + components: [ + { name: "amount", type: "uint128" }, + { name: "duration", type: "uint40" }, + ], + }, + ], + outputs: [{ name: "streamId", type: "uint256" }], + stateMutability: "payable", + }, ] as const; diff --git a/packages/app/src/lib/schedule.test.ts b/packages/app/src/lib/schedule.test.ts index d58cb8b..8735f4b 100644 --- a/packages/app/src/lib/schedule.test.ts +++ b/packages/app/src/lib/schedule.test.ts @@ -13,6 +13,11 @@ import { calculatePayoutSchedule, DURATION_OPTIONS, ALL_INTERVALS, + sablierNetDeposit, + secondsUntilTimeOfDay, + computeTranches, + RELOAD_INTERVAL_SECONDS, + MIN_FIRST_RELOAD_SECONDS, } from "./schedule"; // --- formatDuration --- @@ -347,3 +352,115 @@ describe("toDatetimeLocalValue", () => { expect(parsed).toBe(d.getTime()); }); }); + +// --- daily reloads (Lockup Tranched) --- + +const BROKER_FEE = BigInt("5000000000000000"); // 0.5% as UD60x18, matches contracts.ts +const SCALE_1E18 = BigInt("1000000000000000000"); + +describe("sablierNetDeposit", () => { + it("replicates the contract's floor math", () => { + // Sablier computes brokerFeeAmount = floor(totalAmount * fee / 1e18) + // and streams totalAmount - brokerFeeAmount. Any mismatch here reverts + // the LT create, so check against the formula directly. + const samples = [ + BigInt(1_000_000), // 1 USDC + BigInt(5_000_001), // dusty + BigInt(123_456_789), + BigInt("999999999999999999"), + BigInt(7), + ]; + for (const total of samples) { + const expected = total - (total * BROKER_FEE) / SCALE_1E18; + expect(sablierNetDeposit(total, BROKER_FEE)).toBe(expected); + } + }); + + it("passes the whole amount through at zero fee (testnet)", () => { + expect(sablierNetDeposit(BigInt(42_000_000), BigInt(0))).toBe(BigInt(42_000_000)); + }); + + it("round-trips with computeFee within a wei of dust", () => { + // computeFee grosses the fee up on top of the deposit; Sablier then + // takes its cut from the gross. The recipient should get the deposit + // back, allowing at most 1 unit of floor dust. + const deposits = [BigInt(1_000_000), BigInt(333_333_333), BigInt(999_999)]; + for (const deposit of deposits) { + const total = deposit + computeFee(deposit, BROKER_FEE); + const net = sablierNetDeposit(total, BROKER_FEE); + const dust = net - deposit; + expect(dust >= BigInt(-1) && dust <= BigInt(1)).toBe(true); + } + }); +}); + +describe("secondsUntilTimeOfDay", () => { + // Fixed reference: local noon. + const noon = new Date(2026, 8, 1, 12, 0, 0, 0).getTime(); + + it("targets later today when the time is ahead", () => { + expect(secondsUntilTimeOfDay("18:00", noon)).toBe(6 * 3600); + }); + + it("rolls to tomorrow when the time already passed", () => { + expect(secondsUntilTimeOfDay("09:00", noon)).toBe(21 * 3600); + }); + + it("rolls to tomorrow when the time is too soon", () => { + const in5min = secondsUntilTimeOfDay("12:05", noon); + expect(in5min).toBe(5 * 60 + RELOAD_INTERVAL_SECONDS); + // Boundary: exactly at the minimum stays today. + const atMin = secondsUntilTimeOfDay("12:15", noon); + expect(atMin).toBe(MIN_FIRST_RELOAD_SECONDS); + }); + + it("rejects malformed input", () => { + expect(secondsUntilTimeOfDay("", noon)).toBeNull(); + expect(secondsUntilTimeOfDay("25:00", noon)).toBeNull(); + expect(secondsUntilTimeOfDay("12:60", noon)).toBeNull(); + expect(secondsUntilTimeOfDay("noonish", noon)).toBeNull(); + }); +}); + +describe("computeTranches", () => { + it("splits evenly and folds dust into the last tranche", () => { + const tranches = computeTranches(BigInt(10_000_001), 3600, RELOAD_INTERVAL_SECONDS, 3); + expect(tranches).not.toBeNull(); + expect(tranches!.length).toBe(3); + expect(tranches![0].amount).toBe(BigInt(3_333_333)); + expect(tranches![1].amount).toBe(BigInt(3_333_333)); + expect(tranches![2].amount).toBe(BigInt(3_333_335)); + const sum = tranches!.reduce((a, t) => a + t.amount, BigInt(0)); + expect(sum).toBe(BigInt(10_000_001)); + }); + + it("uses the first delay for tranche 0 and the interval after", () => { + const tranches = computeTranches(BigInt(1_000_000), 7200, RELOAD_INTERVAL_SECONDS, 4)!; + expect(tranches.map((t) => t.duration)).toEqual([ + 7200, + RELOAD_INTERVAL_SECONDS, + RELOAD_INTERVAL_SECONDS, + RELOAD_INTERVAL_SECONDS, + ]); + }); + + it("sums to the net deposit exactly across awkward amounts", () => { + const amounts = [BigInt(1_000_000), BigInt(999_999), BigInt(123_456_789), BigInt(7_000_003)]; + for (const net of amounts) { + for (const count of [2, 7, 30, 365]) { + const tranches = computeTranches(net, 3600, RELOAD_INTERVAL_SECONDS, count)!; + expect(tranches.length).toBe(count); + const sum = tranches.reduce((a, t) => a + t.amount, BigInt(0)); + expect(sum).toBe(net); + // Sablier rejects zero-amount tranches. + for (const t of tranches) expect(t.amount > BigInt(0)).toBe(true); + } + } + }); + + it("returns null when a tranche would be zero or inputs are invalid", () => { + expect(computeTranches(BigInt(2), 3600, RELOAD_INTERVAL_SECONDS, 3)).toBeNull(); + expect(computeTranches(BigInt(1_000_000), 0, RELOAD_INTERVAL_SECONDS, 3)).toBeNull(); + expect(computeTranches(BigInt(1_000_000), 3600, RELOAD_INTERVAL_SECONDS, 0)).toBeNull(); + }); +}); diff --git a/packages/app/src/lib/schedule.ts b/packages/app/src/lib/schedule.ts index 565016d..592337d 100644 --- a/packages/app/src/lib/schedule.ts +++ b/packages/app/src/lib/schedule.ts @@ -122,6 +122,81 @@ export function toDatetimeLocalValue(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +// ---------------------------------------------------------------------------- +// Daily reloads (Lockup Tranched) +// ---------------------------------------------------------------------------- + +/** One Sablier tranche: `amount` unlocks `duration` seconds after the previous one. */ +export interface TrancheWithDuration { + amount: bigint; + duration: number; +} + +export const RELOAD_INTERVAL_SECONDS = 86400; + +/** + * If the picked time-of-day is closer than this, the first reload rolls to + * tomorrow — a drop that lands moments after signing isn't a lock. + */ +export const MIN_FIRST_RELOAD_SECONDS = 900; + +/** Sablier caps tranche count at 500; a year of daily drops stays under it. */ +export const MAX_RELOAD_DAYS = 365; +export const MIN_RELOAD_DAYS = 2; + +export const RELOAD_DAY_OPTIONS = [2, 3, 5, 7, 10, 14, 21, 30, 60, 90] as const; + +/** + * The net amount Sablier will actually stream after taking the broker fee + * out of `totalAmount`. Replicates the contract's UD60x18 floor math — + * tranche amounts must sum to exactly this or createWithDurationsLT reverts. + */ +export function sablierNetDeposit(totalAmount: bigint, brokerFee: bigint): bigint { + return totalAmount - (totalAmount * brokerFee) / SCALE; +} + +/** + * Seconds from `nowMs` until the next local occurrence of "HH:MM". Rolls to + * the following day when the next occurrence is sooner than + * MIN_FIRST_RELOAD_SECONDS. Returns null for a malformed input. + */ +export function secondsUntilTimeOfDay(time: string, nowMs: number): number | null { + const m = /^(\d{2}):(\d{2})$/.exec(time); + if (!m) return null; + const hours = Number(m[1]); + const minutes = Number(m[2]); + if (hours > 23 || minutes > 59) return null; + const target = new Date(nowMs); + target.setHours(hours, minutes, 0, 0); + let delta = Math.floor((target.getTime() - nowMs) / 1000); + if (delta < MIN_FIRST_RELOAD_SECONDS) delta += RELOAD_INTERVAL_SECONDS; + return delta; +} + +/** + * Split `netDeposit` into `count` tranches: the first lands after + * `firstDelaySeconds`, the rest every `intervalSeconds`. Integer dust from + * the division is folded into the final tranche so the sum is exact. + * Returns null when the inputs can't form a valid Sablier tranche list + * (every tranche amount must be > 0). + */ +export function computeTranches( + netDeposit: bigint, + firstDelaySeconds: number, + intervalSeconds: number, + count: number, +): TrancheWithDuration[] | null { + if (count < 1 || firstDelaySeconds < 1 || intervalSeconds < 1) return null; + const n = BigInt(count); + if (netDeposit < n) return null; + const per = netDeposit / n; + const last = netDeposit - per * (n - BigInt(1)); + return Array.from({ length: count }, (_, i) => ({ + amount: i === count - 1 ? last : per, + duration: i === 0 ? firstDelaySeconds : intervalSeconds, + })); +} + /** Calculate payout schedule for the vesting calculator display */ export function calculatePayoutSchedule( depositAmount: number, From f982c644454d660bb788268a9d9fa0da67e977a6 Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Tue, 1 Sep 2026 19:09:32 -0500 Subject: [PATCH 2/4] Rework strict payouts as a toggle on existing schedules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: instead of a separate daily-reload tab with its own inputs, strict is now a payout-style toggle (Steady drip / Strict payouts) that applies to whatever preset or custom schedule is picked. The tranche list derives from the schedule's own cliff/window/cadence — the checkpoints the drip calculator was already displaying become the enforced unlock times, first payout at cliff + one interval. Presets now carry intervalSeconds, which also fixes the calculator previously assuming hourly cadence for the daily presets. Lock-until hides the toggle (already a single strict drop); Panic Lock falls back to drip with a note (no cadence to enforce). Sablier's tranche cap — verified empirically at exactly 500 (501 reverts) — is enforced at the form level with a clear error for over-cap combos like hourly x 30d. --- packages/app/src/app/create/page.tsx | 418 ++++++++++++-------------- packages/app/src/config/contracts.ts | 6 + packages/app/src/lib/schedule.test.ts | 78 +++-- packages/app/src/lib/schedule.ts | 54 ++-- 4 files changed, 266 insertions(+), 290 deletions(-) diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index bb305d7..7d99346 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -41,9 +41,8 @@ import { toDatetimeLocalValue, computeFee, computeMaxDeposit, - RELOAD_INTERVAL_SECONDS, - RELOAD_DAY_OPTIONS, - secondsUntilTimeOfDay, + strictDropCount, + MAX_TRANCHE_COUNT, sablierNetDeposit, computeTranches, } from "@/lib/schedule"; @@ -78,11 +77,13 @@ function parseStreamIdFromReceipt( } type Step = "schedule" | "confirm" | "approve" | "lock" | "success"; -type CustomMode = "reloads" | "dailyReload" | "lockUntil"; -// How the lock is written on-chain: linear/lockUntil use createWithDurationsLL, -// dailyReload uses createWithDurationsLT (discrete tranches — nothing claimable -// between drops). -type ScheduleKind = "linear" | "lockUntil" | "dailyReload"; +type CustomMode = "reloads" | "lockUntil"; +// How the payouts unlock on-chain. "linear" and "lockUntil" write +// createWithDurationsLL; "strict" writes createWithDurationsLT — discrete +// tranches, nothing claimable between payouts. +type ScheduleKind = "linear" | "lockUntil" | "strict"; +// The user-facing toggle: steady per-second drip vs strict on-chain chunks. +type PayoutStyle = "drip" | "strict"; // Sensible default for the lock-until picker: 7 days from "now" at the // minute the page rendered. Captured once per mount so the input doesn't @@ -256,10 +257,9 @@ function CreateLockInner() { const [customInterval, setCustomInterval] = useState(3600); // 1hr default claim interval (display only) const [customMode, setCustomMode] = useState("reloads"); const [lockUntilInput, setLockUntilInput] = useState(defaultLockUntilValue); - // Daily reload (tranched) inputs: how many daily drops, and what local - // time of day they land. - const [reloadDays, setReloadDays] = useState(7); - const [reloadTime, setReloadTime] = useState("18:00"); + // Drip vs strict applies to presets and custom reloads alike; lock-until + // is already a single strict drop, so the toggle doesn't show there. + const [payoutStyle, setPayoutStyle] = useState("drip"); // Tick "now" once every 30s so the lock-until duration preview // ("18d 3h from now") doesn't go stale while the form is open. We don't // need per-second precision — the user is picking a target in days/hours. @@ -321,6 +321,12 @@ function CreateLockInner() { [selectedPreset, customMode, lockUntilInput, nowMs], ); + // The payout cadence of the picked schedule — presets carry their own, + // custom uses the cadence chips. Drives both the calculator display and + // strict-mode tranche derivation, so what's shown is what's enforced. + const intervalSeconds = + selectedPreset === "custom" ? customInterval : PRESETS[selectedPreset].intervalSeconds; + // Derived schedule values const schedule = useMemo<{ kind: ScheduleKind; @@ -329,91 +335,86 @@ function CreateLockInner() { isLumpSum: boolean; label: string; targetMs: number | null; - /** dailyReload only: seconds until the first drop, and drop count. */ - firstDelaySeconds: number; - reloadCount: number; + /** strict only: payout cadence and drop count. */ + intervalSeconds: number; + dropCount: number; }>(() => { - if (selectedPreset !== "custom") { - const p = PRESETS[selectedPreset]; - return { - kind: "linear", - cliffSeconds: p.cliffSeconds, - totalSeconds: p.totalSeconds, - isLumpSum: p.isLumpSum, - label: p.label, - targetMs: null, - firstDelaySeconds: 0, - reloadCount: 0, - }; - } - if (customMode === "lockUntil") { - // Picker not yet valid → totalSeconds = 0 keeps `canProceed` false - // so the action button stays disabled until they pick a real date. - if (!lockUntilParsed) { + const base = (() => { + if (selectedPreset !== "custom") { + const p = PRESETS[selectedPreset]; return { - kind: "lockUntil", - cliffSeconds: 0, - totalSeconds: 0, - isLumpSum: true, - label: "Lock until …", + kind: "linear" as ScheduleKind, + cliffSeconds: p.cliffSeconds, + totalSeconds: p.totalSeconds, + isLumpSum: p.isLumpSum, + label: p.label, targetMs: null, - firstDelaySeconds: 0, - reloadCount: 0, + intervalSeconds: 0, + dropCount: 0, }; } - const total = lockUntilParsed.durationSeconds; - // Sablier requires cliff < total strictly. The one-second gap is - // negligible vs the unlock date; the user sees a clean lump-sum. - return { - kind: "lockUntil", - cliffSeconds: total - 1, - totalSeconds: total, - isLumpSum: true, - label: `Lock until ${formatTargetDate(lockUntilParsed.targetMs)}`, - targetMs: lockUntilParsed.targetMs, - firstDelaySeconds: 0, - reloadCount: 0, - }; - } - if (customMode === "dailyReload") { - const firstDelay = secondsUntilTimeOfDay(reloadTime, nowMs); - // Malformed time input → totalSeconds = 0 keeps `canProceed` false. - if (firstDelay === null) { + if (customMode === "lockUntil") { + // Picker not yet valid → totalSeconds = 0 keeps `canProceed` false + // so the action button stays disabled until they pick a real date. + if (!lockUntilParsed) { + return { + kind: "lockUntil" as ScheduleKind, + cliffSeconds: 0, + totalSeconds: 0, + isLumpSum: true, + label: "Lock until …", + targetMs: null, + intervalSeconds: 0, + dropCount: 0, + }; + } + const total = lockUntilParsed.durationSeconds; + // Sablier requires cliff < total strictly. The one-second gap is + // negligible vs the unlock date; the user sees a clean lump-sum. return { - kind: "dailyReload", - cliffSeconds: 0, - totalSeconds: 0, - isLumpSum: false, - label: "Daily Reload", - targetMs: null, - firstDelaySeconds: 0, - reloadCount: 0, + kind: "lockUntil" as ScheduleKind, + cliffSeconds: total - 1, + totalSeconds: total, + isLumpSum: true, + label: `Lock until ${formatTargetDate(lockUntilParsed.targetMs)}`, + targetMs: lockUntilParsed.targetMs, + intervalSeconds: 0, + dropCount: 0, }; } + const cliffEqualsTotal = customCliff === customTotal && customCliff > 0; return { - kind: "dailyReload", - cliffSeconds: 0, - totalSeconds: firstDelay + (reloadDays - 1) * RELOAD_INTERVAL_SECONDS, - isLumpSum: false, - label: `Daily Reload × ${reloadDays}`, - targetMs: nowMs + firstDelay * 1000, - firstDelaySeconds: firstDelay, - reloadCount: reloadDays, + kind: "linear" as ScheduleKind, + cliffSeconds: customCliff, + // Sablier requires cliff < total strictly; add 1s so linear stream amount is non-zero + totalSeconds: cliffEqualsTotal ? customTotal + 1 : customTotal, + isLumpSum: false, // Never set unlockCliff=totalAmount — Sablier rejects zero linear stream amount + label: "Custom Reloads", + targetMs: null, + intervalSeconds: 0, + dropCount: 0, }; + })(); + + // Strict payouts: same schedule, enforced as discrete tranches. The + // drop count is the exact checkpoint list the drip calculator shows. + if (payoutStyle === "strict" && base.kind === "linear" && !base.isLumpSum) { + const count = strictDropCount(base.totalSeconds, base.cliffSeconds, intervalSeconds); + if (count >= 1) { + return { + ...base, + kind: "strict", + // The stream ends at the last drop — floor the window to the + // cadence rather than leaving a partial tail. + totalSeconds: base.cliffSeconds + count * intervalSeconds, + label: `${base.label} · Strict`, + intervalSeconds, + dropCount: count, + }; + } } - const cliffEqualsTotal = customCliff === customTotal && customCliff > 0; - return { - kind: "linear", - cliffSeconds: customCliff, - // Sablier requires cliff < total strictly; add 1s so linear stream amount is non-zero - totalSeconds: cliffEqualsTotal ? customTotal + 1 : customTotal, - isLumpSum: false, // Never set unlockCliff=totalAmount — Sablier rejects zero linear stream amount - label: "Custom Reloads", - targetMs: null, - firstDelaySeconds: 0, - reloadCount: 0, - }; - }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed, reloadDays, reloadTime, nowMs]); + return base; + }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed, payoutStyle, intervalSeconds]); // Amount parsing const depositAmount = useMemo(() => { @@ -433,17 +434,19 @@ function CreateLockInner() { const unlockStart = BigInt(0); const unlockCliff = schedule.isLumpSum ? depositAmount : BigInt(0); - // Tranche list for daily-reload locks. Amounts must sum to exactly the + // Tranche list for strict-payout locks. Amounts must sum to exactly the // net deposit Sablier computes after the broker cut, or the create reverts - // on-chain — sablierNetDeposit replicates that floor math. + // on-chain — sablierNetDeposit replicates that floor math. The first drop + // lands at cliff + one interval, matching the drip calculator's first + // checkpoint. const tranches = useMemo( () => - schedule.kind === "dailyReload" && totalAmount > BigInt(0) + schedule.kind === "strict" && totalAmount > BigInt(0) ? computeTranches( sablierNetDeposit(totalAmount, brokerFee), - schedule.firstDelaySeconds, - RELOAD_INTERVAL_SECONDS, - schedule.reloadCount, + schedule.cliffSeconds + schedule.intervalSeconds, + schedule.intervalSeconds, + schedule.dropCount, ) : null, [schedule, totalAmount, brokerFee], @@ -508,8 +511,7 @@ function CreateLockInner() { setCustomTotal(3600); setCustomMode("reloads"); setLockUntilInput(defaultLockUntilValue()); - setReloadDays(7); - setReloadTime("18:00"); + setPayoutStyle("drip"); setAmountInput(""); setStep("schedule"); setConfirmed(false); @@ -586,7 +588,7 @@ function CreateLockInner() { shape: "RipGuard", broker: { account: treasury, fee: brokerFee }, }; - if (schedule.kind === "dailyReload") { + if (schedule.kind === "strict") { // canProceed already blocks invalid tranche lists; this guard is for // the async priming path where state may have shifted. if (!tranches) return; @@ -810,9 +812,9 @@ function CreateLockInner() { depositAmount > 0 && meetsMinimum && schedule.totalSeconds > 0 && - // Daily reload needs a valid tranche list (well-formed time, every - // tranche amount > 0) before the lock can be reviewed. - (schedule.kind !== "dailyReload" || tranches !== null) && + // Strict payouts need a valid tranche list (count within Sablier's + // cap, every tranche amount > 0) before the lock can be reviewed. + (schedule.kind !== "strict" || tranches !== null) && isConnected; const isValidForm = canProceed && hasEnoughBalance; @@ -976,14 +978,13 @@ function CreateLockInner() {
Reload - {/* Four-tab control: Presets · Custom · Daily reload · - Lock until. Flattened to the top level so the custom - modes are first-class — no tabs nested inside the - builder. */} + {/* Three-tab control: Presets · Custom · Lock until. + Flattened to the top level so the two custom modes are + first-class — no tabs nested inside the builder. */}
-
+ {/* Payout style — steady drip vs strict on-chain chunks. + Applies to presets and custom reloads alike; hidden for + lock-until, which is already a single strict drop. */} + {!(selectedPreset === "custom" && customMode === "lockUntil") && ( +
+
+ {( + [ + { key: "drip", label: "Steady drip" }, + { key: "strict", label: "Strict payouts" }, + ] as const + ).map((opt) => { + const active = payoutStyle === opt.key; + return ( + + ); + })} +
+

+ {payoutStyle === "drip" + ? "Unlocks by the second. Claim whatever's built up, whenever you want." + : "Nothing between payouts. Each chunk lands on schedule — enforced on-chain, not just displayed."} +

+ {payoutStyle === "strict" && + schedule.kind === "strict" && + schedule.dropCount > MAX_TRANCHE_COUNT && ( +

+ Strict locks cap at {MAX_TRANCHE_COUNT} payouts — + this schedule needs {schedule.dropCount}. Pick a + bigger cadence or a shorter window. +

+ )} + {payoutStyle === "strict" && schedule.kind === "linear" && ( +

+ This schedule has a single unlock, so strict + doesn't change it. +

+ )} +
+ )} + {/* Custom schedule builder — "Custom" (steady reloads) vs "Lock until" is chosen by the top-level tabs above, so there's no nested toggle here. */} @@ -1101,63 +1146,6 @@ function CreateLockInner() { Cadence and waiting period don't apply.

- ) : customMode === "dailyReload" ? ( -
-
- -
- - -
-
-
- - setReloadTime(e.target.value)} - className={`w-full bg-background border border-line rounded-lg px-4 py-3 text-sm tabular focus:outline-none focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-2 focus:border-cyan/50 transition-colors ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`} - /> -
- {schedule.targetMs !== null ? ( -

- First drop{" "} - - {formatTargetDate(schedule.targetMs)} - - , then every 24 hours — {reloadDays} drops total. - Between drops, the claimable balance is zero. - Enforced on-chain, not just displayed. -

- ) : ( -

- Pick a drop time. -

- )} -

- The real daily reload. Unlike steady reloads, - nothing drips between drops — the chunk lands all - at once, on schedule. -

-
) : ( <> {/* Total duration */} @@ -1303,7 +1291,7 @@ function CreateLockInner() { totalSeconds={schedule.totalSeconds} isLumpSum={schedule.isLumpSum} targetMs={schedule.targetMs} - reloadCount={schedule.reloadCount} + dropCount={schedule.dropCount} depositAmount={depositAmount} usdcDecimals={usdcDecimals} /> @@ -1312,15 +1300,8 @@ function CreateLockInner() { depositAmount={depositAmount} totalSeconds={schedule.totalSeconds} cliffSeconds={schedule.cliffSeconds} - intervalSeconds={selectedPreset === "custom" ? customInterval : 3600} - reload={ - schedule.kind === "dailyReload" - ? { - firstDelaySeconds: schedule.firstDelaySeconds, - count: schedule.reloadCount, - } - : null - } + intervalSeconds={intervalSeconds} + strict={schedule.kind === "strict"} usdcDecimals={usdcDecimals} /> )} @@ -1377,10 +1358,8 @@ function CreateLockInner() { customMode === "lockUntil" && !lockUntilParsed ? "Pick a date at least 1 hour out" - : selectedPreset === "custom" && - customMode === "dailyReload" && - !tranches - ? "Pick a drop time" + : schedule.kind === "strict" && !tranches + ? "Too many payouts for one lock" : "Review the lock"} )} @@ -1501,35 +1480,29 @@ function VestingCalculator({ totalSeconds, cliffSeconds, intervalSeconds, - reload = null, + strict = false, usdcDecimals, }: { depositAmount: bigint; totalSeconds: number; cliffSeconds: number; intervalSeconds: number; - /** Tranched daily-reload schedule — exact drops, not synthetic checkpoints. */ - reload?: { firstDelaySeconds: number; count: number } | null; + /** Strict payouts: the rows below are enforced tranches, not synthetic checkpoints. */ + strict?: boolean; usdcDecimals: number; }) { const vestSeconds = totalSeconds - cliffSeconds; - const totalIntervals = reload - ? reload.count - : vestSeconds > 0 ? Math.floor(vestSeconds / intervalSeconds) : 0; + const totalIntervals = vestSeconds > 0 ? Math.floor(vestSeconds / intervalSeconds) : 0; const perInterval = totalIntervals > 0 ? Number(formatUnits(depositAmount, usdcDecimals)) / totalIntervals : 0; - const intervalLabel = reload - ? "day" - : ALL_INTERVALS.find((i) => i.seconds === intervalSeconds)?.label ?? formatDuration(intervalSeconds); + const intervalLabel = ALL_INTERVALS.find((i) => i.seconds === intervalSeconds)?.label ?? formatDuration(intervalSeconds); const pctPerInterval = totalIntervals > 0 ? (100 / totalIntervals) : 0; // Show first few intervals as a mini schedule const previewCount = Math.min(totalIntervals, 5); const previewRows = Array.from({ length: previewCount }, (_, i) => { - const elapsed = reload - ? reload.firstDelaySeconds + i * RELOAD_INTERVAL_SECONDS - : cliffSeconds + (i + 1) * intervalSeconds; + const elapsed = cliffSeconds + (i + 1) * intervalSeconds; const cumulative = perInterval * (i + 1); return { elapsed, cumulative, payout: perInterval }; }); @@ -1542,8 +1515,7 @@ function VestingCalculator({ ${perInterval.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
- every {intervalLabel} ({pctPerInterval.toFixed(1)}%) · {totalIntervals}{" "} - {reload ? "drops" : "reloads"} over {formatDuration(totalSeconds)} + every {intervalLabel} ({pctPerInterval.toFixed(1)}%) · {totalIntervals} {strict ? "strict payouts" : "reloads"} over {formatDuration(totalSeconds)}
@@ -1568,7 +1540,7 @@ function VestingCalculator({ ))} {totalIntervals > previewCount && (
- … {totalIntervals - previewCount} more {reload ? "drops" : "reloads"} + … {totalIntervals - previewCount} more {strict ? "payouts" : "reloads"}
)} @@ -1583,7 +1555,7 @@ function TimelinePreview({ totalSeconds, isLumpSum, targetMs, - reloadCount, + dropCount, depositAmount, usdcDecimals, }: { @@ -1592,7 +1564,7 @@ function TimelinePreview({ totalSeconds: number; isLumpSum: boolean; targetMs: number | null; - reloadCount: number; + dropCount: number; depositAmount: bigint; usdcDecimals: number; }) { @@ -1637,11 +1609,12 @@ function TimelinePreview({ {/* Description */}

- {kind === "dailyReload" && targetMs !== null ? ( + {kind === "strict" ? ( <> {formatUnits(depositAmount, usdcDecimals)} USDC lands in{" "} - {reloadCount} equal daily drops, first on{" "} - {formatTargetDate(targetMs)}. Zero claimable between drops. + {dropCount} equal payouts{cliffSeconds > 0 ? ( + <> after a {formatDuration(cliffSeconds)} wait + ) : null}. Zero claimable between payouts. ) : isLockUntil ? ( <> @@ -1692,7 +1665,8 @@ function ConfirmDialog({ totalSeconds: number; isLumpSum: boolean; targetMs: number | null; - reloadCount: number; + intervalSeconds: number; + dropCount: number; }; depositAmount: bigint; fee: bigint; @@ -1792,16 +1766,18 @@ function ConfirmDialog({ Total from wallet {formatUnits(totalAmount, usdcDecimals)} USDC - {schedule.kind === "dailyReload" && schedule.targetMs !== null ? ( + {schedule.kind === "strict" ? ( <>

- First drop - {formatTargetDate(schedule.targetMs)} + Payouts + + {schedule.dropCount} × every {formatDuration(schedule.intervalSeconds)} +
- Drops + First payout in - {schedule.reloadCount} × every 24h + {formatDuration(schedule.cliffSeconds + schedule.intervalSeconds)}
@@ -1894,11 +1870,14 @@ function SuccessView({ streamId: bigint; depositAmount: bigint; schedule: { + kind: ScheduleKind; label: string; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; targetMs: number | null; + intervalSeconds: number; + dropCount: number; }; usdcDecimals: number; sablierAddress: Address; @@ -1922,6 +1901,9 @@ function SuccessView({ if (schedule.targetMs !== null) { return formatTargetDate(schedule.targetMs); } + if (schedule.kind === "strict") { + return `First payout in ${formatDuration(schedule.cliffSeconds + schedule.intervalSeconds)}`; + } if (schedule.isLumpSum) { const d = Math.floor(schedule.totalSeconds / 86400); return `${d}d`; diff --git a/packages/app/src/config/contracts.ts b/packages/app/src/config/contracts.ts index 1403325..39fb7dc 100644 --- a/packages/app/src/config/contracts.ts +++ b/packages/app/src/config/contracts.ts @@ -37,6 +37,7 @@ export const PRESETS = { label: "Hourly Payouts (24h)", description: "Reload every hour for 24 hours", cliffSeconds: 0, + intervalSeconds: 60 * 60, totalSeconds: 24 * 60 * 60, isLumpSum: false, }, @@ -44,6 +45,7 @@ export const PRESETS = { label: "Hourly Payouts (3d)", description: "Reload every hour for 3 days", cliffSeconds: 0, + intervalSeconds: 60 * 60, totalSeconds: 3 * 24 * 60 * 60, isLumpSum: false, }, @@ -51,6 +53,7 @@ export const PRESETS = { label: "Hourly Payouts (1w)", description: "Reload every hour for 7 days", cliffSeconds: 0, + intervalSeconds: 60 * 60, totalSeconds: 7 * 24 * 60 * 60, isLumpSum: false, }, @@ -58,6 +61,7 @@ export const PRESETS = { label: "Daily Payouts (1w)", description: "Reload once a day for 7 days", cliffSeconds: 0, + intervalSeconds: 24 * 60 * 60, totalSeconds: 7 * 24 * 60 * 60, isLumpSum: false, }, @@ -65,6 +69,7 @@ export const PRESETS = { label: "Panic Lock (24h)", description: "Lock everything for 24 hours", cliffSeconds: 24 * 60 * 60, + intervalSeconds: 60 * 60, totalSeconds: 24 * 60 * 60 + 1, // cliff < total required by Sablier isLumpSum: false, }, @@ -72,6 +77,7 @@ export const PRESETS = { label: "Panic Lock + Daily Payouts", description: "1 day lock, then daily reloads for 7 days", cliffSeconds: 24 * 60 * 60, + intervalSeconds: 24 * 60 * 60, totalSeconds: 8 * 24 * 60 * 60, // 1d cliff + 7d vest isLumpSum: false, }, diff --git a/packages/app/src/lib/schedule.test.ts b/packages/app/src/lib/schedule.test.ts index 8735f4b..459afa8 100644 --- a/packages/app/src/lib/schedule.test.ts +++ b/packages/app/src/lib/schedule.test.ts @@ -14,10 +14,9 @@ import { DURATION_OPTIONS, ALL_INTERVALS, sablierNetDeposit, - secondsUntilTimeOfDay, computeTranches, - RELOAD_INTERVAL_SECONDS, - MIN_FIRST_RELOAD_SECONDS, + strictDropCount, + MAX_TRANCHE_COUNT, } from "./schedule"; // --- formatDuration --- @@ -353,7 +352,7 @@ describe("toDatetimeLocalValue", () => { }); }); -// --- daily reloads (Lockup Tranched) --- +// --- strict payouts (Lockup Tranched) --- const BROKER_FEE = BigInt("5000000000000000"); // 0.5% as UD60x18, matches contracts.ts const SCALE_1E18 = BigInt("1000000000000000000"); @@ -394,37 +393,9 @@ describe("sablierNetDeposit", () => { }); }); -describe("secondsUntilTimeOfDay", () => { - // Fixed reference: local noon. - const noon = new Date(2026, 8, 1, 12, 0, 0, 0).getTime(); - - it("targets later today when the time is ahead", () => { - expect(secondsUntilTimeOfDay("18:00", noon)).toBe(6 * 3600); - }); - - it("rolls to tomorrow when the time already passed", () => { - expect(secondsUntilTimeOfDay("09:00", noon)).toBe(21 * 3600); - }); - - it("rolls to tomorrow when the time is too soon", () => { - const in5min = secondsUntilTimeOfDay("12:05", noon); - expect(in5min).toBe(5 * 60 + RELOAD_INTERVAL_SECONDS); - // Boundary: exactly at the minimum stays today. - const atMin = secondsUntilTimeOfDay("12:15", noon); - expect(atMin).toBe(MIN_FIRST_RELOAD_SECONDS); - }); - - it("rejects malformed input", () => { - expect(secondsUntilTimeOfDay("", noon)).toBeNull(); - expect(secondsUntilTimeOfDay("25:00", noon)).toBeNull(); - expect(secondsUntilTimeOfDay("12:60", noon)).toBeNull(); - expect(secondsUntilTimeOfDay("noonish", noon)).toBeNull(); - }); -}); - describe("computeTranches", () => { it("splits evenly and folds dust into the last tranche", () => { - const tranches = computeTranches(BigInt(10_000_001), 3600, RELOAD_INTERVAL_SECONDS, 3); + const tranches = computeTranches(BigInt(10_000_001), 3600, 86400, 3); expect(tranches).not.toBeNull(); expect(tranches!.length).toBe(3); expect(tranches![0].amount).toBe(BigInt(3_333_333)); @@ -435,12 +406,12 @@ describe("computeTranches", () => { }); it("uses the first delay for tranche 0 and the interval after", () => { - const tranches = computeTranches(BigInt(1_000_000), 7200, RELOAD_INTERVAL_SECONDS, 4)!; + const tranches = computeTranches(BigInt(1_000_000), 7200, 86400, 4)!; expect(tranches.map((t) => t.duration)).toEqual([ 7200, - RELOAD_INTERVAL_SECONDS, - RELOAD_INTERVAL_SECONDS, - RELOAD_INTERVAL_SECONDS, + 86400, + 86400, + 86400, ]); }); @@ -448,7 +419,7 @@ describe("computeTranches", () => { const amounts = [BigInt(1_000_000), BigInt(999_999), BigInt(123_456_789), BigInt(7_000_003)]; for (const net of amounts) { for (const count of [2, 7, 30, 365]) { - const tranches = computeTranches(net, 3600, RELOAD_INTERVAL_SECONDS, count)!; + const tranches = computeTranches(net, 3600, 86400, count)!; expect(tranches.length).toBe(count); const sum = tranches.reduce((a, t) => a + t.amount, BigInt(0)); expect(sum).toBe(net); @@ -459,8 +430,33 @@ describe("computeTranches", () => { }); it("returns null when a tranche would be zero or inputs are invalid", () => { - expect(computeTranches(BigInt(2), 3600, RELOAD_INTERVAL_SECONDS, 3)).toBeNull(); - expect(computeTranches(BigInt(1_000_000), 0, RELOAD_INTERVAL_SECONDS, 3)).toBeNull(); - expect(computeTranches(BigInt(1_000_000), 3600, RELOAD_INTERVAL_SECONDS, 0)).toBeNull(); + expect(computeTranches(BigInt(2), 3600, 86400, 3)).toBeNull(); + expect(computeTranches(BigInt(1_000_000), 0, 86400, 3)).toBeNull(); + expect(computeTranches(BigInt(1_000_000), 3600, 86400, 0)).toBeNull(); + }); + + it("enforces the on-chain tranche cap (500 creates, 501 reverts)", () => { + const big = BigInt(1_000_000_000); + expect(computeTranches(big, 3600, 3600, MAX_TRANCHE_COUNT)).not.toBeNull(); + expect(computeTranches(big, 3600, 3600, MAX_TRANCHE_COUNT + 1)).toBeNull(); + }); +}); + +describe("strictDropCount", () => { + it("matches the payout calculator's checkpoint math", () => { + // hourly over 24h + expect(strictDropCount(86400, 0, 3600)).toBe(24); + // daily over a week + expect(strictDropCount(7 * 86400, 0, 86400)).toBe(7); + // 1d cliff then daily for 7d + expect(strictDropCount(8 * 86400, 86400, 86400)).toBe(7); + // partial tail floors away + expect(strictDropCount(90000, 0, 86400)).toBe(1); + }); + + it("yields zero when there is no vest window or cadence", () => { + // Panic Lock: cliff ~= total, nothing to tranche + expect(strictDropCount(86401, 86400, 3600)).toBe(0); + expect(strictDropCount(86400, 0, 0)).toBe(0); }); }); diff --git a/packages/app/src/lib/schedule.ts b/packages/app/src/lib/schedule.ts index 592337d..7ce971a 100644 --- a/packages/app/src/lib/schedule.ts +++ b/packages/app/src/lib/schedule.ts @@ -123,7 +123,7 @@ export function toDatetimeLocalValue(d: Date): string { } // ---------------------------------------------------------------------------- -// Daily reloads (Lockup Tranched) +// Strict payouts (Lockup Tranched) // ---------------------------------------------------------------------------- /** One Sablier tranche: `amount` unlocks `duration` seconds after the previous one. */ @@ -132,19 +132,28 @@ export interface TrancheWithDuration { duration: number; } -export const RELOAD_INTERVAL_SECONDS = 86400; - /** - * If the picked time-of-day is closer than this, the first reload rolls to - * tomorrow — a drop that lands moments after signing isn't a lock. + * Sablier's on-chain tranche-count cap. Verified empirically against the + * deployed v2.0 contract: 500 creates, 501 reverts. Hourly cadence over a + * long window can exceed this — the form blocks those combinations. */ -export const MIN_FIRST_RELOAD_SECONDS = 900; - -/** Sablier caps tranche count at 500; a year of daily drops stays under it. */ -export const MAX_RELOAD_DAYS = 365; -export const MIN_RELOAD_DAYS = 2; +export const MAX_TRANCHE_COUNT = 500; -export const RELOAD_DAY_OPTIONS = [2, 3, 5, 7, 10, 14, 21, 30, 60, 90] as const; +/** + * How many strict payouts a (cliff, total, interval) schedule yields — + * the same floor math the payout calculator displays, so strict mode + * enforces exactly the checkpoints the drip UI was already showing. + */ +export function strictDropCount( + totalSeconds: number, + cliffSeconds: number, + intervalSeconds: number, +): number { + const vestSeconds = totalSeconds - cliffSeconds; + return vestSeconds > 0 && intervalSeconds > 0 + ? Math.floor(vestSeconds / intervalSeconds) + : 0; +} /** * The net amount Sablier will actually stream after taking the broker fee @@ -155,30 +164,12 @@ export function sablierNetDeposit(totalAmount: bigint, brokerFee: bigint): bigin return totalAmount - (totalAmount * brokerFee) / SCALE; } -/** - * Seconds from `nowMs` until the next local occurrence of "HH:MM". Rolls to - * the following day when the next occurrence is sooner than - * MIN_FIRST_RELOAD_SECONDS. Returns null for a malformed input. - */ -export function secondsUntilTimeOfDay(time: string, nowMs: number): number | null { - const m = /^(\d{2}):(\d{2})$/.exec(time); - if (!m) return null; - const hours = Number(m[1]); - const minutes = Number(m[2]); - if (hours > 23 || minutes > 59) return null; - const target = new Date(nowMs); - target.setHours(hours, minutes, 0, 0); - let delta = Math.floor((target.getTime() - nowMs) / 1000); - if (delta < MIN_FIRST_RELOAD_SECONDS) delta += RELOAD_INTERVAL_SECONDS; - return delta; -} - /** * Split `netDeposit` into `count` tranches: the first lands after * `firstDelaySeconds`, the rest every `intervalSeconds`. Integer dust from * the division is folded into the final tranche so the sum is exact. * Returns null when the inputs can't form a valid Sablier tranche list - * (every tranche amount must be > 0). + * (every tranche amount must be > 0, count within the contract cap). */ export function computeTranches( netDeposit: bigint, @@ -186,7 +177,8 @@ export function computeTranches( intervalSeconds: number, count: number, ): TrancheWithDuration[] | null { - if (count < 1 || firstDelaySeconds < 1 || intervalSeconds < 1) return null; + if (count < 1 || count > MAX_TRANCHE_COUNT) return null; + if (firstDelaySeconds < 1 || intervalSeconds < 1) return null; const n = BigInt(count); if (netDeposit < n) return null; const per = netDeposit / n; From 6be7701a99ba301214ee852e8a57491a5a5bf83f Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Tue, 1 Sep 2026 20:07:22 -0500 Subject: [PATCH 3/4] Raise small-text contrast and minimum type size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secondary-text ramp bottomed out at 0.60L on a 0.14L background — below comfortable contrast for the 11-12px helper lines and footnotes it colors, and captain-reported as hard to read on the payout-style helper. Lift the whole ramp (muted 0.70→0.76, subtle 0.66→0.72, faint 0.60→0.68) so hierarchy is preserved while the floor clears small-text legibility. Also bump the two smallest type sizes app-wide: 11px→12px (text-xs) and 10px→11px. Nothing user-facing renders below 11px now. --- packages/app/src/app/create/page.tsx | 8 ++++---- packages/app/src/app/globals.css | 9 ++++++--- packages/app/src/app/page.tsx | 2 +- packages/app/src/app/vaults/page.tsx | 4 ++-- packages/app/src/components/TestnetBanner.tsx | 2 +- packages/app/src/components/WelcomeModal.tsx | 2 +- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index 7d99346..3e27431 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -203,7 +203,7 @@ function NetworkSection({ isFormLocked }: { isFormLocked: boolean }) { className="inline-flex items-center gap-1.5 px-3.5 py-2 text-[13px] tracking-wide rounded-full border border-dashed border-line text-muted hover:border-cyan/60 hover:text-cyan transition-colors min-h-[2.5rem]" > Solana - +

{helper}

@@ -949,7 +949,7 @@ function CreateLockInner() {

)} {usdcNote && ( -

+

{usdcNote}

)} @@ -1141,7 +1141,7 @@ function CreateLockInner() { Pick a date at least 1 hour from now.

)} -

+

Good for rent, bills, or any single-date deadline. Cadence and waiting period don't apply.

@@ -1539,7 +1539,7 @@ function VestingCalculator({ ))} {totalIntervals > previewCount && ( -
+
… {totalIntervals - previewCount} more {strict ? "payouts" : "reloads"}
)} diff --git a/packages/app/src/app/globals.css b/packages/app/src/app/globals.css index 3fd826b..6328d10 100644 --- a/packages/app/src/app/globals.css +++ b/packages/app/src/app/globals.css @@ -8,9 +8,12 @@ --surface-elevated: oklch(0.25 0.012 200 / 0.84); --foreground: oklch(0.96 0.005 200); - --text-muted: oklch(0.70 0.008 200); - --text-subtle: oklch(0.66 0.012 200); - --text-faint: oklch(0.60 0.012 200); + /* Secondary-text ramp. Keep the muted > subtle > faint ordering, and keep + faint readable at 11-12px on the 0.14L background — it dips below + comfortable small-text contrast under ~0.66L. */ + --text-muted: oklch(0.76 0.008 200); + --text-subtle: oklch(0.72 0.012 200); + --text-faint: oklch(0.68 0.012 200); --line: oklch(0.82 0.040 200 / 0.14); --line-strong: oklch(0.82 0.040 200 / 0.28); diff --git a/packages/app/src/app/page.tsx b/packages/app/src/app/page.tsx index 1c379b1..e1ab066 100644 --- a/packages/app/src/app/page.tsx +++ b/packages/app/src/app/page.tsx @@ -129,7 +129,7 @@ function ChainChipRow() { className="inline-flex items-center gap-1.5 px-3.5 py-2 text-[13px] tracking-wide rounded-full border border-dashed border-line text-muted hover:border-cyan/60 hover:text-cyan transition-colors min-h-[2.5rem]" > Solana - + diff --git a/packages/app/src/app/vaults/page.tsx b/packages/app/src/app/vaults/page.tsx index c19eb30..2ee12a7 100644 --- a/packages/app/src/app/vaults/page.tsx +++ b/packages/app/src/app/vaults/page.tsx @@ -241,7 +241,7 @@ function VaultCard({ USDC
{!canClaim && !isClaimingThis && ( - + {claimStatus} )} @@ -832,7 +832,7 @@ function VaultDashboard() { return (
{chainConfig.usdcNote && ( -

+

{chainConfig.usdcNote}

)} diff --git a/packages/app/src/components/TestnetBanner.tsx b/packages/app/src/components/TestnetBanner.tsx index 3b5c894..294044b 100644 --- a/packages/app/src/components/TestnetBanner.tsx +++ b/packages/app/src/components/TestnetBanner.tsx @@ -6,7 +6,7 @@ export function TestnetBanner() { if (!IS_TESTNET) return null; return ( -
+
Base Sepolia Testnet · No real funds · Get test ETH from{" "}
-

+

RipGuard is the UI · Sablier is the bank

From a50fb3d20d085829cfe01e8c74d9068c9fb33493 Mon Sep 17 00:00:00 2001 From: Fielding Johnston Date: Tue, 1 Sep 2026 20:50:32 -0500 Subject: [PATCH 4/4] Show next-payout countdown for strict streams on /vaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict streams have exact unlock times, so the vault card can finally answer 'when can I claim?' precisely: read getTranches once per stream (immutable — cached forever; linear streams revert and map to null), show 'Next payout in Xm Ys' ticking against the next tranche, and 'All payouts unlocked' when the schedule is done. Countdown gains a seconds tier so an hourly cadence visibly moves. Fallback schedule label for tranched streams without a stored preset label is 'Strict Payouts'. --- packages/app/src/app/vaults/page.tsx | 76 ++++++++++++++++++++++++---- packages/app/src/config/abis.ts | 16 ++++++ 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/app/src/app/vaults/page.tsx b/packages/app/src/app/vaults/page.tsx index 2ee12a7..a3fa17e 100644 --- a/packages/app/src/app/vaults/page.tsx +++ b/packages/app/src/app/vaults/page.tsx @@ -38,6 +38,8 @@ type SubgraphStream = { cliffTime: string | null; }; +type Tranche = { amount: bigint; timestamp: number }; + type VaultData = { streamId: bigint; totalAmount: bigint; @@ -49,6 +51,8 @@ type VaultData = { endTime: number; cliffTime: number; claimable: bigint; + /** Strict (tranched) streams only — null for linear. */ + tranches: Tranche[] | null; }; function formatCountdown(seconds: number): string { @@ -56,12 +60,19 @@ function formatCountdown(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); + const sec = Math.floor(seconds % 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; - return `${m}m`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; } -function getScheduleType(cliffSeconds: number, totalSeconds: number): string { +function getScheduleType( + cliffSeconds: number, + totalSeconds: number, + isTranched = false, +): string { + if (isTranched) return "Strict Payouts"; if (cliffSeconds === totalSeconds && cliffSeconds > 0) return "One Drop"; if (cliffSeconds > 0) return "Wait, then reloads"; return "Steady reloads"; @@ -76,6 +87,7 @@ function getLockLabel( chainId: number, cliffSeconds: number, totalSeconds: number, + isTranched = false, ): string { if (typeof window !== "undefined") { try { @@ -87,7 +99,7 @@ function getLockLabel( // localStorage unavailable, fall through } } - return getScheduleType(cliffSeconds, totalSeconds); + return getScheduleType(cliffSeconds, totalSeconds, isTranched); } function formatDate(timestamp: number): string { @@ -121,14 +133,19 @@ function VaultCard({ }) { const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); - // Tick countdown — faster when close to unlock + const nextTranche = + vault.tranches?.find((t) => t.timestamp > now) ?? null; + + // Tick countdown — faster when close to the next unlock (next tranche + // for strict streams, stream end for linear). useEffect(() => { - if (now >= vault.endTime) return; - const secsLeft = vault.endTime - now; + const target = nextTranche ? nextTranche.timestamp : vault.endTime; + if (now >= target) return; + const secsLeft = target - now; const interval = secsLeft <= 300 ? 1_000 : secsLeft <= 3600 ? 10_000 : 60_000; const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), interval); return () => clearInterval(id); - }, [now, vault.endTime]); + }, [now, vault.endTime, nextTranche]); const remaining = vault.deposited - vault.withdrawn; const vested = vault.withdrawn + vault.claimable; const vestedPct = @@ -141,6 +158,12 @@ function VaultCard({ : 0; const nextUnlock = (() => { + if (vault.tranches) { + if (nextTranche) { + return { label: "Next payout in", time: nextTranche.timestamp - now }; + } + return { label: "All payouts unlocked", time: 0 }; + } if (vault.cliffTime > 0 && now < vault.cliffTime) { return { label: "Reloads start in", time: vault.cliffTime - now }; } @@ -157,8 +180,11 @@ function VaultCard({ const canClaim = vault.claimable > BigInt(0); const isClaimingThis = claimingId === vault.streamId; - const claimStatus = - now < vault.cliffTime + const claimStatus = vault.tranches + ? nextTranche + ? "Locked until next payout" + : "All claimed" + : now < vault.cliffTime ? "Waiting" : now < vault.endTime ? "Reloading" @@ -175,7 +201,7 @@ function VaultCard({ Lock #{vault.streamId.toString()}
- {getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)} + {getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds, vault.tranches !== null)}
@@ -279,7 +305,7 @@ function VaultCard({ ({ + address: sablierLockup, + abi: sablierLockupAbi, + functionName: "getTranches" as const, + args: [id] as const, + })); + + const { data: trancheResults } = useReadContracts({ + contracts: trancheContracts, + query: { + enabled: streamIds.length > 0, + staleTime: Infinity, + }, + }); + // Build vault data from subgraph + on-chain claimable let failedStreamCount = 0; const vaults: VaultData[] = subgraphStreams @@ -591,6 +636,14 @@ function VaultDashboard() { ? (claimableResult.result as bigint) : BigInt(0); + const trancheResult = trancheResults?.[i]; + const tranches: Tranche[] | null = + trancheResult?.status === "success" + ? ( + trancheResult.result as Array<{ amount: bigint; timestamp: number | bigint }> + ).map((t) => ({ amount: t.amount, timestamp: Number(t.timestamp) })) + : null; + if (claimableResults && claimableResult?.status !== "success") { failedStreamCount++; } @@ -609,6 +662,7 @@ function VaultDashboard() { endTime, cliffTime, claimable, + tranches, }; }) .filter((v): v is VaultData => v !== null); diff --git a/packages/app/src/config/abis.ts b/packages/app/src/config/abis.ts index ade0406..1103e8d 100644 --- a/packages/app/src/config/abis.ts +++ b/packages/app/src/config/abis.ts @@ -144,6 +144,22 @@ export const sablierLockupAbi = [ outputs: [{ name: "streamId", type: "uint256" }], stateMutability: "payable", }, + { + type: "function", + name: "getTranches", + inputs: [{ name: "streamId", type: "uint256" }], + outputs: [ + { + name: "tranches", + type: "tuple[]", + components: [ + { name: "amount", type: "uint128" }, + { name: "timestamp", type: "uint40" }, + ], + }, + ], + stateMutability: "view", + }, { type: "function", name: "createWithDurationsLT",