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. */}
Custom
+ {
+ setSelectedPreset("custom");
+ setCustomMode("dailyReload");
+ setStep("schedule");
+ }}
+ className={`px-3 py-3 text-[13px] sm:text-sm font-semibold tabular tracking-wide transition-colors focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-[-2px] ${
+ selectedPreset === "custom" && customMode === "dailyReload"
+ ? "bg-cyan/[0.10] text-cyan"
+ : "bg-background text-muted hover:bg-surface hover:text-foreground"
+ } ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`}
+ >
+ Daily reload
+
+ ) : customMode === "dailyReload" ? (
+
+
+
+ Daily drops
+
+
+
setReloadDays(Number(e.target.value))}
+ className={`w-full appearance-none bg-background border border-line rounded-lg px-4 py-3 text-sm focus:outline-none focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-2 focus:border-cyan/50 transition-colors cursor-pointer ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`}
+ >
+ {RELOAD_DAY_OPTIONS.map((d) => (
+
+ {d} days
+
+ ))}
+
+
+
+
+
+
+ Drop time
+
+ 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. */}
Custom
- {
- setSelectedPreset("custom");
- setCustomMode("dailyReload");
- setStep("schedule");
- }}
- className={`px-3 py-3 text-[13px] sm:text-sm font-semibold tabular tracking-wide transition-colors focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-[-2px] ${
- selectedPreset === "custom" && customMode === "dailyReload"
- ? "bg-cyan/[0.10] text-cyan"
- : "bg-background text-muted hover:bg-surface hover:text-foreground"
- } ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`}
- >
- Daily reload
-
+ {/* 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 (
+ {
+ setPayoutStyle(opt.key);
+ setStep("schedule");
+ }}
+ className={`px-3 py-2.5 text-[13px] font-semibold tabular tracking-wide transition-colors focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-[-2px] ${
+ active
+ ? "bg-cyan/[0.10] text-cyan"
+ : "bg-background text-muted hover:bg-surface hover:text-foreground"
+ } ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`}
+ >
+ {opt.label}
+
+ );
+ })}
+
+
+ {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" ? (
-
-
-
- Daily drops
-
-
-
setReloadDays(Number(e.target.value))}
- className={`w-full appearance-none bg-background border border-line rounded-lg px-4 py-3 text-sm focus:outline-none focus-visible:outline-2 focus-visible:outline-cyan focus-visible:outline-offset-2 focus:border-cyan/50 transition-colors cursor-pointer ${isFormLocked ? "opacity-50 cursor-not-allowed" : ""}`}
- >
- {RELOAD_DAY_OPTIONS.map((d) => (
-
- {d} days
-
- ))}
-
-
-
-
-
-
- Drop time
-
- 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",