diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index db06e049..eb9608ab 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -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 { @@ -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('balanced'); @@ -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).'}

-
+
- +
+ {isPaused && ( + + {pauseReason} + + )} + +
)} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f8a06b3e..a3253215 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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'; @@ -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(null); const [pending, setPending] = useState<{ kind: 'exit' | 'remove'; id: string } | null>(null); const [announcement, setAnnouncement] = useState(''); @@ -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 (
@@ -389,8 +408,14 @@ export default function DashboardPage() { Funds are frozen. Loading the dispute record...

)} + {showCoverageWarning && ( + + )}
-
+
{!frozen && st === 'matured' && ( +
+ + {isPaused && ( + + {exitPauseReason} + + )} +
)} {!frozen && (st === 'active' || st === 'matured') && ( +
+ ); +} + +describe('ProtocolStatusProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Default document.visibilityState to 'visible' + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('polls at 60 s while visible', async () => { + let callCount = 0; + const spy = vi.spyOn(statusModule, 'getProtocolStatus').mockImplementation(async () => { + callCount += 1; + return { + status: 'ok', + paused: false, + yieldPool: 1000n, + fetchedAt: Date.now(), + }; + }); + + await act(async () => { + render( + + + + ); + }); + + expect(spy).toHaveBeenCalledTimes(1); + + // Advance 60 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(spy).toHaveBeenCalledTimes(2); + + // Advance another 60 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(spy).toHaveBeenCalledTimes(3); + }); + + it('stops polling when hidden and resumes when visible', async () => { + const spy = vi.spyOn(statusModule, 'getProtocolStatus').mockImplementation(async () => ({ + status: 'ok', + paused: false, + yieldPool: 500n, + fetchedAt: Date.now(), + })); + + await act(async () => { + render( + + + + ); + }); + + expect(spy).toHaveBeenCalledTimes(1); + + // Tab becomes hidden + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Advance 60s while hidden - should not poll + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(spy).toHaveBeenCalledTimes(1); + + // Tab becomes visible again + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Should refresh immediately when returning to visible + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('refreshes on window focus', async () => { + const spy = vi.spyOn(statusModule, 'getProtocolStatus').mockImplementation(async () => ({ + status: 'ok', + paused: false, + yieldPool: 500n, + fetchedAt: Date.now(), + })); + + await act(async () => { + render( + + + + ); + }); + + expect(spy).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('dedupes concurrent refreshes to one in-flight request', async () => { + let resolveFirst: (val: any) => void; + const slowFirstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + let calls = 0; + const spy = vi.spyOn(statusModule, 'getProtocolStatus').mockImplementation(() => { + calls += 1; + return slowFirstPromise as Promise; + }); + + let rendered: any; + await act(async () => { + rendered = render( + + + + ); + }); + + expect(calls).toBe(1); + + // Trigger multiple concurrent refreshes while the first is in-flight + const btn = screen.getByTestId('refresh-btn'); + await act(async () => { + btn.click(); + btn.click(); + }); + + // Still only 1 call in-flight + expect(calls).toBe(1); + + // Complete the in-flight request + await act(async () => { + resolveFirst!({ + status: 'ok', + paused: true, + yieldPool: 2000n, + fetchedAt: Date.now(), + }); + }); + + expect(screen.getByTestId('status').textContent).toBe('ok'); + expect(screen.getByTestId('paused').textContent).toBe('true'); + expect(screen.getByTestId('yieldPool').textContent).toBe('2000'); + }); +}); diff --git a/src/components/__tests__/YieldCoverageChip.test.tsx b/src/components/__tests__/YieldCoverageChip.test.tsx new file mode 100644 index 00000000..0793509c --- /dev/null +++ b/src/components/__tests__/YieldCoverageChip.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { YieldCoverageChip } from '../YieldCoverageChip'; +import { COVERAGE_WARNING_MESSAGE } from '@/lib/soroban/protocol-status'; + +describe('YieldCoverageChip', () => { + it('does not render when accrued_yield is below the pool', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId('yield-coverage-warning')).toBeNull(); + }); + + it('does not render when accrued_yield is equal to the pool', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId('yield-coverage-warning')).toBeNull(); + }); + + it('renders warning chip when accrued_yield is above the pool', () => { + render( + + ); + const chip = screen.getByTestId('yield-coverage-warning'); + expect(chip).toBeDefined(); + expect(chip.getAttribute('role')).toBe('status'); + expect(chip.textContent).toContain(COVERAGE_WARNING_MESSAGE); + expect(chip.textContent).toContain( + 'Yield pool currently cannot cover this payout; settlement will fail until it is topped up.' + ); + }); +}); diff --git a/src/lib/soroban/__tests__/protocol-status.test.ts b/src/lib/soroban/__tests__/protocol-status.test.ts new file mode 100644 index 00000000..77d58be8 --- /dev/null +++ b/src/lib/soroban/__tests__/protocol-status.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nativeToScVal } from '@stellar/stellar-sdk'; +import { + decodeBool, + decodeI128, + getProtocolStatus, + ACTION_POLICIES, + isActionBlocked, + getActionPauseReason, + COVERAGE_WARNING_MESSAGE, + isPayoutUncovered, + type EscrowAction, +} from '../protocol-status'; +import * as txModule from '../tx'; + +describe('protocol-status decoders and reads', () => { + it('decodes bool simulation results', () => { + const trueScVal = nativeToScVal(true); + const falseScVal = nativeToScVal(false); + + expect(decodeBool(trueScVal)).toBe(true); + expect(decodeBool(falseScVal)).toBe(false); + expect(decodeBool(true)).toBe(true); + expect(decodeBool(false)).toBe(false); + expect(decodeBool({ result: { retval: trueScVal } })).toBe(true); + expect(decodeBool({ result: { retval: falseScVal } })).toBe(false); + }); + + it('decodes i128 simulation results', () => { + const zeroScVal = nativeToScVal(0n, { type: 'i128' }); + const amountScVal = nativeToScVal(50000000n, { type: 'i128' }); + const largeScVal = nativeToScVal(12345678901234567890n, { type: 'i128' }); + + expect(decodeI128(zeroScVal)).toBe(0n); + expect(decodeI128(amountScVal)).toBe(50000000n); + expect(decodeI128(largeScVal)).toBe(12345678901234567890n); + expect(decodeI128(100n)).toBe(100n); + expect(decodeI128(100)).toBe(100n); + expect(decodeI128({ result: { retval: amountScVal } })).toBe(50000000n); + }); + + it('reads protocol status when simulations succeed', async () => { + const spy = vi.spyOn(txModule, 'simulateRead').mockImplementation(async ({ method }) => { + if (method === 'is_paused') { + return { result: nativeToScVal(true) }; + } + if (method === 'get_yield_pool_balance') { + return { result: nativeToScVal(75000000n, { type: 'i128' }) }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + const status = await getProtocolStatus({ contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); + + expect(status.status).toBe('ok'); + expect(status.paused).toBe(true); + expect(status.yieldPool).toBe(75000000n); + expect(typeof status.fetchedAt).toBe('number'); + + spy.mockRestore(); + }); + + it('yields unavailable when RPC error occurs', async () => { + const spy = vi.spyOn(txModule, 'simulateRead').mockRejectedValue(new Error('RPC network timeout')); + + const status = await getProtocolStatus({ contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }); + + expect(status.status).toBe('unavailable'); + expect(status.paused).toBe(false); + expect(status.yieldPool).toBe(0n); + expect(status.error).toContain('RPC network timeout'); + + spy.mockRestore(); + }); +}); + +describe('policy table documentation and action gating', () => { + const ALL_ACTIONS: EscrowAction[] = [ + 'create_commitment', + 'create_commitment_default', + 'fund_escrow', + 'refund', + 'refund_partial', + 'early_exit', + 'release', + 'settle', + ]; + + it('has an entry for every known action with contract documentation', () => { + for (const action of ALL_ACTIONS) { + const policy = ACTION_POLICIES[action]; + expect(policy).toBeDefined(); + expect(policy.action).toBe(action); + expect(typeof policy.pausedBlocked).toBe('boolean'); + expect(policy.contractDoc).toContain('contracts/escrow/src/lib.rs'); + + if (policy.pausedBlocked) { + expect(policy.pausedReason).toBeDefined(); + expect(policy.pausedReason?.length).toBeGreaterThan(0); + } + } + }); + + it('correctly reports blocked status and pause reasons', () => { + // Actions blocked when paused + expect(isActionBlocked('create_commitment', true)).toBe(true); + expect(isActionBlocked('create_commitment_default', true)).toBe(true); + expect(isActionBlocked('fund_escrow', true)).toBe(true); + expect(isActionBlocked('refund', true)).toBe(true); + expect(isActionBlocked('refund_partial', true)).toBe(true); + expect(isActionBlocked('early_exit', true)).toBe(true); + + // Actions NOT blocked when paused (settlement remains open) + expect(isActionBlocked('release', true)).toBe(false); + expect(isActionBlocked('settle', true)).toBe(false); + + // When contract is not paused, nothing is blocked + for (const action of ALL_ACTIONS) { + expect(isActionBlocked(action, false)).toBe(false); + expect(getActionPauseReason(action, false)).toBeUndefined(); + } + + expect(getActionPauseReason('early_exit', true)).toContain('paused'); + }); + + it('evaluates yield pool coverage correctly', () => { + // equal to pool -> covered (not uncovered) + expect(isPayoutUncovered(100n, 100n)).toBe(false); + // below pool -> covered + expect(isPayoutUncovered(50n, 100n)).toBe(false); + // above pool -> uncovered + expect(isPayoutUncovered(150n, 100n)).toBe(true); + expect(COVERAGE_WARNING_MESSAGE).toContain('Yield pool currently cannot cover this payout'); + }); +}); diff --git a/src/lib/soroban/protocol-status.ts b/src/lib/soroban/protocol-status.ts new file mode 100644 index 00000000..8784fb74 --- /dev/null +++ b/src/lib/soroban/protocol-status.ts @@ -0,0 +1,216 @@ +import { rpc, scValToBigInt, scValToNative, xdr } from '@stellar/stellar-sdk'; +import { config } from '../config'; +import { simulateRead } from './tx'; + +export interface ProtocolStatus { + status: 'ok' | 'unavailable'; + paused: boolean; + yieldPool: bigint; + fetchedAt: number; + error?: string; +} + +export type EscrowAction = + | 'create_commitment' + | 'create_commitment_default' + | 'fund_escrow' + | 'refund' + | 'refund_partial' + | 'early_exit' + | 'release' + | 'settle'; + +export interface ActionPolicy { + action: EscrowAction; + pausedBlocked: boolean; + pausedReason?: string; + contractDoc: string; +} + +/** + * Escrow pause policy table documenting contract entrypoint behavior. + * Mirrors contracts/escrow/src/lib.rs: + * - create_commitment: L433 (checks is_paused_internal -> Error::Paused) + * - create_commitment_default: L501 (checks is_paused_internal -> Error::Paused) + * - fund_escrow: L571 (checks is_paused_internal -> Error::Paused) + * - refund: L712 (checks is_paused_internal -> Error::Paused) + * - refund_partial: L723 (checks is_paused_internal -> Error::Paused) + * - early_exit: applies refund under the hood (checks is_paused_internal -> Error::Paused) + * - release: does NOT check pause on-chain (remains active for mature payouts) + * - settle: does NOT check pause on-chain (remains active for mature payouts) + */ +export const ACTION_POLICIES: Record = { + create_commitment: { + action: 'create_commitment', + pausedBlocked: true, + pausedReason: 'Commitment creation is temporarily paused by the contract operator.', + contractDoc: 'contracts/escrow/src/lib.rs: create_commitment checks is_paused_internal', + }, + create_commitment_default: { + action: 'create_commitment_default', + pausedBlocked: true, + pausedReason: 'Commitment creation is temporarily paused by the contract operator.', + contractDoc: 'contracts/escrow/src/lib.rs: create_commitment_default checks is_paused_internal', + }, + fund_escrow: { + action: 'fund_escrow', + pausedBlocked: true, + pausedReason: 'Escrow funding is temporarily paused by the contract operator.', + contractDoc: 'contracts/escrow/src/lib.rs: fund_escrow checks is_paused_internal', + }, + refund: { + action: 'refund', + pausedBlocked: true, + pausedReason: 'Refunds are temporarily paused by the contract operator.', + contractDoc: 'contracts/escrow/src/lib.rs: refund checks is_paused_internal', + }, + refund_partial: { + action: 'refund_partial', + pausedBlocked: true, + pausedReason: 'Partial refunds are temporarily paused by the contract operator.', + contractDoc: 'contracts/escrow/src/lib.rs: refund_partial checks is_paused_internal', + }, + early_exit: { + action: 'early_exit', + pausedBlocked: true, + pausedReason: 'Early exits are temporarily disabled while the contract is paused.', + contractDoc: 'contracts/escrow/src/lib.rs: early_exit / refund checks is_paused_internal', + }, + release: { + action: 'release', + pausedBlocked: false, + contractDoc: 'contracts/escrow/src/lib.rs: release does not check pause', + }, + settle: { + action: 'settle', + pausedBlocked: false, + contractDoc: 'contracts/escrow/src/lib.rs: settle_commitment / release does not check pause', + }, +}; + +export function isActionBlocked(action: EscrowAction, paused: boolean): boolean { + if (!paused) return false; + return Boolean(ACTION_POLICIES[action]?.pausedBlocked); +} + +export function getActionPauseReason(action: EscrowAction, paused: boolean): string | undefined { + if (!paused) return undefined; + return ACTION_POLICIES[action]?.pausedBlocked ? ACTION_POLICIES[action]?.pausedReason : undefined; +} + +export const COVERAGE_WARNING_MESSAGE = + 'Yield pool currently cannot cover this payout; settlement will fail until it is topped up.'; + +export function isPayoutUncovered( + accruedYield: bigint | number, + yieldPool: bigint | number, +): boolean { + return BigInt(accruedYield) > BigInt(yieldPool); +} + +export function decodeBool(val: unknown): boolean { + if (typeof val === 'boolean') return val; + if (val && typeof val === 'object') { + if ('result' in val && (val as any).result?.retval) { + return decodeBool((val as any).result.retval); + } + if (val instanceof xdr.ScVal || ('switch' in val && typeof (val as any).switch === 'function')) { + const native = scValToNative(val as xdr.ScVal); + if (typeof native === 'boolean') return native; + } + } + if (typeof val === 'string') { + try { + const parsed = xdr.ScVal.fromXDR(val, 'base64'); + const native = scValToNative(parsed); + if (typeof native === 'boolean') return native; + } catch { + // not base64 xdr + } + } + return Boolean(val); +} + +export function decodeI128(val: unknown): bigint { + if (typeof val === 'bigint') return val; + if (typeof val === 'number') return BigInt(val); + if (val && typeof val === 'object') { + if ('result' in val && (val as any).result?.retval) { + return decodeI128((val as any).result.retval); + } + if (val instanceof xdr.ScVal || ('switch' in val && typeof (val as any).switch === 'function')) { + try { + return scValToBigInt(val as xdr.ScVal); + } catch { + const native = scValToNative(val as xdr.ScVal); + return BigInt(native); + } + } + } + if (typeof val === 'string') { + try { + const parsed = xdr.ScVal.fromXDR(val, 'base64'); + return scValToBigInt(parsed); + } catch { + return BigInt(val); + } + } + throw new Error(`Unable to decode value into i128 / bigint: ${val}`); +} + +/** Runs a read-only simulation and returns the raw return value, throwing on failure. */ +async function readValue(method: string): Promise { + const res = await simulateRead({ method, args: [] }); + if (res.error) throw new Error(res.error); + return res.result; +} + +export interface GetProtocolStatusOptions { + contractId?: string; + rpcUrl?: string; + server?: rpc.Server; +} + +/** + * Reads escrow pause state and yield-pool balance via read-only simulation. + * Never throws on RPC or contract errors; returns an 'unavailable' status instead. + */ +export async function getProtocolStatus( + options: GetProtocolStatusOptions = {}, +): Promise { + const contractId = options.contractId || config.escrowContractId; + if (!contractId) { + return { + status: 'unavailable', + paused: false, + yieldPool: 0n, + fetchedAt: Date.now(), + error: 'Escrow contract ID is not configured', + }; + } + + try { + const [pausedScVal, yieldPoolScVal] = await Promise.all([ + readValue('is_paused'), + readValue('get_yield_pool_balance'), + ]); + + const paused = decodeBool(pausedScVal); + const yieldPool = decodeI128(yieldPoolScVal); + + return { + status: 'ok', + paused, + yieldPool, + fetchedAt: Date.now(), + }; + } catch (err) { + return { + status: 'unavailable', + paused: false, + yieldPool: 0n, + fetchedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }; + } +}