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
25 changes: 21 additions & 4 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
type CommitmentType,
} from '@/lib/commitments';
import { useWallet } from '@/components/WalletProvider';
import { useProtocolStatus } from '@/components/ProtocolStatusProvider';
import { getActionPauseReason } from '@/lib/soroban/protocol-status';
import { ESCROW_CONTRACT_ID, explorerContractUrl, config } from '@/lib/config';
import { useProtocolParams } from '@/lib/protocol';
import {
Expand Down Expand Up @@ -49,6 +51,9 @@ function daysMessage(reason: DaysError, minDays: number): string {

export default function CreatePage() {
const { address } = useWallet();
const protocolStatus = useProtocolStatus();
const isPaused = protocolStatus.status === 'ok' && protocolStatus.paused;
const pauseReason = getActionPauseReason('create_commitment', isPaused);
const { params, loading: paramsLoading, isFallback } = useProtocolParams();
const [step, setStep] = useState(0);
const [type, setType] = useState<CommitmentType>('balanced');
Expand Down Expand Up @@ -354,13 +359,25 @@ export default function CreatePage() {
{config.networkDetails.label.toLowerCase()} escrow contract is enabled once a contract ID is configured
{ESCROW_CONTRACT_ID ? '.' : ' (none set in this deployment).'}
</p>
<div className="row between">
<div className="row between wrap">
<button className="btn btn-ghost" onClick={() => setStep(1)}>
Back
</button>
<button className="btn btn-primary" disabled={!agree} onClick={submit}>
Create commitment
</button>
<div className="row end wrap" style={{ gap: '12px', alignItems: 'center' }}>
{isPaused && (
<span className="err small" role="status">
{pauseReason}
</span>
)}
<button
className="btn btn-primary"
disabled={!agree || isPaused}
onClick={submit}
title={isPaused ? pauseReason : undefined}
>
Create commitment
</button>
</div>
</div>
</div>
)}
Expand Down
50 changes: 42 additions & 8 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { DisputeDialog } from '@/components/DisputeDialog';
import { useWallet } from '@/components/WalletProvider';
import { useProtocolStatus } from '@/components/ProtocolStatusProvider';
import { getActionPauseReason } from '@/lib/soroban/protocol-status';
import { YieldCoverageChip } from '@/components/YieldCoverageChip';
import { fetchXlmBalance, shortAddress } from '@/lib/wallet';
import type { DecodedEscrowEvent } from '@/lib/soroban/events';
import { DISPUTE_CATEGORY_LABELS, type DisputeRecord } from '@/lib/soroban/dispute';
Expand Down Expand Up @@ -51,6 +54,9 @@ type BalanceState =

export default function DashboardPage() {
const { status, address } = useWallet();
const protocolStatus = useProtocolStatus();
const isPaused = protocolStatus.status === 'ok' && protocolStatus.paused;
const exitPauseReason = getActionPauseReason('early_exit', isPaused);
const [list, setList] = useState<Commitment[] | null>(null);
const [pending, setPending] = useState<{ kind: 'exit' | 'remove'; id: string } | null>(null);
const [announcement, setAnnouncement] = useState('');
Expand Down Expand Up @@ -350,9 +356,22 @@ export default function DashboardPage() {
const record = disputeRecords[c.id];
const pct = Math.round(progressOf(c) * 100);
const shown = st === 'settled' || st === 'exited' ? 100 : pct;
const left =Math.max(0, Math.ceil((maturityOf(c) - Date.now()) / 86_400_000));
const left = Math.max(0, Math.ceil((maturityOf(c) - Date.now()) / 86_400_000));
const frozen = st === 'disputed' || st === 'violated';
const ownerIsConnected = !!address && c.owner === address;
const isNearOrMatured =
st === 'matured' ||
(st === 'active' && maturityOf(c) - Date.now() <= 7 * 86_400_000);
const isOnChain = !c.demo;
const accruedYield = BigInt(
(c as any).accrued_yield ?? (c as any).accruedYield ?? projectedYield(c),
);
const showCoverageWarning =
isOnChain &&
isNearOrMatured &&
protocolStatus.status === 'ok' &&
protocolStatus.yieldPool < accruedYield;

return (
<article className="card commit" key={c.id}>
<div className="row between wrap">
Expand Down Expand Up @@ -389,8 +408,14 @@ export default function DashboardPage() {
Funds are frozen. Loading the dispute record...
</p>
)}
{showCoverageWarning && (
<YieldCoverageChip
accruedYield={accruedYield}
yieldPool={protocolStatus.yieldPool}
/>
)}
</div>
<div className="row">
<div className="row wrap" style={{ alignItems: 'center', gap: '8px' }}>
{!frozen && st === 'matured' && (
<button
className="btn btn-primary"
Expand All @@ -403,12 +428,21 @@ export default function DashboardPage() {
</button>
)}
{!frozen && st === 'active' && (
<button
className="btn btn-ghost"
onClick={() => setPending({ kind: 'exit', id: c.id })}
>
Early exit
</button>
<div className="row wrap" style={{ alignItems: 'center', gap: '8px' }}>
<button
className="btn btn-ghost"
disabled={isPaused}
title={isPaused ? exitPauseReason : undefined}
onClick={() => setPending({ kind: 'exit', id: c.id })}
>
Early exit
</button>
{isPaused && (
<span className="err small" role="status">
{exitPauseReason}
</span>
)}
</div>
)}
{!frozen && (st === 'active' || st === 'matured') && (
<button
Expand Down
3 changes: 3 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-si
.btn-lg { padding: 13px 24px; font-size: 1.05rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
.notice { max-width: 1080px; margin: 12px auto 0; padding: 10px 16px; border: 1px solid var(--warn); border-radius: 10px; color: var(--warn); background: rgba(251,191,36,.08); width: calc(100% - 32px); }
.paused-banner { background: rgba(248,113,113,.12); border-bottom: 1px solid rgba(248,113,113,.35); color: #fca5a5; padding: 12px 16px; font-size: .95rem; font-weight: 500; text-align: center; }
.paused-banner p { margin: 0; line-height: 1.4; }
.coverage-warning-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: .8rem; font-weight: 500; color: var(--warn); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); border-radius: 6px; margin-top: 6px; }
.hero { text-align: center; padding: 80px 0 40px; }
.pill { display: inline-block; padding: 4px 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--accent); font-size: .85rem; margin-bottom: 20px; background: var(--bg2); }
.grad { background: linear-gradient(135deg, var(--accent), var(--accent2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
Expand Down
13 changes: 9 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
import { WalletProvider } from '@/components/WalletProvider';
import { ProtocolStatusProvider } from '@/components/ProtocolStatusProvider';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { WalletNotice } from '@/components/WalletNotice';
import { PausedBanner } from '@/components/PausedBanner';

export const metadata: Metadata = {
title: 'CommitLabs - Liquidity as a Commitment',
Expand All @@ -25,10 +27,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" nonce={nonce}>
<body>
<WalletProvider>
<Header />
<WalletNotice />
<main id="main-content">{children}</main>
<Footer />
<ProtocolStatusProvider>
<Header />
<PausedBanner />
<WalletNotice />
<main id="main-content">{children}</main>
<Footer />
</ProtocolStatusProvider>
</WalletProvider>
</body>
</html>
Expand Down
25 changes: 25 additions & 0 deletions src/components/PausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import React from 'react';
import { useProtocolStatus } from './ProtocolStatusProvider';

export const PAUSED_BANNER_TEXT =
'Escrow is paused by the operator. New commitments, funding and early exits are temporarily disabled. Matured settlements are unaffected.';

export function PausedBanner() {
const { status, paused } = useProtocolStatus();

// RPC failure never blocks actions and never shows the paused banner.
// Only display the banner when protocol status is 'ok' and paused is true.
if (status !== 'ok' || !paused) {
return null;
}

return (
<div className="paused-banner" role="status" aria-live="polite">
<div className="container">
<p>{PAUSED_BANNER_TEXT}</p>
</div>
</div>
);
}
151 changes: 151 additions & 0 deletions src/components/ProtocolStatusProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
'use client';

import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { getProtocolStatus, type ProtocolStatus } from '@/lib/soroban/protocol-status';

export type ProtocolStatusState = 'loading' | 'ok' | 'unavailable';

export interface ProtocolStatusContextValue {
status: ProtocolStatusState;
paused: boolean;
yieldPool: bigint;
fetchedAt: number | null;
refresh: () => Promise<void>;
}

const ProtocolStatusContext = createContext<ProtocolStatusContextValue | null>(null);

export function ProtocolStatusProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<{
status: ProtocolStatusState;
paused: boolean;
yieldPool: bigint;
fetchedAt: number | null;
}>({
status: 'loading',
paused: false,
yieldPool: 0n,
fetchedAt: null,
});

const inFlightRef = useRef<Promise<void> | null>(null);

const refresh = useCallback(async (): Promise<void> => {
// One in-flight request at a time (dedupes concurrent calls)
if (inFlightRef.current) {
return inFlightRef.current;
}

const task = (async () => {
try {
const res = await getProtocolStatus();
setState({
status: res.status,
paused: res.paused,
yieldPool: res.yieldPool,
fetchedAt: res.fetchedAt,
});
} catch {
// Fallback safety: RPC failure never blocks actions and never shows paused banner
setState((prev) => ({
status: 'unavailable',
paused: false,
yieldPool: 0n,
fetchedAt: prev.fetchedAt ?? Date.now(),
}));
} finally {
inFlightRef.current = null;
}
})();

inFlightRef.current = task;
return task;
}, []);

useEffect(() => {
// Fetch on mount
void refresh();

// Poll every 60s while visible
let intervalId: ReturnType<typeof setInterval> | null = null;

const startPolling = () => {
if (intervalId) return;
intervalId = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void refresh();
}
}, 60_000);
};

const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};

const handleVisibilityChange = () => {
if (typeof document === 'undefined') return;
if (document.visibilityState === 'visible') {
void refresh();
startPolling();
} else {
stopPolling();
}
};

const handleFocus = () => {
void refresh();
};

if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
startPolling();
}

if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof window !== 'undefined') {
window.addEventListener('focus', handleFocus);
}

return () => {
stopPolling();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
if (typeof window !== 'undefined') {
window.removeEventListener('focus', handleFocus);
}
};
}, [refresh]);

return (
<ProtocolStatusContext.Provider
value={{
status: state.status,
paused: state.paused,
yieldPool: state.yieldPool,
fetchedAt: state.fetchedAt,
refresh,
}}
>
{children}
</ProtocolStatusContext.Provider>
);
}

export function useProtocolStatus(): ProtocolStatusContextValue {
const ctx = useContext(ProtocolStatusContext);
if (!ctx) {
return {
status: 'unavailable',
paused: false,
yieldPool: 0n,
fetchedAt: null,
refresh: async () => {},
};
}
return ctx;
}
28 changes: 28 additions & 0 deletions src/components/YieldCoverageChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { COVERAGE_WARNING_MESSAGE, isPayoutUncovered } from '@/lib/soroban/protocol-status';

export interface YieldCoverageChipProps {
accruedYield: bigint | number;
yieldPool: bigint | number;
className?: string;
}

export function YieldCoverageChip({
accruedYield,
yieldPool,
className = '',
}: YieldCoverageChipProps) {
if (!isPayoutUncovered(accruedYield, yieldPool)) {
return null;
}

return (
<div
role="status"
className={`coverage-warning-chip ${className}`}
data-testid="yield-coverage-warning"
>
<span aria-hidden="true">&#9888;</span> {COVERAGE_WARNING_MESSAGE}
</div>
);
}
Loading
Loading