diff --git a/app/app/admin/proposals/page.jsx b/app/app/admin/proposals/page.jsx
index 2620d87..5d3da02 100644
--- a/app/app/admin/proposals/page.jsx
+++ b/app/app/admin/proposals/page.jsx
@@ -43,6 +43,8 @@ const MOCK_PROPOSALS = [
createdAt: "2026-05-27T09:00:00Z",
expiresAt: "2026-06-10T09:00:00Z",
proposer: "0x1234...7890",
+ epoch: 2,
+ signerSetHash: "0x8a4ed68eb9827338101baf33c664f8f6012dd77c5f0b56fbd01beac90dbd43c6",
},
{
id: "prop-002",
@@ -66,6 +68,8 @@ const MOCK_PROPOSALS = [
createdAt: "2026-05-30T16:00:00Z",
expiresAt: "2026-06-13T16:00:00Z",
proposer: "0x1234...7890",
+ epoch: 2,
+ signerSetHash: "0x8a4ed68eb9827338101baf33c664f8f6012dd77c5f0b56fbd01beac90dbd43c6",
},
{
id: "prop-003",
@@ -98,6 +102,8 @@ const MOCK_PROPOSALS = [
expiresAt: "2026-06-08T10:00:00Z",
proposer: "0xabcd...abcd",
executedAt: "2026-05-26T10:00:00Z",
+ epoch: 1,
+ signerSetHash: "0x4e6b5b557f1949cf8c6b5b557f1949cf8c6b5b557f1949cf8c6b5b557f1949c",
},
{
id: "prop-004",
@@ -106,18 +112,19 @@ const MOCK_PROPOSALS = [
"Modify the prize distribution to allocate 70% to grand prize and 30% to runner-up prizes.",
type: "configuration",
icon: Settings,
- status: "rejected",
+ status: "stale",
requiredSignatures: 3,
- currentSignatures: 0,
+ currentSignatures: 1,
signers: [
- { address: "0x1234...7890", signed: false, timestamp: null },
+ { address: "0x1234...7890", signed: true, timestamp: "2026-05-20T15:00:00Z" },
{ address: "0xabcd...abcd", signed: false, timestamp: null },
{ address: "0x9876...4321", signed: false, timestamp: null },
],
createdAt: "2026-05-20T14:00:00Z",
expiresAt: "2026-06-03T14:00:00Z",
proposer: "0x9876...4321",
- rejectedAt: "2026-05-22T16:30:00Z",
+ epoch: 1,
+ signerSetHash: "0x4e6b5b557f1949cf8c6b5b557f1949cf8c6b5b557f1949cf8c6b5b557f1949c",
},
];
@@ -146,6 +153,22 @@ const STATUS_CONFIG = {
borderClass: "border-red-500/40",
textClass: "text-red-600 dark:text-red-400",
},
+ stale: {
+ label: "Stale (Prior Epoch)",
+ color: "gray",
+ icon: AlertTriangle,
+ bgClass: "bg-neutral-500/10",
+ borderClass: "border-neutral-500/40",
+ textClass: "text-neutral-500 dark:text-neutral-400",
+ },
+ expired: {
+ label: "Expired",
+ color: "red",
+ icon: XCircle,
+ bgClass: "bg-red-500/10",
+ borderClass: "border-red-500/40",
+ textClass: "text-red-600 dark:text-red-400",
+ },
};
function ProposalTimeline({ proposal }) {
@@ -243,6 +266,22 @@ function ProposalCard({ proposal, isAdmin, onApprove, onReject }) {
Proposed by {proposal.proposer}
•
{new Date(proposal.createdAt).toLocaleDateString()}
+ {proposal.epoch !== undefined && (
+ <>
+ •
+
+ Epoch {proposal.epoch}
+
+ >
+ )}
+ {proposal.signerSetHash && (
+ <>
+ •
+
+ Hash: {proposal.signerSetHash.substring(0, 10)}...
+
+ >
+ )}
@@ -432,6 +471,17 @@ export default function AdminProposalsPage() {
+ {/* Epoch & Counted Approvals Banner/Explanation */}
+
+
+
+
Epoch-Bound Approvals & Quorum Protection
+
+ Approvals are bound to the signer set epoch. When the admin list or threshold changes, the epoch increments, rendering pending proposals from prior epochs stale. Removed signers are automatically disqualified, and every threshold modification preserves a reachable quorum.
+
+
+
+
{/* Stats Overview */}
,
+ pub threshold: u32,
}
#[derive(Clone, Debug, PartialEq)]
@@ -109,8 +120,14 @@ pub struct Participant {
#[derive(Clone, Debug, PartialEq)]
#[contracttype]
pub struct Proposal {
+ pub id: u32,
pub action: ProposalAction,
pub approvals: Vec,
+ pub epoch: u32,
+ pub signer_set_hash: BytesN<32>,
+ pub expires_at: u64,
+ pub proposer: Address,
+ pub status: ProposalStatus,
}
#[derive(Clone, Debug, PartialEq)]
@@ -119,6 +136,7 @@ pub enum ProposalAction {
ReleaseEscrow(Address, i128), // recipient, amount
AddAdmin(Address),
RemoveAdmin(Address),
+ ChangeThreshold(u32),
}
// ── Contract ───────────────────────────────────────────────────────────────
@@ -153,12 +171,58 @@ impl DripPool {
Ok(())
}
+ fn validate_quorum(admins_count: u32, threshold: u32) -> Result<(), Error> {
+ if threshold == 0 || threshold > admins_count {
+ return Err(Error::InvalidThreshold);
+ }
+ Ok(())
+ }
+
+ fn update_signer_set(env: &Env, pool: &mut Pool, new_admins: Vec) -> Result<(), Error> {
+ Self::validate_quorum(new_admins.len() as u32, pool.threshold)?;
+ pool.signer_epoch += 1;
+ pool.signer_set_hash = env.crypto().sha256(&new_admins.clone().to_xdr(env)).into();
+ env.storage().instance().set(&DataKey::Admins, &new_admins);
+ env.storage().instance().set(&DataKey::Pool, pool);
+ env.events().publish(
+ (symbol_short!("epoch_chg"), pool.signer_epoch),
+ pool.signer_set_hash.clone(),
+ );
+ Ok(())
+ }
+
+ fn check_proposal_status(
+ env: &Env,
+ proposal: &mut Proposal,
+ current_epoch: u32,
+ ) -> Result<(), Error> {
+ match proposal.status {
+ ProposalStatus::Executed => return Err(Error::ProposalAlreadyExecuted),
+ ProposalStatus::Cancelled => return Err(Error::ProposalCancelled),
+ ProposalStatus::Expired => return Err(Error::ProposalExpired),
+ ProposalStatus::Pending => {}
+ }
+
+ if proposal.epoch != current_epoch {
+ return Err(Error::StaleEpoch);
+ }
+
+ if env.ledger().timestamp() >= proposal.expires_at {
+ proposal.status = ProposalStatus::Expired;
+ return Err(Error::ProposalExpired);
+ }
+
+ Ok(())
+ }
+
// ── Initialise ─────────────────────────────────────────────────────────
pub fn create(env: Env, admin: Address) -> Result<(), Error> {
admin.require_auth();
if env.storage().instance().has(&DataKey::Pool) {
return Err(Error::AlreadyInitialized);
}
+ let admins: Vec = vec![&env, admin.clone()];
+ let signer_set_hash = env.crypto().sha256(&admins.clone().to_xdr(&env)).into();
let pool = Pool {
admin: admin.clone(),
total_drips: 0,
@@ -166,8 +230,10 @@ impl DripPool {
created_at: env.ledger().timestamp(),
locked: false,
proposal_nonce: 0,
+ signer_epoch: 1,
+ signer_set_hash,
+ threshold: SIG_THRESHOLD,
};
- let admins: Vec = vec![&env, admin.clone()];
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage().instance().set(&DataKey::Admins, &admins);
env.storage().instance().set(&DataKey::Pool, &pool);
@@ -179,14 +245,24 @@ impl DripPool {
pub fn add_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), Error> {
caller.require_auth();
Self::require_signer(&env, &caller)?;
+ let mut pool: Pool = env
+ .storage()
+ .instance()
+ .get(&DataKey::Pool)
+ .ok_or(Error::NotInitialized)?;
let mut admins: Vec = env
.storage()
.instance()
.get(&DataKey::Admins)
.unwrap_or(vec![&env]);
+
+ if admins.len() >= pool.threshold {
+ return Err(Error::BootstrapComplete);
+ }
+
if !admins.contains(&new_admin) {
admins.push_back(new_admin);
- env.storage().instance().set(&DataKey::Admins, &admins);
+ Self::update_signer_set(&env, &mut pool, admins)?;
}
Ok(())
}
@@ -195,6 +271,12 @@ impl DripPool {
caller.require_auth();
Self::require_signer(&env, &caller)?;
+ let mut pool: Pool = env
+ .storage()
+ .instance()
+ .get(&DataKey::Pool)
+ .ok_or(Error::NotInitialized)?;
+
let admins: Vec = env
.storage()
.instance()
@@ -205,6 +287,10 @@ impl DripPool {
return Err(Error::Unauthorized);
}
+ if admins.len() >= pool.threshold {
+ return Err(Error::BootstrapComplete);
+ }
+
let mut updated: Vec = Vec::new(&env);
for a in admins.iter() {
if a != target {
@@ -212,7 +298,7 @@ impl DripPool {
}
}
- env.storage().instance().set(&DataKey::Admins, &updated);
+ Self::update_signer_set(&env, &mut pool, updated)?;
Ok(())
}
@@ -230,13 +316,38 @@ impl DripPool {
pool.proposal_nonce += 1;
env.storage().instance().set(&DataKey::Pool, &pool);
+ let expires_at = env.ledger().timestamp() + 7 * 24 * 60 * 60; // 7 days default expiry
+
+ let threshold_met = pool.threshold <= 1;
+
let proposal = Proposal {
- action,
- approvals: vec![&env, signer],
+ id: nonce,
+ action: action.clone(),
+ approvals: vec![&env, signer.clone()],
+ epoch: pool.signer_epoch,
+ signer_set_hash: pool.signer_set_hash.clone(),
+ expires_at,
+ proposer: signer.clone(),
+ status: if threshold_met { ProposalStatus::Executed } else { ProposalStatus::Pending },
};
+
env.storage()
.instance()
.set(&DataKey::Proposal(nonce), &proposal);
+
+ if threshold_met {
+ Self::execute_proposal(&env, &proposal)?;
+ env.events().publish(
+ (symbol_short!("prop_exe"), nonce),
+ pool.signer_epoch,
+ );
+ }
+
+ env.events().publish(
+ (symbol_short!("prop_new"), nonce, signer),
+ pool.signer_epoch,
+ );
+
Ok(nonce)
}
@@ -245,32 +356,90 @@ impl DripPool {
signer.require_auth();
Self::require_signer(&env, &signer)?;
+ let pool: Pool = env
+ .storage()
+ .instance()
+ .get(&DataKey::Pool)
+ .ok_or(Error::NotInitialized)?;
+
let mut proposal: Proposal = env
.storage()
.instance()
.get(&DataKey::Proposal(proposal_id))
.ok_or(Error::ProposalNotFound)?;
+ Self::check_proposal_status(&env, &mut proposal, pool.signer_epoch)?;
+
if proposal.approvals.contains(&signer) {
return Err(Error::AlreadySigned);
}
- proposal.approvals.push_back(signer);
+ proposal.approvals.push_back(signer.clone());
+
+ env.events().publish(
+ (symbol_short!("prop_app"), proposal_id, signer),
+ proposal.approvals.len() as u32,
+ );
- let threshold_met = proposal.approvals.len() >= SIG_THRESHOLD;
+ let threshold_met = proposal.approvals.len() >= pool.threshold;
if threshold_met {
+ proposal.status = ProposalStatus::Executed;
Self::execute_proposal(&env, &proposal)?;
- env.storage()
- .instance()
- .remove(&DataKey::Proposal(proposal_id));
- } else {
- env.storage()
- .instance()
- .set(&DataKey::Proposal(proposal_id), &proposal);
+ env.events().publish(
+ (symbol_short!("prop_exe"), proposal_id),
+ pool.signer_epoch,
+ );
}
+
+ env.storage()
+ .instance()
+ .set(&DataKey::Proposal(proposal_id), &proposal);
+
Ok(threshold_met)
}
+ /// Cancel a proposal. Only callable by its proposer.
+ pub fn cancel(env: Env, signer: Address, proposal_id: u32) -> Result<(), Error> {
+ signer.require_auth();
+ Self::require_signer(&env, &signer)?;
+
+ let pool: Pool = env
+ .storage()
+ .instance()
+ .get(&DataKey::Pool)
+ .ok_or(Error::NotInitialized)?;
+
+ let mut proposal: Proposal = env
+ .storage()
+ .instance()
+ .get(&DataKey::Proposal(proposal_id))
+ .ok_or(Error::ProposalNotFound)?;
+
+ Self::check_proposal_status(&env, &mut proposal, pool.signer_epoch)?;
+
+ if proposal.proposer != signer {
+ return Err(Error::Unauthorized);
+ }
+
+ proposal.status = ProposalStatus::Cancelled;
+ env.storage()
+ .instance()
+ .set(&DataKey::Proposal(proposal_id), &proposal);
+
+ env.events().publish(
+ (symbol_short!("prop_can"), proposal_id),
+ signer,
+ );
+
+ Ok(())
+ }
+
fn execute_proposal(env: &Env, proposal: &Proposal) -> Result<(), Error> {
+ let mut pool: Pool = env
+ .storage()
+ .instance()
+ .get(&DataKey::Pool)
+ .ok_or(Error::NotInitialized)?;
+
match proposal.action.clone() {
ProposalAction::AddAdmin(addr) => {
let mut admins: Vec = env
@@ -280,7 +449,7 @@ impl DripPool {
.unwrap_or(vec![env]);
if !admins.contains(&addr) {
admins.push_back(addr);
- env.storage().instance().set(&DataKey::Admins, &admins);
+ Self::update_signer_set(env, &mut pool, admins)?;
}
}
ProposalAction::RemoveAdmin(addr) => {
@@ -295,14 +464,24 @@ impl DripPool {
new_admins.push_back(a);
}
}
- env.storage().instance().set(&DataKey::Admins, &new_admins);
+ Self::update_signer_set(env, &mut pool, new_admins)?;
}
- ProposalAction::ReleaseEscrow(_recipient, _amount) => {
- let mut pool: Pool = env
+ ProposalAction::ChangeThreshold(new_threshold) => {
+ let admins: Vec = env
.storage()
.instance()
- .get(&DataKey::Pool)
- .ok_or(Error::NotInitialized)?;
+ .get(&DataKey::Admins)
+ .unwrap_or(vec![env]);
+ Self::validate_quorum(admins.len() as u32, new_threshold)?;
+ pool.threshold = new_threshold;
+ env.storage().instance().set(&DataKey::Pool, &pool);
+
+ env.events().publish(
+ (symbol_short!("thresh_ch"), pool.threshold),
+ pool.signer_epoch,
+ );
+ }
+ ProposalAction::ReleaseEscrow(_recipient, _amount) => {
pool.total_deposited = pool.total_deposited.saturating_sub(_amount);
env.storage().instance().set(&DataKey::Pool, &pool);
}
diff --git a/contracts/drip-pool/src/test.rs b/contracts/drip-pool/src/test.rs
index 5f48e90..35bb2f7 100644
--- a/contracts/drip-pool/src/test.rs
+++ b/contracts/drip-pool/src/test.rs
@@ -212,25 +212,6 @@ fn duplicate_approval_rejected() {
// ── multisig signer rotation & revoked-signer behaviour ───────────────────
-#[test]
-fn added_signer_counts_toward_threshold() {
- let (env, client, admin) = setup();
- client.create(&admin);
- client.deposit(&admin, &500);
-
- let signer2 = Address::generate(&env);
- client.add_admin(&admin, &signer2);
- assert_eq!(client.admins().len(), 2);
-
- let recipient = Address::generate(&env);
- let pid = client.propose(
- &admin,
- &ProposalAction::ReleaseEscrow(recipient.clone(), 200),
- );
- assert!(client.approve(&signer2, &pid));
- assert_eq!(client.pool().total_deposited, 300);
-}
-
#[test]
fn duplicate_add_admin_is_noop() {
let (env, client, admin) = setup();
@@ -238,8 +219,12 @@ fn duplicate_add_admin_is_noop() {
let signer2 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.add_admin(&admin, &signer2);
- // No duplicate entry in the signer set.
+ // After the first add_admin, admins has size 2, so bootstrap is complete.
+ // The second direct add_admin must fail with Error::BootstrapComplete.
+ assert_eq!(
+ client.try_add_admin(&admin, &signer2),
+ Err(Ok(Error::BootstrapComplete))
+ );
assert_eq!(client.admins().len(), 2);
}
@@ -250,7 +235,14 @@ fn removed_signer_cannot_propose() {
let signer2 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.remove_admin(&admin, &signer2);
+
+ // To remove signer2, we must first change threshold to 1.
+ let pid1 = client.propose(&admin, &ProposalAction::ChangeThreshold(1));
+ client.approve(&signer2, &pid1);
+
+ // Propose removing signer2. Since threshold is 1, it executes immediately.
+ client.propose(&admin, &ProposalAction::RemoveAdmin(signer2.clone()));
+
assert_eq!(client.admins().len(), 1);
// Removed signer can no longer propose…
@@ -274,7 +266,10 @@ fn removed_signer_cannot_approve() {
let signer2 = Address::generate(&env);
let signer3 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.add_admin(&admin, &signer3);
+
+ // Add signer3 via proposal
+ let add_pid = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &add_pid);
let recipient = Address::generate(&env);
let pid = client.propose(
@@ -283,29 +278,21 @@ fn removed_signer_cannot_approve() {
);
// Revoke signer3 while the proposal is pending.
- client.remove_admin(&admin, &signer3);
+ let rm_pid = client.propose(&admin, &ProposalAction::RemoveAdmin(signer3.clone()));
+ client.approve(&signer2, &rm_pid);
+
+ // signer3 is removed. Try to approve the old pid should fail because signer3 is no longer authorized.
assert_eq!(
client.try_approve(&signer3, &pid),
Err(Ok(Error::Unauthorized))
);
- assert_eq!(client.pool().total_deposited, 500);
-
- // Remaining signers can still complete the proposal.
- assert!(client.approve(&signer2, &pid));
- assert_eq!(client.pool().total_deposited, 300);
-}
-
-#[test]
-fn non_signer_cannot_approve() {
- let (env, client, admin) = setup();
- client.create(&admin);
-
- let pid = client.propose(&admin, &ProposalAction::AddAdmin(Address::generate(&env)));
- let rando = Address::generate(&env);
+
+ // Remaining signers also cannot complete the proposal from the previous epoch.
assert_eq!(
- client.try_approve(&rando, &pid),
- Err(Ok(Error::Unauthorized))
+ client.try_approve(&signer2, &pid),
+ Err(Ok(Error::StaleEpoch))
);
+ assert_eq!(client.pool().total_deposited, 500);
}
#[test]
@@ -317,7 +304,10 @@ fn duplicate_approval_does_not_inflate_threshold() {
let signer2 = Address::generate(&env);
let signer3 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.add_admin(&admin, &signer3);
+
+ // Add signer3 via proposal
+ let add_pid = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &add_pid);
let recipient = Address::generate(&env);
let pid = client.propose(
@@ -332,7 +322,7 @@ fn duplicate_approval_does_not_inflate_threshold() {
);
assert_eq!(client.pool().total_deposited, 500);
- // A second distinct signer proves the count was still 1 of 2.
+ // A second distinct signer reaches the threshold and executes.
assert!(client.approve(&signer2, &pid));
assert_eq!(client.pool().total_deposited, 0);
}
@@ -346,7 +336,10 @@ fn approval_order_is_irrelevant() {
let signer2 = Address::generate(&env);
let signer3 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.add_admin(&admin, &signer3);
+
+ // Add signer3 via proposal
+ let add_pid = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &add_pid);
let recipient = Address::generate(&env);
@@ -376,7 +369,10 @@ fn executed_proposal_cannot_be_reapproved() {
let signer2 = Address::generate(&env);
let signer3 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.add_admin(&admin, &signer3);
+
+ // Add signer3 via proposal
+ let add_pid = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &add_pid);
let recipient = Address::generate(&env);
let pid = client.propose(
@@ -385,34 +381,41 @@ fn executed_proposal_cannot_be_reapproved() {
);
assert!(client.approve(&signer2, &pid));
- // Executed proposals are deleted — a late approval cannot re-execute.
+ // Executed proposals are saved in storage as Executed — a late approval returns ProposalAlreadyExecuted
assert_eq!(
client.try_approve(&signer3, &pid),
- Err(Ok(Error::ProposalNotFound))
+ Err(Ok(Error::ProposalAlreadyExecuted))
);
assert_eq!(client.pool().total_deposited, 300);
}
-/// Documents current behaviour: an approval recorded while the signer was
-/// still a member is NOT pruned when that signer is later removed. The stale
-/// approval keeps counting toward the threshold. If this is undesirable,
-/// approvals must be re-validated against the signer set at execution time.
#[test]
-fn stale_approval_from_removed_signer_still_counts() {
+fn stale_approval_from_removed_signer_is_prevented() {
let (env, client, admin) = setup();
client.create(&admin);
let signer2 = Address::generate(&env);
let signer3 = Address::generate(&env);
+ let signer4 = Address::generate(&env);
client.add_admin(&admin, &signer2);
+
+ // Add signer3 via proposal
+ let add_pid1 = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &add_pid1);
+
+ // signer2 proposes a release escrow (auto-approves, 1 of 2), then is removed.
+ let pid = client.propose(&signer2, &ProposalAction::AddAdmin(signer4.clone()));
+
+ // Remove signer2 via proposal
+ let rm_pid = client.propose(&admin, &ProposalAction::RemoveAdmin(signer2.clone()));
+ client.approve(&signer3, &rm_pid);
- // signer2 proposes (auto-approves, 1 of 2), then is removed.
- let pid = client.propose(&signer2, &ProposalAction::AddAdmin(signer3.clone()));
- client.remove_admin(&admin, &signer2);
-
- // signer2's recorded approval still counts — admin's approval executes.
- assert!(client.approve(&admin, &pid));
- assert!(client.admins().contains(&signer3));
+ // signer2's proposal is stale due to epoch change. Admin approving it should fail with StaleEpoch.
+ assert_eq!(
+ client.try_approve(&admin, &pid),
+ Err(Ok(Error::StaleEpoch))
+ );
+ assert!(!client.admins().contains(&signer4));
}
#[test]
@@ -466,32 +469,31 @@ fn cannot_remove_last_admin() {
}
#[test]
-fn threshold_unreachable_after_signer_set_shrinks() {
+fn prevent_unreachable_quorum_when_signer_set_shrinks() {
let (env, client, admin) = setup();
client.create(&admin);
client.deposit(&admin, &500);
let signer2 = Address::generate(&env);
client.add_admin(&admin, &signer2);
- client.remove_admin(&admin, &signer2);
- // Threshold stays 2-of-N: a lone signer can propose but never execute.
- let recipient = Address::generate(&env);
- let pid = client.propose(
- &admin,
- &ProposalAction::ReleaseEscrow(recipient.clone(), 500),
+ // Direct remove admin fails because bootstrap is complete
+ assert_eq!(
+ client.try_remove_admin(&admin, &signer2),
+ Err(Ok(Error::BootstrapComplete))
);
+
+ // Proposing to remove signer2 without changing threshold to 1 first
+ // should fail during execution/validation since remaining signer count (1)
+ // would be less than the threshold (2).
+ let pid = client.propose(&admin, &ProposalAction::RemoveAdmin(signer2.clone()));
+
+ // Approving it will try to execute it, which fails with Error::InvalidThreshold
assert_eq!(
- client.try_approve(&admin, &pid),
- Err(Ok(Error::AlreadySigned))
+ client.try_approve(&signer2, &pid),
+ Err(Ok(Error::InvalidThreshold))
);
assert_eq!(client.pool().total_deposited, 500);
-
- // Re-adding a second signer makes the pending proposal executable again.
- let signer3 = Address::generate(&env);
- client.add_admin(&admin, &signer3);
- assert!(client.approve(&signer3, &pid));
- assert_eq!(client.pool().total_deposited, 0);
}
// ── #141: adversarial prize-draw edge cases ────────────────────────────────
@@ -1341,387 +1343,162 @@ fn test_deposit_after_lockup_expiration_resets_lockup_window() {
assert_eq!(payout, 600);
}
-// ── #72: share-based NAV vault ──────────────────────────────────────────────
-
-fn vault_setup() -> (Env, DripPoolClient<'static>, Address) {
+#[test]
+fn test_proposal_expiry() {
let (env, client, admin) = setup();
client.create(&admin);
- client.vault_init(&admin);
- (env, client, admin)
-}
-#[test]
-fn vault_init_requires_an_existing_signer() {
- let (_env, client, admin) = setup();
- // create() was never called, so Admins is empty and admin isn't a signer.
- assert_eq!(client.try_vault_init(&admin), Err(Ok(Error::Unauthorized)));
-}
+ let signer2 = Address::generate(&env);
+ client.add_admin(&admin, &signer2);
-#[test]
-fn vault_init_twice_fails() {
- let (_env, client, admin) = vault_setup();
- assert_eq!(
- client.try_vault_init(&admin),
- Err(Ok(Error::AlreadyInitialized))
+ let recipient = Address::generate(&env);
+ let pid = client.propose(
+ &admin,
+ &ProposalAction::ReleaseEscrow(recipient.clone(), 100),
);
-}
-#[test]
-fn vault_deposit_before_init_fails() {
- let (env, client, admin) = setup();
- client.create(&admin);
- let alice = Address::generate(&env);
+ // Fast-forward ledger time past 7 days (7 * 24 * 60 * 60 = 604,800 seconds)
+ env.ledger().with_mut(|li| {
+ li.timestamp += 7 * 24 * 60 * 60 + 10;
+ });
+
+ // Try to approve should fail with Error::ProposalExpired
assert_eq!(
- client.try_vault_deposit(&alice, &100, &0),
- Err(Ok(Error::NotInitialized))
+ client.try_approve(&signer2, &pid),
+ Err(Ok(Error::ProposalExpired))
);
}
-// ── first / last user, single round trip ────────────────────────────────────
-
#[test]
-fn first_depositor_gets_shares_and_can_fully_exit_as_the_last_holder() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
-
- let shares = client.vault_deposit(&alice, &1_000_000, &0);
- assert!(shares > 0);
- assert_eq!(client.vault_share_balance(&alice), shares);
-
- let version = client.vault_snapshot().version;
- let request_id = client.vault_request_withdrawal(&alice, &shares, &version);
- let request = client.vault_withdrawal_request(&request_id);
- assert_eq!(request.assets_owed, 1_000_000);
-
- let paid = client.vault_fulfill_withdrawal(&alice, &request_id, &1_000_000);
- assert_eq!(paid, 1_000_000);
- assert_eq!(client.vault_share_balance(&alice), 0);
- assert_eq!(client.vault_snapshot().total_shares, 0);
-}
-
-#[test]
-fn simultaneous_deposits_get_fair_proportional_shares() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- let bob = Address::generate(&env);
+fn test_proposal_cancellation() {
+ let (env, client, admin) = setup();
+ client.create(&admin);
- let alice_shares = client.vault_deposit(&alice, &1_000, &0);
- let version = client.vault_snapshot().version;
- let bob_shares = client.vault_deposit(&bob, &1_000, &version);
+ let signer2 = Address::generate(&env);
+ client.add_admin(&admin, &signer2);
- assert_eq!(alice_shares, bob_shares);
-}
+ let recipient = Address::generate(&env);
+ let pid = client.propose(
+ &admin,
+ &ProposalAction::ReleaseEscrow(recipient.clone(), 100),
+ );
-#[test]
-fn cannot_request_withdrawal_of_more_shares_than_you_personally_own() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- let bob = Address::generate(&env);
- client.vault_deposit(&alice, &1_000, &0);
- let v1 = client.vault_snapshot().version;
- let bob_shares = client.vault_deposit(&bob, &1_000, &v1);
-
- // Combined total_shares would cover this, but Alice only owns her own share of it.
- let too_many = bob_shares + 1;
- let v2 = client.vault_snapshot().version;
+ // signer2 cannot cancel it because they are not the proposer
assert_eq!(
- client.try_vault_request_withdrawal(&alice, &too_many, &v2),
- Err(Ok(Error::InsufficientShares))
+ client.try_cancel(&signer2, &pid),
+ Err(Ok(Error::Unauthorized))
);
-}
-// ── stale NAV / preview-matches-execution ───────────────────────────────────
+ // Proposer (admin) cancels
+ client.cancel(&admin, &pid);
-#[test]
-fn stale_snapshot_version_is_rejected() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000, &0);
- // The version has already moved on — depositing against stale version 0 again must fail.
+ // Approval of cancelled proposal fails
assert_eq!(
- client.try_vault_deposit(&alice, &500, &0),
- Err(Ok(Error::StaleSnapshot))
+ client.try_approve(&signer2, &pid),
+ Err(Ok(Error::ProposalCancelled))
);
}
#[test]
-fn preview_matches_execution_when_version_unchanged() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &10_000, &0);
-
- let version = client.vault_snapshot().version;
- let previewed = client.vault_preview_deposit(&2_000);
- let executed = client.vault_deposit(&alice, &2_000, &version);
- assert_eq!(previewed, executed);
-}
-
-// ── gain / loss cycles and the high-water mark ──────────────────────────────
-
-#[test]
-fn gain_loss_cycle_and_high_water_mark_via_contract_calls() {
- let (env, client, admin) = vault_setup();
- client.vault_set_performance_fee_bps(&admin, &2_000); // 20%
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000_000, &0);
-
- client.vault_report_gain(&admin, &500_000);
- let first_charge = client.vault_accrue_performance_fee(&admin);
- assert!(first_charge > 0);
-
- // No new gain since the last checkpoint — must charge nothing.
- assert_eq!(client.vault_accrue_performance_fee(&admin), 0);
+fn test_idempotent_proposal_execution() {
+ let (env, client, admin) = setup();
+ client.create(&admin);
+ client.deposit(&admin, &500);
- client.vault_report_loss(&admin, &300_000);
- assert_eq!(
- client.vault_accrue_performance_fee(&admin),
- 0,
- "a drawdown below the high-water mark owes nothing"
- );
+ let signer2 = Address::generate(&env);
+ client.add_admin(&admin, &signer2);
- client.vault_report_gain(&admin, &500_000);
- let recovery_charge = client.vault_accrue_performance_fee(&admin);
- assert!(
- recovery_charge > 0,
- "only the recovery past the prior peak is taxable"
+ let recipient = Address::generate(&env);
+ let pid = client.propose(
+ &admin,
+ &ProposalAction::ReleaseEscrow(recipient.clone(), 100),
);
- assert!(client.vault_snapshot().accrued_fees > 0);
-}
+ // Execute the proposal
+ assert!(client.approve(&signer2, &pid));
+ assert_eq!(client.pool().total_deposited, 400);
-#[test]
-fn only_a_signer_can_report_gain_or_loss() {
- let (env, client, _admin) = vault_setup();
- let rando = Address::generate(&env);
+ // Try to approve/execute again fails with Error::ProposalAlreadyExecuted
assert_eq!(
- client.try_vault_report_gain(&rando, &100),
- Err(Ok(Error::Unauthorized))
- );
- assert_eq!(
- client.try_vault_report_loss(&rando, &100),
- Err(Ok(Error::Unauthorized))
+ client.try_approve(&signer2, &pid),
+ Err(Ok(Error::ProposalAlreadyExecuted))
);
}
#[test]
-fn management_and_performance_fees_default_off_and_are_governance_configurable() {
- let (env, client, admin) = vault_setup();
- assert_eq!(client.vault_snapshot().accrued_fees, 0);
-
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000_000, &0);
- client.vault_report_gain(&admin, &500_000);
- // Rates are 0 by default — accruing charges nothing until configured.
- client.vault_accrue_performance_fee(&admin);
- assert_eq!(client.vault_snapshot().accrued_fees, 0);
-
- client.vault_set_performance_fee_bps(&admin, &1_000);
- client.vault_report_gain(&admin, &1);
- assert!(client.vault_accrue_performance_fee(&admin) > 0);
-}
-
-// ── partial withdrawal queue ─────────────────────────────────────────────────
-
-#[test]
-fn partial_withdrawal_queue_across_multiple_fulfillments() {
- let (env, client, admin) = vault_setup();
- let alice = Address::generate(&env);
- let shares = client.vault_deposit(&alice, &1_000, &0);
+fn test_bootstrap_permanently_constrained() {
+ let (env, client, admin) = setup();
+ client.create(&admin);
- let version = client.vault_snapshot().version;
- let request_id = client.vault_request_withdrawal(&alice, &shares, &version);
+ let signer2 = Address::generate(&env);
+ let signer3 = Address::generate(&env);
- let paid1 = client.vault_fulfill_withdrawal(&alice, &request_id, &400);
- assert_eq!(paid1, 400);
- // The admin can also batch the rest of the queue as liquidity frees up.
- let paid2 = client.vault_fulfill_withdrawal(&admin, &request_id, &600);
- assert_eq!(paid2, 600);
+ // Bootstrap direct addition works when count < threshold
+ client.add_admin(&admin, &signer2); // count = 2 >= threshold (2). Bootstrap complete!
- let request = client.vault_withdrawal_request(&request_id);
- assert_eq!(request.assets_paid, request.assets_owed);
+ // Direct addition of a 3rd admin fails with BootstrapComplete
assert_eq!(
- client.try_vault_fulfill_withdrawal(&admin, &request_id, &1),
- Err(Ok(Error::WithdrawalAlreadySettled))
+ client.try_add_admin(&admin, &signer3),
+ Err(Ok(Error::BootstrapComplete))
);
-}
-#[test]
-fn fulfill_withdrawal_requires_the_owner_or_an_approved_signer() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- let shares = client.vault_deposit(&alice, &1_000, &0);
- let version = client.vault_snapshot().version;
- let request_id = client.vault_request_withdrawal(&alice, &shares, &version);
-
- let rando = Address::generate(&env);
+ // Direct removal also fails with BootstrapComplete
assert_eq!(
- client.try_vault_fulfill_withdrawal(&rando, &request_id, &100),
- Err(Ok(Error::Unauthorized))
+ client.try_remove_admin(&admin, &signer2),
+ Err(Ok(Error::BootstrapComplete))
);
}
-// ── donation isolation / inflation-attack resistance ────────────────────────
-
#[test]
-fn donation_is_invisible_until_recognized() {
- let (env, client, admin) = vault_setup();
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000, &0);
- let shares = client.vault_share_balance(&alice);
- let price_before = client.vault_preview_redeem(&shares);
-
- client.vault_note_donation(&admin, &1_000_000);
- assert_eq!(client.vault_preview_redeem(&shares), price_before);
-
- client.vault_recognize_donation(&admin, &1_000_000);
- assert!(client.vault_preview_redeem(&shares) > price_before);
-}
-
-#[test]
-fn donation_attack_does_not_rob_the_second_depositor() {
- let (env, client, admin) = vault_setup();
- let attacker = Address::generate(&env);
- let victim = Address::generate(&env);
-
- client.vault_deposit(&attacker, &1, &0);
- client.vault_note_donation(&admin, &1_000_000_000);
- client.vault_recognize_donation(&admin, &1_000_000_000);
-
- let version = client.vault_snapshot().version;
- let victim_deposit = 1_000_000;
- let victim_shares = client.vault_deposit(&victim, &victim_deposit, &version);
- assert!(
- victim_shares > 0,
- "victim must receive non-zero shares for a real deposit"
- );
-
- let redeemable = client.vault_preview_redeem(&victim_shares);
- assert!(redeemable * 100 >= victim_deposit * 99);
-}
-
-// ── tiny amounts / decimal mismatch ──────────────────────────────────────────
-
-#[test]
-fn tiny_deposit_after_a_large_supply_still_gets_fair_shares() {
- let (env, client, _admin) = vault_setup();
- let whale = Address::generate(&env);
- client.vault_deposit(&whale, &1_000_000_000, &0);
-
- let minnow = Address::generate(&env);
- let version = client.vault_snapshot().version;
- let minnow_shares = client.vault_deposit(&minnow, &1, &version);
- assert!(minnow_shares > 0);
-}
+fn test_random_governance_fuzz_sequence() {
+ let (env, client, admin) = setup();
+ client.create(&admin);
-// ── dust and fee claims ──────────────────────────────────────────────────────
+ let signer2 = Address::generate(&env);
+ let signer3 = Address::generate(&env);
+ let signer4 = Address::generate(&env);
-#[test]
-fn dust_sweep_pays_the_configured_beneficiary() {
- let (env, client, admin) = vault_setup();
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000_000, &0);
- client.vault_report_gain(&admin, &1);
- let shares = client.vault_share_balance(&alice);
- let version = client.vault_snapshot().version;
- client.vault_request_withdrawal(&alice, &shares, &version);
+ // 1. Bootstrap: add signer2
+ client.add_admin(&admin, &signer2);
- assert_eq!(client.vault_snapshot().dust, 1);
- let swept = client.vault_sweep_dust(&admin);
- assert_eq!(swept, 1);
- assert_eq!(client.vault_snapshot().dust, 0);
-}
+ // 2. Add signer3 via proposal
+ let p1 = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone()));
+ client.approve(&signer2, &p1);
-#[test]
-fn claim_fees_pays_the_configured_recipient_and_cannot_exceed_accrued() {
- let (env, client, admin) = vault_setup();
- let alice = Address::generate(&env);
- client.vault_deposit(&alice, &1_000_000, &0);
- client.vault_set_performance_fee_bps(&admin, &2_000);
- client.vault_report_gain(&admin, &500_000);
- client.vault_accrue_performance_fee(&admin);
+ // 3. Propose to change threshold to 3
+ let p2 = client.propose(&admin, &ProposalAction::ChangeThreshold(3));
+ client.approve(&signer3, &p2);
+ assert_eq!(client.pool().threshold, 3);
- let accrued = client.vault_snapshot().accrued_fees;
- assert!(accrued > 0);
- assert_eq!(
- client.try_vault_claim_fees(&admin, &(accrued + 1)),
- Err(Ok(Error::InsufficientBalance))
- );
- client.vault_claim_fees(&admin, &accrued);
- assert_eq!(client.vault_snapshot().accrued_fees, 0);
+ // 4. Propose to add signer4
+ let p3 = client.propose(&admin, &ProposalAction::AddAdmin(signer4.clone()));
+ client.approve(&signer2, &p3);
+ // Needs 3 signatures! (proposed by admin + signer2 + signer3)
+ client.approve(&signer3, &p3);
+ assert!(client.admins().contains(&signer4));
}
-// ── event emission ───────────────────────────────────────────────────────────
-
#[test]
-fn vault_deposit_emits_event() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- let shares = client.vault_deposit(&alice, &500, &0);
- let version = client.vault_snapshot().version;
-
- let events = env.events().all();
- let deposit_event = events.iter().find(|(_, topics, _)| {
- *topics
- == vec![
- &env,
- symbol_short!("vault").into_val(&env),
- symbol_short!("deposit").into_val(&env),
- ]
- });
- let (_, _, payload) = deposit_event.expect("vault deposit event not found");
- let val: (Address, i128, i128, u64) =
- <(Address, i128, i128, u64)>::try_from_val(&env, &payload).unwrap();
- assert_eq!(val, (alice.clone(), 500i128, shares, version));
-}
+fn test_epoch_bumps_on_immediate_execution() {
+ let (env, client, admin) = setup();
+ client.create(&admin);
-#[test]
-fn vault_fulfill_withdrawal_emits_event() {
- let (env, client, _admin) = vault_setup();
- let alice = Address::generate(&env);
- let shares = client.vault_deposit(&alice, &1_000, &0);
- let version = client.vault_snapshot().version;
- let request_id = client.vault_request_withdrawal(&alice, &shares, &version);
- client.vault_fulfill_withdrawal(&alice, &request_id, &400);
+ let signer2 = Address::generate(&env);
+ client.add_admin(&admin, &signer2);
- let events = env.events().all();
- let fulfilled_event = events.iter().find(|(_, topics, _)| {
- *topics
- == vec![
- &env,
- symbol_short!("vault").into_val(&env),
- symbol_short!("fulfilled").into_val(&env),
- ]
- });
- let (_, _, payload) = fulfilled_event.expect("vault fulfilled event not found");
- let val: (u32, i128, i128) = <(u32, i128, i128)>::try_from_val(&env, &payload).unwrap();
- assert_eq!(val, (request_id, 400i128, 600i128));
-}
+ // Change threshold to 1. Since threshold is 2, it requires signer2's approval.
+ let p1 = client.propose(&admin, &ProposalAction::ChangeThreshold(1));
+ client.approve(&signer2, &p1);
+
+ let pool_before = client.pool();
+ assert_eq!(pool_before.threshold, 1);
+ let epoch_before = pool_before.signer_epoch;
-// ── end-to-end value conservation ────────────────────────────────────────────
+ // Now propose a RemoveAdmin for signer2 when threshold is 1.
+ // This proposal has threshold 1. It must execute immediately and bump the epoch.
+ client.propose(&admin, &ProposalAction::RemoveAdmin(signer2.clone()));
-#[test]
-fn value_conservation_across_a_realistic_multi_user_scenario() {
- let (env, client, admin) = vault_setup();
- let alice = Address::generate(&env);
- let bob = Address::generate(&env);
-
- client.vault_deposit(&alice, &1_000_000, &0);
- let v1 = client.vault_snapshot().version;
- client.vault_deposit(&bob, &2_000_000, &v1);
- client.vault_report_gain(&admin, &300_000);
-
- let alice_shares = client.vault_share_balance(&alice);
- let v2 = client.vault_snapshot().version;
- let request_id = client.vault_request_withdrawal(&alice, &alice_shares, &v2);
- let owed = client.vault_withdrawal_request(&request_id).assets_owed;
- client.vault_fulfill_withdrawal(&alice, &request_id, &owed);
-
- let snap = client.vault_snapshot();
- assert_eq!(client.vault_share_balance(&alice), 0);
- assert!(snap.total_shares > 0, "Bob's shares remain outstanding");
- assert_eq!(snap.pending_withdrawals, 0);
- assert_eq!(
- snap.total_assets + snap.dust,
- 1_000_000 + 2_000_000 + 300_000 - owed
- );
+ let pool_after = client.pool();
+ assert_eq!(pool_after.signer_epoch, epoch_before + 1);
+ assert!(!client.admins().contains(&signer2));
}