Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions contracts/predictify-hybrid/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ pub struct GovernanceProposal {
pub for_votes: u128,
pub against_votes: u128,
pub executed: bool,
/// Per-proposal random salt generated at creation time using `Env::prng`.
///
/// The salt **must** be included in the canonical vote message signed by
/// each voter. This binds each signature to a specific proposal instance
/// and prevents vote-replay: an off-chain signature authorising a vote on
/// proposal P₁ cannot be replayed against a re-submitted proposal P₂
/// that happens to share the same `id`, `title`, and `description`.
///
/// Entropy source: `env.prng().gen::<BytesN<32>>()` at creation — never
/// derived from the block timestamp or any other predictable value.
pub salt: BytesN<32>,
}

// Key namespaces used in storage
Expand Down Expand Up @@ -220,6 +231,10 @@ impl GovernanceContract {
for_votes: 0,
against_votes: 0,
executed: false,
// Generate a cryptographically random salt using Soroban's PRNG.
// Using env.prng() here — never the block timestamp — ensures that
// entropy is not predictable by the proposer or any observer.
salt: env.prng().gen::<BytesN<32>>(),
};

env.storage()
Expand All @@ -244,11 +259,24 @@ impl GovernanceContract {

/// Vote on a proposal. `support = true` means FOR, false means AGAINST.
/// Each address counts as 1 vote plus 1 for each address that has delegated to it.
///
/// # Vote-replay prevention
///
/// The caller **must** supply the proposal's `salt` value. The contract
/// compares it against the salt stored in the proposal and rejects the vote
/// with `GovernanceError::SaltMismatch` if they differ.
///
/// Off-chain signers should include the salt in the canonical message they
/// sign, e.g. `sha256(proposal_id || salt || voter || support)`. This
/// ensures that a valid signature for one proposal instance cannot be
/// replayed against a different instance that happens to share the same
/// payload.
pub fn vote(
env: Env,
voter: Address,
proposal_id: Symbol,
support: bool,
salt: BytesN<32>,
) -> Result<(), GovernanceError> {
voter.require_auth();

Expand All @@ -262,6 +290,12 @@ impl GovernanceContract {
}
let mut p = p_opt.unwrap();

// Verify that the salt supplied by the voter matches the stored salt.
// This prevents vote-replay across re-submitted proposals.
if salt != p.salt {
return Err(GovernanceError::SaltMismatch);
}

let now = env.ledger().timestamp();
if now < p.start_time {
return Err(GovernanceError::VotingNotStarted);
Expand Down Expand Up @@ -705,6 +739,23 @@ impl GovernanceContract {
Ok(p_opt.unwrap())
}

/// Return the salt for a proposal.
///
/// Off-chain clients use this to build the canonical vote message:
/// `sha256(proposal_id || salt || voter || support)`.
///
/// The salt is generated by `Env::prng` at proposal creation and never
/// derived from predictable data, so it cannot be forged or pre-computed
/// by an attacker before the proposal is submitted.
pub fn get_proposal_salt(env: Env, id: Symbol) -> Result<BytesN<32>, GovernanceError> {
let p: GovernanceProposal = env
.storage()
.persistent()
.get(&StorageKey::Proposal(id.clone()))
.ok_or(GovernanceError::ProposalNotFound)?;
Ok(p.salt)
}

/// Admin-only: set voting period (seconds)
pub fn set_voting_period(
env: Env,
Expand Down
9 changes: 8 additions & 1 deletion contracts/predictify-hybrid/src/governance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,15 @@ impl GovernanceFixture {
}

fn vote(&self, voter: Address, proposal_id: Symbol, support: bool) -> Result<(), GovernanceError> {
// Fetch the proposal's salt from storage and include it in the vote call.
// This mirrors how a real off-chain client would obtain the salt before
// submitting a signed vote transaction.
let salt = self.env.as_contract(&self.contract_id, || {
GovernanceContract::get_proposal_salt(self.env.clone(), proposal_id.clone())
.expect("proposal salt must be readable before voting")
});
self.env.as_contract(&self.contract_id, || {
GovernanceContract::vote(self.env.clone(), voter, proposal_id, support)
GovernanceContract::vote(self.env.clone(), voter, proposal_id, support, salt)
})
}

Expand Down
1 change: 1 addition & 0 deletions contracts/predictify-hybrid/src/market_id_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -771,3 +771,4 @@ mod tests {
assert_eq!(all_ids.len(), 25);
}
}
} // close impl MarketIdGenerator
Loading
Loading