fix(security): audit fixes across 9 custom pallets

- pez-rewards: checked arithmetic in parliamentary reward distribution
- tiki: saturating_add in get_tiki_score fold, benchmarking cleanup
- ping: saturating_add on PingCount
- staking-score: saturating_mul on 4 duration multipliers
- pez-treasury: proper error on TreasuryStartBlock None, saturating_add on NextReleaseMonth, doc fix 70->75%
- messaging: InboxOverflow event on FIFO eviction
- token-wrapper: reorder wrap/unwrap operations, add pallet balance pre-check
- welati: u64 cast for turnout percentage overflow prevention
- presale: fix refund calculation to use net_in_treasury (98%) instead of impossible 99%, update tests
This commit is contained in:
2026-03-22 18:56:37 +03:00
parent ad9204cab1
commit 288978c088
13 changed files with 148 additions and 54 deletions
@@ -194,6 +194,8 @@ pub mod pezpallet {
EraPurged { era: u32 }, EraPurged { era: u32 },
/// Era rotated /// Era rotated
EraRotated { old_era: u32, new_era: u32 }, EraRotated { old_era: u32, new_era: u32 },
/// Oldest message was evicted from a full inbox (FIFO)
InboxOverflow { recipient: T::AccountId, era: u32 },
} }
// ============= ERRORS ============= // ============= ERRORS =============
@@ -435,6 +437,10 @@ pub mod pezpallet {
if inbox.len() >= T::MaxInboxSize::get() as usize { if inbox.len() >= T::MaxInboxSize::get() as usize {
// FIFO: remove oldest message to make room // FIFO: remove oldest message to make room
inbox.remove(0); inbox.remove(0);
Self::deposit_event(Event::InboxOverflow {
recipient: to.clone(),
era: current_era,
});
} }
inbox.try_push(message).map_err(|_| Error::<T>::InboxFull)?; inbox.try_push(message).map_err(|_| Error::<T>::InboxFull)?;
Ok(()) Ok(())
@@ -480,7 +480,10 @@ pub mod pezpallet {
Self::distribute_parliamentary_rewards(current_epoch, total_reward_pool)?; Self::distribute_parliamentary_rewards(current_epoch, total_reward_pool)?;
// Remaining 90% for trust score rewards // Remaining 90% for trust score rewards
let trust_score_pool = total_reward_pool * 90u32.into() / 100u32.into(); let trust_score_pool = total_reward_pool
.checked_mul(&90u32.into())
.and_then(|v| v.checked_div(&100u32.into()))
.unwrap_or_else(Zero::zero);
// Calculate total trust score of all users in this epoch // Calculate total trust score of all users in this epoch
let mut total_trust_score = 0u128; let mut total_trust_score = 0u128;
@@ -670,9 +673,18 @@ pub mod pezpallet {
epoch: u32, epoch: u32,
total_incentive_pool: BalanceOf<T>, total_incentive_pool: BalanceOf<T>,
) -> DispatchResult { ) -> DispatchResult {
let parliamentary_allocation = let parliamentary_allocation = total_incentive_pool
total_incentive_pool * PARLIAMENTARY_REWARD_PERCENT.into() / 100u32.into(); .checked_mul(&PARLIAMENTARY_REWARD_PERCENT.into())
let per_nft_reward = parliamentary_allocation / PARLIAMENTARY_NFT_COUNT.into(); .and_then(|v| v.checked_div(&100u32.into()))
.unwrap_or_else(Zero::zero);
let per_nft_reward = parliamentary_allocation
.checked_div(&PARLIAMENTARY_NFT_COUNT.into())
.unwrap_or_else(Zero::zero);
// Skip the loop entirely if per_nft_reward rounds to zero
if per_nft_reward.is_zero() {
return Ok(());
}
let incentive_pot = Self::incentive_pot_account_id(); let incentive_pot = Self::incentive_pot_account_id();
@@ -26,7 +26,7 @@
//! //!
//! - **Halving Period**: Every 48 months (4 years) //! - **Halving Period**: Every 48 months (4 years)
//! - **Period Duration**: 20,736,000 blocks (~4 years at 10 blocks/minute) //! - **Period Duration**: 20,736,000 blocks (~4 years at 10 blocks/minute)
//! - **Distribution**: 70% to Incentive Pot, 30% to Government Pot //! - **Distribution**: 75% to Incentive Pot, 25% to Government Pot
//! - **Automatic Halving**: Monthly release amount halves at the start of each new period //! - **Automatic Halving**: Monthly release amount halves at the start of each new period
//! //!
//! ## Security Features //! ## Security Features
@@ -360,10 +360,10 @@ pub mod pezpallet {
} }
pub fn do_monthly_release() -> DispatchResult { pub fn do_monthly_release() -> DispatchResult {
ensure!(TreasuryStartBlock::<T>::get().is_some(), Error::<T>::TreasuryNotInitialized); let start_block = TreasuryStartBlock::<T>::get()
.ok_or(Error::<T>::TreasuryNotInitialized)?;
let current_block = pezframe_system::Pezpallet::<T>::block_number(); let current_block = pezframe_system::Pezpallet::<T>::block_number();
let start_block = TreasuryStartBlock::<T>::get().unwrap();
let next_month = NextReleaseMonth::<T>::get(); let next_month = NextReleaseMonth::<T>::get();
ensure!( ensure!(
@@ -441,7 +441,7 @@ pub mod pezpallet {
}; };
MonthlyReleases::<T>::insert(next_month, release_info); MonthlyReleases::<T>::insert(next_month, release_info);
NextReleaseMonth::<T>::put(next_month + 1); NextReleaseMonth::<T>::put(next_month.saturating_add(1));
Self::deposit_event(Event::MonthlyFundsReleased { Self::deposit_event(Event::MonthlyFundsReleased {
month_index: next_month, month_index: next_month,
@@ -116,7 +116,7 @@ pub mod pezpallet {
fn on_finalize(n: BlockNumberFor<T>) { fn on_finalize(n: BlockNumberFor<T>) {
for (para, payload) in Targets::<T>::get().into_iter() { for (para, payload) in Targets::<T>::get().into_iter() {
let seq = PingCount::<T>::mutate(|seq| { let seq = PingCount::<T>::mutate(|seq| {
*seq += 1; *seq = seq.saturating_add(1);
*seq *seq
}); });
match send_xcm::<T::XcmSender>( match send_xcm::<T::XcmSender>(
@@ -979,14 +979,16 @@ pub mod pezpallet {
if let Some(contribution_info) = Contributions::<T>::get(presale_id, contributor) { if let Some(contribution_info) = Contributions::<T>::get(presale_id, contributor) {
if !contribution_info.refunded && contribution_info.amount > 0 { if !contribution_info.refunded && contribution_info.amount > 0 {
let platform_fee = contribution_info // Calculate net amount in treasury (original - platform fee already deducted at contribution)
let platform_fee_at_contribution = contribution_info
.amount .amount
.saturating_mul(T::PlatformFeePercent::get() as u128) .saturating_mul(T::PlatformFeePercent::get() as u128)
/ 100; / 100;
let non_refundable = platform_fee.saturating_mul(50) / 100; let net_in_treasury =
contribution_info.amount.saturating_sub(platform_fee_at_contribution);
let refund_amount: T::Balance = // Refund the full net amount (cancelled presale = no additional fee)
contribution_info.amount.saturating_sub(non_refundable).into(); let refund_amount: T::Balance = net_in_treasury.into();
T::Assets::transfer( T::Assets::transfer(
presale.payment_asset, presale.payment_asset,
@@ -1000,7 +1002,7 @@ pub mod pezpallet {
if let Some(info) = maybe_info { if let Some(info) = maybe_info {
info.refunded = true; info.refunded = true;
info.refunded_at = Some(current_block); info.refunded_at = Some(current_block);
info.refund_fee_paid = 0; info.refund_fee_paid = platform_fee_at_contribution;
} }
Ok::<_, Error<T>>(()) Ok::<_, Error<T>>(())
})?; })?;
@@ -1063,16 +1065,16 @@ pub mod pezpallet {
if let Some(contribution_info) = Contributions::<T>::get(presale_id, contributor) { if let Some(contribution_info) = Contributions::<T>::get(presale_id, contributor) {
// Skip if already refunded or zero amount // Skip if already refunded or zero amount
if !contribution_info.refunded && contribution_info.amount > 0 { if !contribution_info.refunded && contribution_info.amount > 0 {
// Calculate non-refundable portion (burn + stakers = 50% of platform fee) // Calculate net amount in treasury (original - platform fee already deducted at contribution)
let platform_fee = contribution_info let platform_fee_at_contribution = contribution_info
.amount .amount
.saturating_mul(T::PlatformFeePercent::get() as u128) .saturating_mul(T::PlatformFeePercent::get() as u128)
/ 100; / 100;
let non_refundable = platform_fee.saturating_mul(50) / 100; // 1% (burn 25% + stakers 25%) let net_in_treasury =
contribution_info.amount.saturating_sub(platform_fee_at_contribution);
// Refund = 99% (contribution - non_refundable portion) // Refund the full net amount (failed presale = no additional fee)
let refund_amount: T::Balance = let refund_amount: T::Balance = net_in_treasury.into();
contribution_info.amount.saturating_sub(non_refundable).into();
T::Assets::transfer( T::Assets::transfer(
presale.payment_asset, presale.payment_asset,
@@ -1253,11 +1253,12 @@ fn batch_refund_failed_presale_works() {
10, // batch_size (refund up to 10 contributors) 10, // batch_size (refund up to 10 contributors)
)); ));
// Check contributors got refunds minus non-refundable platform fee portion // Check contributors got refunds of net amount in treasury
// Platform fee = 500M * 2% = 10M, non-refundable = 10M * 50% = 5M // Platform fee = 500M * 2% = 10M (already distributed at contribution time)
// Refund = 500M - 5M = 495M // Treasury received net_amount = 500M - 10M = 490M per contributor
assert_eq!(Assets::balance(2, 2), bob_initial + 495_000_000); // Refund = 490M (full net amount, no additional fee for failed presale)
assert_eq!(Assets::balance(2, 3), charlie_initial + 495_000_000); assert_eq!(Assets::balance(2, 2), bob_initial + 490_000_000);
assert_eq!(Assets::balance(2, 3), charlie_initial + 490_000_000);
// Check contributions marked as refunded // Check contributions marked as refunded
let bob_contribution = Presale::contributions(0, 2).unwrap(); let bob_contribution = Presale::contributions(0, 2).unwrap();
@@ -327,13 +327,13 @@ pub mod pezpallet {
let duration_in_blocks = current_block.saturating_sub(start_block); let duration_in_blocks = current_block.saturating_sub(start_block);
let score = if duration_in_blocks >= (12 * MONTH_IN_BLOCKS).into() { let score = if duration_in_blocks >= (12 * MONTH_IN_BLOCKS).into() {
amount_score * 2 // x2.0 (12+ months) amount_score.saturating_mul(2) // x2.0 (12+ months)
} else if duration_in_blocks >= (6 * MONTH_IN_BLOCKS).into() { } else if duration_in_blocks >= (6 * MONTH_IN_BLOCKS).into() {
amount_score * 17 / 10 // x1.7 (6-11 months) amount_score.saturating_mul(17) / 10 // x1.7 (6-11 months)
} else if duration_in_blocks >= (3 * MONTH_IN_BLOCKS).into() { } else if duration_in_blocks >= (3 * MONTH_IN_BLOCKS).into() {
amount_score * 14 / 10 // x1.4 (3-5 months) amount_score.saturating_mul(14) / 10 // x1.4 (3-5 months)
} else if duration_in_blocks >= MONTH_IN_BLOCKS.into() { } else if duration_in_blocks >= MONTH_IN_BLOCKS.into() {
amount_score * 12 / 10 // x1.2 (1-2 months) amount_score.saturating_mul(12) / 10 // x1.2 (1-2 months)
} else { } else {
amount_score // x1.0 (< 1 month) amount_score // x1.0 (< 1 month)
}; };
@@ -161,13 +161,47 @@ mod benchmarks {
Ok(()) Ok(())
} }
// Temporarily skip this benchmark due to KYC complexity in benchmark environment #[benchmark]
// #[benchmark] fn apply_for_citizenship() -> Result<(), BenchmarkError> {
// fn apply_for_citizenship() -> Result<(), BenchmarkError> { let caller: T::AccountId = whitelisted_caller();
// // KYC setup is complex in benchmark environment
// // This functionality is covered by force_mint_citizen_nft benchmark // Fund the caller
// Ok(()) let funding = Balances::<T>::minimum_balance() * 1_000_000_000u32.into();
// } Balances::<T>::make_free_balance_be(&caller, funding);
// Ensure collection exists
ensure_collection_exists::<T>();
// Set KYC status to Approved directly in storage
pezpallet_identity_kyc::KycStatuses::<T>::insert(
&caller,
pezpallet_identity_kyc::types::KycLevel::Approved,
);
#[extrinsic_call]
_(RawOrigin::Signed(caller.clone()));
// Verify citizenship was granted
assert!(Tiki::<T>::is_citizen(&caller));
Ok(())
}
#[benchmark]
fn check_transfer_permission() -> Result<(), BenchmarkError> {
let caller: T::AccountId = whitelisted_caller();
let dest: T::AccountId = account("dest", 0, 0);
// Ensure collections exist past tiki collection so we have a valid non-tiki ID
ensure_collection_exists::<T>();
// NextCollectionId is past tiki, so it's a non-tiki collection (call succeeds)
let non_tiki_id = pezpallet_nfts::NextCollectionId::<T>::get().unwrap_or_default();
#[extrinsic_call]
_(RawOrigin::Signed(caller.clone()), non_tiki_id, 0u32, caller.clone(), dest);
Ok(())
}
impl_benchmark_test_suite!(Tiki, crate::mock::new_test_ext(), crate::mock::Test); impl_benchmark_test_suite!(Tiki, crate::mock::new_test_ext(), crate::mock::Test);
} }
@@ -455,7 +455,7 @@ pub mod pezpallet {
/// Manually mint citizenship NFT (for testing/emergency) /// Manually mint citizenship NFT (for testing/emergency)
#[pezpallet::call_index(2)] #[pezpallet::call_index(2)]
#[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_tiki())] #[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::force_mint_citizen_nft())]
pub fn force_mint_citizen_nft( pub fn force_mint_citizen_nft(
origin: OriginFor<T>, origin: OriginFor<T>,
dest: <T::Lookup as StaticLookup>::Source, dest: <T::Lookup as StaticLookup>::Source,
@@ -469,7 +469,7 @@ pub mod pezpallet {
/// Grant role through election system (called from pezpallet-voting) /// Grant role through election system (called from pezpallet-voting)
#[pezpallet::call_index(3)] #[pezpallet::call_index(3)]
#[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_tiki())] #[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_elected_role())]
pub fn grant_elected_role( pub fn grant_elected_role(
origin: OriginFor<T>, origin: OriginFor<T>,
dest: <T::Lookup as StaticLookup>::Source, dest: <T::Lookup as StaticLookup>::Source,
@@ -490,7 +490,7 @@ pub mod pezpallet {
/// Grant role through exam/test system /// Grant role through exam/test system
#[pezpallet::call_index(4)] #[pezpallet::call_index(4)]
#[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_tiki())] #[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_earned_role())]
pub fn grant_earned_role( pub fn grant_earned_role(
origin: OriginFor<T>, origin: OriginFor<T>,
dest: <T::Lookup as StaticLookup>::Source, dest: <T::Lookup as StaticLookup>::Source,
@@ -511,7 +511,7 @@ pub mod pezpallet {
/// Apply for citizenship after KYC completion /// Apply for citizenship after KYC completion
#[pezpallet::call_index(5)] #[pezpallet::call_index(5)]
#[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_tiki())] #[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::apply_for_citizenship())]
pub fn apply_for_citizenship(origin: OriginFor<T>) -> DispatchResult { pub fn apply_for_citizenship(origin: OriginFor<T>) -> DispatchResult {
let who = ensure_signed(origin)?; let who = ensure_signed(origin)?;
@@ -530,7 +530,7 @@ pub mod pezpallet {
/// Check NFT transfer for transfer blocking system /// Check NFT transfer for transfer blocking system
#[pezpallet::call_index(6)] #[pezpallet::call_index(6)]
#[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::grant_tiki())] #[pezpallet::weight(<T as crate::pezpallet::Config>::WeightInfo::check_transfer_permission())]
pub fn check_transfer_permission( pub fn check_transfer_permission(
_origin: OriginFor<T>, _origin: OriginFor<T>,
collection_id: T::CollectionId, collection_id: T::CollectionId,
@@ -780,7 +780,7 @@ pub trait TikiProvider<AccountId> {
impl<T: Config> TikiScoreProvider<T::AccountId> for Pezpallet<T> { impl<T: Config> TikiScoreProvider<T::AccountId> for Pezpallet<T> {
fn get_tiki_score(who: &T::AccountId) -> u32 { fn get_tiki_score(who: &T::AccountId) -> u32 {
let tikis = Self::user_tikis(who); let tikis = Self::user_tikis(who);
tikis.iter().map(Self::get_bonus_for_tiki).sum() tikis.iter().map(Self::get_bonus_for_tiki).fold(0u32, |acc, x| acc.saturating_add(x))
} }
} }
@@ -60,6 +60,8 @@ pub trait WeightInfo {
fn force_mint_citizen_nft() -> Weight; fn force_mint_citizen_nft() -> Weight;
fn grant_earned_role() -> Weight; fn grant_earned_role() -> Weight;
fn grant_elected_role() -> Weight; fn grant_elected_role() -> Weight;
fn apply_for_citizenship() -> Weight;
fn check_transfer_permission() -> Weight;
} }
/// Weights for `pezpallet_tiki` using the Bizinikiwi node and recommended hardware. /// Weights for `pezpallet_tiki` using the Bizinikiwi node and recommended hardware.
@@ -178,6 +180,24 @@ impl<T: pezframe_system::Config> WeightInfo for BizinikiwiWeight<T> {
.saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().reads(6_u64))
.saturating_add(T::DbWeight::get().writes(3_u64)) .saturating_add(T::DbWeight::get().writes(3_u64))
} }
/// Storage: `IdentityKyc::Verifications` (r:1 w:0)
/// Proof: `IdentityKyc::Verifications` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`)
/// Plus all storage from `force_mint_citizen_nft` (9r + 9w)
fn apply_for_citizenship() -> Weight {
// Conservative estimate: force_mint_citizen_nft + 1 KYC verification read
// Minimum execution time: 120_000_000 picoseconds.
Weight::from_parts(125_000_000, 4326)
.saturating_add(T::DbWeight::get().reads(10_u64))
.saturating_add(T::DbWeight::get().writes(9_u64))
}
/// Storage: `Tiki::CitizenNft` (r:1 w:0)
/// Proof: `Tiki::CitizenNft` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`)
fn check_transfer_permission() -> Weight {
// Lightweight read-only check
// Minimum execution time: 12_000_000 picoseconds.
Weight::from_parts(13_000_000, 2527)
.saturating_add(T::DbWeight::get().reads(1_u64))
}
} }
// For backwards compatibility and tests. // For backwards compatibility and tests.
@@ -295,4 +315,15 @@ impl WeightInfo for () {
.saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().reads(6_u64))
.saturating_add(RocksDbWeight::get().writes(3_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64))
} }
fn apply_for_citizenship() -> Weight {
// Conservative estimate: force_mint_citizen_nft + 1 KYC verification read
Weight::from_parts(125_000_000, 4326)
.saturating_add(RocksDbWeight::get().reads(10_u64))
.saturating_add(RocksDbWeight::get().writes(9_u64))
}
fn check_transfer_permission() -> Weight {
// Lightweight read-only check
Weight::from_parts(13_000_000, 2527)
.saturating_add(RocksDbWeight::get().reads(1_u64))
}
} }
@@ -151,15 +151,16 @@ pub mod pezpallet {
) )
.map_err(|_| Error::<T>::TransferFailed)?; .map_err(|_| Error::<T>::TransferFailed)?;
// Update total locked // Mint wrapped tokens to user BEFORE updating TotalLocked
// If mint fails, the extrinsic reverts (including the transfer above)
T::Assets::mint_into(T::WrapperAssetId::get(), &who, amount)
.map_err(|_| Error::<T>::MintFailed)?;
// Update total locked only after both transfer and mint succeeded
TotalLocked::<T>::mutate(|total| { TotalLocked::<T>::mutate(|total| {
*total = total.saturating_add(amount); *total = total.saturating_add(amount);
}); });
// Mint wrapped tokens to user
T::Assets::mint_into(T::WrapperAssetId::get(), &who, amount)
.map_err(|_| Error::<T>::MintFailed)?;
Self::deposit_event(Event::Wrapped { who, amount }); Self::deposit_event(Event::Wrapped { who, amount });
Ok(()) Ok(())
} }
@@ -188,6 +189,10 @@ pub mod pezpallet {
let wrapped_balance = T::Assets::balance(T::WrapperAssetId::get(), &who); let wrapped_balance = T::Assets::balance(T::WrapperAssetId::get(), &who);
ensure!(wrapped_balance >= amount, Error::<T>::InsufficientWrappedBalance); ensure!(wrapped_balance >= amount, Error::<T>::InsufficientWrappedBalance);
// Verify pallet has sufficient backing before any state changes
let pallet_balance = T::Currency::free_balance(&Self::account_id());
ensure!(pallet_balance >= amount, Error::<T>::TransferFailed);
// Burn wrapped tokens from user // Burn wrapped tokens from user
T::Assets::burn_from( T::Assets::burn_from(
T::WrapperAssetId::get(), T::WrapperAssetId::get(),
@@ -199,12 +204,8 @@ pub mod pezpallet {
) )
.map_err(|_| Error::<T>::BurnFailed)?; .map_err(|_| Error::<T>::BurnFailed)?;
// Update total locked
TotalLocked::<T>::mutate(|total| {
*total = total.saturating_sub(amount);
});
// Transfer native tokens back to user (unlock) // Transfer native tokens back to user (unlock)
// If this fails, the extrinsic reverts (including the burn above)
T::Currency::transfer( T::Currency::transfer(
&Self::account_id(), &Self::account_id(),
&who, &who,
@@ -213,6 +214,11 @@ pub mod pezpallet {
) )
.map_err(|_| Error::<T>::TransferFailed)?; .map_err(|_| Error::<T>::TransferFailed)?;
// Update total locked only after both burn and transfer succeeded
TotalLocked::<T>::mutate(|total| {
*total = total.saturating_sub(amount);
});
Self::deposit_event(Event::Unwrapped { who, amount }); Self::deposit_event(Event::Unwrapped { who, amount });
Ok(()) Ok(())
} }
@@ -400,7 +400,8 @@ pub mod pezpallet {
.saturating_add(perwerde_u128.saturating_mul(300)) .saturating_add(perwerde_u128.saturating_mul(300))
.saturating_add(tiki_u128.saturating_mul(300)); .saturating_add(tiki_u128.saturating_mul(300));
let final_score_u128 = staking_u128 // Safe: both operands are derived from u32 scores, product fits in u128
let final_score_u128 = staking_u128
.saturating_mul(weighted_sum) .saturating_mul(weighted_sum)
.checked_div(base) .checked_div(base)
.ok_or(Error::<T>::CalculationOverflow)?; .ok_or(Error::<T>::CalculationOverflow)?;
@@ -860,7 +860,8 @@ pub mod pezpallet {
let total_citizen_count = Self::get_total_citizen_count(); let total_citizen_count = Self::get_total_citizen_count();
let turnout_percentage = if total_citizen_count > 0 { let turnout_percentage = if total_citizen_count > 0 {
((election.total_votes * 100) / total_citizen_count) as u8 ((election.total_votes as u64).saturating_mul(100) / total_citizen_count as u64)
as u8
} else { } else {
0 0
}; };