From b560c2a2c83241b3e2aa2ba2e57495131124a672 Mon Sep 17 00:00:00 2001 From: nwhator Date: Tue, 20 Oct 2026 15:03:55 +0100 Subject: [PATCH] feat: resolve issues #1173, #1347, #1324, and #1485 --- .pre-commit-config.yaml | 36 +++++ contracts/rewards/src/lib.rs | 150 ++++++++++++++++++ contracts/rewards/src/test.rs | 28 ++++ contracts/rewards/src/types.rs | 8 + frontend/src/components/TransactionStatus.jsx | 45 +++++- frontend/src/hooks/useTransactionStatus.js | 119 ++++++++++++++ .../src/hooks/useTransactionStatus.test.js | 149 +++++++++++++++++ frontend/tests/e2e/accessibility.spec.ts | 48 ++++++ package.json | 4 +- scripts/pre-commit.sh | 29 ++++ 10 files changed, 608 insertions(+), 8 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 contracts/rewards/src/types.rs create mode 100644 frontend/src/hooks/useTransactionStatus.js create mode 100644 frontend/src/hooks/useTransactionStatus.test.js create mode 100644 scripts/pre-commit.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..bb705f9c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# .pre-commit-config.yaml - Trivela pre-commit hooks +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + + - repo: local + hooks: + - id: prettier + name: prettier format check + entry: npx prettier --check + language: system + types_or: [javascript, jsx, ts, tsx, json, yaml, markdown] + files: '\.(js|jsx|ts|tsx|json|ya?ml|md)$' + + - id: eslint + name: eslint lint check + entry: npm run lint + language: system + types_or: [javascript, jsx, ts, tsx] + files: '\.(js|jsx|ts|tsx)$' + pass_filenames: false + + - id: rustfmt + name: rustfmt check + entry: cargo fmt --all -- --check + language: system + types: [rust] + files: '\.rs$' + pass_filenames: false diff --git a/contracts/rewards/src/lib.rs b/contracts/rewards/src/lib.rs index 9b277d3f..2cdfa713 100644 --- a/contracts/rewards/src/lib.rs +++ b/contracts/rewards/src/lib.rs @@ -45,6 +45,8 @@ use soroban_sdk::{ }; pub mod groth16; +pub mod types; +use types::DataKey; #[cfg(test)] mod error_codes_test; @@ -4748,6 +4750,154 @@ impl RewardsContract { Ok(new_balance) } + + // ── Issue #1173: Multi-token reward pool support ─────────────────────────── + + /// Add a supported reward asset (admin only). + pub fn add_reward_asset(env: Env, asset: Address) -> Result<(), Error> { + let admin: Address = get_admin(&env)?; + admin.require_auth(); + + let mut assets: Vec
= env + .storage() + .instance() + .get(&DataKey::SupportedAssets) + .unwrap_or(Vec::new(&env)); + + for existing in assets.iter() { + if existing == asset { + return Ok(()); + } + } + + assets.push_back(asset.clone()); + env.storage() + .instance() + .set(&DataKey::SupportedAssets, &assets); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + + let pool_key = DataKey::RewardPool(asset.clone()); + if !env.storage().persistent().has(&pool_key) { + env.storage().persistent().set(&pool_key, &0i128); + env.storage() + .persistent() + .extend_ttl(&pool_key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + Ok(()) + } + + /// Credit rewards into a specific asset pool (admin only). + pub fn credit_rewards( + env: Env, + participant: Address, + asset: Address, + amount: i128, + ) -> Result<(), Error> { + let admin: Address = get_admin(&env)?; + admin.require_auth(); + + if amount <= 0 { + return Err(Error::InsufficientBalance); + } + + let pool_key = DataKey::RewardPool(asset.clone()); + let pool_balance: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0i128); + let new_pool_balance = pool_balance.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().persistent().set(&pool_key, &new_pool_balance); + env.storage() + .persistent() + .extend_ttl(&pool_key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + + let user_key = (symbol_short!("rw_asset"), participant, asset); + let user_balance: i128 = env.storage().persistent().get(&user_key).unwrap_or(0i128); + let new_user_balance = user_balance.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().persistent().set(&user_key, &new_user_balance); + env.storage() + .persistent() + .extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + Ok(()) + } + + /// Claim rewards from a specific asset pool. Transfers SAC tokens to participant. + pub fn claim_rewards( + env: Env, + participant: Address, + asset: Address, + amount: i128, + ) -> Result<(), Error> { + participant.require_auth(); + + if amount <= 0 { + return Err(Error::InsufficientBalance); + } + + let user_key = (symbol_short!("rw_asset"), participant.clone(), asset.clone()); + let user_balance: i128 = env.storage().persistent().get(&user_key).unwrap_or(0i128); + if user_balance < amount { + return Err(Error::InsufficientBalance); + } + + let pool_key = DataKey::RewardPool(asset.clone()); + let pool_balance: i128 = env.storage().persistent().get(&pool_key).unwrap_or(0i128); + if pool_balance < amount { + return Err(Error::InsufficientBalance); + } + + let new_user_balance = user_balance + .checked_sub(amount) + .ok_or(Error::InsufficientBalance)?; + env.storage().persistent().set(&user_key, &new_user_balance); + env.storage() + .persistent() + .extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let new_pool_balance = pool_balance + .checked_sub(amount) + .ok_or(Error::InsufficientBalance)?; + env.storage().persistent().set(&pool_key, &new_pool_balance); + env.storage() + .persistent() + .extend_ttl(&pool_key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + + let token_client = token::Client::new(&env, &asset); + token_client.transfer(&env.current_contract_address(), &participant, &amount); + + Ok(()) + } + + /// Getter for all supported reward assets. + pub fn get_supported_assets(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&DataKey::SupportedAssets) + .unwrap_or(Vec::new(&env)) + } + + /// Getter for a specific asset reward pool balance. + pub fn get_reward_pool_balance(env: Env, asset: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::RewardPool(asset)) + .unwrap_or(0i128) + } + + /// Getter for participant's reward balance for a specific asset. + pub fn get_participant_reward_balance(env: Env, participant: Address, asset: Address) -> i128 { + env.storage() + .persistent() + .get(&(symbol_short!("rw_asset"), participant, asset)) + .unwrap_or(0i128) + } } fn sort_tiers(_env: &Env, tiers: Vec<(u64, u64)>) -> Vec<(u64, u64)> { diff --git a/contracts/rewards/src/test.rs b/contracts/rewards/src/test.rs index d0d4ad33..8560e711 100644 --- a/contracts/rewards/src/test.rs +++ b/contracts/rewards/src/test.rs @@ -2292,3 +2292,31 @@ fn test_timelock_double_queue_rejected() { let result = client.try_queue_timelock(&admin, &op_hash); assert_eq!(result, Err(Ok(Error::TimelockAlreadyQueued))); } + +#[test] +fn test_multi_token_reward_pool_add_and_supported_assets() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RewardsContract); + let client = RewardsContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let usdc = Address::generate(&env); + let xlm = Address::generate(&env); + + client.add_reward_asset(&usdc); + client.add_reward_asset(&xlm); + + let assets = client.get_supported_assets(); + assert_eq!(assets.len(), 2); + assert_eq!(assets.get(0).unwrap(), usdc); + assert_eq!(assets.get(1).unwrap(), xlm); + + let alice = Address::generate(&env); + client.credit_rewards(&alice, &usdc, &500); + assert_eq!(client.get_reward_pool_balance(&usdc), 500); + assert_eq!(client.get_participant_reward_balance(&alice, &usdc), 500); + assert_eq!(client.get_reward_pool_balance(&xlm), 0); +} diff --git a/contracts/rewards/src/types.rs b/contracts/rewards/src/types.rs new file mode 100644 index 00000000..f7bfde2a --- /dev/null +++ b/contracts/rewards/src/types.rs @@ -0,0 +1,8 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + RewardPool(Address), + SupportedAssets, +} diff --git a/frontend/src/components/TransactionStatus.jsx b/frontend/src/components/TransactionStatus.jsx index 63a7e802..64a5b487 100644 --- a/frontend/src/components/TransactionStatus.jsx +++ b/frontend/src/components/TransactionStatus.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { RECOVERY_ACTION, RECOVERY_ACTION_META } from '../lib/errorMapping'; import { useTransactionRecovery } from '../hooks/useTransactionRecovery'; +import { useTransactionStatus } from '../hooks/useTransactionStatus'; import { playSuccessChime } from '../lib/successChime'; import './TransactionStatus.css'; @@ -37,17 +38,47 @@ export default function TransactionStatus({ onRetry, onSwitchWallet, onTopUp, + pollOnPending = false, + onTerminal, }) { + const [internalVariant, setInternalVariant] = useState(variant); + const [internalStatus, setInternalStatus] = useState(status); const [copied, setCopied] = useState(false); const chimedHashRef = useRef(null); + useEffect(() => { + setInternalVariant(variant); + }, [variant]); + + useEffect(() => { + setInternalStatus(status); + }, [status]); + + useTransactionStatus({ + hash, + enabled: Boolean(pollOnPending && hash && internalVariant === 'pending'), + onTerminal: (terminalStatus) => { + if (['confirmed', 'success', 'SUCCESS'].includes(terminalStatus)) { + setInternalVariant('success'); + setInternalStatus('Transaction confirmed'); + } else if (['failed', 'error', 'FAILED'].includes(terminalStatus)) { + setInternalVariant('error'); + setInternalStatus('Transaction failed'); + } + onTerminal?.(terminalStatus); + }, + }); + + const activeVariant = internalVariant; + const activeStatus = internalStatus; + // Optional audio cue (#1234) when a transaction turns green. Plays once per // hash and only if the user opted in from Notification settings. useEffect(() => { - if (variant !== 'success' || !hash || chimedHashRef.current === hash) return; + if (activeVariant !== 'success' || !hash || chimedHashRef.current === hash) return; chimedHashRef.current = hash; playSuccessChime(); - }, [variant, hash]); + }, [activeVariant, hash]); const { handlers, getExplorerUrl, getReportUrl } = useTransactionRecovery({ onRetry, @@ -59,8 +90,8 @@ export default function TransactionStatus({ const shortenedHash = hash ? `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}` : ''; const explorerUrl = hash ? getExplorerUrl(hash) : null; - const label = status ?? VARIANT_DEFAULT_LABEL[variant] ?? VARIANT_DEFAULT_LABEL.success; - const isError = variant === 'error'; + const label = activeStatus ?? VARIANT_DEFAULT_LABEL[activeVariant] ?? VARIANT_DEFAULT_LABEL.success; + const isError = activeVariant === 'error'; // Recovery actions to render, sourced from the structured mapped error. const recoveryActions = @@ -78,17 +109,17 @@ export default function TransactionStatus({ // Backward compatible: with no hash the card only renders for non-success // (pending/error) lifecycle states. - if (!hash && variant === 'success') return null; + if (!hash && activeVariant === 'success') return null; return (
{label}
diff --git a/frontend/src/hooks/useTransactionStatus.js b/frontend/src/hooks/useTransactionStatus.js new file mode 100644 index 00000000..5645e42c --- /dev/null +++ b/frontend/src/hooks/useTransactionStatus.js @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export const TERMINAL_STATUSES = new Set([ + 'confirmed', + 'failed', + 'success', + 'error', + 'SUCCESS', + 'FAILED', +]); + +/** + * Checks whether a given transaction status string represents a terminal lifecycle state. + * @param {string|null|undefined} status + * @returns {boolean} + */ +export function isTerminalStatus(status) { + if (!status) return false; + return TERMINAL_STATUSES.has(status); +} + +/** + * useTransactionStatus — polls transaction status on a 3-second interval + * until a terminal state ('confirmed' | 'failed') is reached, then immediately + * stops polling to prevent battery and network waste (#1485). + * + * @param {{ + * hash?: string, + * enabled?: boolean, + * pollIntervalMs?: number, + * fetchStatusFn?: (hash: string) => Promise<{ status: string }>, + * onTerminal?: (status: string) => void, + * }} options + */ +export function useTransactionStatus({ + hash, + enabled = true, + pollIntervalMs = 3000, + fetchStatusFn, + onTerminal, +} = {}) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [isPolling, setIsPolling] = useState(false); + const timerRef = useRef(null); + const stoppedRef = useRef(false); + + const stopPolling = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsPolling(false); + stoppedRef.current = true; + }, []); + + const checkStatus = useCallback(async () => { + if (!hash || stoppedRef.current) return; + + try { + let result; + if (fetchStatusFn) { + result = await fetchStatusFn(hash); + } else { + const res = await fetch(`/api/v1/transactions/${hash}/status`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + result = await res.json(); + } + + const currentStatus = result?.status ?? null; + setStatus(currentStatus); + + if (isTerminalStatus(currentStatus)) { + stopPolling(); + onTerminal?.(currentStatus); + } + } catch (err) { + setError(err?.message ?? 'Failed to check status'); + } + }, [hash, fetchStatusFn, onTerminal, stopPolling]); + + useEffect(() => { + if (!enabled || !hash) { + stopPolling(); + return; + } + + stoppedRef.current = false; + setIsPolling(true); + + // Initial status check + checkStatus(); + + timerRef.current = setInterval(() => { + if (stoppedRef.current) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return; + } + checkStatus(); + }, pollIntervalMs); + + return () => { + stopPolling(); + }; + }, [hash, enabled, pollIntervalMs, checkStatus, stopPolling]); + + return { + status, + isTerminal: isTerminalStatus(status), + isPolling, + error, + stopPolling, + }; +} + +export default useTransactionStatus; diff --git a/frontend/src/hooks/useTransactionStatus.test.js b/frontend/src/hooks/useTransactionStatus.test.js new file mode 100644 index 00000000..66aae4b2 --- /dev/null +++ b/frontend/src/hooks/useTransactionStatus.test.js @@ -0,0 +1,149 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { useTransactionStatus, isTerminalStatus } from './useTransactionStatus'; + +describe('useTransactionStatus (#1485)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('correctly identifies terminal statuses', () => { + expect(isTerminalStatus('confirmed')).toBe(true); + expect(isTerminalStatus('failed')).toBe(true); + expect(isTerminalStatus('success')).toBe(true); + expect(isTerminalStatus('error')).toBe(true); + expect(isTerminalStatus('SUCCESS')).toBe(true); + expect(isTerminalStatus('FAILED')).toBe(true); + expect(isTerminalStatus('pending')).toBe(false); + expect(isTerminalStatus('processing')).toBe(false); + expect(isTerminalStatus(null)).toBe(false); + }); + + it('stops polling when a confirmed terminal status is received and makes no requests afterwards', async () => { + let callCount = 0; + const fetchStatusFn = vi.fn().mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + return { status: 'pending' }; + } + return { status: 'confirmed' }; + }); + + const onTerminal = vi.fn(); + + const { result } = renderHook(() => + useTransactionStatus({ + hash: '0xabc123', + pollIntervalMs: 3000, + fetchStatusFn, + onTerminal, + }), + ); + + // Initial check (pending) + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('pending'); + expect(result.current.isPolling).toBe(true); + expect(result.current.isTerminal).toBe(false); + + // Advance 3 seconds -> second poll returns 'confirmed' (terminal) + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('confirmed'); + expect(result.current.isPolling).toBe(false); + expect(result.current.isTerminal).toBe(true); + expect(onTerminal).toHaveBeenCalledWith('confirmed'); + + // Advance time further (3s, 6s, 9s, 12s) - verify NO further requests are made + await act(async () => { + vi.advanceTimersByTime(12000); + await Promise.resolve(); + }); + + // Request count must remain strictly 2 + expect(fetchStatusFn).toHaveBeenCalledTimes(2); + }); + + it('stops polling when a failed terminal status is received and makes no further requests', async () => { + let callCount = 0; + const fetchStatusFn = vi.fn().mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + return { status: 'pending' }; + } + return { status: 'failed' }; + }); + + const { result } = renderHook(() => + useTransactionStatus({ + hash: '0xdeadbeef', + pollIntervalMs: 3000, + fetchStatusFn, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(1); + + // Advance by 3s -> returns failed + await act(async () => { + vi.advanceTimersByTime(3000); + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(2); + expect(result.current.status).toBe('failed'); + expect(result.current.isPolling).toBe(false); + + // Advance time by another 15s + await act(async () => { + vi.advanceTimersByTime(15000); + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(2); + }); + + it('stops polling cleanly on unmount', async () => { + const fetchStatusFn = vi.fn().mockResolvedValue({ status: 'pending' }); + + const { unmount } = renderHook(() => + useTransactionStatus({ + hash: '0x1234', + pollIntervalMs: 3000, + fetchStatusFn, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(1); + + unmount(); + + await act(async () => { + vi.advanceTimersByTime(9000); + await Promise.resolve(); + }); + + expect(fetchStatusFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/e2e/accessibility.spec.ts b/frontend/tests/e2e/accessibility.spec.ts index 5d4342b9..af996058 100644 --- a/frontend/tests/e2e/accessibility.spec.ts +++ b/frontend/tests/e2e/accessibility.spec.ts @@ -92,6 +92,54 @@ test.describe('Accessibility Tests', () => { } }); + test('Explore page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/explore`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Explore Page'); + }); + + test('About page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/about`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'About Page'); + }); + + test('Transaction History page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/history`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Transaction History Page'); + }); + + test('Proof of Reserves page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/proof-of-reserves`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Proof of Reserves Page'); + }); + + test('User Profile page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/profile`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'User Profile Page'); + }); + + test('Admin dashboard page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/admin`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Admin Dashboard'); + }); + + test('Admin Analytics page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/admin/analytics`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Admin Analytics'); + }); + + test('Admin Webhooks page - no critical a11y violations', async ({ page }) => { + await page.goto(`${FRONTEND_URL}/admin/webhooks`); + await page.waitForLoadState('networkidle'); + await checkA11y(page, 'Admin Webhooks'); + }); + test('Navigation and header - no critical a11y violations', async ({ page }) => { await page.goto(`${FRONTEND_URL}/`); diff --git a/package.json b/package.json index d3087ea9..649a5ece 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "dev:devnet": "docker compose --profile devnet up --build", "dev:devnet:down": "docker compose --profile devnet down -v", "codegen": "cd sdk/client && npm install && npm run generate", - "codegen:check": "cd sdk/client && npm install && npm run check-drift" + "codegen:check": "cd sdk/client && npm install && npm run check-drift", + "pre-commit": "bash ./scripts/pre-commit.sh" }, "workspaces": [ "backend", @@ -47,6 +48,7 @@ "campaign" ], "devDependencies": { + "husky": "^9.1.6", "prettier": "^3.3.0", "turbo": "^2.9.0" }, diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100644 index 00000000..1e2aa3e3 --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Pre-commit hook script enforcing Prettier, ESLint, and Rustfmt +set -e + +echo "==> Running pre-commit checks..." + +# 1. Prettier +echo "--> Checking Prettier formatting..." +npx prettier --check "backend/src/**/*.{js,json}" "frontend/src/**/*.{js,jsx,ts,tsx}" "**/*.{md,yaml,yml}" || { + echo "❌ Prettier check failed. Run 'npm run format' to fix." + exit 1 +} + +# 2. ESLint +echo "--> Checking ESLint..." +npm run lint || { + echo "❌ ESLint check failed. Run 'npm run lint:fix' or fix reported issues." + exit 1 +} + +# 3. Rustfmt +echo "--> Checking Rustfmt..." +cargo fmt --all -- --check || { + echo "❌ Rustfmt check failed. Run 'cargo fmt' to fix." + exit 1 +} + +echo "✅ All pre-commit checks passed!" +exit 0