Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
150 changes: 150 additions & 0 deletions contracts/rewards/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ use soroban_sdk::{
};

pub mod groth16;
pub mod types;
use types::DataKey;

#[cfg(test)]
mod error_codes_test;
Expand Down Expand Up @@ -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<Address> = 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<Address> {
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)> {
Expand Down
28 changes: 28 additions & 0 deletions contracts/rewards/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions contracts/rewards/src/types.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use soroban_sdk::{contracttype, Address};

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
RewardPool(Address),
SupportedAssets,
}
45 changes: 38 additions & 7 deletions frontend/src/components/TransactionStatus.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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 =
Expand All @@ -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 (
<div
className={`tx-status tx-status--${variant}`}
className={`tx-status tx-status--${activeVariant}`}
role={isError ? 'alert' : 'status'}
aria-live={isError ? 'assertive' : 'polite'}
>
<div className="tx-status-header">
<span className="tx-status-icon" aria-hidden="true">
{VARIANT_ICON[variant] ?? VARIANT_ICON.success}
{VARIANT_ICON[activeVariant] ?? VARIANT_ICON.success}
</span>
<span className="tx-status-label">{label}</span>
</div>
Expand Down
Loading