diff --git a/backend/package.json b/backend/package.json index 36c5bb93..63ca1400 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,8 @@ "migrate:rollback": "ts-node scripts/generate-rollback.ts", "migrate:status": "prisma migrate status", "migrate:validate-env": "node scripts/validate-migration-env.js", - "migrate:bootstrap": "bash scripts/bootstrap-db.sh" + "migrate:bootstrap": "bash scripts/bootstrap-db.sh", + "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": { "@aws-sdk/client-kms": "^3.500.0", diff --git a/backend/src/api/controllers/sep12.controller.test.ts b/backend/src/api/controllers/sep12.controller.test.ts index 91941bad..fdccd353 100644 --- a/backend/src/api/controllers/sep12.controller.test.ts +++ b/backend/src/api/controllers/sep12.controller.test.ts @@ -321,6 +321,65 @@ describe('Sep12Controller', () => { expect(prismaMock.kycCustomer.update).not.toHaveBeenCalled(); }); + describe('confirmUpload', () => { + it('returns 200 when session account matches record account', async () => { + const req = { + params: { id: 'kyc-record-1' }, + user: { publicKey: 'GACC' }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.kycCustomer.findUnique.mockResolvedValue({ + id: 'kyc-record-1', + user: { publicKey: 'GACC' }, + }); + + await sep12Controller.confirmUpload(req as any, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns 403 when session account does not match record account', async () => { + const req = { + params: { id: 'kyc-record-1' }, + user: { publicKey: 'GDIFF' }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.kycCustomer.findUnique.mockResolvedValue({ + id: 'kyc-record-1', + user: { publicKey: 'GACC' }, + }); + + await sep12Controller.confirmUpload(req as any, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns 404 when record does not exist', async () => { + const req = { + params: { id: 'no-such-record' }, + user: { publicKey: 'GACC' }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.kycCustomer.findUnique.mockResolvedValue(null); + + await sep12Controller.confirmUpload(req as any, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 401 when no authenticated user on request', async () => { + const req = { + params: { id: 'kyc-record-1' }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req as any, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(prismaMock.kycCustomer.findUnique).not.toHaveBeenCalled(); describe('getUploadUrl', () => { it('returns 400 when field query param is missing', async () => { const req = { diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 30699ffc..66f09208 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -15,6 +15,7 @@ type UploadedFiles = { [fieldname: string]: Array<{ path: string }> }; const ALLOWED_CONTENT_TYPES = (process.env.UPLOAD_ALLOWED_CONTENT_TYPES ?? 'image/jpeg,image/png,application/pdf').split(','); const UPLOAD_URL_EXPIRY_SECONDS = parseInt(process.env.UPLOAD_URL_EXPIRY_SECONDS ?? '900', 10); const KEY_PREFIX = process.env.STORAGE_KEY_PREFIX ?? 'kyc'; + const pack = (enc?: { encryptedData: string; iv: string } | null) => enc ? `${enc.iv}|${enc.encryptedData}` : null; diff --git a/contracts/revenue_distributor/src/lib.rs b/contracts/revenue_distributor/src/lib.rs index 64933722..60b0c4e2 100644 --- a/contracts/revenue_distributor/src/lib.rs +++ b/contracts/revenue_distributor/src/lib.rs @@ -153,8 +153,108 @@ mod tests { let (env, distributor_id, admin, _, _, _) = setup(); let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); - distributor_client.set_shares(&admin, &8000); // Change to 80% + distributor_client.set_shares(&admin, &8000); let (_, _, gov_share) = distributor_client.get_config(); assert_eq!(gov_share, 8000); } + + #[test] + fn test_zero_balance_distribute_is_noop() { + 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 distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &5000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&treasury), 0); + assert_eq!(token_client.balance(&gov_stakers), 0); + } + + #[test] + 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 distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &10000); + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&gov_stakers), 1000); + assert_eq!(token_client.balance(&treasury), 0); + } + + #[test] + 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 distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &0); + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&gov_stakers), 0); + assert_eq!(token_client.balance(&treasury), 1000); + } + + #[test] + fn test_distribution_after_set_shares() { + let (env, distributor_id, admin, treasury, gov_stakers, token_addr) = setup(); + let client = RevenueDistributorClient::new(&env, &distributor_id); + let token_client = token::Client::new(&env, &token_addr); + + client.set_shares(&admin, &3000); + client.distribute(&token_addr); + + assert_eq!(token_client.balance(&gov_stakers), 300); + assert_eq!(token_client.balance(&treasury), 700); + } + + #[test] + #[should_panic(expected = "invalid share bps")] + fn test_invalid_bps_panics_on_initialize() { + 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 distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &10001); + } + + #[test] + #[should_panic(expected = "invalid share bps")] + fn test_invalid_bps_panics_on_set_shares() { + let (env, distributor_id, admin, _, _, _) = setup(); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.set_shares(&admin, &10001); + } } diff --git a/contracts/yield/src/lib.rs b/contracts/yield/src/lib.rs index c1de3736..418fc096 100644 --- a/contracts/yield/src/lib.rs +++ b/contracts/yield/src/lib.rs @@ -98,17 +98,9 @@ impl YieldDistribution { .get(&DataKey::TotalStaked) .unwrap_or(0); - // Transfer reward tokens into the contract let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); - token::Client::new(&env, &reward_token).transfer( - &from, - &env.current_contract_address(), - &amount, - ); - // 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). + // CEI: update state before external token transfer if total_staked > 0 { let mut rpt: i128 = env .storage() @@ -124,7 +116,13 @@ impl YieldDistribution { .set(&DataKey::RewardPerTokenStored, &rpt); } - // Topic: event name only; from + amount in data. + // External interaction last + token::Client::new(&env, &reward_token).transfer( + &from, + &env.current_contract_address(), + &amount, + ); + env.events() .publish((symbol_short!("dep_rwd"),), (from, amount)); } @@ -148,12 +146,8 @@ impl YieldDistribution { Self::_update_reward(&env, &user); let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); - token::Client::new(&env, &stake_token).transfer( - &user, - &env.current_contract_address(), - &amount, - ); + // CEI: update state before external token transfer let prev: i128 = Self::_stake_of(&env, &user); env.storage() .persistent() @@ -168,7 +162,13 @@ impl YieldDistribution { .instance() .set(&DataKey::TotalStaked, &total.checked_add(amount).expect("total staked overflow")); - // Topic: event name only; user + amount in data. + // External interaction last + token::Client::new(&env, &stake_token).transfer( + &user, + &env.current_contract_address(), + &amount, + ); + env.events() .publish((symbol_short!("staked"),), (user, amount)); } @@ -250,9 +250,12 @@ impl YieldDistribution { &reward, ); - // Topic: event name only; user + reward in data. env.events() +<<<<<<< HEAD + .publish(symbol_short!("claimed"), (user, reward)); +======= .publish((symbol_short!("claimed"),), (user, reward)); +>>>>>>> upstream/main } reward