Skip to content

Commit d17f896

Browse files
Merge pull request #648 from sanctus-mvp/feat/delegation-storage-griefing-guard
feat: add delegation with storage griefing guard to governance
2 parents d296260 + 8e37be5 commit d17f896

3 files changed

Lines changed: 323 additions & 5 deletions

File tree

contracts/predictify-hybrid/src/governance.rs

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,22 @@ pub struct GovernanceProposal {
2323
enum StorageKey {
2424
Proposal(Symbol),
2525
ProposalList, // Vec<Symbol>
26-
Vote(Symbol, Address), // proposal id + voter -> u8 (0 none, 1 for, 2 against)
26+
Vote(Symbol, Address), // proposal id + voter -> i32 (0 none, 1 for, 2 against)
2727
VotingPeriod, // u64
2828
QuorumVotes, // u128 minimum FOR votes required
2929
Admin, // Address
30+
/// Maps delegator -> delegate (Address). At most one per delegator (griefing guard).
31+
Delegate(Address),
32+
/// Tracks how many delegators are currently delegating to a given address.
33+
/// Capped at MAX_INCOMING_DELEGATIONS to bound storage.
34+
DelegateCount(Address),
3035
}
3136

37+
/// Maximum number of delegators that may point to a single delegate address.
38+
/// Limits griefing: prevents an attacker from forcing the contract to walk an
39+
/// unbounded list when tallying delegated votes.
40+
const MAX_INCOMING_DELEGATIONS: u32 = 50;
41+
3242
/// Simple errors for the contract
3343
#[contracttype]
3444
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -44,6 +54,16 @@ pub enum GovernanceError {
4454
NotAdmin,
4555
NotInitialized,
4656
InvalidParams,
57+
/// Caller tried to delegate to themselves.
58+
SelfDelegation,
59+
/// Caller already has an active delegation; must unset first.
60+
DelegationAlreadySet,
61+
/// The target delegate has reached the incoming-delegation cap (griefing guard).
62+
DelegateLimitReached,
63+
/// No active delegation found for the caller.
64+
NoDelegationSet,
65+
/// Delegation would create a cycle (A→B→A).
66+
DelegationCycle,
4767
}
4868

4969
/// ---------- CONTRACT ----------
@@ -154,7 +174,7 @@ impl GovernanceContract {
154174
}
155175

156176
/// Vote on a proposal. `support = true` means FOR, false means AGAINST.
157-
/// One address one vote (no weighting).
177+
/// Each address counts as 1 vote plus 1 for each address that has delegated to it.
158178
pub fn vote(
159179
env: Env,
160180
voter: Address,
@@ -193,13 +213,21 @@ impl GovernanceContract {
193213
return Err(GovernanceError::AlreadyVoted);
194214
}
195215

216+
// Voting weight = 1 (own vote) + number of addresses delegating to this voter.
217+
let delegated: u128 = env
218+
.storage()
219+
.persistent()
220+
.get::<StorageKey, u32>(&StorageKey::DelegateCount(voter.clone()))
221+
.unwrap_or(0) as u128;
222+
let weight: u128 = 1 + delegated;
223+
196224
if support {
197-
p.for_votes += 1;
225+
p.for_votes += weight;
198226
env.storage()
199227
.persistent()
200228
.set(&StorageKey::Vote(proposal_id.clone(), voter.clone()), &1i32);
201229
} else {
202-
p.against_votes += 1;
230+
p.against_votes += weight;
203231
env.storage()
204232
.persistent()
205233
.set(&StorageKey::Vote(proposal_id.clone(), voter.clone()), &2i32);
@@ -216,6 +244,101 @@ impl GovernanceContract {
216244
Ok(())
217245
}
218246

247+
/// Delegate the caller's vote to `delegate`.
248+
///
249+
/// Storage griefing guard:
250+
/// - A delegator may hold at most **one** active delegation at a time.
251+
/// - A delegate may receive at most `MAX_INCOMING_DELEGATIONS` incoming delegations.
252+
/// - Self-delegation and two-hop cycles (A→B while B→A exists) are rejected.
253+
pub fn set_delegate(
254+
env: Env,
255+
delegator: Address,
256+
delegate: Address,
257+
) -> Result<(), GovernanceError> {
258+
delegator.require_auth();
259+
260+
// No self-delegation
261+
if delegator == delegate {
262+
return Err(GovernanceError::SelfDelegation);
263+
}
264+
265+
// Detect two-hop cycle: reject if delegate has already delegated back to delegator
266+
if let Some(delegates_delegate) = env
267+
.storage()
268+
.persistent()
269+
.get::<StorageKey, Address>(&StorageKey::Delegate(delegate.clone()))
270+
{
271+
if delegates_delegate == delegator {
272+
return Err(GovernanceError::DelegationCycle);
273+
}
274+
}
275+
276+
// Enforce max-1 active delegation per delegator (griefing guard)
277+
if env
278+
.storage()
279+
.persistent()
280+
.has(&StorageKey::Delegate(delegator.clone()))
281+
{
282+
return Err(GovernanceError::DelegationAlreadySet);
283+
}
284+
285+
// Enforce incoming-delegation cap on the delegate (griefing guard)
286+
let incoming: u32 = env
287+
.storage()
288+
.persistent()
289+
.get::<StorageKey, u32>(&StorageKey::DelegateCount(delegate.clone()))
290+
.unwrap_or(0);
291+
if incoming >= MAX_INCOMING_DELEGATIONS {
292+
return Err(GovernanceError::DelegateLimitReached);
293+
}
294+
295+
// Persist delegation and bump counter
296+
env.storage()
297+
.persistent()
298+
.set(&StorageKey::Delegate(delegator.clone()), &delegate);
299+
env.storage()
300+
.persistent()
301+
.set(&StorageKey::DelegateCount(delegate.clone()), &(incoming + 1));
302+
303+
Ok(())
304+
}
305+
306+
/// Remove the caller's active delegation.
307+
pub fn unset_delegate(env: Env, delegator: Address) -> Result<(), GovernanceError> {
308+
delegator.require_auth();
309+
310+
let delegate: Address = env
311+
.storage()
312+
.persistent()
313+
.get::<StorageKey, Address>(&StorageKey::Delegate(delegator.clone()))
314+
.ok_or(GovernanceError::NoDelegationSet)?;
315+
316+
// Decrement incoming count on the previously-pointed-at delegate
317+
let incoming: u32 = env
318+
.storage()
319+
.persistent()
320+
.get::<StorageKey, u32>(&StorageKey::DelegateCount(delegate.clone()))
321+
.unwrap_or(0);
322+
if incoming > 0 {
323+
env.storage()
324+
.persistent()
325+
.set(&StorageKey::DelegateCount(delegate.clone()), &(incoming - 1));
326+
}
327+
328+
env.storage()
329+
.persistent()
330+
.remove(&StorageKey::Delegate(delegator.clone()));
331+
332+
Ok(())
333+
}
334+
335+
/// Return the delegate for `delegator`, if any.
336+
pub fn get_delegate(env: Env, delegator: Address) -> Option<Address> {
337+
env.storage()
338+
.persistent()
339+
.get::<StorageKey, Address>(&StorageKey::Delegate(delegator))
340+
}
341+
219342
/// Validate governance votes for a proposal. Returns (passed: bool, reason: String)
220343
pub fn validate_proposal(
221344
env: Env,

contracts/predictify-hybrid/src/governance_tests.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@ impl GovernanceFixture {
109109
.ledger()
110110
.with_mut(|li| li.timestamp = li.timestamp.saturating_add(seconds));
111111
}
112+
113+
fn set_delegate(&self, delegator: Address, delegate: Address) -> Result<(), GovernanceError> {
114+
self.env.as_contract(&self.contract_id, || {
115+
GovernanceContract::set_delegate(self.env.clone(), delegator, delegate)
116+
})
117+
}
118+
119+
fn unset_delegate(&self, delegator: Address) -> Result<(), GovernanceError> {
120+
self.env.as_contract(&self.contract_id, || {
121+
GovernanceContract::unset_delegate(self.env.clone(), delegator)
122+
})
123+
}
124+
125+
fn get_delegate(&self, delegator: Address) -> Option<Address> {
126+
self.env.as_contract(&self.contract_id, || {
127+
GovernanceContract::get_delegate(self.env.clone(), delegator)
128+
})
129+
}
112130
}
113131

114132
#[test]
@@ -328,3 +346,180 @@ fn governance_set_voting_period_is_applied_to_new_proposals() {
328346

329347
assert_eq!(proposal.end_time - proposal.start_time, 250);
330348
}
349+
350+
// ===== DELEGATION TESTS =====
351+
352+
#[test]
353+
fn delegation_set_and_get_round_trip() {
354+
let fixture = GovernanceFixture::new(100, 1);
355+
let delegator = Address::generate(&fixture.env);
356+
let delegate = Address::generate(&fixture.env);
357+
358+
assert_eq!(fixture.get_delegate(delegator.clone()), None);
359+
360+
fixture.set_delegate(delegator.clone(), delegate.clone()).unwrap();
361+
assert_eq!(fixture.get_delegate(delegator.clone()), Some(delegate));
362+
}
363+
364+
#[test]
365+
fn delegation_unset_removes_entry() {
366+
let fixture = GovernanceFixture::new(100, 1);
367+
let delegator = Address::generate(&fixture.env);
368+
let delegate = Address::generate(&fixture.env);
369+
370+
fixture.set_delegate(delegator.clone(), delegate.clone()).unwrap();
371+
fixture.unset_delegate(delegator.clone()).unwrap();
372+
373+
assert_eq!(fixture.get_delegate(delegator), None);
374+
}
375+
376+
#[test]
377+
fn delegation_rejects_self_delegation() {
378+
let fixture = GovernanceFixture::new(100, 1);
379+
let addr = Address::generate(&fixture.env);
380+
381+
let result = fixture.set_delegate(addr.clone(), addr.clone());
382+
assert_eq!(result, Err(GovernanceError::SelfDelegation));
383+
}
384+
385+
#[test]
386+
fn delegation_rejects_cycle() {
387+
let fixture = GovernanceFixture::new(100, 1);
388+
let a = Address::generate(&fixture.env);
389+
let b = Address::generate(&fixture.env);
390+
391+
// A delegates to B; then B tries to delegate back to A
392+
fixture.set_delegate(a.clone(), b.clone()).unwrap();
393+
let result = fixture.set_delegate(b.clone(), a.clone());
394+
assert_eq!(result, Err(GovernanceError::DelegationCycle));
395+
}
396+
397+
#[test]
398+
fn delegation_rejects_double_set_without_unset() {
399+
let fixture = GovernanceFixture::new(100, 1);
400+
let delegator = Address::generate(&fixture.env);
401+
let d1 = Address::generate(&fixture.env);
402+
let d2 = Address::generate(&fixture.env);
403+
404+
fixture.set_delegate(delegator.clone(), d1.clone()).unwrap();
405+
let result = fixture.set_delegate(delegator.clone(), d2.clone());
406+
assert_eq!(result, Err(GovernanceError::DelegationAlreadySet));
407+
}
408+
409+
#[test]
410+
fn delegation_allows_reset_after_unset() {
411+
let fixture = GovernanceFixture::new(100, 1);
412+
let delegator = Address::generate(&fixture.env);
413+
let d1 = Address::generate(&fixture.env);
414+
let d2 = Address::generate(&fixture.env);
415+
416+
fixture.set_delegate(delegator.clone(), d1.clone()).unwrap();
417+
fixture.unset_delegate(delegator.clone()).unwrap();
418+
fixture.set_delegate(delegator.clone(), d2.clone()).unwrap();
419+
420+
assert_eq!(fixture.get_delegate(delegator), Some(d2));
421+
}
422+
423+
#[test]
424+
fn delegation_unset_without_active_delegation_errors() {
425+
let fixture = GovernanceFixture::new(100, 1);
426+
let delegator = Address::generate(&fixture.env);
427+
428+
let result = fixture.unset_delegate(delegator);
429+
assert_eq!(result, Err(GovernanceError::NoDelegationSet));
430+
}
431+
432+
#[test]
433+
fn delegation_griefing_guard_rejects_beyond_cap() {
434+
let fixture = GovernanceFixture::new(100, 1);
435+
let delegate = Address::generate(&fixture.env);
436+
437+
// Fill delegate up to the cap (MAX_INCOMING_DELEGATIONS = 50)
438+
for _ in 0..50 {
439+
let d = Address::generate(&fixture.env);
440+
fixture.set_delegate(d, delegate.clone()).unwrap();
441+
}
442+
443+
// The 51st should be rejected
444+
let overflow = Address::generate(&fixture.env);
445+
let result = fixture.set_delegate(overflow, delegate.clone());
446+
assert_eq!(result, Err(GovernanceError::DelegateLimitReached));
447+
}
448+
449+
#[test]
450+
fn delegation_weight_counted_in_vote() {
451+
// voter_one has two delegators; their single vote should count as 3
452+
let fixture = GovernanceFixture::new(100, 3);
453+
let proposal_id = fixture.create_noop_proposal("gov_deleg_1");
454+
455+
let delegator_a = Address::generate(&fixture.env);
456+
let delegator_b = Address::generate(&fixture.env);
457+
458+
fixture
459+
.set_delegate(delegator_a.clone(), fixture.voter_one.clone())
460+
.unwrap();
461+
fixture
462+
.set_delegate(delegator_b.clone(), fixture.voter_one.clone())
463+
.unwrap();
464+
465+
// voter_one votes FOR (weight = 1 own + 2 delegated = 3 → meets quorum of 3)
466+
fixture
467+
.vote(fixture.voter_one.clone(), proposal_id.clone(), true)
468+
.unwrap();
469+
470+
fixture.advance_time(100);
471+
let (passed, _) = fixture.validate(proposal_id).unwrap();
472+
assert!(passed, "proposal should pass with delegated weight");
473+
}
474+
475+
#[test]
476+
fn delegation_against_weight_counted_in_vote() {
477+
// voter_one has 1 delegator; their against vote counts as 2
478+
let fixture = GovernanceFixture::new(100, 1);
479+
let proposal_id = fixture.create_noop_proposal("gov_deleg_2");
480+
481+
let delegator = Address::generate(&fixture.env);
482+
fixture
483+
.set_delegate(delegator.clone(), fixture.voter_one.clone())
484+
.unwrap();
485+
486+
// voter_two: 1 FOR; voter_one: 2 AGAINST → proposal should not pass
487+
fixture
488+
.vote(fixture.voter_two.clone(), proposal_id.clone(), true)
489+
.unwrap();
490+
fixture
491+
.vote(fixture.voter_one.clone(), proposal_id.clone(), false)
492+
.unwrap();
493+
494+
fixture.advance_time(100);
495+
let (passed, _) = fixture.validate(proposal_id).unwrap();
496+
assert!(!passed, "proposal should fail when against weight exceeds for");
497+
}
498+
499+
#[test]
500+
fn unset_delegation_restores_delegate_capacity() {
501+
let fixture = GovernanceFixture::new(100, 1);
502+
let delegate = Address::generate(&fixture.env);
503+
504+
// Fill to cap
505+
let mut delegators = soroban_sdk::Vec::new(&fixture.env);
506+
for _ in 0..50 {
507+
let d = Address::generate(&fixture.env);
508+
fixture.set_delegate(d.clone(), delegate.clone()).unwrap();
509+
delegators.push_back(d);
510+
}
511+
512+
// Overflow still rejected
513+
let overflow = Address::generate(&fixture.env);
514+
assert_eq!(
515+
fixture.set_delegate(overflow.clone(), delegate.clone()),
516+
Err(GovernanceError::DelegateLimitReached)
517+
);
518+
519+
// Remove one delegation
520+
let first = delegators.get(0).unwrap();
521+
fixture.unset_delegate(first).unwrap();
522+
523+
// Now the overflow address can delegate
524+
fixture.set_delegate(overflow, delegate).unwrap();
525+
}

contracts/predictify-hybrid/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ mod upgrade_manager_tests;
129129
// mod event_management_tests;
130130

131131
// #[cfg(test)]
132-
// mod governance_tests;
132+
mod governance_tests;
133133

134134
#[cfg(any())]
135135
mod category_tags_tests;

0 commit comments

Comments
 (0)