diff --git a/packages/app/src/app/create/page.tsx b/packages/app/src/app/create/page.tsx index b19c8c6..3e27431 100644 --- a/packages/app/src/app/create/page.tsx +++ b/packages/app/src/app/create/page.tsx @@ -41,6 +41,10 @@ import { toDatetimeLocalValue, computeFee, computeMaxDeposit, + strictDropCount, + MAX_TRANCHE_COUNT, + sablierNetDeposit, + computeTranches, } from "@/lib/schedule"; type PresetKey = keyof typeof PRESETS; @@ -74,6 +78,12 @@ function parseStreamIdFromReceipt( type Step = "schedule" | "confirm" | "approve" | "lock" | "success"; 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 @@ -193,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}

@@ -247,6 +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); + // 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. @@ -308,57 +321,100 @@ 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; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; label: string; targetMs: number | null; + /** strict only: payout cadence and drop count. */ + intervalSeconds: number; + dropCount: number; }>(() => { - if (selectedPreset !== "custom") { - const p = PRESETS[selectedPreset]; - return { - cliffSeconds: p.cliffSeconds, - totalSeconds: p.totalSeconds, - isLumpSum: p.isLumpSum, - label: p.label, - targetMs: 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) { + const base = (() => { + if (selectedPreset !== "custom") { + const p = PRESETS[selectedPreset]; return { - 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, + intervalSeconds: 0, + dropCount: 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) { + 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: "lockUntil" as ScheduleKind, + cliffSeconds: total - 1, + totalSeconds: total, + isLumpSum: true, + label: `Lock until ${formatTargetDate(lockUntilParsed.targetMs)}`, + targetMs: lockUntilParsed.targetMs, + 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. + const cliffEqualsTotal = customCliff === customTotal && customCliff > 0; return { - cliffSeconds: total - 1, - totalSeconds: total, - isLumpSum: true, - label: `Lock until ${formatTargetDate(lockUntilParsed.targetMs)}`, - targetMs: lockUntilParsed.targetMs, + 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 { - 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, - }; - }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed]); + return base; + }, [selectedPreset, customMode, customCliff, customTotal, lockUntilParsed, payoutStyle, intervalSeconds]); // Amount parsing const depositAmount = useMemo(() => { @@ -378,6 +434,24 @@ function CreateLockInner() { const unlockStart = BigInt(0); const unlockCliff = schedule.isLumpSum ? depositAmount : BigInt(0); + // 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. The first drop + // lands at cliff + one interval, matching the drip calculator's first + // checkpoint. + const tranches = useMemo( + () => + schedule.kind === "strict" && totalAmount > BigInt(0) + ? computeTranches( + sablierNetDeposit(totalAmount, brokerFee), + schedule.cliffSeconds + schedule.intervalSeconds, + schedule.intervalSeconds, + schedule.dropCount, + ) + : null, + [schedule, totalAmount, brokerFee], + ); + // Read USDC allowance const { data: allowance, refetch: refetchAllowance } = useReadContract({ address: usdcAddress, @@ -437,6 +511,7 @@ function CreateLockInner() { setCustomTotal(3600); setCustomMode("reloads"); setLockUntilInput(defaultLockUntilValue()); + setPayoutStyle("drip"); setAmountInput(""); setStep("schedule"); setConfirmed(false); @@ -498,28 +573,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 === "strict") { + // 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 +614,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 +682,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 +809,13 @@ function CreateLockInner() { const meetsMinimum = depositAmount >= minDeposit; const canProceed = - depositAmount > 0 && meetsMinimum && schedule.totalSeconds > 0 && isConnected; + depositAmount > 0 && + meetsMinimum && + schedule.totalSeconds > 0 && + // 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; @@ -872,7 +949,7 @@ function CreateLockInner() {

)} {usdcNote && ( -

+

{usdcNote}

)} @@ -964,6 +1041,68 @@ function CreateLockInner() { + {/* 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. */} @@ -1002,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.

@@ -1147,10 +1286,12 @@ function CreateLockInner() {
Payout @@ -1159,7 +1300,8 @@ function CreateLockInner() { depositAmount={depositAmount} totalSeconds={schedule.totalSeconds} cliffSeconds={schedule.cliffSeconds} - intervalSeconds={selectedPreset === "custom" ? customInterval : 3600} + intervalSeconds={intervalSeconds} + strict={schedule.kind === "strict"} usdcDecimals={usdcDecimals} /> )} @@ -1216,7 +1358,9 @@ function CreateLockInner() { customMode === "lockUntil" && !lockUntilParsed ? "Pick a date at least 1 hour out" - : "Review the lock"} + : schedule.kind === "strict" && !tranches + ? "Too many payouts for one lock" + : "Review the lock"} )} @@ -1336,12 +1480,15 @@ function VestingCalculator({ totalSeconds, cliffSeconds, intervalSeconds, + strict = false, usdcDecimals, }: { depositAmount: bigint; totalSeconds: number; cliffSeconds: number; intervalSeconds: number; + /** Strict payouts: the rows below are enforced tranches, not synthetic checkpoints. */ + strict?: boolean; usdcDecimals: number; }) { const vestSeconds = totalSeconds - cliffSeconds; @@ -1368,7 +1515,7 @@ 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} {strict ? "strict payouts" : "reloads"} over {formatDuration(totalSeconds)}
@@ -1392,8 +1539,8 @@ function VestingCalculator({ ))} {totalIntervals > previewCount && ( -
- … {totalIntervals - previewCount} more reloads +
+ … {totalIntervals - previewCount} more {strict ? "payouts" : "reloads"}
)}
@@ -1403,17 +1550,21 @@ function VestingCalculator({ } function TimelinePreview({ + kind, cliffSeconds, totalSeconds, isLumpSum, targetMs, + dropCount, depositAmount, usdcDecimals, }: { + kind: ScheduleKind; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; targetMs: number | null; + dropCount: number; depositAmount: bigint; usdcDecimals: number; }) { @@ -1458,7 +1609,14 @@ function TimelinePreview({ {/* Description */}

- {isLockUntil ? ( + {kind === "strict" ? ( + <> + {formatUnits(depositAmount, usdcDecimals)} USDC lands in{" "} + {dropCount} equal payouts{cliffSeconds > 0 ? ( + <> after a {formatDuration(cliffSeconds)} wait + ) : null}. Zero claimable between payouts. + + ) : isLockUntil ? ( <> {formatUnits(depositAmount, usdcDecimals)} USDC unlocks in one drop on {formatTargetDate(targetMs)}. @@ -1501,11 +1659,14 @@ function ConfirmDialog({ onCancel, }: { schedule: { + kind: ScheduleKind; label: string; cliffSeconds: number; totalSeconds: number; isLumpSum: boolean; targetMs: number | null; + intervalSeconds: number; + dropCount: number; }; depositAmount: bigint; fee: bigint; @@ -1605,7 +1766,22 @@ function ConfirmDialog({ Total from wallet {formatUnits(totalAmount, usdcDecimals)} USDC - {schedule.targetMs !== null ? ( + {schedule.kind === "strict" ? ( + <> +

+ Payouts + + {schedule.dropCount} × every {formatDuration(schedule.intervalSeconds)} + +
+
+ First payout in + + {formatDuration(schedule.cliffSeconds + schedule.intervalSeconds)} + +
+ + ) : schedule.targetMs !== null ? (
Unlocks on {formatTargetDate(schedule.targetMs)} @@ -1694,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; @@ -1711,8 +1890,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); @@ -1720,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/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..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)}
@@ -241,7 +267,7 @@ function VaultCard({ USDC
{!canClaim && !isClaimingThis && ( - + {claimStatus} )} @@ -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); @@ -832,7 +886,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

diff --git a/packages/app/src/config/abis.ts b/packages/app/src/config/abis.ts index a34b452..1103e8d 100644 --- a/packages/app/src/config/abis.ts +++ b/packages/app/src/config/abis.ts @@ -144,4 +144,57 @@ 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", + 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/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 d58cb8b..459afa8 100644 --- a/packages/app/src/lib/schedule.test.ts +++ b/packages/app/src/lib/schedule.test.ts @@ -13,6 +13,10 @@ import { calculatePayoutSchedule, DURATION_OPTIONS, ALL_INTERVALS, + sablierNetDeposit, + computeTranches, + strictDropCount, + MAX_TRANCHE_COUNT, } from "./schedule"; // --- formatDuration --- @@ -347,3 +351,112 @@ describe("toDatetimeLocalValue", () => { expect(parsed).toBe(d.getTime()); }); }); + +// --- strict payouts (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("computeTranches", () => { + it("splits evenly and folds dust into the last tranche", () => { + 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)); + 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, 86400, 4)!; + expect(tranches.map((t) => t.duration)).toEqual([ + 7200, + 86400, + 86400, + 86400, + ]); + }); + + 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, 86400, 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, 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 565016d..7ce971a 100644 --- a/packages/app/src/lib/schedule.ts +++ b/packages/app/src/lib/schedule.ts @@ -122,6 +122,73 @@ export function toDatetimeLocalValue(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +// ---------------------------------------------------------------------------- +// Strict payouts (Lockup Tranched) +// ---------------------------------------------------------------------------- + +/** One Sablier tranche: `amount` unlocks `duration` seconds after the previous one. */ +export interface TrancheWithDuration { + amount: bigint; + duration: number; +} + +/** + * 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 MAX_TRANCHE_COUNT = 500; + +/** + * 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 + * 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; +} + +/** + * 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, count within the contract cap). + */ +export function computeTranches( + netDeposit: bigint, + firstDelaySeconds: number, + intervalSeconds: number, + count: number, +): TrancheWithDuration[] | 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; + 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,