Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 277 additions & 93 deletions packages/app/src/app/create/page.tsx

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions packages/app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<span aria-hidden="true" className="text-[11px] -mt-0.5">↗</span>
<span aria-hidden="true" className="text-xs -mt-0.5">↗</span>
</a>
</li>
</ul>
Expand Down
80 changes: 67 additions & 13 deletions packages/app/src/app/vaults/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type SubgraphStream = {
cliffTime: string | null;
};

type Tranche = { amount: bigint; timestamp: number };

type VaultData = {
streamId: bigint;
totalAmount: bigint;
Expand All @@ -49,19 +51,28 @@ type VaultData = {
endTime: number;
cliffTime: number;
claimable: bigint;
/** Strict (tranched) streams only — null for linear. */
tranches: Tranche[] | null;
};

function formatCountdown(seconds: number): string {
if (seconds <= 0) return "Now";
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";
Expand All @@ -76,6 +87,7 @@ function getLockLabel(
chainId: number,
cliffSeconds: number,
totalSeconds: number,
isTranched = false,
): string {
if (typeof window !== "undefined") {
try {
Expand All @@ -87,7 +99,7 @@ function getLockLabel(
// localStorage unavailable, fall through
}
}
return getScheduleType(cliffSeconds, totalSeconds);
return getScheduleType(cliffSeconds, totalSeconds, isTranched);
}

function formatDate(timestamp: number): string {
Expand Down Expand Up @@ -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 =
Expand All @@ -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 };
}
Expand All @@ -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"
Expand All @@ -175,7 +201,7 @@ function VaultCard({
Lock #{vault.streamId.toString()}
</div>
<div className="font-display text-xl tracking-tight">
{getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)}
{getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds, vault.tranches !== null)}
</div>
</div>
<div className="sm:text-right">
Expand Down Expand Up @@ -241,7 +267,7 @@ function VaultCard({
<span className="text-sm text-cyan/60 font-sans ml-1.5 tracking-wider">USDC</span>
</div>
{!canClaim && !isClaimingThis && (
<span className="text-[11px] text-faint mt-1 block tabular">
<span className="text-xs text-faint mt-1 block tabular">
{claimStatus}
</span>
)}
Expand Down Expand Up @@ -279,7 +305,7 @@ function VaultCard({
<ShareCard
streamId={vault.streamId}
amountLocked={formatTokenAmount(vault.deposited, usdcDecimals)}
scheduleType={getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds)}
scheduleType={getLockLabel(vault.streamId, chainId, vault.cliffSeconds, vault.totalSeconds, vault.tranches !== null)}
endDate={new Date(vault.endTime * 1000)}
nextUnlock={nextUnlockLabel}
sablierAddress={sablierAddress}
Expand Down Expand Up @@ -576,6 +602,25 @@ function VaultDashboard() {
},
});

// Tranche schedules for strict (LT) streams. Immutable once created, so
// fetch once and cache forever. Linear streams revert on getTranches —
// allowFailure (the default) turns that into a per-call failure we map
// to null rather than an error.
const trancheContracts = streamIds.map((id) => ({
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
Expand All @@ -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++;
}
Expand All @@ -609,6 +662,7 @@ function VaultDashboard() {
endTime,
cliffTime,
claimable,
tranches,
};
})
.filter((v): v is VaultData => v !== null);
Expand Down Expand Up @@ -832,7 +886,7 @@ function VaultDashboard() {
return (
<div className="flex-1 w-full max-w-4xl mx-auto px-5 sm:px-8 pb-24 space-y-10">
{chainConfig.usdcNote && (
<p className="text-[11px] text-faint leading-relaxed pt-1">
<p className="text-xs text-faint leading-relaxed pt-1">
{chainConfig.usdcNote}
</p>
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/TestnetBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function TestnetBanner() {
if (!IS_TESTNET) return null;

return (
<div className="bg-warning text-background text-center text-[11px] font-semibold py-1.5 px-4 tracking-[0.18em] uppercase tabular">
<div className="bg-warning text-background text-center text-xs font-semibold py-1.5 px-4 tracking-[0.18em] uppercase tabular">
Base Sepolia Testnet · No real funds · Get test ETH from{" "}
<a
href="https://www.alchemy.com/faucets/base-sepolia"
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/WelcomeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function WelcomeModal() {
</button>
</div>

<p className="mt-6 text-[10px] tabular text-faint text-center tracking-wider uppercase">
<p className="mt-6 text-[11px] tabular text-faint text-center tracking-wider uppercase">
RipGuard is the UI · Sablier is the bank
</p>
</div>
Expand Down
53 changes: 53 additions & 0 deletions packages/app/src/config/abis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions packages/app/src/config/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,41 +37,47 @@ 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,
},
hourly3d: {
label: "Hourly Payouts (3d)",
description: "Reload every hour for 3 days",
cliffSeconds: 0,
intervalSeconds: 60 * 60,
totalSeconds: 3 * 24 * 60 * 60,
isLumpSum: false,
},
hourly1w: {
label: "Hourly Payouts (1w)",
description: "Reload every hour for 7 days",
cliffSeconds: 0,
intervalSeconds: 60 * 60,
totalSeconds: 7 * 24 * 60 * 60,
isLumpSum: false,
},
daily1w: {
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,
},
panicLock1d: {
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,
},
panicThenDaily: {
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,
},
Expand Down
Loading
Loading