Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/nodemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/**/*.test.js", "src/tests/**", "src/integration/**/*.test.js", "*.db"],
"delay": "300"
}
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions backend/src/config/devHotReload.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
12 changes: 10 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -169,6 +176,7 @@ services:
environment:
VITE_STELLAR_NETWORK: local
VITE_SOROBAN_RPC_URL: http://localhost:8001
CHOKIDAR_USEPOLLING: 'true'
ports:
- '5173:5173'
volumes:
Expand Down
74 changes: 73 additions & 1 deletion contracts/campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<ParticipationFee> {
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)?;
Expand Down Expand Up @@ -2042,10 +2102,22 @@ fn do_register(env: &Env, participant: Address, referrer: Option<Address>) -> 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);
Expand Down
57 changes: 57 additions & 0 deletions contracts/campaign/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
20 changes: 20 additions & 0 deletions frontend/src/lib/viteWatchConfig.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
20 changes: 20 additions & 0 deletions frontend/src/lib/viteWatchConfig.test.js
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
2 changes: 2 additions & 0 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -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 },
Expand Down