Skip to content
Merged
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
27 changes: 20 additions & 7 deletions .github/workflows/contracts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,24 @@ jobs:
- name: Run contract tests
run: cargo test --workspace

- name: Build Soroban WASM artifacts
- name: Install Stellar CLI
run: |
if command -v stellar >/dev/null 2>&1; then
stellar contract build
else
echo "Stellar CLI is not installed in this runner; skipping Soroban WASM build."
echo "Install Stellar CLI to produce .wasm artifacts in CI."
fi
curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH

- name: Build Soroban WASM artifacts
run: cargo build --target wasm32-unknown-unknown --release -p trivela-rewards-contract -p trivela-campaign-contract

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install dependencies
run: npm ci

- name: Regenerate contract bindings
run: npm run contracts:build-bindings

- name: Check for unstaged changes (bindings out of sync)
run: git diff --exit-code
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Thank you for considering contributing to Trivela. This project is part of the [
## Setup for development

- **Contracts**: Rust + Stellar CLI. From repo root: `cargo test --workspace` and `stellar contract build` in each contract dir.
- **Contract Bindings**: To regenerate type-safe TypeScript bindings from contract WASM artifacts, run `npm run contracts:build-bindings` from the repository root. Ensure you regenerate the bindings and commit the updated files whenever contract interfaces change.
- **Backend**: `cd backend && npm install && npm run dev`
- **Frontend**: `cd frontend && npm install && npm run dev`

Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"cors": "^2.8.5",
"compression": "^1.8.0",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"@stellar/stellar-sdk": "^14.0.0",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import cors from 'cors';
import express from 'express';
import compression from 'compression';
import { pathToFileURL } from 'node:url';
import Redis from 'ioredis';
import createApiKeyAuth from './middleware/apiKeyAuth.js';
Expand Down Expand Up @@ -242,6 +243,7 @@ export async function createApp(options = {}) {
});

app.use(requestId);
app.use(compression({ threshold: 1024 }));
app.use(cors(createCorsOptions(allowedOrigins)));
app.use(securityHeaders);
app.use(requestLogger);
Expand Down
47 changes: 47 additions & 0 deletions backend/src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1080,3 +1080,50 @@ test('GET /api/v1/explorer returns correct URL for mainnet', async () => {
await stopTestServer(server);
}
});

test('API response compression is applied to payloads larger than 1KB', async () => {
const largeCampaign = {
name: 'A'.repeat(600),
description: 'B'.repeat(600),
active: true,
rewardPerAction: 10,
createdAt: new Date().toISOString(),
};
const { server, baseUrl } = await startTestServer({ campaigns: [largeCampaign] });

try {
const response = await fetch(`${baseUrl}/api/v1/campaigns`, {
headers: {
'Accept-Encoding': 'gzip',
},
});
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-encoding'), 'gzip');
} finally {
await stopTestServer(server);
}
});

test('API response compression is NOT applied to payloads smaller than 1KB', async () => {
const smallCampaign = {
name: 'Small',
description: 'Short',
active: true,
rewardPerAction: 10,
createdAt: new Date().toISOString(),
};
const { server, baseUrl } = await startTestServer({ campaigns: [smallCampaign] });

try {
const response = await fetch(`${baseUrl}/api/v1/campaigns`, {
headers: {
'Accept-Encoding': 'gzip',
},
});
assert.equal(response.status, 200);
assert.strictEqual(response.headers.get('content-encoding'), null);
} finally {
await stopTestServer(server);
}
});

55 changes: 55 additions & 0 deletions contracts/campaign/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,42 @@ impl CampaignContract {
Ok(true)
}

/// Deregister a participant.
///
/// Checks liveness/window: if end_time is u64::MAX, checks if campaign is active;
/// otherwise, checks if current timestamp <= end_time.
pub fn deregister(env: Env, participant: Address) -> Result<bool, Error> {
participant.require_auth();

let end_time: u64 = env.storage().instance().get(&END_TIME).unwrap_or(u64::MAX);
if end_time != u64::MAX {
let now = env.ledger().timestamp();
if now > end_time {
return Err(Error::OutsideTimeWindow);
}
} else {
let active: bool = env.storage().instance().get(&CAMPAIGN_ACTIVE).unwrap_or(false);
if !active {
return Err(Error::CampaignInactive);
}
}

Ok(do_deregister(&env, participant))
}

/// Deregister a participant by the admin.
///
/// Bypasses time window and liveness checks. Requires admin auth and nonce validation.
pub fn admin_deregister(
env: Env,
admin: Address,
nonce: u64,
participant: Address,
) -> Result<bool, Error> {
require_admin_with_nonce(&env, &admin, nonce)?;
Ok(do_deregister(&env, participant))
}

/// Check if a participant is registered.
pub fn is_participant(env: Env, participant: Address) -> bool {
env.storage()
Expand Down Expand Up @@ -362,5 +398,24 @@ impl CampaignContract {
}
}

fn do_deregister(env: &Env, participant: Address) -> bool {
let key = (PARTICIPANT, participant.clone());
if !env.storage().instance().get::<_, bool>(&key).unwrap_or(false) {
return false;
}
env.storage().instance().remove(&key);
let count: u64 = env.storage().instance().get(&PARTICIPANT_COUNT).unwrap_or(0);
if count > 0 {
env.storage().instance().set(&PARTICIPANT_COUNT, &(count - 1));
}
env.events().publish(
(Symbol::new(env, "deregister"), participant),
(),
);
env.storage().instance().extend_ttl(50, 100);
true
}

#[cfg(test)]
mod test;

127 changes: 127 additions & 0 deletions contracts/campaign/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,130 @@ fn test_admin_nonce_replay_protection() {
client.set_active(&admin, &1, &true);
assert_eq!(client.admin_nonce(), 2);
}

#[test]
fn test_deregister_success_and_re_register() {
let (env, contract_id, client) = setup();
let admin = Address::generate(&env);
let participant = Address::generate(&env);
client.initialize(&admin);

env.mock_all_auths();
let (leaf, proof) = no_proof_args(&env);

// Register participant
assert!(client.register(&participant, &leaf, &proof));
assert!(client.is_participant(&participant));
assert_eq!(client.get_participant_count(), 1);

// Deregister participant
assert!(client.deregister(&participant));
assert!(!client.is_participant(&participant));
assert_eq!(client.get_participant_count(), 0);

// Check deregister event
let register_event = Symbol::new(&env, "register");
let deregister_event = Symbol::new(&env, "deregister");
assert_eq!(
env.events().all(),
vec![
&env,
(
contract_id.clone(),
vec![&env, register_event.into_val(&env), participant.clone().into_val(&env)],
().into_val(&env)
),
(
contract_id.clone(),
vec![&env, deregister_event.into_val(&env), participant.clone().into_val(&env)],
().into_val(&env)
)
]
);

// Re-register works
assert!(client.register(&participant, &leaf, &proof));
assert!(client.is_participant(&participant));
assert_eq!(client.get_participant_count(), 1);
}

#[test]
fn test_admin_deregister() {
let (env, contract_id, client) = setup();
let admin = Address::generate(&env);
let participant = Address::generate(&env);
client.initialize(&admin);

env.mock_all_auths();
let (leaf, proof) = no_proof_args(&env);

// Register participant
assert!(client.register(&participant, &leaf, &proof));
assert!(client.is_participant(&participant));
assert_eq!(client.get_participant_count(), 1);

// Admin deregister
assert!(client.admin_deregister(&admin, &0, &participant));
assert!(!client.is_participant(&participant));
assert_eq!(client.get_participant_count(), 0);

// Check deregister event
let register_event = Symbol::new(&env, "register");
let deregister_event = Symbol::new(&env, "deregister");
assert_eq!(
env.events().all(),
vec![
&env,
(
contract_id.clone(),
vec![&env, register_event.into_val(&env), participant.clone().into_val(&env)],
().into_val(&env)
),
(
contract_id.clone(),
vec![&env, deregister_event.into_val(&env), participant.clone().into_val(&env)],
().into_val(&env)
)
]
);

// Call admin deregister again for same participant (should return false and not panic)
assert!(!client.admin_deregister(&admin, &1, &participant));
assert_eq!(client.get_participant_count(), 0);
}

#[test]
fn test_deregister_liveness_checks() {
let (env, _contract_id, client) = setup();
let admin = Address::generate(&env);
let participant = Address::generate(&env);
client.initialize(&admin);

env.mock_all_auths();
let (leaf, proof) = no_proof_args(&env);

// Register
client.register(&participant, &leaf, &proof);

// Case 1: end_time != u64::MAX and now > end_time
client.set_window(&admin, &0, &100, &200);
env.ledger().with_mut(|li| li.timestamp = 250);
assert_eq!(
client.try_deregister(&participant),
Err(Ok(Error::OutsideTimeWindow))
);

// Reset window to u64::MAX but campaign inactive
client.set_window(&admin, &1, &100, &u64::MAX);
client.set_active(&admin, &2, &false);
env.ledger().with_mut(|li| li.timestamp = 250);
assert_eq!(
client.try_deregister(&participant),
Err(Ok(Error::CampaignInactive))
);

// Admin deregister bypasses all these checks
assert!(client.admin_deregister(&admin, &3, &participant));
assert!(!client.is_participant(&participant));
}

Loading
Loading