test: add coverage for governance and timelock authorization, initialization, and boundary paths (closes #1421) - #1481
Merged
pope-h merged 1 commit intoJul 30, 2026
Conversation
…ization, and boundary paths (closes Shelterflex#1421)
|
@Cyber-Mitch is attempting to deploy a commit to the pope-h's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@Cyber-Mitch Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #1421
Summary
Adds test coverage for the
governanceandtimelockcontracts, focused on authorization boundaries, initialization edge cases, boundary/failure paths, and event assertions. Closes #1421.Changes
contracts/governance/src/lib.rs(tests module, +29 tests)contracts/timelock/src/test.rs(+28 tests, +1 helper)No contract logic (impl blocks, storage, error enums) was touched —
git status --shortconfirms only test code changed.Recon Summary
lib.rsunder#[cfg(test)] mod tests, so new tests were appended there. Timelock's tests are split acrosstest.rs(canonical, 3 files total including a mock-auth/mock-target idiom) plusintegration_test.rs;security_properties.rsis doc-only invariants, not test code.cargo fmt --all -- --check,cargo clippy --workspace --all-targets --all-features,cargo test --workspaceall passed unmodified (35 test binaries, 0 failures). Per-crate counts matched the issue exactly: governance 12, timelock 12.governance/lib.rs:120) are baseline and untouched.env.events().all()+try_into_val).Governance mechanics (as found in code)
require_admin=require_auth+ caller == Admin, elseNotAuthorized):set_total_staked,set_voter_stake. Admin read uses.expect("not init")— a bare panic pre-init.create_proposal(require_auth+ stake ≥MIN_STAKE_TO_PROPOSE= 1),vote(require_auth),cancel_proposal(require_auth+ proposer identity check). Plus read-onlyfinalize_proposal,execute_proposal,get_proposal,proposal_count.AlreadyInitialized).now <= voting_ends_at.total_staked × 1000 / 10000(10%). Passes ifftotal_votes >= quorumandvotes_for > votes_against(strict — a tie is Rejected).execute_after = voting_ends_at + TIMELOCK_SECS(48h); rejects ifnow < execute_after(boundary inclusive at exactlyexecute_after). Executed proposals reject re-execution withProposalAlreadyExecuted.proposal_created,vote_cast(pid, voter, support, weight),proposal_finalized(pid, for, against, quorum_met),proposal_executed(pid, param_key, proposed_value),proposal_cancelled(pid).Timelock mechanics (as found in code)
require_auth+ admin == Admin, elseNotAuthorized):cancel,unpause,set_min_delay,expire. Some paths use bare.unwrap()→ panic pre-init.emergency_pause— insufficient signers →InsufficientMultisigApprovals; each signer checked viarequire_auth+ membership, elseNotAuthorized.execute.AlreadyInitialized;min > maxormin == 0→InvalidDelay.[min, max]; execute checksPaused→ hash match →now >= eta(boundary inclusive) →now < eta + GRACE_PERIOD(14 days) before invoking. Cancel removes the queued entry (verified via re-execute attempt, not just the cancel call's return). Double-execute →TransactionNotQueued(entry already removed).expirerequires the grace period to have elapsed.queued,executed,cancelled,emergency_paused,expired(tx_hash, eta).Coverage matrix (before this PR)
cancelhad it;set_total_staked/set_voter_stakeMISSINGqueuehad it;cancel/unpause/set_min_delay/expire/emergency_pausemembership MISSINGTest List
Governance (+29 → 41 total):
set_total_staked_unauthorized_caller_rejected,set_voter_stake_unauthorized_caller_rejected→NotAuthorizeddouble_init_rejected→AlreadyInitializedset_total_staked_before_init_panics(bare panic),create_proposal_before_init_rejects_insufficient_stake→InsufficientStake(zeroed state),vote_before_init_not_found→ProposalNotFoundexecute_one_second_before_timelock_rejected→TimelockNotElapsed;execute_exactly_at_timelock_boundary_succeeds→Executed(boundary inclusive)vote_on_nonexistent_proposal_rejected,vote_after_voting_period_rejected(VotingNotEnded),vote_on_inactive_proposal_rejected(ProposalNotActive)finalize_before_voting_ends_rejected(VotingNotEnded),finalize_nonexistent_proposal_rejected,finalize_already_finalized_rejected(ProposalNotActive)execute_unfinalized_proposal_rejected,execute_rejected_proposal_rejected→ProposalNotPassed(proves proposals can't execute without passing),execute_nonexistent_proposal_rejectedcancel_nonexistent_proposal_rejected,cancel_already_finalized_rejected(ProposalNotActive)quorum_exactly_at_threshold_passes(100_000 == quorum →Passed, asserts onvotes_for),quorum_one_below_threshold_rejected(99_999 →Rejected),tie_vote_is_rejected(equal for/against →Rejected),zero_total_staked_makes_quorum_trivial→Passed(flagged as a FINDING below),vote_counting_overflow_panics(#[should_panic("overflow")],i128::MAX + 1, flagged below)create_proposal_emits_event,vote_emits_event,finalize_emits_event,execute_proposal_emits_event,cancel_emits_event— assert exact topics and decode full event-data tuples (ids, addresses, weights, values)Timelock (+28 → 40 total):
non_admin_cannot_cancel/unpause/set_min_delay/expire→NotAuthorized;non_member_cannot_emergency_pause;emergency_pause_insufficient_signers_rejected(1 of 2 required signers) →InsufficientMultisigApprovalsdouble_init_rejected→AlreadyInitialized;init_min_greater_than_max_rejected→InvalidDelay;execute_before_init_returns_error;cancel_before_init_panics(#[should_panic], bare.unwrap())queue_delay_below_min_rejected,queue_delay_above_max_rejected(604801) →InvalidDelayexecute_one_second_before_eta_rejected(TimelockNotMet),execute_exactly_at_eta_succeeds(real target invoked, confirmed via post-state)double_execute_rejected(TransactionNotQueued),execute_while_paused_rejected(ContractPaused, even with valid eta)cancel_nonexistent_rejected,cancel_confirms_removal(re-cancel →TransactionNotQueued, proving removal),expire_before_grace_elapsed_rejected(TimestampNotMet),expire_after_grace_removes_operation(then re-execute →TransactionNotQueued),expire_nonexistent_returns_errorset_min_delay_zero_rejected,set_min_delay_above_max_rejected→InvalidDelayqueue_emits_event,execute_emits_event,cancel_emits_event,emergency_pause_emits_event,expire_emits_event— assert exact namespace+name topics and decode full event data (hash, target, function, eta)Ambiguous Behavior — Needs Maintainer Confirmation
No test was written to encode a guess for any of the following; each was left as-is with the currently-observed behavior only.
create_proposalandcancel_proposalare callable before init (they don't readAdmin; onlyset_*functions panic pre-init).create_proposalpre-init returnsInsufficientStakerather than an explicit "not initialized" error. Tests pin the currently-observed typed errors — is this the intended contract, or should all entrypoints uniformly reject pre-init?votereads the voter's current stake at vote time, not a proposal-creation-time snapshot — despite aVoterSnapshot-shaped storage key existing and an existing baseline test (flash_stake_voting_prevented) whose own comments suggest snapshotting was intended, deferred pending a staking-pool cross-call integration. Is current-stake voting the intended production behavior, or a known placeholder? (Related to Finding 1 below.)get_stake_forkey reuse: reusesDataKey::Voted(0, voter)as a stake-weight storage slot, per an inline comment suggesting it's a placeholder pending the real staking-pool cross-call. Is this namespace reuse intentional or a temporary shim?Implementation Findings
No test demonstrates a bypassable timelock delay, a non-cancelling cancel, a double-execute, or an executable-without-passing proposal — all core guards from the issue's stated risk list hold. Two findings surfaced during boundary testing, tied to the issue's stated "governance capture" and arithmetic-safety concerns:
total_staked. Withtotal_staked = 0, the required quorum computes to 0, so a single minimal-stake vote can pass a proposal with effectively no quorum.set_total_stakedhas no floor/validation, so a wrong or zero value silently removes quorum protection. Demonstrated byzero_total_staked_makes_quorum_trivial.votes_for/votes_against += weightuses plaini128addition and can overflow-panic given admin-set adversarial stake weights (demonstrated byvote_counting_overflow_panics). Low severity since it requires an admin-controlled input, but it's an unguarded arithmetic path where the rest of the codebase generally uses overflow-checked (rather than wrapping) arithmetic.Per the issue's instructions, contract logic was not modified and no separate issue was opened — a follow-up issue should be filed covering (i) a quorum/total-staked floor and (ii) confirming intended vote-weighting timing (ambiguity #2 above).
Separately noted, out of scope: running the full workspace test suite regenerated a pre-existing stale snapshot at
contracts/contract_access/tests/delegate_permission_grants_access.1.json(au32: 6value is now serialized as a map). This is unrelated to governance/timelock, was reverted before committing so it's not in this diff, but indicatescontract_accesshas an out-of-date committed snapshot worth a separate look.Test Results
$ cargo fmt --all -- --check
PASS — clean, no diff
$ cargo clippy --workspace --all-targets --all-features
exit 0 — governance and timelock at baseline warning counts, zero new warnings introduced
$ cargo test --workspace
exit 0 — 35 test binaries, zero failures
governance: running 41 tests → ok. 41 passed; 0 failed
timelock: running 40 tests → ok. 40 passed; 0 failed
Checklist
Governance (
governance/src/lib.rs):set_total_stakedL764,set_voter_stakeL777Timelock (
timelock/src/test.rs):cancelL333,unpauseL365,set_min_delayL384,expire/emergency_pauseL462 (queue was pre-existing)Err(Ok(TimelockError::…))+ decoded event data + post-state re-execution checkscargo fmt/clippy/test --workspaceall pass