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 (