diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 00000000..fa0baf1c --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "js,json", + "ignore": ["src/**/*.test.js", "src/tests/**", "src/integration/**/*.test.js", "*.db"], + "delay": "300" +} diff --git a/backend/package.json b/backend/package.json index b44d1840..1c07131f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "dev": "node --watch src/server.js", + "dev:docker": "nodemon --legacy-watch --exec node src/server.js", "start": "node src/server.js", "test": "node --test src/**/*.test.js", "test:integration": "node --test src/integration/*.test.js", diff --git a/backend/src/config/devHotReload.test.js b/backend/src/config/devHotReload.test.js new file mode 100644 index 00000000..5b24bff5 --- /dev/null +++ b/backend/src/config/devHotReload.test.js @@ -0,0 +1,55 @@ +// Verifies the Docker Compose dev services are wired for hot reload +// (issue #1366): source is bind-mounted and each dev service either uses a +// polling-capable watcher or opts into Chokidar polling, since bind mounts +// don't reliably deliver native filesystem events inside a container on +// every host platform. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +const repoRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../../..'); +const compose = yaml.load(readFileSync(path.join(repoRoot, 'compose.yaml'), 'utf8')); + +function bindMountsRepoRoot(service) { + return (service.volumes ?? []).some((v) => v === './:/app'); +} + +const backendServices = ['backend', 'backend-devnet']; +const frontendServices = ['frontend', 'frontend-devnet']; + +for (const name of backendServices) { + test(`${name} bind-mounts the repo and runs the polling-based dev script`, () => { + const service = compose.services[name]; + assert.ok(service, `expected a "${name}" service in compose.yaml`); + assert.ok(bindMountsRepoRoot(service), `${name} should bind-mount ./:/app for hot reload`); + assert.match( + service.command, + /npm run dev:docker/, + `${name} should run the docker-specific (polling) dev script` + ); + }); +} + +for (const name of frontendServices) { + test(`${name} bind-mounts the repo and enables Chokidar polling`, () => { + const service = compose.services[name]; + assert.ok(service, `expected a "${name}" service in compose.yaml`); + assert.ok(bindMountsRepoRoot(service), `${name} should bind-mount ./:/app for hot reload`); + assert.equal( + service.environment?.CHOKIDAR_USEPOLLING, + 'true', + `${name} should set CHOKIDAR_USEPOLLING=true so Vite polls the bind mount` + ); + }); +} + +test('backend package.json defines a polling-based dev:docker script', () => { + const backendPkg = JSON.parse( + readFileSync(path.join(repoRoot, 'backend/package.json'), 'utf8') + ); + assert.match(backendPkg.scripts['dev:docker'], /nodemon/); + assert.match(backendPkg.scripts['dev:docker'], /--legacy-watch/); +}); diff --git a/compose.yaml b/compose.yaml index e8abe300..aedd8225 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,11 @@ services: backend: image: node:20-alpine working_dir: /app/backend - command: sh -c "npm install && npm run dev" + # dev:docker runs nodemon in polling mode — bind-mounted source changes + # made on the host don't reliably emit native filesystem events inside + # the container on every platform (notably Docker Desktop on + # macOS/Windows), so polling is required for hot reload to work. + command: sh -c "npm install && npm run dev:docker" environment: PORT: 3001 CORS_ALLOWED_ORIGINS: http://localhost:5173 @@ -68,6 +72,9 @@ services: environment: VITE_API_URL: http://backend:3001 VITE_STELLAR_NETWORK: testnet + # Enables Chokidar polling in vite.config.js — see comment on the + # backend service above for why bind mounts need it. + CHOKIDAR_USEPOLLING: 'true' depends_on: - backend ports: @@ -145,7 +152,7 @@ services: image: node:20-alpine profiles: ['devnet'] working_dir: /app/backend - command: sh -c "npm install && npm run dev" + command: sh -c "npm install && npm run dev:docker" environment: PORT: 3001 STELLAR_NETWORK: local @@ -169,6 +176,7 @@ services: environment: VITE_STELLAR_NETWORK: local VITE_SOROBAN_RPC_URL: http://localhost:8001 + CHOKIDAR_USEPOLLING: 'true' ports: - '5173:5173' volumes: diff --git a/contracts/campaign/src/lib.rs b/contracts/campaign/src/lib.rs index 59742b0d..519aa2ce 100644 --- a/contracts/campaign/src/lib.rs +++ b/contracts/campaign/src/lib.rs @@ -63,6 +63,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contractmeta, contracttype, symbol_short, vec, Address, Bytes, BytesN, Env, Symbol, Vec, }; +use soroban_sdk::token::TokenClient; #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -103,6 +104,8 @@ pub enum Error { NotInAddressAllowlist = 125, /// Address is in the address blocklist when blocklist is enabled. InAddressBlocklist = 126, + /// Participation fee amount must be positive. + InvalidFee = 127, } contractmeta!(key = "Description", val = "Trivela campaign configuration"); @@ -298,6 +301,24 @@ pub enum UniquenessMode { Nullifier = 1, } +// ── Participation fee ──────────────────────────────────────────────────────── +// Optional entry fee charged on first registration in a custom token and +// forwarded to the reward pool address. +const FEE_CONFIG: Symbol = symbol_short!("feecfg"); +const SET_FEE_EVENT: Symbol = symbol_short!("feeset"); +const FEE_PAID_EVENT: Symbol = symbol_short!("feepaid"); + +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct ParticipationFee { + /// SEP-41 token the fee is denominated in. + pub token: Address, + /// Fee per new registration, in the token's smallest unit (> 0). + pub amount: i128, + /// Reward pool address that receives the fee. + pub pool: Address, +} + #[contract] pub struct CampaignContract; @@ -659,6 +680,45 @@ impl CampaignContract { Ok(()) } + /// Configure the participation fee (admin only). The fee is charged from + /// each new participant in `token` and transferred to `pool`. + pub fn set_participation_fee( + env: Env, + admin: Address, + nonce: u64, + token: Address, + amount: i128, + pool: Address, + ) -> Result<(), Error> { + require_admin_with_nonce(&env, &admin, nonce)?; + if amount <= 0 { + return Err(Error::InvalidFee); + } + let fee = ParticipationFee { token, amount, pool }; + env.storage().instance().set(&FEE_CONFIG, &fee); + env.events().publish((SET_FEE_EVENT,), fee); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(()) + } + + /// Remove the participation fee (admin only); registration becomes free. + pub fn clear_participation_fee(env: Env, admin: Address, nonce: u64) -> Result<(), Error> { + require_admin_with_nonce(&env, &admin, nonce)?; + env.storage().instance().remove(&FEE_CONFIG); + env.events().publish((SET_FEE_EVENT,), ()); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); + Ok(()) + } + + /// Current participation fee, if configured. + pub fn get_participation_fee(env: Env) -> Option { + env.storage().instance().get(&FEE_CONFIG) + } + /// Set maximum participant cap (admin only). Set to 0 for unlimited. pub fn set_max_cap(env: Env, admin: Address, nonce: u64, max_cap: u64) -> Result<(), Error> { require_admin_with_nonce(&env, &admin, nonce)?; @@ -2042,10 +2102,22 @@ fn do_register(env: &Env, participant: Address, referrer: Option
) -> Re ); env.events() - .publish((REFERRED_EVENT, participant, referrer), ()); + .publish((REFERRED_EVENT, participant.clone(), referrer), ()); } } + // Charge the participation fee (if configured). A failed transfer traps + // and rolls back the whole registration. + if let Some(fee) = env + .storage() + .instance() + .get::<_, ParticipationFee>(&FEE_CONFIG) + { + TokenClient::new(env, &fee.token).transfer(&participant, &fee.pool, &fee.amount); + env.events() + .publish((FEE_PAID_EVENT, participant.clone()), fee.amount); + } + env.storage() .instance() .extend_ttl(TTL_THRESHOLD, TTL_EXTEND_TO); diff --git a/contracts/campaign/src/test.rs b/contracts/campaign/src/test.rs index e29015fb..bfec5730 100644 --- a/contracts/campaign/src/test.rs +++ b/contracts/campaign/src/test.rs @@ -1770,3 +1770,60 @@ fn test_high_volume_registration_stress() { STRESS_PARTICIPANT_COUNT as u64 ); } + +// ── participation fee (issue #1371) ────────────────────────────────────────── + +use soroban_sdk::token::{StellarAssetClient, TokenClient as FeeTokenClient}; + +fn fee_setup() -> (Env, CampaignContractClient<'static>, Address, Address, Address, Address) { + let (env, _id, client) = setup(); + env.mock_all_auths(); + let admin = Address::generate(&env); + client.initialize(&admin); + client.set_active(&admin, &0, &true); + let issuer = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(issuer).address(); + let pool = Address::generate(&env); + let user = Address::generate(&env); + (env, client, admin, token, pool, user) +} + +#[test] +fn test_fee_charged_and_sent_to_pool() { + let (env, client, admin, token, pool, user) = fee_setup(); + StellarAssetClient::new(&env, &token).mint(&user, &100); + client.set_participation_fee(&admin, &1, &token, &10, &pool); + assert_eq!(client.get_participation_fee().unwrap().amount, 10); + let (leaf, proof) = no_proof_args(&env); + assert!(client.register(&user, &leaf, &proof, &None, &None)); + let t = FeeTokenClient::new(&env, &token); + assert_eq!(t.balance(&user), 90); + assert_eq!(t.balance(&pool), 10); + // A repeat registration is rejected and is not charged again. + assert!(client.try_register(&user, &leaf, &proof, &None, &None).is_err()); + assert_eq!(t.balance(&pool), 10); +} + +#[test] +fn test_fee_insufficient_balance_reverts_registration() { + let (env, client, admin, token, pool, user) = fee_setup(); + client.set_participation_fee(&admin, &1, &token, &10, &pool); + let (leaf, proof) = no_proof_args(&env); + assert!(client.try_register(&user, &leaf, &proof, &None, &None).is_err()); + assert!(!client.is_participant(&user)); + assert_eq!(client.get_participant_count(), 0); +} + +#[test] +fn test_fee_invalid_amount_and_clear() { + let (env, client, admin, token, pool, user) = fee_setup(); + assert_eq!( + client.try_set_participation_fee(&admin, &1, &token, &0, &pool), + Err(Ok(Error::InvalidFee)) + ); + client.set_participation_fee(&admin, &1, &token, &5, &pool); + client.clear_participation_fee(&admin, &2); + assert!(client.get_participation_fee().is_none()); + let (leaf, proof) = no_proof_args(&env); + assert!(client.register(&user, &leaf, &proof, &None, &None)); +} diff --git a/frontend/src/lib/viteWatchConfig.js b/frontend/src/lib/viteWatchConfig.js new file mode 100644 index 00000000..ca4baae5 --- /dev/null +++ b/frontend/src/lib/viteWatchConfig.js @@ -0,0 +1,20 @@ +// Dev-server watch options for Vite (issue #1366). +// +// Bind-mounted source directories in Docker Compose don't reliably emit +// native filesystem events (inotify) across every host platform — Docker +// Desktop on macOS/Windows in particular. Chokidar's polling mode works +// everywhere at the cost of some CPU, so it's opted into only when the +// container sets CHOKIDAR_USEPOLLING, leaving native (non-polling) watching +// as the default for local, non-Docker development. +export function getWatchOptions(env = process.env) { + const usePolling = env.CHOKIDAR_USEPOLLING === 'true'; + + if (!usePolling) { + return {}; + } + + return { + usePolling: true, + interval: 300, + }; +} diff --git a/frontend/src/lib/viteWatchConfig.test.js b/frontend/src/lib/viteWatchConfig.test.js new file mode 100644 index 00000000..8739a4ec --- /dev/null +++ b/frontend/src/lib/viteWatchConfig.test.js @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { getWatchOptions } from './viteWatchConfig'; + +describe('getWatchOptions', () => { + it('returns no explicit watch options when polling is not requested', () => { + expect(getWatchOptions({})).toEqual({}); + }); + + it('leaves native watching enabled when CHOKIDAR_USEPOLLING is unset or falsy', () => { + expect(getWatchOptions({ CHOKIDAR_USEPOLLING: 'false' })).toEqual({}); + expect(getWatchOptions({ CHOKIDAR_USEPOLLING: undefined })).toEqual({}); + }); + + it('enables polling when CHOKIDAR_USEPOLLING=true, for bind-mounted Docker volumes', () => { + expect(getWatchOptions({ CHOKIDAR_USEPOLLING: 'true' })).toEqual({ + usePolling: true, + interval: 300, + }); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8051e2ff..b2d7ce27 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; +import { getWatchOptions } from './src/lib/viteWatchConfig.js'; export default defineConfig({ plugins: [ @@ -94,6 +95,7 @@ export default defineConfig({ }, server: { port: 5173, + watch: getWatchOptions(), proxy: { '/api/v1': { target: 'http://localhost:3001', changeOrigin: true }, '/api': { target: 'http://localhost:3001', changeOrigin: true },