Skip to content

Commit b8b81ee

Browse files
authored
feat(governance): add cancel_proposal with proposer/admin auth (#471)
- Add cancel_proposal(canceller, proposal_id) function; only the original proposer or contract admin may cancel - Cancelled proposals return ProposalAlreadyCancelled on execute - Emits PropCanc event with (proposal_id) topics and canceller data - Add ProposalAlreadyCancelled = 14 error variant - Add cancelled: bool field to GovernanceProposal struct - Add DataKey::Proposer(u64) to store proposer address at creation - Unit tests: test_cancel_emits_event, plus cancel suite - Integration tests: test_cancel_proposal_create_cancel_execute_fails, test_admin_can_cancel_proposal, test_unauthorized_cancel_is_rejected, test_double_cancel_is_rejected - Fix event capture ordering in integration test (events().all() must be called before any subsequent client call drains the buffer) - CHANGELOG.md entry added Closes #(cancel-mechanism issue)
1 parent 6ff6903 commit b8b81ee

31 files changed

Lines changed: 4146 additions & 632 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `governance`: `cancel_proposal(canceller, proposal_id)` — replaces the previous no-op `cancel()` stub with a real on-chain cancellation function. Only the original proposer or the contract admin may cancel; any other caller receives `NotAuthorized`. Cancelled proposals cannot be executed (`ProposalAlreadyCancelled` is returned on any `execute` attempt). Emits a `PropCanc` event with `(proposal_id)` as topics and the canceller address as data. Votes already cast are preserved in storage but have no effect — registered voter weight is not consumed globally and remains available for other proposals. `GovernanceProposal` struct gains a `cancelled: bool` field and a `proposer: Address` field. New `ProposalAlreadyCancelled = 14` error variant added. New `DataKey::Proposer(u64)` persistent storage key stores the proposer address at creation time. Integration tests: `test_cancel_proposal_create_cancel_execute_fails`, `test_admin_can_cancel_proposal`, `test_unauthorized_cancel_is_rejected`, `test_double_cancel_is_rejected` (#issue)
13+
1214
- `credit-oracle`: on-chain dispute mechanism for score inputs. Subjects can call `flag_score_input(subject, input_key, reason)` to flag a `tx_stats`, `repayment`, or `vc_count` input as incorrect; admins resolve disputes via `resolve_dispute(subject, input_key, accepted)`. Anti-griefing enforced: only one `Pending` dispute per `(subject, input_key)` pair at a time. Emits `DsptFild`, `DsptRslv`, and `DsptRjct` events for off-chain feeder indexing. Read helpers: `get_dispute` and `list_disputes`. Dispute records stored with 30-day TTL (#244)
1315
- `packages/cli` (`@stellar-did-credit/cli`): new command-line interface with four commands — `anchor-did` (stores a DID document CID on-chain), `get-score` (reads a credit score with formatted table or JSON output), `verify-vc` (checks whether a VC hash is valid and non-revoked), and `compute-score` (submits a score computation transaction and returns the result). Reads contract IDs from environment variables, a `stellar-did-config.json` file, or `deployments.testnet.json`-style config. Built on `commander` with `--help` for every command (#161)
1416

contracts/governance/src/lib.rs

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub enum GovernanceError {
3838
VoterNotRegistered = 12,
3939
/// Vote weight exceeds voter's available balance.
4040
InsufficientVoteWeight = 13,
41+
/// Proposal has already been cancelled and cannot be executed or cancelled again.
42+
ProposalAlreadyCancelled = 14,
4143
}
4244

4345
/// Storage keys for the governance contract.
@@ -53,6 +55,8 @@ pub enum DataKey {
5355
QuorumRequired,
5456
/// Proposal data stored by proposal ID.
5557
Proposal(u64),
58+
/// Original proposer address for a given proposal ID.
59+
Proposer(u64),
5660
/// Registered voting weight for an address.
5761
VoterWeight(Address),
5862
/// Amount of weight already used by voter in a specific proposal.
@@ -80,6 +84,8 @@ const INSTANCE_BUMP_AMOUNT: u32 = 500_000;
8084
pub struct GovernanceProposal {
8185
/// Unique proposal identifier, assigned at creation.
8286
pub id: u64,
87+
/// Address of the account that created this proposal.
88+
pub proposer: Address,
8389
/// Scoring weights to apply to the credit-oracle if the proposal passes.
8490
pub proposed_weights: ScoringWeights,
8591
/// Accumulated weight of votes cast in favor.
@@ -94,6 +100,9 @@ pub struct GovernanceProposal {
94100
pub execution_delay_ledgers: u32,
95101
/// Whether this proposal has been executed (weights applied or vote failed).
96102
pub executed: bool,
103+
/// Whether this proposal has been cancelled. Cancelled proposals cannot be
104+
/// executed. Only the original proposer or the contract admin may cancel.
105+
pub cancelled: bool,
97106
/// Minimum `votes_for + votes_against` required for `execute` to apply
98107
/// this proposal's weights, snapshotted from the contract-wide default
99108
/// at proposal-creation time so later `set_quorum` calls never change
@@ -232,18 +241,23 @@ impl Governance {
232241

233242
let proposal = GovernanceProposal {
234243
id,
244+
proposer: proposer.clone(),
235245
proposed_weights: weights,
236246
votes_for: 0,
237247
votes_against: 0,
238248
expiry_ledger,
239249
execution_delay_ledgers,
240250
executed: false,
251+
cancelled: false,
241252
quorum_required,
242253
};
243254

244255
env.storage()
245256
.persistent()
246257
.set(&DataKey::Proposal(id), &proposal);
258+
env.storage()
259+
.persistent()
260+
.set(&DataKey::Proposer(id), &proposer);
247261
env.storage()
248262
.instance()
249263
.set(&DataKey::NextProposalId, &(id + 1));
@@ -372,6 +386,10 @@ impl Governance {
372386
return Err(GovernanceError::ProposalAlreadyExecuted);
373387
}
374388

389+
if proposal.cancelled {
390+
return Err(GovernanceError::ProposalAlreadyCancelled);
391+
}
392+
375393
if proposal.votes_for + proposal.votes_against < proposal.quorum_required {
376394
return Err(GovernanceError::QuorumNotMet);
377395
}
@@ -599,18 +617,64 @@ impl Governance {
599617

600618
/// Cancel a governance proposal.
601619
///
602-
/// Note: Full cancellation logic is out of scope. This only emits the cancellation event.
603-
pub fn cancel(
620+
/// Only the original proposer or the contract admin may cancel a proposal.
621+
/// A proposal that has already been executed or cancelled cannot be cancelled again.
622+
/// Cancellation is immediate and permanent — a cancelled proposal can never be
623+
/// executed, regardless of how many votes it accumulated.
624+
///
625+
/// Votes already cast are preserved in storage but have no effect on a cancelled
626+
/// proposal. The votes are not refunded because registered voting weight is not
627+
/// consumed globally — each voter retains their full weight for other proposals.
628+
///
629+
/// Emits a `PropCanc` event with `(proposal_id)` as topics and
630+
/// `(canceller)` as data so off-chain indexers can track cancellations.
631+
///
632+
/// Auth: `canceller` must sign the transaction and must be either the
633+
/// original proposer or the contract admin.
634+
pub fn cancel_proposal(
604635
env: Env,
605636
canceller: Address,
606637
proposal_id: u64,
607-
reason: Option<soroban_sdk::String>,
608638
) -> Result<(), GovernanceError> {
609639
canceller.require_auth();
610640

641+
let proposal_key = DataKey::Proposal(proposal_id);
642+
let mut proposal: GovernanceProposal = env
643+
.storage()
644+
.persistent()
645+
.get(&proposal_key)
646+
.ok_or(GovernanceError::ProposalNotFound)?;
647+
648+
if proposal.executed {
649+
return Err(GovernanceError::ProposalAlreadyExecuted);
650+
}
651+
652+
if proposal.cancelled {
653+
return Err(GovernanceError::ProposalAlreadyCancelled);
654+
}
655+
656+
// Only the original proposer or the admin may cancel.
657+
let stored_admin: Address = env
658+
.storage()
659+
.instance()
660+
.get(&DataKey::Admin)
661+
.ok_or(GovernanceError::NotAuthorized)?;
662+
let stored_proposer: Address = env
663+
.storage()
664+
.persistent()
665+
.get(&DataKey::Proposer(proposal_id))
666+
.ok_or(GovernanceError::NotAuthorized)?;
667+
668+
if canceller != stored_admin && canceller != stored_proposer {
669+
return Err(GovernanceError::NotAuthorized);
670+
}
671+
672+
proposal.cancelled = true;
673+
env.storage().persistent().set(&proposal_key, &proposal);
674+
611675
env.events().publish(
612676
(symbol_short!("PropCanc"), proposal_id),
613-
(canceller, reason),
677+
canceller,
614678
);
615679

616680
Ok(())
@@ -852,10 +916,8 @@ mod tests {
852916
let proposer = Address::generate(&env);
853917
let proposal_id = gov_client.create_proposal(&proposer, &proposed_weights, &100, &0);
854918

855-
let canceller = Address::generate(&env);
856-
let reason = Some(soroban_sdk::String::from_str(&env, "Spam proposal"));
857-
858-
gov_client.cancel(&canceller, &proposal_id, &reason);
919+
// The proposer cancels their own proposal.
920+
gov_client.cancel_proposal(&proposer, &proposal_id);
859921

860922
let events = env.events().all();
861923
let mut found_event = false;
@@ -873,17 +935,19 @@ mod tests {
873935
let id: u64 = topics.get(1).unwrap().try_into_val(&env).unwrap();
874936
assert_eq!(id, proposal_id);
875937

876-
let event_data: (Address, Option<soroban_sdk::String>) =
877-
data.try_into_val(&env).unwrap();
878-
assert_eq!(event_data.0, canceller);
879-
// String comparison might require converting to bytes or comparing values but Option<String> should be somewhat comparable.
880-
// We will skip strict String value checking for now.
938+
let event_canceller: Address = data.try_into_val(&env).unwrap();
939+
assert_eq!(event_canceller, proposer);
881940
}
882941
}
883942
}
884943
}
885944

886945
assert!(found_event, "ProposalCancelled event should be emitted");
946+
947+
// Verify the proposal is now marked cancelled on-chain.
948+
let proposal = gov_client.get_proposal(&proposal_id).unwrap();
949+
assert!(proposal.cancelled, "proposal.cancelled must be true after cancel_proposal");
950+
assert!(!proposal.executed, "proposal.executed must remain false");
887951
}
888952

889953
/// Verifies the full execution timelock flow:

contracts/governance/test_snapshots/tests/test_cancel_emits_event.1.json

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"generators": {
3-
"address": 5,
3+
"address": 4,
44
"nonce": 0
55
},
66
"auth": [
@@ -108,29 +108,27 @@
108108
],
109109
[
110110
[
111-
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM",
111+
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
112112
{
113113
"function": {
114114
"contract_fn": {
115115
"contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
116-
"function_name": "cancel",
116+
"function_name": "cancel_proposal",
117117
"args": [
118118
{
119-
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
119+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
120120
},
121121
{
122122
"u64": 1
123-
},
124-
{
125-
"string": "Spam proposal"
126123
}
127124
]
128125
}
129126
},
130127
"sub_invocations": []
131128
}
132129
]
133-
]
130+
],
131+
[]
134132
],
135133
"ledger": {
136134
"protocol_version": 22,
@@ -351,6 +349,14 @@
351349
"durability": "persistent",
352350
"val": {
353351
"map": [
352+
{
353+
"key": {
354+
"symbol": "cancelled"
355+
},
356+
"val": {
357+
"bool": true
358+
}
359+
},
354360
{
355361
"key": {
356362
"symbol": "executed"
@@ -416,6 +422,14 @@
416422
]
417423
}
418424
},
425+
{
426+
"key": {
427+
"symbol": "proposer"
428+
},
429+
"val": {
430+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
431+
}
432+
},
419433
{
420434
"key": {
421435
"symbol": "quorum_required"
@@ -458,6 +472,51 @@
458472
4095
459473
]
460474
],
475+
[
476+
{
477+
"contract_data": {
478+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
479+
"key": {
480+
"vec": [
481+
{
482+
"symbol": "Proposer"
483+
},
484+
{
485+
"u64": 1
486+
}
487+
]
488+
},
489+
"durability": "persistent"
490+
}
491+
},
492+
[
493+
{
494+
"last_modified_ledger_seq": 0,
495+
"data": {
496+
"contract_data": {
497+
"ext": "v0",
498+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
499+
"key": {
500+
"vec": [
501+
{
502+
"symbol": "Proposer"
503+
},
504+
{
505+
"u64": 1
506+
}
507+
]
508+
},
509+
"durability": "persistent",
510+
"val": {
511+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4"
512+
}
513+
}
514+
},
515+
"ext": "v0"
516+
},
517+
4095
518+
]
519+
],
461520
[
462521
{
463522
"contract_data": {
@@ -578,7 +637,7 @@
578637
[
579638
{
580639
"contract_data": {
581-
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM",
640+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
582641
"key": {
583642
"ledger_key_nonce": {
584643
"nonce": 4837995959683129791
@@ -593,7 +652,7 @@
593652
"data": {
594653
"contract_data": {
595654
"ext": "v0",
596-
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM",
655+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
597656
"key": {
598657
"ledger_key_nonce": {
599658
"nonce": 4837995959683129791
@@ -631,36 +690,5 @@
631690
]
632691
]
633692
},
634-
"events": [
635-
{
636-
"event": {
637-
"ext": "v0",
638-
"contract_id": "0000000000000000000000000000000000000000000000000000000000000003",
639-
"type_": "contract",
640-
"body": {
641-
"v0": {
642-
"topics": [
643-
{
644-
"symbol": "PropCanc"
645-
},
646-
{
647-
"u64": 1
648-
}
649-
],
650-
"data": {
651-
"vec": [
652-
{
653-
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
654-
},
655-
{
656-
"string": "Spam proposal"
657-
}
658-
]
659-
}
660-
}
661-
}
662-
},
663-
"failed_call": false
664-
}
665-
]
693+
"events": []
666694
}

0 commit comments

Comments
 (0)