Skip to content

Commit 749a6ce

Browse files
authored
Merge pull request #87 from vestor-dev/error-handling-and-validation
feat: enhance contracts error handling and validation
2 parents bf68c2d + 1e183c7 commit 749a6ce

28 files changed

+760
-420
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"Bash(cargo test:*)",
1010
"Bash(cargo build:*)",
1111
"Bash(cargo clean:*)",
12-
"Bash(cargo doc:*)"
12+
"Bash(cargo doc:*)",
13+
"Bash(grep:*)"
1314
]
1415
}
1516
}

.clippy.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Clippy configuration for TeachLink contracts
2+
# These lints are allowed because they're overly pedantic for Soroban contracts
3+
4+
# Allow functions with many arguments (common in contract interfaces)
5+
too-many-arguments-threshold = 10
6+
7+
# Allow needless pass by value (Soroban SDK requires owned Env)
8+
avoid-breaking-exported-api = true

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
run: cargo fmt --all -- --check
2525

2626
- name: Clippy
27-
run: echo "Clippy temporarily disabled to fix CI"
27+
run: cargo clippy --all-targets --all-features -- -D warnings -A clippy::needless_pass_by_value -A clippy::must_use_candidate -A clippy::missing_panics_doc -A clippy::missing_errors_doc -A clippy::doc_markdown -A clippy::panic_in_result_fn -A clippy::assertions_on_constants -A clippy::unreadable_literal -A clippy::ignore_without_reason -A clippy::too_many_lines -A clippy::trivially_copy_pass_by_ref -A clippy::needless_borrow -A clippy::unused_unit -A clippy::len_zero -A clippy::unnecessary_cast -A clippy::needless_late_init -A clippy::map_unwrap_or -A clippy::items_after_statements -A clippy::manual_assert -A clippy::unnecessary_wraps -A clippy::similar_names -A clippy::no_effect_underscore_binding -A clippy::bool_assert_comparison -A clippy::uninlined_format_args -A clippy::useless_vec -A dead_code -A unused_variables
2828

2929
- name: Test
3030
run: cargo test --lib
@@ -34,4 +34,3 @@ jobs:
3434

3535
- name: Docs
3636
run: cargo doc --no-deps --document-private-items
37-

contracts/governance/src/events.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#![allow(deprecated)]
2+
13
use soroban_sdk::{contractevent, Address, Bytes, Env};
24

35
use crate::types::{ProposalStatus, ProposalType, VoteDirection};

contracts/governance/src/governance.rs

Lines changed: 110 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,15 @@ impl Governance {
6060
/// * `env` - The Soroban environment
6161
/// * `token` - Address of the governance token (used for voting power)
6262
/// * `admin` - Address with administrative privileges
63-
/// * `proposal_threshold` - Minimum token balance to create proposals
64-
/// * `quorum` - Minimum total votes required for valid decisions
65-
/// * `voting_period` - Duration of voting in seconds
63+
/// * `proposal_threshold` - Minimum token balance to create proposals (must be >= 0)
64+
/// * `quorum` - Minimum total votes required for valid decisions (must be >= 0)
65+
/// * `voting_period` - Duration of voting in seconds (must be > 0)
6666
/// * `execution_delay` - Delay before executing passed proposals in seconds
6767
///
6868
/// # Panics
6969
/// * If the contract is already initialized
70+
/// * If voting_period is 0
71+
/// * If proposal_threshold or quorum are negative
7072
pub fn initialize(
7173
env: &Env,
7274
token: Address,
@@ -76,9 +78,21 @@ impl Governance {
7678
voting_period: u64,
7779
execution_delay: u64,
7880
) {
79-
if env.storage().instance().has(&CONFIG) {
80-
panic!("Already initialized");
81-
}
81+
assert!(
82+
!env.storage().instance().has(&CONFIG),
83+
"ERR_ALREADY_INITIALIZED: Contract is already initialized"
84+
);
85+
86+
// Validate configuration parameters
87+
assert!(
88+
proposal_threshold >= 0 && quorum >= 0,
89+
"ERR_INVALID_CONFIG: Governance parameters must not be negative"
90+
);
91+
92+
assert!(
93+
voting_period != 0,
94+
"ERR_INVALID_CONFIG: Voting period must be greater than 0"
95+
);
8296

8397
let config = GovernanceConfig {
8498
token,
@@ -107,7 +121,7 @@ impl Governance {
107121
env.storage()
108122
.instance()
109123
.get(&CONFIG)
110-
.expect("Not initialized")
124+
.expect("ERR_NOT_INITIALIZED: Contract not initialized")
111125
}
112126

113127
/// Get the admin address.
@@ -140,8 +154,8 @@ impl Governance {
140154
/// # Arguments
141155
/// * `env` - The Soroban environment
142156
/// * `proposer` - Address creating the proposal (must authorize)
143-
/// * `title` - Short descriptive title for the proposal
144-
/// * `description` - Detailed description of the proposal
157+
/// * `title` - Short descriptive title for the proposal (must not be empty)
158+
/// * `description` - Detailed description of the proposal (must not be empty)
145159
/// * `proposal_type` - Category of the proposal
146160
/// * `execution_data` - Optional data for proposal execution
147161
///
@@ -152,7 +166,8 @@ impl Governance {
152166
/// Requires authorization from `proposer`.
153167
///
154168
/// # Panics
155-
/// * If proposer's token balance is below `proposal_threshold`
169+
/// * If proposer has insufficient token balance
170+
/// * If title or description is empty
156171
///
157172
/// # Events
158173
/// Emits a `proposal_created` event.
@@ -166,14 +181,26 @@ impl Governance {
166181
) -> u64 {
167182
proposer.require_auth();
168183

184+
// Validate input parameters
185+
assert!(
186+
!title.is_empty(),
187+
"ERR_EMPTY_TITLE: Proposal title cannot be empty"
188+
);
189+
190+
assert!(
191+
!description.is_empty(),
192+
"ERR_EMPTY_DESCRIPTION: Proposal description cannot be empty"
193+
);
194+
169195
let config = Self::get_config(env);
170196

171197
// Check proposer has enough tokens
172198
let token_client = token::Client::new(env, &config.token);
173199
let balance = token_client.balance(&proposer);
174-
if balance < config.proposal_threshold {
175-
panic!("Insufficient token balance to create proposal");
176-
}
200+
assert!(
201+
balance >= config.proposal_threshold,
202+
"ERR_INSUFFICIENT_BALANCE: Proposer balance below threshold"
203+
);
177204

178205
// Generate proposal ID
179206
let mut proposal_count: u64 = env.storage().instance().get(&PROPOSAL_COUNT).unwrap_or(0);
@@ -254,34 +281,38 @@ impl Governance {
254281
.storage()
255282
.persistent()
256283
.get(&(PROPOSALS, proposal_id))
257-
.expect("Proposal not found");
284+
.expect("ERR_PROPOSAL_NOT_FOUND: Proposal does not exist");
258285

259286
// Check proposal is active
260-
if proposal.status != ProposalStatus::Active {
261-
panic!("Proposal is not active");
262-
}
287+
assert!(
288+
proposal.status == ProposalStatus::Active,
289+
"ERR_INVALID_STATUS: Proposal is not in active status"
290+
);
263291

264292
// Check voting period
265293
let now = env.ledger().timestamp();
266-
if now < proposal.voting_start || now > proposal.voting_end {
267-
panic!("Voting period not active");
268-
}
294+
assert!(
295+
now >= proposal.voting_start && now <= proposal.voting_end,
296+
"ERR_VOTING_PERIOD_INACTIVE: Voting period is not active"
297+
);
269298

270299
// Check if already voted
271300
let vote_key = VoteKey {
272301
proposal_id,
273302
voter: voter.clone(),
274303
};
275-
if env.storage().persistent().has(&(VOTES, vote_key.clone())) {
276-
panic!("Already voted on this proposal");
277-
}
304+
assert!(
305+
!env.storage().persistent().has(&(VOTES, vote_key.clone())),
306+
"ERR_ALREADY_VOTED: Address has already voted on this proposal"
307+
);
278308

279309
// Get voting power (token balance)
280310
let token_client = token::Client::new(env, &config.token);
281311
let power = token_client.balance(&voter);
282-
if power <= 0 {
283-
panic!("No voting power");
284-
}
312+
assert!(
313+
power > 0,
314+
"ERR_NO_VOTING_POWER: Address has no voting power"
315+
);
285316

286317
// Record vote
287318
let vote = Vote {
@@ -333,18 +364,20 @@ impl Governance {
333364
.storage()
334365
.persistent()
335366
.get(&(PROPOSALS, proposal_id))
336-
.expect("Proposal not found");
367+
.expect("ERR_PROPOSAL_NOT_FOUND: Proposal does not exist");
337368

338369
// Check proposal is still active
339-
if proposal.status != ProposalStatus::Active {
340-
panic!("Proposal is not active");
341-
}
370+
assert!(
371+
proposal.status == ProposalStatus::Active,
372+
"ERR_INVALID_STATUS: Proposal is not in active status"
373+
);
342374

343375
// Check voting period has ended
344376
let now = env.ledger().timestamp();
345-
if now <= proposal.voting_end {
346-
panic!("Voting period not ended");
347-
}
377+
assert!(
378+
now > proposal.voting_end,
379+
"ERR_VOTING_PERIOD_ACTIVE: Voting period has not ended yet"
380+
);
348381

349382
let old_status = proposal.status.clone();
350383

@@ -394,18 +427,20 @@ impl Governance {
394427
.storage()
395428
.persistent()
396429
.get(&(PROPOSALS, proposal_id))
397-
.expect("Proposal not found");
430+
.expect("ERR_PROPOSAL_NOT_FOUND: Proposal does not exist");
398431

399432
// Check proposal has passed
400-
if proposal.status != ProposalStatus::Passed {
401-
panic!("Proposal has not passed");
402-
}
433+
assert!(
434+
proposal.status == ProposalStatus::Passed,
435+
"ERR_INVALID_STATUS: Proposal has not passed"
436+
);
403437

404438
// Check execution delay has passed
405439
let now = env.ledger().timestamp();
406-
if now < proposal.voting_end + config.execution_delay {
407-
panic!("Execution delay not met");
408-
}
440+
assert!(
441+
now >= proposal.voting_end + config.execution_delay,
442+
"ERR_EXECUTION_DELAY_NOT_MET: Execution delay period has not passed"
443+
);
409444

410445
let old_status = proposal.status.clone();
411446
proposal.status = ProposalStatus::Executed;
@@ -448,26 +483,29 @@ impl Governance {
448483
.storage()
449484
.persistent()
450485
.get(&(PROPOSALS, proposal_id))
451-
.expect("Proposal not found");
486+
.expect("ERR_PROPOSAL_NOT_FOUND: Proposal does not exist");
452487

453488
// Check if cancellable
454489
let is_admin = caller == config.admin;
455490
let is_proposer = caller == proposal.proposer;
456491
let now = env.ledger().timestamp();
457492
let voting_ended = now > proposal.voting_end;
458493

459-
if !is_admin && !is_proposer {
460-
panic!("Only proposer or admin can cancel");
461-
}
494+
assert!(
495+
is_admin || is_proposer,
496+
"ERR_UNAUTHORIZED: Only proposer or admin can cancel"
497+
);
462498

463-
if !is_admin && voting_ended {
464-
panic!("Proposer can only cancel during voting period");
465-
}
499+
assert!(
500+
is_admin || !voting_ended,
501+
"ERR_VOTING_ENDED: Proposer can only cancel during voting period"
502+
);
466503

467504
// Cannot cancel executed proposals
468-
if proposal.status == ProposalStatus::Executed {
469-
panic!("Cannot cancel executed proposal");
470-
}
505+
assert!(
506+
proposal.status != ProposalStatus::Executed,
507+
"ERR_INVALID_STATUS: Cannot cancel executed proposal"
508+
);
471509

472510
let old_status = proposal.status.clone();
473511
proposal.status = ProposalStatus::Cancelled;
@@ -487,14 +525,17 @@ impl Governance {
487525
///
488526
/// # Arguments
489527
/// * `env` - The Soroban environment
490-
/// * `new_proposal_threshold` - New minimum tokens for proposals (optional)
491-
/// * `new_quorum` - New quorum requirement (optional)
492-
/// * `new_voting_period` - New voting duration in seconds (optional)
528+
/// * `new_proposal_threshold` - New minimum tokens for proposals (optional, must be >= 0)
529+
/// * `new_quorum` - New quorum requirement (optional, must be >= 0)
530+
/// * `new_voting_period` - New voting duration in seconds (optional, must be > 0)
493531
/// * `new_execution_delay` - New execution delay in seconds (optional)
494532
///
495533
/// # Authorization
496534
/// Requires authorization from the admin address.
497535
///
536+
/// # Panics
537+
/// * If invalid configuration parameters are provided
538+
///
498539
/// # Events
499540
/// Emits a `config_updated` event.
500541
pub fn update_config(
@@ -507,15 +548,31 @@ impl Governance {
507548
let mut config = Self::get_config(env);
508549
config.admin.require_auth();
509550

551+
// Validate parameters if provided
510552
if let Some(threshold) = new_proposal_threshold {
553+
assert!(
554+
threshold >= 0,
555+
"ERR_INVALID_CONFIG: Proposal threshold must not be negative"
556+
);
511557
config.proposal_threshold = threshold;
512558
}
559+
513560
if let Some(quorum) = new_quorum {
561+
assert!(
562+
quorum >= 0,
563+
"ERR_INVALID_CONFIG: Quorum must not be negative"
564+
);
514565
config.quorum = quorum;
515566
}
567+
516568
if let Some(period) = new_voting_period {
569+
assert!(
570+
period != 0,
571+
"ERR_INVALID_CONFIG: Voting period must be greater than 0"
572+
);
517573
config.voting_period = period;
518574
}
575+
519576
if let Some(delay) = new_execution_delay {
520577
config.execution_delay = delay;
521578
}

contracts/governance/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#![no_std]
2-
#![allow(clippy::all)]
3-
#![allow(unused)]
2+
#![allow(clippy::needless_pass_by_value)]
3+
#![allow(clippy::must_use_candidate)]
4+
#![allow(clippy::missing_panics_doc)]
5+
#![allow(clippy::missing_errors_doc)]
6+
#![allow(clippy::doc_markdown)]
47
#![allow(deprecated)]
58

69
//! TeachLink Governance Contract
@@ -18,7 +21,8 @@ mod types;
1821

1922
pub use mock_token::{MockToken, MockTokenClient};
2023
pub use types::{
21-
GovernanceConfig, Proposal, ProposalStatus, ProposalType, Vote, VoteDirection, VoteKey,
24+
GovernanceConfig, GovernanceError, Proposal, ProposalStatus, ProposalType, Vote, VoteDirection,
25+
VoteKey,
2226
};
2327

2428
#[contract]

0 commit comments

Comments
 (0)