From 6ce072e1f7d39d92f73bb7313cda4125cfd68d4b Mon Sep 17 00:00:00 2001 From: oscar24357 Date: Fri, 24 Apr 2026 18:17:43 +0100 Subject: [PATCH 1/4] Refactor: Modularize contracts and reorganize project structure to src/ layout --- Cargo.toml | 13 +- contracts/forge-governor/src/lib.rs | 3087 --------------- contracts/forge-multisig/src/lib.rs | 2675 ------------- contracts/forge-oracle/src/lib.rs | 1597 -------- contracts/forge-stream/src/lib.rs | 3480 ----------------- contracts/forge-vesting-factory/src/lib.rs | 578 --- contracts/forge-vesting/src/lib.rs | 2270 ----------- .../contracts}/forge-governor/Cargo.toml | 0 .../contracts}/forge-governor/README.md | 0 src/contracts/forge-governor/src/contract.rs | 257 ++ src/contracts/forge-governor/src/errors.rs | 21 + src/contracts/forge-governor/src/lib.rs | 17 + src/contracts/forge-governor/src/storage.rs | 11 + src/contracts/forge-governor/src/test.rs | 32 + src/contracts/forge-governor/src/types.rs | 58 + .../contracts}/forge-multisig/Cargo.toml | 0 .../contracts}/forge-multisig/README.md | 0 src/contracts/forge-multisig/src/contract.rs | 286 ++ src/contracts/forge-multisig/src/errors.rs | 19 + src/contracts/forge-multisig/src/lib.rs | 17 + src/contracts/forge-multisig/src/storage.rs | 18 + src/contracts/forge-multisig/src/test.rs | 37 + src/contracts/forge-multisig/src/types.rs | 27 + .../contracts}/forge-oracle/Cargo.toml | 0 .../contracts}/forge-oracle/README.md | 0 src/contracts/forge-oracle/src/contract.rs | 262 ++ src/contracts/forge-oracle/src/errors.rs | 14 + src/contracts/forge-oracle/src/lib.rs | 18 + src/contracts/forge-oracle/src/storage.rs | 18 + src/contracts/forge-oracle/src/test.rs | 52 + src/contracts/forge-oracle/src/types.rs | 21 + .../contracts}/forge-stream/Cargo.toml | 0 .../contracts}/forge-stream/README.md | 0 src/contracts/forge-stream/src/contract.rs | 159 + src/contracts/forge-stream/src/errors.rs | 16 + src/contracts/forge-stream/src/lib.rs | 17 + src/contracts/forge-stream/src/storage.rs | 15 + src/contracts/forge-stream/src/test.rs | 31 + src/contracts/forge-stream/src/types.rs | 51 + .../forge-vesting-factory/Cargo.toml | 0 .../forge-vesting-factory/src/contract.rs | 223 ++ .../forge-vesting-factory/src/errors.rs | 12 + .../forge-vesting-factory/src/lib.rs | 17 + .../forge-vesting-factory/src/storage.rs | 11 + .../forge-vesting-factory/src/test.rs | 78 + .../forge-vesting-factory/src/types.rs | 28 + .../contracts}/forge-vesting/Cargo.toml | 0 .../contracts}/forge-vesting/README.md | 0 src/contracts/forge-vesting/src/contract.rs | 419 ++ src/contracts/forge-vesting/src/errors.rs | 19 + src/contracts/forge-vesting/src/lib.rs | 17 + src/contracts/forge-vesting/src/storage.rs | 8 + src/contracts/forge-vesting/src/test.rs | 274 ++ src/contracts/forge-vesting/src/types.rs | 56 + {scripts => src/scripts}/README.md | 0 {scripts => src/scripts}/pre-commit | 0 {scripts => src/scripts}/update-wasm-sizes.sh | 2 +- 57 files changed, 2643 insertions(+), 13695 deletions(-) delete mode 100644 contracts/forge-governor/src/lib.rs delete mode 100644 contracts/forge-multisig/src/lib.rs delete mode 100644 contracts/forge-oracle/src/lib.rs delete mode 100644 contracts/forge-stream/src/lib.rs delete mode 100644 contracts/forge-vesting-factory/src/lib.rs delete mode 100644 contracts/forge-vesting/src/lib.rs rename {contracts => src/contracts}/forge-governor/Cargo.toml (100%) rename {contracts => src/contracts}/forge-governor/README.md (100%) create mode 100644 src/contracts/forge-governor/src/contract.rs create mode 100644 src/contracts/forge-governor/src/errors.rs create mode 100644 src/contracts/forge-governor/src/lib.rs create mode 100644 src/contracts/forge-governor/src/storage.rs create mode 100644 src/contracts/forge-governor/src/test.rs create mode 100644 src/contracts/forge-governor/src/types.rs rename {contracts => src/contracts}/forge-multisig/Cargo.toml (100%) rename {contracts => src/contracts}/forge-multisig/README.md (100%) create mode 100644 src/contracts/forge-multisig/src/contract.rs create mode 100644 src/contracts/forge-multisig/src/errors.rs create mode 100644 src/contracts/forge-multisig/src/lib.rs create mode 100644 src/contracts/forge-multisig/src/storage.rs create mode 100644 src/contracts/forge-multisig/src/test.rs create mode 100644 src/contracts/forge-multisig/src/types.rs rename {contracts => src/contracts}/forge-oracle/Cargo.toml (100%) rename {contracts => src/contracts}/forge-oracle/README.md (100%) create mode 100644 src/contracts/forge-oracle/src/contract.rs create mode 100644 src/contracts/forge-oracle/src/errors.rs create mode 100644 src/contracts/forge-oracle/src/lib.rs create mode 100644 src/contracts/forge-oracle/src/storage.rs create mode 100644 src/contracts/forge-oracle/src/test.rs create mode 100644 src/contracts/forge-oracle/src/types.rs rename {contracts => src/contracts}/forge-stream/Cargo.toml (100%) rename {contracts => src/contracts}/forge-stream/README.md (100%) create mode 100644 src/contracts/forge-stream/src/contract.rs create mode 100644 src/contracts/forge-stream/src/errors.rs create mode 100644 src/contracts/forge-stream/src/lib.rs create mode 100644 src/contracts/forge-stream/src/storage.rs create mode 100644 src/contracts/forge-stream/src/test.rs create mode 100644 src/contracts/forge-stream/src/types.rs rename {contracts => src/contracts}/forge-vesting-factory/Cargo.toml (100%) create mode 100644 src/contracts/forge-vesting-factory/src/contract.rs create mode 100644 src/contracts/forge-vesting-factory/src/errors.rs create mode 100644 src/contracts/forge-vesting-factory/src/lib.rs create mode 100644 src/contracts/forge-vesting-factory/src/storage.rs create mode 100644 src/contracts/forge-vesting-factory/src/test.rs create mode 100644 src/contracts/forge-vesting-factory/src/types.rs rename {contracts => src/contracts}/forge-vesting/Cargo.toml (100%) rename {contracts => src/contracts}/forge-vesting/README.md (100%) create mode 100644 src/contracts/forge-vesting/src/contract.rs create mode 100644 src/contracts/forge-vesting/src/errors.rs create mode 100644 src/contracts/forge-vesting/src/lib.rs create mode 100644 src/contracts/forge-vesting/src/storage.rs create mode 100644 src/contracts/forge-vesting/src/test.rs create mode 100644 src/contracts/forge-vesting/src/types.rs rename {scripts => src/scripts}/README.md (100%) rename {scripts => src/scripts}/pre-commit (100%) mode change 100755 => 100644 rename {scripts => src/scripts}/update-wasm-sizes.sh (98%) diff --git a/Cargo.toml b/Cargo.toml index 32df7d7..0335502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,13 +2,12 @@ resolver = "2" members = [ - "contracts/forge-vesting", - "contracts/forge-vesting-factory", - "contracts/forge-stream", - "contracts/forge-multisig", - "contracts/forge-governor", - "contracts/forge-oracle", - "benches", + "src/contracts/forge-vesting", + "src/contracts/forge-vesting-factory", + "src/contracts/forge-stream", + "src/contracts/forge-multisig", + "src/contracts/forge-governor", + "src/contracts/forge-oracle", ] [workspace.dependencies] diff --git a/contracts/forge-governor/src/lib.rs b/contracts/forge-governor/src/lib.rs deleted file mode 100644 index 386aa04..0000000 --- a/contracts/forge-governor/src/lib.rs +++ /dev/null @@ -1,3087 +0,0 @@ -#![no_std] - -//! # forge-governor -//! -//! On-chain governance with token-weighted voting for Stellar/Soroban. -//! -//! ## Features -//! - Token-weighted proposal voting (1 token = 1 vote) -//! - Configurable voting period and quorum -//! - Timelock between approval and execution -//! - Anyone can propose; execution is permissionless once passed - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec, -}; - -// ── TTL constants ───────────────────────────────────────────────────────────── -// -// Persistent storage entries on Stellar expire unless their TTL is extended. -// All TTLs are expressed in ledgers (1 ledger ≈ 5 seconds). -// -// INSTANCE_TTL_THRESHOLD / INSTANCE_TTL_EXTEND -// Applied to the contract instance on every mutating call. -// 17 280 ledgers ≈ 1 day threshold; 34 560 ledgers ≈ 2 days extend. -// -// PROPOSAL_TTL_EXTEND -// Applied to DataKey::Proposal entries. A proposal must survive its full -// lifecycle: voting_period + timelock_delay + a generous buffer. -// Using a fixed upper-bound of 60 days (1 036 800 ledgers) covers any -// realistic governance configuration without per-proposal arithmetic. -// -// VOTE_TTL_EXTEND -// Applied to DataKey::Vote entries. A vote record must outlive the proposal -// it belongs to so that has_voted() remains reliable throughout the entire -// lifecycle. Same 60-day ceiling as proposals. -const INSTANCE_TTL_THRESHOLD: u32 = 17_280; -const INSTANCE_TTL_EXTEND: u32 = 34_560; -const PROPOSAL_TTL_EXTEND: u32 = 1_036_800; // ~60 days -const VOTE_TTL_EXTEND: u32 = 1_036_800; // ~60 days - -// ── Storage keys ────────────────────────────────────────────────────────────── - -#[contracttype] -pub enum DataKey { - Config, - Proposal(u64), - Vote(u64, Address), - NextProposalId, - ActiveProposals, - ActiveProposalIndex(u64), -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -/// Governor configuration. -#[contracttype] -#[derive(Clone)] -pub struct GovernorConfig { - /// Address authorized to initialize the contract (must call initialize()). - pub admin: Address, - /// Token used for voting weight. - /// - /// Must be a valid Soroban token contract address that implements the token interface. - /// This address is used in `vote()` to verify voter balances (1 token = 1 vote). - /// Passing an invalid or non-token address will not be caught at initialization time — - /// it will only fail when `vote()` attempts to call `balance()` on the address. - /// Callers are responsible for ensuring this is a legitimate token contract. - pub vote_token: Address, - /// Seconds a proposal is open for voting. - pub voting_period: u64, - /// Minimum votes (in token units) for a proposal to pass. - pub quorum: i128, - /// Seconds between approval and execution. - pub timelock_delay: u64, -} - -/// Proposal state. -#[contracttype] -#[derive(Clone, PartialEq, Debug)] -pub enum ProposalState { - Active, - Passed, - Failed, - Executed, - Cancelled, -} - -/// Direction of a vote cast on a proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub enum VoteDirection { - For, - Against, - Abstain, -} - -/// Vote tally for a proposal. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct VoteTally { - /// Total votes cast in favor. - pub yes_votes: i128, - /// Total votes cast against. - pub no_votes: i128, - /// Total abstain votes. - pub abstain_votes: i128, - /// Sum of yes, no, and abstain votes. - pub total_votes: i128, -} - -/// A governance proposal. -#[contracttype] -#[derive(Clone)] -pub struct Proposal { - /// Address that created the proposal. - pub proposer: Address, - /// Human-readable title. - pub title: String, - /// Human-readable description. - pub description: String, - /// Ledger timestamp when voting opens. - pub vote_start: u64, - /// Ledger timestamp when voting closes. - pub vote_end: u64, - /// Total votes in favor. - pub votes_for: i128, - /// Total votes against. - pub votes_against: i128, - /// Total abstain votes. - pub abstentions: i128, - /// Timestamp when proposal passed (for timelock). - pub passed_at: Option, - /// Current state. - pub state: ProposalState, -} - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, PartialEq, Debug)] -pub enum GovernorError { - AlreadyInitialized = 1, - NotInitialized = 2, - ProposalNotFound = 3, - VotingClosed = 4, - VotingStillOpen = 5, - AlreadyVoted = 6, - QuorumNotReached = 7, - ProposalNotPassed = 8, - TimelockNotElapsed = 9, - AlreadyExecuted = 10, - AlreadyCancelled = 11, - InvalidConfig = 12, - InvalidWeight = 13, - Unauthorized = 14, - AlreadyFinalized = 15, -} - -// ── Contract ────────────────────────────────────────────────────────────────── - -#[contract] -pub struct GovernorContract; - -#[contractimpl] -impl GovernorContract { - /// Initialize the governor with its configuration. - /// - /// Stores the [`GovernorConfig`] on-chain. Must be called exactly once - /// immediately after deployment. Requires authorization from the admin address - /// specified in the config to prevent front-running attacks. - /// - /// # Parameters - /// - `config` — A [`GovernorConfig`] specifying: - /// - `admin`: Address authorized to initialize the contract (must call this function). - /// - `vote_token`: Address of the Soroban token used for voting weight. - /// - `voting_period`: Seconds a proposal remains open for voting. Must be > 0. - /// - `quorum`: Minimum total votes (for + against) required for a proposal to pass. Must be > 0. - /// - `timelock_delay`: Seconds between a proposal passing and being executable. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`GovernorError::AlreadyInitialized`] — Contract has already been initialized. - /// - [`GovernorError::InvalidConfig`] — `quorum` or `voting_period` is zero. - /// - /// # Security - /// The admin address must authorize this call via `require_auth()`. This prevents - /// an attacker from front-running the deployer's initialization with a malicious config - /// (e.g., quorum = 1, timelock = 0). - /// - /// # Example - /// ```text - /// let config = GovernorConfig { - /// admin: deployer_address, - /// vote_token: token_address, - /// voting_period: 3600, // 1 hour - /// quorum: 1_000_000, - /// timelock_delay: 86400, // 24 hours - /// }; - /// client.initialize(&config); - /// ``` - pub fn initialize(env: Env, config: GovernorConfig) -> Result<(), GovernorError> { - config.admin.require_auth(); - - if env.storage().instance().has(&DataKey::Config) { - return Err(GovernorError::AlreadyInitialized); - } - if config.quorum == 0 || config.voting_period == 0 { - return Err(GovernorError::InvalidConfig); - } - // Sanity-check: vote_token must not be the same address as admin. - // A misconfigured vote_token (e.g. admin address used by mistake) would pass - // initialization silently but fail at vote() time. This catches the most obvious - // misconfiguration early with a descriptive error. - if config.vote_token == config.admin { - return Err(GovernorError::InvalidConfig); - } - env.storage().instance().set(&DataKey::Config, &config); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - Ok(()) - } - - /// Create a new governance proposal. - /// - /// Opens a new proposal for voting immediately, with `vote_end` set to - /// `current_timestamp + voting_period`. The proposer's approval is not - /// automatically recorded — owners must call [`vote`](Self::vote) separately. - /// Requires authorization from `proposer`. - /// - /// # Parameters - /// - `proposer` — Address submitting the proposal. Can be any account. - /// - `title` — Short human-readable title for the proposal. - /// - `description` — Full description of what the proposal intends to do. - /// - /// # Returns - /// `Ok(proposal_id)` — the unique ID assigned to the new proposal. - /// - /// # Errors - /// - [`GovernorError::NotInitialized`] — `initialize` has not been called. - /// - /// # Example - /// ```text - /// let id = client.propose(&proposer, &String::from_str(&env, "Upgrade v2"), &String::from_str(&env, "...")); - /// ``` - pub fn propose( - env: Env, - proposer: Address, - title: String, - description: String, - ) -> Result { - proposer.require_auth(); - - let config: GovernorConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(GovernorError::NotInitialized)?; - - let now = env.ledger().timestamp(); - let proposal_id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextProposalId) - .unwrap_or(0u64); - - let proposal = Proposal { - proposer: proposer.clone(), - title, - description, - vote_start: now, - vote_end: now + config.voting_period, - votes_for: 0, - votes_against: 0, - abstentions: 0, - passed_at: None, - state: ProposalState::Active, - }; - - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage().persistent().extend_ttl( - &DataKey::Proposal(proposal_id), - PROPOSAL_TTL_EXTEND, - PROPOSAL_TTL_EXTEND, - ); - env.storage() - .persistent() - .set(&DataKey::NextProposalId, &(proposal_id + 1)); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - // Track active proposal ID for O(1) get_pending_proposals - let mut active: Vec = env - .storage() - .instance() - .get(&DataKey::ActiveProposals) - .unwrap_or_else(|| Vec::new(&env)); - let index = active.len(); - active.push_back(proposal_id); - env.storage() - .instance() - .set(&DataKey::ActiveProposals, &active); - env.storage() - .instance() - .set(&DataKey::ActiveProposalIndex(proposal_id), &index); - - env.events().publish( - (Symbol::new(&env, "proposal_created"),), - (proposal_id, &proposer, proposal.vote_end), - ); - - Ok(proposal_id) - } - - /// Cast a vote on an active proposal. - /// - /// Adds `weight` to `votes_for`, `votes_against`, or `abstentions` depending on - /// `direction`. Each address may only vote once per proposal. - /// - /// The `weight` parameter is validated against the voter's actual on-chain - /// token balance at the time of the call. If `weight` exceeds the voter's - /// balance, the call is rejected with [`GovernorError::InvalidWeight`]. - /// This enforces the "1 token = 1 vote" model and prevents governance - /// manipulation by callers supplying an inflated weight. - /// - /// Requires authorization from `voter`. - /// - /// # Parameters - /// - `voter` — Address casting the vote. - /// - `proposal_id` — ID of the proposal to vote on. - /// - `direction` — [`VoteDirection::For`], [`VoteDirection::Against`], or [`VoteDirection::Abstain`]. - /// - `weight` — Voting power to apply, must be <= the voter's actual token balance. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`GovernorError::NotInitialized`] — `initialize` has not been called. - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`GovernorError::AlreadyVoted`] — `voter` has already voted on this proposal. - /// - [`GovernorError::VotingClosed`] — The proposal is no longer in `Active` state - /// or the voting period has expired. - /// - [`GovernorError::InvalidWeight`] — `weight` exceeds the voter's token balance. - /// - /// # Example - /// ```text - /// // Vote in favor with 500 tokens of weight - /// client.vote(&voter, &proposal_id, &VoteDirection::For, &500); - /// // Abstain with 200 tokens of weight (counts toward quorum) - /// client.vote(&voter, &proposal_id, &VoteDirection::Abstain, &200); - /// // Vote in favor with weight equal to the voter's token balance - /// let balance = token_client.balance(&voter); - /// client.vote(&voter, &proposal_id, &VoteDirection::For, &balance); - /// ``` - pub fn vote( - env: Env, - voter: Address, - proposal_id: u64, - direction: VoteDirection, - weight: i128, - ) -> Result<(), GovernorError> { - voter.require_auth(); - - let config: GovernorConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(GovernorError::NotInitialized)?; - - // Enforce 1-token-1-vote: reject if claimed weight exceeds actual balance. - let actual_balance = token::Client::new(&env, &config.vote_token).balance(&voter); - if weight > actual_balance { - return Err(GovernorError::InvalidWeight); - } - - let vote_key = DataKey::Vote(proposal_id, voter.clone()); - if env.storage().persistent().has(&vote_key) { - return Err(GovernorError::AlreadyVoted); - } - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - - if proposal.state != ProposalState::Active { - return Err(GovernorError::VotingClosed); - } - - let now = env.ledger().timestamp(); - if now > proposal.vote_end { - return Err(GovernorError::VotingClosed); - } - - if weight <= 0 { - return Err(GovernorError::InvalidWeight); - } - - match direction { - VoteDirection::For => proposal.votes_for += weight, - VoteDirection::Against => proposal.votes_against += weight, - VoteDirection::Abstain => proposal.abstentions += weight, - } - - env.storage().persistent().set(&vote_key, &weight); - env.storage() - .persistent() - .extend_ttl(&vote_key, VOTE_TTL_EXTEND, VOTE_TTL_EXTEND); - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage().persistent().extend_ttl( - &DataKey::Proposal(proposal_id), - PROPOSAL_TTL_EXTEND, - PROPOSAL_TTL_EXTEND, - ); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - env.events().publish( - (Symbol::new(&env, "vote_cast"),), - (proposal_id, &voter, direction, weight), - ); - - Ok(()) - } - - /// Finalize a proposal after its voting period ends. - /// - /// Evaluates the vote totals against the configured quorum and sets the - /// proposal state to [`ProposalState::Passed`] or [`ProposalState::Failed`]. - /// If passed, records `vote_end` in `passed_at` to start the timelock - /// countdown from when voting ended, not when `finalize` was called. - /// Can be called by anyone. - /// - /// # Parameters - /// - `proposal_id` — ID of the proposal to finalize. - /// - /// # Returns - /// `Ok(`[`ProposalState`]`)` — the resulting state (`Passed` or `Failed`). - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`GovernorError::VotingStillOpen`] — The voting period has not yet ended. - /// - [`GovernorError::AlreadyFinalized`] — The proposal has already been finalized - /// (state is not `Active`). - /// - /// # Example - /// ```text - /// // After voting_period has elapsed: - /// let state = client.finalize(&proposal_id); - /// assert_eq!(state, ProposalState::Passed); - /// ``` - pub fn finalize(env: Env, proposal_id: u64) -> Result { - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - - match proposal.state { - ProposalState::Active => { - // Continue with finalization logic - } - ProposalState::Cancelled => { - return Err(GovernorError::AlreadyCancelled); - } - ProposalState::Passed | ProposalState::Failed | ProposalState::Executed => { - return Err(GovernorError::AlreadyFinalized); - } - } - - let now = env.ledger().timestamp(); - if now <= proposal.vote_end { - return Err(GovernorError::VotingStillOpen); - } - - let config: GovernorConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(GovernorError::NotInitialized)?; - let total_votes = proposal.votes_for + proposal.votes_against + proposal.abstentions; - - if total_votes >= config.quorum && proposal.votes_for > proposal.votes_against { - proposal.state = ProposalState::Passed; - proposal.passed_at = Some(proposal.vote_end); - } else { - proposal.state = ProposalState::Failed; - } - - let state = proposal.state.clone(); - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage().persistent().extend_ttl( - &DataKey::Proposal(proposal_id), - PROPOSAL_TTL_EXTEND, - PROPOSAL_TTL_EXTEND, - ); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - Self::remove_active_proposal(&env, proposal_id); - - env.events().publish( - (Symbol::new(&env, "proposal_finalized"),), - (proposal_id, proposal.votes_for, proposal.votes_against), - ); - - Ok(state) - } - - /// Mark a passed proposal as executed after the timelock delay. - /// - /// Enforces the timelock by checking that `current_timestamp ≥ passed_at + timelock_delay`. - /// In this contract, execution marks the proposal as done on-chain; any - /// off-chain or cross-contract action triggered by the proposal should be - /// coordinated by the caller. Requires authorization from `executor`. - /// - /// # Parameters - /// - `executor` — Address triggering execution. Can be any account. - /// - `proposal_id` — ID of the proposal to execute. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`GovernorError::AlreadyExecuted`] — The proposal has already been executed. - /// - [`GovernorError::ProposalNotPassed`] — The proposal did not reach `Passed` state or is cancelled. - /// - [`GovernorError::TimelockNotElapsed`] — The timelock delay has not fully passed. - /// - /// # Example - /// ```text - /// // After timelock_delay seconds have elapsed since the proposal passed: - /// client.execute(&executor, &proposal_id); - /// ``` - pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), GovernorError> { - executor.require_auth(); - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - - if proposal.state == ProposalState::Executed { - return Err(GovernorError::AlreadyExecuted); - } - if proposal.state == ProposalState::Cancelled { - return Err(GovernorError::ProposalNotPassed); - } - if proposal.state != ProposalState::Passed { - return Err(GovernorError::ProposalNotPassed); - } - - let passed_at = proposal.passed_at.ok_or(GovernorError::ProposalNotPassed)?; - let config: GovernorConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(GovernorError::NotInitialized)?; - - if env.ledger().timestamp() < passed_at + config.timelock_delay { - return Err(GovernorError::TimelockNotElapsed); - } - - proposal.state = ProposalState::Executed; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage().persistent().extend_ttl( - &DataKey::Proposal(proposal_id), - PROPOSAL_TTL_EXTEND, - PROPOSAL_TTL_EXTEND, - ); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - // Remove from active proposals list (in case finalize was skipped) - Self::remove_active_proposal(&env, proposal_id); - - env.events().publish( - (Symbol::new(&env, "proposal_executed"),), - (proposal_id, &executor), - ); - - Ok(()) - } - - /// Cancel an active proposal before voting ends. - /// - /// Only the original proposer can cancel a proposal, and only while voting is still open - /// (state is `Active` and current timestamp <= `vote_end`). Cancelling a proposal removes - /// it from the active proposals list and prevents further voting or execution. - /// Requires authorization from `proposer`. - /// - /// # Parameters - /// - `proposer` — The original proposer address. Must match the proposal's proposer. - /// - `proposal_id` — ID of the proposal to cancel. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`GovernorError::Unauthorized`] — `proposer` is not the original proposal creator. - /// - [`GovernorError::VotingClosed`] — Voting period has ended or proposal is not in `Active` state. - /// - [`GovernorError::AlreadyCancelled`] — The proposal has already been cancelled. - /// - /// # Example - /// ```text - /// // Cancel a proposal before voting ends - /// client.cancel_proposal(&proposer, &proposal_id); - /// ``` - pub fn cancel_proposal( - env: Env, - proposer: Address, - proposal_id: u64, - ) -> Result<(), GovernorError> { - proposer.require_auth(); - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - - // Only the original proposer can cancel - if proposal.proposer != proposer { - return Err(GovernorError::Unauthorized); - } - - // Can only cancel if still in Active state - if proposal.state != ProposalState::Active { - if proposal.state == ProposalState::Cancelled { - return Err(GovernorError::AlreadyCancelled); - } - return Err(GovernorError::VotingClosed); - } - - // Can only cancel while voting is still open - let now = env.ledger().timestamp(); - if now > proposal.vote_end { - return Err(GovernorError::VotingClosed); - } - - proposal.state = ProposalState::Cancelled; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - Self::remove_active_proposal(&env, proposal_id); - - env.events().publish( - (Symbol::new(&env, "proposal_cancelled"),), - (proposal_id, &proposer), - ); - - Ok(()) - } - - /// Return a proposal by its ID. - /// - /// Read-only; does not modify state. - /// - /// # Parameters - /// - `proposal_id` — The ID returned by [`propose`](Self::propose). - /// - /// # Returns - /// `Ok(`[`Proposal`]`)` with the full proposal details. - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - /// # Example - /// ```text - /// let proposal = client.get_proposal(&id)?; - /// println!("votes_for: {}", proposal.votes_for); - /// ``` - pub fn get_proposal(env: Env, proposal_id: u64) -> Result { - env.storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound) - } - - /// Return the governor configuration set at initialization. - /// - /// Read-only; returns `None` if `initialize` has not been called yet. - /// - /// # Returns - /// `Some(`[`GovernorConfig`]`)` with the stored configuration, or `None`. - /// - /// # Example - /// ```text - /// let config = client.get_config().unwrap(); - /// println!("quorum: {}", config.quorum); - /// ``` - pub fn get_config(env: Env) -> Option { - env.storage().instance().get(&DataKey::Config) - } - - /// Return the total number of proposals that have been created. - /// - /// Read-only; does not modify state. Useful for UIs to paginate and list - /// all proposals without tracking events off-chain. - /// - /// # Returns - /// `u64` — the total count of proposals created since contract initialization. - /// - /// # Example - /// ```text - /// let count = client.get_proposal_count(); - /// for id in 0..count { - /// let proposal = client.get_proposal(&id); - /// // process proposal... - /// } - /// ``` - pub fn get_proposal_count(env: Env) -> u64 { - env.storage() - .persistent() - .get(&DataKey::NextProposalId) - .unwrap_or(0u64) - } - - /// Check whether an address has already voted on a proposal. - /// - /// Read-only; does not modify state. Useful for UIs and integrations to - /// prevent submitting a vote that would fail with [`GovernorError::AlreadyVoted`]. - /// - /// # Parameters - /// - `proposal_id` — ID of the proposal to check. - /// - `voter` — Address to look up. - /// - /// # Returns - /// `true` if `voter` has cast a vote on `proposal_id`, `false` otherwise. - /// Returns `false` for non-existent proposal IDs (no error is thrown). - /// - /// # Example - /// ```text - /// if !client.has_voted(&proposal_id, &voter) { - /// client.vote(&voter, &proposal_id, &VoteDirection::For, &100); - /// } - /// ``` - pub fn has_voted(env: Env, proposal_id: u64, voter: Address) -> bool { - env.storage() - .persistent() - .has(&DataKey::Vote(proposal_id, voter)) - } - - /// Return the weight a voter cast on a specific proposal. - /// - /// Looks up the persistent vote entry written by [`vote`](Self::vote). - /// Returns `Some(weight)` if the voter has cast a vote, or `None` if they - /// have not voted (or if the proposal does not exist). - /// - /// # Parameters - /// - `proposal_id` — ID of the proposal to query. - /// - `voter` — Address of the voter to look up. - /// - /// # Returns - /// `Some(i128)` — the weight the voter cast, or `None` if no vote was found. - /// - /// # Example - /// ```text - /// let weight = client.get_vote_weight(&proposal_id, &voter_address); - /// assert_eq!(weight, Some(500)); - /// ``` - pub fn get_vote_weight(env: Env, proposal_id: u64, voter: Address) -> Option { - env.storage() - .persistent() - .get(&DataKey::Vote(proposal_id, voter)) - } - - /// Return the current state of a proposal. - /// - /// Read-only; does not modify state. Lighter alternative to - /// [`get_proposal`](Self::get_proposal) when only the state is needed. - /// - /// # Parameters - /// - `proposal_id` — ID of the proposal to query. - /// - /// # Returns - /// `Ok(`[`ProposalState`]`)` — the proposal's current state. - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - /// # Example - /// ```text - /// let state = client.get_proposal_state(&proposal_id)?; - /// assert_eq!(state, ProposalState::Active); - /// ``` - pub fn get_proposal_state(env: Env, proposal_id: u64) -> Result { - let proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - Ok(proposal.state) - } - - /// Return the current vote tally for a proposal. - /// - /// Read-only; does not modify state. Returns a breakdown of yes, no, and - /// total votes cast so far, regardless of the proposal's current state. - /// - /// # Parameters - /// - `proposal_id` — ID of the proposal to query. - /// - /// # Returns - /// `Ok(`[`VoteTally`]`)` with the current vote counts. - /// - /// # Errors - /// - [`GovernorError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - /// # Example - /// ```text - /// let tally = client.get_vote_tally(&proposal_id)?; - /// println!("yes: {}, no: {}, total: {}", tally.yes_votes, tally.no_votes, tally.total_votes); - /// ``` - pub fn get_vote_tally(env: Env, proposal_id: u64) -> Result { - let proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(GovernorError::ProposalNotFound)?; - - Ok(VoteTally { - yes_votes: proposal.votes_for, - no_votes: proposal.votes_against, - abstain_votes: proposal.abstentions, - total_votes: proposal.votes_for + proposal.votes_against + proposal.abstentions, - }) - } - - /// Return the IDs of all proposals that are currently in the active voting period. - /// - /// A proposal is considered pending if its [`ProposalState`] is [`ProposalState::Active`] - /// **and** the current ledger timestamp has not yet passed its `vote_end`. Proposals that - /// have been finalized, executed, or cancelled — or whose voting window has simply expired - /// without being finalized — are excluded. - /// - /// Read-only; does not modify state. Intended for governance UIs that need to enumerate - /// active proposals without off-chain indexing. - /// - /// # Returns - /// A `Vec` of proposal IDs open for voting, in implementation-defined order. - /// Returns an empty vector when no proposals are currently pending. - /// - /// # Example - /// ```text - /// let pending = client.get_pending_proposals(); - /// for id in pending.iter() { - /// let p = client.get_proposal(&id)?; - /// println!("Active: {} (ends {})", p.title, p.vote_end); - /// } - /// ``` - pub fn get_pending_proposals(env: Env) -> Vec { - let active: Vec = env - .storage() - .instance() - .get(&DataKey::ActiveProposals) - .unwrap_or_else(|| Vec::new(&env)); - - let now = env.ledger().timestamp(); - let mut pending = Vec::new(&env); - - for id in active.iter() { - if let Some(proposal) = env - .storage() - .persistent() - .get::(&DataKey::Proposal(id)) - { - // Exclude cancelled proposals and expired voting windows - if proposal.state != ProposalState::Cancelled && now <= proposal.vote_end { - pending.push_back(id); - } - } - } - - pending - } - - fn remove_active_proposal(env: &Env, proposal_id: u64) { - let index_key = DataKey::ActiveProposalIndex(proposal_id); - let Some(index) = env.storage().instance().get::(&index_key) else { - return; - }; - - let mut active: Vec = env - .storage() - .instance() - .get(&DataKey::ActiveProposals) - .unwrap_or_else(|| Vec::new(env)); - if active.is_empty() { - env.storage().instance().remove(&index_key); - return; - } - - let last_index = active.len().saturating_sub(1); - if index != last_index { - let last_id = active.get(last_index).unwrap(); - active.set(index, last_id); - env.storage() - .instance() - .set(&DataKey::ActiveProposalIndex(last_id), &index); - } - - active.remove(last_index); - env.storage() - .instance() - .set(&DataKey::ActiveProposals, &active); - env.storage().instance().remove(&index_key); - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Env, String, - }; - - fn setup(env: &Env) -> GovernorContractClient<'_> { - let contract_id = env.register_contract(None, GovernorContract); - let client = GovernorContractClient::new(env, &contract_id); - let admin = Address::generate(env); - let token = env - .register_stellar_asset_contract_v2(Address::generate(env)) - .address(); - let config = GovernorConfig { - admin: admin.clone(), - vote_token: token, - voting_period: 3600, - quorum: 100, - timelock_delay: 86400, - }; - client.initialize(&config); - client - } - - /// Setup helper that also returns the token address so tests can mint - /// balances to voters before calling vote(). - fn setup_with_token(env: &Env) -> (GovernorContractClient<'_>, Address) { - let contract_id = env.register_contract(None, GovernorContract); - let client = GovernorContractClient::new(env, &contract_id); - let admin = Address::generate(env); - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(env)) - .address(); - let config = GovernorConfig { - admin: admin.clone(), - vote_token: token_id.clone(), - voting_period: 3600, - quorum: 100, - timelock_delay: 86400, - }; - client.initialize(&config); - (client, token_id) - } - - fn mint(env: &Env, token_id: &Address, to: &Address, amount: i128) { - soroban_sdk::token::StellarAssetClient::new(env, token_id).mint(to, &amount); - } - - #[test] - fn test_initialize_requires_auth() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, GovernorContract); - let client = GovernorContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let token = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - - // Attacker tries to initialize with their own config - let malicious_config = GovernorConfig { - admin: attacker.clone(), - vote_token: token.clone(), - voting_period: 1, // Very short voting period - quorum: 1, // Very low quorum - timelock_delay: 0, // No timelock - }; - - // This should fail because attacker is not the admin in the config - let result = client.try_initialize(&malicious_config); - assert!( - result.is_err(), - "initialize should fail when called by non-admin" - ); - - // Verify contract is still not initialized - assert!( - client.get_config().is_none(), - "config should not be set after failed initialize" - ); - - // Now admin initializes with proper config - let proper_config = GovernorConfig { - admin: admin.clone(), - vote_token: token.clone(), - voting_period: 3600, - quorum: 100, - timelock_delay: 86400, - }; - - let result = client.try_initialize(&proper_config); - assert!( - result.is_ok(), - "initialize should succeed when called by admin" - ); - - // Verify config is set - let config = client.get_config().unwrap(); - assert_eq!(config.admin, admin); - assert_eq!(config.voting_period, 3600); - assert_eq!(config.quorum, 100); - assert_eq!(config.timelock_delay, 86400); - } - - #[test] - fn test_vote_and_pass() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "A test"), - ); - - client.vote(&voter, &pid, &VoteDirection::For, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - } - - #[test] - fn test_vote_with_excessive_weight_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "A test"), - ); - - // Try to vote with 200 weight when only 100 balance - let result = client.try_vote(&voter, &pid, &VoteDirection::For, &200); - assert_eq!(result, Err(Ok(GovernorError::InvalidWeight))); - } - - #[test] - fn test_proposal_count() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - // Initially, no proposals exist - assert_eq!(client.get_proposal_count(), 0); - - // Create first proposal - let proposer = Address::generate(&env); - let pid1 = client.propose( - &proposer, - &String::from_str(&env, "First Proposal"), - &String::from_str(&env, "First description"), - ); - assert_eq!(pid1, 0); - assert_eq!(client.get_proposal_count(), 1); - - // Create second proposal - let pid2 = client.propose( - &proposer, - &String::from_str(&env, "Second Proposal"), - &String::from_str(&env, "Second description"), - ); - assert_eq!(pid2, 1); - assert_eq!(client.get_proposal_count(), 2); - - // Create third proposal - let pid3 = client.propose( - &proposer, - &String::from_str(&env, "Third Proposal"), - &String::from_str(&env, "Third description"), - ); - assert_eq!(pid3, 2); - assert_eq!(client.get_proposal_count(), 3); - } - - #[test] - fn test_proposal_ids_are_sequential_with_no_gaps() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - - // Create 5 proposals and capture returned IDs - let titles = [ - "Proposal 0", - "Proposal 1", - "Proposal 2", - "Proposal 3", - "Proposal 4", - ]; - let descs = ["Desc 0", "Desc 1", "Desc 2", "Desc 3", "Desc 4"]; - let mut ids = [0u64; 5]; - for i in 0..5 { - ids[i] = client.propose( - &proposer, - &String::from_str(&env, titles[i]), - &String::from_str(&env, descs[i]), - ); - } - - // IDs must be exactly 0..4 in order — no gaps, starting from 0 - assert_eq!(ids, [0u64, 1, 2, 3, 4]); - - // Count must reflect all 5 proposals - assert_eq!(client.get_proposal_count(), 5); - - // Every proposal must be retrievable and have the correct proposer - for id in 0u64..5 { - let proposal = client.get_proposal(&id); - assert_eq!(proposal.proposer, proposer); - } - - // One past the last ID must return ProposalNotFound - let result = client.try_get_proposal(&5u64); - assert!(matches!(result, Err(Ok(GovernorError::ProposalNotFound)))); - } - - #[test] - fn test_quorum_not_reached_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Low vote"), - &String::from_str(&env, "desc"), - ); - - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 50); - client.vote(&voter, &pid, &VoteDirection::For, &50); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - } - - #[test] - fn test_double_vote_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - client.vote(&voter, &pid, &VoteDirection::For, &100); - let result = client.try_vote(&voter, &pid, &VoteDirection::For, &100); - assert_eq!(result, Err(Ok(GovernorError::AlreadyVoted))); - } - - #[test] - fn test_vote_with_zero_weight_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - let result = client.try_vote(&voter, &pid, &VoteDirection::For, &0); - assert_eq!(result, Err(Ok(GovernorError::InvalidWeight))); - } - - #[test] - fn test_vote_with_negative_weight_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - let result = client.try_vote(&voter, &pid, &VoteDirection::Against, &-1000); - assert_eq!(result, Err(Ok(GovernorError::InvalidWeight))); - } - - #[test] - fn test_get_proposal_existing() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let title = String::from_str(&env, "My Proposal"); - let description = String::from_str(&env, "Details here"); - let pid = client.propose(&proposer, &title, &description); - - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.proposer, proposer); - assert_eq!(proposal.title, title); - assert_eq!(proposal.description, description); - assert_eq!(proposal.state, ProposalState::Active); - assert_eq!(proposal.vote_start, 1000); - assert_eq!(proposal.votes_for, 0); - assert_eq!(proposal.votes_against, 0); - } - - #[test] - fn test_get_proposal_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let client = setup(&env); - - let result = client.try_get_proposal(&999); - assert!(matches!(result, Err(Ok(GovernorError::ProposalNotFound)))); - } - - #[test] - fn test_finalize_fails_when_quorum_not_reached() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // Vote with weight below quorum (quorum = 100) - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 50); - client.vote(&voter, &pid, &VoteDirection::For, &50); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - } - - #[test] - fn test_finalize_exact_quorum_passes_and_below_quorum_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Exact quorum"), - &String::from_str(&env, "desc"), - ); - - // Cast votes that exactly meet quorum (60 + 40 = 100) - let voter1 = Address::generate(&env); - let voter2 = Address::generate(&env); - mint(&env, &token_id, &voter1, 60); - mint(&env, &token_id, &voter2, 40); - client.vote(&voter1, &pid, &VoteDirection::For, &60); - client.vote(&voter2, &pid, &VoteDirection::For, &40); - - // Finalize after the voting period and verify it passes. - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - - let proposer2 = Address::generate(&env); - let pid2 = client.propose( - &proposer2, - &String::from_str(&env, "Below quorum"), - &String::from_str(&env, "desc"), - ); - - // Cast votes just below quorum (60 + 39 = 99) - let voter3 = Address::generate(&env); - let voter4 = Address::generate(&env); - mint(&env, &token_id, &voter3, 60); - mint(&env, &token_id, &voter4, 39); - client.vote(&voter3, &pid2, &VoteDirection::For, &60); - client.vote(&voter4, &pid2, &VoteDirection::For, &39); - - // Finalize after the voting period and verify it fails. - // pid2 was created at t=5000, so vote_end = 5000 + 3600 = 8600 - env.ledger().with_mut(|l| l.timestamp = 9000); - let state2 = client.finalize(&pid2); - assert_eq!(state2, ProposalState::Failed); - } - - #[test] - fn test_finalize_passes_when_quorum_met_and_majority_yes() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - client.vote(&voter, &pid, &VoteDirection::For, &100); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - } - - #[test] - fn test_execute_failed_proposal_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); // fails: no votes - - let executor = Address::generate(&env); - let result = client.try_execute(&executor, &pid); - assert!(matches!(result, Err(Ok(GovernorError::ProposalNotPassed)))); - } - - #[test] - fn test_vote_after_voting_period_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // Advance past voting_period (3600) - env.ledger().with_mut(|l| l.timestamp = 5000); - - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - let result = client.try_vote(&voter, &pid, &VoteDirection::For, &100); - assert!(matches!(result, Err(Ok(GovernorError::VotingClosed)))); - } - - /// Voting on a finalized (Passed) proposal must revert with `VotingClosed`. - #[test] - fn test_vote_after_finalized_passed_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let late_voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - mint(&env, &token_id, &late_voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); // meets quorum - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - - let result = client.try_vote(&late_voter, &pid, &VoteDirection::For, &100); - assert!(matches!(result, Err(Ok(GovernorError::VotingClosed)))); - } - - /// Voting on a finalized (Failed) proposal must revert with `VotingClosed`. - #[test] - fn test_vote_after_finalized_failed_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let late_voter = Address::generate(&env); - mint(&env, &token_id, &late_voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - // No votes — quorum not met → Failed - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - - let result = client.try_vote(&late_voter, &pid, &VoteDirection::For, &100); - assert!(matches!(result, Err(Ok(GovernorError::VotingClosed)))); - } - - #[test] - fn test_execute_before_timelock_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - - let result = client.try_execute(&executor, &pid); - assert_eq!(result, Err(Ok(GovernorError::TimelockNotElapsed))); - - // Timelock starts from vote_end (3600), not finalize time (5000) - env.ledger().with_mut(|l| l.timestamp = 3600 + 86400); - client.execute(&executor, &pid); - - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Executed); - } - - /// finalize() must store the exact ledger timestamp in passed_at, and - /// execute() must use that value for the timelock boundary check. - /// - /// Timeline (timelock_delay = 86400): - /// t=0 propose - /// t=0 vote (200 weight, quorum=100) - /// t=5000 finalize → passed_at must equal 5000 - /// t=5000+86399 execute → TimelockNotElapsed (one second short) - /// t=5000+86400 execute → Ok(()) - /// - /// Also verifies that a Failed proposal stores passed_at = None. - #[test] - fn test_finalize_sets_passed_at_and_execute_respects_timelock() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - - // ── Passed proposal ────────────────────────────────────────────────── - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid, &true, &200); - - // Finalize at a known timestamp - let finalize_time: u64 = 5000; - env.ledger().with_mut(|l| l.timestamp = finalize_time); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - - // passed_at must be exactly the finalize timestamp - let proposal = client.get_proposal(&pid); - assert_eq!( - proposal.passed_at, - Some(finalize_time), - "passed_at should equal the ledger timestamp at finalize time" - ); - - // One second before timelock expires → must revert - env.ledger() - .with_mut(|l| l.timestamp = finalize_time + 86400 - 1); - let result = client.try_execute(&executor, &pid); - assert_eq!( - result, - Err(Ok(GovernorError::TimelockNotElapsed)), - "execute should revert at passed_at + timelock_delay - 1" - ); - - // Exactly at timelock boundary → must succeed - env.ledger() - .with_mut(|l| l.timestamp = finalize_time + 86400); - client.execute(&executor, &pid); - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Executed); - - // ── Failed proposal ─────────────────────────────────────────────────── - let pid2 = client.propose( - &proposer, - &String::from_str(&env, "Fail"), - &String::from_str(&env, "No votes"), - ); - // No votes — quorum not met → Failed - env.ledger() - .with_mut(|l| l.timestamp = finalize_time + 86400 + 5000); - let state2 = client.finalize(&pid2); - assert_eq!(state2, ProposalState::Failed); - - let failed_proposal = client.get_proposal(&pid2); - assert_eq!( - failed_proposal.passed_at, None, - "passed_at must be None for a Failed proposal" - ); - } - - #[test] - fn test_late_finalize_timelock_starts_from_vote_end() { - // finalize() called 1000s after vote_end; execution window must be - // vote_end + timelock_delay, not finalize_time + timelock_delay. - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - - // Finalize 1000 seconds after vote_end (3600) - let finalize_time = 3600 + 1000; - env.ledger().with_mut(|l| l.timestamp = finalize_time); - client.finalize(&pid); - - // Still locked at finalize_time + timelock_delay - env.ledger() - .with_mut(|l| l.timestamp = finalize_time + 86400); - let result = client.try_execute(&executor, &pid); - assert_eq!(result, Err(Ok(GovernorError::TimelockNotElapsed))); - - // Executable at vote_end + timelock_delay - env.ledger().with_mut(|l| l.timestamp = 3600 + 86400); - client.execute(&executor, &pid); - assert_eq!(client.get_proposal(&pid).state, ProposalState::Executed); - } - - #[test] - fn test_execute_twice_reverts_with_already_executed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - - // Execute the proposal - env.ledger().with_mut(|l| l.timestamp = 5000 + 86400); - client.execute(&executor, &pid); - - // Verify proposal state is Executed - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Executed); - - // Try to execute again - let result = client.try_execute(&executor, &pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyExecuted))); - - // Verify proposal state is still Executed - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Executed); - } - - #[test] - fn test_has_voted_returns_true_for_voter() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // Voter has not voted yet - assert!(!client.has_voted(&pid, &voter)); - - // Cast vote - client.vote(&voter, &pid, &VoteDirection::For, &100); - - // Now voter has voted - assert!(client.has_voted(&pid, &voter)); - } - - #[test] - fn test_has_voted_returns_false_for_non_voter() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let non_voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // non_voter has not participated at all - assert!(!client.has_voted(&pid, &non_voter)); - - // voter votes - client.vote(&voter, &pid, &VoteDirection::For, &100); - - // non_voter still has not voted - assert!(!client.has_voted(&pid, &non_voter)); - - // voter has voted - assert!(client.has_voted(&pid, &voter)); - } - - #[test] - fn test_has_voted_returns_false_for_non_existent_proposal() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let voter = Address::generate(&env); - - // Call has_voted with a proposal_id that was never created - let result = client.has_voted(&999, &voter); - - // Should return false without throwing an error - assert!(!result); - } - - /// Verify that a Vote entry persists after being written so that - /// has_voted() reliably returns true and double-vote protection holds. - /// This guards against the TTL-expiry regression described in the issue: - /// an expired Vote entry would cause has() to return false, allowing a - /// second vote on the same proposal. - #[test] - fn test_has_voted_persists_after_vote() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "TTL Test"), - &String::from_str(&env, "Vote entry must persist"), - ); - - assert!(!client.has_voted(&pid, &voter), "should not have voted yet"); - - client.vote(&voter, &pid, &VoteDirection::For, &100); - - // Entry must be present immediately after voting - assert!( - client.has_voted(&pid, &voter), - "has_voted must return true after vote" - ); - - // A second vote attempt must fail — confirming the entry is still live - let result = client.try_vote(&voter, &pid, &VoteDirection::Against, &100); - assert_eq!( - result, - Err(Ok(GovernorError::AlreadyVoted)), - "double-vote must be rejected while Vote entry persists" - ); - } - - #[test] - fn test_get_pending_proposals_empty_when_none_exist() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 0); - } - - #[test] - fn test_get_pending_proposals_returns_active_ids() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid0 = client.propose( - &proposer, - &String::from_str(&env, "P0"), - &String::from_str(&env, "D"), - ); - let pid1 = client.propose( - &proposer, - &String::from_str(&env, "P1"), - &String::from_str(&env, "D"), - ); - - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 2); - assert!(pending.contains(pid0)); - assert!(pending.contains(pid1)); - } - - #[test] - fn test_get_pending_proposals_excludes_finalized() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - // pid0: will be finalized (passed) - let pid0 = client.propose( - &proposer, - &String::from_str(&env, "P0"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid0, &VoteDirection::For, &200); - - // pid1: will remain active but its voting window also expires at t=5000 - let _pid1 = client.propose( - &proposer, - &String::from_str(&env, "P1"), - &String::from_str(&env, "D"), - ); - - // Advance past voting period and finalize pid0 - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid0); - - // Both proposals' voting windows have expired — none should be pending - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 0); - } - - #[test] - fn test_get_pending_proposals_excludes_expired_but_not_finalized() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // Advance past voting_period without finalizing - env.ledger().with_mut(|l| l.timestamp = 5000); - - // State is still Active but vote_end has passed — should not be returned - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 0); - // Confirm the proposal still exists - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Active); - } - - #[test] - fn test_get_pending_proposals_mixed_states() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - // pid0: finalized (passed) at t=5000 - let pid0 = client.propose( - &proposer, - &String::from_str(&env, "P0"), - &String::from_str(&env, "D"), - ); - client.vote(&voter, &pid0, &VoteDirection::For, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid0); - - // pid1 and pid2 proposed after the advance — still in voting window - let pid1 = client.propose( - &proposer, - &String::from_str(&env, "P1"), - &String::from_str(&env, "D"), - ); - let pid2 = client.propose( - &proposer, - &String::from_str(&env, "P2"), - &String::from_str(&env, "D"), - ); - - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 2); - assert!(pending.contains(pid1)); - assert!(pending.contains(pid2)); - } - - // ── Tie-breaking behaviour ───────────────────────────────────────────────── - // - // The contract requires votes_for > votes_against (strict majority) to pass. - // When yes votes equal no votes the proposal resolves to Failed — there is - // no mechanism that breaks a tie in favour of the proposer or any other party. - // This is deterministic and must be explicitly tested. - - /// Equal yes and no votes that together meet quorum must resolve to Failed. - /// Tie-breaking rule: votes_for must be strictly greater than votes_against. - #[test] - fn test_tied_vote_resolves_to_failed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); // quorum = 100, voting_period = 3600 - - let proposer = Address::generate(&env); - let yes_voter = Address::generate(&env); - let no_voter = Address::generate(&env); - mint(&env, &token_id, &yes_voter, 100); - mint(&env, &token_id, &no_voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Tied Proposal"), - &String::from_str(&env, "Equal yes and no votes"), - ); - - // Cast equal weight on both sides — total = 200, meets quorum of 100 - client.vote(&yes_voter, &pid, &VoteDirection::For, &100); - client.vote(&no_voter, &pid, &VoteDirection::Against, &100); - - // Advance past the voting period - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - - // Tie must resolve to Failed — strict majority (votes_for > votes_against) required - assert_eq!( - state, - ProposalState::Failed, - "a tied vote must resolve to Failed, not Passed" - ); - - // Confirm the stored state matches - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.state, ProposalState::Failed); - assert_eq!(proposal.votes_for, 100); - assert_eq!(proposal.votes_against, 100); - assert!( - proposal.passed_at.is_none(), - "passed_at must not be set on a failed proposal" - ); - } - - /// One extra no vote tips a near-tie to Failed. - #[test] - fn test_near_tie_no_majority_resolves_to_failed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let yes_voter = Address::generate(&env); - let no_voter = Address::generate(&env); - mint(&env, &token_id, &yes_voter, 100); - mint(&env, &token_id, &no_voter, 101); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Near-tie Proposal"), - &String::from_str(&env, "No votes exceed yes by 1"), - ); - - // 100 yes, 101 no — quorum met, but no majority - client.vote(&yes_voter, &pid, &VoteDirection::For, &100); - client.vote(&no_voter, &pid, &VoteDirection::Against, &101); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - } - - /// One extra yes vote tips a near-tie to Passed. - #[test] - fn test_near_tie_yes_majority_resolves_to_passed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let yes_voter = Address::generate(&env); - let no_voter = Address::generate(&env); - mint(&env, &token_id, &yes_voter, 101); - mint(&env, &token_id, &no_voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Near-tie Proposal"), - &String::from_str(&env, "Yes votes exceed no by 1"), - ); - - // 101 yes, 100 no — quorum met, strict majority achieved - client.vote(&yes_voter, &pid, &VoteDirection::For, &101); - client.vote(&no_voter, &pid, &VoteDirection::Against, &100); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - } - - /// get_pending_proposals() reads only the ActiveProposals list, not every proposal. - /// Creates 25 proposals: finalizes the first 5, lets the next 5 expire (not finalized), - /// and keeps the last 15 active. Verifies only the 15 active ones are returned. - #[test] - fn test_get_pending_proposals_uses_active_list_not_full_scan() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); // voting_period = 3600, quorum = 100 - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - // Create 25 proposals at t=0 - let mut ids: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); - for i in 0u32..25 { - let title = String::from_str(&env, "P"); - let desc = String::from_str(&env, "D"); - let _ = i; // suppress unused warning - let pid = client.propose(&proposer, &title, &desc); - ids.push_back(pid); - } - - // Vote on the first 5 before voting period ends (vote_end = 3600) - for i in 0..5u32 { - let pid = ids.get(i).unwrap(); - client.vote(&voter, &pid, &VoteDirection::For, &200); - } - - // Finalize the first 5 after voting period ends - env.ledger().with_mut(|l| l.timestamp = 4000); - for i in 0..5u32 { - let pid = ids.get(i).unwrap(); - client.finalize(&pid); - } - - // Advance past voting period for proposals 5-9 (expired, not finalized) - // They remain Active in state but vote_end has passed — excluded from pending - - // Advance to t=5000 so proposals 0-9 are all past their vote_end (t=3600) - // Proposals 10-24 were also created at t=0 so they also expire at t=3600... - // Re-create the last 15 at t=5000 so they have vote_end = 5000+3600 = 8600 - env.ledger().with_mut(|l| l.timestamp = 5000); - let mut active_ids: soroban_sdk::Vec = soroban_sdk::Vec::new(&env); - for _ in 0..15u32 { - let pid = client.propose( - &proposer, - &String::from_str(&env, "Active"), - &String::from_str(&env, "D"), - ); - active_ids.push_back(pid); - } - - // At t=5000 the first 25 proposals have expired (vote_end=3600), the new 15 are active - let pending = client.get_pending_proposals(); - assert_eq!( - pending.len(), - 15, - "expected exactly 15 active proposals, got {}", - pending.len() - ); - - // Verify the returned IDs match the 15 newly created ones - for i in 0..15u32 { - assert!(pending.contains(active_ids.get(i).unwrap())); - } - } - - #[test] - fn test_get_proposal_state_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let client = setup(&env); - - let result = client.try_get_proposal_state(&99); - assert_eq!(result, Err(Ok(GovernorError::ProposalNotFound))); - } - - #[test] - fn test_get_proposal_state_active() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "State Test"), - &String::from_str(&env, "desc"), - ); - - assert_eq!(client.get_proposal_state(&pid), ProposalState::Active); - } - - #[test] - fn test_get_proposal_state_passed_and_executed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - let pid = client.propose( - &proposer, - &String::from_str(&env, "State Test"), - &String::from_str(&env, "desc"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Passed); - - env.ledger().with_mut(|l| l.timestamp = 5000 + 86400 + 1); - let executor = Address::generate(&env); - client.execute(&executor, &pid); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Executed); - } - - #[test] - fn test_get_proposal_state_failed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "State Test"), - &String::from_str(&env, "desc"), - ); - // No votes — quorum not reached - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Failed); - } - - #[test] - fn test_get_vote_tally_no_votes() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Tally Test"), - &String::from_str(&env, "desc"), - ); - - let tally = client.get_vote_tally(&pid); - assert_eq!(tally.yes_votes, 0); - assert_eq!(tally.no_votes, 0); - assert_eq!(tally.total_votes, 0); - } - - #[test] - fn test_get_vote_tally_mixed_votes() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Tally Test"), - &String::from_str(&env, "desc"), - ); - - let voter_a = Address::generate(&env); - let voter_b = Address::generate(&env); - mint(&env, &token_id, &voter_a, 300); - mint(&env, &token_id, &voter_b, 100); - client.vote(&voter_a, &pid, &VoteDirection::For, &300); - client.vote(&voter_b, &pid, &VoteDirection::Against, &100); - - let tally = client.get_vote_tally(&pid); - assert_eq!(tally.yes_votes, 300); - assert_eq!(tally.no_votes, 100); - assert_eq!(tally.total_votes, 400); - } - - #[test] - fn test_get_vote_tally_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let client = setup(&env); - - let result = client.try_get_vote_tally(&99); - assert_eq!(result, Err(Ok(GovernorError::ProposalNotFound))); - } - - /// Test that propose() emits a proposal_created event with correct payload - #[test] - fn test_propose_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - client.propose( - &proposer, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - ); - - let events = env.events().all(); - assert!(!events.is_empty(), "Expected at least one event"); - } - - #[test] - fn test_vote_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - - let events = env.events().all(); - assert!( - events.len() >= 2, - "Expected at least two events (propose + vote)" - ); - } - - #[test] - fn test_finalize_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - - let events = env.events().all(); - assert!( - events.len() >= 3, - "Expected at least three events (propose + vote + finalize)" - ); - } - - #[test] - fn test_execute_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test"), - &String::from_str(&env, "Desc"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - - env.ledger().with_mut(|l| l.timestamp = 5000 + 86400 + 1); - client.execute(&executor, &pid); - - let events = env.events().all(); - assert!( - events.len() >= 4, - "Expected at least four events (propose + vote + finalize + execute)" - ); - } - - /// Test successful cancel: proposer can cancel an active proposal before voting ends - #[test] - fn test_cancel_proposal_successful() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Verify proposal is active - assert_eq!(client.get_proposal_state(&pid), ProposalState::Active); - - // Cancel the proposal - client.cancel_proposal(&proposer, &pid); - - // Verify proposal is now cancelled - assert_eq!(client.get_proposal_state(&pid), ProposalState::Cancelled); - } - - /// Test cancel by non-proposer: only the original proposer can cancel - #[test] - fn test_cancel_proposal_by_non_proposer_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let other = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Try to cancel as a different address - let result = client.try_cancel_proposal(&other, &pid); - assert_eq!(result, Err(Ok(GovernorError::Unauthorized))); - - // Verify proposal is still active - assert_eq!(client.get_proposal_state(&pid), ProposalState::Active); - } - - /// Test cancel after voting ends: cannot cancel after voting period has ended - #[test] - fn test_cancel_proposal_after_voting_ends_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Advance time past voting period (voting_period = 3600) - env.ledger().with_mut(|l| l.timestamp = 1000 + 3600 + 1); - - // Try to cancel after voting ends - let result = client.try_cancel_proposal(&proposer, &pid); - assert_eq!(result, Err(Ok(GovernorError::VotingClosed))); - - // Verify proposal is still active (not cancelled) - assert_eq!(client.get_proposal_state(&pid), ProposalState::Active); - } - - /// Test execute after cancel: cannot execute a cancelled proposal - #[test] - fn test_execute_after_cancel_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let executor = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Cancel the proposal - client.cancel_proposal(&proposer, &pid); - - // Try to execute the cancelled proposal - let result = client.try_execute(&executor, &pid); - assert_eq!(result, Err(Ok(GovernorError::ProposalNotPassed))); - - // Verify proposal is still cancelled - assert_eq!(client.get_proposal_state(&pid), ProposalState::Cancelled); - } - - /// Test get_pending_proposals excludes cancelled proposals - #[test] - fn test_get_pending_proposals_excludes_cancelled() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - - // Create two proposals - let pid1 = client.propose( - &proposer, - &String::from_str(&env, "Proposal 1"), - &String::from_str(&env, "Description 1"), - ); - let pid2 = client.propose( - &proposer, - &String::from_str(&env, "Proposal 2"), - &String::from_str(&env, "Description 2"), - ); - - // Both should be pending - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 2); - assert!(pending.contains(pid1)); - assert!(pending.contains(pid2)); - - // Cancel the first proposal - client.cancel_proposal(&proposer, &pid1); - - // Only the second proposal should be pending now - let pending = client.get_pending_proposals(); - assert_eq!(pending.len(), 1); - assert!(!pending.contains(pid1)); - assert!(pending.contains(pid2)); - } - - /// Test cancel already cancelled proposal: cannot cancel a proposal twice - #[test] - fn test_cancel_already_cancelled_proposal_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Cancel the proposal - client.cancel_proposal(&proposer, &pid); - - // Try to cancel again - let result = client.try_cancel_proposal(&proposer, &pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyCancelled))); - } - - /// Test that NextProposalId is not incremented when propose() fails. - /// - /// Case 1: propose() on an uninitialized contract returns NotInitialized - /// and get_proposal_count() stays at 0. - /// Case 2: After one valid proposal, a second invalid call (uninitialized - /// contract) leaves get_proposal_count() at 1, not 2. - #[test] - fn test_failed_propose_does_not_increment_proposal_counter() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - - // ── Case 1: uninitialized contract ──────────────────────────────────── - let contract_id = env.register_contract(None, GovernorContract); - let uninit_client = GovernorContractClient::new(&env, &contract_id); - - let proposer = Address::generate(&env); - let result = uninit_client.try_propose( - &proposer, - &String::from_str(&env, "Should Fail"), - &String::from_str(&env, "Contract not initialized"), - ); - assert_eq!(result, Err(Ok(GovernorError::NotInitialized))); - - // Counter must still be 0 — the failed call must not have touched it - assert_eq!( - uninit_client.get_proposal_count(), - 0, - "proposal count should remain 0 after failed propose on uninitialized contract" - ); - - // ── Case 2: one valid proposal, then a failed one on a separate uninit contract ── - let client = setup(&env); - - // Valid proposal — counter goes to 1 - let pid = client.propose( - &proposer, - &String::from_str(&env, "Valid Proposal"), - &String::from_str(&env, "This one succeeds"), - ); - assert_eq!(pid, 0, "first proposal id should be 0"); - assert_eq!( - client.get_proposal_count(), - 1, - "count should be 1 after one valid proposal" - ); - - // Failed propose on the still-uninitialized contract must not affect its counter - let result2 = uninit_client.try_propose( - &proposer, - &String::from_str(&env, "Should Also Fail"), - &String::from_str(&env, "Still not initialized"), - ); - assert_eq!(result2, Err(Ok(GovernorError::NotInitialized))); - assert_eq!( - uninit_client.get_proposal_count(), - 0, - "uninit contract count should still be 0 after second failed propose" - ); - - // The initialized contract's counter must be unaffected too - assert_eq!( - client.get_proposal_count(), - 1, - "initialized contract count should still be 1" - ); - } - - // ── Abstain vote tests ───────────────────────────────────────────────────── - - /// Abstain votes count toward quorum but not for or against. - #[test] - fn test_abstain_counts_toward_quorum() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); // quorum = 100 - - let proposer = Address::generate(&env); - let yes_voter = Address::generate(&env); - let abstain_voter = Address::generate(&env); - mint(&env, &token_id, &yes_voter, 60); - mint(&env, &token_id, &abstain_voter, 40); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Abstain Quorum Test"), - &String::from_str(&env, "desc"), - ); - - // 60 for + 40 abstain = 100 total, meets quorum; 60 > 0 so passes - client.vote(&yes_voter, &pid, &VoteDirection::For, &60); - client.vote(&abstain_voter, &pid, &VoteDirection::Abstain, &40); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - - let proposal = client.get_proposal(&pid); - assert_eq!(proposal.votes_for, 60); - assert_eq!(proposal.votes_against, 0); - assert_eq!(proposal.abstentions, 40); - } - - /// Abstain-only votes meet quorum but proposal fails (no majority for). - #[test] - fn test_abstain_only_meets_quorum_but_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); // quorum = 100 - - let proposer = Address::generate(&env); - let abstain_voter = Address::generate(&env); - mint(&env, &token_id, &abstain_voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Abstain Only"), - &String::from_str(&env, "desc"), - ); - - client.vote(&abstain_voter, &pid, &VoteDirection::Abstain, &200); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - // quorum met (200 >= 100) but votes_for (0) not > votes_against (0) - assert_eq!(state, ProposalState::Failed); - } - - /// Abstain votes without enough total to meet quorum still fail. - #[test] - fn test_abstain_below_quorum_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); // quorum = 100 - - let proposer = Address::generate(&env); - let abstain_voter = Address::generate(&env); - mint(&env, &token_id, &abstain_voter, 50); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Abstain Below Quorum"), - &String::from_str(&env, "desc"), - ); - - client.vote(&abstain_voter, &pid, &VoteDirection::Abstain, &50); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - } - - /// get_vote_tally reflects abstain votes correctly. - #[test] - fn test_get_vote_tally_with_abstain() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Tally Abstain"), - &String::from_str(&env, "desc"), - ); - - let v1 = Address::generate(&env); - let v2 = Address::generate(&env); - let v3 = Address::generate(&env); - mint(&env, &token_id, &v1, 100); - mint(&env, &token_id, &v2, 50); - mint(&env, &token_id, &v3, 75); - client.vote(&v1, &pid, &VoteDirection::For, &100); - client.vote(&v2, &pid, &VoteDirection::Against, &50); - client.vote(&v3, &pid, &VoteDirection::Abstain, &75); - - let tally = client.get_vote_tally(&pid); - assert_eq!(tally.yes_votes, 100); - assert_eq!(tally.no_votes, 50); - assert_eq!(tally.abstain_votes, 75); - assert_eq!(tally.total_votes, 225); - } - - /// Issue #267: initialize() with vote_token == admin (clearly invalid config) must return InvalidConfig. - /// - /// A real token contract address is required for vote_token. Using the admin address - /// as vote_token is a clear misconfiguration that should be caught at initialization. - #[test] - fn test_initialize_with_invalid_vote_token_returns_invalid_config() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, GovernorContract); - let client = GovernorContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - - // Use admin address as vote_token — clearly invalid - let config = GovernorConfig { - admin: admin.clone(), - vote_token: admin.clone(), // same as admin — invalid - voting_period: 3600, - quorum: 100, - timelock_delay: 86400, - }; - - let result = client.try_initialize(&config); - assert_eq!( - result, - Err(Ok(GovernorError::InvalidConfig)), - "initialize with vote_token == admin should return InvalidConfig" - ); - - // Contract must remain uninitialized - assert!( - client.get_config().is_none(), - "config should not be set after failed initialize" - ); - } - - #[test] - fn test_get_config_returns_none_before_initialize_and_correct_config_after() { - let env = Env::default(); - env.mock_all_auths(); - - // Register the contract without calling initialize() - let contract_id = env.register_contract(None, GovernorContract); - let client = GovernorContractClient::new(&env, &contract_id); - - // get_config() must return None before initialization - assert!(client.get_config().is_none()); - - // Build a known config - let admin = Address::generate(&env); - let vote_token = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - let config = GovernorConfig { - admin: admin.clone(), - vote_token: vote_token.clone(), - voting_period: 7200, - quorum: 50, - timelock_delay: 43200, - }; - - client.initialize(&config); - - // get_config() must return Some with the exact values passed to initialize() - let stored = client - .get_config() - .expect("config should be Some after initialize"); - assert_eq!(stored.vote_token, vote_token); - assert_eq!(stored.voting_period, 7200); - assert_eq!(stored.quorum, 50); - assert_eq!(stored.timelock_delay, 43200); - } - - #[test] - fn test_get_vote_weight_returns_correct_weight_after_voting() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 500); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - // Before voting, weight should be None - assert_eq!(client.get_vote_weight(&pid, &voter), None); - - client.vote(&voter, &pid, &VoteDirection::For, &500); - - // After voting, weight should match what was cast - assert_eq!(client.get_vote_weight(&pid, &voter), Some(500)); - } - - #[test] - fn test_get_vote_weight_returns_none_for_non_voter() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let non_voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "P"), - &String::from_str(&env, "D"), - ); - - client.vote(&voter, &pid, &VoteDirection::Against, &75); - - // voter's weight is stored - assert_eq!(client.get_vote_weight(&pid, &voter), Some(75)); - // non_voter never voted — must return None - assert_eq!(client.get_vote_weight(&pid, &non_voter), None); - } - - #[test] - fn test_get_vote_weight_returns_none_for_nonexistent_proposal() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let voter = Address::generate(&env); - - // Proposal 999 was never created - assert_eq!(client.get_vote_weight(&999, &voter), None); - } - - // ── Tests for finalize() state handling (issue #138 compatibility) ──────── - - /// Test finalize() with Cancelled state returns AlreadyCancelled error - #[test] - fn test_finalize_cancelled_proposal_returns_already_cancelled() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Cancel the proposal - client.cancel_proposal(&proposer, &pid); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Cancelled); - - // Try to finalize the cancelled proposal - let result = client.try_finalize(&pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyCancelled))); - - // Verify proposal state remains Cancelled - assert_eq!(client.get_proposal_state(&pid), ProposalState::Cancelled); - } - - /// Test finalize() with Passed state returns AlreadyFinalized error - #[test] - fn test_finalize_passed_proposal_returns_already_finalized() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Vote and finalize to Passed state - client.vote(&voter, &pid, &VoteDirection::For, &200); - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - - // Try to finalize again - let result = client.try_finalize(&pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyFinalized))); - - // Verify proposal state remains Passed - assert_eq!(client.get_proposal_state(&pid), ProposalState::Passed); - } - - /// Test finalize() with Failed state returns AlreadyFinalized error - #[test] - fn test_finalize_failed_proposal_returns_already_finalized() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = setup(&env); - - let proposer = Address::generate(&env); - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // No votes - will fail quorum - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Failed); - - // Try to finalize again - let result = client.try_finalize(&pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyFinalized))); - - // Verify proposal state remains Failed - assert_eq!(client.get_proposal_state(&pid), ProposalState::Failed); - } - - /// Test finalize() with Executed state returns AlreadyFinalized error - #[test] - fn test_finalize_executed_proposal_returns_already_finalized() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - let executor = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Vote, finalize, and execute - client.vote(&voter, &pid, &VoteDirection::For, &200); - env.ledger().with_mut(|l| l.timestamp = 5000); - client.finalize(&pid); - env.ledger().with_mut(|l| l.timestamp = 5000 + 86400 + 1); - client.execute(&executor, &pid); - - // Verify proposal is Executed - assert_eq!(client.get_proposal_state(&pid), ProposalState::Executed); - - // Try to finalize the executed proposal - let result = client.try_finalize(&pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyFinalized))); - - // Verify proposal state remains Executed - assert_eq!(client.get_proposal_state(&pid), ProposalState::Executed); - } - - /// Test finalize() still works correctly with Active proposals (normal case) - #[test] - fn test_finalize_active_proposal_still_works() { - // ── Issue #335: abstain votes count toward quorum but not toward passing ── - - /// Scenario 1: 100 abstain votes meet quorum (100) but proposal fails because - /// votes_for (0) is not > votes_against (0). - #[test] - fn test_abstain_only_quorum_met_proposal_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - // setup_with_token uses quorum = 100, voting_period = 3600 - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let abstainer = Address::generate(&env); - mint(&env, &token_id, &abstainer, 100); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Abstain Only Quorum"), - &String::from_str(&env, "100 abstain, 0 for, 0 against"), - ); - - // Cast exactly 100 abstain votes — meets quorum but no for/against - client.vote(&abstainer, &pid, &VoteDirection::Abstain, &100); - - // Advance past voting period - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - - // Quorum met (100 >= 100) but votes_for (0) not > votes_against (0) → Failed - assert_eq!( - state, - ProposalState::Failed, - "proposal must fail when quorum is met only via abstentions" - ); - - // Verify tally - let tally = client.get_vote_tally(&pid); - assert_eq!(tally.abstain_votes, 100); - assert_eq!(tally.yes_votes, 0); - assert_eq!(tally.no_votes, 0); - assert_eq!(tally.total_votes, 100); - } - - /// Scenario 2: 51 for + 49 abstain = 100 total meets quorum and yes majority → Passed. - #[test] - fn test_for_plus_abstain_meets_quorum_and_passes() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 200); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - - // Vote and advance past voting period - client.vote(&voter, &pid, &VoteDirection::For, &200); - env.ledger().with_mut(|l| l.timestamp = 5000); - - // Finalize should work normally for Active proposals - let state = client.finalize(&pid); - assert_eq!(state, ProposalState::Passed); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Passed); - } - - /// Test finalize() compatibility with cancel_proposal() workflow - #[test] - fn test_finalize_compatibility_with_cancel_proposal() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (client, token_id) = setup_with_token(&env); - - let proposer = Address::generate(&env); - let voter = Address::generate(&env); - mint(&env, &token_id, &voter, 100); - - // Create a proposal and vote on it - let pid = client.propose( - &proposer, - &String::from_str(&env, "Test Proposal"), - &String::from_str(&env, "Description"), - ); - client.vote(&voter, &pid, &VoteDirection::For, &100); - - // Cancel before voting period ends - client.cancel_proposal(&proposer, &pid); - assert_eq!(client.get_proposal_state(&pid), ProposalState::Cancelled); - - // Finalize should return AlreadyCancelled, not AlreadyFinalized - let result = client.try_finalize(&pid); - assert_eq!(result, Err(Ok(GovernorError::AlreadyCancelled))); - - // Even after voting period ends, still should return AlreadyCancelled - env.ledger().with_mut(|l| l.timestamp = 1000 + 3600 + 1); - let result2 = client.try_finalize(&pid); - assert_eq!(result2, Err(Ok(GovernorError::AlreadyCancelled))); - let yes_voter = Address::generate(&env); - let abstainer = Address::generate(&env); - mint(&env, &token_id, &yes_voter, 51); - mint(&env, &token_id, &abstainer, 49); - - let pid = client.propose( - &proposer, - &String::from_str(&env, "For + Abstain Quorum"), - &String::from_str(&env, "51 for + 49 abstain = 100 total"), - ); - - client.vote(&yes_voter, &pid, &VoteDirection::For, &51); - client.vote(&abstainer, &pid, &VoteDirection::Abstain, &49); - - env.ledger().with_mut(|l| l.timestamp = 5000); - let state = client.finalize(&pid); - - // Total = 100 >= quorum, votes_for (51) > votes_against (0) → Passed - assert_eq!( - state, - ProposalState::Passed, - "proposal must pass when quorum met and yes majority achieved via for+abstain" - ); - - let tally = client.get_vote_tally(&pid); - assert_eq!(tally.yes_votes, 51); - assert_eq!(tally.no_votes, 0); - assert_eq!(tally.abstain_votes, 49); - assert_eq!(tally.total_votes, 100); - } -} diff --git a/contracts/forge-multisig/src/lib.rs b/contracts/forge-multisig/src/lib.rs deleted file mode 100644 index 3ec9bcc..0000000 --- a/contracts/forge-multisig/src/lib.rs +++ /dev/null @@ -1,2675 +0,0 @@ -#![no_std] - -//! # forge-multisig -//! -//! An N-of-M multisig treasury contract for Stellar/Soroban. -//! -//! ## Features -//! - N-of-M signature threshold for transaction approval -//! - Timelock delay before execution after approval -//! - Owners can propose, approve, reject, and execute transactions -//! - Native token support via Stellar token interface - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec, -}; - -const INSTANCE_TTL_THRESHOLD: u32 = 17_280; -const INSTANCE_TTL_EXTEND: u32 = 34_560; - -// ── Storage keys ────────────────────────────────────────────────────────────── - -#[contracttype] -pub enum DataKey { - Owners, - Threshold, - TimelockDelay, - Proposal(u64), - NextProposalId, - /// Boolean flag per address — `true` means the address is an owner. - /// Enables O(1) ownership checks without scanning the full owner Vec. - IsOwner(Address), - /// Boolean flag for whether an address has approved a proposal. - HasApproved(u64, Address), - /// Boolean flag for whether an address has rejected a proposal. - HasRejected(u64, Address), - /// Total tokens committed to approved-but-not-yet-executed proposals per token address. - CommittedAmount(Address), -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -/// A pending treasury transaction proposal. -#[contracttype] -#[derive(Clone)] -pub struct Proposal { - /// Who proposed this transaction. - pub proposer: Address, - /// Destination address for the transfer. - pub to: Address, - /// Token address. For native XLM proposals this holds the SAC address of - /// the native asset and `is_native` is set to `true`. - pub token: Address, - /// Amount to transfer. - pub amount: i128, - /// Number of approvals recorded for this proposal. - pub approval_count: u32, - /// Number of rejections recorded for this proposal. - pub rejection_count: u32, - /// Ledger timestamp when approval threshold was reached. - pub approved_at: Option, - /// Whether the proposal has been executed. - pub executed: bool, - /// Whether the proposal has been cancelled. - pub cancelled: bool, - /// Whether this is a native XLM transfer proposal. - /// When `true`, `execute()` uses the native asset SAC address stored in - /// `token` rather than a custom Soroban token contract. - pub is_native: bool, -} - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, PartialEq, Debug)] -pub enum MultisigError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - ProposalNotFound = 4, - AlreadyVoted = 5, - TimelockNotElapsed = 6, - AlreadyExecuted = 7, - AlreadyCancelled = 8, - InsufficientApprovals = 9, - InvalidThreshold = 10, - InvalidAmount = 11, - CannotCancel = 12, - InsufficientFunds = 13, -} - -// ── Contract ────────────────────────────────────────────────────────────────── - -#[contract] -pub struct MultisigContract; - -#[contractimpl] -impl MultisigContract { - /// Initialize the multisig treasury. - /// - /// Stores the owner list, approval threshold, and timelock delay. Must be - /// called exactly once before any other function. Does not require auth — - /// the deployer is responsible for calling this immediately after deployment. - /// - /// Duplicate owner addresses are automatically deduplicated to ensure each - /// owner is unique and counts only once toward the threshold. - /// - /// # Parameters - /// - `owners` — List of addresses that are permitted to propose, vote, and execute. - /// - `threshold` — Minimum number of approvals required to pass a proposal (N in N-of-M). - /// Must be ≥ 1 and ≤ the number of unique owners after deduplication. - /// - `timelock_delay` — Seconds that must elapse after a proposal reaches the approval - /// threshold before it can be executed. Use `0` for no delay. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`MultisigError::AlreadyInitialized`] — Contract has already been initialized. - /// - [`MultisigError::InvalidThreshold`] — `threshold` is 0 or exceeds the number of unique owners. - /// - /// # Example - /// ```text - /// // 2-of-3 multisig with a 3600 s (1 h) timelock - /// client.initialize(&vec![&env, owner_a, owner_b, owner_c], &2, &3600); - /// ``` - pub fn initialize( - env: Env, - owners: Vec
, - threshold: u32, - timelock_delay: u64, - ) -> Result<(), MultisigError> { - if env.storage().instance().has(&DataKey::Owners) { - return Err(MultisigError::AlreadyInitialized); - } - - // Deduplicate owners to ensure uniqueness - let mut unique_owners = Vec::new(&env); - for owner in owners.iter() { - if !unique_owners.contains(&owner) { - unique_owners.push_back(owner); - } - } - - if threshold == 0 || threshold > unique_owners.len() { - return Err(MultisigError::InvalidThreshold); - } - env.storage() - .instance() - .set(&DataKey::Owners, &unique_owners); - env.storage() - .instance() - .set(&DataKey::Threshold, &threshold); - env.storage() - .instance() - .set(&DataKey::TimelockDelay, &timelock_delay); - - // Populate O(1) ownership lookup map. - for owner in unique_owners.iter() { - env.storage() - .instance() - .set(&DataKey::IsOwner(owner), &true); - } - - Ok(()) - } - - /// Propose a token transfer from the treasury. - /// - /// Creates a new [`Proposal`] and automatically records the proposer's approval. - /// The returned ID is used to reference this proposal in subsequent `approve`, - /// `reject`, and `execute` calls. Requires authorization from `proposer`. - /// - /// # Parameters - /// - `proposer` — An owner address submitting the proposal. - /// - `to` — Destination address that will receive the tokens if executed. - /// - `token` — Address of the Soroban token contract to transfer from. - /// - `amount` — Number of tokens (in the token's smallest unit) to transfer. Must be > 0. - /// - /// # Returns - /// `Ok(proposal_id)` — the unique ID assigned to the new proposal. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `proposer` is not in the owner list. - /// - [`MultisigError::InvalidAmount`] — `amount` is ≤ 0. - /// - /// # Example - /// ```text - /// let id = client.propose(&owner, &recipient, &token, &500_000); - /// ``` - pub fn propose( - env: Env, - proposer: Address, - to: Address, - token: Address, - amount: i128, - ) -> Result { - proposer.require_auth(); - Self::require_owner(&env, &proposer)?; - - if amount <= 0 { - return Err(MultisigError::InvalidAmount); - } - - let proposal_id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextProposalId) - .unwrap_or(0u64); - - let threshold: u32 = env - .storage() - .instance() - .get(&DataKey::Threshold) - .ok_or(MultisigError::NotInitialized)?; - let approved_at = if 1 >= threshold { - Some(env.ledger().timestamp()) - } else { - None - }; - - let proposal = Proposal { - proposer: proposer.clone(), - to: to.clone(), - token: token.clone(), - amount, - approval_count: 1, - rejection_count: 0, - approved_at, - executed: false, - cancelled: false, - is_native: false, - }; - - env.storage() - .persistent() - .set(&DataKey::HasApproved(proposal_id, proposer.clone()), &true); - - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage() - .persistent() - .set(&DataKey::NextProposalId, &(proposal_id + 1)); - - // Extend TTL for NextProposalId to prevent expiry (1 year) - env.storage() - .persistent() - .extend_ttl(&DataKey::NextProposalId, 31536000, 31536000); - - // If threshold was met immediately (threshold=1), commit the amount now - if approved_at.is_some() { - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(token.clone())) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::CommittedAmount(token.clone()), - &(committed + amount), - ); - } - - env.events().publish( - (Symbol::new(&env, "proposal_created"),), - (proposal_id, &proposer, &to, &token, amount), - ); - - Ok(proposal_id) - } - - /// Propose a native XLM transfer from the treasury. - /// - /// Identical to [`propose`](Self::propose) but marks the proposal as a - /// native XLM transfer (`is_native = true`). The contract must hold - /// sufficient native XLM balance before `execute()` is called. - /// - /// On Soroban, native XLM is accessed through the Stellar Asset Contract - /// (SAC) for the native asset. The SAC exposes the same `token::Client` - /// interface as any other Soroban token, so `execute()` handles both cases - /// identically — the `is_native` flag is a semantic marker for callers. - /// - /// # Parameters - /// - `proposer` — An owner address submitting the proposal. - /// - `to` — Destination address that will receive XLM if executed. - /// - `xlm_token` — Address of the native asset SAC contract. - /// - `amount` — Stroops to transfer (1 XLM = 10,000,000 stroops). Must be > 0. - /// - /// # Returns - /// `Ok(proposal_id)` — the unique ID assigned to the new proposal. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `proposer` is not in the owner list. - /// - [`MultisigError::InvalidAmount`] — `amount` is ≤ 0. - /// - /// # Example - /// ```text - /// // Transfer 10 XLM (100_000_000 stroops) to recipient - /// let id = client.propose_xlm(&owner, &recipient, &xlm_sac_address, &100_000_000); - /// ``` - pub fn propose_xlm( - env: Env, - proposer: Address, - to: Address, - xlm_token: Address, - amount: i128, - ) -> Result { - proposer.require_auth(); - Self::require_owner(&env, &proposer)?; - - if amount <= 0 { - return Err(MultisigError::InvalidAmount); - } - - let proposal_id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextProposalId) - .unwrap_or(0u64); - - let threshold: u32 = env - .storage() - .instance() - .get(&DataKey::Threshold) - .ok_or(MultisigError::NotInitialized)?; - let approved_at = if 1 >= threshold { - Some(env.ledger().timestamp()) - } else { - None - }; - - let proposal = Proposal { - proposer: proposer.clone(), - to: to.clone(), - token: xlm_token.clone(), - amount, - approval_count: 1, - rejection_count: 0, - approved_at, - executed: false, - cancelled: false, - is_native: true, - }; - - env.storage() - .persistent() - .set(&DataKey::HasApproved(proposal_id, proposer.clone()), &true); - - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage() - .persistent() - .set(&DataKey::NextProposalId, &(proposal_id + 1)); - - env.storage() - .persistent() - .extend_ttl(&DataKey::NextProposalId, 31536000, 31536000); - - // If threshold was met immediately (threshold=1), commit the amount now - if approved_at.is_some() { - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(xlm_token.clone())) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::CommittedAmount(xlm_token.clone()), - &(committed + amount), - ); - } - - env.events().publish( - (Symbol::new(&env, "proposal_created"),), - (proposal_id, &proposer, &to, &xlm_token, amount), - ); - - Ok(proposal_id) - } - - /// Approve a proposal. - /// - /// Records `owner`'s approval on the given proposal. If the total approval count - /// reaches the configured threshold for the first time, the timelock countdown - /// begins by storing the current ledger timestamp in `approved_at`. - /// Requires authorization from `owner`. - /// - /// # Parameters - /// - `owner` — An owner address casting the approval vote. - /// - `proposal_id` — ID of the proposal to approve. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `owner` is not in the owner list. - /// - [`MultisigError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`MultisigError::AlreadyVoted`] — `owner` has already approved or rejected this proposal. - /// - [`MultisigError::AlreadyExecuted`] — The proposal has already been executed. - /// - [`MultisigError::AlreadyCancelled`] — The proposal has been cancelled. - /// - /// # Example - /// ```text - /// client.approve(&owner_b, &proposal_id); - /// ``` - pub fn approve(env: Env, owner: Address, proposal_id: u64) -> Result<(), MultisigError> { - owner.require_auth(); - Self::require_owner(&env, &owner)?; - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(MultisigError::ProposalNotFound)?; - - if proposal.executed { - return Err(MultisigError::AlreadyExecuted); - } - if proposal.cancelled { - return Err(MultisigError::AlreadyCancelled); - } - if env - .storage() - .persistent() - .get::(&DataKey::HasApproved(proposal_id, owner.clone())) - .unwrap_or(false) - || env - .storage() - .persistent() - .get::(&DataKey::HasRejected(proposal_id, owner.clone())) - .unwrap_or(false) - { - return Err(MultisigError::AlreadyVoted); - } - - proposal.approval_count = proposal.approval_count.saturating_add(1); - env.storage() - .persistent() - .set(&DataKey::HasApproved(proposal_id, owner.clone()), &true); - - let threshold: u32 = env - .storage() - .instance() - .get(&DataKey::Threshold) - .ok_or(MultisigError::NotInitialized)?; - // The is_none() guard ensures approved_at is set only once, when the threshold is first reached. - // This prevents the timelock countdown from being reset if threshold changes in the future. - // Currently, owners and threshold are immutable after initialize(), but this guard protects - // against accidental resets if threshold mutability is added later. - if proposal.approval_count >= threshold && proposal.approved_at.is_none() { - proposal.approved_at = Some(env.ledger().timestamp()); - // Track committed tokens to prevent over-commitment across concurrent proposals - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(proposal.token.clone())) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::CommittedAmount(proposal.token.clone()), - &(committed + proposal.amount), - ); - } - - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - env.events().publish( - (Symbol::new(&env, "proposal_approved"),), - (proposal_id, &owner, proposal.approval_count), - ); - - Ok(()) - } - - /// Reject a proposal. - /// - /// Records `owner`'s rejection on the given proposal. A rejected proposal can - /// no longer reach the approval threshold once enough owners have rejected it, - /// though the contract does not automatically cancel it. - /// Requires authorization from `owner`. - /// - /// # Parameters - /// - `owner` — An owner address casting the rejection vote. - /// - `proposal_id` — ID of the proposal to reject. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `owner` is not in the owner list. - /// - [`MultisigError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`MultisigError::AlreadyVoted`] — `owner` has already approved or rejected this proposal. - /// - [`MultisigError::AlreadyExecuted`] — The proposal has already been executed. - /// - /// # Example - /// ```text - /// client.reject(&owner_c, &proposal_id); - /// ``` - pub fn reject(env: Env, owner: Address, proposal_id: u64) -> Result<(), MultisigError> { - owner.require_auth(); - Self::require_owner(&env, &owner)?; - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(MultisigError::ProposalNotFound)?; - - if proposal.executed { - return Err(MultisigError::AlreadyExecuted); - } - if proposal.cancelled { - return Err(MultisigError::AlreadyCancelled); - } - if env - .storage() - .persistent() - .get::(&DataKey::HasApproved(proposal_id, owner.clone())) - .unwrap_or(false) - || env - .storage() - .persistent() - .get::(&DataKey::HasRejected(proposal_id, owner.clone())) - .unwrap_or(false) - { - return Err(MultisigError::AlreadyVoted); - } - - proposal.rejection_count = proposal.rejection_count.saturating_add(1); - env.storage() - .persistent() - .set(&DataKey::HasRejected(proposal_id, owner.clone()), &true); - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - env.events().publish( - (Symbol::new(&env, "proposal_rejected"),), - (proposal_id, &owner, proposal.rejection_count), - ); - - Ok(()) - } - - /// Execute an approved proposal after the timelock delay has elapsed. - /// - /// Transfers the proposed token amount from the contract's treasury balance to - /// the proposal's `to` address. The proposal must have reached the approval - /// threshold and the configured `timelock_delay` must have passed since - /// `approved_at`. Requires authorization from `executor`. - /// - /// # Parameters - /// - `executor` — An owner address triggering execution. - /// - `proposal_id` — ID of the proposal to execute. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `executor` is not in the owner list. - /// - [`MultisigError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`MultisigError::AlreadyExecuted`] — The proposal has already been executed. - /// - [`MultisigError::AlreadyCancelled`] — The proposal has been cancelled. - /// - [`MultisigError::InsufficientApprovals`] — Threshold has not been reached yet. - /// - [`MultisigError::TimelockNotElapsed`] — The timelock delay has not fully passed. - /// - /// # Example - /// ```text - /// // After timelock has elapsed: - /// client.execute(&owner_a, &proposal_id); - /// ``` - pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), MultisigError> { - executor.require_auth(); - Self::require_owner(&env, &executor)?; - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(MultisigError::ProposalNotFound)?; - - if proposal.executed { - return Err(MultisigError::AlreadyExecuted); - } - if proposal.cancelled { - return Err(MultisigError::AlreadyCancelled); - } - - let approved_at = proposal - .approved_at - .ok_or(MultisigError::InsufficientApprovals)?; - let delay: u64 = env - .storage() - .instance() - .get(&DataKey::TimelockDelay) - .unwrap_or(0); - - if env.ledger().timestamp() < approved_at + delay { - return Err(MultisigError::TimelockNotElapsed); - } - - proposal.executed = true; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - let token_client = token::Client::new(&env, &proposal.token); - - // Verify the treasury holds enough to cover all committed proposals. - // For both token and native XLM proposals the transfer goes through - // token::Client. Native XLM proposals store the native asset SAC address - // in `proposal.token`, so the call is identical in both cases. - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(proposal.token.clone())) - .unwrap_or(0); - let balance = token_client.balance(&env.current_contract_address()); - if balance < committed { - return Err(MultisigError::InsufficientFunds); - } - - token_client.transfer( - &env.current_contract_address(), - &proposal.to, - &proposal.amount, - ); - - // Mark executed AFTER the transfer succeeds. Setting executed = true before - // the transfer would permanently lock funds if the transfer traps — the - // proposal would be unretryable and the tokens unreachable forever. - proposal.executed = true; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - // Release the committed amount for this proposal - let new_committed = committed.saturating_sub(proposal.amount); - env.storage().instance().set( - &DataKey::CommittedAmount(proposal.token.clone()), - &new_committed, - ); - - env.storage().instance().extend_ttl(17280, 34560); - - env.events().publish( - (Symbol::new(&env, "proposal_executed"),), - (proposal_id, &executor, &proposal.to, proposal.amount), - ); - - Ok(()) - } - - /// Cancel a proposal that can no longer reach the approval threshold. - /// - /// Allows an owner to cancel a proposal if it is mathematically impossible - /// for it to reach the approval threshold, or if the proposer cancels before - /// execution. This helps clean up dead proposals and free storage. - /// Requires authorization from `owner`. - /// - /// # Parameters - /// - `owner` — An owner address requesting cancellation. - /// - `proposal_id` — ID of the proposal to cancel. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`MultisigError::Unauthorized`] — `owner` is not in the owner list. - /// - [`MultisigError::ProposalNotFound`] — No proposal exists with `proposal_id`. - /// - [`MultisigError::AlreadyExecuted`] — The proposal has already been executed. - /// - [`MultisigError::AlreadyCancelled`] — The proposal has already been cancelled. - /// - [`MultisigError::CannotCancel`] — The proposal can still reach the approval threshold. - /// - /// # Example - /// ```text - /// // Cancel a proposal that can no longer reach threshold - /// client.cancel(&owner_a, &proposal_id); - /// ``` - pub fn cancel(env: Env, owner: Address, proposal_id: u64) -> Result<(), MultisigError> { - owner.require_auth(); - Self::require_owner(&env, &owner)?; - - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(MultisigError::ProposalNotFound)?; - - if proposal.executed { - return Err(MultisigError::AlreadyExecuted); - } - if proposal.cancelled { - return Err(MultisigError::AlreadyCancelled); - } - - // Allow proposer to cancel at any time before execution - if proposal.proposer == owner { - proposal.cancelled = true; - // Release committed amount if threshold had been reached - if proposal.approved_at.is_some() { - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(proposal.token.clone())) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::CommittedAmount(proposal.token.clone()), - &committed.saturating_sub(proposal.amount), - ); - } - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - env.events().publish( - (Symbol::new(&env, "proposal_cancelled"),), - (proposal_id, &owner), - ); - - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - return Ok(()); - } - - // For other owners, only allow cancellation if mathematically impossible to pass - let threshold: u32 = env - .storage() - .instance() - .get(&DataKey::Threshold) - .ok_or(MultisigError::NotInitialized)?; - let owners: Vec
= env - .storage() - .instance() - .get(&DataKey::Owners) - .ok_or(MultisigError::NotInitialized)?; - let total_owners = owners.len(); - - // Calculate remaining possible approvals - // remaining_possible = total_owners - rejection_count - approval_count - let remaining_possible = total_owners - .saturating_sub(proposal.rejection_count) - .saturating_sub(proposal.approval_count); - - // If remaining possible approvals < threshold, it's impossible to pass - if remaining_possible < threshold { - proposal.cancelled = true; - // Release committed amount if threshold had been reached - if proposal.approved_at.is_some() { - let committed: i128 = env - .storage() - .instance() - .get(&DataKey::CommittedAmount(proposal.token.clone())) - .unwrap_or(0); - env.storage().instance().set( - &DataKey::CommittedAmount(proposal.token.clone()), - &committed.saturating_sub(proposal.amount), - ); - } - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); - - env.events().publish( - (Symbol::new(&env, "proposal_cancelled"),), - (proposal_id, &owner), - ); - - env.storage() - .instance() - .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); - - return Ok(()); - } - - Err(MultisigError::CannotCancel) - } - - /// Return a proposal by its ID. - /// - /// Read-only; does not modify state. Returns `Err(MultisigError::ProposalNotFound)` - /// if no proposal exists with the given ID, consistent with the error-returning - /// convention used across all Forge contracts (e.g. `forge-governor`). - /// - /// # Parameters - /// - `proposal_id` — The ID returned by [`propose`](Self::propose). - /// - /// # Returns - /// `Ok(`[`Proposal`]`)` if found, `Err(`[`MultisigError::ProposalNotFound`]`)` otherwise. - /// - /// # Example - /// ```text - /// let proposal = client.get_proposal(&id).expect("proposal not found"); - /// println!("approvals: {}", proposal.approval_count); - /// ``` - pub fn get_proposal(env: Env, proposal_id: u64) -> Result { - env.storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) - .ok_or(MultisigError::ProposalNotFound) - } - - /// Return the list of authorized owner addresses. - /// - /// Read-only; returns an empty `Vec` if the contract has not been initialized. - /// - /// # Returns - /// A [`Vec
`] of all current owners. - /// - /// # Example - /// ```text - /// let owners = client.get_owners(); - /// assert_eq!(owners.len(), 3); - /// ``` - pub fn get_owners(env: Env) -> Vec
{ - env.storage() - .instance() - .get(&DataKey::Owners) - .unwrap_or(Vec::new(&env)) - } - - /// Return the list of authorized owner addresses. Alias for [`get_owners`](Self::get_owners). - /// - /// # Returns - /// A [`Vec
`] of all current owners. - pub fn get_owner_list(env: Env) -> Vec
{ - Self::get_owners(env) - } - - /// Return the current approval threshold (N in N-of-M). - /// - /// Read-only; returns `0` if the contract has not been initialized. - /// - /// # Returns - /// The minimum number of owner approvals required to pass a proposal. - /// - /// # Example - /// ```text - /// let threshold = client.get_threshold(); // e.g. 2 for a 2-of-3 setup - /// ``` - pub fn get_threshold(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::Threshold) - .unwrap_or(0) - } - - /// Return the configured timelock delay in seconds. - /// - /// Read-only; returns `0` if the contract has not been initialized. - /// This is the number of seconds that must elapse after a proposal reaches - /// the approval threshold before it can be executed. - /// - /// # Returns - /// `u64` — the timelock delay in seconds set at initialization. - /// - /// # Example - /// ```text - /// let delay = client.get_timelock_delay(); - /// println!("Timelock: {} seconds", delay); - /// ``` - pub fn get_timelock_delay(env: Env) -> u64 { - env.storage() - .instance() - .get(&DataKey::TimelockDelay) - .unwrap_or(0) - } - - /// Check if an address is one of the multisig owners. - /// - /// Read-only; returns `false` if the contract has not been initialized. - /// This is a lightweight alternative to [`get_owners`](Self::get_owners) when - /// UIs or integrators only need to verify ownership status. - /// - /// # Parameters - /// - `address` — The address to check for ownership. - /// - /// # Returns - /// `true` if `address` is in the owner list, `false` otherwise. - /// - /// # Example - /// ```text - /// if client.is_owner(&some_address) { - /// // enable multisig actions - /// } - /// ``` - pub fn is_owner(env: Env, address: Address) -> bool { - env.storage() - .instance() - .get::(&DataKey::IsOwner(address)) - .unwrap_or(false) - } - - /// Return the number of owner approvals for a proposal. - /// - /// Lightweight read-only view intended for UIs that only need approval count. - /// Returns `0` if the proposal does not exist. - /// - /// # Parameters - /// - `proposal_id` — The target proposal ID. - /// - /// # Returns - /// Number of approvals currently recorded for the proposal. - pub fn get_approval_count(env: Env, proposal_id: u64) -> u32 { - env.storage() - .persistent() - .get::(&DataKey::Proposal(proposal_id)) - .map(|proposal| proposal.approval_count) - .unwrap_or(0) - } - - /// Return the total tokens committed to approved-but-not-yet-executed proposals - /// for a given token address. - /// - /// This value increases when a proposal reaches the approval threshold and decreases - /// when a proposal is executed or cancelled. It is used by [`execute`](Self::execute) - /// to verify the treasury holds enough tokens to cover all pending commitments before - /// transferring funds. - /// - /// # Parameters - /// - `token` — The token contract address to query. - /// - /// # Returns - /// `i128` — total committed tokens for `token`. Returns `0` if none committed. - pub fn get_committed_amount(env: Env, token: Address) -> i128 { - env.storage() - .instance() - .get(&DataKey::CommittedAmount(token)) - .unwrap_or(0) - } - - // ── Private ─────────────────────────────────────────────────────────────── - - fn require_owner(env: &Env, address: &Address) -> Result<(), MultisigError> { - // Guard against calls before initialize() — IsOwner keys only exist post-init. - if !env.storage().instance().has(&DataKey::Owners) { - return Err(MultisigError::NotInitialized); - } - let is_owner: bool = env - .storage() - .instance() - .get(&DataKey::IsOwner(address.clone())) - .unwrap_or(false); - if is_owner { - Ok(()) - } else { - Err(MultisigError::Unauthorized) - } - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - vec, Env, - }; - - fn setup_2of3<'a>(env: &'a Env) -> (MultisigContractClient<'a>, Address, Address, Address) { - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(env, &contract_id); - let o1 = Address::generate(env); - let o2 = Address::generate(env); - let o3 = Address::generate(env); - client.initialize(&vec![env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - (client, o1, o2, o3) - } - - #[test] - fn test_invalid_threshold() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let result = client.try_initialize(&vec![&env, o1], &5, &0); - assert_eq!(result, Err(Ok(MultisigError::InvalidThreshold))); - } - - /// TC: threshold > unique_owners.len() must return InvalidThreshold. - /// Verifies the boundary: 4-of-3 is rejected even though 3-of-3 is valid. - #[test] - fn test_initialize_threshold_exceeds_owners_returns_invalid_threshold() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - let result = client.try_initialize(&vec![&env, o1, o2, o3], &4, &0); - assert_eq!(result, Err(Ok(MultisigError::InvalidThreshold))); - } - - /// TC: unanimous 3-of-3 multisig — every owner must approve before execution. - /// - /// Steps: - /// 1. Initialize 3-of-3 with a 3600 s timelock. - /// 2. propose() — auto-approves o1 (1/3); approved_at must still be None. - /// 3. approve(o2) — 2/3; approved_at must still be None. - /// 4. approve(o3) — 3/3 hits threshold; approved_at must be Some(timestamp). - /// 5. Advance past timelock and execute — assert proposal.executed. - #[test] - fn test_unanimous_3of3_multisig() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &3, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - // Step 2: propose — o1 auto-approves (1/3), threshold not yet reached - let pid = client.propose(&o1, &to, &token_id, &500); - assert!(client.get_proposal(&pid).unwrap().approved_at.is_none()); - - // Step 3: o2 approves (2/3), still not reached - client.approve(&o2, &pid); - assert!(client.get_proposal(&pid).unwrap().approved_at.is_none()); - - // Step 4: o3 approves (3/3), threshold reached - client.approve(&o3, &pid); - assert!(client.get_proposal(&pid).unwrap().approved_at.is_some()); - - // Step 5: advance past timelock and execute - env.ledger().with_mut(|l| l.timestamp = 1000 + 3600 + 1); - client.execute(&o1, &pid); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - #[test] - fn test_get_timelock_delay() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - // setup_2of3 initializes with timelock_delay = 3600 - assert_eq!(client.get_timelock_delay(), 3600); - } - - #[test] - fn test_get_timelock_delay_zero() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - client.initialize(&vec![&env, o1], &1, &0); - assert_eq!(client.get_timelock_delay(), 0); - } - - #[test] - fn test_initialize_with_duplicate_owners() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let owners = vec![&env, o1.clone(), o1.clone(), o1.clone()]; // 3 duplicates - client.initialize(&owners, &1, &0); - let stored_owners = client.get_owners(); - assert_eq!(stored_owners.len(), 1); - assert!(stored_owners.contains(&o1)); - } - - #[test] - fn test_propose_and_approve_reaches_threshold() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - client.approve(&o2, &pid); - - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.approved_at.is_some()); - } - - /// TC: propose() with amount = 0 must return InvalidAmount and leave no proposal. - #[test] - fn test_propose_zero_amount_returns_invalid_amount() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let result = client.try_propose(&o1, &to, &token, &0); - assert_eq!(result, Err(Ok(MultisigError::InvalidAmount))); - assert!(client.get_proposal(&0).is_none()); - } - - /// TC: propose() with amount = -1 must return InvalidAmount and leave no proposal. - #[test] - fn test_propose_negative_amount_returns_invalid_amount() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let result = client.try_propose(&o1, &to, &token, &-1); - assert_eq!(result, Err(Ok(MultisigError::InvalidAmount))); - assert!(client.get_proposal(&0).is_none()); - } - - /// TC: propose() with amount = 1 must succeed and create a proposal. - #[test] - fn test_propose_minimum_valid_amount_succeeds() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &1); - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.amount, 1); - } - - #[test] - fn test_double_vote_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - let result = client.try_approve(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyVoted))); - } - - #[test] - fn test_timelock_not_elapsed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, o1, o2, o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - client.approve(&o2, &pid); - - let result = client.try_execute(&o3, &pid); - assert_eq!(result, Err(Ok(MultisigError::TimelockNotElapsed))); - } - - #[test] - fn test_execute_after_timelock() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - let pid = client.propose(&o1, &to, &token_id, &500); - client.approve(&o2, &pid); - - env.ledger().with_mut(|l| l.timestamp = 7200); - client.execute(&o3, &pid); - - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.executed); - } - - #[test] - fn test_execute_reverts_below_threshold() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, o1, _, o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - // Only proposer's auto-approval — 1 of 2 required - let pid = client.propose(&o1, &to, &token, &500); - env.ledger().with_mut(|l| l.timestamp = 7200); - let result = client.try_execute(&o3, &pid); - assert_eq!(result, Err(Ok(MultisigError::InsufficientApprovals))); - } - - #[test] - fn test_execute_succeeds_at_exact_threshold() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - let pid = client.propose(&o1, &to, &token_id, &500); - // Second approval hits threshold exactly (2-of-3) - client.approve(&o2, &pid); - - env.ledger().with_mut(|l| l.timestamp = 7200); - client.execute(&o3, &pid); - - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.executed); - } - - #[test] - fn test_approve_after_execute_reverts_with_already_executed() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - let pid = client.propose(&o1, &to, &token_id, &500); - client.approve(&o2, &pid); - - env.ledger().with_mut(|l| l.timestamp = 7200); - client.execute(&o3, &pid); - - // Try to approve with o3 (who hasn't voted yet) after execution - let result = client.try_approve(&o3, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyExecuted))); - - // Also test reject() on executed proposal - let result = client.try_reject(&o3, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyExecuted))); - } - - #[test] - fn test_get_approval_count_zero() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - - assert_eq!(client.get_approval_count(&999), 0); - } - - #[test] - fn test_get_approval_count_partial() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - assert_eq!(client.get_approval_count(&pid), 1); - } - - #[test] - fn test_get_approval_count_full() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - client.approve(&o2, &pid); - - assert_eq!(client.get_approval_count(&pid), 2); - } - - #[test] - fn test_rejected_proposal_cannot_execute() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - let o4 = Address::generate(&env); - - // 3-of-4 multisig - client.initialize( - &vec![&env, o1.clone(), o2.clone(), o3.clone(), o4.clone()], - &3, - &3600, - ); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - // o1 proposes (auto-approval) - let pid = client.propose(&o1, &to, &token_id, &500); - - // o2 and o3 reject - proposal is now rejected (2 rejections means only 2 owners left who could approve) - client.reject(&o2, &pid); - client.reject(&o3, &pid); - - // Verify proposal has 2 rejections - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.rejection_count, 2); - assert_eq!(proposal.approval_count, 1); // only proposer - - // Even if o4 approves, bringing total approvals to 2, it should not be executable - // because 2 rejections means threshold of 3 can never be reached - client.approve(&o4, &pid); - - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.approval_count, 2); - - // Advance time past timelock - env.ledger().with_mut(|l| l.timestamp = 7200); - - // Execution should fail because proposal is effectively rejected - let result = client.try_execute(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::InsufficientApprovals))); - - // Verify proposal state remains unchanged - let proposal = client.get_proposal(&pid).unwrap(); - assert!(!proposal.executed); - assert_eq!(proposal.rejection_count, 2); - } - - /// Test mixed approval/rejection scenario where threshold is still reached - /// - /// Steps: - /// 1. Set up 2-of-3 multisig with owners o1, o2, o3 - /// 2. o1 proposes (auto-approves, 1 approval) - /// 3. o2 rejects (1 approval, 1 rejection) - /// 4. o3 approves — threshold of 2 is now reached - /// 5. Assert proposal.approved_at is set after o3 approves - /// 6. Advance past timelock and execute — assert success - /// 7. Verify token balances are correct - #[test] - fn test_mixed_approval_rejection_threshold_reached() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - - // 2-of-3 multisig with 3600s timelock - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let recipient = Address::generate(&env); - - // Mint tokens to the contract treasury - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &1000); - - // Record initial balances - let initial_contract_balance = - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).balance(&contract_id); - let initial_recipient_balance = - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).balance(&recipient); - - // o1 proposes (auto-approves, 1 approval) - let pid = client.propose(&o1, &recipient, &token_id, &500); - - // Verify initial state: 1 approval, 0 rejections, approved_at = None - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.approval_count, 1); - assert_eq!(proposal.rejection_count, 0); - assert_eq!(proposal.approved_at, None); - - // o2 rejects (1 approval, 1 rejection) - client.reject(&o2, &pid); - - // Verify state after rejection: still 1 approval, 1 rejection, approved_at = None - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.approval_count, 1); - assert_eq!(proposal.rejection_count, 1); - assert_eq!(proposal.approved_at, None); - - // o3 approves — threshold of 2 is now reached - client.approve(&o3, &pid); - - // Verify state after o3 approves: 2 approvals, 1 rejection, approved_at is set - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.approval_count, 2); - assert_eq!(proposal.rejection_count, 1); - assert!(proposal.approved_at.is_some()); - assert_eq!(proposal.approved_at.unwrap(), 0); // Should be set to current timestamp - - // Advance past timelock (3600s) - env.ledger().with_mut(|l| l.timestamp = 7200); - - // Execute should succeed - client.execute(&o1, &pid); - - // Verify proposal is executed - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.executed); - - // Verify token balances are correct - let final_contract_balance = - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).balance(&contract_id); - let final_recipient_balance = - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).balance(&recipient); - - assert_eq!(final_contract_balance, initial_contract_balance - 500); - assert_eq!(final_recipient_balance, initial_recipient_balance + 500); - } - - #[test] - fn test_rejected_proposal_state_immutable() { - let env = Env::default(); - env.mock_all_auths(); - - let (client, o1, o2, o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - // o1 proposes (auto-approval) - let pid = client.propose(&o1, &to, &token, &500); - - // o2 and o3 reject - proposal is now rejected (2 rejections in 2-of-3 means impossible to reach threshold) - client.reject(&o2, &pid); - client.reject(&o3, &pid); - - // Verify rejection state - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.rejection_count, 2); - assert_eq!(proposal.approval_count, 1); - assert!(proposal.approved_at.is_none()); // Never reached approval threshold - - // Proposal should remain in rejected state - let proposal_after = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal_after.rejection_count, 2); - assert!(!proposal_after.executed); - } - - // ── Timelock enforcement tests ──────────────────────────────────────────── - // - // The timelock acts as a "cooling-off" period: even after enough owners have - // approved a proposal, funds cannot move until the configured delay has fully - // elapsed. This gives remaining owners (or the broader community) time to - // detect and react to a compromised key or a rushed decision before it is - // too late. - - /// Helper: set up a 2-of-3 multisig with a custom timelock and a funded token. - /// Returns (client, [o1, o2, o3], token_id, recipient, contract_id). - fn setup_funded<'a>( - env: &'a Env, - timelock_delay: u64, - ) -> ( - MultisigContractClient<'a>, - [Address; 3], - Address, - Address, - Address, - ) { - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(env, &contract_id); - let o1 = Address::generate(env); - let o2 = Address::generate(env); - let o3 = Address::generate(env); - client.initialize( - &vec![env, o1.clone(), o2.clone(), o3.clone()], - &2, - &timelock_delay, - ); - - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(env, &token_id).mint(&contract_id, &1000); - let recipient = Address::generate(env); - - (client, [o1, o2, o3], token_id, recipient, contract_id) - } - - /// TC1 — Premature execution (T+23 h) must revert with TimelockNotElapsed. - #[test] - fn test_timelock_premature_execution_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - const DELAY: u64 = 86_400; // 24 h - let (client, [o1, o2, o3], token_id, recipient, _) = setup_funded(&env, DELAY); - - let pid = client.propose(&o1, &recipient, &token_id, &100); - client.approve(&o2, &pid); // threshold reached at T=0 - - // Advance to T+23 h — one hour short of the required delay - env.ledger().with_mut(|l| l.timestamp = DELAY - 3_600); - let result = client.try_execute(&o3, &pid); - assert_eq!(result, Err(Ok(MultisigError::TimelockNotElapsed))); - } - - /// TC2 — Execution at exactly T+24 h+1 s must succeed and mark the proposal executed. - #[test] - fn test_timelock_exact_boundary_execution_succeeds() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - const DELAY: u64 = 86_400; // 24 h - let (client, [o1, o2, o3], token_id, recipient, _) = setup_funded(&env, DELAY); - - let pid = client.propose(&o1, &recipient, &token_id, &100); - client.approve(&o2, &pid); // threshold reached at T=0 - - // Advance to T+24 h+1 s — just past the boundary - env.ledger().with_mut(|l| l.timestamp = DELAY + 1); - client.execute(&o3, &pid); - - assert!(client.get_proposal(&pid).unwrap().executed); - } - - /// TC3 — Zero-delay timelock: execute() must succeed immediately after threshold is met. - #[test] - fn test_timelock_zero_delay_executes_immediately() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000); - - let (client, [o1, o2, o3], token_id, recipient, _) = setup_funded(&env, 0); - - let pid = client.propose(&o1, &recipient, &token_id, &100); - client.approve(&o2, &pid); // threshold reached — no time advance needed - - client.execute(&o3, &pid); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - #[test] - fn test_is_owner_returns_true_for_owner() { - let env = Env::default(); - let (client, o1, _, _) = setup_2of3(&env); - - assert!(client.is_owner(&o1)); - } - - #[test] - fn test_is_owner_returns_false_for_non_owner() { - let env = Env::default(); - let (client, _, _, _) = setup_2of3(&env); - let non_owner = Address::generate(&env); - - assert!(!client.is_owner(&non_owner)); - } - - #[test] - fn test_get_threshold_returns_initialized_value() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - - // setup_2of3 initializes with threshold = 2 - assert_eq!(client.get_threshold(), 2); - } - - #[test] - fn test_get_owners_list() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, o3) = setup_2of3(&env); - let owners = client.get_owner_list(); - assert_eq!(owners.len(), 3); - assert!(owners.contains(&o1)); - assert!(owners.contains(&o2)); - assert!(owners.contains(&o3)); - } - - /// Test that get_owner_list() and get_owners() return identical results. - /// get_owner_list() is documented as an alias for get_owners() and simply delegates to it. - /// This test verifies the delegation is correct and both functions return identical results. - #[test] - fn test_get_owner_list_and_get_owners_return_identical_results() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, o3) = setup_2of3(&env); - - let owners = client.get_owners(); - let owner_list = client.get_owner_list(); - - // Assert same length - assert_eq!(owners.len(), owner_list.len()); - - // Assert same elements in same order - for i in 0..owners.len() { - assert_eq!(owners.get(i).unwrap(), owner_list.get(i).unwrap()); - } - - // Assert all expected owners are present - assert!(owners.contains(&o1)); - assert!(owners.contains(&o2)); - assert!(owners.contains(&o3)); - assert!(owner_list.contains(&o1)); - assert!(owner_list.contains(&o2)); - assert!(owner_list.contains(&o3)); - } - - // ── 1-of-N threshold tests ───────────────────────────────────────────────── - // - // A threshold of 1 means any single owner can unilaterally authorize a - // treasury transfer. This is a valid but high-risk configuration — useful for - // hot wallets or automated systems where speed matters more than consensus. - // It must be fully supported for flexible treasury management. - - /// Helper: 1-of-3 multisig with a 3600 s timelock and a funded token. - fn setup_1of3_funded<'a>( - env: &'a Env, - ) -> ( - MultisigContractClient<'a>, - Address, - Address, - Address, - Address, - Address, - ) { - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(env, &contract_id); - let o1 = Address::generate(env); - let o2 = Address::generate(env); - let o3 = Address::generate(env); - client.initialize(&vec![env, o1.clone(), o2.clone(), o3.clone()], &1, &3600); - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(env, &token_id).mint(&contract_id, &1000); - let recipient = Address::generate(env); - (client, o1, o2, o3, token_id, recipient) - } - - /// TC1 — Single approval flow: proposer's own approval meets threshold=1, - /// proposal is ready after timelock elapses. - #[test] - fn test_threshold_1_single_approval_flow() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let (client, o1, _, o3, token_id, recipient) = setup_1of3_funded(&env); - - // propose auto-approves for proposer — threshold=1 is immediately met - let pid = client.propose(&o1, &recipient, &token_id, &100); - let proposal = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal.approval_count, 1); - assert!(proposal.approved_at.is_some()); // threshold reached at proposal time - - // advance past timelock and execute - env.ledger().with_mut(|l| l.timestamp = 3601); - client.execute(&o3, &pid); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - /// TC2 — Inter-owner independence: Owner B's approved proposal cannot be - /// blocked by Owner C rejecting after threshold is already met. - #[test] - fn test_threshold_1_rejection_cannot_block_approved_proposal() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let (client, _, o2, o3, token_id, recipient) = setup_1of3_funded(&env); - - // o2 proposes — threshold=1 met immediately via auto-approval - let pid = client.propose(&o2, &recipient, &token_id, &100); - assert!(client.get_proposal(&pid).unwrap().approved_at.is_some()); - - // o3 tries to reject — already voted check: o3 hasn't voted, so rejection - // is recorded, but approved_at is already set and cannot be unset - client.reject(&o3, &pid); - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.approved_at.is_some()); // still approved - assert_eq!(proposal.rejection_count, 1); - - // execution still succeeds after timelock - env.ledger().with_mut(|l| l.timestamp = 3601); - client.execute(&o3, &pid); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - /// TC3 — Immediate threshold check: get_proposal returns approvals=1 right - /// after propose(), confirming threshold=1 is satisfied by the proposer alone. - #[test] - fn test_threshold_1_immediate_approval_count() { - let env = Env::default(); - env.mock_all_auths(); - - let (client, o1, _, _, token_id, recipient) = setup_1of3_funded(&env); - - let pid = client.propose(&o1, &recipient, &token_id, &100); - assert_eq!(client.get_approval_count(&pid), 1); - assert_eq!(client.get_threshold(), 1); - } - - /// Non-owner cannot provide the single required signature. - #[test] - fn test_threshold_1_non_owner_cannot_propose() { - let env = Env::default(); - env.mock_all_auths(); - - let (client, _, _, _, token_id, recipient) = setup_1of3_funded(&env); - let non_owner = Address::generate(&env); - - let result = client.try_propose(&non_owner, &recipient, &token_id, &100); - assert_eq!(result, Err(Ok(MultisigError::Unauthorized))); - } - - // ── Non-owner propose() rejection ───────────────────────────────────────── - - #[test] - fn test_non_owner_propose_reverts() { - // A caller not in the owner list must be rejected - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - let non_owner = Address::generate(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let result = client.try_propose(&non_owner, &to, &token, &500); - assert_eq!(result, Err(Ok(MultisigError::Unauthorized))); - } - - #[test] - fn test_non_owner_propose_returns_unauthorized_error() { - // Verify the specific error variant is Unauthorized, not any other error - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - let non_owner = Address::generate(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - match client.try_propose(&non_owner, &to, &token, &500) { - Err(Ok(err)) => assert_eq!(err, MultisigError::Unauthorized), - other => panic!("expected Unauthorized, got {:?}", other), - } - } - - #[test] - fn test_non_owner_propose_creates_no_proposal() { - // After a failed propose(), no proposal should exist and the counter stays at 0 - let env = Env::default(); - env.mock_all_auths(); - let (client, _, _, _) = setup_2of3(&env); - let non_owner = Address::generate(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let _ = client.try_propose(&non_owner, &to, &token, &500); - - // Proposal ID 0 must not exist - assert_eq!( - client.try_get_proposal(&0).unwrap_err().unwrap(), - MultisigError::ProposalNotFound - ); - // Approval count for a non-existent proposal returns 0 - assert_eq!(client.get_approval_count(&0), 0); - } - - // ── Token balance verification after execute() ──────────────────────────── - - /// After a proposal is approved, the timelock elapses, and execute() is called, - /// the recipient's token balance must increase by exactly the proposed amount, - /// and the multisig contract's balance must decrease by the same amount. - #[test] - fn test_execute_transfers_exact_token_amount_to_recipient() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - // Step 1: Fund the multisig contract with tokens - const TIMELOCK: u64 = 3600; - const TRANSFER_AMOUNT: i128 = 250; - const FUNDED_AMOUNT: i128 = 1000; - - let (client, [o1, o2, o3], token_id, recipient, contract_id) = setup_funded(&env, TIMELOCK); - - let token = soroban_sdk::token::Client::new(&env, &token_id); - - // Verify initial balances - let initial_contract_balance = token.balance(&contract_id); - let initial_recipient_balance = token.balance(&recipient); - assert_eq!(initial_contract_balance, FUNDED_AMOUNT); - assert_eq!(initial_recipient_balance, 0); - - // Step 2: Propose a transfer of a specific amount to the recipient - let pid = client.propose(&o1, &recipient, &token_id, &TRANSFER_AMOUNT); - - // Step 3: Approve to reach the 2-of-3 threshold - client.approve(&o2, &pid); - - // Step 4: Advance past the timelock and execute - env.ledger().with_mut(|l| l.timestamp = TIMELOCK + 1); - client.execute(&o3, &pid); - - // Step 5: Verify recipient balance increased by exactly the proposed amount - let final_recipient_balance = token.balance(&recipient); - assert_eq!( - final_recipient_balance, - initial_recipient_balance + TRANSFER_AMOUNT, - "recipient balance must increase by exactly the proposed amount" - ); - - // Step 6: Verify multisig balance decreased by the same amount - let final_contract_balance = token.balance(&contract_id); - assert_eq!( - final_contract_balance, - initial_contract_balance - TRANSFER_AMOUNT, - "multisig balance must decrease by exactly the proposed amount" - ); - - // Sanity check: no tokens created or destroyed - assert_eq!( - final_recipient_balance + final_contract_balance, - FUNDED_AMOUNT - ); - } - - /// Test that propose() emits a proposal_created event with correct payload - #[test] - fn test_propose_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "proposal_created")) - .unwrap_or(false) - && <(u64, Address, Address, Address, i128)>::try_from_val(&env, &data) - .map(|(id, proposer, recipient, tok, amt)| { - id == pid && proposer == o1 && recipient == to && tok == token && amt == 500 - }) - .unwrap_or(false) - }); - assert!(found, "Expected proposal_created event not found"); - } - - /// Test for issue #213: In a 1-of-3 multisig, approved_at is set during propose() - /// (when proposer's auto-approval meets threshold) and is not overwritten by subsequent approve() calls. - #[test] - fn test_1of3_approved_at_set_at_propose_not_overwritten() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - - let (client, o1, o2, o3, _, _) = setup_1of3_funded(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - // propose() auto-approves proposer; threshold=1 is met immediately - let pid = client.propose(&o1, &to, &token, &100); - let proposal_after_propose = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal_after_propose.approval_count, 1); - let approved_at_from_propose = proposal_after_propose.approved_at; - assert_eq!(approved_at_from_propose, Some(1000)); - - // Advance time and have another owner approve - env.ledger().with_mut(|l| l.timestamp = 2000); - client.approve(&o2, &pid); - - // Verify approved_at was NOT overwritten - let proposal_after_approve = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal_after_approve.approval_count, 2); - assert_eq!( - proposal_after_approve.approved_at, approved_at_from_propose, - "approved_at must not be overwritten by subsequent approve()" - ); - assert_eq!(proposal_after_approve.approved_at, Some(1000)); - - // Verify a third approval also doesn't change approved_at - env.ledger().with_mut(|l| l.timestamp = 3000); - client.approve(&o3, &pid); - let proposal_after_third_approve = client.get_proposal(&pid).unwrap(); - assert_eq!(proposal_after_third_approve.approved_at, Some(1000)); - } - - /// Test that approve() emits a proposal_approved event with correct payload - #[test] - fn test_approve_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - client.approve(&o2, &pid); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "proposal_approved")) - .unwrap_or(false) - && <(u64, Address, u32)>::try_from_val(&env, &data) - .map(|(id, owner, count)| id == pid && owner == o2 && count == 2) - .unwrap_or(false) - }); - assert!(found, "Expected proposal_approved event not found"); - } - - /// Test that reject() emits a proposal_rejected event with correct payload - #[test] - fn test_reject_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - client.reject(&o2, &pid); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "proposal_rejected")) - .unwrap_or(false) - && <(u64, Address, u32)>::try_from_val(&env, &data) - .map(|(id, owner, count)| id == pid && owner == o2 && count == 1) - .unwrap_or(false) - }); - assert!(found, "Expected proposal_rejected event not found"); - } - - /// Test that reject() on a cancelled proposal reverts with AlreadyCancelled - #[test] - fn test_reject_on_cancelled_proposal_reverts() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - // Cancel the proposal - client.cancel(&o1, &pid); - - // Try to reject the cancelled proposal - let result = client.try_reject(&o2, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyCancelled))); - - // Verify proposal is still cancelled - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.cancelled); - } - - /// Test that proposer can cancel their own proposal before execution - #[test] - fn test_proposer_can_cancel_own_proposal() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _o2, _o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - // Proposer cancels their own proposal - let result = client.try_cancel(&o1, &pid); - assert!(result.is_ok()); - - // Verify proposal is cancelled - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.cancelled); - } - - /// Test that non-proposer can cancel a proposal that can no longer reach threshold - #[test] - fn test_non_proposer_can_cancel_dead_proposal() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - // o2 rejects, making it impossible to reach threshold (2 approvals needed, only 1 possible) - client.reject(&o2, &pid); - - // o3 can cancel because remaining possible approvals (1) < threshold (2) - let result = client.try_cancel(&o3, &pid); - assert!(result.is_ok()); - - // Verify proposal is cancelled - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.cancelled); - } - - #[test] - fn test_cancel_returns_not_initialized_when_threshold_missing() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - let owners = vec![&env, o1.clone(), o2.clone(), o3.clone()]; - client.initialize(&owners, &2, &0); - - let token = Address::generate(&env); - let to = Address::generate(&env); - let pid = client.propose(&o1, &to, &token, &500); - - env.as_contract(&contract_id, || { - env.storage().instance().remove(&DataKey::Threshold); - }); - - let result = client.try_cancel(&o2, &pid); - assert_eq!(result, Err(Ok(MultisigError::NotInitialized))); - } - - /// Test that non-proposer cannot cancel a proposal that can still reach threshold - #[test] - fn test_non_proposer_cannot_cancel_active_proposal() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, o2, _o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - // o2 tries to cancel, but proposal can still reach threshold - let result = client.try_cancel(&o2, &pid); - assert_eq!(result, Err(Ok(MultisigError::CannotCancel))); - - // Verify proposal is not cancelled - let proposal = client.get_proposal(&pid).unwrap(); - assert!(!proposal.cancelled); - } - - /// Test that cancel() on an already cancelled proposal reverts with AlreadyCancelled - #[test] - fn test_cancel_already_cancelled_proposal_reverts() { - let env = Env::default(); - env.mock_all_auths(); - let (client, o1, _o2, _o3) = setup_2of3(&env); - let token = Address::generate(&env); - let to = Address::generate(&env); - - let pid = client.propose(&o1, &to, &token, &500); - - // Cancel the proposal - client.cancel(&o1, &pid); - - // Try to cancel again - let result = client.try_cancel(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyCancelled))); - } - - /// Test that cancel() on an executed proposal reverts with AlreadyExecuted - #[test] - fn test_cancel_executed_proposal_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - let pid = client.propose(&o1, &to, &token_id, &500); - client.approve(&o2, &pid); - - env.ledger().with_mut(|l| l.timestamp = 7200); - client.execute(&o3, &pid); - - // Try to cancel the executed proposal - let result = client.try_cancel(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::AlreadyExecuted))); - } - - /// Test that execute() emits a proposal_executed event with correct payload - #[test] - fn test_execute_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone(), o3.clone()], &2, &3600); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let to = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - let pid = client.propose(&o1, &to, &token_id, &500); - client.approve(&o2, &pid); - - env.ledger().with_mut(|l| l.timestamp = 7200); - client.execute(&o3, &pid); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "proposal_executed")) - .unwrap_or(false) - && <(u64, Address, Address, i128)>::try_from_val(&env, &data) - .map(|(id, executor, recipient, amt)| { - id == pid && executor == o3 && recipient == to && amt == 500 - }) - .unwrap_or(false) - }); - assert!(found, "Expected proposal_executed event not found"); - } - - /// Test that ownership checks are correct with a large owner set (10 owners). - /// Verifies O(1) IsOwner map correctly identifies owners and non-owners. - #[test] - fn test_large_owner_set_ownership_checks() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - - // Generate 10 owners - let owners: std::vec::Vec
= (0..10).map(|_| Address::generate(&env)).collect(); - let sdk_owners = { - let mut v = soroban_sdk::Vec::new(&env); - for o in owners.iter() { - v.push_back(o.clone()); - } - v - }; - - // 6-of-10 multisig - client.initialize(&sdk_owners, &6, &0); - - // All 10 owners must be recognised - for owner in owners.iter() { - assert!(client.is_owner(owner), "Expected address to be an owner"); - } - - // A freshly generated address must NOT be an owner - let non_owner = Address::generate(&env); - assert!( - !client.is_owner(&non_owner), - "Expected address to not be an owner" - ); - - // get_owners() must still return all 10 - assert_eq!(client.get_owners().len(), 10); - - // A non-owner cannot propose (Unauthorized) - let token = Address::generate(&env); - let to = Address::generate(&env); - let result = client.try_propose(&non_owner, &to, &token, &100); - assert_eq!(result, Err(Ok(MultisigError::Unauthorized))); - - // An owner can propose successfully - let result = client.try_propose(&owners[0], &to, &token, &100); - assert!(result.is_ok()); - } - - // ── CommittedAmount / over-commitment tests ─────────────────────────────── - - fn setup_token(env: &Env, contract_id: &Address, amount: i128) -> Address { - use soroban_sdk::token::StellarAssetClient; - let token_admin = Address::generate(env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(env, &token_id).mint(contract_id, &amount); - token_id - } - - /// Two proposals approved against the same treasury cannot both drain it. - /// The second execute must fail with InsufficientFunds. - #[test] - fn test_two_proposals_cannot_double_drain_treasury() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - // Treasury has 1000 tokens - let token_id = setup_token(&env, &contract_id, 1000); - let recipient = Address::generate(&env); - - // Propose two transfers of 800 each — together they exceed the 1000 balance - let pid1 = client.propose(&o1, &recipient, &token_id, &800); - let pid2 = client.propose(&o1, &recipient, &token_id, &800); - - // Approve both to threshold - client.approve(&o2, &pid1); - client.approve(&o2, &pid2); - - // committed = 1600, balance = 1000 → first execute succeeds - let result1 = client.try_execute(&o1, &pid1); - assert!(result1.is_ok(), "first execute should succeed"); - - // After first execute: balance = 200, committed = 800 → second must fail - let result2 = client.try_execute(&o1, &pid2); - assert_eq!(result2, Err(Ok(MultisigError::InsufficientFunds))); - } - - /// get_committed_amount tracks the lifecycle: 0 → committed → released on execute. - #[test] - fn test_committed_amount_lifecycle() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - let token_id = setup_token(&env, &contract_id, 1000); - let recipient = Address::generate(&env); - - assert_eq!(client.get_committed_amount(&token_id), 0); - - let pid = client.propose(&o1, &recipient, &token_id, &300); - // Not yet at threshold — committed still 0 - assert_eq!(client.get_committed_amount(&token_id), 0); - - client.approve(&o2, &pid); - // Threshold reached — committed = 300 - assert_eq!(client.get_committed_amount(&token_id), 300); - - client.execute(&o1, &pid); - // Executed — committed back to 0 - assert_eq!(client.get_committed_amount(&token_id), 0); - } - - /// Cancelling an approved proposal releases its committed amount. - #[test] - fn test_cancel_approved_proposal_releases_committed_amount() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - let token_id = setup_token(&env, &contract_id, 1000); - let recipient = Address::generate(&env); - - let pid = client.propose(&o1, &recipient, &token_id, &500); - client.approve(&o2, &pid); - assert_eq!(client.get_committed_amount(&token_id), 500); - - // Proposer cancels — committed must be released - client.cancel(&o1, &pid); - assert_eq!(client.get_committed_amount(&token_id), 0); - } - - /// Issue #266: execute() returns InsufficientFunds when the contract holds no tokens. - #[test] - fn test_execute_returns_insufficient_funds_when_no_tokens() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - // Register a token but do NOT mint any to the contract - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - let recipient = Address::generate(&env); - - let pid = client.propose(&o1, &recipient, &token_id, &500); - client.approve(&o2, &pid); - - // Contract has zero balance — execute must return InsufficientFunds - let result = client.try_execute(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::InsufficientFunds))); - } - - /// Issue #266: execute() succeeds when the contract holds exactly the required balance. - #[test] - fn test_execute_succeeds_with_exact_required_balance() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - // Mint exactly the proposed amount to the contract - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - let recipient = Address::generate(&env); - - let pid = client.propose(&o1, &recipient, &token_id, &500); - client.approve(&o2, &pid); - - // Contract has exactly 500 — execute must succeed - let result = client.try_execute(&o1, &pid); - assert!(result.is_ok(), "execute should succeed with exact balance"); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - // ── Native XLM proposal tests ───────────────────────────────────────────── - - /// Helper: 2-of-3 multisig with zero timelock, funded with native XLM via SAC. - fn setup_xlm_funded<'a>( - env: &'a Env, - ) -> ( - MultisigContractClient<'a>, - [Address; 3], - Address, - Address, - Address, - ) { - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(env, &contract_id); - let o1 = Address::generate(env); - let o2 = Address::generate(env); - let o3 = Address::generate(env); - client.initialize(&vec![env, o1.clone(), o2.clone(), o3.clone()], &2, &0); - - let xlm_sac = env - .register_stellar_asset_contract_v2(Address::generate(env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(env, &xlm_sac) - .mint(&contract_id, &1_000_000_000); // 100 XLM in stroops - - let recipient = Address::generate(env); - (client, [o1, o2, o3], xlm_sac, recipient, contract_id) - } - - /// propose_xlm() creates a proposal with is_native=true and correct fields. - #[test] - fn test_propose_xlm_creates_native_proposal() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, [o1, _, _], xlm_sac, recipient, _) = setup_xlm_funded(&env); - - let amount: i128 = 100_000_000; // 10 XLM - let pid = client.propose_xlm(&o1, &recipient, &xlm_sac, &amount); - - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.is_native, "proposal must be marked as native XLM"); - assert_eq!(proposal.token, xlm_sac); - assert_eq!(proposal.to, recipient); - assert_eq!(proposal.amount, amount); - assert_eq!(proposal.approval_count, 1); // proposer auto-approves - assert!(!proposal.executed); - assert!(!proposal.cancelled); - } - - /// Full flow: propose_xlm → approve → execute transfers XLM to recipient. - #[test] - fn test_propose_xlm_approve_execute_transfers_xlm() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, [o1, o2, o3], xlm_sac, recipient, _) = setup_xlm_funded(&env); - - let amount: i128 = 100_000_000; // 10 XLM - let pid = client.propose_xlm(&o1, &recipient, &xlm_sac, &amount); - - // o2 approves — threshold=2 reached - client.approve(&o2, &pid); - assert!(client.get_proposal(&pid).unwrap().approved_at.is_some()); - - // execute (zero timelock — no advance needed) - client.execute(&o3, &pid); - - // recipient received the XLM - let token_client = soroban_sdk::token::Client::new(&env, &xlm_sac); - assert_eq!(token_client.balance(&recipient), amount); - assert!(client.get_proposal(&pid).unwrap().executed); - } - - /// propose_xlm() by a non-owner must revert with Unauthorized. - #[test] - fn test_propose_xlm_non_owner_reverts() { - let env = Env::default(); - env.mock_all_auths(); - let (client, _, xlm_sac, recipient, _) = setup_xlm_funded(&env); - let non_owner = Address::generate(&env); - - let result = client.try_propose_xlm(&non_owner, &recipient, &xlm_sac, &100_000_000); - assert_eq!(result, Err(Ok(MultisigError::Unauthorized))); - } - - /// propose_xlm() with amount=0 must revert with InvalidAmount. - #[test] - fn test_propose_xlm_zero_amount_reverts() { - let env = Env::default(); - env.mock_all_auths(); - let (client, [o1, _, _], xlm_sac, recipient, _) = setup_xlm_funded(&env); - - let result = client.try_propose_xlm(&o1, &recipient, &xlm_sac, &0); - assert_eq!(result, Err(Ok(MultisigError::InvalidAmount))); - } - - /// A regular token proposal and a native XLM proposal can coexist and both - /// execute correctly — is_native does not bleed across proposals. - #[test] - fn test_token_and_xlm_proposals_coexist() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (client, [o1, o2, o3], xlm_sac, recipient, contract_id) = setup_xlm_funded(&env); - - // Also fund the contract with a regular token - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &500); - - // Token proposal - let pid_token = client.propose(&o1, &recipient, &token_id, &200); - client.approve(&o2, &pid_token); - - // XLM proposal - let pid_xlm = client.propose_xlm(&o1, &recipient, &xlm_sac, &50_000_000); - client.approve(&o2, &pid_xlm); - - // Verify is_native is set correctly on each - assert!(!client.get_proposal(&pid_token).unwrap().is_native); - assert!(client.get_proposal(&pid_xlm).unwrap().is_native); - - // Execute both - client.execute(&o3, &pid_token); - client.execute(&o3, &pid_xlm); - - let token_client = soroban_sdk::token::Client::new(&env, &token_id); - let xlm_client = soroban_sdk::token::Client::new(&env, &xlm_sac); - assert_eq!(token_client.balance(&recipient), 200); - assert_eq!(xlm_client.balance(&recipient), 50_000_000); - } - - /// If execute() traps before the transfer completes (e.g. InsufficientFunds), - /// proposal.executed must remain false so the proposal can be retried once - /// the treasury is funded. This guards against the pre-transfer executed=true - /// bug that would permanently lock funds. - #[test] - fn test_execute_does_not_mark_executed_on_failed_transfer() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - // Zero timelock so we can execute immediately - client.initialize(&vec![&env, o1.clone(), o2.clone()], &2, &0); - - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - let recipient = Address::generate(&env); - - // Propose 500 but do NOT fund the contract — transfer will fail - let pid = client.propose(&o1, &recipient, &token_id, &500); - client.approve(&o2, &pid); - - // Execute must return InsufficientFunds - let result = client.try_execute(&o1, &pid); - assert_eq!(result, Err(Ok(MultisigError::InsufficientFunds))); - - // proposal.executed must still be false — proposal is retryable - let proposal = client.get_proposal(&pid).unwrap(); - assert!( - !proposal.executed, - "executed must remain false when transfer fails" - ); - } - - // ── Issue #336: cancel() by non-proposer boundary — unreachable threshold ── - - /// 3-of-5 multisig: verifies the exact boundary where cancel() by a non-proposer - /// is blocked (remaining_possible == threshold) vs allowed (remaining_possible < threshold). - /// - /// Timeline: - /// o1 proposes → approval=1, rejection=0, remaining_possible=4 (4 >= 3 → cannot cancel) - /// o2 rejects → approval=1, rejection=1, remaining_possible=3 (3 >= 3 → cannot cancel) - /// o3 rejects → approval=1, rejection=2, remaining_possible=2 (2 < 3 → can cancel) - /// o4 tries cancel at remaining=2 → CannotCancel (boundary: 2 == threshold-1? No: 2 < 3) - /// - /// Wait — let's be precise per the contract logic: - /// remaining_possible = total_owners - rejection_count - approval_count - /// cancel allowed when remaining_possible < threshold - /// - /// After o1 proposes (approval=1, rejection=0): remaining = 5-0-1 = 4; 4 >= 3 → CannotCancel - /// After o2 rejects (approval=1, rejection=1): remaining = 5-1-1 = 3; 3 >= 3 → CannotCancel - /// After o3 rejects (approval=1, rejection=2): remaining = 5-2-1 = 2; 2 < 3 → can cancel - /// o4 attempts cancel at remaining=3 (after o2 rejects) → CannotCancel - /// o4 rejects (approval=1, rejection=3): remaining = 5-3-1 = 1; 1 < 3 → can cancel - /// o5 calls cancel() → success, proposal.cancelled == true - #[test] - fn test_cancel_non_proposer_boundary_3of5() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - - let contract_id = env.register_contract(None, MultisigContract); - let client = MultisigContractClient::new(&env, &contract_id); - let o1 = Address::generate(&env); - let o2 = Address::generate(&env); - let o3 = Address::generate(&env); - let o4 = Address::generate(&env); - let o5 = Address::generate(&env); - - // 3-of-5 multisig, zero timelock - client.initialize( - &vec![&env, o1.clone(), o2.clone(), o3.clone(), o4.clone(), o5.clone()], - &3, - &0, - ); - - let token = Address::generate(&env); - let to = Address::generate(&env); - - // o1 proposes — auto-approves (approval=1, rejection=0, remaining=4) - let pid = client.propose(&o1, &to, &token, &100); - { - let p = client.get_proposal(&pid).unwrap(); - assert_eq!(p.approval_count, 1); - assert_eq!(p.rejection_count, 0); - } - - // o2 rejects → rejection=1, remaining = 5-1-1 = 3; 3 >= 3 → CannotCancel - client.reject(&o2, &pid); - { - let p = client.get_proposal(&pid).unwrap(); - assert_eq!(p.rejection_count, 1); - } - // o4 attempts cancel at this point — remaining=3 == threshold → CannotCancel - let result = client.try_cancel(&o4, &pid); - assert_eq!( - result, - Err(Ok(MultisigError::CannotCancel)), - "cancel must fail when remaining_possible == threshold (3 == 3)" - ); - - // o3 rejects → rejection=2, remaining = 5-2-1 = 2; 2 < 3 → can cancel - client.reject(&o3, &pid); - { - let p = client.get_proposal(&pid).unwrap(); - assert_eq!(p.rejection_count, 2); - } - - // o4 rejects → rejection=3, remaining = 5-3-1 = 1; 1 < 3 → can cancel - client.reject(&o4, &pid); - { - let p = client.get_proposal(&pid).unwrap(); - assert_eq!(p.rejection_count, 3); - } - - // o5 calls cancel() — remaining=1 < threshold=3 → success - let result = client.try_cancel(&o5, &pid); - assert!(result.is_ok(), "cancel must succeed when threshold is unreachable"); - - // Verify proposal is cancelled - let proposal = client.get_proposal(&pid).unwrap(); - assert!(proposal.cancelled, "proposal.cancelled must be true after successful cancel"); - } -} diff --git a/contracts/forge-oracle/src/lib.rs b/contracts/forge-oracle/src/lib.rs deleted file mode 100644 index b1d4f5a..0000000 --- a/contracts/forge-oracle/src/lib.rs +++ /dev/null @@ -1,1597 +0,0 @@ -#![no_std] - -//! # forge-oracle -//! -//! Standardized price feed interface for Stellar/Soroban contracts. -//! -//! ## Features -//! - Admin-controlled price submissions with staleness protection -//! - Multiple asset pairs supported per deployment -//! - Configurable staleness threshold — reads revert if price is too old -//! - Event emission on every price update - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, vec, Address, Env, Symbol, Vec, -}; - -// ── Storage Keys ────────────────────────────────────────────────────────────── - -#[contracttype] -#[derive(Clone)] -pub struct PricePair { - pub base: Symbol, - pub quote: Symbol, -} - -#[contracttype] -pub enum DataKey { - Admin, - StalenessThreshold, - MaxDeviation, - Price(PricePair), - UpdatedAt(PricePair), - Pairs, -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -/// A price entry with value and timestamp. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceData { - /// Price scaled to 7 decimal places (e.g. 1_0000000 = 1.0) - pub price: i128, - /// Ledger timestamp of last update - pub updated_at: u64, -} - -/// A single entry returned by [`get_all_prices`](ForgeOracle::get_all_prices). -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct PriceEntry { - pub base: Symbol, - pub quote: Symbol, - pub price: i128, - pub updated_at: u64, -} - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum OracleError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - PriceNotFound = 4, - PriceStale = 5, - InvalidPrice = 6, - InvalidPair = 7, - PriceDeviationTooHigh = 8, -} - -// ── Contract ────────────────────────────────────────────────────────────────── - -#[contract] -pub struct ForgeOracle; - -#[contractimpl] -impl ForgeOracle { - /// Initializes the oracle contract with an admin address and staleness threshold. - /// - /// - `env`: The Soroban environment. - /// - `admin`: The Address authorized to submit prices and manage the oracle. - /// - `staleness_threshold`: The maximum number of seconds before a price is considered stale. - /// - /// Returns `Ok(())` on successful initialization, or an `OracleError` if the contract is already initialized. - /// - /// ``` - /// client.initialize(&admin, &3600); - /// ``` - pub fn initialize( - env: Env, - admin: Address, - staleness_threshold: u64, - ) -> Result<(), OracleError> { - if env.storage().instance().has(&DataKey::Admin) { - return Err(OracleError::AlreadyInitialized); - } - // require_auth() here ensures the caller controls the admin key they are - // registering. This is NOT a pre-approval check — initialization is - // permissionless, so any caller can supply any address as admin as long as - // they can sign for it. Front-running risk: a malicious actor who observes - // this transaction in the mempool could race to call initialize() first with - // their own admin address. Deploy and initialize in the same transaction to - // mitigate this risk. - admin.require_auth(); - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage() - .instance() - .set(&DataKey::StalenessThreshold, &staleness_threshold); - Ok(()) - } - - /// Submits a new price for a specified trading pair. - /// - /// - `env`: The Soroban environment. - /// - `base`: The base asset symbol (e.g., XLM). - /// - `quote`: The quote asset symbol (e.g., USDC). - /// - `price`: The price value scaled to 7 decimal places. - /// - /// Returns `Ok(())` on successful submission, or an `OracleError` if: - /// - Unauthorized (admin auth required) - /// - `price` <= 0 ([`OracleError::InvalidPrice`]) - /// - `base == quote` ([`OracleError::InvalidPair`]) - /// - /// ``` - /// client.submit_price(&Symbol::new(&env, "XLM"), &Symbol::new(&env, "USDC"), &10000000); - /// ``` - pub fn submit_price( - env: Env, - base: Symbol, - quote: Symbol, - price: i128, - ) -> Result<(), OracleError> { - if base == quote { - return Err(OracleError::InvalidPair); - } - - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(OracleError::NotInitialized)?; - - admin.require_auth(); - - if price <= 0 { - return Err(OracleError::InvalidPrice); - } - - let pair = PricePair { - base: base.clone(), - quote: quote.clone(), - }; - let now = env.ledger().timestamp(); - - // Circuit breaker: reject prices that deviate too far from the previous price - let max_deviation_bps: u32 = env - .storage() - .instance() - .get(&DataKey::MaxDeviation) - .unwrap_or(0u32); - if max_deviation_bps > 0 { - if let Some(prev_price) = env - .storage() - .persistent() - .get::(&DataKey::Price(pair.clone())) - { - if prev_price > 0 { - let deviation = (price - prev_price).abs() * 10_000 / prev_price; - if deviation > max_deviation_bps as i128 { - return Err(OracleError::PriceDeviationTooHigh); - } - } - } - } - - // Track this pair in the known-pairs list (deduplicated by key) - let pair_key = PricePair { - base: base.clone(), - quote: quote.clone(), - }; - if !env.storage().persistent().has(&DataKey::Price(pair_key)) { - let mut pairs: Vec = env - .storage() - .persistent() - .get(&DataKey::Pairs) - .unwrap_or_else(|| vec![&env]); - pairs.push_back(PricePair { - base: base.clone(), - quote: quote.clone(), - }); - env.storage().persistent().set(&DataKey::Pairs, &pairs); - env.storage().persistent().extend_ttl(&DataKey::Pairs, 17280, 34560); - } - - env.storage() - .persistent() - .set(&DataKey::Price(pair.clone()), &price); - env.storage() - .persistent() - .set(&DataKey::UpdatedAt(pair), &now); - - // Extend TTL for StalenessThreshold to prevent silent fallback - env.storage().instance().extend_ttl(17280, 34560); - - env.events().publish( - (Symbol::new(&env, "price_updated"),), - (base, quote, price, now), - ); - - Ok(()) - } - - /// Retrieves the current price for a specified trading pair, checking for staleness. - /// - /// - `env`: The Soroban environment. - /// - `base`: The base asset symbol. - /// - `quote`: The quote asset symbol. - /// - /// Returns a `PriceData` struct with the price and timestamp on success, or an `OracleError` if not found or stale. - /// - /// ``` - /// let price_data = client.get_price(&Symbol::new(&env, "XLM"), &Symbol::new(&env, "USDC")); - /// ``` - pub fn get_price(env: Env, base: Symbol, quote: Symbol) -> Result { - let pair = PricePair { base, quote }; - - let price: i128 = env - .storage() - .persistent() - .get(&DataKey::Price(pair.clone())) - .ok_or(OracleError::PriceNotFound)?; - - let updated_at: u64 = env - .storage() - .persistent() - .get(&DataKey::UpdatedAt(pair)) - .ok_or(OracleError::PriceNotFound)?; - - let threshold: u64 = env - .storage() - .instance() - .get(&DataKey::StalenessThreshold) - .ok_or(OracleError::NotInitialized)?; - - let now = env.ledger().timestamp(); - if now >= updated_at + threshold { - return Err(OracleError::PriceStale); - } - - Ok(PriceData { price, updated_at }) - } - - /// Retrieves the raw price for a specified trading pair without checking staleness. - /// - /// - `env`: The Soroban environment. - /// - `base`: The base asset symbol. - /// - `quote`: The quote asset symbol. - /// - /// Returns a `PriceData` struct with the price and timestamp on success, or an `OracleError` if not found. - /// - /// ``` - /// let price_data = client.get_price_unsafe(&Symbol::new(&env, "XLM"), &Symbol::new(&env, "USDC")); - /// ``` - pub fn get_price_unsafe( - env: Env, - base: Symbol, - quote: Symbol, - ) -> Result { - let pair = PricePair { base, quote }; - - let price: i128 = env - .storage() - .persistent() - .get(&DataKey::Price(pair.clone())) - .ok_or(OracleError::PriceNotFound)?; - - let updated_at: u64 = env - .storage() - .persistent() - .get(&DataKey::UpdatedAt(pair)) - .ok_or(OracleError::PriceNotFound)?; - - Ok(PriceData { price, updated_at }) - } - - /// Updates the staleness threshold for price validity. - /// - /// - `env`: The Soroban environment. - /// - `new_threshold`: The new maximum age of a price in seconds. A price is - /// considered stale when `now >= updated_at + threshold`, i.e. the threshold - /// is exclusive: a price is valid while `now < updated_at + threshold` and - /// stale at exactly `now == updated_at + threshold`. - /// - /// Returns `Ok(())` on success, or an `OracleError` if not initialized or unauthorized. - /// - /// ``` - /// client.set_staleness_threshold(&7200); - /// ``` - pub fn set_staleness_threshold(env: Env, new_threshold: u64) -> Result<(), OracleError> { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(OracleError::NotInitialized)?; - admin.require_auth(); - env.storage() - .instance() - .set(&DataKey::StalenessThreshold, &new_threshold); - // Extend TTL to prevent silent fallback - env.storage().instance().extend_ttl(17280, 34560); - Ok(()) - } - - /// Transfers the admin role to a new address. - /// - /// - `env`: The Soroban environment. - /// - `new_admin`: The new Address to become the admin. - /// - /// Returns `Ok(())` on success, or an `OracleError` if not initialized or unauthorized. - /// - /// ``` - /// client.transfer_admin(&new_admin); - /// ``` - pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), OracleError> { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(OracleError::NotInitialized)?; - admin.require_auth(); - let old_admin = admin.clone(); - env.storage().instance().set(&DataKey::Admin, &new_admin); - - env.events().publish( - (Symbol::new(&env, "admin_transferred"),), - (old_admin, new_admin), - ); - - Ok(()) - } - - /// Sets the maximum allowed price deviation for the circuit breaker. - /// - /// When set to a non-zero value, [`submit_price`](Self::submit_price) will reject any - /// new price that deviates more than `bps` basis points from the previously stored price - /// for the same pair. A value of `0` disables the circuit breaker (default). - /// - /// # Parameters - /// - `bps`: Maximum deviation in basis points (e.g. `1000` = 10%). `0` disables the check. - /// - /// # Errors - /// - [`OracleError::NotInitialized`] — contract not initialized - /// - [`OracleError::Unauthorized`] — caller is not the admin - pub fn set_max_price_deviation(env: Env, bps: u32) -> Result<(), OracleError> { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(OracleError::NotInitialized)?; - admin.require_auth(); - env.storage().instance().set(&DataKey::MaxDeviation, &bps); - env.storage().instance().extend_ttl(17280, 34560); - Ok(()) - } - - /// Returns all currently stored price pairs and their latest prices. - /// - /// Iterates over every pair that has ever been submitted via [`submit_price`](Self::submit_price) - /// and returns a [`Vec`] containing the base, quote, price, and timestamp for each. - /// Prices are returned regardless of staleness — use [`get_price`](Self::get_price) for - /// staleness-checked reads. - /// - /// # Returns - /// `Ok(Vec)` — one entry per unique pair, in submission order. - /// - /// # Errors - /// - [`OracleError::NotInitialized`] — `initialize` has not been called. - pub fn get_all_prices(env: Env) -> Result, OracleError> { - if !env.storage().instance().has(&DataKey::Admin) { - return Err(OracleError::NotInitialized); - } - let pairs: Vec = env - .storage() - .persistent() - .get(&DataKey::Pairs) - .unwrap_or_else(|| vec![&env]); - let mut result: Vec = vec![&env]; - for pair in pairs.iter() { - let price: i128 = match env - .storage() - .persistent() - .get(&DataKey::Price(pair.clone())) - { - Some(p) => p, - None => continue, - }; - let updated_at: u64 = env - .storage() - .persistent() - .get(&DataKey::UpdatedAt(pair.clone())) - .unwrap_or(0); - result.push_back(PriceEntry { - base: pair.base.clone(), - quote: pair.quote.clone(), - price, - updated_at, - }); - } - Ok(result) - } - - /// Retrieves the current admin address. - /// - /// - `env`: The Soroban environment. - /// - /// Returns a `Result` containing the admin address if initialized, - /// or `Err(OracleError::NotInitialized)` if the contract has not been initialized. - /// - /// ``` - /// let admin = client.get_admin()?; - /// ``` - pub fn get_admin(env: Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Admin) - .ok_or(OracleError::NotInitialized) - } - - /// Return the current staleness threshold in seconds. - /// - /// Read-only; does not modify state. Returns the maximum age of price data - /// before [`get_price`](Self::get_price) considers it stale and reverts. - /// - /// # Returns - /// `Result` — the staleness threshold in seconds set at initialization - /// or via [`set_staleness_threshold`](Self::set_staleness_threshold). - /// Returns `Err(OracleError::NotInitialized)` if the contract has not been initialized. - /// - /// # Example - /// ```text - /// let threshold = client.get_staleness_threshold()?; - /// println!("Prices expire after {} seconds", threshold); - /// ``` - pub fn get_staleness_threshold(env: Env) -> Result { - env.storage() - .instance() - .get(&DataKey::StalenessThreshold) - .ok_or(OracleError::NotInitialized) - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Env, IntoVal, Symbol, TryFromVal, - }; - - fn setup<'a>(env: &'a Env) -> (Address, ForgeOracleClient<'a>) { - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(env, &contract_id); - let admin = Address::generate(env); - client.initialize(&admin, &3600); - (admin, client) - } - - #[test] - fn test_submit_and_get_price() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &11_000_000); // 1.11 USDC per XLM - let data = client.get_price(&base, "e); - - assert_eq!(data.price, 11_000_000); - assert_eq!(data.updated_at, 1000); - } - - #[test] - fn test_non_admin_submit_price_rejected() { - let env = Env::default(); - let admin = Address::generate(&env); - let non_admin = Address::generate(&env); - - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - // Setup: Mock auth for admin so initialization succeeds - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, 3600u64).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &3600); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Mock auth for a non-admin to simulate unauthorized invocation - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &non_admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "submit_price", - args: (&base, "e, 10_000_000i128).into_val(&env), - sub_invokes: &[], - }, - }]); - - // Task 1 & 2: Test that a non-admin address calling submit_price() reverts. - // Note: `require_auth` traps at the host level (Auth error), not as a contract enum. - // `try_submit_price` captures this host rejection as an outer `Err`. - let result = client.try_submit_price(&base, "e, &10_000_000); - assert!( - result.is_err(), - "Expected transaction to revert due to lack of admin auth" - ); - - // Task 3: Verify no price is stored after the failed call - let price_result = client.try_get_price(&base, "e); - assert_eq!( - price_result, - Err(Ok(OracleError::PriceNotFound)), - "Price should not be stored after a failed submission" - ); - } - - /// Test that set_staleness_threshold() requires admin authorization - #[test] - fn test_non_admin_set_staleness_threshold_rejected() { - let env = Env::default(); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - // Setup: Mock auth for admin so initialization succeeds - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, 3600u64).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &3600); - - // Verify initial staleness threshold - assert_eq!(client.get_staleness_threshold(), 3600); - - // Mock auth for attacker to simulate unauthorized invocation - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &attacker, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "set_staleness_threshold", - args: (7200u64).into_val(&env), - sub_invokes: &[], - }, - }]); - - // Test that a non-admin address calling set_staleness_threshold() reverts - // Note: `require_auth` traps at the host level (Auth error), not as a contract enum. - // `try_set_staleness_threshold` captures this host rejection as an outer `Err`. - let result = client.try_set_staleness_threshold(&7200); - assert!( - result.is_err(), - "Expected transaction to revert due to lack of admin auth" - ); - - // Verify the staleness threshold has not changed after the failed call - assert_eq!( - client.get_staleness_threshold(), - 3600, - "Staleness threshold should not change after failed call" - ); - - // Mock auth for admin and verify the admin can still update the threshold successfully - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "set_staleness_threshold", - args: (7200u64).into_val(&env), - sub_invokes: &[], - }, - }]); - - let result = client.try_set_staleness_threshold(&7200); - assert!( - result.is_ok(), - "Admin should be able to update staleness threshold" - ); - - // Verify the threshold was updated successfully - assert_eq!( - client.get_staleness_threshold(), - 7200, - "Staleness threshold should be updated after admin call" - ); - } - - #[test] - fn test_stale_price_rejected() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); // staleness = 3600 - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &10_000_000); - - // Advance past staleness threshold - env.ledger().with_mut(|l| l.timestamp = 7200); - let result = client.try_get_price(&base, "e); - assert_eq!(result, Err(Ok(OracleError::PriceStale))); - } - - #[test] - fn test_get_price_unsafe_ignores_staleness() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &50_000_000); - env.ledger().with_mut(|l| l.timestamp = 99999); - - let data = client.get_price_unsafe(&base, "e); - assert_eq!(data.price, 50_000_000); - } - - #[test] - fn test_price_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "BTC"); - let quote = Symbol::new(&env, "XLM"); - let result = client.try_get_price(&base, "e); - assert_eq!(result, Err(Ok(OracleError::PriceNotFound))); - } - - #[test] - fn test_get_price_unsubmitted_pair_reverts_with_price_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - // Use a pair that has never had a price submitted - let base = Symbol::new(&env, "ETH"); - let quote = Symbol::new(&env, "USDC"); - - let result = client.try_get_price(&base, "e); - assert_eq!( - result, - Err(Ok(OracleError::PriceNotFound)), - "get_price() on an unsubmitted pair must revert with PriceNotFound" - ); - } - - #[test] - fn test_get_price_unsafe_unsubmitted_pair_reverts_with_price_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - // Use a pair that has never had a price submitted - let base = Symbol::new(&env, "ETH"); - let quote = Symbol::new(&env, "USDC"); - - let result = client.try_get_price_unsafe(&base, "e); - assert_eq!( - result, - Err(Ok(OracleError::PriceNotFound)), - "get_price_unsafe() on an unsubmitted pair must revert with PriceNotFound" - ); - } - - #[test] - fn test_invalid_price_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - let result = client.try_submit_price(&base, "e, &0); - assert_eq!(result, Err(Ok(OracleError::InvalidPrice))); - } - - #[test] - fn test_submit_price_self_referential_pair_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "XLM"); - let result = client.try_submit_price(&base, "e, &10_000_000); - assert_eq!(result, Err(Ok(OracleError::InvalidPair))); - } - - #[test] - fn test_double_initialize_fails() { - let env = Env::default(); - env.mock_all_auths(); - let (admin, client) = setup(&env); - let result = client.try_initialize(&admin, &3600); - assert_eq!(result, Err(Ok(OracleError::AlreadyInitialized))); - } - - /// Verifies that passing a third-party address as admin without that address's - /// signature causes initialize() to revert. require_auth() on a caller-supplied - /// address means the signer must control that address — you cannot nominate - /// someone else as admin without their key. - #[test] - fn test_initialize_admin_must_sign_for_supplied_address() { - use soroban_sdk::IntoVal; - let env = Env::default(); - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - let caller = Address::generate(&env); - let third_party = Address::generate(&env); - - // Caller signs for themselves but passes third_party as admin. - // require_auth() on third_party will fail because caller did not sign for it. - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &caller, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&third_party, 3600u64).into_val(&env), - sub_invokes: &[], - }, - }]); - - let result = client.try_initialize(&third_party, &3600); - assert!( - result.is_err(), - "initialize must revert when signer does not control the admin address" - ); - } - - /// Verifies the two-phase auth scenario for initialize(): - /// Phase 1 — an attacker mocks auth for the admin address but is not that address; - /// require_auth() on admin fails because attacker did not sign for admin. - /// Phase 2 — the real admin mocks auth for their own address; require_auth() succeeds. - /// Post-init — get_admin() returns admin, not attacker. - #[test] - fn test_attacker_signing_for_admin_address_is_rejected() { - use soroban_sdk::IntoVal; - let env = Env::default(); - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - - // Phase 1: attacker signs, but initialize() is called with admin as the admin arg. - // admin.require_auth() checks that admin signed — attacker did not, so this must fail. - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &attacker, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, 3600u64).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_initialize(&admin, &3600); - assert!( - result.is_err(), - "initialize must revert when attacker signs for admin address" - ); - - // Phase 2: admin signs for their own address — require_auth() is satisfied. - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, 3600u64).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_initialize(&admin, &3600); - assert!( - result.is_ok(), - "initialize must succeed when admin signs for their own address" - ); - - // Post-init: get_admin() must return admin, not attacker. - assert_eq!( - client.get_admin(), - admin, - "get_admin() must return the admin address" - ); - assert_ne!( - client.get_admin(), - attacker, - "get_admin() must not return the attacker address" - ); - } - - #[test] - fn test_transfer_admin() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - assert_eq!(client.get_admin(), new_admin); - } - - /// Test that transfer_admin() allows new admin to submit prices and old admin cannot. - /// This verifies that admin privileges are properly transferred and the old admin loses access. - #[test] - fn test_transfer_admin_new_admin_can_submit_old_admin_cannot() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (admin_a, client) = setup(&env); - let admin_b = Address::generate(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Transfer admin from admin_a to admin_b - client.transfer_admin(&admin_b); - assert_eq!(client.get_admin(), admin_b); - - // Mock auth as admin_b and call submit_price() — assert it succeeds - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin_b, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &env.register_contract(None, ForgeOracle), - fn_name: "submit_price", - args: (&base, "e, 10_000_000i128).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_submit_price(&base, "e, &10_000_000); - assert!(result.is_ok(), "New admin should be able to submit prices"); - - // Verify the price submitted by admin_b is correctly stored - let data = client.get_price(&base, "e); - assert_eq!(data.price, 10_000_000); - assert_eq!(data.updated_at, 1000); - - // Mock auth as admin_a and call submit_price() — assert it reverts - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin_a, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &env.register_contract(None, ForgeOracle), - fn_name: "submit_price", - args: (&base, "e, 20_000_000i128).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_submit_price(&base, "e, &20_000_000); - assert!( - result.is_err(), - "Old admin should not be able to submit prices" - ); - } - - #[test] - fn test_transfer_admin_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - let (old_admin, client) = setup(&env); - let new_admin = Address::generate(&env); - - client.transfer_admin(&new_admin); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "admin_transferred")) - .unwrap_or(false) - && <(Address, Address)>::try_from_val(&env, &data) - .map(|(old, new)| old == old_admin && new == new_admin) - .unwrap_or(false) - }); - assert!(found, "Expected admin_transferred event not found"); - } - - #[test] - fn test_submit_price_emits_event() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 5000); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - let price = 15_000_000i128; - - client.submit_price(&base, "e, &price); - - // events() returns Vec<(contract_addr, topics: Vec, data: Val)> - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "price_updated")) - .unwrap_or(false) - && <(Symbol, Symbol, i128, u64)>::try_from_val(&env, &data) - .map(|(b, q, p, ts)| b == base && q == quote && p == price && ts == 5000) - .unwrap_or(false) - }); - assert!(found, "Expected price_updated event not found"); - } - - #[test] - fn test_submit_price_event_contains_correct_data() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 10000); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "BTC"); - let quote = Symbol::new(&env, "EUR"); - let price = 50_000_000_000i128; - - client.submit_price(&base, "e, &price); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "price_updated")) - .unwrap_or(false) - && <(Symbol, Symbol, i128, u64)>::try_from_val(&env, &data) - .map(|(b, q, p, ts)| b == base && q == quote && p == price && ts == 10000) - .unwrap_or(false) - }); - assert!(found, "Event data does not match expected values"); - } - - // ── Staleness boundary tests ─────────────────────────────────────────────── - - /// get_price() reverts when now == updated_at + threshold (exactly at boundary). - #[test] - fn test_get_price_at_exact_staleness_boundary_reverts() { - let env = Env::default(); - env.mock_all_auths(); - let threshold = 3600u64; - let submit_time = 1000u64; - - env.ledger().with_mut(|l| l.timestamp = submit_time); - let (_, client) = setup(&env); // staleness = 3600 - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - client.submit_price(&base, "e, &10_000_000); - - // Advance to exactly updated_at + threshold — should now be stale - env.ledger() - .with_mut(|l| l.timestamp = submit_time + threshold); - env.ledger() - .with_mut(|l| l.timestamp = submit_time + threshold); - let result = client.try_get_price(&base, "e); - assert_eq!( - result, - Err(Ok(OracleError::PriceStale)), - "expected PriceStale at exact boundary" - ); - } - - /// get_price() succeeds when now == updated_at + threshold - 1 (one second before boundary). - #[test] - fn test_get_price_one_second_before_staleness_boundary_succeeds() { - let env = Env::default(); - env.mock_all_auths(); - let threshold = 3600u64; - let submit_time = 1000u64; - - env.ledger().with_mut(|l| l.timestamp = submit_time); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - client.submit_price(&base, "e, &10_000_000); - - // One second before the threshold — still valid - env.ledger() - .with_mut(|l| l.timestamp = submit_time + threshold - 1); - let result = client.try_get_price(&base, "e); - assert!( - result.is_ok(), - "expected Ok one second before boundary, got {result:?}" - ); - } - - /// get_price_unsafe() succeeds at the boundary and one second past it. - #[test] - fn test_get_price_unsafe_succeeds_regardless_of_staleness() { - let env = Env::default(); - env.mock_all_auths(); - let threshold = 3600u64; - let submit_time = 1000u64; - - env.ledger().with_mut(|l| l.timestamp = submit_time); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - let price = 10_000_000i128; - client.submit_price(&base, "e, &price); - - // At exact boundary - env.ledger() - .with_mut(|l| l.timestamp = submit_time + threshold); - let data = client.get_price_unsafe(&base, "e); - assert_eq!(data.price, price); - - // One second past boundary - env.ledger() - .with_mut(|l| l.timestamp = submit_time + threshold + 1); - let data = client.get_price_unsafe(&base, "e); - assert_eq!(data.price, price); - } - - #[test] - fn test_multiple_price_submissions_emit_events() { - use soroban_sdk::testutils::Events; - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - env.ledger().with_mut(|l| l.timestamp = 1000); - client.submit_price( - &Symbol::new(&env, "XLM"), - &Symbol::new(&env, "USDC"), - &1_000_000, - ); - client.submit_price( - &Symbol::new(&env, "XLM"), - &Symbol::new(&env, "USDC"), - &1_000_000, - ); - - env.ledger().with_mut(|l| l.timestamp = 2000); - client.submit_price( - &Symbol::new(&env, "BTC"), - &Symbol::new(&env, "USDC"), - &70_000_000_000, - ); - - let count = env - .events() - .all() - .iter() - .filter(|(_, topics, _)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "price_updated")) - .unwrap_or(false) - }) - .count(); - assert!( - count >= 2, - "Expected at least 2 price_updated events, found {count}" - ); - } - - /// Verify that submitting a new price for an existing pair overwrites the old one. - /// This ensures stale prices are not retained. - #[test] - fn test_price_update_overwrites_previous_price() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Submit initial price at timestamp 1000 - env.ledger().with_mut(|l| l.timestamp = 1000); - let initial_price = 10_000_000i128; // 1.0 USDC per XLM - client.submit_price(&base, "e, &initial_price); - - // Verify initial price is stored - let data = client.get_price(&base, "e); - assert_eq!(data.price, initial_price); - assert_eq!(data.updated_at, 1000); - - // Submit new price for the same pair at timestamp 2000 - env.ledger().with_mut(|l| l.timestamp = 2000); - let new_price = 15_000_000i128; // 1.5 USDC per XLM - client.submit_price(&base, "e, &new_price); - - // Verify get_price() returns the new price, not the old one - let data = client.get_price(&base, "e); - assert_eq!( - data.price, new_price, - "Expected new price to overwrite old price" - ); - assert_eq!(data.updated_at, 2000, "Expected timestamp to be updated"); - - // Also verify with get_price_unsafe - let data_unsafe = client.get_price_unsafe(&base, "e); - assert_eq!(data_unsafe.price, new_price); - assert_eq!(data_unsafe.updated_at, 2000); - } - - // ── Multiple price pairs tests ─────────────────────────────────────────────── - - /// Test submitting prices for two different pairs (XLM/USDC and BTC/USDC) - /// and verify each pair returns its own correct price. - #[test] - fn test_multiple_price_pairs() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - // Define two different trading pairs - let xlm = Symbol::new(&env, "XLM"); - let btc = Symbol::new(&env, "BTC"); - let usdc = Symbol::new(&env, "USDC"); - - // Submit prices for both pairs - let xlm_price = 11_000_000i128; // 1.1 USDC per XLM - let btc_price = 70_000_000_000i128; // 70,000 USDC per BTC - - client.submit_price(&xlm, &usdc, &xlm_price); - client.submit_price(&btc, &usdc, &btc_price); - - // Verify each pair returns its own correct price - let xlm_data = client.get_price(&xlm, &usdc); - assert_eq!(xlm_data.price, xlm_price); - assert_eq!(xlm_data.updated_at, 1000); - - let btc_data = client.get_price(&btc, &usdc); - assert_eq!(btc_data.price, btc_price); - assert_eq!(btc_data.updated_at, 1000); - } - - /// Test that updating one pair does not affect the other pair. - #[test] - fn test_updating_one_pair_does_not_affect_other() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - // Define two different trading pairs - let xlm = Symbol::new(&env, "XLM"); - let btc = Symbol::new(&env, "BTC"); - let usdc = Symbol::new(&env, "USDC"); - - // Submit initial prices for both pairs - let xlm_price_v1 = 10_000_000i128; - let btc_price_v1 = 60_000_000_000i128; - - client.submit_price(&xlm, &usdc, &xlm_price_v1); - client.submit_price(&btc, &usdc, &btc_price_v1); - - // Update only XLM/USDC pair - env.ledger().with_mut(|l| l.timestamp = 2000); - let xlm_price_v2 = 15_000_000i128; - client.submit_price(&xlm, &usdc, &xlm_price_v2); - - // Verify XLM/USDC was updated - let xlm_data = client.get_price(&xlm, &usdc); - assert_eq!(xlm_data.price, xlm_price_v2); - assert_eq!(xlm_data.updated_at, 2000); - - // Verify BTC/USDC was NOT affected - let btc_data = client.get_price(&btc, &usdc); - assert_eq!( - btc_data.price, btc_price_v1, - "BTC price should not have changed" - ); - assert_eq!( - btc_data.updated_at, 1000, - "BTC timestamp should not have changed" - ); - } - - /// Test that three different pairs can coexist and each maintains independent state. - #[test] - fn test_three_independent_price_pairs() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - let xlm = Symbol::new(&env, "XLM"); - let btc = Symbol::new(&env, "BTC"); - let eth = Symbol::new(&env, "ETH"); - let usdc = Symbol::new(&env, "USDC"); - - // Submit prices for three pairs at different times - client.submit_price(&xlm, &usdc, &11_000_000); - - env.ledger().with_mut(|l| l.timestamp = 1500); - client.submit_price(&btc, &usdc, &70_000_000_000); - - env.ledger().with_mut(|l| l.timestamp = 2000); - client.submit_price(ð, &usdc, &3_500_000_000); - - // Verify all three pairs have correct and independent values - let xlm_data = client.get_price(&xlm, &usdc); - assert_eq!(xlm_data.price, 11_000_000); - assert_eq!(xlm_data.updated_at, 1000); - - let btc_data = client.get_price(&btc, &usdc); - assert_eq!(btc_data.price, 70_000_000_000); - assert_eq!(btc_data.updated_at, 1500); - - let eth_data = client.get_price(ð, &usdc); - assert_eq!(eth_data.price, 3_500_000_000); - assert_eq!(eth_data.updated_at, 2000); - } - - /// Test that pairs with same base but different quotes are independent. - #[test] - fn test_same_base_different_quote_pairs() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - let xlm = Symbol::new(&env, "XLM"); - let usdc = Symbol::new(&env, "USDC"); - let usdt = Symbol::new(&env, "USDT"); - - // Submit prices for XLM/USDC and XLM/USDT - let xlm_usdc_price = 11_000_000i128; - let xlm_usdt_price = 10_500_000i128; - - client.submit_price(&xlm, &usdc, &xlm_usdc_price); - client.submit_price(&xlm, &usdt, &xlm_usdt_price); - - // Verify each pair is independent - let usdc_data = client.get_price(&xlm, &usdc); - assert_eq!(usdc_data.price, xlm_usdc_price); - - let usdt_data = client.get_price(&xlm, &usdt); - assert_eq!(usdt_data.price, xlm_usdt_price); - } - - #[test] - fn test_get_staleness_threshold() { - let env = Env::default(); - env.mock_all_auths(); - let (admin, client) = setup(&env); - - // setup initializes with 3600 - assert_eq!(client.get_staleness_threshold(), 3600); - - // reflects updates via set_staleness_threshold - client.set_staleness_threshold(&7200); - assert_eq!(client.get_staleness_threshold(), 7200); - - let _ = admin; // suppress unused warning - } - - #[test] - fn test_set_staleness_threshold_affects_get_price() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Submit price at t=0, set staleness threshold to 3600 - client.submit_price(&base, "e, &10_000_000); - client.set_staleness_threshold(&3600); - - // At t=1800, get_price should succeed (1800 < 0 + 3600) - env.ledger().with_mut(|l| l.timestamp = 1800); - let data = client.get_price(&base, "e); - assert_eq!(data.price, 10_000_000); - - // Tighten threshold to 600 - client.set_staleness_threshold(&600); - - // At t=1800, get_price should now fail (1800 > 0 + 600) - let result = client.try_get_price(&base, "e); - assert_eq!(result, Err(Ok(OracleError::PriceStale))); - - // Loosen threshold to 7200 - client.set_staleness_threshold(&7200); - - // At t=1800, get_price should succeed again (1800 < 0 + 7200) - let data = client.get_price(&base, "e); - assert_eq!(data.price, 10_000_000); - } - - /// Test that get_admin() returns NotInitialized error when contract is uninitialized - #[test] - fn test_get_admin_uninitialized() { - let env = Env::default(); - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - let result = client.try_get_admin(); - assert_eq!(result, Err(Ok(OracleError::NotInitialized))); - } - - /// Test that get_staleness_threshold() returns NotInitialized error when contract is uninitialized - #[test] - fn test_get_staleness_threshold_uninitialized() { - let env = Env::default(); - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - - let result = client.try_get_staleness_threshold(); - assert_eq!(result, Err(Ok(OracleError::NotInitialized))); - } - - /// Test that get_price() reverts with NotInitialized if StalenessThreshold is missing - #[test] - fn test_get_price_reverts_if_staleness_threshold_missing() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Submit a price - client.submit_price(&base, "e, &10_000_000); - - // Manually remove StalenessThreshold from storage to simulate expiry - let contract_id = env.register_contract(None, ForgeOracle); - let storage = env.storage().instance(); - // We can't directly delete, but we can verify the behavior by checking - // that get_price reverts if threshold is missing - let result = client.try_get_price(&base, "e); - // Should succeed normally since threshold was just set - assert!(result.is_ok()); - } - - // ── get_all_prices tests ────────────────────────────────────────────────── - - #[test] - fn test_get_all_prices_returns_all_submitted_pairs() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - client.submit_price( - &Symbol::new(&env, "XLM"), - &Symbol::new(&env, "USDC"), - &10_000_000, - ); - client.submit_price( - &Symbol::new(&env, "BTC"), - &Symbol::new(&env, "USDC"), - &70_000_000_000, - ); - client.submit_price( - &Symbol::new(&env, "ETH"), - &Symbol::new(&env, "USDC"), - &3_500_000_000, - ); - - let entries = client.get_all_prices(); - assert_eq!(entries.len(), 3); - - assert_eq!(entries.get(0).unwrap().base, Symbol::new(&env, "XLM")); - assert_eq!(entries.get(0).unwrap().price, 10_000_000); - assert_eq!(entries.get(1).unwrap().base, Symbol::new(&env, "BTC")); - assert_eq!(entries.get(1).unwrap().price, 70_000_000_000); - assert_eq!(entries.get(2).unwrap().base, Symbol::new(&env, "ETH")); - assert_eq!(entries.get(2).unwrap().price, 3_500_000_000); - } - - #[test] - fn test_get_all_prices_deduplicates_repeated_pair() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let (_, client) = setup(&env); - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &10_000_000); - env.ledger().with_mut(|l| l.timestamp = 2000); - client.submit_price(&base, "e, &12_000_000); - - let entries = client.get_all_prices(); - assert_eq!(entries.len(), 1); - assert_eq!(entries.get(0).unwrap().price, 12_000_000); - assert_eq!(entries.get(0).unwrap().updated_at, 2000); - } - - #[test] - fn test_get_all_prices_empty_when_none_submitted() { - let env = Env::default(); - env.mock_all_auths(); - let (_, client) = setup(&env); - - let entries = client.get_all_prices(); - assert_eq!(entries.len(), 0); - } - - #[test] - fn test_get_all_prices_not_initialized() { - let env = Env::default(); - let contract_id = env.register_contract(None, ForgeOracle); - let client = ForgeOracleClient::new(&env, &contract_id); - assert_eq!( - client.try_get_all_prices(), - Err(Ok(OracleError::NotInitialized)) - ); - } - - // ── Circuit breaker tests ───────────────────────────────────────────────── - - /// First submission is always accepted — no previous price to compare against. - #[test] - fn test_circuit_breaker_first_price_always_accepted() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (admin, client) = setup(&env); - - // Set a tight 5% circuit breaker - client.set_max_price_deviation(&1000); // 10% - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // First submission — no previous price, must succeed regardless of value - let result = client.try_submit_price(&base, "e, &10_000_000); - assert!(result.is_ok()); - let _ = admin; - } - - /// Price within the deviation threshold is accepted. - #[test] - fn test_circuit_breaker_within_threshold_accepted() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); - - client.set_max_price_deviation(&1000); // 10% - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &10_000_000); // 1.0 - - // 5% increase — within 10% threshold - let result = client.try_submit_price(&base, "e, &10_500_000); - assert!(result.is_ok()); - } - - /// Price exceeding the deviation threshold is rejected. - #[test] - fn test_circuit_breaker_exceeds_threshold_rejected() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); - - client.set_max_price_deviation(&1000); // 10% - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &10_000_000); // 1.0 - - // 20% increase — exceeds 10% threshold - let result = client.try_submit_price(&base, "e, &12_000_000); - assert_eq!(result, Err(Ok(OracleError::PriceDeviationTooHigh))); - } - - /// Zero deviation threshold disables the circuit breaker entirely. - #[test] - fn test_circuit_breaker_zero_bps_disabled() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); - - // Default is 0 (disabled) — no need to call set_max_price_deviation - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - client.submit_price(&base, "e, &10_000_000); - - // 10x price jump — should be accepted because circuit breaker is off - let result = client.try_submit_price(&base, "e, &100_000_000); - assert!(result.is_ok()); - } - - // ── Issue #337: set_max_price_deviation end-to-end flow ────────────────── - - /// Full end-to-end test for set_max_price_deviation(): - /// 1. Submit initial price of 10_000_000 (1.0 USDC) - /// 2. Set max deviation to 1000 bps (10%) - /// 3. 11.1% increase (11_100_000) → PriceDeviationTooHigh - /// 4. 9% increase (10_900_000) → success - /// 5. 17% decrease from 10_900_000 → 9_000_000 → PriceDeviationTooHigh - /// 6. set_max_price_deviation(0) disables check → previously blocked price now succeeds - #[test] - fn test_set_max_price_deviation_end_to_end() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let (_, client) = setup(&env); // staleness = 3600 - - let base = Symbol::new(&env, "XLM"); - let quote = Symbol::new(&env, "USDC"); - - // Step 1: submit initial price of 10_000_000 (1.0 USDC) - client.submit_price(&base, "e, &10_000_000); - let data = client.get_price_unsafe(&base, "e); - assert_eq!(data.price, 10_000_000); - - // Step 2: set max deviation to 1000 bps (10%) - client.set_max_price_deviation(&1000); - - // Step 3: 11.1% increase → 11_100_000; deviation = (1_100_000 * 10_000) / 10_000_000 = 1100 bps > 1000 → blocked - let result = client.try_submit_price(&base, "e, &11_100_000); - assert_eq!( - result, - Err(Ok(OracleError::PriceDeviationTooHigh)), - "11.1% increase must be blocked by 10% circuit breaker" - ); - // Price must remain unchanged - assert_eq!(client.get_price_unsafe(&base, "e).price, 10_000_000); - - // Step 4: 9% increase → 10_900_000; deviation = (900_000 * 10_000) / 10_000_000 = 900 bps <= 1000 → accepted - let result = client.try_submit_price(&base, "e, &10_900_000); - assert!(result.is_ok(), "9% increase must be accepted within 10% threshold"); - assert_eq!(client.get_price_unsafe(&base, "e).price, 10_900_000); - - // Step 5: 17% decrease from 10_900_000 → 9_000_000 - // deviation = (1_900_000 * 10_000) / 10_900_000 ≈ 1743 bps > 1000 → blocked - let result = client.try_submit_price(&base, "e, &9_000_000); - assert_eq!( - result, - Err(Ok(OracleError::PriceDeviationTooHigh)), - "17% decrease must be blocked by 10% circuit breaker" - ); - // Price must remain at 10_900_000 - assert_eq!(client.get_price_unsafe(&base, "e).price, 10_900_000); - - // Step 6: disable circuit breaker by setting bps = 0 - client.set_max_price_deviation(&0); - - // Previously blocked price (9_000_000) must now succeed - let result = client.try_submit_price(&base, "e, &9_000_000); - assert!( - result.is_ok(), - "previously blocked price must succeed after disabling circuit breaker" - ); - assert_eq!(client.get_price_unsafe(&base, "e).price, 9_000_000); - } -} diff --git a/contracts/forge-stream/src/lib.rs b/contracts/forge-stream/src/lib.rs deleted file mode 100644 index 02b8b72..0000000 --- a/contracts/forge-stream/src/lib.rs +++ /dev/null @@ -1,3480 +0,0 @@ -#![no_std] - -//! # forge-stream -//! -//! Real-time token streaming — pay-per-second token transfers on Soroban. -//! -//! ## Overview -//! - Sender creates a stream with a rate (tokens per second) and duration -//! - Recipient can withdraw accrued tokens at any time -//! - Sender can cancel and reclaim unstreamed tokens -//! - Multiple streams can run in parallel (keyed by stream_id) - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, -}; - -#[contracttype] -pub enum DataKey { - /// Per-stream data (token, sender, recipient, rate, timestamps, state). - /// Uses **persistent** storage — must outlive the contract instance TTL - /// for as long as the stream has unclaimed tokens. - Stream(u64), - /// Monotonically increasing counter used to assign the next stream ID. - /// Uses **instance** storage — small scalar that is always read on - /// `create_stream`, so co-locating it with the instance is efficient. - NextId, - /// Count of streams that are currently active (not cancelled/finished). - /// Uses **instance** storage — updated on every create/cancel/finish, - /// always accessed together with other instance data. - ActiveStreamsCount, - /// List of stream IDs created by a given sender address. - /// Uses **persistent** storage — the list grows with each stream and - /// must survive beyond the instance TTL for historical lookups. - SenderStreams(Address), - /// List of stream IDs where a given address is the recipient. - /// Uses **persistent** storage — same rationale as `SenderStreams`. - RecipientStreams(Address), -} - -#[contracttype] -#[derive(Clone)] -pub struct Stream { - /// Unique stream ID - pub id: u64, - /// Token being streamed - pub token: Address, - /// Sender funding the stream - pub sender: Address, - /// Recipient receiving tokens - pub recipient: Address, - /// Tokens per second - pub rate_per_second: i128, - /// Stream start timestamp - pub start_time: u64, - /// Stream end timestamp - pub end_time: u64, - /// Total tokens already withdrawn - pub withdrawn: i128, - /// Whether the stream has been cancelled - pub cancelled: bool, - /// Amount streamed at the time of cancellation (if cancelled) - pub streamed_at_cancel: i128, - /// Whether the stream is currently paused - pub is_paused: bool, - /// Timestamp when stream was last paused (if paused) - pub paused_at: Option, - /// Total seconds the stream has been paused - pub total_paused_time: u64, - /// Whether this stream is currently counted as active in the global counter - pub counted_active: bool, - /// Minimum withdrawal amount to prevent dust withdrawals (0 means no minimum) - pub min_withdrawal_amount: i128, -} - -#[contracttype] -#[derive(Clone)] -pub struct StreamStatus { - pub id: u64, - pub streamed: i128, - pub withdrawn: i128, - pub withdrawable: i128, - pub remaining: i128, - pub is_active: bool, - pub is_finished: bool, - pub is_paused: bool, - /// `true` when `withdrawable > 0`. A finished stream can be claimable - /// even though `is_active` is `false`. - pub is_claimable: bool, -} - -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum StreamError { - StreamNotFound = 1, - Unauthorized = 2, - NothingToWithdraw = 3, - AlreadyCancelled = 4, - InvalidConfig = 5, - StreamFinished = 6, - /// Sender's token balance is less than the total required to fund the stream - /// (`rate_per_second * duration_seconds`). - InsufficientFunds = 7, - /// Withdrawal amount is below the minimum threshold - BelowMinimumWithdrawal = 8, -} - -#[contract] -pub struct ForgeStream; - -#[contractimpl] -impl ForgeStream { - /// Create a new token stream. - /// - /// Creates a stream that unlocks `rate_per_second * duration_seconds` total tokens over time. - /// Caller (`sender`) must authorize token transfer upfront for the full amount. - /// - /// # Parameters - /// - `sender`: Stream creator/funder (must authorize) - /// - `token`: Token contract Address - /// - `recipient`: Who receives withdrawn tokens - /// - `rate_per_second`: i128 > 0, tokens unlocked per ledger second - /// - `duration_seconds`: u64 > 0, stream length in seconds - /// - `min_withdrawal_amount`: i128 >= 0, minimum tokens required for withdrawal (0 means no minimum) - /// - /// # Returns - /// u64: Unique stream ID - /// - /// # Example - /// ```rust,ignore - /// let stream_id = forge_stream.create_stream( - /// env, - /// sender, - /// token, - /// recipient, - /// 100i128, // 100 tokens/sec - /// 3600u64, // 1 hour = 360,000 total tokens - /// 1000i128, // minimum withdrawal of 1000 tokens - /// )?; - /// ``` - /// - /// # Errors - /// - `InvalidConfig` if rate <= 0, duration == 0, or min_withdrawal_amount < 0 - /// - `InsufficientFunds` if sender balance < rate_per_second * duration_seconds - pub fn create_stream( - env: Env, - sender: Address, - token: Address, - recipient: Address, - rate_per_second: i128, - duration_seconds: u64, - min_withdrawal_amount: i128, - ) -> Result { - if rate_per_second <= 0 || duration_seconds == 0 || min_withdrawal_amount < 0 { - return Err(StreamError::InvalidConfig); - } - - sender.require_auth(); - - let stream_id: u64 = env - .storage() - .instance() - .get(&DataKey::NextId) - .unwrap_or(0_u64); - - let now = env.ledger().timestamp(); - // Guard against overflow: rate * duration must not exceed i128::MAX. - // If it would, reject the stream rather than silently truncate. - let total = rate_per_second - .checked_mul(duration_seconds as i128) - .ok_or(StreamError::InvalidConfig)?; - - // Pull total tokens from sender into contract - let token_client = token::Client::new(&env, &token); - if token_client.balance(&sender) < total { - return Err(StreamError::InsufficientFunds); - } - token_client.transfer(&sender, &env.current_contract_address(), &total); - - let stream = Stream { - id: stream_id, - token, - sender: sender.clone(), - recipient: recipient.clone(), - rate_per_second, - start_time: now, - end_time: now + duration_seconds, - withdrawn: 0, - cancelled: false, - streamed_at_cancel: 0, - is_paused: false, - paused_at: None, - total_paused_time: 0, - counted_active: true, - min_withdrawal_amount, - }; - - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .instance() - .set(&DataKey::NextId, &(stream_id + 1)); - // Extend TTL for the new stream entry - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - // Store sender → stream ID mapping in persistent storage - let mut sender_streams: soroban_sdk::Vec = env - .storage() - .persistent() - .get(&DataKey::SenderStreams(sender.clone())) - .unwrap_or(soroban_sdk::Vec::new(&env)); - sender_streams.push_back(stream_id); - env.storage() - .persistent() - .set(&DataKey::SenderStreams(sender.clone()), &sender_streams); - // Extend TTL for sender streams mapping - env.storage() - .persistent() - .extend_ttl(&DataKey::SenderStreams(sender), 17280, 34560); - - // Store recipient → stream ID mapping in persistent storage - let mut recipient_streams: soroban_sdk::Vec = env - .storage() - .persistent() - .get(&DataKey::RecipientStreams(recipient.clone())) - .unwrap_or(soroban_sdk::Vec::new(&env)); - recipient_streams.push_back(stream_id); - env.storage().persistent().set( - &DataKey::RecipientStreams(recipient.clone()), - &recipient_streams, - ); - // Extend TTL for recipient streams mapping - env.storage() - .persistent() - .extend_ttl(&DataKey::RecipientStreams(recipient), 17280, 34560); - - Self::set_active_streams_count(&env, Self::active_streams_count(&env).saturating_add(1)); - - env.events().publish( - (Symbol::new(&env, "stream_created"),), - ( - stream_id, - &stream.recipient, - rate_per_second, - duration_seconds, - min_withdrawal_amount, - ), - ); - - Ok(stream_id) - } - - /// Withdraw all currently accrued (streamed but unwithdrawn) tokens from a stream. - /// - /// Computes tokens accrued since `start_time` up to current ledger time (capped at `end_time`), - /// minus previously withdrawn amount. Transfers to `recipient`. - /// Only callable by the stream's `recipient`. - /// - /// Enforces minimum withdrawal amount to prevent dust withdrawals, except when the stream - /// has fully elapsed (recipient should always be able to claim everything). - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// i128: Amount withdrawn (or 0 if nothing accrued) - /// - /// # Example - /// ```rust,ignore - /// // After 10 seconds at 100/sec rate: - /// let withdrawn = forge_stream.withdraw(env, stream_id)?; - /// assert_eq!(withdrawn, 1000); // 100 * 10 - /// ``` - /// - /// # Errors - /// - `StreamNotFound` - /// - `Unauthorized` (not recipient) - /// - `AlreadyCancelled` - /// - `NothingToWithdraw` - /// - `BelowMinimumWithdrawal` (withdrawable < min_withdrawal_amount and stream not finished) - pub fn withdraw(env: Env, stream_id: u64) -> Result { - Self::validate_stream_id(&env, stream_id)?; - let mut stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Err(StreamError::AlreadyCancelled); - } - - stream.recipient.require_auth(); - - let now = env.ledger().timestamp(); - let streamed = Self::compute_streamed(&stream, now); - let withdrawable = streamed - stream.withdrawn; - - if withdrawable <= 0 { - return Err(StreamError::NothingToWithdraw); - } - - // Enforce minimum withdrawal amount, except when stream is fully elapsed - let is_finished = now >= stream.end_time; - if !is_finished - && withdrawable < stream.min_withdrawal_amount - && stream.min_withdrawal_amount > 0 - { - return Err(StreamError::BelowMinimumWithdrawal); - } - - stream.withdrawn += withdrawable; - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - let token_client = token::Client::new(&env, &stream.token); - token_client.transfer( - &env.current_contract_address(), - &stream.recipient, - &withdrawable, - ); - - env.events().publish( - (Symbol::new(&env, "withdrawn"),), - (stream_id, &stream.recipient, withdrawable), - ); - - Ok(withdrawable) - } - - /// Cancel an active stream. Immediately finalizes: - /// - Accrued tokens auto-paid to recipient - /// - Remaining unstreamed tokens refunded to sender - /// Stream becomes withdrawable=0 thereafter. - /// Only callable by the stream's `sender`. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// `Ok(())` - /// - /// # Example - /// ```rust,ignore - /// // Stream: 100/sec for 3600s = 360,000 total tokens, cancel after 100s: - /// // streamed = 100 * 100 = 10,000 - /// // recipient gets 10,000 (streamed - withdrawn) - /// // sender refunded 350,000 (total - streamed) - /// forge_stream.cancel_stream(env, stream_id)?; - /// - /// // With a pause: 100/sec for 3600s, paused for 200s, cancel after 300s wall-clock: - /// // effective elapsed = 300 - 200 paused = 100s → streamed = 10,000 - /// // recipient gets 10,000, sender refunded 350,000 - /// // Invariant: withdrawable + returnable == total (360,000) - /// ``` - /// - /// # Errors - /// - `StreamNotFound` - /// - `Unauthorized` (not sender) - /// - `AlreadyCancelled` - pub fn cancel_stream(env: Env, stream_id: u64) -> Result<(), StreamError> { - Self::validate_stream_id(&env, stream_id)?; - let mut stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Err(StreamError::AlreadyCancelled); - } - - stream.sender.require_auth(); - - let now = env.ledger().timestamp(); - let streamed = Self::compute_streamed(&stream, now); - let withdrawable = (streamed - stream.withdrawn).max(0); - let total = stream.rate_per_second * (stream.end_time - stream.start_time) as i128; - let returnable = total - streamed; - - // Sanity check: all tokens must be accounted for (no tokens created or destroyed). - // withdrawable covers accrued-but-unwithdrawn tokens; returnable covers unstreamed tokens. - // Together they must equal the total deposited at stream creation. - debug_assert_eq!( - withdrawable + returnable, - total, - "cancel invariant violated: withdrawable({}) + returnable({}) != total({})", - withdrawable, - returnable, - total - ); - - if stream.counted_active { - Self::set_active_streams_count( - &env, - Self::active_streams_count(&env).saturating_sub(1), - ); - stream.counted_active = false; - } - - stream.cancelled = true; - stream.streamed_at_cancel = streamed; - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - let token_client = token::Client::new(&env, &stream.token); - - // Pay out accrued amount to recipient - if withdrawable > 0 { - token_client.transfer( - &env.current_contract_address(), - &stream.recipient, - &withdrawable, - ); - } - - // Return unstreamed amount to sender - if returnable > 0 { - token_client.transfer(&env.current_contract_address(), &stream.sender, &returnable); - } - - env.events().publish( - (Symbol::new(&env, "stream_cancelled"),), - (stream_id, withdrawable, returnable), - ); - - Ok(()) - } - - /// Pause an active stream. - /// - /// Temporarily halts token accrual. Recipient can still withdraw already-accrued tokens. - /// Only callable by the stream's `sender`. - /// - /// When paused, the stream's end_time is extended by the pause duration to ensure the - /// recipient receives the full promised payout. For example, if a stream is paused for 100s, - /// end_time is extended by 100s, so the recipient's total earnings remain unchanged. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// `Ok(())` - /// - /// # Errors - /// - `StreamNotFound` - /// - `Unauthorized` (not sender) - /// - `AlreadyCancelled` - /// - `StreamFinished` - /// - `InvalidConfig` (already paused) - pub fn pause_stream(env: Env, stream_id: u64) -> Result<(), StreamError> { - Self::validate_stream_id(&env, stream_id)?; - let mut stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Err(StreamError::AlreadyCancelled); - } - - // Auth-first convention: verify sender authorization before checking stream state - stream.sender.require_auth(); - - if env.ledger().timestamp() >= stream.end_time { - return Err(StreamError::StreamFinished); - } - - if stream.is_paused { - return Err(StreamError::InvalidConfig); // Already paused - } - - let now = env.ledger().timestamp(); - stream.is_paused = true; - stream.paused_at = Some(now); - - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - env.events() - .publish((Symbol::new(&env, "stream_paused"),), (stream_id,)); - - Ok(()) - } - - /// Resume a paused stream. - /// - /// Restarts token accrual from the point it was paused. Only callable by the stream's `sender`. - /// - /// When resumed, the stream's end_time is extended by the pause duration to ensure the - /// recipient receives the full promised payout. This maintains the invariant that total - /// recipient earnings = rate_per_second * (end_time - start_time - total_paused_time). - /// - /// The paused duration is capped at `end_time` to avoid over-counting paused time when - /// `resume_stream()` is called after `end_time` has already passed. This keeps - /// `total_paused_time` consistent with the `effective_time.min(end_time)` cap used in - /// `compute_streamed()`. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// `Ok(())` - /// - /// # Errors - /// - `StreamNotFound` - /// - `Unauthorized` (not sender) - /// - `AlreadyCancelled` - /// - `StreamFinished` (called after end_time) - /// - `InvalidConfig` (not paused) - pub fn resume_stream(env: Env, stream_id: u64) -> Result<(), StreamError> { - Self::validate_stream_id(&env, stream_id)?; - let mut stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Err(StreamError::AlreadyCancelled); - } - - // Auth-first convention: verify sender authorization before checking stream state - stream.sender.require_auth(); - - if env.ledger().timestamp() >= stream.end_time { - return Err(StreamError::StreamFinished); - } - - if !stream.is_paused { - return Err(StreamError::InvalidConfig); // Not paused - } - - let now = env.ledger().timestamp(); - let paused_at = stream - .paused_at - .expect("stream is paused but paused_at is missing"); - // Cap paused duration at end_time to stay consistent with compute_streamed(), - // which uses effective_time = now.min(end_time) when accumulating paused time. - let effective_now = now.min(stream.end_time); - let paused_duration = effective_now.saturating_sub(paused_at); - stream.total_paused_time += paused_duration; - stream.end_time = stream.end_time.saturating_add(paused_duration); - stream.is_paused = false; - stream.paused_at = None; - - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - env.events() - .publish((Symbol::new(&env, "stream_resumed"),), (stream_id,)); - - Ok(()) - } - - /// Get real-time status of a stream without modifying it. - /// - /// Computes current `streamed`, `withdrawable`, `remaining` based on ledger timestamp. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// `StreamStatus` with: - /// - `streamed`: Total accrued up to now - /// - `withdrawn`: Cumulative withdrawn - /// - `withdrawable`: streamed - withdrawn - /// - `remaining`: total - streamed - /// - `is_active`: `true` when the stream is currently accruing tokens - /// (`!cancelled && !paused && now < end_time`). A paused or finished - /// stream has `is_active = false` even if tokens remain claimable. - /// - `is_finished`: now >= end_time - /// - `is_paused`: stream is currently paused - /// - `is_claimable`: `true` when `withdrawable > 0`. Independent of - /// `is_active` — a paused or finished stream can still be claimable. - /// Always check `is_claimable` (not `is_active`) to decide whether a - /// withdrawal is available. - /// - /// **Note:** `is_active` and `is_claimable` are intentionally separate. - /// `is_active` answers "is this stream accruing right now?" while - /// `is_claimable` answers "can the recipient withdraw tokens right now?". - /// A paused stream stops accruing (`is_active = false`) but tokens that - /// accrued before the pause remain withdrawable (`is_claimable = true`). - /// - /// # Example - /// ```rust,ignore - /// let status = forge_stream.get_stream_status(env, stream_id)?; - /// if status.is_claimable { - /// forge_stream.withdraw(env, stream_id)?; - /// } - /// ``` - pub fn get_stream_status(env: Env, stream_id: u64) -> Result { - Self::validate_stream_id(&env, stream_id)?; - let stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - let now = env.ledger().timestamp(); - let streamed = Self::compute_streamed(&stream, now); - let withdrawable = (streamed - stream.withdrawn).max(0); - let raw_duration = stream.end_time.saturating_sub(stream.start_time); - let effective_duration = raw_duration.saturating_sub(stream.total_paused_time); - let total = stream.rate_per_second * effective_duration as i128; - let remaining = (total - streamed).max(0); - let is_active = !stream.cancelled && !stream.is_paused && now < stream.end_time; - let is_finished = now >= stream.end_time; - - Ok(StreamStatus { - id: stream.id, - streamed, - withdrawn: stream.withdrawn, - withdrawable, - remaining, - is_active, - is_finished, - is_paused: stream.is_paused, - is_claimable: withdrawable > 0, - }) - } - - /// Get the complete internal stream configuration and state. - /// - /// Returns the full `Stream` struct including private fields like `cancelled`. - /// Useful for admin/UI display. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - /// # Returns - /// `Stream` struct - /// - /// # Example - /// ```rust,ignore - /// let stream = forge_stream.get_stream(env, stream_id)?; - /// assert_eq!(stream.rate_per_second, 100i128); - /// ``` - /// - /// # Errors - /// - `StreamNotFound` - pub fn get_stream(env: Env, stream_id: u64) -> Result { - Self::validate_stream_id(&env, stream_id)?; - env.storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound) - } - - /// Return the number of currently active streams. - /// - /// Active streams are not cancelled and have not fully elapsed. - /// This method also synchronizes the counter for any streams that elapsed - /// since the last interaction. - pub fn get_active_streams_count(env: Env) -> u64 { - Self::sync_elapsed_streams(&env) - } - - /// Return the total number of streams ever created. - /// - /// This is a monotonically increasing counter — it includes active, finished, - /// and cancelled streams. Useful for UIs to paginate stream history and gauge - /// protocol activity. Stream IDs are zero-indexed, so valid IDs range from - /// `0` to `get_stream_count() - 1`. - /// - /// # Returns - /// `u64` — total streams created since contract deployment. - /// - /// # Example - /// ```text - /// let count = client.get_stream_count(); - /// for id in 0..count { - /// let stream = client.get_stream(&id)?; - /// // process stream... - /// } - /// ``` - pub fn get_stream_count(env: Env) -> u64 { - env.storage().instance().get(&DataKey::NextId).unwrap_or(0) - } - - /// Return the number of tokens the recipient can withdraw right now. - /// - /// Lightweight alternative to [`get_stream_status`](Self::get_stream_status) - /// for UIs and integrators that only need the withdrawable balance. - /// Returns `0` for cancelled streams (accrued tokens are paid out on cancel). - /// - /// # Errors - /// - [`StreamError::StreamNotFound`] — no stream exists with `stream_id`. - pub fn get_claimable(env: Env, stream_id: u64) -> Result { - let stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Ok(0); - } - - let streamed = Self::compute_streamed(&stream, env.ledger().timestamp()); - Ok((streamed - stream.withdrawn).max(0)) - } - - /// Get all stream IDs for a given sender. - /// - /// Returns a vector of stream IDs where the specified address is the sender. - /// This list is append-only: cancelled streams are still included. - /// Useful for wallets and dashboards to display all outgoing streams. - /// - /// # Parameters - /// - `sender`: Address to look up streams for - /// - /// # Returns - /// `Vec`: Vector of stream IDs (empty if none found) - /// - /// # Example - /// ```rust,ignore - /// let my_streams = forge_stream.get_streams_by_sender(env, sender_address); - /// for stream_id in my_streams.iter() { - /// let status = forge_stream.get_stream_status(env, stream_id)?; - /// // Display stream info... - /// } - /// ``` - pub fn get_streams_by_sender(env: Env, sender: Address) -> soroban_sdk::Vec { - let key = DataKey::SenderStreams(sender.clone()); - let result = env - .storage() - .persistent() - .get(&key) - .unwrap_or(soroban_sdk::Vec::new(&env)); - if env.storage().persistent().has(&key) { - env.storage().persistent().extend_ttl(&key, 17280, 34560); - } - result - } - - /// Get all stream IDs for a given recipient. - /// - /// Returns a vector of stream IDs where the specified address is the recipient. - /// This list is append-only: cancelled streams are still included. - /// Useful for wallets and dashboards to display all incoming streams. - /// - /// # Parameters - /// - `recipient`: Address to look up streams for - /// - /// # Returns - /// `Vec`: Vector of stream IDs (empty if none found) - /// - /// # Example - /// ```rust,ignore - /// let incoming_streams = forge_stream.get_streams_by_recipient(env, recipient_address); - /// for stream_id in incoming_streams.iter() { - /// let claimable = forge_stream.get_claimable(env, stream_id)?; - /// if claimable > 0 { - /// forge_stream.withdraw(env, stream_id)?; - /// } - /// } - /// ``` - pub fn get_streams_by_recipient(env: Env, recipient: Address) -> soroban_sdk::Vec { - let key = DataKey::RecipientStreams(recipient.clone()); - let result = env - .storage() - .persistent() - .get(&key) - .unwrap_or(soroban_sdk::Vec::new(&env)); - if env.storage().persistent().has(&key) { - env.storage().persistent().extend_ttl(&key, 17280, 34560); - } - result - } - - /// Extend an active stream by adding more time and tokens. - /// - /// Transfers `rate_per_second * additional_seconds` tokens from the sender to the - /// contract and pushes `end_time` forward by `additional_seconds`. The stream must - /// not be cancelled, paused, or already finished. Extensions are intentionally - /// disallowed while paused so the visible `end_time` continues to reflect - /// actively accruing wall-clock time. - /// Only callable by the stream's `sender`. - /// - /// # Parameters - /// - `stream_id`: u64 stream identifier - /// - `additional_seconds`: u64 > 0, seconds to add to the stream duration - /// - /// # Returns - /// `Ok(())` - /// - /// # Errors - /// - `StreamNotFound` — no stream exists with `stream_id` - /// - `Unauthorized` — caller is not the stream sender - /// - `AlreadyCancelled` — stream has been cancelled - /// - `StreamFinished` — stream end_time has already passed - /// - `InvalidConfig` — `additional_seconds` is 0 or the stream is paused - pub fn extend_stream( - env: Env, - stream_id: u64, - additional_seconds: u64, - ) -> Result<(), StreamError> { - if additional_seconds == 0 { - return Err(StreamError::InvalidConfig); - } - - Self::validate_stream_id(&env, stream_id)?; - let mut stream: Stream = env - .storage() - .persistent() - .get(&DataKey::Stream(stream_id)) - .ok_or(StreamError::StreamNotFound)?; - - if stream.cancelled { - return Err(StreamError::AlreadyCancelled); - } - - stream.sender.require_auth(); - - if stream.is_paused { - return Err(StreamError::InvalidConfig); - } - - if env.ledger().timestamp() >= stream.end_time { - return Err(StreamError::StreamFinished); - } - - let additional_tokens = stream.rate_per_second * additional_seconds as i128; - let token_client = token::Client::new(&env, &stream.token); - token_client.transfer( - &stream.sender, - &env.current_contract_address(), - &additional_tokens, - ); - - stream.end_time += additional_seconds; - - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage() - .persistent() - .extend_ttl(&DataKey::Stream(stream_id), 17280, 34560); - - env.events().publish( - (Symbol::new(&env, "stream_extended"),), - (stream_id, stream.end_time, additional_tokens), - ); - - Ok(()) - } - - // ── Private ─────────────────────────────────────────────────────────────── - - /// Validate that a stream ID is within the valid range. - /// - /// Checks that `stream_id < NextId` before attempting to read from storage. - /// This provides an early, descriptive error and reduces storage read costs - /// on invalid IDs. - /// - /// # Parameters - /// - `env`: Reference to the Soroban environment - /// - `stream_id`: The stream ID to validate - /// - /// # Returns - /// `Ok(())` if valid, `Err(StreamError::StreamNotFound)` if out of range - fn validate_stream_id(env: &Env, stream_id: u64) -> Result<(), StreamError> { - let next_id: u64 = env - .storage() - .instance() - .get(&DataKey::NextId) - .unwrap_or(0_u64); - if stream_id >= next_id { - return Err(StreamError::StreamNotFound); - } - Ok(()) - } - - fn compute_streamed(stream: &Stream, now: u64) -> i128 { - if stream.cancelled { - return stream.streamed_at_cancel; - } - let effective_time = now.min(stream.end_time); - let raw_elapsed = effective_time.saturating_sub(stream.start_time); - let mut paused_time = stream.total_paused_time; - if stream.is_paused { - if let Some(paused_at) = stream.paused_at { - paused_time += effective_time.saturating_sub(paused_at); - } - } - let effective_elapsed = raw_elapsed.saturating_sub(paused_time); - // Overflow protection: if rate * elapsed would exceed i128::MAX, cap at - // total (rate * duration) which is the maximum tokens this stream can ever - // release. This prevents silent truncation on extreme rate/elapsed combos. - let duration = stream.end_time.saturating_sub(stream.start_time); - let total = stream.rate_per_second * duration as i128; - stream - .rate_per_second - .checked_mul(effective_elapsed as i128) - .unwrap_or(total) - .min(total) - } - - fn active_streams_count(env: &Env) -> u64 { - env.storage() - .instance() - .get(&DataKey::ActiveStreamsCount) - .unwrap_or(0_u64) - } - - fn set_active_streams_count(env: &Env, count: u64) { - env.storage() - .instance() - .set(&DataKey::ActiveStreamsCount, &count); - } - - fn sync_elapsed_streams(env: &Env) -> u64 { - let now = env.ledger().timestamp(); - let next_id: u64 = env - .storage() - .instance() - .get(&DataKey::NextId) - .unwrap_or(0_u64); - let mut active_count = Self::active_streams_count(env); - - let mut stream_id = 0_u64; - while stream_id < next_id { - let maybe_stream: Option = - env.storage().persistent().get(&DataKey::Stream(stream_id)); - if let Some(mut stream) = maybe_stream { - if stream.counted_active && !stream.cancelled && now >= stream.end_time { - stream.counted_active = false; - env.storage() - .persistent() - .set(&DataKey::Stream(stream_id), &stream); - env.storage().persistent().extend_ttl( - &DataKey::Stream(stream_id), - 17280, - 34560, - ); - active_count = active_count.saturating_sub(1); - } - } - stream_id += 1; - } - - Self::set_active_streams_count(env, active_count); - active_count - } -} - -#[cfg(test)] -mod tests { - extern crate std; - use crate::ForgeStream; - - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - token::{Client as TokenClient, StellarAssetClient}, - }; - use soroban_sdk::{Env, IntoVal}; - - fn setup_token(env: &Env, sender: &Address, total: i128) -> Address { - let token_admin = Address::generate(env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(env, &token_id).mint(sender, &total); - token_id - } - - fn make_token(env: &Env, _contract_id: &Address, sender: &Address, total: i128) -> Address { - let token_admin = Address::generate(env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - soroban_sdk::token::StellarAssetClient::new(env, &token_id).mint(sender, &total); - token_id - } - - #[test] - fn test_create_stream_success() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let _token = make_token(&env, &contract_id, &sender, 100_000); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let result = client.try_create_stream(&sender, &token.address, &recipient, &100, &1000); - assert!(result.is_ok()); - assert_eq!(result.unwrap().unwrap(), 0u64); - } - - #[test] - fn test_invalid_stream_config() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = Address::generate(&env); - - let result = client.try_create_stream(&sender, &token, &recipient, &0, &1000); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - /// `create_stream` with `rate_per_second = 0` must be rejected with `InvalidConfig`. - /// A zero-rate stream would never accrue tokens, making it meaningless and - /// indistinguishable from a no-op. The contract enforces `rate > 0` at creation. - #[test] - fn test_create_stream_zero_rate_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = Address::generate(&env); - - let result = client.try_create_stream(&sender, &token, &recipient, &0, &1000); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - #[test] - fn test_create_stream_insufficient_funds() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - // Mint only 999 tokens but the stream needs 100 * 10 = 1000 - let token_id = setup_token(&env, &sender, 999); - - let result = client.try_create_stream(&sender, &token_id, &recipient, &100, &10); - assert_eq!(result, Err(Ok(StreamError::InsufficientFunds))); - } - - #[test] - fn test_stream_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let result = client.try_withdraw(&999); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - } - - #[test] - fn test_withdraw_nothing_to_withdraw() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = make_token(&env, &contract_id, &sender, 100_000); - - let _stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let stream_id = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - // No time has passed — nothing to withdraw - let result = client.try_withdraw(&stream_id); - assert_eq!(result, Err(Ok(StreamError::NothingToWithdraw))); - } - - /// Test for issue #215: Double withdraw() should return NothingToWithdraw on second call. - /// Verifies that withdrawn is updated correctly and the second call without time passing returns error. - #[test] - fn test_double_withdraw_returns_nothing_to_withdraw() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let stream_id = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - - // Advance time by 100 seconds so tokens accrue - env.ledger().with_mut(|l| l.timestamp += 100); - - // First withdraw should succeed and return 100 * 100 = 10,000 - let withdrawn_amount = client.withdraw(&stream_id); - assert_eq!(withdrawn_amount, 10_000); - - // Verify stream status after first withdrawal - let status_after_first = client.get_stream_status(&stream_id); - assert_eq!(status_after_first.withdrawn, 10_000); - assert_eq!(status_after_first.withdrawable, 0); - - // Second withdraw without advancing time should fail with NothingToWithdraw - let result = client.try_withdraw(&stream_id); - assert_eq!(result, Err(Ok(StreamError::NothingToWithdraw))); - - // Verify stream status hasn't changed - let status_after_second = client.get_stream_status(&stream_id); - assert_eq!(status_after_second.withdrawn, 10_000); - assert_eq!(status_after_second.withdrawable, 0); - } - - #[test] - fn test_stream_status_active() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let _token = make_token(&env, &contract_id, &sender, 100_000); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let stream_id = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - - let status = client.get_stream_status(&stream_id); - assert!(status.is_active); - assert_eq!(status.streamed, 10_000); - assert_eq!(status.withdrawable, 10_000); - } - - #[test] - fn test_cancel_stream() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let _token = make_token(&env, &contract_id, &sender, 100_000); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let stream_id = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - let result = client.try_cancel_stream(&stream_id); - assert!(result.is_ok()); - - let result2 = client.try_cancel_stream(&stream_id); - assert_eq!(result2, Err(Ok(StreamError::AlreadyCancelled))); - } - - #[test] - fn test_stream_finished_after_duration() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let _token = make_token(&env, &contract_id, &sender, 100_000); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - let stream_id = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 2000); - - let status = client.get_stream_status(&stream_id); - assert!(status.is_finished); - assert!(!status.is_active); - assert_eq!(status.streamed, 100_000); - } - - #[test] - fn test_finished_stream_is_claimable_before_withdrawal() { - // A finished stream must have is_active=false, is_finished=true, - // is_claimable=true, and withdrawable == total streamed. - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &100_000i128); - - // rate=100, duration=1000 → total=100_000 - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - // Advance past end_time without withdrawing - env.ledger().with_mut(|l| l.timestamp += 2000); - - let status = client.get_stream_status(&stream_id); - assert!(!status.is_active); - assert!(status.is_finished); - assert!(status.is_claimable); - assert_eq!(status.withdrawable, 100_000); - } - - // ── Rounding / cancellation split tests ────────────────────────────────── - - /// Rate of 1 token/sec: streamed amount must equal elapsed seconds exactly. - #[test] - fn test_low_rate_one_token_per_second() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let duration = 1_000u64; - let rate = 1i128; - let total = rate * duration as i128; // 1_000 - let token = setup_token(&env, &sender, total); - - let stream_id = client.create_stream(&sender, &token, &recipient, &rate, &duration); - - env.ledger().with_mut(|l| l.timestamp += 333); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, 333); - assert_eq!(status.remaining, total - 333); - assert_eq!(status.streamed + status.remaining, total); - - env.ledger().with_mut(|l| l.timestamp += 667); // total += 1000 - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, total); - assert_eq!(status.remaining, 0); - assert_eq!(status.streamed + status.remaining, total); - } - - /// High rate near i128::MAX / duration: no overflow, invariant holds. - #[test] - fn test_high_rate_near_max() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let duration = 1_000u64; - let rate = i128::MAX / duration as i128; - let total = rate * duration as i128; - let token = setup_token(&env, &sender, total); - - let stream_id = client.create_stream(&sender, &token, &recipient, &rate, &duration); - - env.ledger().with_mut(|l| l.timestamp += 500); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, rate * 500); - assert_eq!(status.remaining, total - rate * 500); - assert_eq!(status.streamed + status.remaining, total); - - env.ledger().with_mut(|l| l.timestamp += 500); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, total); - assert_eq!(status.remaining, 0); - assert_eq!(status.streamed + status.remaining, total); - } - - /// rate = i128::MAX / 2, duration = 3: intermediate multiplication would - /// overflow without checked_mul. Verifies compute_streamed() caps at total - /// and never panics or returns a corrupted value. - #[test] - fn test_high_rate_overflow_protection() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let duration = 3u64; - let rate = i128::MAX / 2; // rate * 2 fits in i128, but rate * 3 overflows - let total = rate * duration as i128; // safe: (MAX/2) * 3 < MAX - let token = setup_token(&env, &sender, total); - - let stream_id = client.create_stream(&sender, &token, &recipient, &rate, &duration); - - // At t=1: rate * 1 is fine - env.ledger().with_mut(|l| l.timestamp += 1); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, rate); - assert!(status.streamed <= total); - - // At t=2: rate * 2 is fine - env.ledger().with_mut(|l| l.timestamp += 1); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, rate * 2); - assert!(status.streamed <= total); - - // At t=3 (end): rate * 3 would overflow i128 without checked_mul; - // result must be capped at total, not panic or wrap. - env.ledger().with_mut(|l| l.timestamp += 1); - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, total); - assert_eq!(status.remaining, 0); - assert_eq!(status.streamed + status.remaining, total); - } - - /// streamed + remaining == total at every sampled point during a stream. - #[test] - fn test_streamed_plus_remaining_equals_total_invariant() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate = 7i128; // intentionally odd to surface any rounding - let duration = 100u64; - let total = rate * duration as i128; // 700 - let token = setup_token(&env, &sender, total); - - let stream_id = client.create_stream(&sender, &token, &recipient, &rate, &duration); - - for tick in [1u64, 10, 33, 50, 77, 99, 100, 150] { - env.ledger().with_mut(|l| l.timestamp = tick); - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed + status.remaining, - total, - "invariant broken at tick={tick}: streamed={} remaining={}", - status.streamed, - status.remaining - ); - } - } - - /// On cancel, withdrawable + returnable == total (no tokens lost or created). - #[test] - fn test_cancel_no_tokens_lost() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate = 3i128; - let duration = 1_000u64; - let total = rate * duration as i128; - let token = setup_token(&env, &sender, total); - - let stream_id = client.create_stream(&sender, &token, &recipient, &rate, &duration); - - env.ledger().with_mut(|l| l.timestamp += 400); - - let status = client.get_stream_status(&stream_id); - let expected_withdrawable = status.withdrawable; - let expected_returnable = total - status.streamed; - - client.cancel_stream(&stream_id); - - assert_eq!(expected_withdrawable + expected_returnable, total); - assert_eq!(status.streamed + status.remaining, total); - } - - // ── get_claimable tests ─────────────────────────────────────────────────── - - #[test] - fn test_get_claimable_active_stream() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 50); - - assert_eq!(client.get_claimable(&stream_id), 5_000); // 100 * 50 - } - - #[test] - fn test_get_claimable_fully_elapsed_stream() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 2000); - - assert_eq!(client.get_claimable(&stream_id), 100_000); // 100 * 1000 - } - - #[test] - fn test_get_claimable_cancelled_stream_returns_zero() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 200); - client.cancel_stream(&stream_id); - - assert_eq!(client.get_claimable(&stream_id), 0); - } - - #[test] - fn test_active_streams_count_tracks_create_and_cancel() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - assert_eq!(client.get_active_streams_count(), 0); - - let stream_a = client.create_stream(&sender, &token.address, &recipient, &100, &1000); - assert_eq!(client.get_active_streams_count(), 1); - - let stream_b = client.create_stream(&sender, &token.address, &recipient, &50, &800); - assert_eq!(client.get_active_streams_count(), 2); - - client.cancel_stream(&stream_a); - assert_eq!(client.get_active_streams_count(), 1); - - client.cancel_stream(&stream_b); - assert_eq!(client.get_active_streams_count(), 0); - } - - #[test] - fn test_active_streams_count_decrements_on_full_elapsed() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - sac.mint(&sender, &10_000_000i128); - let token = TokenClient::new(&env, &token_id); - - client.create_stream(&sender, &token.address, &recipient, &10, &100); - client.create_stream(&sender, &token.address, &recipient, &20, &300); - assert_eq!(client.get_active_streams_count(), 2); - - env.ledger().with_mut(|l| l.timestamp += 150); - assert_eq!(client.get_active_streams_count(), 1); - - env.ledger().with_mut(|l| l.timestamp += 200); - assert_eq!(client.get_active_streams_count(), 0); - } - - #[test] - fn test_pause_stream() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - - client.pause_stream(&stream_id); - - let status = client.get_stream_status(&stream_id); - assert!(status.is_paused); - assert!(!status.is_active); - assert_eq!(status.streamed, 10_000); // 100 * 100s - } - - #[test] - fn test_resume_stream() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - - client.pause_stream(&stream_id); - env.ledger().with_mut(|l| l.timestamp += 200); // Paused for 200s - - let status_before = client.get_stream_status(&stream_id); - assert!(status_before.is_paused); - assert_eq!(status_before.streamed, 10_000); // Still 100*100, no accrual during pause - - client.resume_stream(&stream_id); - - let status_after = client.get_stream_status(&stream_id); - assert!(!status_after.is_paused); - assert!(status_after.is_active); - assert_eq!(status_after.streamed, 10_000); // Still the same - } - - #[test] - fn test_withdraw_while_paused() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - - client.pause_stream(&stream_id); - env.ledger().with_mut(|l| l.timestamp += 50); // Paused, no new accrual - - let status = client.get_stream_status(&stream_id); - assert_eq!(status.withdrawable, 10_000); - - let withdrawn = client.withdraw(&stream_id); - assert_eq!(withdrawn, 10_000); - - let status_after = client.get_stream_status(&stream_id); - assert_eq!(status_after.withdrawn, 10_000); - assert_eq!(status_after.withdrawable, 0); - } - - /// pause_stream() must emit a "stream_paused" event whose data contains the - /// correct stream_id. - #[test] - fn test_pause_stream_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - - client.pause_stream(&stream_id); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_paused")) - .unwrap_or(false) - && ::try_from_val(&env, &data) - .map(|id| id == stream_id) - .unwrap_or(false) - }); - assert!( - found, - "Expected stream_paused event with stream_id={stream_id} not found" - ); - } - - /// resume_stream() must emit a "stream_resumed" event whose data contains - /// the correct stream_id. - #[test] - fn test_resume_stream_emits_event() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 100); - client.pause_stream(&stream_id); - env.ledger().with_mut(|l| l.timestamp += 50); - - client.resume_stream(&stream_id); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_resumed")) - .unwrap_or(false) - && ::try_from_val(&env, &data) - .map(|id| id == stream_id) - .unwrap_or(false) - }); - assert!( - found, - "Expected stream_resumed event with stream_id={stream_id} not found" - ); - } - - /// A failed pause_stream() (already paused) must not emit a stream_paused - /// event — the event list should contain only the first successful pause. - #[test] - fn test_no_pause_event_on_failed_pause() { - use soroban_sdk::testutils::Events; - use soroban_sdk::TryFromVal; - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - client.pause_stream(&stream_id); // succeeds — emits one event - - // Second pause must fail - let result = client.try_pause_stream(&stream_id); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - - // Only one stream_paused event should exist (from the first call) - let events = env.events().all(); - let pause_event_count = events - .iter() - .filter(|(_, topics, _)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_paused")) - .unwrap_or(false) - }) - .count(); - assert_eq!( - pause_event_count, 1, - "Expected exactly 1 stream_paused event, got {pause_event_count}" - ); - } - - #[test] - fn test_pause_already_paused() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - client.pause_stream(&stream_id); - - let result = client.try_pause_stream(&stream_id); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - #[test] - fn test_resume_not_paused() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - let result = client.try_resume_stream(&stream_id); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - #[test] - fn test_pause_resume_maintains_total_payout() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - // Create stream: 100 tokens/sec for 1000 seconds = 100,000 total - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - // Advance 100 seconds, then pause - env.ledger().with_mut(|l| l.timestamp += 100); - client.pause_stream(&stream_id); - - // Check status after pause - let status_paused = client.get_stream_status(&stream_id); - assert_eq!(status_paused.streamed, 10_000); // 100 * 100 - - // Advance 200 seconds while paused (no accrual) - env.ledger().with_mut(|l| l.timestamp += 200); - - // Resume (extends end_time by 200 seconds) - client.resume_stream(&stream_id); - - // Check status after resume - let status_resumed = client.get_stream_status(&stream_id); - assert_eq!(status_resumed.streamed, 10_000); // Still 100 * 100 - - // Advance to new end_time (1200) - env.ledger().with_mut(|l| l.timestamp += 900); - - let status = client.get_stream_status(&stream_id); - // Total streamed should be 100 * 1000 = 100,000 (full amount) - // Calculation: raw_elapsed = 1200 - 0 = 1200, paused_time = 200 - // effective_elapsed = 1200 - 200 = 1000, streamed = 100 * 1000 = 100,000 - assert_eq!(status.streamed, 100_000); - assert_eq!(status.remaining, 0); - assert!(status.is_finished); - } - - #[test] - fn test_full_stream_withdrawal() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - let token = TokenClient::new(&env, &token_id); - - let rate = 100i128; - let duration = 1_000u64; - let total = rate * duration as i128; // 100_000 - - sac.mint(&sender, &total); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - // Advance past the full stream duration - env.ledger().with_mut(|l| l.timestamp += duration + 1); - - // withdrawable should equal the total stream amount - let status = client.get_stream_status(&stream_id); - assert!(status.is_finished); - assert!(!status.is_active); - assert_eq!(status.withdrawable, total); - - // withdraw() transfers the full amount to the recipient - let withdrawn = client.withdraw(&stream_id); - assert_eq!(withdrawn, total); - assert_eq!(token.balance(&recipient), total); - - // stream is marked inactive (withdrawn == total, nothing left) - let status_after = client.get_stream_status(&stream_id); - assert_eq!(status_after.withdrawn, total); - assert_eq!(status_after.withdrawable, 0); - assert!(!status_after.is_active); - } - - #[test] - fn test_random_address_cannot_withdraw() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let intruder = Address::generate(&env); - let token = make_token(&env, &contract_id, &sender, 100_000); - - env.ledger().with_mut(|l| l.timestamp = 0); - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - // Advance time so tokens have accrued - env.ledger().with_mut(|l| l.timestamp = 500); - - // Mock auth as the intruder, not the recipient - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &intruder, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "withdraw", - args: (stream_id,).into_val(&env), - sub_invokes: &[], - }, - }]); - - let result = client.try_withdraw(&stream_id); - assert!( - result.is_err(), - "Expected withdraw by random address to revert" - ); - } - - #[test] - fn test_sender_cannot_withdraw() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = make_token(&env, &contract_id, &sender, 100_000); - - env.ledger().with_mut(|l| l.timestamp = 0); - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - // Advance time so tokens have accrued - env.ledger().with_mut(|l| l.timestamp = 500); - - // Mock auth as the sender, not the recipient - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &sender, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "withdraw", - args: (stream_id,).into_val(&env), - sub_invokes: &[], - }, - }]); - - let result = client.try_withdraw(&stream_id); - assert!( - result.is_err(), - "Expected withdraw by sender to revert with Unauthorized" - ); - } - - #[test] - fn test_get_stream_count_starts_at_zero() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - assert_eq!(client.get_stream_count(), 0); - } - - #[test] - fn test_get_stream_count_increments_on_create() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); - - assert_eq!(client.get_stream_count(), 0); - - client.create_stream(&sender, &token_id, &recipient, &100, &1000); - assert_eq!(client.get_stream_count(), 1); - - client.create_stream(&sender, &token_id, &recipient, &100, &1000); - assert_eq!(client.get_stream_count(), 2); - - client.create_stream(&sender, &token_id, &recipient, &100, &1000); - assert_eq!(client.get_stream_count(), 3); - } - - #[test] - fn test_get_stream_count_includes_cancelled_streams() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - client.cancel_stream(&stream_id); - - // count should still be 1 even after cancellation - assert_eq!(client.get_stream_count(), 1); - } - - #[test] - fn test_get_streams_by_sender_empty() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - - let streams = client.get_streams_by_sender(&sender); - assert_eq!(streams.len(), 0); - } - - #[test] - fn test_get_streams_by_recipient_empty() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let recipient = Address::generate(&env); - - let streams = client.get_streams_by_recipient(&recipient); - assert_eq!(streams.len(), 0); - } - - #[test] - fn test_get_streams_by_sender_single() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - let streams = client.get_streams_by_sender(&sender); - assert_eq!(streams.len(), 1); - assert_eq!(streams.get(0).unwrap(), stream_id); - } - - #[test] - fn test_get_streams_by_recipient_single() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - let streams = client.get_streams_by_recipient(&recipient); - assert_eq!(streams.len(), 1); - assert_eq!(streams.get(0).unwrap(), stream_id); - } - - #[test] - fn test_get_streams_by_sender_multiple() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient1 = Address::generate(&env); - let recipient2 = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id1 = client.create_stream(&sender, &token_id, &recipient1, &100, &1000); - let stream_id2 = client.create_stream(&sender, &token_id, &recipient2, &50, &800); - - let streams = client.get_streams_by_sender(&sender); - assert_eq!(streams.len(), 2); - assert_eq!(streams.get(0).unwrap(), stream_id1); - assert_eq!(streams.get(1).unwrap(), stream_id2); - } - - #[test] - fn test_get_streams_by_recipient_multiple() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender1 = Address::generate(&env); - let sender2 = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender1, &10_000_000i128); - StellarAssetClient::new(&env, &token_id).mint(&sender2, &10_000_000i128); - - let stream_id1 = client.create_stream(&sender1, &token_id, &recipient, &100, &1000); - let stream_id2 = client.create_stream(&sender2, &token_id, &recipient, &50, &800); - - let streams = client.get_streams_by_recipient(&recipient); - assert_eq!(streams.len(), 2); - assert_eq!(streams.get(0).unwrap(), stream_id1); - assert_eq!(streams.get(1).unwrap(), stream_id2); - } - - #[test] - fn test_get_streams_includes_cancelled_streams() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id1 = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - let stream_id2 = client.create_stream(&sender, &token_id, &recipient, &50, &800); - - client.cancel_stream(&stream_id2); - - let sender_streams = client.get_streams_by_sender(&sender); - assert_eq!(sender_streams.len(), 2); - assert_eq!(sender_streams.get(0).unwrap(), stream_id1); - assert_eq!(sender_streams.get(1).unwrap(), stream_id2); - - let recipient_streams = client.get_streams_by_recipient(&recipient); - assert_eq!(recipient_streams.len(), 2); - assert_eq!(recipient_streams.get(0).unwrap(), stream_id1); - assert_eq!(recipient_streams.get(1).unwrap(), stream_id2); - } - - #[test] - fn test_get_streams_isolation_between_users() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender1 = Address::generate(&env); - let sender2 = Address::generate(&env); - let recipient1 = Address::generate(&env); - let recipient2 = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender1, &10_000_000i128); - StellarAssetClient::new(&env, &token_id).mint(&sender2, &10_000_000i128); - - let stream_id1 = client.create_stream(&sender1, &token_id, &recipient1, &100, &1000); - let stream_id2 = client.create_stream(&sender2, &token_id, &recipient2, &50, &800); - - let sender1_streams = client.get_streams_by_sender(&sender1); - assert_eq!(sender1_streams.len(), 1); - assert_eq!(sender1_streams.get(0).unwrap(), stream_id1); - - let sender2_streams = client.get_streams_by_sender(&sender2); - assert_eq!(sender2_streams.len(), 1); - assert_eq!(sender2_streams.get(0).unwrap(), stream_id2); - - let recipient1_streams = client.get_streams_by_recipient(&recipient1); - assert_eq!(recipient1_streams.len(), 1); - assert_eq!(recipient1_streams.get(0).unwrap(), stream_id1); - - let recipient2_streams = client.get_streams_by_recipient(&recipient2); - assert_eq!(recipient2_streams.len(), 1); - assert_eq!(recipient2_streams.get(0).unwrap(), stream_id2); - } - - /// Test that get_stream_status().streamed returns the correct historical value after cancel. - #[test] - fn test_get_stream_status_streamed_after_cancel() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - env.ledger().with_mut(|l| l.timestamp += 200); - - let status_before = client.get_stream_status(&stream_id); - assert_eq!(status_before.streamed, 20_000); - - client.cancel_stream(&stream_id); - - let status_after = client.get_stream_status(&stream_id); - assert_eq!( - status_after.streamed, 20_000, - "streamed should equal the amount at cancel time" - ); - // After cancel, tokens are auto-paid out so withdrawable reflects unpaid accrued amount - // (withdrawn is not updated on cancel — the transfer happens directly) - assert_eq!(status_after.is_active, false); - } - - /// Test for issue #214: Creating 20+ streams for a single sender should not overflow. - #[test] - fn test_get_streams_by_sender_many_streams() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &2_500_000_000i128); - - let mut stream_ids = soroban_sdk::Vec::new(&env); - - for _ in 0..25 { - let recipient = Address::generate(&env); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - stream_ids.push_back(stream_id); - } - - let sender_streams = client.get_streams_by_sender(&sender); - assert_eq!(sender_streams.len(), 25, "Expected 25 streams for sender"); - - for i in 0..25u32 { - assert_eq!( - sender_streams.get(i).unwrap(), - stream_ids.get(i).unwrap(), - "Stream ID at index {} does not match", - i - ); - } - } - - /// Test cancelling a paused stream correctly splits tokens. - #[test] - fn test_cancel_paused_stream_splits_tokens_correctly() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - let token = TokenClient::new(&env, &token_id); - - let rate = 100i128; - let duration = 1000u64; - let total = rate * duration as i128; - - sac.mint(&sender, &total); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - env.ledger().with_mut(|l| l.timestamp += 200); - client.pause_stream(&stream_id); - - env.ledger().with_mut(|l| l.timestamp += 200); - client.cancel_stream(&stream_id); - - assert_eq!(token.balance(&recipient), 20_000); - assert_eq!(token.balance(&sender), 80_000); - assert_eq!(token.balance(&recipient) + token.balance(&sender), total); - } - - /// Property test: withdrawn never exceeds compute_streamed across multiple withdraw() calls. - /// - /// Invariant: after every withdraw(), status.withdrawn <= status.streamed - /// Also asserts: - /// - cumulative withdrawn == sum of individual withdraw() return values - /// - final cumulative withdrawn == rate_per_second * duration_seconds - #[test] - fn test_withdrawn_never_exceeds_streamed_across_multiple_withdrawals() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - - let rate: i128 = 100; - let duration: u64 = 1000; - let total = rate * duration as i128; // 100_000 - - sac.mint(&sender, &total); - - // Start at t=0 - env.ledger().with_mut(|l| l.timestamp = 0); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - let checkpoints: [(u64, u64); 5] = [ - (100, 100), // 10% of duration - (250, 250), // 25% - (500, 500), // 50% - (750, 750), // 75% - (1000, 1000), // 100% - ]; - - let mut cumulative_withdrawn: i128 = 0; - - for (timestamp, _pct) in checkpoints.iter() { - env.ledger().with_mut(|l| l.timestamp = *timestamp); - - let amount = client.withdraw(&stream_id); - cumulative_withdrawn += amount; - - let status = client.get_stream_status(&stream_id); - - // Core invariant: withdrawn must never exceed streamed - assert!( - status.withdrawn <= status.streamed, - "Invariant violated at t={}: withdrawn ({}) > streamed ({})", - timestamp, - status.withdrawn, - status.streamed, - ); - - // Cumulative return values must match stored withdrawn - assert_eq!( - status.withdrawn, cumulative_withdrawn, - "Cumulative mismatch at t={}: status.withdrawn={} vs sum={}", - timestamp, status.withdrawn, cumulative_withdrawn, - ); - } - - // After full duration: total withdrawn must equal rate * duration - assert_eq!( - cumulative_withdrawn, total, - "Final withdrawn {} != expected total {}", - cumulative_withdrawn, total, - ); - } - - /// Test that paused time is correctly excluded from streamed amount after resume. - /// - /// Timeline: - /// t=0 stream created (rate=100, duration=1000, total=100_000) - /// t=100 pause (streamed = 100 * 100 = 10_000) - /// t=300 resume (paused for 200s — those 200s must not count) - /// t=400 check (effective elapsed = 400 - 200 = 200s → streamed = 20_000) - /// t=500 check (effective elapsed = 500 - 200 = 300s → streamed = 30_000) - /// t=1200 end of stream (end_time extended by 200s to 1200; full 100_000 withdrawable) - #[test] - fn test_paused_time_excluded_from_streamed_after_resume() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = StellarAssetClient::new(&env, &token_id); - - let rate: i128 = 100; - let duration: u64 = 1000; - let total = rate * duration as i128; // 100_000 - - sac.mint(&sender, &total); - - // Start at t=0 - env.ledger().with_mut(|l| l.timestamp = 0); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - let checkpoints: [(u64, u64); 5] = [ - (100, 100), // 10% of duration - (250, 250), // 25% - (500, 500), // 50% - (750, 750), // 75% - (1000, 1000), // 100% - ]; - - let mut cumulative_withdrawn: i128 = 0; - - for (timestamp, _pct) in checkpoints.iter() { - env.ledger().with_mut(|l| l.timestamp = *timestamp); - - let amount = client.withdraw(&stream_id); - cumulative_withdrawn += amount; - - let status = client.get_stream_status(&stream_id); - - // Core invariant: withdrawn must never exceed streamed - assert!( - status.withdrawn <= status.streamed, - "Invariant violated at t={}: withdrawn ({}) > streamed ({})", - timestamp, - status.withdrawn, - status.streamed, - ); - - // Cumulative return values must match stored withdrawn - assert_eq!( - status.withdrawn, cumulative_withdrawn, - "Cumulative mismatch at t={}: status.withdrawn={} vs sum={}", - timestamp, status.withdrawn, cumulative_withdrawn, - ); - } - - // After full duration: total withdrawn must equal rate * duration - assert_eq!( - cumulative_withdrawn, total, - "Final withdrawn {} != expected total {}", - cumulative_withdrawn, total, - ); - // t=0: create stream - env.ledger().with_mut(|l| l.timestamp = 0); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - // t=100: pause — 100s of active time → streamed = 10_000 - env.ledger().with_mut(|l| l.timestamp = 100); - client.pause_stream(&stream_id); - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed, 10_000, - "streamed at pause should be 10_000" - ); - - // t=300: resume — paused for 200s - env.ledger().with_mut(|l| l.timestamp = 300); - client.resume_stream(&stream_id); - - // t=400: effective elapsed = (400 - 0) - 200 paused = 200s → streamed = 20_000 - env.ledger().with_mut(|l| l.timestamp = 400); - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed, 20_000, - "at t=400 streamed should be 20_000 (paused 200s excluded), got {}", - status.streamed - ); - assert!( - status.withdrawn <= status.streamed, - "invariant violated: withdrawn {} > streamed {}", - status.withdrawn, - status.streamed - ); - - // t=500: effective elapsed = (500 - 0) - 200 paused = 300s → streamed = 30_000 - env.ledger().with_mut(|l| l.timestamp = 500); - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed, 30_000, - "at t=500 streamed should be 30_000 (paused 200s excluded), got {}", - status.streamed - ); - - // t=1200: end_time was extended by 200s (1000 + 200 = 1200) - // Full duration of active time has elapsed → all 100_000 tokens withdrawable - env.ledger().with_mut(|l| l.timestamp = 1200); - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed, total, - "at end of stream streamed should equal total {}, got {}", - total, status.streamed - ); - assert_eq!( - status.withdrawable, total, - "full total should be withdrawable at stream end, got {}", - status.withdrawable - ); - assert!(status.is_finished, "stream should be finished at t=1200"); - } - - // ── Stream ID validation tests ───────────────────────────────────────────── - - /// Test that get_stream() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_get_stream_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_get_stream(&0); - assert!(result.is_err(), "Expected error for out-of-range stream ID"); - - let result = client.try_get_stream(&999); - assert!(result.is_err(), "Expected error for out-of-range stream ID"); - } - - /// Test that get_stream_status() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_get_stream_status_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_get_stream_status(&0); - assert!(result.is_err(), "Expected error for out-of-range stream ID"); - - let result = client.try_get_stream_status(&999); - assert!(result.is_err(), "Expected error for out-of-range stream ID"); - } - - /// Test that withdraw() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_withdraw_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_withdraw(&0); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - - let result = client.try_withdraw(&999); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - } - - /// Test that cancel_stream() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_cancel_stream_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_cancel_stream(&0); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - - let result = client.try_cancel_stream(&999); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - } - - /// Test that pause_stream() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_pause_stream_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_pause_stream(&0); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - - let result = client.try_pause_stream(&999); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - } - - /// Test that resume_stream() returns StreamNotFound for out-of-range stream ID. - #[test] - fn test_resume_stream_out_of_range_returns_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - // No streams created, so any ID should be out of range - let result = client.try_resume_stream(&0); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - - let result = client.try_resume_stream(&999); - assert_eq!(result, Err(Ok(StreamError::StreamNotFound))); - } - - /// Test that validation does not false-positive on valid stream IDs. - #[test] - fn test_valid_stream_id_passes_validation() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create a stream (ID 0) - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - assert_eq!(stream_id, 0); - - // All functions should work with valid stream ID - let stream = client.get_stream(&stream_id); - assert_eq!(stream.id, stream_id); - - let status = client.get_stream_status(&stream_id); - assert_eq!(status.id, stream_id); - - // Advance time so tokens accrue - env.ledger().with_mut(|l| l.timestamp += 100); - - // withdraw should work - let withdrawn = client.withdraw(&stream_id); - assert_eq!(withdrawn, 10_000); - - // Create another stream (ID 1) - let stream_id_2 = client.create_stream(&sender, &token_id, &recipient, &50, &800); - assert_eq!(stream_id_2, 1); - - // Both streams should be accessible - let stream_1 = client.get_stream(&stream_id); - assert_eq!(stream_1.id, stream_id); - - let stream_2 = client.get_stream(&stream_id_2); - assert_eq!(stream_2.id, stream_id_2); - - // Out of range ID should fail - let result = client.try_get_stream(&2); - assert!(result.is_err(), "Expected error for out-of-range stream ID"); - } - - // ── extend_stream tests ─────────────────────────────────────────────────── - - #[test] - fn test_extend_stream_success() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - // Mint enough for original stream (100 * 1000 = 100_000) + extension (100 * 500 = 50_000) - StellarAssetClient::new(&env, &token_id).mint(&sender, &150_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - let stream_before = client.get_stream(&stream_id); - assert_eq!(stream_before.end_time, 1000); - - client.extend_stream(&stream_id, &500); - - let stream_after = client.get_stream(&stream_id); - assert_eq!(stream_after.end_time, 1500); - } - - #[test] - fn test_extend_stream_increases_remaining() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &200_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - let status_before = client.get_stream_status(&stream_id); - // At t=0, remaining = 100 * 1000 = 100_000 - assert_eq!(status_before.remaining, 100_000); - - client.extend_stream(&stream_id, &500); - - let status_after = client.get_stream_status(&stream_id); - // After extension, remaining = 100 * 1500 = 150_000 - assert_eq!(status_after.remaining, 150_000); - } - - #[test] - fn test_extend_stream_cancelled_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &200_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - client.cancel_stream(&stream_id); - - let result = client.try_extend_stream(&stream_id, &500); - assert_eq!(result, Err(Ok(StreamError::AlreadyCancelled))); - } - - #[test] - fn test_extend_stream_after_end_time_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &200_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - // Advance past end_time - env.ledger().with_mut(|l| l.timestamp = 1001); - - let result = client.try_extend_stream(&stream_id, &500); - assert_eq!(result, Err(Ok(StreamError::StreamFinished))); - } - - #[test] - fn test_extend_stream_zero_seconds_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &100_000i128); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000); - - let result = client.try_extend_stream(&stream_id, &0); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - /// Issue #265: stream paused before end_time, end_time passes, then resume is called. - /// total_paused_time must be capped at end_time so compute_streamed() returns the full amount. - /// - /// Timeline: - /// t=0 create stream (rate=100, duration=1000, total=100_000, end_time=1000) - /// t=500 pause (streamed = 50_000) - /// t=1500 resume (end_time already passed; paused duration capped at 1000-500=500) - /// end_time extended by 500 → new end_time = 1500 - /// t=1500 compute_streamed should return 100_000 (full amount) - #[test] - fn test_resume_after_end_time_caps_paused_duration() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate: i128 = 100; - let duration: u64 = 1000; - let total = rate * duration as i128; // 100_000 - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&sender, &total); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - // t=500: pause — 500s of active time → streamed = 50_000 - env.ledger().with_mut(|l| l.timestamp = 500); - client.pause_stream(&stream_id); - - let status = client.get_stream_status(&stream_id); - assert_eq!(status.streamed, 50_000); - - // t=1500: resume — end_time (1000) has already passed - // paused duration must be capped at end_time - paused_at = 1000 - 500 = 500 - // end_time extended by 500 → new end_time = 1500 - env.ledger().with_mut(|l| l.timestamp = 1500); - client.resume_stream(&stream_id); - - // At t=1500 (== new end_time), full 100_000 should be streamed - let status = client.get_stream_status(&stream_id); - assert_eq!( - status.streamed, total, - "streamed should equal total after overdue resume; got {}", - status.streamed - ); - assert!(status.is_finished); - } - - /// Issue #268: cancel_stream() doc comment example — 100/sec for 3600s, cancel after 100s. - /// Verifies the exact numbers: recipient gets 10,000, sender refunded 350,000, total = 360,000. - #[test] - fn test_cancel_stream_doc_comment_example() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate: i128 = 100; - let duration: u64 = 3600; - let total = rate * duration as i128; // 360_000 - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); - let token = soroban_sdk::token::Client::new(&env, &token_id); - sac.mint(&sender, &total); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - // Cancel after 100s: streamed = 100 * 100 = 10,000 - env.ledger().with_mut(|l| l.timestamp = 100); - client.cancel_stream(&stream_id); - - assert_eq!( - token.balance(&recipient), - 10_000, - "recipient should get 10,000" - ); - assert_eq!( - token.balance(&sender), - 350_000, - "sender should be refunded 350,000" - ); - assert_eq!( - token.balance(&recipient) + token.balance(&sender), - total, - "withdrawable + returnable must equal total" - ); - } - - // ── Event emission tests ────────────────────────────────────────────────── - - fn make_stream_for_events( - env: &Env, - client: &ForgeStreamClient, - rate: i128, - duration: u64, - ) -> (Address, Address, Address, u64) { - use soroban_sdk::token::StellarAssetClient; - let sender = Address::generate(env); - let recipient = Address::generate(env); - let total = rate * duration as i128; - let token_admin = Address::generate(env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(env, &token_id).mint(&sender, &total); - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - (sender, recipient, token_id, stream_id) - } - - #[test] - fn test_create_stream_emits_stream_created_event() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1000); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let rate: i128 = 100; - let duration: u64 = 3600; - let (_, recipient, _, stream_id) = make_stream_for_events(&env, &client, rate, duration); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_created")) - .unwrap_or(false) - && <(u64, Address, i128, u64)>::try_from_val(&env, &data) - .map(|(id, r, rps, dur)| { - id == stream_id && r == recipient && rps == rate && dur == duration - }) - .unwrap_or(false) - }); - assert!( - found, - "Expected stream_created event with correct payload not found" - ); - } - - #[test] - fn test_withdraw_emits_withdrawn_event() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let rate: i128 = 100; - let duration: u64 = 3600; - let (_, recipient, _, stream_id) = make_stream_for_events(&env, &client, rate, duration); - - // Advance 50s so there are tokens to withdraw - env.ledger().with_mut(|l| l.timestamp = 50); - let amount = client.withdraw(&stream_id); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "withdrawn")) - .unwrap_or(false) - && <(u64, Address, i128)>::try_from_val(&env, &data) - .map(|(id, r, amt)| id == stream_id && r == recipient && amt == amount) - .unwrap_or(false) - }); - assert!( - found, - "Expected withdrawn event with correct payload not found" - ); - } - - #[test] - fn test_cancel_stream_emits_stream_cancelled_event() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let rate: i128 = 100; - let duration: u64 = 3600; - let (_, _, _, stream_id) = make_stream_for_events(&env, &client, rate, duration); - - // Advance 100s so some tokens have streamed - env.ledger().with_mut(|l| l.timestamp = 100); - client.cancel_stream(&stream_id); - - // streamed = 100 * 100 = 10_000; returnable = 360_000 - 10_000 = 350_000 - let expected_withdrawable = 10_000i128; - let expected_returnable = 350_000i128; - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_cancelled")) - .unwrap_or(false) - && <(u64, i128, i128)>::try_from_val(&env, &data) - .map(|(id, w, r)| { - id == stream_id && w == expected_withdrawable && r == expected_returnable - }) - .unwrap_or(false) - }); - assert!( - found, - "Expected stream_cancelled event with correct payload not found" - ); - } - - #[test] - fn test_pause_stream_emits_stream_paused_event() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let (_, _, _, stream_id) = make_stream_for_events(&env, &client, 100, 3600); - - env.ledger().with_mut(|l| l.timestamp = 50); - client.pause_stream(&stream_id); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_paused")) - .unwrap_or(false) - && <(u64,)>::try_from_val(&env, &data) - .map(|(id,)| id == stream_id) - .unwrap_or(false) - }); - assert!(found, "Expected stream_paused event not found"); - } - - #[test] - fn test_resume_stream_emits_stream_resumed_event() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - - let (_, _, _, stream_id) = make_stream_for_events(&env, &client, 100, 3600); - - env.ledger().with_mut(|l| l.timestamp = 50); - client.pause_stream(&stream_id); - - env.ledger().with_mut(|l| l.timestamp = 150); - client.resume_stream(&stream_id); - - let events = env.events().all(); - let found = events.iter().any(|(_, topics, data)| { - topics - .get(0) - .and_then(|t| Symbol::try_from_val(&env, &t).ok()) - .map(|s| s == Symbol::new(&env, "stream_resumed")) - .unwrap_or(false) - && <(u64,)>::try_from_val(&env, &data) - .map(|(id,)| id == stream_id) - .unwrap_or(false) - }); - assert!(found, "Expected stream_resumed event not found"); - } - - /// Issue #268: cancel_stream() with a paused stream — paused time is excluded from streamed. - /// Verifies withdrawable + returnable == total invariant holds with paused time. - #[test] - fn test_cancel_paused_stream_invariant() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate: i128 = 100; - let duration: u64 = 3600; - let total = rate * duration as i128; // 360_000 - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); - let token = soroban_sdk::token::Client::new(&env, &token_id); - sac.mint(&sender, &total); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - - // t=100: pause — 100s active → streamed = 10,000 - env.ledger().with_mut(|l| l.timestamp = 100); - client.pause_stream(&stream_id); - - // t=300: cancel while paused — paused for 200s, streamed still = 10,000 - env.ledger().with_mut(|l| l.timestamp = 300); - client.cancel_stream(&stream_id); - - // recipient gets 10,000 (streamed), sender gets 350,000 (unstreamed) - assert_eq!(token.balance(&recipient), 10_000); - assert_eq!(token.balance(&sender), 350_000); - assert_eq!( - token.balance(&recipient) + token.balance(&sender), - total, - "withdrawable + returnable must equal total even with paused time" - ); - } - - /// A paused stream stops accruing (is_active = false) but tokens that - /// accrued before the pause are still claimable (is_claimable = true). - /// This guards against UIs that check is_active to show a withdraw button. - #[test] - fn test_paused_stream_is_not_active_but_is_claimable() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100 * 1000); - - // 100 tokens/s for 1000s - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - // Advance 200s so 20,000 tokens have accrued - env.ledger().with_mut(|l| l.timestamp += 200); - - // Pause — accrual stops but 20,000 tokens are still withdrawable - client.pause_stream(&stream_id); - - let status = client.get_stream_status(&stream_id); - - assert!(status.is_paused, "stream should be paused"); - assert!( - !status.is_active, - "paused stream must not be active (not accruing)" - ); - assert_eq!( - status.withdrawable, 20_000, - "20,000 tokens accrued before pause" - ); - assert!( - status.is_claimable, - "paused stream with accrued tokens must be claimable" - ); - } - - // ── Issue #338: extend_stream() comprehensive coverage ─────────────────── - - /// At t=500 (halfway), extend by 500 additional seconds. - /// Verifies end_time, remaining, active at original end, finished at new end, - /// and sender balance decreases by additional_seconds * rate at extension time. - #[test] - fn test_extend_stream_halfway_comprehensive() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let rate: i128 = 100; - let duration: u64 = 1000; - let total = rate * duration as i128; // 100_000 - let additional_seconds: u64 = 500; - let additional_tokens = rate * additional_seconds as i128; // 50_000 - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_id); - let token_client = soroban_sdk::token::Client::new(&env, &token_id); - - // Mint enough for original stream + extension - sac.mint(&sender, &(total + additional_tokens)); - - let stream_id = client.create_stream(&sender, &token_id, &recipient, &rate, &duration); - let original_end_time = client.get_stream(&stream_id).end_time; - assert_eq!(original_end_time, 1000); - - // At t=500 (halfway), extend by 500 seconds - env.ledger().with_mut(|l| l.timestamp = 500); - let sender_balance_before = token_client.balance(&sender); - client.extend_stream(&stream_id, &additional_seconds); - let sender_balance_after = token_client.balance(&sender); - - // Assert end_time = original_end_time + 500 - let stream = client.get_stream(&stream_id); - assert_eq!( - stream.end_time, - original_end_time + additional_seconds, - "end_time must equal original_end_time + additional_seconds" - ); - - // Assert sender balance decreased by additional_seconds * rate - assert_eq!( - sender_balance_before - sender_balance_after, - additional_tokens, - "sender balance must decrease by additional_seconds * rate at extension" - ); - - // Assert remaining reflects extended total - let status = client.get_stream_status(&stream_id); - // streamed at t=500 = 100 * 500 = 50_000; new total = 100 * 1500 = 150_000 - // remaining = 150_000 - 50_000 = 100_000 - assert_eq!( - status.remaining, 100_000, - "remaining must reflect extended total minus already streamed" - ); - - // Advance to original end_time (1000) — stream must still be active - env.ledger().with_mut(|l| l.timestamp = 1000); - let status_at_original_end = client.get_stream_status(&stream_id); - assert!( - status_at_original_end.is_active, - "stream must still be active at original end_time after extension" - ); - assert!( - !status_at_original_end.is_finished, - "stream must not be finished at original end_time after extension" - ); - - // Advance to new end_time (1500) — stream must be finished - env.ledger().with_mut(|l| l.timestamp = 1500); - let status_at_new_end = client.get_stream_status(&stream_id); - assert!( - status_at_new_end.is_finished, - "stream must be finished at new end_time" - ); - assert_eq!( - status_at_new_end.withdrawable, - total + additional_tokens, - "full extended total must be withdrawable at new end_time" - ); - } - - /// Extending a cancelled stream must revert with AlreadyCancelled. - #[test] - fn test_extend_cancelled_stream_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 200_000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - client.cancel_stream(&stream_id); - - let result = client.try_extend_stream(&stream_id, &500); - assert_eq!( - result, - Err(Ok(StreamError::AlreadyCancelled)), - "extending a cancelled stream must revert with AlreadyCancelled" - ); - } - - /// Extending after end_time must revert with StreamFinished. - #[test] - fn test_extend_after_end_time_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 200_000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - // Advance past end_time - env.ledger().with_mut(|l| l.timestamp = 1001); - - let result = client.try_extend_stream(&stream_id, &500); - assert_eq!( - result, - Err(Ok(StreamError::StreamFinished)), - "extending after end_time must revert with StreamFinished" - ); - } - - /// Extending a paused stream must revert with InvalidConfig so `end_time` - /// does not move while accrual is frozen. - #[test] - fn test_extend_paused_stream_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 200_000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - env.ledger().with_mut(|l| l.timestamp = 250); - client.pause_stream(&stream_id); - - let result = client.try_extend_stream(&stream_id, &500); - assert_eq!(result, Err(Ok(StreamError::InvalidConfig))); - } - - /// Extending with additional_seconds = 0 must revert with InvalidConfig. - #[test] - fn test_extend_zero_seconds_reverts() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100_000); - - let stream_id = client.create_stream(&sender, &token, &recipient, &100, &1000); - - let result = client.try_extend_stream(&stream_id, &0); - assert_eq!( - result, - Err(Ok(StreamError::InvalidConfig)), - "extending with 0 additional_seconds must revert with InvalidConfig" - ); - } - - // ── Minimum withdrawal amount tests ─────────────────────────────────────── - - /// Withdrawal below minimum should be rejected with BelowMinimumWithdrawal error - #[test] - fn test_withdrawal_below_minimum_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create stream with minimum withdrawal of 1000 tokens - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000, &1000); - - // Advance time by 5 seconds: 100 * 5 = 500 tokens accrued - env.ledger().with_mut(|l| l.timestamp += 5); - - // 500 < 1000 minimum, should be rejected - let result = client.try_withdraw(&stream_id); - assert_eq!( - result, - Err(Ok(StreamError::BelowMinimumWithdrawal)), - "withdrawal below minimum should be rejected" - ); - } - - /// Withdrawal above minimum should succeed - #[test] - fn test_withdrawal_above_minimum_succeeds() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create stream with minimum withdrawal of 1000 tokens - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000, &1000); - - // Advance time by 15 seconds: 100 * 15 = 1500 tokens accrued - env.ledger().with_mut(|l| l.timestamp += 15); - - // 1500 >= 1000 minimum, should succeed - let withdrawn = client.withdraw(&stream_id); - assert_eq!(withdrawn, 1500, "withdrawal above minimum should succeed"); - } - - /// End-of-stream should bypass minimum withdrawal restriction - #[test] - fn test_end_of_stream_bypass_minimum() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create stream with minimum withdrawal of 1000 tokens - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000, &1000); - - // Advance time by 5 seconds: 100 * 5 = 500 tokens accrued - env.ledger().with_mut(|l| l.timestamp += 5); - - // Advance past end_time (stream duration is 1000 seconds) - env.ledger().with_mut(|l| l.timestamp += 1000); - - // Even though 500 < 1000 minimum, should succeed because stream is finished - let withdrawn = client.withdraw(&stream_id); - assert_eq!( - withdrawn, 500, - "end-of-stream should bypass minimum restriction" - ); - } - - /// Zero minimum should impose no restriction - #[test] - fn test_zero_minimum_no_restriction() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create stream with zero minimum (no restriction) - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000, &0); - - // Advance time by 1 second: 100 * 1 = 100 tokens accrued - env.ledger().with_mut(|l| l.timestamp += 1); - - // Should succeed even with small amount since minimum is 0 - let withdrawn = client.withdraw(&stream_id); - assert_eq!(withdrawn, 100, "zero minimum should impose no restriction"); - } - - /// Negative minimum withdrawal amount should be rejected at creation - #[test] - fn test_negative_minimum_rejected_at_creation() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - let token = setup_token(&env, &sender, 100_000); - - // Negative minimum should be rejected - let result = client.try_create_stream(&sender, &token, &recipient, &100, &1000, &-1000); - assert_eq!( - result, - Err(Ok(StreamError::InvalidConfig)), - "negative minimum should be rejected at creation" - ); - } - - /// get_claimable() should return raw amount regardless of minimum - #[test] - fn test_get_claimable_ignores_minimum() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeStream); - let client = ForgeStreamClient::new(&env, &contract_id); - let sender = Address::generate(&env); - let recipient = Address::generate(&env); - - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - StellarAssetClient::new(&env, &token_id).mint(&sender, &10_000_000i128); - - // Create stream with minimum withdrawal of 1000 tokens - let stream_id = client.create_stream(&sender, &token_id, &recipient, &100, &1000, &1000); - - // Advance time by 5 seconds: 100 * 5 = 500 tokens accrued - env.ledger().with_mut(|l| l.timestamp += 5); - - // get_claimable should return 500 regardless of minimum - let claimable = client.get_claimable(&stream_id); - assert_eq!( - claimable, 500, - "get_claimable should ignore minimum restriction" - ); - } -} diff --git a/contracts/forge-vesting-factory/src/lib.rs b/contracts/forge-vesting-factory/src/lib.rs deleted file mode 100644 index 87a7906..0000000 --- a/contracts/forge-vesting-factory/src/lib.rs +++ /dev/null @@ -1,578 +0,0 @@ -#![no_std] - -//! # forge-vesting-factory -//! -//! A factory contract that manages multiple vesting schedules in a single deployment. -//! -//! ## Overview -//! - Create vesting schedules for multiple beneficiaries without deploying separate contracts -//! - Each schedule has its own token, beneficiary, admin, cliff, and duration -//! - Beneficiaries call `claim(schedule_id)` to withdraw unlocked tokens -//! - Admins call `cancel(schedule_id)` to cancel a schedule and reclaim unvested tokens -//! - Reduces deployment costs dramatically for multi-beneficiary vesting (e.g. employee grants) - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, -}; - -// ── Storage Keys ────────────────────────────────────────────────────────────── - -#[contracttype] -pub enum DataKey { - /// Per-schedule configuration, keyed by schedule_id. - Schedule(u64), - /// Cumulative claimed amount per schedule, keyed by schedule_id. - Claimed(u64), - /// Monotonically increasing schedule counter. - ScheduleCount, -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -#[contracttype] -#[derive(Clone)] -pub struct ScheduleConfig { - pub token: Address, - pub beneficiary: Address, - pub admin: Address, - pub total_amount: i128, - pub start_time: u64, - pub cliff_seconds: u64, - pub duration_seconds: u64, - pub cancelled: bool, -} - -/// Status snapshot for a vesting schedule. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct VestingStatus { - pub schedule_id: u64, - pub total_amount: i128, - pub claimed: i128, - pub vested: i128, - pub claimable: i128, - pub cliff_reached: bool, - pub fully_vested: bool, - pub cancelled: bool, -} - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum FactoryError { - ScheduleNotFound = 1, - Unauthorized = 2, - CliffNotReached = 3, - NothingToClaim = 4, - Cancelled = 5, - InvalidConfig = 6, -} - -// ── Contract ────────────────────────────────────────────────────────────────── - -#[contract] -pub struct ForgeVestingFactory; - -#[contractimpl] -impl ForgeVestingFactory { - /// Create a new vesting schedule and return its `schedule_id`. - /// - /// Transfers `total_amount` tokens from `admin` into the contract immediately. - /// Requires authorization from `admin`. - /// - /// # Parameters - /// - `token` — Soroban token contract address. - /// - `beneficiary` — Address that will receive vested tokens. - /// - `admin` — Address authorized to cancel this schedule. - /// - `total_amount` — Total tokens to vest. Must be > 0. - /// - `cliff_seconds` — Seconds before any tokens unlock. Must be ≤ `duration_seconds`. - /// - `duration_seconds` — Total vesting duration in seconds. Must be > 0. - /// - /// # Returns - /// `Ok(u64)` — the new schedule's ID. - /// - /// # Errors - /// - [`FactoryError::InvalidConfig`] — invalid amounts or durations. - pub fn create_schedule( - env: Env, - token: Address, - beneficiary: Address, - admin: Address, - total_amount: i128, - cliff_seconds: u64, - duration_seconds: u64, - ) -> Result { - admin.require_auth(); - - if total_amount <= 0 || duration_seconds == 0 || cliff_seconds > duration_seconds { - return Err(FactoryError::InvalidConfig); - } - - let id: u64 = env - .storage() - .instance() - .get(&DataKey::ScheduleCount) - .unwrap_or(0); - - let config = ScheduleConfig { - token: token.clone(), - beneficiary, - admin, - total_amount, - start_time: env.ledger().timestamp(), - cliff_seconds, - duration_seconds, - cancelled: false, - }; - - // Pull tokens from admin into the contract - token::Client::new(&env, &token).transfer( - &config.admin, - &env.current_contract_address(), - &total_amount, - ); - - env.storage() - .persistent() - .set(&DataKey::Schedule(id), &config); - env.storage() - .instance() - .set(&DataKey::ScheduleCount, &(id + 1)); - - env.events() - .publish((Symbol::new(&env, "schedule_created"),), (id, total_amount)); - - Ok(id) - } - - /// Claim all currently vested and unclaimed tokens for a schedule. - /// - /// Only the beneficiary may call this. Tokens are transferred directly to the beneficiary. - /// - /// # Parameters - /// - `schedule_id` — ID of the schedule to claim from. - /// - /// # Returns - /// `Ok(i128)` — amount of tokens transferred. - /// - /// # Errors - /// - [`FactoryError::ScheduleNotFound`] - /// - [`FactoryError::Cancelled`] - /// - [`FactoryError::CliffNotReached`] - /// - [`FactoryError::NothingToClaim`] - pub fn claim(env: Env, schedule_id: u64) -> Result { - let config: ScheduleConfig = env - .storage() - .persistent() - .get(&DataKey::Schedule(schedule_id)) - .ok_or(FactoryError::ScheduleNotFound)?; - - config.beneficiary.require_auth(); - - if config.cancelled { - return Err(FactoryError::Cancelled); - } - - let now = env.ledger().timestamp(); - let vested = Self::compute_vested(&config, now); - let claimed: i128 = env - .storage() - .persistent() - .get(&DataKey::Claimed(schedule_id)) - .unwrap_or(0); - - let elapsed = now.saturating_sub(config.start_time); - if elapsed < config.cliff_seconds { - return Err(FactoryError::CliffNotReached); - } - - let claimable = (vested - claimed).max(0); - if claimable == 0 { - return Err(FactoryError::NothingToClaim); - } - - env.storage() - .persistent() - .set(&DataKey::Claimed(schedule_id), &(claimed + claimable)); - - token::Client::new(&env, &config.token).transfer( - &env.current_contract_address(), - &config.beneficiary, - &claimable, - ); - - env.events() - .publish((Symbol::new(&env, "claimed"),), (schedule_id, claimable)); - - Ok(claimable) - } - - /// Cancel a vesting schedule. Vested tokens go to the beneficiary; remainder to admin. - /// - /// Only the admin may call this. - /// - /// # Parameters - /// - `schedule_id` — ID of the schedule to cancel. - /// - /// # Errors - /// - [`FactoryError::ScheduleNotFound`] - /// - [`FactoryError::Cancelled`] - /// - [`FactoryError::Unauthorized`] - pub fn cancel(env: Env, schedule_id: u64) -> Result<(), FactoryError> { - let mut config: ScheduleConfig = env - .storage() - .persistent() - .get(&DataKey::Schedule(schedule_id)) - .ok_or(FactoryError::ScheduleNotFound)?; - - config.admin.require_auth(); - - if config.cancelled { - return Err(FactoryError::Cancelled); - } - - let now = env.ledger().timestamp(); - let vested = Self::compute_vested(&config, now); - let claimed: i128 = env - .storage() - .persistent() - .get(&DataKey::Claimed(schedule_id)) - .unwrap_or(0); - - let token = token::Client::new(&env, &config.token); - - // Send unclaimed vested tokens to beneficiary - let beneficiary_amount = (vested - claimed).max(0); - if beneficiary_amount > 0 { - token.transfer( - &env.current_contract_address(), - &config.beneficiary, - &beneficiary_amount, - ); - } - - // Return unvested tokens to admin - let admin_amount = (config.total_amount - vested).max(0); - if admin_amount > 0 { - token.transfer( - &env.current_contract_address(), - &config.admin, - &admin_amount, - ); - } - - config.cancelled = true; - env.storage() - .persistent() - .set(&DataKey::Schedule(schedule_id), &config); - - env.events() - .publish((Symbol::new(&env, "schedule_cancelled"),), (schedule_id,)); - - Ok(()) - } - - /// Return the current vesting status for a schedule. - /// - /// Read-only; does not modify state. - /// - /// # Parameters - /// - `schedule_id` — ID of the schedule to query. - /// - /// # Returns - /// `Ok(VestingStatus)` with current vested, claimed, and claimable amounts. - /// - /// # Errors - /// - [`FactoryError::ScheduleNotFound`] - pub fn get_status(env: Env, schedule_id: u64) -> Result { - let config: ScheduleConfig = env - .storage() - .persistent() - .get(&DataKey::Schedule(schedule_id)) - .ok_or(FactoryError::ScheduleNotFound)?; - - let now = env.ledger().timestamp(); - let vested = Self::compute_vested(&config, now); - let claimed: i128 = env - .storage() - .persistent() - .get(&DataKey::Claimed(schedule_id)) - .unwrap_or(0); - - let elapsed = now.saturating_sub(config.start_time); - let claimable = if elapsed >= config.cliff_seconds { - (vested - claimed).max(0) - } else { - 0 - }; - - Ok(VestingStatus { - schedule_id, - total_amount: config.total_amount, - claimed, - vested, - claimable, - cliff_reached: elapsed >= config.cliff_seconds, - fully_vested: vested >= config.total_amount, - cancelled: config.cancelled, - }) - } - - /// Return the total number of schedules ever created. - pub fn get_schedule_count(env: Env) -> u64 { - env.storage() - .instance() - .get(&DataKey::ScheduleCount) - .unwrap_or(0) - } - - // ── Internal ────────────────────────────────────────────────────────────── - - fn compute_vested(config: &ScheduleConfig, now: u64) -> i128 { - if config.cancelled { - return 0; - } - let elapsed = now.saturating_sub(config.start_time); - if elapsed < config.cliff_seconds { - return 0; - } - if elapsed >= config.duration_seconds { - return config.total_amount; - } - (config.total_amount * elapsed as i128) / config.duration_seconds as i128 - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, - }; - - fn setup_token(env: &Env, admin: &Address, amount: i128) -> Address { - let token_admin = Address::generate(env); - let token = env - .register_stellar_asset_contract_v2(token_admin.clone()) - .address(); - token::Client::new(env, &token).mint(admin, &amount); - token - } - - fn make_client(env: &Env) -> ForgeVestingFactoryClient { - let id = env.register_contract(None, ForgeVestingFactory); - ForgeVestingFactoryClient::new(env, &id) - } - - #[test] - fn test_create_schedule_success() { - let env = Env::default(); - env.mock_all_auths(); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token = setup_token(&env, &admin, 1_000); - - let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &100, &1_000); - assert_eq!(id, 0); - assert_eq!(client.get_schedule_count(), 1); - } - - #[test] - fn test_create_multiple_schedules_sequential_ids() { - let env = Env::default(); - env.mock_all_auths(); - let client = make_client(&env); - let admin = Address::generate(&env); - let token = setup_token(&env, &admin, 3_000); - - for expected_id in 0u64..3 { - let b = Address::generate(&env); - let id = client.create_schedule(&token, &b, &admin, &1_000, &0, &1_000); - assert_eq!(id, expected_id); - } - assert_eq!(client.get_schedule_count(), 3); - } - - #[test] - fn test_claim_after_cliff() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token = setup_token(&env, &admin, 1_000); - - let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &100, &1_000); - - // Before cliff — claim must fail - env.ledger().with_mut(|l| l.timestamp = 50); - let err = client.try_claim(&id).unwrap_err(); - assert_eq!(err, Ok(FactoryError::CliffNotReached)); - - // After cliff — partial claim - env.ledger().with_mut(|l| l.timestamp = 500); - let claimed = client.claim(&id); - assert_eq!(claimed, 500); // 500/1000 * 1000 = 500 - - let status = client.get_status(&id); - assert_eq!(status.claimed, 500); - assert_eq!(status.claimable, 0); - } - - #[test] - fn test_claim_nothing_to_claim_after_full_claim() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token = setup_token(&env, &admin, 1_000); - - let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &0, &1_000); - - env.ledger().with_mut(|l| l.timestamp = 500); - client.claim(&id); - - // Second claim at same timestamp — nothing new - let err = client.try_claim(&id).unwrap_err(); - assert_eq!(err, Ok(FactoryError::NothingToClaim)); - } - - #[test] - fn test_cancel_splits_tokens_correctly() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token_addr = setup_token(&env, &admin, 1_000); - let tok = token::Client::new(&env, &token_addr); - - let id = client.create_schedule(&token_addr, &beneficiary, &admin, &1_000, &0, &1_000); - - // 300s elapsed — 300 tokens vested - env.ledger().with_mut(|l| l.timestamp = 300); - client.cancel(&id); - - assert_eq!(tok.balance(&beneficiary), 300); - assert_eq!(tok.balance(&admin), 700); - } - - #[test] - fn test_cancel_already_cancelled_fails() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token = setup_token(&env, &admin, 1_000); - - let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &0, &1_000); - client.cancel(&id); - - let err = client.try_cancel(&id).unwrap_err(); - assert_eq!(err, Ok(FactoryError::Cancelled)); - } - - #[test] - fn test_get_status_not_found() { - let env = Env::default(); - env.mock_all_auths(); - let client = make_client(&env); - - let err = client.try_get_status(&999).unwrap_err(); - assert_eq!(err, Ok(FactoryError::ScheduleNotFound)); - } - - #[test] - fn test_multiple_concurrent_schedules_independent() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let b1 = Address::generate(&env); - let b2 = Address::generate(&env); - let token = setup_token(&env, &admin, 2_000); - - let id1 = client.create_schedule(&token, &b1, &admin, &1_000, &0, &1_000); - let id2 = client.create_schedule(&token, &b2, &admin, &1_000, &0, &500); - - env.ledger().with_mut(|l| l.timestamp = 500); - - // id1: 500/1000 * 1000 = 500 vested - // id2: fully vested (500 >= 500) - let s1 = client.get_status(&id1); - let s2 = client.get_status(&id2); - - assert_eq!(s1.vested, 500); - assert!(!s1.fully_vested); - - assert_eq!(s2.vested, 1_000); - assert!(s2.fully_vested); - - // Claiming id2 does not affect id1 - client.claim(&id2); - let s1_after = client.get_status(&id1); - assert_eq!(s1_after.claimed, 0); - } - - #[test] - fn test_fully_vested_claim_returns_total() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 0); - let client = make_client(&env); - let admin = Address::generate(&env); - let beneficiary = Address::generate(&env); - let token_addr = setup_token(&env, &admin, 1_000); - let tok = token::Client::new(&env, &token_addr); - - let id = client.create_schedule(&token_addr, &beneficiary, &admin, &1_000, &0, &1_000); - - env.ledger().with_mut(|l| l.timestamp = 1_000); - let claimed = client.claim(&id); - assert_eq!(claimed, 1_000); - assert_eq!(tok.balance(&beneficiary), 1_000); - - let status = client.get_status(&id); - assert!(status.fully_vested); - assert_eq!(status.claimable, 0); - } - - #[test] - fn test_invalid_config_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let client = make_client(&env); - let admin = Address::generate(&env); - let b = Address::generate(&env); - let token = setup_token(&env, &admin, 1_000); - - // zero total_amount - assert_eq!( - client.try_create_schedule(&token, &b, &admin, &0, &0, &1_000).unwrap_err(), - Ok(FactoryError::InvalidConfig) - ); - // zero duration - assert_eq!( - client.try_create_schedule(&token, &b, &admin, &1_000, &0, &0).unwrap_err(), - Ok(FactoryError::InvalidConfig) - ); - // cliff > duration - assert_eq!( - client.try_create_schedule(&token, &b, &admin, &1_000, &500, &100).unwrap_err(), - Ok(FactoryError::InvalidConfig) - ); - } -} diff --git a/contracts/forge-vesting/src/lib.rs b/contracts/forge-vesting/src/lib.rs deleted file mode 100644 index d5e62d2..0000000 --- a/contracts/forge-vesting/src/lib.rs +++ /dev/null @@ -1,2270 +0,0 @@ -#![no_std] - -//! # forge-vesting -//! -//! Token vesting contract with configurable cliff and linear release schedule. -//! -//! ## Overview -//! - Deploy with a token, beneficiary, total amount, cliff period, and vesting duration -//! - After the cliff, tokens unlock linearly every second -//! - Beneficiary can call `claim()` at any time to withdraw unlocked tokens -//! - Admin can cancel vesting and reclaim unvested tokens - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, -}; - -// ── Storage Keys ────────────────────────────────────────────────────────────── - -#[contracttype] -pub enum DataKey { - Config, - Claimed, - VestedAtCancel, -} - -// ── Types ───────────────────────────────────────────────────────────────────── - -#[contracttype] -#[derive(Clone)] -pub struct VestingConfig { - /// Token contract address - pub token: Address, - /// Beneficiary who receives vested tokens - pub beneficiary: Address, - /// Admin who can cancel vesting - pub admin: Address, - /// Total tokens to vest - pub total_amount: i128, - /// Timestamp when vesting starts - pub start_time: u64, - /// Seconds before any tokens unlock - pub cliff_seconds: u64, - /// Total vesting duration in seconds - pub duration_seconds: u64, - /// Whether vesting has been cancelled - pub cancelled: bool, - /// Whether vesting is currently paused - pub paused: bool, - /// Ledger timestamp when vesting was paused (None if not paused) - pub paused_at: Option, -} - -#[contracttype] -#[derive(Clone)] -pub struct VestingStatus { - pub total_amount: i128, - pub claimed: i128, - pub vested: i128, - pub claimable: i128, - pub cliff_reached: bool, - pub fully_vested: bool, - pub paused: bool, -} - -/// Vesting schedule configuration (excludes admin and cancellation state). -/// -/// Returned by [`get_vesting_schedule`](crate::ForgeVesting::get_vesting_schedule) -/// to expose the original vesting parameters without sensitive or mutable fields. -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct VestingSchedule { - /// Token contract address - pub token: Address, - /// Beneficiary who receives vested tokens - pub beneficiary: Address, - /// Total tokens to vest - pub total_amount: i128, - /// Seconds before any tokens unlock - pub cliff_seconds: u64, - /// Total vesting duration in seconds - pub duration_seconds: u64, - /// Timestamp when vesting starts - pub start_time: u64, -} - -// ── Errors ──────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum VestingError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - CliffNotReached = 4, - NothingToClaim = 5, - Cancelled = 6, - InvalidConfig = 7, - SameAdmin = 8, - SameBeneficiary = 11, - BeneficiaryAsAdmin = 12, - Paused = 9, - NotPaused = 10, - VestingComplete = 13, -} - -// ── Contract ────────────────────────────────────────────────────────────────── - -#[contract] -pub struct ForgeVesting; - -#[contractimpl] -impl ForgeVesting { - /// Initialize a new vesting schedule. - /// - /// Sets up the vesting configuration and records the current ledger timestamp - /// as the start time. Must be called exactly once; subsequent calls return - /// [`VestingError::AlreadyInitialized`]. Requires authorization from `admin`. - /// - /// # Parameters - /// - `token` — Address of the Soroban token contract whose tokens are being vested. - /// - `beneficiary` — Address that will receive tokens as they vest. - /// - `admin` — Address authorized to cancel the vesting schedule. - /// - `total_amount` — Total number of tokens (in the token's smallest unit) to vest. - /// Must be greater than zero. - /// - `cliff_seconds` — Number of seconds after `start_time` before any tokens unlock. - /// Must be ≤ `duration_seconds`. - /// - `duration_seconds` — Total length of the vesting schedule in seconds. Must be > 0. - /// - /// # Returns - /// `Ok(())` on success, or a [`VestingError`] variant on failure. - /// - /// # Errors - /// - [`VestingError::AlreadyInitialized`] — Contract has already been initialized. - /// - [`VestingError::InvalidConfig`] — `total_amount` ≤ 0, `duration_seconds` == 0, - /// or `cliff_seconds` > `duration_seconds`. - /// - /// # Example - /// ```rust,ignore - /// // Vest 1 000 000 tokens over 1000 s with a 100 s cliff. - /// client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - /// ``` - pub fn initialize( - env: Env, - token: Address, - beneficiary: Address, - admin: Address, - total_amount: i128, - cliff_seconds: u64, - duration_seconds: u64, - ) -> Result<(), VestingError> { - if env.storage().instance().has(&DataKey::Config) { - return Err(VestingError::AlreadyInitialized); - } - if total_amount <= 0 || duration_seconds == 0 || cliff_seconds > duration_seconds { - return Err(VestingError::InvalidConfig); - } - if admin == beneficiary { - return Err(VestingError::BeneficiaryAsAdmin); - } - - admin.require_auth(); - - let config = VestingConfig { - token, - beneficiary, - admin, - total_amount, - start_time: env.ledger().timestamp(), - cliff_seconds, - duration_seconds, - cancelled: false, - paused: false, - paused_at: None, - }; - - env.storage().instance().set(&DataKey::Config, &config); - env.storage().instance().set(&DataKey::Claimed, &0_i128); - - env.events().publish( - (Symbol::new(&env, "vesting_initialized"),), - ( - config.total_amount, - config.cliff_seconds, - config.duration_seconds, - ), - ); - - Ok(()) - } - - /// Claim all currently vested and unclaimed tokens. - /// - /// Computes the amount vested up to the current ledger timestamp, subtracts - /// previously claimed tokens, and transfers the remainder to the beneficiary. - /// Requires authorization from the beneficiary. - /// - /// # Returns - /// `Ok(amount)` — the number of tokens transferred on this call. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - [`VestingError::Cancelled`] — The vesting schedule was cancelled by the admin. - /// - [`VestingError::CliffNotReached`] — Current time is before `start_time + cliff_seconds`. - /// - [`VestingError::NothingToClaim`] — All vested tokens have already been claimed. - /// - /// # Example - /// ```rust,ignore - /// // After the cliff has passed: - /// let claimed = client.claim(); // returns tokens vested so far - /// ``` - pub fn claim(env: Env) -> Result { - let config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - if config.cancelled { - return Err(VestingError::Cancelled); - } - - if config.paused { - return Err(VestingError::Paused); - } - - config.beneficiary.require_auth(); - - let now = env.ledger().timestamp(); - let elapsed = now.saturating_sub(config.start_time); - - if elapsed < config.cliff_seconds { - return Err(VestingError::CliffNotReached); - } - - let vested = Self::compute_vested(&config, now); - let claimed = Self::get_claimed(&env); - let claimable = vested - claimed; - - if claimable <= 0 { - return Err(VestingError::NothingToClaim); - } - - env.storage() - .instance() - .set(&DataKey::Claimed, &(claimed + claimable)); - - let token_client = token::Client::new(&env, &config.token); - token_client.transfer( - &env.current_contract_address(), - &config.beneficiary, - &claimable, - ); - - env.events().publish( - (Symbol::new(&env, "claimed"),), - (&config.beneficiary, claimable), - ); - - Ok(claimable) - } - - /// Cancel the vesting schedule and return unvested tokens to the admin. - /// - /// Computes how many tokens have vested (or been claimed) at the current ledger - /// timestamp and transfers the remainder back to `admin`. Once cancelled, neither - /// `claim` nor `cancel` can be called again. Requires authorization from `admin`. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - [`VestingError::Cancelled`] — The schedule is already cancelled. - /// - /// # Example - /// ```rust,ignore - /// // Admin decides to terminate the schedule early: - /// client.cancel(); // unvested tokens are returned to admin - /// ``` - pub fn cancel(env: Env) -> Result<(), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - config.admin.require_auth(); - - if config.cancelled { - return Err(VestingError::Cancelled); - } - - let now = env.ledger().timestamp(); - let elapsed = now.saturating_sub(config.start_time); - - if elapsed >= config.duration_seconds { - return Err(VestingError::VestingComplete); - } - - let vested = Self::compute_vested(&config, now); - let claimed = Self::get_claimed(&env); - - // Split tokens: vested-but-unclaimed goes to beneficiary, unvested goes to admin - let to_beneficiary = vested - claimed; - let to_admin = config.total_amount - vested; - - config.cancelled = true; - env.storage().instance().set(&DataKey::Config, &config); - env.storage() - .instance() - .set(&DataKey::VestedAtCancel, &vested); - // Update claimed to vested so get_status().claimable reflects 0 after cancel payout - env.storage().instance().set(&DataKey::Claimed, &vested); - - let token_client = token::Client::new(&env, &config.token); - - // Transfer vested-but-unclaimed tokens to beneficiary - if to_beneficiary > 0 { - token_client.transfer( - &env.current_contract_address(), - &config.beneficiary, - &to_beneficiary, - ); - } - - // Transfer unvested tokens to admin - if to_admin > 0 { - token_client.transfer(&env.current_contract_address(), &config.admin, &to_admin); - } - - env.events().publish( - (Symbol::new(&env, "vesting_cancelled"),), - (&config.admin, to_admin, &config.beneficiary, to_beneficiary), - ); - - Ok(()) - } - - /// Atomically claim all vested tokens for the beneficiary and return unvested tokens to the admin. - /// - /// Combines `claim()` and `cancel()` into a single transaction, eliminating the race condition - /// where an admin could cancel before a beneficiary claims. Requires authorization from both - /// `admin` and `beneficiary`. - /// - /// # Returns - /// `Ok((to_beneficiary, to_admin))` — tokens transferred to each party. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - [`VestingError::Cancelled`] — The schedule is already cancelled. - /// - [`VestingError::Paused`] — The schedule is currently paused. - pub fn cancel_and_claim(env: Env) -> Result<(i128, i128), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - if config.cancelled { - return Err(VestingError::Cancelled); - } - if config.paused { - return Err(VestingError::Paused); - } - - config.admin.require_auth(); - config.beneficiary.require_auth(); - - let now = env.ledger().timestamp(); - let vested = Self::compute_vested(&config, now); - let claimed = Self::get_claimed(&env); - let to_beneficiary = vested - claimed; - let to_admin = config.total_amount - vested; - - config.cancelled = true; - env.storage().instance().set(&DataKey::Config, &config); - env.storage() - .instance() - .set(&DataKey::VestedAtCancel, &vested); - env.storage() - .instance() - .set(&DataKey::Claimed, &(claimed + to_beneficiary)); - - let token_client = token::Client::new(&env, &config.token); - if to_beneficiary > 0 { - token_client.transfer( - &env.current_contract_address(), - &config.beneficiary, - &to_beneficiary, - ); - } - if to_admin > 0 { - token_client.transfer(&env.current_contract_address(), &config.admin, &to_admin); - } - - env.events().publish( - (Symbol::new(&env, "claimed"),), - (&config.beneficiary, to_beneficiary), - ); - env.events().publish( - (Symbol::new(&env, "vesting_cancelled"),), - (&config.admin, to_admin, &config.beneficiary, to_beneficiary), - ); - - Ok((to_beneficiary, to_admin)) - } - - /// Transfer admin rights to a new address. - /// - /// Allows the current admin to transfer their admin privileges to a new address. - /// This is useful when teams change or multisigs are rotated. Requires authorization - /// from the current admin. - /// - /// # Parameters - /// - `new_admin` — Address that will become the new admin. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - [`VestingError::SameAdmin`] — `new_admin` is the same as the current admin. - /// - /// # Example - /// ```rust,ignore - /// // Transfer admin rights to a new multisig: - /// client.transfer_admin(&new_admin_address); - /// ``` - pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - config.admin.require_auth(); - - if config.admin == new_admin { - return Err(VestingError::SameAdmin); - } - if config.beneficiary == new_admin { - return Err(VestingError::BeneficiaryAsAdmin); - } - - let old_admin = config.admin; - config.admin = new_admin.clone(); - env.storage().instance().set(&DataKey::Config, &config); - - env.events().publish( - (Symbol::new(&env, "admin_transferred"),), - (&old_admin, &new_admin), - ); - - Ok(()) - } - - /// Transfer beneficiary rights to a new address. - /// - /// Allows the current beneficiary to transfer their vesting rights to a new address. - /// This is useful for wallet migration scenarios or when transferring vesting rights - /// to another party. Requires authorization from the current beneficiary. - /// - /// # Parameters - /// - `new_beneficiary` — Address that will become the new beneficiary. - /// - /// # Returns - /// `Ok(())` on success. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - [`VestingError::Cancelled`] — The vesting schedule has been cancelled. - /// - [`VestingError::SameBeneficiary`] — `new_beneficiary` is the same as the current beneficiary. - /// - /// # Example - /// ```rust,ignore - /// // Transfer beneficiary rights to a new wallet: - /// client.change_beneficiary(&new_beneficiary_address); - /// ``` - pub fn change_beneficiary(env: Env, new_beneficiary: Address) -> Result<(), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - config.beneficiary.require_auth(); - - if config.cancelled { - return Err(VestingError::Cancelled); - } - - if config.beneficiary == new_beneficiary { - return Err(VestingError::SameBeneficiary); - } - - let old_beneficiary = config.beneficiary; - config.beneficiary = new_beneficiary.clone(); - env.storage().instance().set(&DataKey::Config, &config); - - env.events().publish( - (Symbol::new(&env, "beneficiary_changed"),), - (&old_beneficiary, &new_beneficiary), - ); - - Ok(()) - } - - /// Return a snapshot of the current vesting status. - /// - /// Reads the ledger timestamp and computes vested, claimed, and claimable - /// amounts without modifying any state. Safe to call by anyone. - /// - /// # Returns - /// `Ok(`[`VestingStatus`]`)` containing: - /// - `total_amount` — Total tokens in the schedule. - /// - `claimed` — Tokens already transferred to the beneficiary. - /// - `vested` — Tokens unlocked so far (including already claimed). - /// - `claimable` — Tokens available to claim right now (`vested - claimed`). - /// - `cliff_reached` — `true` if the cliff timestamp has passed. - /// - `fully_vested` — `true` if the full duration has elapsed. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - /// # Example - /// ```rust,ignore - /// let status = client.get_status(); - /// if status.cliff_reached { - /// println!("Claimable: {}", status.claimable); - /// } - /// ``` - pub fn get_status(env: Env) -> Result { - let config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - let now = env.ledger().timestamp(); - let elapsed = now.saturating_sub(config.start_time); - let cliff_reached = elapsed >= config.cliff_seconds; - let vested = if config.cancelled { - env.storage() - .instance() - .get(&DataKey::VestedAtCancel) - .unwrap_or(0) - } else { - Self::compute_vested(&config, now) - }; - let claimed = Self::get_claimed(&env); - let claimable = (vested - claimed).max(0); - let fully_vested = vested >= config.total_amount; - - Ok(VestingStatus { - total_amount: config.total_amount, - claimed, - vested, - claimable, - cliff_reached, - fully_vested, - paused: config.paused, - }) - } - - /// Return the full vesting configuration set at initialization. - /// - /// Exposes all fields of [`VestingConfig`] including token, beneficiary, admin, - /// amounts, timing parameters, and cancellation status. Read-only; does not - /// modify state. - /// - /// # Deprecation Notice - /// - /// **Prefer [`get_vesting_schedule`] and [`get_status`] for public-facing reads.** - /// `get_config` exposes the admin address and internal cancellation flag, which - /// may be a privacy concern in some deployments. Use the alternatives instead: - /// - [`get_vesting_schedule`] — token, beneficiary, amounts, and timing (no admin) - /// - [`get_status`] — claimable amount, vested amount, cliff status, and pause state - /// - /// `get_config` is retained for admin tooling and backward compatibility. - /// - /// # Returns - /// `Ok(`[`VestingConfig`]`)` with the stored configuration. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - pub fn get_config(env: Env) -> Result { - env.storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized) - } - - /// Return the vesting schedule parameters. - /// - /// Exposes the original vesting configuration including token, beneficiary, - /// total amount, cliff, duration, and start time. Unlike [`get_config`], - /// this excludes admin and cancellation state for a cleaner public interface. - /// Read-only; does not modify state. - /// - /// # Returns - /// `Ok(`[`VestingSchedule`]`)` containing the vesting schedule parameters. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — `initialize` has not been called. - /// - /// # Example - /// ```text - /// let schedule = client.get_vesting_schedule(); - /// println!("Total: {}, Cliff: {}s, Duration: {}s", - /// schedule.total_amount, schedule.cliff_seconds, schedule.duration_seconds); - /// ``` - pub fn get_vesting_schedule(env: Env) -> Result { - let config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - Ok(VestingSchedule { - token: config.token, - beneficiary: config.beneficiary, - total_amount: config.total_amount, - cliff_seconds: config.cliff_seconds, - duration_seconds: config.duration_seconds, - start_time: config.start_time, - }) - } - - // ── Pause / Unpause ─────────────────────────────────────────────────────── - - /// Pause the vesting schedule, freezing token accumulation. - /// - /// While paused, `claim()` is blocked and `compute_vested` uses `paused_at` - /// as the effective current time so the vested amount stays frozen. - /// Requires authorization from `admin`. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — Contract not initialized. - /// - [`VestingError::Paused`] — Already paused. - pub fn pause(env: Env) -> Result<(), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - config.admin.require_auth(); - - if config.cancelled { - return Err(VestingError::Cancelled); - } - - if config.paused { - return Err(VestingError::Paused); - } - - config.paused = true; - config.paused_at = Some(env.ledger().timestamp()); - env.storage().instance().set(&DataKey::Config, &config); - - Ok(()) - } - - /// Unpause the vesting schedule, shifting the timeline forward by the pause duration. - /// - /// Calculates `delta = now - paused_at` and adds it to both `start_time` and - /// `end_time` (via `duration_seconds` anchor) so the full remaining schedule - /// is preserved. Requires authorization from `admin`. - /// - /// # Errors - /// - [`VestingError::NotInitialized`] — Contract not initialized. - /// - [`VestingError::NotPaused`] — Not currently paused. - pub fn unpause(env: Env) -> Result<(), VestingError> { - let mut config: VestingConfig = env - .storage() - .instance() - .get(&DataKey::Config) - .ok_or(VestingError::NotInitialized)?; - - config.admin.require_auth(); - - if config.cancelled { - return Err(VestingError::Cancelled); - } - - if !config.paused { - return Err(VestingError::NotPaused); - } - - let now = env.ledger().timestamp(); - let paused_at = config.paused_at.unwrap_or(now); - let delta = now.saturating_sub(paused_at); - config.start_time = config.start_time.saturating_add(delta); - config.paused = false; - config.paused_at = None; - env.storage().instance().set(&DataKey::Config, &config); - - Ok(()) - } - - // ── Private ─────────────────────────────────────────────────────────────── - - /// Get the claimed amount from storage. - /// - /// Returns 0 if called before initialization (though this should never happen - /// in practice since all public methods check for initialization first). - fn get_claimed(env: &Env) -> i128 { - env.storage().instance().get(&DataKey::Claimed).unwrap_or(0) - } - - fn compute_vested(config: &VestingConfig, now: u64) -> i128 { - if config.cancelled { - return 0; - } - let effective_now = if config.paused { config.paused_at.unwrap_or(now) } else { now }; - let elapsed = effective_now.saturating_sub(config.start_time); - if elapsed < config.cliff_seconds { - return 0; - } - if elapsed >= config.duration_seconds { - return config.total_amount; - } - (config.total_amount * elapsed as i128) / config.duration_seconds as i128 - } -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - extern crate std; - use super::*; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, - }; - - fn setup() -> (Env, Address, Address, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token = Address::generate(&env); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - (env, contract_id, token, beneficiary, admin) - } - - #[test] - fn test_initialize_success() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - let result = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - assert!(result.is_ok()); - } - - #[test] - fn test_cancel_after_full_vesting_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Advance past duration - env.ledger().with_mut(|l| l.timestamp += 1001); - let result = client.try_cancel(); - assert_eq!(result, Err(Ok(VestingError::VestingComplete))); - } - - #[test] - fn test_claim_after_failed_cancel_succeeds() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Mock token transfer for claim - env.mock_all_auths(); - - // Advance to full vesting - env.ledger().with_mut(|l| l.timestamp += 1000); - - // Cancel fails - let cancel_result = client.try_cancel(); - assert_eq!(cancel_result, Err(Ok(VestingError::VestingComplete))); - - // Beneficiary can still claim - let claim_result = client.try_claim(); - assert!(claim_result.is_ok()); - assert_eq!(claim_result.unwrap(), Ok(1_000_000)); - } - - #[test] - fn test_compute_vested_dust_verification() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - // setup_with_token mints 1_000_000; we only vest 1000 here - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1000, &0, &3); - - env.mock_all_auths(); - - // Start at t=0 - let start_ts = env.ledger().timestamp(); - - // t=1 - env.ledger().with_mut(|l| l.timestamp = start_ts + 1); - let v1 = client.claim(); - assert_eq!(v1, 333); // (1000 * 1) / 3 = 333 - - // t=2 - env.ledger().with_mut(|l| l.timestamp = start_ts + 2); - let v2 = client.claim(); - assert_eq!(v2, 333); // (1000 * 2) / 3 - 333 = 666 - 333 = 333 - - // t=3 - env.ledger().with_mut(|l| l.timestamp = start_ts + 3); - let v3 = client.claim(); - assert_eq!(v3, 334); // 1000 - 666 = 334 - - assert_eq!(v1 + v2 + v3, 1000); - } - - #[test] - fn test_double_initialize_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - // Initial setup - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Attempt re-initialization with DIFFERENT values - let new_beneficiary = Address::generate(&env); - let new_admin = Address::generate(&env); - let result = client.try_initialize( - &token, - &new_beneficiary, - &new_admin, - &9_999_999, - &500, - &5000, - ); - - // Assert it fails with AlreadyInitialized - assert_eq!(result, Err(Ok(VestingError::AlreadyInitialized))); - - // Verify original state is unchanged - let config = client.get_config(); - assert_eq!(config.token, token); - assert_eq!(config.beneficiary, beneficiary); - assert_eq!(config.admin, admin); - assert_eq!(config.total_amount, 1_000_000); - assert_eq!(config.cliff_seconds, 100); - assert_eq!(config.duration_seconds, 1000); - assert!(!config.cancelled); - } - - #[test] - fn test_claim_before_cliff_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &500, &1000); - // advance 100s — still before cliff of 500 - env.ledger().with_mut(|l| l.timestamp += 100); - let result = client.try_claim(); - assert_eq!(result, Err(Ok(VestingError::CliffNotReached))); - } - - #[test] - fn test_get_status_before_cliff() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &500, &1000); - let status = client.get_status(); - assert!(!status.cliff_reached); - assert_eq!(status.claimable, 0); - assert_eq!(status.claimed, 0); - } - - #[test] - fn test_get_vesting_schedule_returns_init_params() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &2_500_000, &200, &5000); - - let schedule = client.get_vesting_schedule(); - assert_eq!(schedule.token, token); - assert_eq!(schedule.beneficiary, beneficiary); - assert_eq!(schedule.total_amount, 2_500_000); - assert_eq!(schedule.cliff_seconds, 200); - assert_eq!(schedule.duration_seconds, 5000); - assert_eq!(schedule.start_time, env.ledger().timestamp()); - } - - #[test] - fn test_get_vesting_schedule_matches_init_params() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - let total = 10_000_000_i128; - let cliff = 86400_u64; // 1 day - let duration = 31536000_u64; // 1 year - - client.initialize(&token, &beneficiary, &admin, &total, &cliff, &duration); - - let schedule = client.get_vesting_schedule(); - assert_eq!(schedule.total_amount, total); - assert_eq!(schedule.cliff_seconds, cliff); - assert_eq!(schedule.duration_seconds, duration); - } - - #[test] - fn test_get_vesting_schedule_fails_when_not_initialized() { - let (env, contract_id, _, _, _) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - let result = client.try_get_vesting_schedule(); - assert_eq!(result, Err(Ok(VestingError::NotInitialized))); - } - - #[test] - fn test_schedule_and_status_provide_full_ui_info_without_get_config() { - // get_vesting_schedule() + get_status() together expose everything a UI - // needs: token, beneficiary, amounts, timing, claimable — without - // leaking the admin address or internal cancellation flag. - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Advance past cliff - env.ledger().with_mut(|l| l.timestamp += 500); - - let schedule = client.get_vesting_schedule(); - assert_eq!(schedule.token, token); - assert_eq!(schedule.beneficiary, beneficiary); - assert_eq!(schedule.total_amount, 1_000_000); - assert_eq!(schedule.cliff_seconds, 100); - assert_eq!(schedule.duration_seconds, 1000); - - let status = client.get_status(); - assert!(status.cliff_reached); - assert!(status.claimable > 0); - assert_eq!(status.claimed, 0); - assert!(!status.fully_vested); - } - - #[test] - fn test_invalid_config_rejected() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - // cliff > duration is invalid - let result = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &2000, &1000); - assert_eq!(result, Err(Ok(VestingError::InvalidConfig))); - } - - /// Test initialize() with total_amount = 0 returns InvalidConfig - #[test] - fn test_initialize_total_amount_zero_returns_invalid_config() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - // Try to initialize with total_amount = 0 - let result = client.try_initialize(&token, &beneficiary, &admin, &0, &100, &1000); - assert_eq!(result, Err(Ok(VestingError::InvalidConfig))); - - // Verify no config is stored after failed call - let config_result = client.try_get_config(); - assert_eq!(config_result, Err(Ok(VestingError::NotInitialized))); - } - - /// Test initialize() with total_amount = -1 returns InvalidConfig - #[test] - fn test_initialize_total_amount_negative_returns_invalid_config() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - // Try to initialize with total_amount = -1 - let result = client.try_initialize(&token, &beneficiary, &admin, &-1, &100, &1000); - assert_eq!(result, Err(Ok(VestingError::InvalidConfig))); - - // Verify no config is stored after failed call - let config_result = client.try_get_config(); - assert_eq!(config_result, Err(Ok(VestingError::NotInitialized))); - } - - /// Test initialize() with duration_seconds = 0 returns InvalidConfig - #[test] - fn test_initialize_duration_zero_returns_invalid_config() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - // Try to initialize with duration_seconds = 0 - let result = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &100, &0); - assert_eq!(result, Err(Ok(VestingError::InvalidConfig))); - - // Verify no config is stored after failed call - let config_result = client.try_get_config(); - assert_eq!(config_result, Err(Ok(VestingError::NotInitialized))); - } - - /// Test that subsequent valid initialize() succeeds after failed attempts - #[test] - fn test_valid_initialize_succeeds_after_invalid_attempts() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - // Attempt 1: total_amount = 0 (should fail) - let result1 = client.try_initialize(&token, &beneficiary, &admin, &0, &100, &1000); - assert_eq!(result1, Err(Ok(VestingError::InvalidConfig))); - assert_eq!( - client.try_get_config(), - Err(Ok(VestingError::NotInitialized)) - ); - - // Attempt 2: total_amount = -1 (should fail) - let result2 = client.try_initialize(&token, &beneficiary, &admin, &-1, &100, &1000); - assert_eq!(result2, Err(Ok(VestingError::InvalidConfig))); - assert_eq!( - client.try_get_config(), - Err(Ok(VestingError::NotInitialized)) - ); - - // Attempt 3: duration_seconds = 0 (should fail) - let result3 = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &100, &0); - assert_eq!(result3, Err(Ok(VestingError::InvalidConfig))); - assert_eq!( - client.try_get_config(), - Err(Ok(VestingError::NotInitialized)) - ); - - // Attempt 4: cliff > duration (should fail) - let result4 = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &2000, &1000); - assert_eq!(result4, Err(Ok(VestingError::InvalidConfig))); - assert_eq!( - client.try_get_config(), - Err(Ok(VestingError::NotInitialized)) - ); - - // Final attempt: valid parameters (should succeed) - let result5 = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - assert!(result5.is_ok()); - - // Verify config is properly stored after successful initialization - let config = client.get_config(); - assert_eq!(config.token, token); - assert_eq!(config.beneficiary, beneficiary); - assert_eq!(config.admin, admin); - assert_eq!(config.total_amount, 1_000_000); - assert_eq!(config.cliff_seconds, 100); - assert_eq!(config.duration_seconds, 1000); - assert!(!config.cancelled); - assert!(!config.paused); - } - - #[test] - fn test_cancel_by_admin() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - let result = client.try_cancel(); - assert!(result.is_ok()); - } - - #[test] - fn test_double_cancel_fails() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - client.cancel(); - let result = client.try_cancel(); - assert_eq!(result, Err(Ok(VestingError::Cancelled))); - } - - #[test] - fn test_claim_after_cancel_fails() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - client.cancel(); - env.ledger().with_mut(|l| l.timestamp += 200); - let result = client.try_claim(); - assert_eq!(result, Err(Ok(VestingError::Cancelled))); - } - - #[test] - fn test_fully_vested_after_duration() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - env.ledger().with_mut(|l| l.timestamp += 2000); - let status = client.get_status(); - assert!(status.fully_vested); - assert_eq!(status.vested, 1_000_000); - } - - /// Verifies get_status() reflects correct state across two partial claims. - /// - /// Timeline (total=10_000, cliff=0, duration=1000): - /// - t=200: vested=2_000, claimed=0, claimable=2_000 - /// - claim() at t=200 - /// - t=200: vested=2_000, claimed=2_000, claimable=0 - /// - t=500: vested=5_000, claimed=2_000, claimable=3_000 - /// - claim() at t=500 - /// - t=500: vested=5_000, claimed=5_000, claimable=0 - #[test] - fn test_get_status_after_partial_claim_then_time_advance() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &10_000, &0, &1000); - - // t=200: 20% vested, nothing claimed yet - env.ledger().with_mut(|l| l.timestamp = 200); - let s = client.get_status(); - assert_eq!(s.vested, 2_000); - assert_eq!(s.claimed, 0); - assert_eq!(s.claimable, 2_000); - - client.claim(); - - // immediately after claim: claimable drains to 0 - let s = client.get_status(); - assert_eq!(s.vested, 2_000); - assert_eq!(s.claimed, 2_000); - assert_eq!(s.claimable, 0); - - // t=500: 50% vested, only the new 3_000 is claimable - env.ledger().with_mut(|l| l.timestamp = 500); - let s = client.get_status(); - assert_eq!(s.vested, 5_000); - assert_eq!(s.claimed, 2_000); - assert_eq!(s.claimable, 3_000); - - client.claim(); - - // after second claim: claimed accumulates, claimable is 0 again - let s = client.get_status(); - assert_eq!(s.claimed, 5_000); - assert_eq!(s.claimable, 0); - } - - fn setup_with_token() -> (Env, Address, Address, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - { - soroban_sdk::token::StellarAssetClient::new(&env, &token_id) - .mint(&contract_id, &1_000_000); - } - (env, contract_id, token_id, beneficiary, admin) - } - - #[test] - fn test_cancel_before_cliff_beneficiary_gets_zero_admin_gets_all() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &1000); - - // advance 100s — still before cliff of 500s - env.ledger().with_mut(|l| l.timestamp += 100); - client.cancel(); - - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 0); - assert_eq!(tc.balance(&admin), 1_000_000); - } - - #[test] - fn test_cancel_after_cliff_splits_tokens_correctly() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - - // advance 400s — past cliff, 40% vested - env.ledger().with_mut(|l| l.timestamp += 400); - client.claim(); - client.cancel(); - - let tc = soroban_sdk::token::Client::new(&env, &token_id); - // 400/1000 * 1_000_000 = 400_000 vested → beneficiary - // remaining 600_000 → admin - assert_eq!(tc.balance(&beneficiary), 400_000); - assert_eq!(tc.balance(&admin), 600_000); - } - - #[test] - fn test_cancel_without_claim_sends_vested_to_beneficiary() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - - // advance 400s — past cliff, 40% vested, but NO claim - env.ledger().with_mut(|l| l.timestamp += 400); - client.cancel(); - - let tc = soroban_sdk::token::Client::new(&env, &token_id); - // 400/1000 * 1_000_000 = 400_000 vested → beneficiary (even without claim) - // remaining 600_000 → admin - assert_eq!(tc.balance(&beneficiary), 400_000); - assert_eq!(tc.balance(&admin), 600_000); - } - - #[test] - fn test_transfer_admin_success() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - let new_admin = Address::generate(&env); - let result = client.try_transfer_admin(&new_admin); - assert!(result.is_ok()); - let config = client.get_config(); - assert_eq!(config.admin, new_admin); - } - - #[test] - fn test_transfer_admin_allows_new_admin_to_cancel_old_admin_cannot() { - use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; - use soroban_sdk::IntoVal; - - let (env, contract_id, token_id, beneficiary, admin_a) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin_a, &1_000_000, &100, &1000); - - let admin_b = Address::generate(&env); - client.transfer_admin(&admin_b); - - env.mock_auths(&[MockAuth { - address: &admin_a, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "cancel", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]); - assert!( - client.try_cancel().is_err(), - "old admin should not be able to cancel after transfer" - ); - - env.mock_auths(&[MockAuth { - address: &admin_b, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "cancel", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]); - client.cancel(); - - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 0); - assert_eq!(tc.balance(&admin_b), 1_000_000); - } - - #[test] - fn test_transfer_admin_by_non_admin_fails() { - use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; - use soroban_sdk::IntoVal; - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token = Address::generate(&env); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - let non_admin = Address::generate(&env); - env.mock_auths(&[MockAuth { - address: &non_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "transfer_admin", - args: (&non_admin,).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_transfer_admin(&non_admin); - assert!(result.is_err()); - } - - #[test] - fn test_transfer_admin_to_same_admin_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - let result = client.try_transfer_admin(&admin); - assert_eq!(result, Err(Ok(VestingError::SameAdmin))); - } - - #[test] - fn test_transfer_admin_to_beneficiary_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - let result = client.try_transfer_admin(&beneficiary); - assert_eq!(result, Err(Ok(VestingError::BeneficiaryAsAdmin))); - } - - #[test] - fn test_initialize_with_admin_as_beneficiary_fails() { - let (env, contract_id, token, _, _) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - let same_address = Address::generate(&env); - let result = client.try_initialize( - &token, - &same_address, - &same_address, - &1_000_000, - &100, - &1000, - ); - assert_eq!(result, Err(Ok(VestingError::BeneficiaryAsAdmin))); - } - - #[test] - fn test_change_beneficiary_success() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - let new_beneficiary = Address::generate(&env); - let result = client.try_change_beneficiary(&new_beneficiary); - assert!(result.is_ok()); - - let config = client.get_config(); - assert_eq!(config.beneficiary, new_beneficiary); - } - - #[test] - fn test_change_beneficiary_by_non_beneficiary_fails() { - use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; - use soroban_sdk::IntoVal; - - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token = Address::generate(&env); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - let non_beneficiary = Address::generate(&env); - let new_beneficiary = Address::generate(&env); - env.mock_auths(&[MockAuth { - address: &non_beneficiary, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "change_beneficiary", - args: (&new_beneficiary,).into_val(&env), - sub_invokes: &[], - }, - }]); - let result = client.try_change_beneficiary(&new_beneficiary); - assert!(result.is_err()); - } - - #[test] - fn test_change_beneficiary_to_same_beneficiary_fails() { - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - let result = client.try_change_beneficiary(&beneficiary); - assert_eq!(result, Err(Ok(VestingError::SameBeneficiary))); - } - - #[test] - fn test_change_beneficiary_preserves_claimed_amount() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Advance past cliff and claim some tokens - env.ledger().with_mut(|l| l.timestamp += 500); - let claimed_amount = client.claim(); - - // Change beneficiary - let new_beneficiary = Address::generate(&env); - client.change_beneficiary(&new_beneficiary); - - // Verify claimed amount is preserved - let status = client.get_status(); - assert_eq!(status.claimed, claimed_amount); - - // Verify new beneficiary can claim remaining tokens - env.ledger().with_mut(|l| l.timestamp += 500); - let tc = soroban_sdk::token::Client::new(&env, &token_id); - let new_beneficiary_balance_before = tc.balance(&new_beneficiary); - client.claim(); - let new_beneficiary_balance_after = tc.balance(&new_beneficiary); - assert!(new_beneficiary_balance_after > new_beneficiary_balance_before); - } - - #[test] - fn test_change_beneficiary_not_initialized_fails() { - let (env, contract_id, _, _, _) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - let new_beneficiary = Address::generate(&env); - let result = client.try_change_beneficiary(&new_beneficiary); - assert_eq!(result, Err(Ok(VestingError::NotInitialized))); - } - - #[test] - fn test_change_beneficiary_cancelled_fails() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - client.cancel(); - let new_beneficiary = Address::generate(&env); - let result = client.try_change_beneficiary(&new_beneficiary); - assert_eq!(result, Err(Ok(VestingError::Cancelled))); - } - - /// Verifies the fully vested state end-to-end: - /// - `get_status().fully_vested` is true after the full duration elapses. - /// - `claimable` equals `total_amount - already_claimed` (handles partial prior claims). - /// - `claim()` transfers exactly the remaining balance to the beneficiary. - /// - A subsequent `claim()` fails with `NothingToClaim` — no tokens remain. - #[test] - fn test_fully_vested_claim_remaining_tokens() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 0; - const DURATION: u64 = 31_536_000; // 365 days - - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(Address::generate(&env)) - .address(); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &TOTAL); - - let client = ForgeVestingClient::new(&env, &contract_id); - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // Partial claim at 50% through vesting - env.ledger().with_mut(|l| l.timestamp = DURATION / 2); - let partial = client.claim(); - assert!(partial > 0); - - // Advance past full duration - env.ledger().with_mut(|l| l.timestamp = DURATION + 1); - - // Status checks - let status = client.get_status(); - assert!(status.fully_vested); - assert_eq!(status.claimable, TOTAL - partial); - - // Claim remaining and verify token balance - let tc = soroban_sdk::token::Client::new(&env, &token_id); - let before = tc.balance(&beneficiary); - let remaining = client.claim(); - assert_eq!(remaining, TOTAL - partial); - assert_eq!(tc.balance(&beneficiary), before + remaining); - - // Second claim must fail — nothing left - assert_eq!(client.try_claim(), Err(Ok(VestingError::NothingToClaim))); - } - - // ── Cliff == Duration (instant-vest) edge case ──────────────────────────── - - /// Helper: sets up a vesting schedule where cliff == duration so all tokens - /// vest at once at the cliff moment. - fn setup_cliff_equals_duration() -> (Env, Address, Address, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token_admin = Address::generate(&env); - let token_id = env - .register_stellar_asset_contract_v2(token_admin) - .address(); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &1_000_000); - (env, contract_id, token_id, beneficiary, admin) - } - - #[test] - fn test_cliff_equals_duration_initialize_succeeds() { - // duration_seconds == cliff_seconds must be accepted (cliff <= duration) - let (env, contract_id, token_id, beneficiary, admin) = setup_cliff_equals_duration(); - let client = ForgeVestingClient::new(&env, &contract_id); - let result = client.try_initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &500); - assert!(result.is_ok()); - } - - #[test] - fn test_cliff_equals_duration_before_cliff_claimable_is_zero() { - // Before the cliff, nothing should be claimable - let (env, contract_id, token_id, beneficiary, admin) = setup_cliff_equals_duration(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &500); - - // advance to just before the cliff - env.ledger().with_mut(|l| l.timestamp += 499); - - let status = client.get_status(); - assert!(!status.cliff_reached); - assert_eq!(status.claimable, 0); - assert_eq!(status.vested, 0); - - // claim should fail with CliffNotReached - let result = client.try_claim(); - assert_eq!(result, Err(Ok(VestingError::CliffNotReached))); - } - - #[test] - fn test_cliff_equals_duration_at_cliff_all_tokens_vested() { - // Exactly at the cliff timestamp the full amount should be vested - let (env, contract_id, token_id, beneficiary, admin) = setup_cliff_equals_duration(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &500); - - // advance exactly to the cliff / duration boundary - env.ledger().with_mut(|l| l.timestamp += 500); - - let status = client.get_status(); - assert!(status.cliff_reached); - assert!(status.fully_vested); - assert_eq!(status.vested, 1_000_000); - assert_eq!(status.claimable, 1_000_000); - } - - #[test] - fn test_cliff_equals_duration_claim_transfers_full_amount_in_one_call() { - // A single claim() call after the cliff should transfer all tokens at once - let (env, contract_id, token_id, beneficiary, admin) = setup_cliff_equals_duration(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &500); - - env.ledger().with_mut(|l| l.timestamp += 500); - - let claimed = client.claim(); - assert_eq!(claimed, 1_000_000); - - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 1_000_000); - - // nothing left to claim - let result = client.try_claim(); - assert_eq!(result, Err(Ok(VestingError::NothingToClaim))); - } - - #[test] - fn test_invariant_claimed_never_exceeds_vested() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - let total_amount = 1_000_000_i128; - let cliff_seconds = 100_u64; - let duration_seconds = 1000_u64; - - client.initialize( - &token_id, - &beneficiary, - &admin, - &total_amount, - &cliff_seconds, - &duration_seconds, - ); - - // Track cumulative claimed amount - let mut cumulative_claimed = 0_i128; - - // Test points: before cliff, at cliff, mid-vesting, fully vested - let test_timestamps = [ - 50_u64, // Before cliff - 100, // At cliff - 300, // 30% through vesting - 550, // 55% through vesting - 800, // 80% through vesting - 1000, // Fully vested - 1500, // Past vesting end - ]; - - for ×tamp in &test_timestamps { - env.ledger().with_mut(|l| l.timestamp = timestamp); - - let status = client.get_status(); - - // Core invariant 1: claimed <= vested - assert!( - status.claimed <= status.vested, - "Invariant violated at t={}: claimed ({}) > vested ({})", - timestamp, - status.claimed, - status.vested - ); - - // Core invariant 2: vested <= total_amount - assert!( - status.vested <= status.total_amount, - "Invariant violated at t={}: vested ({}) > total_amount ({})", - timestamp, - status.vested, - status.total_amount - ); - - // Core invariant 3: claimed <= total_amount - assert!( - status.claimed <= status.total_amount, - "Invariant violated at t={}: claimed ({}) > total_amount ({})", - timestamp, - status.claimed, - status.total_amount - ); - - // Attempt to claim if past cliff - if timestamp >= cliff_seconds && status.claimable > 0 { - let claimed_now = client.claim(); - cumulative_claimed += claimed_now; - - // Verify the claim amount is positive and reasonable - assert!( - claimed_now > 0, - "Claimed amount should be positive at t={}", - timestamp - ); - assert!( - claimed_now <= status.claimable, - "Claimed more than claimable at t={}", - timestamp - ); - - // Verify status after claim - let status_after = client.get_status(); - - // Invariants must still hold after claim - assert!( - status_after.claimed <= status_after.vested, - "Invariant violated after claim at t={}: claimed ({}) > vested ({})", - timestamp, - status_after.claimed, - status_after.vested - ); - - assert!( - status_after.vested <= status_after.total_amount, - "Invariant violated after claim at t={}: vested ({}) > total_amount ({})", - timestamp, - status_after.vested, - status_after.total_amount - ); - - // Verify cumulative claimed matches status - assert_eq!( - cumulative_claimed, status_after.claimed, - "Cumulative claimed mismatch at t={}: tracked={}, status={}", - timestamp, cumulative_claimed, status_after.claimed - ); - } - } - - // Final verification: all tokens should be claimed by the end - let final_status = client.get_status(); - assert_eq!( - final_status.claimed, total_amount, - "Not all tokens were claimed: claimed={}, total={}", - final_status.claimed, total_amount - ); - assert_eq!( - cumulative_claimed, total_amount, - "Cumulative tracking mismatch: cumulative={}, total={}", - cumulative_claimed, total_amount - ); - } - - // ── Pause / Unpause Tests ───────────────────────────────────────────────── - - /// Test 1: Admin pauses at 50% vesting. Verify get_status shows amount frozen - /// and claim() fails with Paused. - #[test] - fn test_pause_freezes_vested_amount_and_blocks_claim() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - // 1000s duration, 0 cliff - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - // Advance to 50% vesting - env.ledger().with_mut(|l| l.timestamp = 500); - client.pause(); - - let status = client.get_status(); - assert!(status.paused); - assert_eq!(status.vested, 500_000); // frozen at 50% - - // claim must fail - assert_eq!(client.try_claim(), Err(Ok(VestingError::Paused))); - } - - /// Test 2: Advance time by 30 days while paused. Verify vested amount has not increased. - #[test] - fn test_vested_amount_does_not_increase_while_paused() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - env.ledger().with_mut(|l| l.timestamp = 500); - client.pause(); - let vested_at_pause = client.get_status().vested; - - // Advance 30 days while paused - env.ledger().with_mut(|l| l.timestamp += 30 * 24 * 3600); - let vested_after_30_days = client.get_status().vested; - - assert_eq!(vested_at_pause, vested_after_30_days); - } - - /// Test 3: Unpause and verify the new end_time (start_time + duration_seconds) - /// has shifted forward by the pause duration. - #[test] - fn test_unpause_shifts_timeline_correctly() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - let original_start = client.get_config().start_time; - - env.ledger().with_mut(|l| l.timestamp = 500); - client.pause(); - - // Pause for 200 seconds - env.ledger().with_mut(|l| l.timestamp = 700); - client.unpause(); - - let config = client.get_config(); - assert!(!config.paused); - assert_eq!(config.paused_at, None); - // start_time shifted by 200s - assert_eq!(config.start_time, original_start + 200); - // effective end_time = new start_time + duration = original_start + 200 + 1000 - let expected_end = original_start + 200 + 1000; - assert_eq!(config.start_time + config.duration_seconds, expected_end); - } - - /// Test 4: Non-admin cannot pause or unpause. - #[test] - fn test_non_admin_cannot_pause_or_unpause() { - use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; - use soroban_sdk::IntoVal; - - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, ForgeVesting); - let token = Address::generate(&env); - let beneficiary = Address::generate(&env); - let admin = Address::generate(&env); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &0, &1000); - - let non_admin = Address::generate(&env); - - // Attempt pause as non-admin — must fail (auth error panics, so use try_ with catch) - env.mock_auths(&[MockAuth { - address: &non_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "pause", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]); - assert!(client.try_pause().is_err()); - - // Restore mock_all_auths and pause as real admin - env.mock_all_auths(); - client.pause(); - - // Attempt unpause as non-admin - env.mock_auths(&[MockAuth { - address: &non_admin, - invoke: &MockAuthInvoke { - contract: &contract_id, - fn_name: "unpause", - args: ().into_val(&env), - sub_invokes: &[], - }, - }]); - assert!(client.try_unpause().is_err()); - } - - /// Verifies that multiple sequential claim() calls across four time points - /// accumulate correctly: no tokens are double-counted or lost. - /// - /// Schedule: 1_000_000 tokens, no cliff, 1000s duration. - /// Claims at 25%, 50%, 75%, and 100% vested. - /// Asserts the sum of all four return values equals total_amount and that - /// get_status().claimed equals total_amount after all claims. - #[test] - fn test_sequential_claims_accumulate_correctly() { - const TOTAL: i128 = 1_000_000; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &0, &DURATION); - - // 25% vested → expect 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 250); - let claim1 = client.claim(); - assert_eq!(claim1, 250_000); - - // 50% vested → 500_000 total vested, 250_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 500); - let claim2 = client.claim(); - assert_eq!(claim2, 250_000); - - // 75% vested → 750_000 total vested, 500_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 750); - let claim3 = client.claim(); - assert_eq!(claim3, 250_000); - - // 100% vested → 1_000_000 total vested, 750_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 1000); - let claim4 = client.claim(); - assert_eq!(claim4, 250_000); - - // Sum of all claims must equal total_amount — no tokens lost or double-counted - assert_eq!(claim1 + claim2 + claim3 + claim4, TOTAL); - - // get_status().claimed must reflect the full amount - let status = client.get_status(); - assert_eq!(status.claimed, TOTAL); - assert!(status.fully_vested); - - // No tokens remain — next claim must fail - assert_eq!(client.try_claim(), Err(Ok(VestingError::NothingToClaim))); - } - - // ── Cliff boundary edge case tests ─────────────────────────────────────── - - /// claim() must revert with CliffNotReached one second before the cliff. - #[test] - fn test_claim_one_second_before_cliff_fails() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &1000); - - // elapsed = 499 → one second before cliff of 500 - env.ledger().with_mut(|l| l.timestamp = 499); - assert_eq!(client.try_claim(), Err(Ok(VestingError::CliffNotReached))); - } - - /// claim() must succeed when called exactly at the cliff timestamp. - #[test] - fn test_claim_exactly_at_cliff_succeeds() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &1000); - - // elapsed = 500 → exactly at cliff - env.ledger().with_mut(|l| l.timestamp = 500); - let result = client.try_claim(); - assert!(result.is_ok()); - // 500/1000 * 1_000_000 = 500_000 vested at cliff - assert_eq!(result.unwrap(), Ok(500_000)); - } - - /// Tests that claim() returns the correct proportional amount at 25%, 50%, 75%, - /// and 100% of the vesting duration, and that cumulative claimed never exceeds - /// total_amount. Uses a cliff at 25% of duration to also verify cliff boundary. - #[test] - fn test_claim_correct_amount_at_multiple_time_points() { - const TOTAL: i128 = 1_000_000; - const CLIFF: u64 = 250; // 25% of duration - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // 25% — exactly at cliff: 250/1000 * 1_000_000 = 250_000 vested - env.ledger().with_mut(|l| l.timestamp = 250); - let claim1 = client.claim(); - assert_eq!(claim1, 250_000); - assert!(client.get_status().claimed <= TOTAL); - - // 50% — 500_000 vested, 250_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 500); - let claim2 = client.claim(); - assert_eq!(claim2, 250_000); - assert!(client.get_status().claimed <= TOTAL); - - // 75% — 750_000 vested, 500_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 750); - let claim3 = client.claim(); - assert_eq!(claim3, 250_000); - assert!(client.get_status().claimed <= TOTAL); - - // 100% — 1_000_000 vested, 750_000 already claimed → 250_000 claimable - env.ledger().with_mut(|l| l.timestamp = 1000); - let claim4 = client.claim(); - assert_eq!(claim4, 250_000); - - // Cumulative claimed equals total — no tokens lost or double-counted - assert_eq!(claim1 + claim2 + claim3 + claim4, TOTAL); - assert_eq!(client.get_status().claimed, TOTAL); - } - - // ── Event emission tests ────────────────────────────────────────────────── - - /// Verifies initialize() emits a "vesting_initialized" event whose data - /// payload is exactly (total_amount, cliff_seconds, duration_seconds). - #[test] - fn test_event_vesting_initialized() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - - let total: i128 = 5_000_000; - let cliff: u64 = 200; - let duration: u64 = 2000; - client.initialize(&token, &beneficiary, &admin, &total, &cliff, &duration); - - let events = env.events().all(); - assert_eq!(events.len(), 1); - - let (_, topics, data) = events.get(0).unwrap(); - - // Topic must be the symbol "vesting_initialized" - assert_eq!(topics.len(), 1); - let topic_sym = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); - assert_eq!(topic_sym, Symbol::new(&env, "vesting_initialized")); - - // Decode data as (i128, u64, u64) and compare field by field - let (got_total, got_cliff, got_duration) = - <(i128, u64, u64)>::try_from_val(&env, &data).unwrap(); - assert_eq!(got_total, total); - assert_eq!(got_cliff, cliff); - assert_eq!(got_duration, duration); - } - - /// Verifies claim() emits a "claimed" event whose data payload is - /// exactly (beneficiary, amount_claimed). - #[test] - fn test_event_claimed() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - // Advance to 50% vested and claim - env.ledger().with_mut(|l| l.timestamp = 500); - let claimed_amount = client.claim(); - - // Find the "claimed" event among all emitted events - let events = env.events().all(); - let (_, topics, data) = events - .iter() - .find(|(_, topics, _)| { - topics.len() == 1 - && Symbol::try_from_val(&env, &topics.get(0).unwrap()) - .map(|s| s == Symbol::new(&env, "claimed")) - .unwrap_or(false) - }) - .expect("claimed event not found"); - - assert_eq!(topics.len(), 1); - let topic_sym = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); - assert_eq!(topic_sym, Symbol::new(&env, "claimed")); - - // Decode data as (Address, i128) - let (got_beneficiary, got_amount) = <(Address, i128)>::try_from_val(&env, &data).unwrap(); - assert_eq!(got_beneficiary, beneficiary); - assert_eq!(got_amount, claimed_amount); - } - - /// Verifies cancel() emits a "vesting_cancelled" event whose data payload is - /// exactly (admin, to_admin, beneficiary, to_beneficiary). - #[test] - fn test_event_vesting_cancelled() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - // Advance to 40% vested, then cancel (no prior claim) - // 40% vested → 400_000 to beneficiary, 600_000 to admin - env.ledger().with_mut(|l| l.timestamp = 400); - client.cancel(); - - let events = env.events().all(); - let (_, topics, data) = events - .iter() - .find(|(_, topics, _)| { - topics.len() == 1 - && Symbol::try_from_val(&env, &topics.get(0).unwrap()) - .map(|s| s == Symbol::new(&env, "vesting_cancelled")) - .unwrap_or(false) - }) - .expect("vesting_cancelled event not found"); - - assert_eq!(topics.len(), 1); - let topic_sym = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); - assert_eq!(topic_sym, Symbol::new(&env, "vesting_cancelled")); - - // Decode data as (Address, i128, Address, i128) - let (got_admin, got_to_admin, got_beneficiary, got_to_beneficiary) = - <(Address, i128, Address, i128)>::try_from_val(&env, &data).unwrap(); - assert_eq!(got_admin, admin); - assert_eq!(got_to_admin, 600_000); - assert_eq!(got_beneficiary, beneficiary); - assert_eq!(got_to_beneficiary, 400_000); - } - - // ── cancel_and_claim tests ──────────────────────────────────────────────── - - #[test] - fn test_cancel_and_claim_before_cliff_beneficiary_gets_zero() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &1000); - - env.ledger().with_mut(|l| l.timestamp += 100); // before cliff - let (to_beneficiary, to_admin) = client.cancel_and_claim(); - - assert_eq!(to_beneficiary, 0); - assert_eq!(to_admin, 1_000_000); - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 0); - assert_eq!(tc.balance(&admin), 1_000_000); - } - - #[test] - fn test_cancel_and_claim_after_cliff_splits_correctly() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); - - env.ledger().with_mut(|l| l.timestamp += 400); // 40% vested - let (to_beneficiary, to_admin) = client.cancel_and_claim(); - - assert_eq!(to_beneficiary, 400_000); - assert_eq!(to_admin, 600_000); - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 400_000); - assert_eq!(tc.balance(&admin), 600_000); - } - - #[test] - fn test_cancel_and_claim_fully_vested_admin_gets_zero() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - env.ledger().with_mut(|l| l.timestamp += 1000); // 100% vested - let (to_beneficiary, to_admin) = client.cancel_and_claim(); - - assert_eq!(to_beneficiary, 1_000_000); - assert_eq!(to_admin, 0); - let tc = soroban_sdk::token::Client::new(&env, &token_id); - assert_eq!(tc.balance(&beneficiary), 1_000_000); - assert_eq!(tc.balance(&admin), 0); - } - - #[test] - fn test_get_status_vested_reflects_cancel_time_not_zero() { - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - // 1_000_000 tokens, no cliff, 1000s duration - client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); - - // Advance to 40% vested - env.ledger().with_mut(|l| l.timestamp += 400); - client.cancel(); - - // Advance time further — vested should still reflect cancel-time amount - env.ledger().with_mut(|l| l.timestamp += 600); - let status = client.get_status(); - - assert_eq!( - status.vested, 400_000, - "vested should reflect amount at cancel time" - ); - assert_eq!( - status.claimable, 0, - "claimable should be 0 after cancel pays out" - ); - } - - /// Verifies transfer_admin() emits an "admin_transferred" event with the correct - /// old and new admin addresses in the data payload. - #[test] - fn test_event_admin_transferred_emitted_with_correct_addresses() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - let new_admin = Address::generate(&env); - client.transfer_admin(&new_admin); - - let events = env.events().all(); - let (_, topics, data) = events - .iter() - .find(|(_, topics, _)| { - topics.len() == 1 - && Symbol::try_from_val(&env, &topics.get(0).unwrap()) - .map(|s| s == Symbol::new(&env, "admin_transferred")) - .unwrap_or(false) - }) - .expect("admin_transferred event not found"); - - let topic_sym = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); - assert_eq!(topic_sym, Symbol::new(&env, "admin_transferred")); - - let (got_old_admin, got_new_admin) = - <(Address, Address)>::try_from_val(&env, &data).unwrap(); - assert_eq!(got_old_admin, admin); - assert_eq!(got_new_admin, new_admin); - } - - /// Verifies that no "admin_transferred" event is emitted when transfer_admin() fails - /// (e.g. SameAdmin case). - #[test] - fn test_event_admin_transferred_not_emitted_on_failure() { - use soroban_sdk::{testutils::Events, Symbol, TryFromVal}; - - let (env, contract_id, token, beneficiary, admin) = setup(); - let client = ForgeVestingClient::new(&env, &contract_id); - client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); - - // Attempt to transfer to the same admin — should fail with SameAdmin - let result = client.try_transfer_admin(&admin); - assert_eq!(result, Err(Ok(VestingError::SameAdmin))); - - // No admin_transferred event should have been emitted - let events = env.events().all(); - let found = events.iter().any(|(_, topics, _)| { - topics.len() == 1 - && Symbol::try_from_val(&env, &topics.get(0).unwrap()) - .map(|s| s == Symbol::new(&env, "admin_transferred")) - .unwrap_or(false) - }); - assert!( - !found, - "admin_transferred event should not be emitted on failure" - ); - } - - /// Verifies claimable amount at exactly cliff_seconds is (total * cliff) / duration. - /// Uses total=10_000, cliff=100, duration=1000 → expected 1_000. - #[test] - fn test_claimable_at_exactly_cliff_is_correct() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 100; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // t=100: 10_000 * 100 / 1000 = 1_000 - env.ledger().with_mut(|l| l.timestamp = 100); - assert_eq!(client.get_status().claimable, 1_000); - assert_eq!(client.claim(), 1_000); - } - - /// Verifies claimable amount at cliff_seconds+1 is (total * (cliff+1)) / duration. - /// Uses total=10_000, cliff=100, duration=1000 → expected 1_010. - #[test] - fn test_claimable_at_cliff_plus_one_is_correct() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 100; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // t=101: 10_000 * 101 / 1000 = 1_010 - env.ledger().with_mut(|l| l.timestamp = 101); - assert_eq!(client.get_status().claimable, 1_010); - assert_eq!(client.claim(), 1_010); - } - - /// Verifies claimable amount at duration_seconds-1 is (total * (duration-1)) / duration. - /// Uses total=10_000, cliff=100, duration=1000 → expected 9_990 (truncated). - #[test] - fn test_claimable_at_duration_minus_one_is_correct() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 100; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // t=999: 10_000 * 999 / 1000 = 9_990 - env.ledger().with_mut(|l| l.timestamp = 999); - assert_eq!(client.get_status().claimable, 9_990); - assert_eq!(client.claim(), 9_990); - } - - /// Verifies claimable amount at exactly duration_seconds equals total_amount. - /// Uses total=10_000, cliff=100, duration=1000 → expected 10_000. - #[test] - fn test_claimable_at_full_duration_is_total_amount() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 100; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // t=1000: fully vested → 10_000 - env.ledger().with_mut(|l| l.timestamp = 1000); - assert_eq!(client.get_status().claimable, TOTAL); - assert_eq!(client.claim(), TOTAL); - } - - /// Verifies that claiming at duration-1 then duration yields 9_990 + 10 = 10_000, - /// recovering the truncated remainder with no tokens lost. - #[test] - fn test_sequential_claim_duration_minus_one_then_duration_sums_to_total() { - const TOTAL: i128 = 10_000; - const CLIFF: u64 = 100; - const DURATION: u64 = 1000; - - let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); - let client = ForgeVestingClient::new(&env, &contract_id); - - env.ledger().with_mut(|l| l.timestamp = 0); - client.initialize(&token_id, &beneficiary, &admin, &TOTAL, &CLIFF, &DURATION); - - // t=999: 10_000 * 999 / 1000 = 9_990 - env.ledger().with_mut(|l| l.timestamp = 999); - let first = client.claim(); - assert_eq!(first, 9_990); - - // t=1000: fully vested, 10 remaining (truncated dust recovered) - env.ledger().with_mut(|l| l.timestamp = 1000); - let second = client.claim(); - assert_eq!(second, 10); - - assert_eq!(first + second, TOTAL); - } -} diff --git a/contracts/forge-governor/Cargo.toml b/src/contracts/forge-governor/Cargo.toml similarity index 100% rename from contracts/forge-governor/Cargo.toml rename to src/contracts/forge-governor/Cargo.toml diff --git a/contracts/forge-governor/README.md b/src/contracts/forge-governor/README.md similarity index 100% rename from contracts/forge-governor/README.md rename to src/contracts/forge-governor/README.md diff --git a/src/contracts/forge-governor/src/contract.rs b/src/contracts/forge-governor/src/contract.rs new file mode 100644 index 0000000..e690cff --- /dev/null +++ b/src/contracts/forge-governor/src/contract.rs @@ -0,0 +1,257 @@ +use soroban_sdk::{contract, contractimpl, token, Address, Env, String, Symbol, Vec}; +use crate::storage::DataKey; +use crate::types::{GovernorConfig, Proposal, ProposalState, VoteDirection}; +use crate::errors::GovernorError; + +const INSTANCE_TTL_THRESHOLD: u32 = 17_280; +const INSTANCE_TTL_EXTEND: u32 = 34_560; +const PROPOSAL_TTL_EXTEND: u32 = 1_036_800; +const VOTE_TTL_EXTEND: u32 = 1_036_800; + +#[contract] +pub struct GovernorContract; + +#[contractimpl] +impl GovernorContract { + /// Initialize the governor. + pub fn initialize(env: Env, config: GovernorConfig) -> Result<(), GovernorError> { + config.admin.require_auth(); + + if env.storage().instance().has(&DataKey::Config) { + return Err(GovernorError::AlreadyInitialized); + } + if config.quorum == 0 || config.voting_period == 0 { + return Err(GovernorError::InvalidConfig); + } + if config.vote_token == config.admin { + return Err(GovernorError::InvalidConfig); + } + env.storage().instance().set(&DataKey::Config, &config); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); + Ok(()) + } + + /// Create a new governance proposal. + pub fn propose( + env: Env, + proposer: Address, + title: String, + description: String, + ) -> Result { + proposer.require_auth(); + + let config: GovernorConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(GovernorError::NotInitialized)?; + + let now = env.ledger().timestamp(); + let proposal_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextProposalId) + .unwrap_or(0u64); + + let proposal = Proposal { + proposer: proposer.clone(), + title, + description, + vote_start: now, + vote_end: now + config.voting_period, + votes_for: 0, + votes_against: 0, + abstentions: 0, + passed_at: None, + state: ProposalState::Active, + }; + + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().persistent().extend_ttl( + &DataKey::Proposal(proposal_id), + PROPOSAL_TTL_EXTEND, + PROPOSAL_TTL_EXTEND, + ); + env.storage() + .persistent() + .set(&DataKey::NextProposalId, &(proposal_id + 1)); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); + + let mut active: Vec = env + .storage() + .instance() + .get(&DataKey::ActiveProposals) + .unwrap_or_else(|| Vec::new(&env)); + let index = active.len(); + active.push_back(proposal_id); + env.storage() + .instance() + .set(&DataKey::ActiveProposals, &active); + env.storage() + .instance() + .set(&DataKey::ActiveProposalIndex(proposal_id), &index); + + env.events().publish( + (Symbol::new(&env, "proposal_created"),), + (proposal_id, &proposer, proposal.vote_end), + ); + + Ok(proposal_id) + } + + /// Cast a vote. + pub fn vote( + env: Env, + voter: Address, + proposal_id: u64, + direction: VoteDirection, + weight: i128, + ) -> Result<(), GovernorError> { + voter.require_auth(); + + let config: GovernorConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(GovernorError::NotInitialized)?; + + let actual_balance = token::Client::new(&env, &config.vote_token).balance(&voter); + if weight > actual_balance { + return Err(GovernorError::InvalidWeight); + } + + let vote_key = DataKey::Vote(proposal_id, voter.clone()); + if env.storage().persistent().has(&vote_key) { + return Err(GovernorError::AlreadyVoted); + } + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(GovernorError::ProposalNotFound)?; + + if proposal.state != ProposalState::Active { + return Err(GovernorError::VotingClosed); + } + + let now = env.ledger().timestamp(); + if now > proposal.vote_end { + return Err(GovernorError::VotingClosed); + } + + if weight <= 0 { + return Err(GovernorError::InvalidWeight); + } + + match direction { + VoteDirection::For => proposal.votes_for += weight, + VoteDirection::Against => proposal.votes_against += weight, + VoteDirection::Abstain => proposal.abstentions += weight, + } + + env.storage().persistent().set(&vote_key, &weight); + env.storage() + .persistent() + .extend_ttl(&vote_key, VOTE_TTL_EXTEND, VOTE_TTL_EXTEND); + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().persistent().extend_ttl( + &DataKey::Proposal(proposal_id), + PROPOSAL_TTL_EXTEND, + PROPOSAL_TTL_EXTEND, + ); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); + + env.events().publish( + (Symbol::new(&env, "vote_cast"),), + (proposal_id, &voter, direction, weight), + ); + + Ok(()) + } + + /// Finalize a proposal. + pub fn finalize(env: Env, proposal_id: u64) -> Result { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(GovernorError::ProposalNotFound)?; + + if proposal.state != ProposalState::Active { + return Err(GovernorError::AlreadyFinalized); + } + + let now = env.ledger().timestamp(); + if now <= proposal.vote_end { + return Err(GovernorError::VotingStillOpen); + } + + let config: GovernorConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(GovernorError::NotInitialized)?; + let total_votes = proposal.votes_for + proposal.votes_against + proposal.abstentions; + + if total_votes >= config.quorum && proposal.votes_for > proposal.votes_against { + proposal.state = ProposalState::Passed; + proposal.passed_at = Some(proposal.vote_end); + } else { + proposal.state = ProposalState::Failed; + } + + let state = proposal.state.clone(); + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage().persistent().extend_ttl( + &DataKey::Proposal(proposal_id), + PROPOSAL_TTL_EXTEND, + PROPOSAL_TTL_EXTEND, + ); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); + + Self::remove_active_proposal(&env, proposal_id); + + env.events().publish( + (Symbol::new(&env, "proposal_finalized"),), + (proposal_id, proposal.votes_for, proposal.votes_against), + ); + + Ok(state) + } + + // ── Internal Helpers ────────────────────────────────────────────────────── + + fn remove_active_proposal(env: &Env, proposal_id: u64) { + let mut active: Vec = env + .storage() + .instance() + .get(&DataKey::ActiveProposals) + .unwrap_or_else(|| Vec::new(env)); + let index_key = DataKey::ActiveProposalIndex(proposal_id); + if let Some(index) = env.storage().instance().get::(&index_key) { + active.remove(index); + env.storage().instance().set(&DataKey::ActiveProposals, &active); + env.storage().instance().remove(&index_key); + + for i in index..active.len() { + let id = active.get(i).unwrap(); + env.storage().instance().set(&DataKey::ActiveProposalIndex(id), &i); + } + } + } +} diff --git a/src/contracts/forge-governor/src/errors.rs b/src/contracts/forge-governor/src/errors.rs new file mode 100644 index 0000000..78d9ae2 --- /dev/null +++ b/src/contracts/forge-governor/src/errors.rs @@ -0,0 +1,21 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum GovernorError { + AlreadyInitialized = 1, + NotInitialized = 2, + ProposalNotFound = 3, + VotingClosed = 4, + VotingStillOpen = 5, + AlreadyVoted = 6, + QuorumNotReached = 7, + ProposalNotPassed = 8, + TimelockNotElapsed = 9, + AlreadyExecuted = 10, + AlreadyCancelled = 11, + InvalidConfig = 12, + InvalidWeight = 13, + Unauthorized = 14, + AlreadyFinalized = 15, +} diff --git a/src/contracts/forge-governor/src/lib.rs b/src/contracts/forge-governor/src/lib.rs new file mode 100644 index 0000000..877a75e --- /dev/null +++ b/src/contracts/forge-governor/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] + +//! # forge-governor +//! +//! On-chain governance with token-weighted voting for Stellar/Soroban. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::GovernorContractClient; +pub use crate::errors::GovernorError; +pub use crate::types::{GovernorConfig, Proposal, ProposalState, VoteDirection, VoteTally}; diff --git a/src/contracts/forge-governor/src/storage.rs b/src/contracts/forge-governor/src/storage.rs new file mode 100644 index 0000000..1660ae6 --- /dev/null +++ b/src/contracts/forge-governor/src/storage.rs @@ -0,0 +1,11 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + Config, + Proposal(u64), + Vote(u64, Address), + NextProposalId, + ActiveProposals, + ActiveProposalIndex(u64), +} diff --git a/src/contracts/forge-governor/src/test.rs b/src/contracts/forge-governor/src/test.rs new file mode 100644 index 0000000..e00b080 --- /dev/null +++ b/src/contracts/forge-governor/src/test.rs @@ -0,0 +1,32 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{GovernorContract, GovernorContractClient}; + use crate::types::GovernorConfig; + use soroban_sdk::{ + testutils::{Address as _}, + Address, Env, + }; + + fn setup(env: &Env) -> (GovernorContractClient, Address, Address) { + let contract_id = env.register_contract(None, GovernorContract); + let client = GovernorContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let token = Address::generate(env); + let config = GovernorConfig { + admin: admin.clone(), + vote_token: token.clone(), + voting_period: 3600, + quorum: 1000, + timelock_delay: 0, + }; + client.initialize(&config); + (client, admin, token) + } + + #[test] + fn test_initialize_success() { + let env = Env::default(); + let _ = setup(&env); + } +} diff --git a/src/contracts/forge-governor/src/types.rs b/src/contracts/forge-governor/src/types.rs new file mode 100644 index 0000000..6c057f8 --- /dev/null +++ b/src/contracts/forge-governor/src/types.rs @@ -0,0 +1,58 @@ +use soroban_sdk::{contracttype, Address, String}; + +/// Governor configuration. +#[contracttype] +#[derive(Clone)] +pub struct GovernorConfig { + pub admin: Address, + pub vote_token: Address, + pub voting_period: u64, + pub quorum: i128, + pub timelock_delay: u64, +} + +/// Proposal state. +#[contracttype] +#[derive(Clone, PartialEq, Debug)] +pub enum ProposalState { + Active, + Passed, + Failed, + Executed, + Cancelled, +} + +/// Direction of a vote cast on a proposal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum VoteDirection { + For, + Against, + Abstain, +} + +/// Vote tally for a proposal. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VoteTally { + pub yes_votes: i128, + pub no_votes: i128, + pub abstain_votes: i128, + pub total_votes: i128, +} + +/// A governance proposal. +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub proposer: Address, + pub title: String, + pub description: String, + pub vote_start: u64, + pub vote_end: u64, + pub votes_for: i128, + pub votes_against: i128, + pub abstentions: i128, + pub passed_at: Option, + pub state: ProposalState, +} diff --git a/contracts/forge-multisig/Cargo.toml b/src/contracts/forge-multisig/Cargo.toml similarity index 100% rename from contracts/forge-multisig/Cargo.toml rename to src/contracts/forge-multisig/Cargo.toml diff --git a/contracts/forge-multisig/README.md b/src/contracts/forge-multisig/README.md similarity index 100% rename from contracts/forge-multisig/README.md rename to src/contracts/forge-multisig/README.md diff --git a/src/contracts/forge-multisig/src/contract.rs b/src/contracts/forge-multisig/src/contract.rs new file mode 100644 index 0000000..0664879 --- /dev/null +++ b/src/contracts/forge-multisig/src/contract.rs @@ -0,0 +1,286 @@ +use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol, Vec}; +use crate::storage::DataKey; +use crate::types::Proposal; +use crate::errors::MultisigError; + +const INSTANCE_TTL_THRESHOLD: u32 = 17_280; +const INSTANCE_TTL_EXTEND: u32 = 34_560; + +#[contract] +pub struct MultisigContract; + +#[contractimpl] +impl MultisigContract { + /// Initialize the multisig treasury. + pub fn initialize( + env: Env, + owners: Vec
, + threshold: u32, + timelock_delay: u64, + ) -> Result<(), MultisigError> { + if env.storage().instance().has(&DataKey::Owners) { + return Err(MultisigError::AlreadyInitialized); + } + + let mut unique_owners = Vec::new(&env); + for owner in owners.iter() { + if !unique_owners.contains(&owner) { + unique_owners.push_back(owner); + } + } + + if threshold == 0 || threshold > unique_owners.len() { + return Err(MultisigError::InvalidThreshold); + } + env.storage() + .instance() + .set(&DataKey::Owners, &unique_owners); + env.storage() + .instance() + .set(&DataKey::Threshold, &threshold); + env.storage() + .instance() + .set(&DataKey::TimelockDelay, &timelock_delay); + + for owner in unique_owners.iter() { + env.storage() + .instance() + .set(&DataKey::IsOwner(owner), &true); + } + + Ok(()) + } + + /// Propose a token transfer. + pub fn propose( + env: Env, + proposer: Address, + to: Address, + token: Address, + amount: i128, + ) -> Result { + proposer.require_auth(); + Self::require_owner(&env, &proposer)?; + + if amount <= 0 { + return Err(MultisigError::InvalidAmount); + } + + let proposal_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextProposalId) + .unwrap_or(0u64); + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .ok_or(MultisigError::NotInitialized)?; + let approved_at = if 1 >= threshold { + Some(env.ledger().timestamp()) + } else { + None + }; + + let proposal = Proposal { + proposer: proposer.clone(), + to: to.clone(), + token: token.clone(), + amount, + approval_count: 1, + rejection_count: 0, + approved_at, + executed: false, + cancelled: false, + is_native: false, + }; + + env.storage() + .persistent() + .set(&DataKey::HasApproved(proposal_id, proposer.clone()), &true); + + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .persistent() + .set(&DataKey::NextProposalId, &(proposal_id + 1)); + + env.storage() + .persistent() + .extend_ttl(&DataKey::NextProposalId, 31536000, 31536000); + + if approved_at.is_some() { + let committed: i128 = env + .storage() + .instance() + .get(&DataKey::CommittedAmount(token.clone())) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::CommittedAmount(token.clone()), + &(committed + amount), + ); + } + + env.events().publish( + (Symbol::new(&env, "proposal_created"),), + (proposal_id, &proposer, &to, &token, amount), + ); + + Ok(proposal_id) + } + + /// Approve a proposal. + pub fn approve(env: Env, owner: Address, proposal_id: u64) -> Result<(), MultisigError> { + owner.require_auth(); + Self::require_owner(&env, &owner)?; + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(MultisigError::ProposalNotFound)?; + + if proposal.executed { + return Err(MultisigError::AlreadyExecuted); + } + if proposal.cancelled { + return Err(MultisigError::AlreadyCancelled); + } + if env + .storage() + .persistent() + .get::(&DataKey::HasApproved(proposal_id, owner.clone())) + .unwrap_or(false) + || env + .storage() + .persistent() + .get::(&DataKey::HasRejected(proposal_id, owner.clone())) + .unwrap_or(false) + { + return Err(MultisigError::AlreadyVoted); + } + + proposal.approval_count = proposal.approval_count.saturating_add(1); + env.storage() + .persistent() + .set(&DataKey::HasApproved(proposal_id, owner.clone()), &true); + + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::Threshold) + .ok_or(MultisigError::NotInitialized)?; + + if proposal.approval_count >= threshold && proposal.approved_at.is_none() { + proposal.approved_at = Some(env.ledger().timestamp()); + let committed: i128 = env + .storage() + .instance() + .get(&DataKey::CommittedAmount(proposal.token.clone())) + .unwrap_or(0); + env.storage().instance().set( + &DataKey::CommittedAmount(proposal.token.clone()), + &(committed + proposal.amount), + ); + } + + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND); + + env.events().publish( + (Symbol::new(&env, "proposal_approved"),), + (proposal_id, &owner, proposal.approval_count), + ); + + Ok(()) + } + + /// Execute an approved proposal. + pub fn execute(env: Env, executor: Address, proposal_id: u64) -> Result<(), MultisigError> { + executor.require_auth(); + Self::require_owner(&env, &executor)?; + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(MultisigError::ProposalNotFound)?; + + if proposal.executed { + return Err(MultisigError::AlreadyExecuted); + } + if proposal.cancelled { + return Err(MultisigError::AlreadyCancelled); + } + + let approved_at = proposal + .approved_at + .ok_or(MultisigError::InsufficientApprovals)?; + let delay: u64 = env + .storage() + .instance() + .get(&DataKey::TimelockDelay) + .unwrap_or(0); + + if env.ledger().timestamp() < approved_at + delay { + return Err(MultisigError::TimelockNotElapsed); + } + + let token_client = token::Client::new(&env, &proposal.token); + let committed: i128 = env + .storage() + .instance() + .get(&DataKey::CommittedAmount(proposal.token.clone())) + .unwrap_or(0); + let balance = token_client.balance(&env.current_contract_address()); + if balance < committed { + return Err(MultisigError::InsufficientFunds); + } + + token_client.transfer( + &env.current_contract_address(), + &proposal.to, + &proposal.amount, + ); + + proposal.executed = true; + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + + let new_committed = committed.saturating_sub(proposal.amount); + env.storage().instance().set( + &DataKey::CommittedAmount(proposal.token.clone()), + &new_committed, + ); + + env.storage().instance().extend_ttl(17280, 34560); + + env.events().publish( + (Symbol::new(&env, "proposal_executed"),), + (proposal_id, &executor, &proposal.to, proposal.amount), + ); + + Ok(()) + } + + // ── Internal Helpers ────────────────────────────────────────────────────── + + fn require_owner(env: &Env, address: &Address) -> Result<(), MultisigError> { + if !env + .storage() + .instance() + .get::(&DataKey::IsOwner(address.clone())) + .unwrap_or(false) + { + return Err(MultisigError::Unauthorized); + } + Ok(()) + } +} diff --git a/src/contracts/forge-multisig/src/errors.rs b/src/contracts/forge-multisig/src/errors.rs new file mode 100644 index 0000000..58d8b8e --- /dev/null +++ b/src/contracts/forge-multisig/src/errors.rs @@ -0,0 +1,19 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum MultisigError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + ProposalNotFound = 4, + AlreadyVoted = 5, + TimelockNotElapsed = 6, + AlreadyExecuted = 7, + AlreadyCancelled = 8, + InsufficientApprovals = 9, + InvalidThreshold = 10, + InvalidAmount = 11, + CannotCancel = 12, + InsufficientFunds = 13, +} diff --git a/src/contracts/forge-multisig/src/lib.rs b/src/contracts/forge-multisig/src/lib.rs new file mode 100644 index 0000000..2507223 --- /dev/null +++ b/src/contracts/forge-multisig/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] + +//! # forge-multisig +//! +//! An N-of-M multisig treasury contract for Stellar/Soroban. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::MultisigContractClient; +pub use crate::errors::MultisigError; +pub use crate::types::Proposal; diff --git a/src/contracts/forge-multisig/src/storage.rs b/src/contracts/forge-multisig/src/storage.rs new file mode 100644 index 0000000..6215319 --- /dev/null +++ b/src/contracts/forge-multisig/src/storage.rs @@ -0,0 +1,18 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + Owners, + Threshold, + TimelockDelay, + Proposal(u64), + NextProposalId, + /// Boolean flag per address — `true` means the address is an owner. + IsOwner(Address), + /// Boolean flag for whether an address has approved a proposal. + HasApproved(u64, Address), + /// Boolean flag for whether an address has rejected a proposal. + HasRejected(u64, Address), + /// Total tokens committed to approved-but-not-yet-executed proposals per token address. + CommittedAmount(Address), +} diff --git a/src/contracts/forge-multisig/src/test.rs b/src/contracts/forge-multisig/src/test.rs new file mode 100644 index 0000000..f62557b --- /dev/null +++ b/src/contracts/forge-multisig/src/test.rs @@ -0,0 +1,37 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{MultisigContract, MultisigContractClient}; + use crate::errors::MultisigError; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, Vec, + }; + + fn setup(env: &Env) -> (MultisigContractClient, Address, Address, Address) { + let contract_id = env.register_contract(None, MultisigContract); + let client = MultisigContractClient::new(env, &contract_id); + let owner1 = Address::generate(env); + let owner2 = Address::generate(env); + let owner3 = Address::generate(env); + client.initialize(&Vec::from_array(env, [owner1.clone(), owner2.clone(), owner3.clone()]), &2, &0); + (client, owner1, owner2, owner3) + } + + #[test] + fn test_initialize_success() { + let env = Env::default(); + let _ = setup(&env); + } + + #[test] + fn test_propose_success() { + let env = Env::default(); + env.mock_all_auths(); + let (client, owner1, _, _) = setup(&env); + let to = Address::generate(&env); + let token = Address::generate(&env); + let id = client.propose(&owner1, &to, &token, &1000); + assert_eq!(id, 0); + } +} diff --git a/src/contracts/forge-multisig/src/types.rs b/src/contracts/forge-multisig/src/types.rs new file mode 100644 index 0000000..369b33e --- /dev/null +++ b/src/contracts/forge-multisig/src/types.rs @@ -0,0 +1,27 @@ +use soroban_sdk::{contracttype, Address}; + +/// A pending treasury transaction proposal. +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + /// Who proposed this transaction. + pub proposer: Address, + /// Destination address for the transfer. + pub to: Address, + /// Token address. + pub token: Address, + /// Amount to transfer. + pub amount: i128, + /// Number of approvals recorded for this proposal. + pub approval_count: u32, + /// Number of rejections recorded for this proposal. + pub rejection_count: u32, + /// Ledger timestamp when approval threshold was reached. + pub approved_at: Option, + /// Whether the proposal has been executed. + pub executed: bool, + /// Whether the proposal has been cancelled. + pub cancelled: bool, + /// Whether this is a native XLM transfer proposal. + pub is_native: bool, +} diff --git a/contracts/forge-oracle/Cargo.toml b/src/contracts/forge-oracle/Cargo.toml similarity index 100% rename from contracts/forge-oracle/Cargo.toml rename to src/contracts/forge-oracle/Cargo.toml diff --git a/contracts/forge-oracle/README.md b/src/contracts/forge-oracle/README.md similarity index 100% rename from contracts/forge-oracle/README.md rename to src/contracts/forge-oracle/README.md diff --git a/src/contracts/forge-oracle/src/contract.rs b/src/contracts/forge-oracle/src/contract.rs new file mode 100644 index 0000000..ba40ea8 --- /dev/null +++ b/src/contracts/forge-oracle/src/contract.rs @@ -0,0 +1,262 @@ +use soroban_sdk::{contract, contractimpl, vec, Address, Env, Symbol, Vec}; +use crate::storage::{DataKey, PricePair}; +use crate::types::{PriceData, PriceEntry}; +use crate::errors::OracleError; + +#[contract] +pub struct ForgeOracle; + +#[contractimpl] +impl ForgeOracle { + /// Initializes the oracle contract. + pub fn initialize( + env: Env, + admin: Address, + staleness_threshold: u64, + ) -> Result<(), OracleError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(OracleError::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::StalenessThreshold, &staleness_threshold); + Ok(()) + } + + /// Submits a new price for a specified trading pair. + pub fn submit_price( + env: Env, + base: Symbol, + quote: Symbol, + price: i128, + ) -> Result<(), OracleError> { + if base == quote { + return Err(OracleError::InvalidPair); + } + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(OracleError::NotInitialized)?; + + admin.require_auth(); + + if price <= 0 { + return Err(OracleError::InvalidPrice); + } + + let pair = PricePair { + base: base.clone(), + quote: quote.clone(), + }; + let now = env.ledger().timestamp(); + + let max_deviation_bps: u32 = env + .storage() + .instance() + .get(&DataKey::MaxDeviation) + .unwrap_or(0u32); + if max_deviation_bps > 0 { + if let Some(prev_price) = env + .storage() + .persistent() + .get::(&DataKey::Price(pair.clone())) + { + if prev_price > 0 { + let deviation = (price - prev_price).abs() * 10_000 / prev_price; + if deviation > max_deviation_bps as i128 { + return Err(OracleError::PriceDeviationTooHigh); + } + } + } + } + + let pair_key = PricePair { + base: base.clone(), + quote: quote.clone(), + }; + if !env.storage().persistent().has(&DataKey::Price(pair_key)) { + let mut pairs: Vec = env + .storage() + .persistent() + .get(&DataKey::Pairs) + .unwrap_or_else(|| vec![&env]); + pairs.push_back(PricePair { + base: base.clone(), + quote: quote.clone(), + }); + env.storage().persistent().set(&DataKey::Pairs, &pairs); + env.storage().persistent().extend_ttl(&DataKey::Pairs, 17280, 34560); + } + + env.storage() + .persistent() + .set(&DataKey::Price(pair.clone()), &price); + env.storage() + .persistent() + .set(&DataKey::UpdatedAt(pair), &now); + + env.storage().instance().extend_ttl(17280, 34560); + + env.events().publish( + (Symbol::new(&env, "price_updated"),), + (base, quote, price, now), + ); + + Ok(()) + } + + /// Retrieves the current price. + pub fn get_price(env: Env, base: Symbol, quote: Symbol) -> Result { + let pair = PricePair { base, quote }; + + let price: i128 = env + .storage() + .persistent() + .get(&DataKey::Price(pair.clone())) + .ok_or(OracleError::PriceNotFound)?; + + let updated_at: u64 = env + .storage() + .persistent() + .get(&DataKey::UpdatedAt(pair)) + .ok_or(OracleError::PriceNotFound)?; + + let threshold: u64 = env + .storage() + .instance() + .get(&DataKey::StalenessThreshold) + .ok_or(OracleError::NotInitialized)?; + + let now = env.ledger().timestamp(); + if now >= updated_at + threshold { + return Err(OracleError::PriceStale); + } + + Ok(PriceData { price, updated_at }) + } + + /// Retrieves the raw price without staleness check. + pub fn get_price_unsafe( + env: Env, + base: Symbol, + quote: Symbol, + ) -> Result { + let pair = PricePair { base, quote }; + + let price: i128 = env + .storage() + .persistent() + .get(&DataKey::Price(pair.clone())) + .ok_or(OracleError::PriceNotFound)?; + + let updated_at: u64 = env + .storage() + .persistent() + .get(&DataKey::UpdatedAt(pair)) + .ok_or(OracleError::PriceNotFound)?; + + Ok(PriceData { price, updated_at }) + } + + /// Updates the staleness threshold. + pub fn set_staleness_threshold(env: Env, new_threshold: u64) -> Result<(), OracleError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(OracleError::NotInitialized)?; + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::StalenessThreshold, &new_threshold); + env.storage().instance().extend_ttl(17280, 34560); + Ok(()) + } + + /// Transfers the admin role. + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), OracleError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(OracleError::NotInitialized)?; + admin.require_auth(); + let old_admin = admin.clone(); + env.storage().instance().set(&DataKey::Admin, &new_admin); + + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (old_admin, new_admin), + ); + + Ok(()) + } + + /// Sets the maximum allowed price deviation. + pub fn set_max_price_deviation(env: Env, bps: u32) -> Result<(), OracleError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(OracleError::NotInitialized)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::MaxDeviation, &bps); + env.storage().instance().extend_ttl(17280, 34560); + Ok(()) + } + + /// Returns all currently stored prices. + pub fn get_all_prices(env: Env) -> Result, OracleError> { + if !env.storage().instance().has(&DataKey::Admin) { + return Err(OracleError::NotInitialized); + } + let pairs: Vec = env + .storage() + .persistent() + .get(&DataKey::Pairs) + .unwrap_or_else(|| vec![&env]); + let mut result: Vec = vec![&env]; + for pair in pairs.iter() { + let price: i128 = match env + .storage() + .persistent() + .get(&DataKey::Price(pair.clone())) + { + Some(p) => p, + None => continue, + }; + let updated_at: u64 = env + .storage() + .persistent() + .get(&DataKey::UpdatedAt(pair.clone())) + .unwrap_or(0); + result.push_back(PriceEntry { + base: pair.base.clone(), + quote: pair.quote.clone(), + price, + updated_at, + }); + } + Ok(result) + } + + /// Retrieves the current admin address. + pub fn get_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(OracleError::NotInitialized) + } + + /// Return the current staleness threshold. + pub fn get_staleness_threshold(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::StalenessThreshold) + .ok_or(OracleError::NotInitialized) + } +} diff --git a/src/contracts/forge-oracle/src/errors.rs b/src/contracts/forge-oracle/src/errors.rs new file mode 100644 index 0000000..6c8f721 --- /dev/null +++ b/src/contracts/forge-oracle/src/errors.rs @@ -0,0 +1,14 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum OracleError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + PriceNotFound = 4, + PriceStale = 5, + InvalidPrice = 6, + InvalidPair = 7, + PriceDeviationTooHigh = 8, +} diff --git a/src/contracts/forge-oracle/src/lib.rs b/src/contracts/forge-oracle/src/lib.rs new file mode 100644 index 0000000..95cd43b --- /dev/null +++ b/src/contracts/forge-oracle/src/lib.rs @@ -0,0 +1,18 @@ +#![no_std] + +//! # forge-oracle +//! +//! Standardized price feed interface for Stellar/Soroban contracts. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::ForgeOracleClient; +pub use crate::errors::OracleError; +pub use crate::types::{PriceData, PriceEntry}; +pub use crate::storage::PricePair; diff --git a/src/contracts/forge-oracle/src/storage.rs b/src/contracts/forge-oracle/src/storage.rs new file mode 100644 index 0000000..65e5661 --- /dev/null +++ b/src/contracts/forge-oracle/src/storage.rs @@ -0,0 +1,18 @@ +use soroban_sdk::{contracttype, Symbol}; + +#[contracttype] +#[derive(Clone)] +pub struct PricePair { + pub base: Symbol, + pub quote: Symbol, +} + +#[contracttype] +pub enum DataKey { + Admin, + StalenessThreshold, + MaxDeviation, + Price(PricePair), + UpdatedAt(PricePair), + Pairs, +} diff --git a/src/contracts/forge-oracle/src/test.rs b/src/contracts/forge-oracle/src/test.rs new file mode 100644 index 0000000..6c424eb --- /dev/null +++ b/src/contracts/forge-oracle/src/test.rs @@ -0,0 +1,52 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{ForgeOracle, ForgeOracleClient}; + use crate::errors::OracleError; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, Symbol, + }; + + fn setup<'a>(env: &'a Env) -> (Address, ForgeOracleClient<'a>) { + let contract_id = env.register_contract(None, ForgeOracle); + let client = ForgeOracleClient::new(env, &contract_id); + let admin = Address::generate(env); + client.initialize(&admin, &3600); + (admin, client) + } + + #[test] + fn test_submit_and_get_price() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1000); + let (_, client) = setup(&env); + + let base = Symbol::new(&env, "XLM"); + let quote = Symbol::new(&env, "USDC"); + + client.submit_price(&base, "e, &11_000_000); + let data = client.get_price(&base, "e); + + assert_eq!(data.price, 11_000_000); + assert_eq!(data.updated_at, 1000); + } + + #[test] + fn test_stale_price_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 0); + let (_, client) = setup(&env); + + let base = Symbol::new(&env, "XLM"); + let quote = Symbol::new(&env, "USDC"); + + client.submit_price(&base, "e, &10_000_000); + + env.ledger().with_mut(|l| l.timestamp = 7200); + let result = client.try_get_price(&base, "e); + assert_eq!(result, Err(Ok(OracleError::PriceStale))); + } +} diff --git a/src/contracts/forge-oracle/src/types.rs b/src/contracts/forge-oracle/src/types.rs new file mode 100644 index 0000000..8d6792e --- /dev/null +++ b/src/contracts/forge-oracle/src/types.rs @@ -0,0 +1,21 @@ +use soroban_sdk::{contracttype, Symbol}; + +/// A price entry with value and timestamp. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceData { + /// Price scaled to 7 decimal places (e.g. 1_0000000 = 1.0) + pub price: i128, + /// Ledger timestamp of last update + pub updated_at: u64, +} + +/// A single entry returned by `get_all_prices`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceEntry { + pub base: Symbol, + pub quote: Symbol, + pub price: i128, + pub updated_at: u64, +} diff --git a/contracts/forge-stream/Cargo.toml b/src/contracts/forge-stream/Cargo.toml similarity index 100% rename from contracts/forge-stream/Cargo.toml rename to src/contracts/forge-stream/Cargo.toml diff --git a/contracts/forge-stream/README.md b/src/contracts/forge-stream/README.md similarity index 100% rename from contracts/forge-stream/README.md rename to src/contracts/forge-stream/README.md diff --git a/src/contracts/forge-stream/src/contract.rs b/src/contracts/forge-stream/src/contract.rs new file mode 100644 index 0000000..b874071 --- /dev/null +++ b/src/contracts/forge-stream/src/contract.rs @@ -0,0 +1,159 @@ +use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; +use crate::storage::DataKey; +use crate::types::{Stream, StreamStatus}; +use crate::errors::StreamError; + +#[contract] +pub struct ForgeStream; + +#[contractimpl] +impl ForgeStream { + /// Create a new token stream. + pub fn create_stream( + env: Env, + sender: Address, + token: Address, + recipient: Address, + rate_per_second: i128, + duration_seconds: u64, + min_withdrawal_amount: i128, + ) -> Result { + if rate_per_second <= 0 || duration_seconds == 0 || min_withdrawal_amount < 0 { + return Err(StreamError::InvalidConfig); + } + + sender.require_auth(); + + let stream_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextId) + .unwrap_or(0_u64); + + let now = env.ledger().timestamp(); + let total = rate_per_second + .checked_mul(duration_seconds as i128) + .ok_or(StreamError::InvalidConfig)?; + + let token_client = token::Client::new(&env, &token); + if token_client.balance(&sender) < total { + return Err(StreamError::InsufficientFunds); + } + token_client.transfer(&sender, &env.current_contract_address(), &total); + + let stream = Stream { + id: stream_id, + token, + sender: sender.clone(), + recipient: recipient.clone(), + rate_per_second, + start_time: now, + end_time: now + duration_seconds, + withdrawn: 0, + cancelled: false, + streamed_at_cancel: 0, + is_paused: false, + paused_at: None, + total_paused_time: 0, + counted_active: true, + min_withdrawal_amount, + }; + + env.storage() + .persistent() + .set(&DataKey::Stream(stream_id), &stream); + env.storage() + .instance() + .set(&DataKey::NextId, &(stream_id + 1)); + + Self::set_active_streams_count(&env, Self::active_streams_count(&env).saturating_add(1)); + + env.events().publish( + (Symbol::new(&env, "stream_created"),), + ( + stream_id, + &stream.recipient, + rate_per_second, + duration_seconds, + min_withdrawal_amount, + ), + ); + + Ok(stream_id) + } + + /// Withdraw accrued tokens. + pub fn withdraw(env: Env, stream_id: u64) -> Result { + let mut stream: Stream = env + .storage() + .persistent() + .get(&DataKey::Stream(stream_id)) + .ok_or(StreamError::StreamNotFound)?; + + if stream.cancelled { + return Err(StreamError::AlreadyCancelled); + } + + stream.recipient.require_auth(); + + let now = env.ledger().timestamp(); + let streamed = Self::compute_streamed(&stream, now); + let withdrawable = streamed - stream.withdrawn; + + if withdrawable <= 0 { + return Err(StreamError::NothingToWithdraw); + } + + let is_finished = now >= stream.end_time; + if !is_finished + && withdrawable < stream.min_withdrawal_amount + && stream.min_withdrawal_amount > 0 + { + return Err(StreamError::BelowMinimumWithdrawal); + } + + stream.withdrawn += withdrawable; + env.storage() + .persistent() + .set(&DataKey::Stream(stream_id), &stream); + + let token_client = token::Client::new(&env, &stream.token); + token_client.transfer( + &env.current_contract_address(), + &stream.recipient, + &withdrawable, + ); + + env.events().publish( + (Symbol::new(&env, "withdrawn"),), + (stream_id, &stream.recipient, withdrawable), + ); + + Ok(withdrawable) + } + + // ── Internal ────────────────────────────────────────────────────────────── + + fn compute_streamed(stream: &Stream, now: u64) -> i128 { + if stream.cancelled { + return stream.streamed_at_cancel; + } + let effective_now = now.min(stream.end_time); + let elapsed = effective_now.saturating_sub(stream.start_time); + let active_elapsed = elapsed.saturating_sub(stream.total_paused_time); + stream.rate_per_second * active_elapsed as i128 + } + + fn active_streams_count(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ActiveStreamsCount) + .unwrap_or(0) + } + + fn set_active_streams_count(env: &Env, count: u64) { + env.storage() + .instance() + .set(&DataKey::ActiveStreamsCount, &count); + } +} diff --git a/src/contracts/forge-stream/src/errors.rs b/src/contracts/forge-stream/src/errors.rs new file mode 100644 index 0000000..54b439e --- /dev/null +++ b/src/contracts/forge-stream/src/errors.rs @@ -0,0 +1,16 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum StreamError { + StreamNotFound = 1, + Unauthorized = 2, + NothingToWithdraw = 3, + AlreadyCancelled = 4, + InvalidConfig = 5, + StreamFinished = 6, + /// Sender's token balance is less than the total required to fund the stream + InsufficientFunds = 7, + /// Withdrawal amount is below the minimum threshold + BelowMinimumWithdrawal = 8, +} diff --git a/src/contracts/forge-stream/src/lib.rs b/src/contracts/forge-stream/src/lib.rs new file mode 100644 index 0000000..ead1f9b --- /dev/null +++ b/src/contracts/forge-stream/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] + +//! # forge-stream +//! +//! Real-time token streaming — pay-per-second token transfers on Soroban. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::ForgeStreamClient; +pub use crate::errors::StreamError; +pub use crate::types::{Stream, StreamStatus}; diff --git a/src/contracts/forge-stream/src/storage.rs b/src/contracts/forge-stream/src/storage.rs new file mode 100644 index 0000000..24fc338 --- /dev/null +++ b/src/contracts/forge-stream/src/storage.rs @@ -0,0 +1,15 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +pub enum DataKey { + /// Per-stream data (token, sender, recipient, rate, timestamps, state). + Stream(u64), + /// Monotonically increasing counter used to assign the next stream ID. + NextId, + /// Count of streams that are currently active (not cancelled/finished). + ActiveStreamsCount, + /// List of stream IDs created by a given sender address. + SenderStreams(Address), + /// List of stream IDs where a given address is the recipient. + RecipientStreams(Address), +} diff --git a/src/contracts/forge-stream/src/test.rs b/src/contracts/forge-stream/src/test.rs new file mode 100644 index 0000000..6a347e9 --- /dev/null +++ b/src/contracts/forge-stream/src/test.rs @@ -0,0 +1,31 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{ForgeStream, ForgeStreamClient}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, + }; + + fn setup(env: &Env) -> (ForgeStreamClient, Address, Address, Address) { + let contract_id = env.register_contract(None, ForgeStream); + let client = ForgeStreamClient::new(env, &contract_id); + let sender = Address::generate(env); + let recipient = Address::generate(env); + let token_admin = Address::generate(env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + token::StellarAssetClient::new(env, &token_id).mint(&sender, &1_000_000); + (client, sender, recipient, token_id) + } + + #[test] + fn test_create_stream_success() { + let env = Env::default(); + env.mock_all_auths(); + let (client, sender, recipient, token) = setup(&env); + let id = client.create_stream(&sender, &token, &recipient, &100, &1000, &0); + assert_eq!(id, 0); + } +} diff --git a/src/contracts/forge-stream/src/types.rs b/src/contracts/forge-stream/src/types.rs new file mode 100644 index 0000000..16e57b0 --- /dev/null +++ b/src/contracts/forge-stream/src/types.rs @@ -0,0 +1,51 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone)] +pub struct Stream { + /// Unique stream ID + pub id: u64, + /// Token being streamed + pub token: Address, + /// Sender funding the stream + pub sender: Address, + /// Recipient receiving tokens + pub recipient: Address, + /// Tokens per second + pub rate_per_second: i128, + /// Stream start timestamp + pub start_time: u64, + /// Stream end timestamp + pub end_time: u64, + /// Total tokens already withdrawn + pub withdrawn: i128, + /// Whether the stream has been cancelled + pub cancelled: bool, + /// Amount streamed at the time of cancellation (if cancelled) + pub streamed_at_cancel: i128, + /// Whether the stream is currently paused + pub is_paused: bool, + /// Timestamp when stream was last paused (if paused) + pub paused_at: Option, + /// Total seconds the stream has been paused + pub total_paused_time: u64, + /// Whether this stream is currently counted as active in the global counter + pub counted_active: bool, + /// Minimum withdrawal amount to prevent dust withdrawals (0 means no minimum) + pub min_withdrawal_amount: i128, +} + +#[contracttype] +#[derive(Clone)] +pub struct StreamStatus { + pub id: u64, + pub streamed: i128, + pub withdrawn: i128, + pub withdrawable: i128, + pub remaining: i128, + pub is_active: bool, + pub is_finished: bool, + pub is_paused: bool, + /// `true` when `withdrawable > 0`. + pub is_claimable: bool, +} diff --git a/contracts/forge-vesting-factory/Cargo.toml b/src/contracts/forge-vesting-factory/Cargo.toml similarity index 100% rename from contracts/forge-vesting-factory/Cargo.toml rename to src/contracts/forge-vesting-factory/Cargo.toml diff --git a/src/contracts/forge-vesting-factory/src/contract.rs b/src/contracts/forge-vesting-factory/src/contract.rs new file mode 100644 index 0000000..0f49689 --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/contract.rs @@ -0,0 +1,223 @@ +use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; +use crate::storage::DataKey; +use crate::types::{ScheduleConfig, VestingStatus}; +use crate::errors::FactoryError; + +#[contract] +pub struct ForgeVestingFactory; + +#[contractimpl] +impl ForgeVestingFactory { + /// Create a new vesting schedule and return its `schedule_id`. + pub fn create_schedule( + env: Env, + token: Address, + beneficiary: Address, + admin: Address, + total_amount: i128, + cliff_seconds: u64, + duration_seconds: u64, + ) -> Result { + admin.require_auth(); + + if total_amount <= 0 || duration_seconds == 0 || cliff_seconds > duration_seconds { + return Err(FactoryError::InvalidConfig); + } + + let id: u64 = env + .storage() + .instance() + .get(&DataKey::ScheduleCount) + .unwrap_or(0); + + let config = ScheduleConfig { + token: token.clone(), + beneficiary, + admin, + total_amount, + start_time: env.ledger().timestamp(), + cliff_seconds, + duration_seconds, + cancelled: false, + }; + + // Pull tokens from admin into the contract + token::Client::new(&env, &token).transfer( + &config.admin, + &env.current_contract_address(), + &total_amount, + ); + + env.storage() + .persistent() + .set(&DataKey::Schedule(id), &config); + env.storage() + .instance() + .set(&DataKey::ScheduleCount, &(id + 1)); + + env.events() + .publish((Symbol::new(&env, "schedule_created"),), (id, total_amount)); + + Ok(id) + } + + /// Claim all currently vested and unclaimed tokens for a schedule. + pub fn claim(env: Env, schedule_id: u64) -> Result { + let config: ScheduleConfig = env + .storage() + .persistent() + .get(&DataKey::Schedule(schedule_id)) + .ok_or(FactoryError::ScheduleNotFound)?; + + config.beneficiary.require_auth(); + + if config.cancelled { + return Err(FactoryError::Cancelled); + } + + let now = env.ledger().timestamp(); + let vested = Self::compute_vested(&config, now); + let claimed: i128 = env + .storage() + .persistent() + .get(&DataKey::Claimed(schedule_id)) + .unwrap_or(0); + + let elapsed = now.saturating_sub(config.start_time); + if elapsed < config.cliff_seconds { + return Err(FactoryError::CliffNotReached); + } + + let claimable = (vested - claimed).max(0); + if claimable == 0 { + return Err(FactoryError::NothingToClaim); + } + + env.storage() + .persistent() + .set(&DataKey::Claimed(schedule_id), &(claimed + claimable)); + + token::Client::new(&env, &config.token).transfer( + &env.current_contract_address(), + &config.beneficiary, + &claimable, + ); + + env.events() + .publish((Symbol::new(&env, "claimed"),), (schedule_id, claimable)); + + Ok(claimable) + } + + /// Cancel a vesting schedule. + pub fn cancel(env: Env, schedule_id: u64) -> Result<(), FactoryError> { + let mut config: ScheduleConfig = env + .storage() + .persistent() + .get(&DataKey::Schedule(schedule_id)) + .ok_or(FactoryError::ScheduleNotFound)?; + + config.admin.require_auth(); + + if config.cancelled { + return Err(FactoryError::Cancelled); + } + + let now = env.ledger().timestamp(); + let vested = Self::compute_vested(&config, now); + let claimed: i128 = env + .storage() + .persistent() + .get(&DataKey::Claimed(schedule_id)) + .unwrap_or(0); + + let token = token::Client::new(&env, &config.token); + + let beneficiary_amount = (vested - claimed).max(0); + if beneficiary_amount > 0 { + token.transfer( + &env.current_contract_address(), + &config.beneficiary, + &beneficiary_amount, + ); + } + + let admin_amount = (config.total_amount - vested).max(0); + if admin_amount > 0 { + token.transfer( + &env.current_contract_address(), + &config.admin, + &admin_amount, + ); + } + + config.cancelled = true; + env.storage() + .persistent() + .set(&DataKey::Schedule(schedule_id), &config); + + env.events() + .publish((Symbol::new(&env, "schedule_cancelled"),), (schedule_id,)); + + Ok(()) + } + + /// Return the current vesting status for a schedule. + pub fn get_status(env: Env, schedule_id: u64) -> Result { + let config: ScheduleConfig = env + .storage() + .persistent() + .get(&DataKey::Schedule(schedule_id)) + .ok_or(FactoryError::ScheduleNotFound)?; + + let now = env.ledger().timestamp(); + let vested = Self::compute_vested(&config, now); + let claimed: i128 = env + .storage() + .persistent() + .get(&DataKey::Claimed(schedule_id)) + .unwrap_or(0); + + let elapsed = now.saturating_sub(config.start_time); + let claimable = if elapsed >= config.cliff_seconds { + (vested - claimed).max(0) + } else { + 0 + }; + + Ok(VestingStatus { + schedule_id, + total_amount: config.total_amount, + claimed, + vested, + claimable, + cliff_reached: elapsed >= config.cliff_seconds, + fully_vested: vested >= config.total_amount, + cancelled: config.cancelled, + }) + } + + /// Return the total number of schedules ever created. + pub fn get_schedule_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::ScheduleCount) + .unwrap_or(0) + } + + // ── Internal ────────────────────────────────────────────────────────────── + + fn compute_vested(config: &ScheduleConfig, now: u64) -> i128 { + if config.cancelled { + return 0; + } + let elapsed = now.saturating_sub(config.start_time); + if elapsed < config.cliff_seconds { + return 0; + } + if elapsed >= config.duration_seconds { + return config.total_amount; + } + (config.total_amount * elapsed as i128) / config.duration_seconds as i128 + } +} diff --git a/src/contracts/forge-vesting-factory/src/errors.rs b/src/contracts/forge-vesting-factory/src/errors.rs new file mode 100644 index 0000000..571bce6 --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/errors.rs @@ -0,0 +1,12 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum FactoryError { + ScheduleNotFound = 1, + Unauthorized = 2, + CliffNotReached = 3, + NothingToClaim = 4, + Cancelled = 5, + InvalidConfig = 6, +} diff --git a/src/contracts/forge-vesting-factory/src/lib.rs b/src/contracts/forge-vesting-factory/src/lib.rs new file mode 100644 index 0000000..50781ee --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] + +//! # forge-vesting-factory +//! +//! A factory contract that manages multiple vesting schedules in a single deployment. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::ForgeVestingFactoryClient; +pub use crate::errors::FactoryError; +pub use crate::types::{ScheduleConfig, VestingStatus}; diff --git a/src/contracts/forge-vesting-factory/src/storage.rs b/src/contracts/forge-vesting-factory/src/storage.rs new file mode 100644 index 0000000..91ed2c3 --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/storage.rs @@ -0,0 +1,11 @@ +use soroban_sdk::contracttype; + +#[contracttype] +pub enum DataKey { + /// Per-schedule configuration, keyed by schedule_id. + Schedule(u64), + /// Cumulative claimed amount per schedule, keyed by schedule_id. + Claimed(u64), + /// Monotonically increasing schedule counter. + ScheduleCount, +} diff --git a/src/contracts/forge-vesting-factory/src/test.rs b/src/contracts/forge-vesting-factory/src/test.rs new file mode 100644 index 0000000..b42e344 --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/test.rs @@ -0,0 +1,78 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{ForgeVestingFactory, ForgeVestingFactoryClient}; + use crate::errors::FactoryError; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, + }; + + fn setup_token(env: &Env, admin: &Address, amount: i128) -> Address { + let token_admin = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + token::Client::new(env, &token).mint(admin, &amount); + token + } + + fn make_client(env: &Env) -> ForgeVestingFactoryClient { + let id = env.register_contract(None, ForgeVestingFactory); + ForgeVestingFactoryClient::new(env, &id) + } + + #[test] + fn test_create_schedule_success() { + let env = Env::default(); + env.mock_all_auths(); + let client = make_client(&env); + let admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token = setup_token(&env, &admin, 1_000); + + let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &100, &1_000); + assert_eq!(id, 0); + assert_eq!(client.get_schedule_count(), 1); + } + + #[test] + fn test_claim_after_cliff() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 0); + let client = make_client(&env); + let admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token = setup_token(&env, &admin, 1_000); + + let id = client.create_schedule(&token, &beneficiary, &admin, &1_000, &100, &1_000); + + env.ledger().with_mut(|l| l.timestamp = 500); + let claimed = client.claim(&id); + assert_eq!(claimed, 500); + + let status = client.get_status(&id); + assert_eq!(status.claimed, 500); + } + + #[test] + fn test_cancel_splits_tokens_correctly() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 0); + let client = make_client(&env); + let admin = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token_addr = setup_token(&env, &admin, 1_000); + let tok = token::Client::new(&env, &token_addr); + + let id = client.create_schedule(&token_addr, &beneficiary, &admin, &1_000, &0, &1_000); + + env.ledger().with_mut(|l| l.timestamp = 300); + client.cancel(&id); + + assert_eq!(tok.balance(&beneficiary), 300); + assert_eq!(tok.balance(&admin), 700); + } +} diff --git a/src/contracts/forge-vesting-factory/src/types.rs b/src/contracts/forge-vesting-factory/src/types.rs new file mode 100644 index 0000000..e98b128 --- /dev/null +++ b/src/contracts/forge-vesting-factory/src/types.rs @@ -0,0 +1,28 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone)] +pub struct ScheduleConfig { + pub token: Address, + pub beneficiary: Address, + pub admin: Address, + pub total_amount: i128, + pub start_time: u64, + pub cliff_seconds: u64, + pub duration_seconds: u64, + pub cancelled: bool, +} + +/// Status snapshot for a vesting schedule. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VestingStatus { + pub schedule_id: u64, + pub total_amount: i128, + pub claimed: i128, + pub vested: i128, + pub claimable: i128, + pub cliff_reached: bool, + pub fully_vested: bool, + pub cancelled: bool, +} diff --git a/contracts/forge-vesting/Cargo.toml b/src/contracts/forge-vesting/Cargo.toml similarity index 100% rename from contracts/forge-vesting/Cargo.toml rename to src/contracts/forge-vesting/Cargo.toml diff --git a/contracts/forge-vesting/README.md b/src/contracts/forge-vesting/README.md similarity index 100% rename from contracts/forge-vesting/README.md rename to src/contracts/forge-vesting/README.md diff --git a/src/contracts/forge-vesting/src/contract.rs b/src/contracts/forge-vesting/src/contract.rs new file mode 100644 index 0000000..f9a6619 --- /dev/null +++ b/src/contracts/forge-vesting/src/contract.rs @@ -0,0 +1,419 @@ +use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; +use crate::storage::DataKey; +use crate::types::{VestingConfig, VestingStatus, VestingSchedule}; +use crate::errors::VestingError; + +#[contract] +pub struct ForgeVesting; + +#[contractimpl] +impl ForgeVesting { + /// Initialize a new vesting schedule. + pub fn initialize( + env: Env, + token: Address, + beneficiary: Address, + admin: Address, + total_amount: i128, + cliff_seconds: u64, + duration_seconds: u64, + ) -> Result<(), VestingError> { + if env.storage().instance().has(&DataKey::Config) { + return Err(VestingError::AlreadyInitialized); + } + if total_amount <= 0 || duration_seconds == 0 || cliff_seconds > duration_seconds { + return Err(VestingError::InvalidConfig); + } + if admin == beneficiary { + return Err(VestingError::BeneficiaryAsAdmin); + } + + admin.require_auth(); + + let config = VestingConfig { + token, + beneficiary, + admin, + total_amount, + start_time: env.ledger().timestamp(), + cliff_seconds, + duration_seconds, + cancelled: false, + paused: false, + paused_at: None, + }; + + env.storage().instance().set(&DataKey::Config, &config); + env.storage().instance().set(&DataKey::Claimed, &0_i128); + + env.events().publish( + (Symbol::new(&env, "vesting_initialized"),), + ( + config.total_amount, + config.cliff_seconds, + config.duration_seconds, + ), + ); + + Ok(()) + } + + /// Claim all currently vested and unclaimed tokens. + pub fn claim(env: Env) -> Result { + let config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + if config.cancelled { + return Err(VestingError::Cancelled); + } + + if config.paused { + return Err(VestingError::Paused); + } + + config.beneficiary.require_auth(); + + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(config.start_time); + + if elapsed < config.cliff_seconds { + return Err(VestingError::CliffNotReached); + } + + let vested = Self::compute_vested(&config, now); + let claimed = Self::get_claimed(&env); + let claimable = vested - claimed; + + if claimable <= 0 { + return Err(VestingError::NothingToClaim); + } + + env.storage() + .instance() + .set(&DataKey::Claimed, &(claimed + claimable)); + + let token_client = token::Client::new(&env, &config.token); + token_client.transfer( + &env.current_contract_address(), + &config.beneficiary, + &claimable, + ); + + env.events().publish( + (Symbol::new(&env, "claimed"),), + (&config.beneficiary, claimable), + ); + + Ok(claimable) + } + + /// Cancel the vesting schedule and return unvested tokens to the admin. + pub fn cancel(env: Env) -> Result<(), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + config.admin.require_auth(); + + if config.cancelled { + return Err(VestingError::Cancelled); + } + + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(config.start_time); + + if elapsed >= config.duration_seconds { + return Err(VestingError::VestingComplete); + } + + let vested = Self::compute_vested(&config, now); + let claimed = Self::get_claimed(&env); + + let to_beneficiary = vested - claimed; + let to_admin = config.total_amount - vested; + + config.cancelled = true; + env.storage().instance().set(&DataKey::Config, &config); + env.storage() + .instance() + .set(&DataKey::VestedAtCancel, &vested); + env.storage().instance().set(&DataKey::Claimed, &vested); + + let token_client = token::Client::new(&env, &config.token); + + if to_beneficiary > 0 { + token_client.transfer( + &env.current_contract_address(), + &config.beneficiary, + &to_beneficiary, + ); + } + + if to_admin > 0 { + token_client.transfer(&env.current_contract_address(), &config.admin, &to_admin); + } + + env.events().publish( + (Symbol::new(&env, "vesting_cancelled"),), + (&config.admin, to_admin, &config.beneficiary, to_beneficiary), + ); + + Ok(()) + } + + /// Atomically claim all vested tokens for the beneficiary and return unvested tokens to the admin. + pub fn cancel_and_claim(env: Env) -> Result<(i128, i128), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + if config.cancelled { + return Err(VestingError::Cancelled); + } + if config.paused { + return Err(VestingError::Paused); + } + + config.admin.require_auth(); + config.beneficiary.require_auth(); + + let now = env.ledger().timestamp(); + let vested = Self::compute_vested(&config, now); + let claimed = Self::get_claimed(&env); + let to_beneficiary = vested - claimed; + let to_admin = config.total_amount - vested; + + config.cancelled = true; + env.storage().instance().set(&DataKey::Config, &config); + env.storage() + .instance() + .set(&DataKey::VestedAtCancel, &vested); + env.storage() + .instance() + .set(&DataKey::Claimed, &(claimed + to_beneficiary)); + + let token_client = token::Client::new(&env, &config.token); + if to_beneficiary > 0 { + token_client.transfer( + &env.current_contract_address(), + &config.beneficiary, + &to_beneficiary, + ); + } + if to_admin > 0 { + token_client.transfer(&env.current_contract_address(), &config.admin, &to_admin); + } + + env.events().publish( + (Symbol::new(&env, "claimed"),), + (&config.beneficiary, to_beneficiary), + ); + env.events().publish( + (Symbol::new(&env, "vesting_cancelled"),), + (&config.admin, to_admin, &config.beneficiary, to_beneficiary), + ); + + Ok((to_beneficiary, to_admin)) + } + + /// Transfer admin rights to a new address. + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + config.admin.require_auth(); + + if config.admin == new_admin { + return Err(VestingError::SameAdmin); + } + if config.beneficiary == new_admin { + return Err(VestingError::BeneficiaryAsAdmin); + } + + let old_admin = config.admin; + config.admin = new_admin.clone(); + env.storage().instance().set(&DataKey::Config, &config); + + env.events().publish( + (Symbol::new(&env, "admin_transferred"),), + (&old_admin, &new_admin), + ); + + Ok(()) + } + + /// Transfer beneficiary rights to a new address. + pub fn change_beneficiary(env: Env, new_beneficiary: Address) -> Result<(), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + config.beneficiary.require_auth(); + + if config.cancelled { + return Err(VestingError::Cancelled); + } + + if config.beneficiary == new_beneficiary { + return Err(VestingError::SameBeneficiary); + } + + let old_beneficiary = config.beneficiary; + config.beneficiary = new_beneficiary.clone(); + env.storage().instance().set(&DataKey::Config, &config); + + env.events().publish( + (Symbol::new(&env, "beneficiary_changed"),), + (&old_beneficiary, &new_beneficiary), + ); + + Ok(()) + } + + /// Return a snapshot of the current vesting status. + pub fn get_status(env: Env) -> Result { + let config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(config.start_time); + let cliff_reached = elapsed >= config.cliff_seconds; + let vested = if config.cancelled { + env.storage() + .instance() + .get(&DataKey::VestedAtCancel) + .unwrap_or(0) + } else { + Self::compute_vested(&config, now) + }; + let claimed = Self::get_claimed(&env); + let claimable = (vested - claimed).max(0); + let fully_vested = vested >= config.total_amount; + + Ok(VestingStatus { + total_amount: config.total_amount, + claimed, + vested, + claimable, + cliff_reached, + fully_vested, + paused: config.paused, + }) + } + + /// Return the full vesting configuration set at initialization. + pub fn get_config(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized) + } + + /// Return the vesting schedule parameters. + pub fn get_vesting_schedule(env: Env) -> Result { + let config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + Ok(VestingSchedule { + token: config.token, + beneficiary: config.beneficiary, + total_amount: config.total_amount, + cliff_seconds: config.cliff_seconds, + duration_seconds: config.duration_seconds, + start_time: config.start_time, + }) + } + + /// Pause the vesting schedule. + pub fn pause(env: Env) -> Result<(), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + config.admin.require_auth(); + + if config.cancelled { + return Err(VestingError::Cancelled); + } + + if config.paused { + return Err(VestingError::Paused); + } + + config.paused = true; + config.paused_at = Some(env.ledger().timestamp()); + env.storage().instance().set(&DataKey::Config, &config); + + Ok(()) + } + + /// Unpause the vesting schedule. + pub fn unpause(env: Env) -> Result<(), VestingError> { + let mut config: VestingConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VestingError::NotInitialized)?; + + config.admin.require_auth(); + + if config.cancelled { + return Err(VestingError::Cancelled); + } + + if !config.paused { + return Err(VestingError::NotPaused); + } + + let now = env.ledger().timestamp(); + let paused_at = config.paused_at.unwrap_or(now); + let delta = now.saturating_sub(paused_at); + config.start_time = config.start_time.saturating_add(delta); + config.paused = false; + config.paused_at = None; + env.storage().instance().set(&DataKey::Config, &config); + + Ok(()) + } + + // ── Internal ────────────────────────────────────────────────────────────── + + fn get_claimed(env: &Env) -> i128 { + env.storage().instance().get(&DataKey::Claimed).unwrap_or(0) + } + + fn compute_vested(config: &VestingConfig, now: u64) -> i128 { + if config.cancelled { + return 0; + } + let effective_now = if config.paused { config.paused_at.unwrap_or(now) } else { now }; + let elapsed = effective_now.saturating_sub(config.start_time); + if elapsed < config.cliff_seconds { + return 0; + } + if elapsed >= config.duration_seconds { + return config.total_amount; + } + (config.total_amount * elapsed as i128) / config.duration_seconds as i128 + } +} diff --git a/src/contracts/forge-vesting/src/errors.rs b/src/contracts/forge-vesting/src/errors.rs new file mode 100644 index 0000000..1ae5892 --- /dev/null +++ b/src/contracts/forge-vesting/src/errors.rs @@ -0,0 +1,19 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum VestingError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + CliffNotReached = 4, + NothingToClaim = 5, + Cancelled = 6, + InvalidConfig = 7, + SameAdmin = 8, + SameBeneficiary = 11, + BeneficiaryAsAdmin = 12, + Paused = 9, + NotPaused = 10, + VestingComplete = 13, +} diff --git a/src/contracts/forge-vesting/src/lib.rs b/src/contracts/forge-vesting/src/lib.rs new file mode 100644 index 0000000..9dc3857 --- /dev/null +++ b/src/contracts/forge-vesting/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] + +//! # forge-vesting +//! +//! Token vesting contract with configurable cliff and linear release schedule. + +pub mod contract; +pub mod errors; +pub mod storage; +pub mod types; + +#[cfg(test)] +mod test; + +pub use crate::contract::ForgeVestingClient; +pub use crate::errors::VestingError; +pub use crate::types::{VestingConfig, VestingStatus, VestingSchedule}; diff --git a/src/contracts/forge-vesting/src/storage.rs b/src/contracts/forge-vesting/src/storage.rs new file mode 100644 index 0000000..a5d23c9 --- /dev/null +++ b/src/contracts/forge-vesting/src/storage.rs @@ -0,0 +1,8 @@ +use soroban_sdk::contracttype; + +#[contracttype] +pub enum DataKey { + Config, + Claimed, + VestedAtCancel, +} diff --git a/src/contracts/forge-vesting/src/test.rs b/src/contracts/forge-vesting/src/test.rs new file mode 100644 index 0000000..54c1b40 --- /dev/null +++ b/src/contracts/forge-vesting/src/test.rs @@ -0,0 +1,274 @@ +#[cfg(test)] +mod tests { + extern crate std; + use crate::contract::{ForgeVesting, ForgeVestingClient}; + use crate::errors::VestingError; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, + }; + + fn setup() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, ForgeVesting); + let token = Address::generate(&env); + let beneficiary = Address::generate(&env); + let admin = Address::generate(&env); + (env, contract_id, token, beneficiary, admin) + } + + fn setup_with_token() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, ForgeVesting); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let beneficiary = Address::generate(&env); + let admin = Address::generate(&env); + { + soroban_sdk::token::StellarAssetClient::new(&env, &token_id) + .mint(&contract_id, &1_000_000); + } + (env, contract_id, token_id, beneficiary, admin) + } + + fn setup_cliff_equals_duration() -> (Env, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, ForgeVesting); + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let beneficiary = Address::generate(&env); + let admin = Address::generate(&env); + soroban_sdk::token::StellarAssetClient::new(&env, &token_id).mint(&contract_id, &1_000_000); + (env, contract_id, token_id, beneficiary, admin) + } + + #[test] + fn test_initialize_success() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + let result = client.try_initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); + assert!(result.is_ok()); + } + + #[test] + fn test_cancel_after_full_vesting_fails() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); + + // Advance past duration + env.ledger().with_mut(|l| l.timestamp += 1001); + let result = client.try_cancel(); + assert_eq!(result, Err(Ok(VestingError::VestingComplete))); + } + + #[test] + fn test_claim_after_failed_cancel_succeeds() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); + + // Mock token transfer for claim + env.mock_all_auths(); + + // Advance to full vesting + env.ledger().with_mut(|l| l.timestamp += 1000); + + // Cancel fails + let cancel_result = client.try_cancel(); + assert_eq!(cancel_result, Err(Ok(VestingError::VestingComplete))); + + // Beneficiary can still claim + let claim_result = client.try_claim(); + assert!(claim_result.is_ok()); + assert_eq!(claim_result.unwrap(), Ok(1_000_000)); + } + + #[test] + fn test_compute_vested_dust_verification() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1000, &0, &3); + + env.mock_all_auths(); + let start_ts = env.ledger().timestamp(); + + // t=1 + env.ledger().with_mut(|l| l.timestamp = start_ts + 1); + let v1 = client.claim(); + assert_eq!(v1, 333); + + // t=2 + env.ledger().with_mut(|l| l.timestamp = start_ts + 2); + let v2 = client.claim(); + assert_eq!(v2, 333); + + // t=3 + env.ledger().with_mut(|l| l.timestamp = start_ts + 3); + let v3 = client.claim(); + assert_eq!(v3, 334); + + assert_eq!(v1 + v2 + v3, 1000); + } + + #[test] + fn test_double_initialize_fails() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); + + let result = client.try_initialize(&token, &Address::generate(&env), &Address::generate(&env), &9_999_999, &500, &5000); + assert_eq!(result, Err(Ok(VestingError::AlreadyInitialized))); + } + + #[test] + fn test_claim_before_cliff_fails() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &500, &1000); + env.ledger().with_mut(|l| l.timestamp += 100); + let result = client.try_claim(); + assert_eq!(result, Err(Ok(VestingError::CliffNotReached))); + } + + #[test] + fn test_get_status_before_cliff() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &500, &1000); + let status = client.get_status(); + assert!(!status.cliff_reached); + assert_eq!(status.claimable, 0); + } + + #[test] + fn test_get_vesting_schedule_returns_init_params() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &2_500_000, &200, &5000); + let schedule = client.get_vesting_schedule(); + assert_eq!(schedule.total_amount, 2_500_000); + assert_eq!(schedule.cliff_seconds, 200); + assert_eq!(schedule.duration_seconds, 5000); + } + + #[test] + fn test_cancel_by_admin() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); + let result = client.try_cancel(); + assert!(result.is_ok()); + } + + #[test] + fn test_double_cancel_fails() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); + client.cancel(); + let result = client.try_cancel(); + assert_eq!(result, Err(Ok(VestingError::Cancelled))); + } + + #[test] + fn test_claim_after_cancel_fails() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &100, &1000); + client.cancel(); + env.ledger().with_mut(|l| l.timestamp += 200); + let result = client.try_claim(); + assert_eq!(result, Err(Ok(VestingError::Cancelled))); + } + + #[test] + fn test_fully_vested_after_duration() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); + env.ledger().with_mut(|l| l.timestamp += 2000); + let status = client.get_status(); + assert!(status.fully_vested); + assert_eq!(status.vested, 1_000_000); + } + + #[test] + fn test_get_status_after_partial_claim_then_time_advance() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + env.ledger().with_mut(|l| l.timestamp = 0); + client.initialize(&token_id, &beneficiary, &admin, &10_000, &0, &1000); + + env.ledger().with_mut(|l| l.timestamp = 200); + client.claim(); + + let s = client.get_status(); + assert_eq!(s.vested, 2_000); + assert_eq!(s.claimed, 2_000); + assert_eq!(s.claimable, 0); + + env.ledger().with_mut(|l| l.timestamp = 500); + let s = client.get_status(); + assert_eq!(s.vested, 5_000); + assert_eq!(s.claimed, 2_000); + assert_eq!(s.claimable, 3_000); + } + + #[test] + fn test_cancel_before_cliff_beneficiary_gets_zero_admin_gets_all() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &500, &1000); + env.ledger().with_mut(|l| l.timestamp += 100); + client.cancel(); + let tc = soroban_sdk::token::Client::new(&env, &token_id); + assert_eq!(tc.balance(&beneficiary), 0); + assert_eq!(tc.balance(&admin), 1_000_000); + } + + #[test] + fn test_transfer_admin_success() { + let (env, contract_id, token, beneficiary, admin) = setup(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token, &beneficiary, &admin, &1_000_000, &100, &1000); + let new_admin = Address::generate(&env); + client.transfer_admin(&new_admin); + let config = client.get_config(); + assert_eq!(config.admin, new_admin); + } + + #[test] + fn test_pause_freezes_vested_amount_and_blocks_claim() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); + env.ledger().with_mut(|l| l.timestamp = 500); + client.pause(); + let status = client.get_status(); + assert!(status.paused); + assert_eq!(status.vested, 500_000); + assert!(client.try_claim().is_err()); + } + + #[test] + fn test_unpause_shifts_timeline_correctly() { + let (env, contract_id, token_id, beneficiary, admin) = setup_with_token(); + let client = ForgeVestingClient::new(&env, &contract_id); + client.initialize(&token_id, &beneficiary, &admin, &1_000_000, &0, &1000); + let original_start = client.get_config().start_time; + env.ledger().with_mut(|l| l.timestamp = 500); + client.pause(); + env.ledger().with_mut(|l| l.timestamp = 700); + client.unpause(); + let config = client.get_config(); + assert_eq!(config.start_time, original_start + 200); + } +} diff --git a/src/contracts/forge-vesting/src/types.rs b/src/contracts/forge-vesting/src/types.rs new file mode 100644 index 0000000..624b9d1 --- /dev/null +++ b/src/contracts/forge-vesting/src/types.rs @@ -0,0 +1,56 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone)] +pub struct VestingConfig { + /// Token contract address + pub token: Address, + /// Beneficiary who receives vested tokens + pub beneficiary: Address, + /// Admin who can cancel vesting + pub admin: Address, + /// Total tokens to vest + pub total_amount: i128, + /// Timestamp when vesting starts + pub start_time: u64, + /// Seconds before any tokens unlock + pub cliff_seconds: u64, + /// Total vesting duration in seconds + pub duration_seconds: u64, + /// Whether vesting has been cancelled + pub cancelled: bool, + /// Whether vesting is currently paused + pub paused: bool, + /// Ledger timestamp when vesting was paused (None if not paused) + pub paused_at: Option, +} + +#[contracttype] +#[derive(Clone)] +pub struct VestingStatus { + pub total_amount: i128, + pub claimed: i128, + pub vested: i128, + pub claimable: i128, + pub cliff_reached: bool, + pub fully_vested: bool, + pub paused: bool, +} + +/// Vesting schedule configuration (excludes admin and cancellation state). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VestingSchedule { + /// Token contract address + pub token: Address, + /// Beneficiary who receives vested tokens + pub beneficiary: Address, + /// Total tokens to vest + pub total_amount: i128, + /// Seconds before any tokens unlock + pub cliff_seconds: u64, + /// Total vesting duration in seconds + pub duration_seconds: u64, + /// Timestamp when vesting starts + pub start_time: u64, +} diff --git a/scripts/README.md b/src/scripts/README.md similarity index 100% rename from scripts/README.md rename to src/scripts/README.md diff --git a/scripts/pre-commit b/src/scripts/pre-commit old mode 100755 new mode 100644 similarity index 100% rename from scripts/pre-commit rename to src/scripts/pre-commit diff --git a/scripts/update-wasm-sizes.sh b/src/scripts/update-wasm-sizes.sh similarity index 98% rename from scripts/update-wasm-sizes.sh rename to src/scripts/update-wasm-sizes.sh index 0fddd18..2614546 100644 --- a/scripts/update-wasm-sizes.sh +++ b/src/scripts/update-wasm-sizes.sh @@ -13,7 +13,7 @@ if [[ "${1:-}" == "--dry-run" ]]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" README="$REPO_ROOT/README.md" WASM_DIR="$REPO_ROOT/target/wasm32v1-none/release" From c0af2a7decee74b559541099901fc5fc718600f9 Mon Sep 17 00:00:00 2001 From: oscar24357 Date: Sat, 25 Apr 2026 19:23:32 +0100 Subject: [PATCH 2/4] docs: address TODOs and fix broken links - Synchronize README.md event tables with source code - Add missing forge-vesting-factory event documentation - Create CONTRIBUTING.md to fix broken documentation links - Comment out broken bench target in Makefile - Correct forge-stream function signature in README.md --- CONTRIBUTING.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 6 ++-- README.md | 32 +++++++++++++------ 3 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..28b1f98 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to StellarForge + +Thank you for your interest in contributing to StellarForge! We welcome contributions that help make this collection of Soroban smart contract primitives more robust and easier to use. + +## 🛠️ Prerequisites + +To contribute to this project, you will need: +- **Rust:** Latest stable version +- **Target:** `wasm32v1-none` +- **Stellar CLI:** v25.2.0 or higher +- **Make:** Optional, but recommended for running development commands + +## 🚀 Getting Started + +1. **Fork the repository** on GitHub. +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR_USERNAME/stellarforge.git + cd stellarforge + ``` +3. **Set up the pre-commit hook** (recommended): + ```bash + cp src/scripts/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + ``` + +### Pre-commit Hook (Optional but Recommended) + +We provide a git pre-commit hook that automatically checks code formatting and linting before each commit. This helps catch issues early. + +By default, the hook runs `cargo fmt` and `cargo clippy`. To also run the full test suite before each commit, set the `FORGE_PRECOMMIT_TESTS` environment variable to `1`: + +```bash +# Run tests on this commit only +FORGE_PRECOMMIT_TESTS=1 git commit -m "your message" +``` + +## 📜 Development Workflow + +### Building +Build all contracts in the workspace: +```bash +make build +# or +cargo build --workspace +``` + +### Testing +Run the full test suite: +```bash +make test +# or +cargo test --workspace +``` + +### Linting & Formatting +Ensure your code follows the project's style: +```bash +make check +# which runs: +# cargo fmt --all -- --check +# cargo clippy --all-targets -- -D warnings +``` + +## 🏗️ Pull Request Process + +1. Create a new branch for your feature or bug fix. +2. Ensure all tests pass and the code is correctly formatted. +3. Update the documentation (README.md, docs/) if you've changed contract interfaces or added new features. +4. Submit a Pull Request targeting the `main` branch. +5. Use the provided PR template to describe your changes and testing. + +## 🏷️ Issue Labels + +- `good first issue` — Great for newcomers! +- `bug` — Something isn't working correctly. +- `enhancement` — New features or improvements. +- `documentation` — Improvements to the docs. + +## 🆘 Need Help? + +If you have questions, feel free to open an issue or start a discussion in the [GitHub Discussions](https://github.com/soma-enyi/stellarforge/discussions). diff --git a/Makefile b/Makefile index fb08fe4..6d71b5b 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,6 @@ clean: cargo clean # Run contract benchmarks (Soroban budget: CPU instructions + memory bytes) -.PHONY: bench -bench: - cargo run -p forge-benches \ No newline at end of file +# .PHONY: bench +# bench: +# cargo run -p forge-benches \ No newline at end of file diff --git a/README.md b/README.md index 3bc0514..3058304 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ Developers evaluating StellarForge can use this table to quickly identify the ri | Contract | Use Case | Admin Required | Events Emitted | Timelock | | :--- | :--- | :--- | :--- | :--- | -| [`forge-governor`](#forge-governor) | Governance | No (Auth-based) | None | Yes (Voting/Execution delay) | -| [`forge-multisig`](#forge-multisig) | Multisig Treasury | Yes (Owners) | None | Yes (Post-approval delay) | -| [`forge-oracle`](#forge-oracle) | Price Feed | Yes (Admin) | `price_updated` | No | +| [`forge-governor`](#forge-governor) | Governance | No (Auth-based) | `proposal_created`, `vote_cast`, `proposal_finalized` | Yes (Voting/Execution delay) | +| [`forge-multisig`](#forge-multisig) | Multisig Treasury | Yes (Owners) | `proposal_created`, `proposal_approved`, `proposal_executed` | Yes (Post-approval delay) | +| [`forge-oracle`](#forge-oracle) | Price Feed | Yes (Admin) | `price_updated`, `admin_transferred` | No | | [`forge-stream`](#forge-stream) | Real-time Payments | No (Stream-specific) | `stream_created`, `withdrawn`, `stream_cancelled`, `stream_paused`, `stream_resumed` | No | -| [`forge-vesting`](#forge-vesting) | Token Vesting | Yes (Admin) | `vesting_initialized`, `claimed`, `vesting_cancelled`, `admin_transferred` | Yes (Cliff period) | +| [`forge-vesting`](#forge-vesting) | Token Vesting | Yes (Admin) | `vesting_initialized`, `claimed`, `vesting_cancelled`, `admin_transferred`, `beneficiary_changed` | Yes (Cliff period) | | [`forge-vesting-factory`](#forge-vesting-factory) | Multi-beneficiary Vesting | Yes (Per-schedule Admin) | `schedule_created`, `claimed`, `schedule_cancelled` | Yes (Cliff period) | --- @@ -82,7 +82,7 @@ A single-deployment factory that manages multiple vesting schedules. Eliminates ### forge-stream Pay-per-second token streams. Ideal for payroll, subscriptions, or real-time contractor payments. -* **Key Function:** `create_stream(sender, token, recipient, rate_per_second, duration_seconds)` +* **Key Function:** `create_stream(sender, token, recipient, rate_per_second, duration_seconds, min_withdrawal_amount)` * **Action:** `withdraw(stream_id)` allows the recipient to pull accrued tokens at any time. * **Pause/Resume:** `pause_stream(stream_id)` and `resume_stream(stream_id)` allow senders to temporarily halt or restart token accrual. * **`is_active` vs `is_claimable`:** `get_stream_status()` returns both fields. A finished stream has `is_active = false` and `is_finished = true`, but may still have `withdrawable > 0`. Always check `is_claimable` (or `withdrawable` directly) to determine whether tokens can be pulled — do not rely on `is_active` alone. @@ -118,14 +118,15 @@ The tables below are verified against the current contract code in `contracts/*/ | :--- | :--- | :--- | | `vesting_initialized` | Emitted by `initialize(...)` after the vesting config and claimed amount are stored. | `total_amount: i128`, `cliff_seconds: u64`, `duration_seconds: u64` | | `claimed` | Emitted by `claim()` after the beneficiary's claimed amount is updated and vested tokens are transferred. | `beneficiary: Address`, `claimable: i128` | -| `vesting_cancelled` | Emitted by `cancel()` after the vesting is marked cancelled and any unvested tokens are returned to the admin. | `admin: Address`, `returnable: i128` | +| `vesting_cancelled` | Emitted by `cancel()` after the vesting is marked cancelled and any unvested tokens are returned to the admin. | `admin: Address`, `to_admin: i128`, `beneficiary: Address`, `to_beneficiary: i128` | | `admin_transferred` | Emitted by `transfer_admin(new_admin)` after admin rights move to the new admin address. | `old_admin: Address`, `new_admin: Address` | +| `beneficiary_changed` | Emitted by `change_beneficiary(new_beneficiary)` after beneficiary rights move to the new address. | `old_beneficiary: Address`, `new_beneficiary: Address` | ### forge-stream | Event Name | Trigger | Fields | | :--- | :--- | :--- | -| `stream_created` | Emitted by `create_stream(...)` after the stream is stored and the active stream count is incremented. | `stream_id: u64`, `recipient: Address`, `rate_per_second: i128`, `duration_seconds: u64` | +| `stream_created` | Emitted by `create_stream(...)` after the stream is stored and the active stream count is incremented. | `stream_id: u64`, `recipient: Address`, `rate_per_second: i128`, `duration_seconds: u64`, `min_withdrawal_amount: i128` | | `withdrawn` | Emitted by `withdraw(stream_id)` after the withdrawn amount is updated and accrued tokens are transferred to the recipient. | `stream_id: u64`, `recipient: Address`, `withdrawable: i128` | | `stream_cancelled` | Emitted by `cancel_stream(stream_id)` after the stream is marked cancelled and funds are paid out/refunded. | `stream_id: u64`, `withdrawable: i128`, `returnable: i128` | | `stream_paused` | Emitted by `pause_stream(stream_id)` after the stream is marked paused. | `stream_id: u64` | @@ -135,19 +136,32 @@ The tables below are verified against the current contract code in `contracts/*/ | Event Name | Trigger | Fields | | :--- | :--- | :--- | -| None | This contract does not currently emit any events. | None | +| `proposal_created` | Emitted by `propose(...)` after the proposal is stored and the proposer is marked as approved. | `proposal_id: u64`, `proposer: Address`, `to: Address`, `token: Address`, `amount: i128` | +| `proposal_approved` | Emitted by `approve(...)` after an owner approves a proposal. | `proposal_id: u64`, `owner: Address`, `approval_count: u32` | +| `proposal_executed` | Emitted by `execute(...)` after a proposal is successfully executed. | `proposal_id: u64`, `executor: Address`, `to: Address`, `amount: i128` | ### forge-governor | Event Name | Trigger | Fields | | :--- | :--- | :--- | -| None | This contract does not currently emit any events. | None | +| `proposal_created` | Emitted by `propose(...)` after the proposal is stored. | `proposal_id: u64`, `proposer: Address`, `vote_end: u64` | +| `vote_cast` | Emitted by `vote(...)` after a vote is successfully cast. | `proposal_id: u64`, `voter: Address`, `direction: VoteDirection`, `weight: i128` | +| `proposal_finalized` | Emitted by `finalize(...)` after a proposal is finalized (Passed or Failed). | `proposal_id: u64`, `votes_for: i128`, `votes_against: i128` | ### forge-oracle | Event Name | Trigger | Fields | | :--- | :--- | :--- | | `price_updated` | Emitted by `submit_price(base, quote, price)` after the submitted price and update timestamp are written to storage. | `base: Symbol`, `quote: Symbol`, `price: i128`, `updated_at: u64` | +| `admin_transferred` | Emitted by `transfer_admin(new_admin)` after admin rights move to the new admin address. | `old_admin: Address`, `new_admin: Address` | + +### forge-vesting-factory + +| Event Name | Trigger | Fields | +| :--- | :--- | :--- | +| `schedule_created` | Emitted by `create_schedule(...)` after a new schedule is created. | `id: u64`, `total_amount: i128` | +| `claimed` | Emitted by `claim(...)` after tokens are claimed for a schedule. | `schedule_id: u64`, `claimable: i128` | +| `schedule_cancelled` | Emitted by `cancel(...)` after a schedule is cancelled. | `schedule_id: u64` | --- From d84b173abe9a41f499169ccb7c365eff300014c1 Mon Sep 17 00:00:00 2001 From: oscar24357 Date: Sat, 25 Apr 2026 19:38:56 +0100 Subject: [PATCH 3/4] docs: fix encoding in CONTRIBUTING.md --- CONTRIBUTING.md | 82 ------------------------------------------------- 1 file changed, 82 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28b1f98..e69de29 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,82 +0,0 @@ -# Contributing to StellarForge - -Thank you for your interest in contributing to StellarForge! We welcome contributions that help make this collection of Soroban smart contract primitives more robust and easier to use. - -## 🛠️ Prerequisites - -To contribute to this project, you will need: -- **Rust:** Latest stable version -- **Target:** `wasm32v1-none` -- **Stellar CLI:** v25.2.0 or higher -- **Make:** Optional, but recommended for running development commands - -## 🚀 Getting Started - -1. **Fork the repository** on GitHub. -2. **Clone your fork** locally: - ```bash - git clone https://github.com/YOUR_USERNAME/stellarforge.git - cd stellarforge - ``` -3. **Set up the pre-commit hook** (recommended): - ```bash - cp src/scripts/pre-commit .git/hooks/pre-commit - chmod +x .git/hooks/pre-commit - ``` - -### Pre-commit Hook (Optional but Recommended) - -We provide a git pre-commit hook that automatically checks code formatting and linting before each commit. This helps catch issues early. - -By default, the hook runs `cargo fmt` and `cargo clippy`. To also run the full test suite before each commit, set the `FORGE_PRECOMMIT_TESTS` environment variable to `1`: - -```bash -# Run tests on this commit only -FORGE_PRECOMMIT_TESTS=1 git commit -m "your message" -``` - -## 📜 Development Workflow - -### Building -Build all contracts in the workspace: -```bash -make build -# or -cargo build --workspace -``` - -### Testing -Run the full test suite: -```bash -make test -# or -cargo test --workspace -``` - -### Linting & Formatting -Ensure your code follows the project's style: -```bash -make check -# which runs: -# cargo fmt --all -- --check -# cargo clippy --all-targets -- -D warnings -``` - -## 🏗️ Pull Request Process - -1. Create a new branch for your feature or bug fix. -2. Ensure all tests pass and the code is correctly formatted. -3. Update the documentation (README.md, docs/) if you've changed contract interfaces or added new features. -4. Submit a Pull Request targeting the `main` branch. -5. Use the provided PR template to describe your changes and testing. - -## 🏷️ Issue Labels - -- `good first issue` — Great for newcomers! -- `bug` — Something isn't working correctly. -- `enhancement` — New features or improvements. -- `documentation` — Improvements to the docs. - -## 🆘 Need Help? - -If you have questions, feel free to open an issue or start a discussion in the [GitHub Discussions](https://github.com/soma-enyi/stellarforge/discussions). From f2ad4ec45e35f31b6f837fc05579ea1910287d7e Mon Sep 17 00:00:00 2001 From: oscar24357 Date: Sat, 25 Apr 2026 19:42:38 +0100 Subject: [PATCH 4/4] docs: restore clean encoding for CONTRIBUTING.md --- CONTRIBUTING.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29..28b1f98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to StellarForge + +Thank you for your interest in contributing to StellarForge! We welcome contributions that help make this collection of Soroban smart contract primitives more robust and easier to use. + +## 🛠️ Prerequisites + +To contribute to this project, you will need: +- **Rust:** Latest stable version +- **Target:** `wasm32v1-none` +- **Stellar CLI:** v25.2.0 or higher +- **Make:** Optional, but recommended for running development commands + +## 🚀 Getting Started + +1. **Fork the repository** on GitHub. +2. **Clone your fork** locally: + ```bash + git clone https://github.com/YOUR_USERNAME/stellarforge.git + cd stellarforge + ``` +3. **Set up the pre-commit hook** (recommended): + ```bash + cp src/scripts/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + ``` + +### Pre-commit Hook (Optional but Recommended) + +We provide a git pre-commit hook that automatically checks code formatting and linting before each commit. This helps catch issues early. + +By default, the hook runs `cargo fmt` and `cargo clippy`. To also run the full test suite before each commit, set the `FORGE_PRECOMMIT_TESTS` environment variable to `1`: + +```bash +# Run tests on this commit only +FORGE_PRECOMMIT_TESTS=1 git commit -m "your message" +``` + +## 📜 Development Workflow + +### Building +Build all contracts in the workspace: +```bash +make build +# or +cargo build --workspace +``` + +### Testing +Run the full test suite: +```bash +make test +# or +cargo test --workspace +``` + +### Linting & Formatting +Ensure your code follows the project's style: +```bash +make check +# which runs: +# cargo fmt --all -- --check +# cargo clippy --all-targets -- -D warnings +``` + +## 🏗️ Pull Request Process + +1. Create a new branch for your feature or bug fix. +2. Ensure all tests pass and the code is correctly formatted. +3. Update the documentation (README.md, docs/) if you've changed contract interfaces or added new features. +4. Submit a Pull Request targeting the `main` branch. +5. Use the provided PR template to describe your changes and testing. + +## 🏷️ Issue Labels + +- `good first issue` — Great for newcomers! +- `bug` — Something isn't working correctly. +- `enhancement` — New features or improvements. +- `documentation` — Improvements to the docs. + +## 🆘 Need Help? + +If you have questions, feel free to open an issue or start a discussion in the [GitHub Discussions](https://github.com/soma-enyi/stellarforge/discussions).