From 684db6b1eb59bd92a701d395c5de5ef1637c414e Mon Sep 17 00:00:00 2001 From: Damola09 Date: Sat, 27 Jun 2026 16:40:08 +0100 Subject: [PATCH] feat: resolve issues #542 #554 #563 #569 (closes #542, closes #554, closes #563, closes #569) - feat(revenue-distributor): add unit tests for distribution weights, zero balance no-op, full gov share, zero gov share, invalid bps panics, and set_shares re-distribution (#569) - feat(yield-contract): apply CEI pattern to deposit_rewards and stake so state updates precede external token transfers; remove duplicate event publish syntax errors (#563) - feat(devops): add clean script to backend/package.json that deletes SQLite journal files and removes logs/coverage directories (#542) - feat(backend-sep12): add confirmUpload endpoint that verifies session account matches KycCustomer record account, returns 403 on mismatch (#554) --- backend/package.json | 3 +- .../api/controllers/sep12.controller.test.ts | 62 +++++++++++ .../src/api/controllers/sep12.controller.ts | 30 ++++++ backend/src/api/routes/sep12.route.ts | 10 ++ contracts/revenue_distributor/src/lib.rs | 102 +++++++++++++++++- contracts/yield/src/lib.rs | 35 +++--- 6 files changed, 221 insertions(+), 21 deletions(-) diff --git a/backend/package.json b/backend/package.json index aef33e20..534ea4ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,8 @@ "migrate:verify": "ts-node scripts/verify-migrations.ts", "migrate:check": "ts-node scripts/migration-integrity-checker.ts", "migrate:rollback": "ts-node scripts/generate-rollback.ts", - "migrate:status": "prisma migrate status" + "migrate:status": "prisma migrate status", + "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 a96b25d4..527ed9ee 100644 --- a/backend/src/api/controllers/sep12.controller.test.ts +++ b/backend/src/api/controllers/sep12.controller.test.ts @@ -142,4 +142,66 @@ describe('Sep12Controller', () => { expect(res.status).toHaveBeenCalledWith(401); 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(); + }); + }); }); diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 9ef26c8f..513ec987 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -3,6 +3,7 @@ import prisma from '../../lib/prisma'; import { cryptoService } from '../../services/crypto.service'; import { kycProvider, KycStatus } from '../../services/kyc-provider.service'; import { KYCStatus } from '@prisma/client'; +import { AuthRequest } from '../middleware/auth.middleware'; export class Sep12Controller { @@ -163,6 +164,35 @@ export class Sep12Controller { } } + async confirmUpload(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const authenticatedAccount = req.user?.publicKey; + + if (!authenticatedAccount) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const kycRecord = await prisma.kycCustomer.findUnique({ + where: { id }, + include: { user: true }, + }); + + if (!kycRecord) { + return res.status(404).json({ error: 'Upload record not found' }); + } + + if (kycRecord.user.publicKey !== authenticatedAccount) { + return res.status(403).json({ error: 'Forbidden: account mismatch' }); + } + + return res.status(200).json({ status: 'confirmed', id: kycRecord.id }); + } catch (error) { + console.error(error); + return res.status(500).json({ error: 'Internal Server Error' }); + } + } + async handleWebhook(req: Request, res: Response) { try { const signature = req.headers['x-kyc-signature'] as string | undefined; diff --git a/backend/src/api/routes/sep12.route.ts b/backend/src/api/routes/sep12.route.ts index ac2c88d3..a06f6941 100644 --- a/backend/src/api/routes/sep12.route.ts +++ b/backend/src/api/routes/sep12.route.ts @@ -3,6 +3,7 @@ import multer from 'multer'; import fs from 'fs'; import path from 'path'; import { sep12Controller } from '../controllers/sep12.controller'; +import { authMiddleware } from '../middleware/auth.middleware'; const router = Router(); @@ -61,4 +62,13 @@ router.delete('/customer/:account', sep12Controller.deleteCustomer); */ router.post('/webhook', sep12Controller.handleWebhook); +/** + * @swagger + * /sep12/customer/{id}/confirm: + * post: + * summary: Confirm upload ownership — session account must match record account + * tags: [SEP-12] + */ +router.post('/customer/:id/confirm', authMiddleware, (req, res) => sep12Controller.confirmUpload(req as any, res)); + export default router; 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 97ad8e81..d5dc3da1 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,10 +116,15 @@ 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)); - .publish((symbol_short!("dep_rwd"), from, reward_token), amount); } // ── Staking ─────────────────────────────────────────────────────────── @@ -149,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() @@ -169,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)); } @@ -251,10 +250,8 @@ impl YieldDistribution { &reward, ); - // Topic: event name only; user + reward in data. env.events() .publish(symbol_short!("claimed"), (user, reward)); - .publish((symbol_short!("claimed"), user, reward_token), reward); } reward