From 67c66edd89543be22f6166b562e4f10f494f1df2 Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Sat, 24 Oct 2026 02:24:27 +0100 Subject: [PATCH] feat(frontend): reflect escrow pause state and yield-pool coverage across the app Closes #2098 --- src/app/create/page.tsx | 25 ++- src/app/dashboard/page.tsx | 52 ++++- src/app/globals.css | 3 + src/app/layout.tsx | 13 +- src/components/PausedBanner.tsx | 25 +++ src/components/ProtocolStatusProvider.tsx | 151 +++++++++++++ src/components/YieldCoverageChip.tsx | 28 +++ .../__tests__/PausedBanner.test.tsx | 171 ++++++++++++++ .../__tests__/ProtocolStatusProvider.test.tsx | 192 ++++++++++++++++ .../__tests__/YieldCoverageChip.test.tsx | 36 +++ .../soroban/__tests__/protocol-status.test.ts | 135 +++++++++++ src/lib/soroban/errors.test.ts | 2 +- src/lib/soroban/errors.ts | 14 ++ src/lib/soroban/protocol-status.ts | 209 ++++++++++++++++++ src/lib/soroban/tx.ts | 92 ++++++++ 15 files changed, 1131 insertions(+), 17 deletions(-) create mode 100644 src/components/PausedBanner.tsx create mode 100644 src/components/ProtocolStatusProvider.tsx create mode 100644 src/components/YieldCoverageChip.tsx create mode 100644 src/components/__tests__/PausedBanner.test.tsx create mode 100644 src/components/__tests__/ProtocolStatusProvider.test.tsx create mode 100644 src/components/__tests__/YieldCoverageChip.test.tsx create mode 100644 src/lib/soroban/__tests__/protocol-status.test.ts create mode 100644 src/lib/soroban/protocol-status.ts create mode 100644 src/lib/soroban/tx.ts diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 5753f5a3..76482fbe 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -13,6 +13,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'; const ASSETS = ['USDC', 'XLM', 'EURC']; @@ -20,6 +22,9 @@ const STEP_LABELS = ['Type', 'Configure', 'Review', 'Done']; export default function CreatePage() { const { address } = useWallet(); + const protocolStatus = useProtocolStatus(); + const isPaused = protocolStatus.status === 'ok' && protocolStatus.paused; + const pauseReason = getActionPauseReason('create_commitment', isPaused); const [step, setStep] = useState(0); const [type, setType] = useState('balanced'); const [asset, setAsset] = useState('USDC'); @@ -303,13 +308,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 e2d3f083..bea9cd20 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,6 +18,9 @@ import { } from '@/lib/commitments'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { useWallet } from '@/components/WalletProvider'; +import { useProtocolStatus } from '@/components/ProtocolStatusProvider'; +import { getActionPauseReason } from '@/lib/soroban/protocol-status'; +import { YieldCoverageChip } from '@/components/YieldCoverageChip'; import { fetchXlmBalance } from '@/lib/wallet'; import type { DecodedEscrowEvent } from '@/lib/soroban/events'; import { config, explorerTx } from '@/lib/config'; @@ -45,6 +48,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(''); @@ -282,7 +288,22 @@ export default function DashboardPage() { const st = effectiveStatus(c); 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 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 ?? + Math.round(projectedYield(c) * 10_000_000), + ); + const showCoverageWarning = + isOnChain && + isNearOrMatured && + protocolStatus.status === 'ok' && + protocolStatus.yieldPool < accruedYield; + return (
@@ -296,8 +317,14 @@ export default function DashboardPage() { {c.id} · {specFor(c.type).name} · {c.apy}% APY · max loss {c.maxLoss}%

+ {showCoverageWarning && ( + + )}
-
+
{st === 'matured' && ( +
+ + {isPaused && ( + + {exitPauseReason} + + )} +
)} {(st === 'settled' || st === 'exited') && ( +
+ ); +} + +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..26bf7f7a --- /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 nativeToScVal(true); + } + if (method === 'get_yield_pool_balance') { + return 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/errors.test.ts b/src/lib/soroban/errors.test.ts index d758efaf..7d6f9e17 100644 --- a/src/lib/soroban/errors.test.ts +++ b/src/lib/soroban/errors.test.ts @@ -51,7 +51,7 @@ describe('contract error table', () => { expect(errorBlock).not.toBeNull(); const contractErrors = [...(errorBlock?.[1] ?? '').matchAll(/^\s*(\w+)\s*=\s*(\d+)\s*,/gm)]; - expect(contractErrors).toHaveLength(15); + expect(contractErrors).toHaveLength(17); for (const [, name, rawCode] of contractErrors) { const code = Number(rawCode) as EscrowErrorCode; expect(CONTRACT_ERRORS[code]).toMatchObject({ name, code }); diff --git a/src/lib/soroban/errors.ts b/src/lib/soroban/errors.ts index 7ea9e267..7ac12081 100644 --- a/src/lib/soroban/errors.ts +++ b/src/lib/soroban/errors.ts @@ -16,6 +16,8 @@ export enum EscrowErrorCode { InsufficientBalance = 13, CommitmentViolated = 14, InvalidWasmHash = 15, + FundingWindowExpired = 16, + ReservedYieldInvariant = 17, } export interface ContractError { @@ -116,6 +118,18 @@ export const CONTRACT_ERRORS: Record = { message: 'The contract Wasm hash is invalid.', hint: 'Check the contract configuration and try again.', }, + [EscrowErrorCode.FundingWindowExpired]: { + code: EscrowErrorCode.FundingWindowExpired, + name: 'FundingWindowExpired', + message: 'The commitment funding window has expired.', + hint: 'Create a new commitment to try again.', + }, + [EscrowErrorCode.ReservedYieldInvariant]: { + code: EscrowErrorCode.ReservedYieldInvariant, + name: 'ReservedYieldInvariant', + message: 'The yield pool cannot fulfill the yield reservation for this commitment.', + hint: 'Try again later or contact the contract administrator.', + }, }; export type TxErrorKind = diff --git a/src/lib/soroban/protocol-status.ts b/src/lib/soroban/protocol-status.ts new file mode 100644 index 00000000..2ee5b0af --- /dev/null +++ b/src/lib/soroban/protocol-status.ts @@ -0,0 +1,209 @@ +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}`); +} + +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([ + simulateRead('is_paused', [], options), + simulateRead('get_yield_pool_balance', [], options), + ]); + + 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), + }; + } +} diff --git a/src/lib/soroban/tx.ts b/src/lib/soroban/tx.ts new file mode 100644 index 00000000..4c71778f --- /dev/null +++ b/src/lib/soroban/tx.ts @@ -0,0 +1,92 @@ +import { Account, Contract, nativeToScVal, rpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'; +import { config } from '../config'; + +export interface SimulateReadOptions { + contractId?: string; + rpcUrl?: string; + server?: rpc.Server; +} + +/** + * Simulate a read-only invocation on a Soroban contract. + */ +export async function simulateRead( + method: string, + args: (xdr.ScVal | any)[] = [], + options: SimulateReadOptions = {}, +): Promise { + const contractId = options.contractId || config.escrowContractId; + if (!contractId) { + throw new Error('Escrow contract ID is not configured'); + } + + const server = options.server || new rpc.Server(options.rpcUrl || config.sorobanRpcUrl); + const contract = new Contract(contractId); + const scValArgs = args.map((arg) => (arg instanceof xdr.ScVal ? arg : nativeToScVal(arg))); + + // Dummy account for read-only contract simulation + const dummyAccount = new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '0'); + const tx = new TransactionBuilder(dummyAccount, { + fee: '100', + networkPassphrase: config.passphrase, + }) + .addOperation(contract.call(method, ...(scValArgs as any))) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(sim.error || `Simulation of ${method} failed`); + } + if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval) { + throw new Error(`Simulation of ${method} returned no value`); + } + return sim.result.retval; +} + +export interface InvokeContractParams { + method: string; + args?: (xdr.ScVal | any)[]; + source: string; + signer?: (txXdr: string) => Promise; + contractId?: string; + rpcUrl?: string; + server?: rpc.Server; +} + +/** + * Minimal invokeContract helper matching the required exported API. + */ +export async function invokeContract({ + method, + args = [], + source, + signer, + contractId = config.escrowContractId, + rpcUrl = config.sorobanRpcUrl, + server = new rpc.Server(rpcUrl), +}: InvokeContractParams): Promise<{ status: string; txHash?: string }> { + if (!contractId) { + throw new Error('Escrow contract ID is not configured'); + } + + const contract = new Contract(contractId); + const scValArgs = args.map((arg) => (arg instanceof xdr.ScVal ? arg : nativeToScVal(arg))); + const dummyAccount = new Account(source, '0'); + const tx = new TransactionBuilder(dummyAccount, { + fee: '1000', + networkPassphrase: config.passphrase, + }) + .addOperation(contract.call(method, ...(scValArgs as any))) + .setTimeout(30) + .build(); + + if (signer) { + const signedXdr = await signer(tx.toXDR()); + const sendRes = await server.sendTransaction( + TransactionBuilder.fromXDR(signedXdr, config.passphrase) as any, + ); + return { status: sendRes.status, txHash: sendRes.hash }; + } + return { status: 'simulated' }; +}