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+
{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") && ( ++ {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. +
+ )} ++
Good for rent, bills, or any single-date deadline. Cadence and waiting period don't apply.
@@ -1147,10 +1286,12 @@ function CreateLockInner() {- {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" ? ( + <> +
+
{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 ( - 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,