Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
49 changes: 49 additions & 0 deletions contracts/pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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]
Expand Down
75 changes: 75 additions & 0 deletions docs/contract-upgrade-guide.md
Original file line number Diff line number Diff line change
@@ -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 <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.

3 changes: 3 additions & 0 deletions docs/mainnet-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions frontend/__tests__/components/error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ErrorBoundary fallback={<p>Fallback rendered</p>}>
<ThrowingComponent />
</ErrorBoundary>,
);

expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
});

it('reports the error through console.error', () => {
render(
<ErrorBoundary fallback={<p>Fallback rendered</p>}>
<ThrowingComponent />
</ErrorBoundary>,
);

expect(consoleSpy).toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions frontend/__tests__/lib/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
15 changes: 15 additions & 0 deletions frontend/__tests__/lib/dashboardFilters.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
26 changes: 26 additions & 0 deletions frontend/__tests__/lib/store.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading