Skip to content

fix(proposal): strictly validate threshold and quorum before execution - #383

Merged
Cjay-Cyber-2 merged 4 commits into
ASTROIDX556:mainfrom
Nife-tanny:fix/proposal-execution-guards
Sep 26, 2026
Merged

Cjay-Cyber-2 merged 4 commits into
ASTROIDX556:mainfrom
Nife-tanny:fix/proposal-execution-guards

Conversation

@Nife-tanny

@Nife-tanny Nife-tanny commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #226

Why this PR is small

Most of #226 was already merged before I started. #344 (1d139f2) added the quorum/majority/threshold re-check to execute (ensure_vote_valid), plus tests for tied votes, exact quorum/majority equality and narrow misses. #256 / #359 added the expiry gate. #382 (for #333, a near-duplicate of this issue) has since merged too: it added more quorum/majority tests and moved the vote-bar helpers into a VoteBars type.

So this PR does not re-implement any of that. It only covers what #226 asks for that main did not have yet:

  1. Expired proposals: the docs didn't match the code. The execute doc comment said an expired proposal "reports Error::ProposalExpired", but execute actually records Expired, refunds the deposit, emits expired and returns Ok(()). It has to return Ok, because an error would roll back that settlement (the design from fix(proposal): enforce expiry on every proposal interaction (#320) #359). I fixed the comment rather than the behaviour; see the questions below.
  2. Execution guards with no tests: executing twice, executing a closed / rejected / cancelled / failed proposal, a nonexistent id, a non-proposer caller, and whether the proposer's authorization is required.
  3. Edge cases with no tests: exact threshold equality where the threshold itself is the binding bar (stricter than quorum and majority), overflow safety of the vote-bar arithmetic, and the tie/majority boundary at the MAX_APPROVERS (32) allow-list.

No contract logic changes. lib.rs changes only in a doc comment, and the rest is new tests.

Rules execute enforces (unchanged, now fully pinned by tests)

Guard order in execute:

# Guard Result
1 Proposal exists NotFound (1)
2 Deadline passed (now >= expires_at, 0 = never) Settles to Expired, refunds deposit once, emits expired, returns Ok(()). Repeat calls are no-ops. Never executes.
3 Caller is the proposer (require_auth) Unauthorized
4 State is Approved (so not Pending, Executed, Closed, Rejected, Cancelled or Failed) ProposalNotApproved (73)
5 Vote bars: approvals ≥ threshold, approvals ≥ quorum ceil(n × 50 / 100), approvals > n / 2 (strict majority, so a tie fails) ThresholdNotMet (61)
6 Timelock elapsed TimelockNotExpired (91)
7 Prerequisites executed PrerequisiteNotMet

State is set to Executed and the deposit refunded only after every guard passes. execute makes no other external call; the actual value movement happens in the wallet/treasury. A second call then fails at guard 4, so the refund happens exactly once.

Error codes: none added. The shared #[contracterror] enum is at Soroban's 50-variant limit (see the note in shared/src/errors.rs), so the existing codes are reused: NotFound = 1, ThresholdNotMet = 61, ProposalNotApproved = 73, Unauthorized, TimelockNotExpired = 91.

Acceptance criteria

  • Review vote tallying and quorum verification. Already in ensure_vote_valid (fix(proposal): harden timelock and expiry guards on execute and cancel #344), whose helpers quorum_required / majority_required / has_majority live on VoteBars since test(proposal): cover quorum and majority vote-bar edge cases (issue … #382. Reviewed; integer-only, rounds up, tie ≠ majority. Newly pinned here: vote_bars_hold_at_the_approver_cap_and_do_not_overflow.
  • Execution guards reject unapproved or expired proposals with precise error codes.
    • Unapproved and terminal states: rejected_proposal_cannot_be_executed, cancelled_proposal_cannot_be_executed, failed_proposal_cannot_be_executed, closed_proposal_cannot_be_executed_again, executing_twice_is_refused_and_refunds_the_deposit_once (all ProposalNotApproved, nothing moves). Existing: execute_before_approved_fails.
    • Nonexistent id / caller: execute_unknown_proposal_reports_not_found (NotFound), only_the_proposer_may_execute (Unauthorized), execute_requires_the_proposer_authorization (checks env.auths()).
    • Expired: expired_execution_settles_once_and_never_executes. This returns Ok (see above), and the test checks that the proposal ends Expired, never becomes executed, emits exactly one expired event and refunds the deposit exactly once. Existing: expired_proposal_cannot_be_executed, expiry_gate_wins_over_the_timelock_gate.
  • Tests for edge cases such as exact threshold equality and tied votes.
    • New: threshold_equal_to_the_whole_allow_list_executes_only_when_unanimous, and the 16-of-32 tie / 17-of-32 majority case in vote_bars_hold_at_the_approver_cap_and_do_not_overflow.
    • Existing on main: tied_vote_blocks_execution, exact_quorum_and_majority_boundary_executes, narrowly_missing_the_quorum_blocks_execution, narrowly_missing_the_threshold_never_approves_and_cannot_execute, quorum_calculation_rounds_up_with_integer_scaling, majority_check_never_accepts_a_tie.

Avoiding conflicts

Verification

Check Result
cargo fmt --check ✅
cargo clippy --workspace -- -D warnings (CI flags) ✅ (also clean with --all-targets on astroid-proposal)
cargo test --package astroid-proposal ✅ 75 passed (64 on main after #382 + 11 new)
Other crates (cargo test -p each: shared, interfaces, budget, treasury, wallet, escrow, multisig, registry, policy) ✅ all pass
Wasm build (cargo build --target wasm32v1-none --release -p astroid-proposal) ✅
CI (stellar contract build, cargo test --workspace) runs on this PR

Local-only issue, unrelated to this PR: on my Windows machine cargo test --workspace fails to compile the astroid-integration-tests crate (a rustc "only metadata stub found for core" toolchain error), including on unmodified main. CI on Linux is unaffected, so the workspace-wide and integration results come from CI.

Questions for the maintainer

  1. Expired execute returning Ok(()). The issue asks for a precise error code on expired proposals. Returning Error::ProposalExpired would roll back the expiry settlement (state, refund, event) that fix(proposal): enforce expiry on every proposal interaction (#320) #359 deliberately commits, so I kept the behaviour and fixed the docs. Would you prefer execute to return ProposalExpired when the proposal is already Expired (nothing to roll back), and keep Ok only for the call that performs the settlement? Several existing tests call execute on stale proposals and expect success, so I didn't change this without your go-ahead.
  2. Error for re-executing. Executing an Executed/Closed proposal reports ProposalNotApproved. InvalidProposalState (72) might be more precise for "already executed". I left it unchanged to avoid a behaviour change; happy to switch if you prefer.
  3. Out of scope, noted only: the cancel and fail doc comments have the same mismatch (they say ProposalExpired, but both settle and return Ok). I can fix those here or in a follow-up.

Nife-tanny and others added 2 commits September 26, 2026 18:18
The execute doc comment said an expired proposal reports
Error::ProposalExpired, but since ASTROIDX556#256/ASTROIDX556#359 execute records the terminal
Expired state, refunds the deposit, emits the expired event and returns
Ok(()) - returning an error would roll that settlement back. Document the
actual behaviour and how callers can tell it apart from a real execution.

Refs ASTROIDX556#226

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pin every guard execute applies before a proposal may fire:
- unknown id -> NotFound; non-proposer -> Unauthorized; proposer auth is
  the only authorization demanded
- exact threshold equality (unanimous 3-of-3) executes, one below is
  refused with ProposalNotApproved
- executing twice, or executing a closed, rejected, cancelled or failed
  proposal, is refused with ProposalNotApproved and moves nothing; the
  deposit is refunded exactly once
- an expired proposal is settled once (Expired, deposit refunded, one
  expired event) and never becomes executed, however often execute runs
- quorum/majority arithmetic cannot overflow and holds its tie/majority
  boundary at the MAX_APPROVERS allow-list

Refs ASTROIDX556#226

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@Nife-tanny 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! 🚀

Learn more about application limits

@Cjay-Cyber-2 Cjay-Cyber-2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good — approving.

@Cjay-Cyber-2

Copy link
Copy Markdown
Contributor

CI Checks Failed — Action Needed

The continuous integration checks on this pull request are failing. I can't merge until all required checks pass.

Failing checks:

  • test: failure

What to do: review the failing checks in the "Checks" tab above, fix the issues in your code, and push the changes.

ASTROIDX556#382 moved quorum_required / majority_required from ProposalContract to
the new VoteBars type. The overflow test added for ASTROIDX556#226 still called them
on ProposalContract, so after main was merged in the test build failed
with E0599. Point it at VoteBars like the rest of the suite.

Refs ASTROIDX556#226

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Cjay-Cyber-2 Cjay-Cyber-2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good — approving.

@Cjay-Cyber-2 Cjay-Cyber-2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good — approving.

@Cjay-Cyber-2
Cjay-Cyber-2 merged commit 88ee859 into ASTROIDX556:main Sep 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Establish proposal execution threshold validation in astroid_proposal contract

2 participants