Skip to content

Commit 2967742

Browse files
Merge pull request #177 from luhrhenz/feature/governance-mintcap-multitoken-persistent-storage
feat: governance staking fix, persistent cert storage, TTL renew
2 parents b4aa815 + 561d1f2 commit 2967742

2 files changed

Lines changed: 122 additions & 7 deletions

File tree

contracts/src/lib.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ const DEFAULT_MINT_CAP: u32 = 1000;
126126
const LEDGERS_PER_PERIOD: u32 = 17280;
127127
const GOVERNANCE_THRESHOLD: u32 = 2;
128128
const GOVERNANCE_ADMIN_COUNT: u32 = 3;
129+
/// ~1 year in ledgers (5-second ledger close time).
130+
const CERT_TTL_LEDGERS: u32 = 6_307_200;
129131

130132
const NONCE_PREFIX: &str = "nonce";
131133

@@ -487,7 +489,10 @@ impl CertificateContract {
487489
revoked: false,
488490
};
489491

490-
env.storage().instance().set(&key, &cert);
492+
env.storage().persistent().set(&key, &cert);
493+
env.storage()
494+
.persistent()
495+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
491496

492497
env.events().publish(
493498
(Symbol::new(&env, "cert_issued"), course_symbol.clone()),
@@ -554,7 +559,10 @@ impl CertificateContract {
554559
};
555560

556561
// Batch storage operations for efficiency
557-
env.storage().instance().set(&key, &cert);
562+
env.storage().persistent().set(&key, &cert);
563+
env.storage()
564+
.persistent()
565+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
558566

559567
// Batch event emission (emit one event per certificate for transparency)
560568
env.events().publish(
@@ -595,12 +603,15 @@ impl CertificateContract {
595603
student: student.clone(),
596604
};
597605

598-
let mut cert: Certificate = env.storage().instance().get(&key).unwrap_or_else(|| {
606+
let mut cert: Certificate = env.storage().persistent().get(&key).unwrap_or_else(|| {
599607
panic_with_error!(&env, CertError::CertificateNotFound);
600608
});
601609

602610
cert.revoked = true;
603-
env.storage().instance().set(&key, &cert);
611+
env.storage().persistent().set(&key, &cert);
612+
env.storage()
613+
.persistent()
614+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
604615

605616
env.events().publish(
606617
(Symbol::new(&env, "cert_revoked"), course_symbol),
@@ -617,7 +628,37 @@ impl CertificateContract {
617628
course_symbol,
618629
student,
619630
};
620-
env.storage().instance().get(&key)
631+
env.storage().persistent().get(&key)
632+
}
633+
634+
/// Extend the TTL of a certificate entry in persistent storage.
635+
/// The student (or a governance admin) pays for the storage rent extension.
636+
pub fn renew_certificate(env: Env, caller: Address, course_symbol: Symbol, student: Address) {
637+
caller.require_auth();
638+
639+
// Only the student themselves or a governance admin may renew.
640+
let is_admin = Self::governance_admin_index(&env, &caller).is_some();
641+
if caller != student && !is_admin {
642+
panic_with_error!(&env, CertError::Unauthorized);
643+
}
644+
645+
let key = CertKey {
646+
course_symbol: course_symbol.clone(),
647+
student: student.clone(),
648+
};
649+
650+
if !env.storage().persistent().has(&key) {
651+
panic_with_error!(&env, CertError::CertificateNotFound);
652+
}
653+
654+
env.storage()
655+
.persistent()
656+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
657+
658+
env.events().publish(
659+
(Symbol::new(&env, "cert_renewed"), course_symbol),
660+
(caller, student),
661+
);
621662
}
622663

623664
pub fn execute_meta_tx(
@@ -686,7 +727,10 @@ impl CertificateContract {
686727
revoked: false,
687728
};
688729

689-
env.storage().instance().set(&key, &cert);
730+
env.storage().persistent().set(&key, &cert);
731+
env.storage()
732+
.persistent()
733+
.extend_ttl(&key, CERT_TTL_LEDGERS, CERT_TTL_LEDGERS);
690734

691735
env.events().publish(
692736
(

contracts/src/staking.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub enum StakingKey {
1919
Vote(Address, u32),
2020
/// Whether a proposal is currently active.
2121
ProposalActive(u32),
22+
/// List of all currently-active proposal IDs (used to gate withdrawals).
23+
ActiveProposals,
2224
}
2325

2426
// ---------------------------------------------------------------------------
@@ -58,14 +60,29 @@ impl StakingContract {
5860
}
5961

6062
/// Withdraw `amount` RS-Tokens for `staker`.
61-
/// Panics if the staker has any active vote outstanding.
63+
/// Panics if the staker has any active vote outstanding (checked via `ActiveProposals` list).
6264
pub fn withdraw_tokens(env: Env, staker: Address, amount: i128) {
6365
staker.require_auth();
6466

6567
if amount <= 0 {
6668
panic_with_error!(&env, StakingError::InvalidAmount);
6769
}
6870

71+
// Block withdrawal if the staker has voted on any currently-active proposal.
72+
let active_proposals: soroban_sdk::Vec<u32> = env
73+
.storage()
74+
.instance()
75+
.get(&StakingKey::ActiveProposals)
76+
.unwrap_or_else(|| soroban_sdk::Vec::new(&env));
77+
78+
for proposal_id in active_proposals.iter() {
79+
let vote_key = StakingKey::Vote(staker.clone(), proposal_id);
80+
let voted: i128 = env.storage().instance().get(&vote_key).unwrap_or(0);
81+
if voted > 0 {
82+
panic_with_error!(&env, StakingError::VoteActive);
83+
}
84+
}
85+
6986
let stake_key = StakingKey::Stake(staker.clone());
7087
let current: i128 = env.storage().instance().get(&stake_key).unwrap_or(0);
7188

@@ -110,6 +127,19 @@ impl StakingContract {
110127
env.storage()
111128
.instance()
112129
.set(&StakingKey::ProposalActive(proposal_id), &true);
130+
131+
// Track in the active proposals list so withdraw_tokens can check it.
132+
let mut active: soroban_sdk::Vec<u32> = env
133+
.storage()
134+
.instance()
135+
.get(&StakingKey::ActiveProposals)
136+
.unwrap_or_else(|| soroban_sdk::Vec::new(&env));
137+
if !active.contains(proposal_id) {
138+
active.push_back(proposal_id);
139+
env.storage()
140+
.instance()
141+
.set(&StakingKey::ActiveProposals, &active);
142+
}
113143
}
114144

115145
/// Close a proposal, allowing stakers to withdraw again.
@@ -118,6 +148,22 @@ impl StakingContract {
118148
env.storage()
119149
.instance()
120150
.set(&StakingKey::ProposalActive(proposal_id), &false);
151+
152+
// Remove from the active proposals list.
153+
let active: soroban_sdk::Vec<u32> = env
154+
.storage()
155+
.instance()
156+
.get(&StakingKey::ActiveProposals)
157+
.unwrap_or_else(|| soroban_sdk::Vec::new(&env));
158+
let mut updated: soroban_sdk::Vec<u32> = soroban_sdk::Vec::new(&env);
159+
for id in active.iter() {
160+
if id != proposal_id {
161+
updated.push_back(id);
162+
}
163+
}
164+
env.storage()
165+
.instance()
166+
.set(&StakingKey::ActiveProposals, &updated);
121167
}
122168

123169
pub fn get_stake(env: Env, staker: Address) -> i128 {
@@ -179,6 +225,31 @@ mod tests {
179225
client.withdraw_tokens(&staker, &100);
180226
}
181227

228+
#[test]
229+
#[should_panic]
230+
fn cannot_withdraw_while_vote_active() {
231+
let (env, admin, client) = setup();
232+
let staker = Address::generate(&env);
233+
client.stake_tokens(&staker, &100);
234+
client.open_proposal(&admin, &5);
235+
client.cast_vote(&staker, &5, &50);
236+
// Should panic: staker has an active vote on proposal 5
237+
client.withdraw_tokens(&staker, &10);
238+
}
239+
240+
#[test]
241+
fn can_withdraw_after_proposal_closed() {
242+
let (env, admin, client) = setup();
243+
let staker = Address::generate(&env);
244+
client.stake_tokens(&staker, &100);
245+
client.open_proposal(&admin, &6);
246+
client.cast_vote(&staker, &6, &50);
247+
client.close_proposal(&admin, &6);
248+
// Proposal closed — withdrawal should succeed
249+
client.withdraw_tokens(&staker, &10);
250+
assert_eq!(client.get_stake(&staker), 90);
251+
}
252+
182253
#[test]
183254
fn cast_vote_records_weight() {
184255
let (env, admin, client) = setup();

0 commit comments

Comments
 (0)