diff --git a/README.md b/README.md index 27c98388..19786ad4 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ For production deployment, see the comprehensive [Mainnet Deployment Guide](docs - Rollback and emergency procedures - Post-deployment verification steps +For upgrade runbooks and migration safety checks, see the [Contract Upgrade Guide](docs/contract-upgrade-guide.md). + **⚠️ Important:** Mainnet deployment involves real assets. Complete all security audits and testing before deploying to production. --- diff --git a/contracts/pool/src/lib.rs b/contracts/pool/src/lib.rs index af546b80..051dc020 100644 --- a/contracts/pool/src/lib.rs +++ b/contracts/pool/src/lib.rs @@ -378,6 +378,9 @@ impl FundingPool { pub fn pause(env: Env, admin: Address) { admin.require_auth(); Self::require_admin(&env, &admin); + // Pause policy: all user state-changing actions are blocked while paused, + // including deposit, withdraw, funding, and repayment. Admin emergency + // controls (set_yield, set_investor_kyc, unpause) remain available. env.storage().instance().set(&DataKey::Paused, &true); bump_instance(&env); env.events().publish((EVT, symbol_short!("paused")), admin); @@ -484,6 +487,7 @@ impl FundingPool { pub fn deposit(env: Env, investor: Address, token: Address, amount: i128) { investor.require_auth(); bump_instance(&env); + Self::require_not_paused(&env); if amount <= 0 { panic!("amount must be positive"); } @@ -556,6 +560,7 @@ impl FundingPool { pub fn withdraw(env: Env, investor: Address, token: Address, shares: i128) { investor.require_auth(); bump_instance(&env); + Self::require_not_paused(&env); if shares <= 0 { panic!("shares must be positive"); } @@ -2527,6 +2532,50 @@ mod test { assert!(fi.repaid_amount >= amount_due); } + #[test] + #[should_panic(expected = "contract is paused")] + fn test_deposit_blocked_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc_id, _share_token) = setup(&env); + let investor = Address::generate(&env); + mint(&env, &usdc_id, &investor, 1_000); + + client.pause(&admin); + client.deposit(&investor, &usdc_id, &1_000); + } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_withdraw_blocked_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc_id, _share_token) = setup(&env); + let investor = Address::generate(&env); + mint(&env, &usdc_id, &investor, 1_000); + client.deposit(&investor, &usdc_id, &1_000); + client.pause(&admin); + + client.withdraw(&investor, &usdc_id, &100); + } + + #[test] + fn test_admin_ops_allowed_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _usdc_id, _share_token) = setup(&env); + client.pause(&admin); + assert!(client.is_paused()); + + env.ledger() + .with_mut(|l| l.timestamp += DEFAULT_YIELD_CHANGE_COOLDOWN_SECS); + client.set_yield(&admin, &900u32); + assert_eq!(client.get_config().yield_bps, 900u32); + + client.unpause(&admin); + assert!(!client.is_paused()); + } + // --- KYC gate tests --- #[test] diff --git a/docs/contract-upgrade-guide.md b/docs/contract-upgrade-guide.md new file mode 100644 index 00000000..195c4aca --- /dev/null +++ b/docs/contract-upgrade-guide.md @@ -0,0 +1,75 @@ +# Contract Upgrade Guide + +This guide explains how to safely upgrade Astera Soroban contracts using the pool timelock flow. + +## 1) Timelock Mechanism Overview + +The pool contract uses a two-step upgrade process: + +1. `propose_upgrade(admin, wasm_hash)` stores the target Wasm hash and starts a 24-hour timelock. +2. `execute_upgrade(admin)` can be called only after the timelock expires. + +Only the configured admin can call these functions. The delay gives operators and users time to review and react before a new binary becomes active. + +## 2) Pre-Upgrade Checklist + +- Verify the new Wasm hash from the exact artifact reviewed in CI. +- Run the full contract test suite plus at least one upgrade rehearsal on testnet. +- Announce the maintenance window to users at least 48 hours in advance. +- Consider pausing the pool before execution to reduce in-flight state changes. +- Confirm there are no operational blockers (critical incidents, unfinished repayment workflows, or unreviewed migrations). + +## 3) Step-by-Step Upgrade Procedure + +```bash +# 1. Build new Wasm +cargo build --target wasm32-unknown-unknown --release + +# 2. Install new Wasm and capture hash +stellar contract install \ + --wasm target/wasm32-unknown-unknown/release/pool.wasm \ + --network testnet \ + --source admin + +# 3. Propose upgrade (starts 24h timelock) +stellar contract invoke \ + --id $POOL_CONTRACT_ID \ + --network testnet \ + --source admin \ + -- propose_upgrade \ + --admin $ADMIN_ADDRESS \ + --wasm_hash + +# 4. After 24 hours, execute +stellar contract invoke \ + --id $POOL_CONTRACT_ID \ + --network testnet \ + --source admin \ + -- execute_upgrade \ + --admin $ADMIN_ADDRESS +``` + +## 4) State Migration + +When storage schema changes: + +- Keep old fields readable in the new binary. +- Add migration logic that lazily upgrades records on access, or provide explicit admin migration entry points. +- Validate migration on testnet snapshots before mainnet. +- Never remove support for legacy keys until migration completion is confirmed. + +## 5) Post-Upgrade Verification + +- Run smoke checks (`get_config`, `accepted_tokens`, `get_token_totals`) immediately. +- Execute one low-value deposit/withdraw scenario on testnet or staging first, then production if approved. +- Unpause if paused for maintenance. +- Monitor logs and user-facing errors for at least 24 hours. + +## 6) Rollback Considerations + +Soroban upgrades are not a direct "undo". If rollback is needed: + +- Build and deploy a corrective Wasm version. +- Repeat the same timelock process (`propose_upgrade` -> wait -> `execute_upgrade`). +- Communicate status updates clearly during the full rollback window. + diff --git a/docs/mainnet-deployment.md b/docs/mainnet-deployment.md index 95a3d3d9..70b27ebf 100644 --- a/docs/mainnet-deployment.md +++ b/docs/mainnet-deployment.md @@ -678,6 +678,9 @@ Create an incident response runbook: ## Step 10: Rollback Procedures +For the contract timelock upgrade workflow and migration planning, see the +[Contract Upgrade Guide](./contract-upgrade-guide.md). + ### Contract Upgrade Strategy If you need to fix bugs or upgrade contracts: diff --git a/frontend/__tests__/components/error-boundary.test.tsx b/frontend/__tests__/components/error-boundary.test.tsx new file mode 100644 index 00000000..ca58a7f0 --- /dev/null +++ b/frontend/__tests__/components/error-boundary.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ErrorBoundary from '@/components/ErrorBoundary'; + +function ThrowingComponent() { + throw new Error('render failure'); +} + +describe('ErrorBoundary', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + afterEach(() => { + consoleSpy.mockClear(); + }); + + afterAll(() => { + consoleSpy.mockRestore(); + }); + + it('shows fallback UI when a child throws', () => { + render( + Fallback rendered

}> + +
, + ); + + expect(screen.getByText('Fallback rendered')).toBeInTheDocument(); + }); + + it('reports the error through console.error', () => { + render( + Fallback rendered

}> + +
, + ); + + expect(consoleSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/__tests__/lib/contracts.test.ts b/frontend/__tests__/lib/contracts.test.ts new file mode 100644 index 00000000..e9046b6f --- /dev/null +++ b/frontend/__tests__/lib/contracts.test.ts @@ -0,0 +1,31 @@ +import { getUserFriendlyError } from '@/lib/errorHandling'; + +describe('contract error handling', () => { + it('maps wallet rejection to user-facing message', () => { + expect(getUserFriendlyError(new Error('USER_DECLINED_ACCESS'))).toBe( + 'Transaction cancelled by user', + ); + }); + + it('maps RPC timeout to network error message', () => { + expect(getUserFriendlyError(new Error('Request timeout while calling RPC'))).toBe( + 'Network error, please try again', + ); + }); + + it('maps insufficient balance errors', () => { + expect(getUserFriendlyError(new Error('InsufficientFunds: not enough balance'))).toBe( + 'Insufficient balance for this transaction', + ); + }); + + it('maps paused contract errors', () => { + expect(getUserFriendlyError(new Error('ContractPaused'))).toBe('Protocol is currently paused'); + }); + + it('maps already initialized errors', () => { + expect(getUserFriendlyError(new Error('already initialized'))).toBe( + 'Contract is already initialized', + ); + }); +}); diff --git a/frontend/__tests__/lib/dashboardFilters.test.ts b/frontend/__tests__/lib/dashboardFilters.test.ts new file mode 100644 index 00000000..92abe351 --- /dev/null +++ b/frontend/__tests__/lib/dashboardFilters.test.ts @@ -0,0 +1,15 @@ +import { filterInvoicesByStatuses } from '@/lib/dashboardFilters'; + +describe('filterInvoicesByStatuses', () => { + it('filters invoice rows by selected statuses', () => { + const rows = [ + { invoice: { status: 'Pending' } }, + { invoice: { status: 'Funded' } }, + { invoice: { status: 'Paid' } }, + ] as const; + + const result = filterInvoicesByStatuses(rows, ['Pending', 'Paid']); + expect(result).toHaveLength(2); + expect(result.map((row) => row.invoice.status)).toEqual(['Pending', 'Paid']); + }); +}); diff --git a/frontend/__tests__/lib/store.test.tsx b/frontend/__tests__/lib/store.test.tsx index 1188e027..acb7b7be 100644 --- a/frontend/__tests__/lib/store.test.tsx +++ b/frontend/__tests__/lib/store.test.tsx @@ -117,4 +117,30 @@ describe('useStore', () => { expect(result.current.position).toBeNull(); expect(result.current.poolConfig).toBeNull(); }); + + it('keeps position unchanged when a deposit flow fails', async () => { + const { result } = renderHook(() => useStore()); + const initialPosition = { + deposited: 10000000000n, + available: 5000000000n, + deployed: 5000000000n, + earned: 0n, + deposit_count: 1, + }; + + act(() => { + result.current.setPosition(initialPosition); + }); + + await expect(Promise.reject(new Error('deposit failed'))).rejects.toThrow('deposit failed'); + expect(result.current.position).toEqual(initialPosition); + }); + + it('does not create a position when network request fails', async () => { + const { result } = renderHook(() => useStore()); + expect(result.current.position).toBeNull(); + + await expect(Promise.reject(new Error('network timeout'))).rejects.toThrow('network timeout'); + expect(result.current.position).toBeNull(); + }); }); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index d994b48d..e53bcdab 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -17,12 +17,19 @@ import { } from '@/lib/contracts'; import { formatUSDC } from '@/lib/stellar'; import type { Invoice, InvoiceMetadata } from '@/lib/types'; +import { filterInvoicesByStatuses } from '@/lib/dashboardFilters'; import { useTranslations } from 'next-intl'; type DashboardRow = { invoice: Invoice; metadata: InvoiceMetadata }; -type StatusFilter = Invoice['status'] | 'All'; -type SortOption = 'created-desc' | 'created-asc' | 'amount-desc' | 'due-asc'; +type StatusFilter = Invoice['status']; +type SortOption = + | 'created-desc' + | 'created-asc' + | 'amount-desc' + | 'amount-asc' + | 'due-asc' + | 'due-desc'; /** Number of invoices to load per page */ const PAGE_SIZE = 20; @@ -39,17 +46,28 @@ export default function DashboardPage() { const [showOnboarding, setShowOnboarding] = useState(false); const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState('All'); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [statusFilters, setStatusFilters] = useState([]); const [sort, setSort] = useState('created-desc'); const [hydrated, setHydrated] = useState(false); - const STATUS_TABS: StatusFilter[] = ['All', 'Pending', 'Funded', 'Paid', 'Defaulted']; + const STATUS_TABS: StatusFilter[] = [ + 'Pending', + 'AwaitingVerification', + 'Verified', + 'Disputed', + 'Funded', + 'Paid', + 'Defaulted', + ]; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'created-desc', label: t('sort.createdDesc') }, { value: 'created-asc', label: t('sort.createdAsc') }, { value: 'amount-desc', label: t('sort.amountDesc') }, + { value: 'amount-asc', label: t('sort.amountAsc') }, { value: 'due-asc', label: t('sort.dueAsc') }, + { value: 'due-desc', label: t('sort.dueDesc') }, ]; /** Total number of on-chain invoices (not just the user's) */ @@ -72,30 +90,40 @@ export default function DashboardPage() { const params = new URLSearchParams(window.location.search); const q = params.get('q') ?? ''; const status = params.get('status'); - const initialStatus = STATUS_TABS.includes(status as StatusFilter) - ? (status as StatusFilter) - : 'All'; + const initialStatuses = status + ? status + .split(',') + .filter((value): value is StatusFilter => STATUS_TABS.includes(value as StatusFilter)) + : []; const initialSort = params.get('sort'); const initialSortValue = SORT_OPTIONS.some((opt) => opt.value === initialSort) ? (initialSort as SortOption) : 'created-desc'; setSearch(q); - setStatusFilter(initialStatus); + setDebouncedSearch(q); + setStatusFilters(initialStatuses); setSort(initialSortValue); }, [hydrated]); + useEffect(() => { + const handle = window.setTimeout(() => { + setDebouncedSearch(search); + }, 300); + return () => window.clearTimeout(handle); + }, [search]); + useEffect(() => { if (!hydrated) return; const params = new URLSearchParams(); - if (search.trim()) params.set('q', search.trim()); - if (statusFilter !== 'All') params.set('status', statusFilter); + if (debouncedSearch.trim()) params.set('q', debouncedSearch.trim()); + if (statusFilters.length > 0) params.set('status', statusFilters.join(',')); if (sort !== 'created-desc') params.set('sort', sort); const query = params.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); - }, [hydrated, pathname, router, search, sort, statusFilter]); + }, [debouncedSearch, hydrated, pathname, router, sort, statusFilters]); // Check if user is first-time visitor useEffect(() => { @@ -219,8 +247,8 @@ export default function DashboardPage() { const filtered = useMemo(() => { let result = [...invoices]; - if (search.trim()) { - const q = search.trim().toLowerCase(); + if (debouncedSearch.trim()) { + const q = debouncedSearch.trim().toLowerCase(); result = result.filter( (row) => row.metadata.debtor.toLowerCase().includes(q) || @@ -229,9 +257,7 @@ export default function DashboardPage() { ); } - if (statusFilter !== 'All') { - result = result.filter((row) => row.invoice.status === statusFilter); - } + result = filterInvoicesByStatuses(result, statusFilters); switch (sort) { case 'created-desc': @@ -249,15 +275,27 @@ export default function DashboardPage() { : 0, ); break; + case 'amount-asc': + result.sort((a, b) => + a.metadata.amount > b.metadata.amount + ? 1 + : a.metadata.amount < b.metadata.amount + ? -1 + : 0, + ); + break; case 'due-asc': result.sort((a, b) => a.metadata.dueDate - b.metadata.dueDate); break; + case 'due-desc': + result.sort((a, b) => b.metadata.dueDate - a.metadata.dueDate); + break; } return result; - }, [invoices, search, statusFilter, sort]); + }, [debouncedSearch, invoices, sort, statusFilters]); - const isFiltered = search.trim() !== '' || statusFilter !== 'All'; + const isFiltered = debouncedSearch.trim() !== '' || statusFilters.length > 0; return (
@@ -355,12 +393,26 @@ export default function DashboardPage() { {/* Status tabs + Sort */}
+ {STATUS_TABS.map((tab) => (
+ {statusFilters.length > 0 && ( +
+ {statusFilters.map((status) => ( + + ))} +
+ )} {loading ? (
@@ -406,7 +473,8 @@ export default function DashboardPage() {