diff --git a/backend/package.json b/backend/package.json index 63ca140..214e85a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "migrate:status": "prisma migrate status", "migrate:validate-env": "node scripts/validate-migration-env.js", "migrate:bootstrap": "bash scripts/bootstrap-db.sh", + "clean": "rm -rf logs coverage && find . -name '*.db-journal' -delete && find . -name '*.sqlite-journal' -delete" "clean": "node -e \"const fs=require('fs'),path=require('path');function walk(dir){if(!fs.existsSync(dir))return;for(const f of fs.readdirSync(dir)){const fp=path.join(dir,f);if(fs.statSync(fp).isDirectory())walk(fp);else if(['.db-journal','.db-wal','.db-shm'].some(e=>f.endsWith(e))){fs.unlinkSync(fp);console.log('Removed:',fp)}}};walk('.');['logs','coverage'].forEach(d=>{if(fs.existsSync(d)){fs.rmSync(d,{recursive:true,force:true});console.log('Removed:',d)}});console.log('Clean complete');\"" }, "dependencies": { diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 66f0920..54460f5 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -366,6 +366,10 @@ export class Sep12Controller { return res.status(400).json({ error: 'upload_id and account are required' }); } + if (req.user && req.user.publicKey !== account) { + return res.status(403).json({ error: 'Forbidden: session account does not match request account' }); + } + const record = uploadStore.get(upload_id); if (!record || record.status === 'EXPIRED') { diff --git a/contracts/revenue_distributor/src/lib.rs b/contracts/revenue_distributor/src/lib.rs index 60b0c4e..42b22fe 100644 --- a/contracts/revenue_distributor/src/lib.rs +++ b/contracts/revenue_distributor/src/lib.rs @@ -159,6 +159,27 @@ mod tests { } #[test] + fn test_zero_balance_distribute() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + distributor_client.initialize(&admin, &treasury, &gov_stakers, &6000); + + // Distributor has no balance — distribute should be a no-op + distributor_client.distribute(&token_id.address()); + + assert_eq!(token_client.balance(&gov_stakers), 0); + assert_eq!(token_client.balance(&treasury), 0); fn test_zero_balance_distribute_is_noop() { let env = Env::default(); env.mock_all_auths(); @@ -183,6 +204,26 @@ mod tests { fn test_full_gov_share() { let env = Env::default(); env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + // 100% to gov_stakers, 0% to treasury + distributor_client.initialize(&admin, &treasury, &gov_stakers, &10000); + + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + distributor_client.distribute(&token_id.address()); + + assert_eq!(token_client.balance(&gov_stakers), 1000); + assert_eq!(token_client.balance(&treasury), 0); + assert_eq!(token_client.balance(&distributor_id), 0); let admin = Address::generate(&env); let treasury = Address::generate(&env); let gov_stakers = Address::generate(&env); @@ -205,6 +246,33 @@ mod tests { fn test_zero_gov_share() { let env = Env::default(); env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + // 0% to gov_stakers, 100% to treasury + distributor_client.initialize(&admin, &treasury, &gov_stakers, &0); + + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + distributor_client.distribute(&token_id.address()); + + assert_eq!(token_client.balance(&gov_stakers), 0); + assert_eq!(token_client.balance(&treasury), 1000); + assert_eq!(token_client.balance(&distributor_id), 0); + } + + #[test] + fn test_weight_precision_with_odd_amounts() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); let treasury = Address::generate(&env); let gov_stakers = Address::generate(&env); @@ -245,6 +313,21 @@ mod tests { let treasury = Address::generate(&env); let gov_stakers = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + // 30% gov, 70% treasury + distributor_client.initialize(&admin, &treasury, &gov_stakers, &3000); + + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + distributor_client.distribute(&token_id.address()); + + assert_eq!(token_client.balance(&gov_stakers), 300); + assert_eq!(token_client.balance(&treasury), 700); + assert_eq!(token_client.balance(&distributor_id), 0); let distributor_id = env.register_contract(None, RevenueDistributor); let client = RevenueDistributorClient::new(&env, &distributor_id); client.initialize(&admin, &treasury, &gov_stakers, &10001); diff --git a/contracts/yield/src/lib.rs b/contracts/yield/src/lib.rs index 418fc09..2975ff5 100644 --- a/contracts/yield/src/lib.rs +++ b/contracts/yield/src/lib.rs @@ -98,6 +98,10 @@ impl YieldDistribution { .get(&DataKey::TotalStaked) .unwrap_or(0); + // Update state before external token transfer (reentrancy guard pattern). + // If nobody is staking yet, rewards accumulate but can't be distributed — + // they will be claimable once the first stake occurs (reward_per_token + // stays 0 until then, so the deposited tokens sit idle). let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); // CEI: update state before external token transfer @@ -116,6 +120,8 @@ impl YieldDistribution { .set(&DataKey::RewardPerTokenStored, &rpt); } + // Transfer reward tokens into the contract after state is updated + let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); // External interaction last token::Client::new(&env, &reward_token).transfer( &from, @@ -123,6 +129,7 @@ impl YieldDistribution { &amount, ); + // Topic: event name only; from + amount in data. env.events() .publish((symbol_short!("dep_rwd"),), (from, amount)); } @@ -145,6 +152,7 @@ impl YieldDistribution { // Settle any pending rewards before changing the stake Self::_update_reward(&env, &user); + // Update state before external token transfer (reentrancy guard pattern) let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); // CEI: update state before external token transfer @@ -162,6 +170,7 @@ impl YieldDistribution { .instance() .set(&DataKey::TotalStaked, &total.checked_add(amount).expect("total staked overflow")); + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); // External interaction last token::Client::new(&env, &stake_token).transfer( &user, @@ -169,6 +178,7 @@ impl YieldDistribution { &amount, ); + // Topic: event name only; user + amount in data. env.events() .publish((symbol_short!("staked"),), (user, amount)); } @@ -251,11 +261,8 @@ impl YieldDistribution { ); env.events() -<<<<<<< HEAD .publish(symbol_short!("claimed"), (user, reward)); -======= .publish((symbol_short!("claimed"),), (user, reward)); ->>>>>>> upstream/main } reward