From 6d48b9e863030a2fdf9c1a48a01dc0e175e7dfa1 Mon Sep 17 00:00:00 2001
From: JamesVictor-O
Date: Sun, 26 Apr 2026 11:03:17 +0100
Subject: [PATCH] feat: resolve assigned issues 167, 174, 175, and 180
Improve dashboard search/filter/sort URL persistence, enforce pause checks for deposit/withdraw with new pause tests, add frontend error handling and boundary tests, and document the contract upgrade runbook with deployment/readme links.
Made-with: Cursor
---
README.md | 2 +
contracts/pool/src/lib.rs | 49 ++++++++
docs/contract-upgrade-guide.md | 75 ++++++++++++
docs/mainnet-deployment.md | 3 +
.../components/error-boundary.test.tsx | 39 +++++++
frontend/__tests__/lib/contracts.test.ts | 31 +++++
.../__tests__/lib/dashboardFilters.test.ts | 15 +++
frontend/__tests__/lib/store.test.tsx | 26 +++++
frontend/app/dashboard/page.tsx | 110 ++++++++++++++----
frontend/components/ErrorBoundary.tsx | 30 +++++
frontend/lib/dashboardFilters.ts | 9 ++
frontend/lib/errorHandling.ts | 13 +++
frontend/locales/en/common.json | 7 +-
frontend/locales/fr/common.json | 7 +-
14 files changed, 393 insertions(+), 23 deletions(-)
create mode 100644 docs/contract-upgrade-guide.md
create mode 100644 frontend/__tests__/components/error-boundary.test.tsx
create mode 100644 frontend/__tests__/lib/contracts.test.ts
create mode 100644 frontend/__tests__/lib/dashboardFilters.test.ts
create mode 100644 frontend/components/ErrorBoundary.tsx
create mode 100644 frontend/lib/dashboardFilters.ts
create mode 100644 frontend/lib/errorHandling.ts
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() {