diff --git a/contracts/iln_governance/src/lib.rs b/contracts/iln_governance/src/lib.rs index 45a9afa9..ba9ba383 100644 --- a/contracts/iln_governance/src/lib.rs +++ b/contracts/iln_governance/src/lib.rs @@ -274,6 +274,11 @@ pub enum StorageKey { /// Configurable minimum participation required for proposal passing. /// Expressed in basis points (bps) of total supply, e.g. 1000 = 10%. MinQuorumBps, + /// Issue #622: governance token total supply used as the quorum + /// denominator. Seeded at `initialize` time and only updatable via the + /// ILN-contract-gated `set_gov_token_total_supply` — no longer a + /// caller-supplied `execute_proposal` argument. + GovTokenTotalSupply, Proposal(u64), ProposalCount, VoteWeightSnapshot(u64, Address), @@ -318,6 +323,7 @@ impl GovContract { distribution_contract: Address, gov_token: Address, admin: Address, + gov_token_total_supply: i128, ) -> Result<(), GovernanceError> { if env.storage().instance().has(&StorageKey::IlnContract) { return Err(GovernanceError::AlreadyInitialized); @@ -341,6 +347,19 @@ impl GovContract { env.storage() .instance() .set(&StorageKey::ProposalCount, &0_u64); + // Issue #622: total_supply used to be a caller-supplied argument to + // execute_proposal, letting any caller inflate or deflate it to + // manipulate quorum. soroban-sdk 21.x's token::Client has no + // total_supply() query (SEP-41's TokenInterface/StellarAssetInterface + // don't expose one), so a live on-chain read isn't available here — + // instead this value is seeded at initialize time and can only be + // updated afterwards via set_gov_token_total_supply, which requires + // the same iln_contract authorization as set_min_quorum_bps / + // set_min_proposal_balance. It is no longer settable by whoever + // happens to call execute_proposal. + env.storage() + .instance() + .set(&StorageKey::GovTokenTotalSupply, &gov_token_total_supply); env.events().publish( (Symbol::new(&env, "initialized"), admin.clone()), @@ -361,6 +380,54 @@ impl GovContract { .unwrap_or(DEFAULT_MIN_QUORUM_BPS) } + /// Returns the configured governance token total supply used for quorum + /// calculations (see Issue #622). + pub fn get_gov_token_total_supply(env: Env) -> i128 { + env.storage() + .instance() + .get(&StorageKey::GovTokenTotalSupply) + .unwrap_or(0) + } + + /// Updates the governance token total supply used for quorum + /// calculations. + /// + /// Authorization: the configured ILN contract address must authorize — + /// the same trust boundary as `set_min_quorum_bps` / + /// `set_min_proposal_balance`. This replaces the old caller-supplied + /// `total_supply` argument on `execute_proposal` (Issue #622): quorum's + /// denominator can no longer be chosen by whoever happens to call + /// execute_proposal, only by the same authority that already controls + /// the quorum bps and proposal-balance thresholds. + pub fn set_gov_token_total_supply(env: Env, total_supply: i128) -> Result<(), GovernanceError> { + let iln_contract: Address = env + .storage() + .instance() + .get(&StorageKey::IlnContract) + .unwrap(); + iln_contract.require_auth(); + + let old_value: i128 = env + .storage() + .instance() + .get(&StorageKey::GovTokenTotalSupply) + .unwrap_or(0); + env.storage() + .instance() + .set(&StorageKey::GovTokenTotalSupply, &total_supply); + + let pn = Symbol::new(&env, "gov_token_total_supply"); + env.events().publish( + (Symbol::new(&env, "parameter_updated"), pn.clone()), + GovernanceParameterUpdated { + param_name: pn, + old_value, + new_value: total_supply, + }, + ); + Ok(()) + } + /// Updates the minimum quorum configuration. /// /// Authorization: the configured ILN contract address must authorize. @@ -848,11 +915,7 @@ impl GovContract { // ── execute_proposal ───────────────────────────────────────── - pub fn execute_proposal( - env: Env, - proposal_id: u64, - total_supply: i128, - ) -> Result<(), GovernanceError> { + pub fn execute_proposal(env: Env, proposal_id: u64) -> Result<(), GovernanceError> { let mut proposal: GovernanceProposal = env .storage() .persistent() @@ -872,8 +935,18 @@ impl GovContract { .get(&StorageKey::MinQuorumBps) .unwrap_or(DEFAULT_MIN_QUORUM_BPS); - // TODO(#602): Query total supply from the governance token contract - // once a compatible total_supply interface is available. + // Issue #622: total_supply used to be a caller-supplied argument, + // letting a caller inflate it (lowering the effective quorum) or + // deflate it (blocking quorum entirely). Read the contract-stored + // value instead (seeded at initialize, only updatable via the + // ILN-contract-gated set_gov_token_total_supply) — it can no + // longer be chosen by whoever happens to call execute_proposal. + let total_supply: i128 = env + .storage() + .instance() + .get(&StorageKey::GovTokenTotalSupply) + .unwrap_or(0); + let quorum = if total_supply <= 0 { 0_i128 } else { diff --git a/contracts/iln_governance/src/test.rs b/contracts/iln_governance/src/test.rs index 194494c5..e369194c 100644 --- a/contracts/iln_governance/src/test.rs +++ b/contracts/iln_governance/src/test.rs @@ -107,7 +107,7 @@ fn setup() -> GovTestEnv { let contract_id = env.register_contract(None, GovContract); let contract = GovContractClient::new(&env, &contract_id); - contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin); + contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin, &10_000); let mut ledger = env.ledger().get(); ledger.timestamp = 1_700_000_000; @@ -224,7 +224,7 @@ fn test_double_initialize_rejected() { let dist = Address::generate(&t.env); let token = Address::generate(&t.env); let admin = Address::generate(&t.env); - t.contract.initialize(&iln, &dist, &token, &admin); + t.contract.initialize(&iln, &dist, &token, &admin, &10_000); } #[test] @@ -427,7 +427,7 @@ fn test_execute_before_voting_ends_fails() { let t = setup(); let id = create_fee_proposal(&t); t.contract.cast_vote(&t.voter_a, &id, &true); - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); } #[test] @@ -436,10 +436,13 @@ fn test_execute_quorum_not_reached_rejected() { let t = setup(); let id = create_fee_proposal(&t); t.contract.cast_vote(&t.voter_a, &id, &true); + // Raise total_supply well above the default seeded in setup() so + // voter_a's 1_000 vote falls well short of the (now much larger) quorum. + t.contract.set_gov_token_total_supply(&100_000); let mut ledger = t.env.ledger().get(); ledger.timestamp += 259_201; t.env.ledger().set(ledger); - t.contract.execute_proposal(&id, &100_000); + t.contract.execute_proposal(&id); } #[test] @@ -460,8 +463,8 @@ fn test_execute_quorum_exact_threshold_is_allowed() { // total_supply = 10_000; quorum = 1_000; total_votes = 1_000 => meets quorum. let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000)?; - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id)?; + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res.is_ok()); @@ -484,7 +487,7 @@ fn test_execute_quorum_not_met_fails_without_executing() { t.env.ledger().set(ledger); let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(res, Err(GovernanceError::QuorumNotReached)); @@ -508,8 +511,8 @@ fn test_execute_quorum_met_passes_with_custom_quorum_bps() { t.env.ledger().set(ledger); let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000)?; - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id)?; + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res.is_ok()); @@ -527,7 +530,7 @@ fn test_proposal_rejected_when_against_wins() { let mut ledger = t.env.ledger().get(); ledger.timestamp += 259_201; t.env.ledger().set(ledger); - t.contract.execute_proposal(&id, &3_000); + t.contract.execute_proposal(&id); } #[test] @@ -540,11 +543,11 @@ fn test_already_resolved_proposal_cannot_be_executed_again() { ledger.timestamp += 259_201; t.env.ledger().set(ledger); // Call 1: Active -> Passed - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); // Call 2: Passed -> Executed - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); // Call 3: Already Executed -> should panic with AlreadyResolved - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); } // ── Issue #64: delegate_votes / undelegate_votes ────────────────────────────── @@ -708,14 +711,14 @@ fn test_execute_timelock_delay_flow() { // Call execute_proposal to queue it (transition Active -> Passed) // The proposal has passed and sets eta_ledger to current_ledger + 100 let initial_ledger = t.env.ledger().sequence(); - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); let p = t.contract.get_proposal(&id); assert_eq!(p.status, ProposalStatus::Passed); assert_eq!(p.eta_ledger, initial_ledger + 100); // Attempting to execute immediately should fail with TimelockNotExpired - let res = t.contract.try_execute_proposal(&id, &10_000); + let res = t.contract.try_execute_proposal(&id); assert_eq!(res, Err(Ok(GovernanceError::TimelockNotExpired))); // Progress ledger by 99 blocks (still before timelock) @@ -723,7 +726,7 @@ fn test_execute_timelock_delay_flow() { ledger.sequence_number += 99; t.env.ledger().set(ledger); - let res = t.contract.try_execute_proposal(&id, &10_000); + let res = t.contract.try_execute_proposal(&id); assert_eq!(res, Err(Ok(GovernanceError::TimelockNotExpired))); // Progress to timelock expiration (sequence_number >= eta_ledger) @@ -732,7 +735,7 @@ fn test_execute_timelock_delay_flow() { t.env.ledger().set(ledger); // Now execution should succeed - let res = t.contract.try_execute_proposal(&id, &10_000); + let res = t.contract.try_execute_proposal(&id); assert!(res.is_ok()); let p_final = t.contract.get_proposal(&id); @@ -752,7 +755,7 @@ fn test_execute_failed_proposal_fails() { t.env.ledger().set(ledger); // Execution should panic because quorum is not met (QuorumNotReached) - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); } #[test] @@ -821,7 +824,7 @@ fn test_veto_passed_proposal_succeeds() { // Manually set the proposal to Passed via internal call. let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res.is_ok()); let p = t.contract.get_proposal(&id); @@ -857,7 +860,7 @@ fn test_non_admin_veto_fails() { // Initialize using mock_all_auths scoped to setup only. env.mock_all_auths(); - contract.initialize(&iln_id, &dist_id, &token_addr, &admin); + contract.initialize(&iln_id, &dist_id, &token_addr, &admin, &10_000); let gov_token_admin = StellarAssetClient::new(&env, &token_addr); gov_token_admin.mint(&non_admin, &1_000); @@ -912,7 +915,7 @@ fn test_vetoed_proposal_cannot_be_executed() { let mut ledger = t.env.ledger().get(); ledger.timestamp += 259_201; t.env.ledger().set(ledger); - t.contract.execute_proposal(&id, &10_000); + t.contract.execute_proposal(&id); } /// Veto emits the ProposalVetoed event. @@ -980,8 +983,8 @@ fn test_veto_executed_proposal_returns_not_vetoable() { t.env.ledger().set(ledger); let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000)?; - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id)?; + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res.is_ok()); @@ -1246,9 +1249,8 @@ fn test_list_proposals_status_filtering() { t.env .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = 10_000; let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id3, total_supply) + GovContract::execute_proposal(t.env.clone(), id3) }); // Let's verify statuses: 1 is Active, 2 is Vetoed, 3 is Rejected. @@ -1322,11 +1324,8 @@ fn test_create_and_execute_decay_params_proposal() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); // Should be passed (pending timelock) @@ -1334,7 +1333,7 @@ fn test_create_and_execute_decay_params_proposal() { // Execute after timelock let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( @@ -1372,17 +1371,14 @@ fn test_create_and_execute_distribution_reward_params_proposal() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( @@ -1433,17 +1429,14 @@ fn test_create_and_execute_fee_tiers_proposal() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( @@ -1479,17 +1472,13 @@ fn test_upgrade_proposal_creates_and_executes() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); - let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( t.contract.get_proposal(&id).status, @@ -1617,16 +1606,13 @@ fn test_create_and_execute_register_oracle_proposal() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( t.contract.get_proposal(&id).status, @@ -1659,14 +1645,11 @@ fn test_create_and_execute_remove_oracle_proposal() { .ledger() .set_timestamp(t.env.ledger().timestamp() + VOTING_PERIOD_SECS + 1); - let total_supply = t.gov_token.balance(&t.voter_a) - + t.gov_token.balance(&t.voter_b) - + t.gov_token.balance(&t.proposer); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, total_supply) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!( t.contract.get_proposal(&id).status, @@ -1829,7 +1812,7 @@ fn test_set_quadratic_voting_enabled_requires_iln_auth() { let contract = GovContractClient::new(&env, &contract_id); env.mock_all_auths(); - contract.initialize(&iln_id, &dist_id, &token_addr, &admin); + contract.initialize(&iln_id, &dist_id, &token_addr, &admin, &10_000); // No auths mocked on this second client — require_auth on the ILN // contract address must reject an arbitrary caller. @@ -1869,7 +1852,7 @@ fn setup_with_failing_iln() -> FailingGovTestEnv { let contract_id = env.register_contract(None, GovContract); let contract = GovContractClient::new(&env, &contract_id); - contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin); + contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin, &11_000); FailingGovTestEnv { env, @@ -1899,7 +1882,7 @@ fn test_execute_proposal_call_failure_reverts_to_passed() { // Active -> Passed (quorum met, votes_for > votes_against). let res_pass = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res_pass.is_ok()); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); @@ -1907,7 +1890,7 @@ fn test_execute_proposal_call_failure_reverts_to_passed() { // Passed -> the callee always fails, so execution must report // ExecutionFailed and the proposal must remain Passed (retryable). let res_exec = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(res_exec, Err(GovernanceError::ExecutionFailed)); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); @@ -1931,13 +1914,13 @@ fn test_execute_proposal_failure_allows_repeated_retry() { t.env.ledger().set(ledger); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); for _ in 0..3 { let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); assert_eq!(res, Err(GovernanceError::ExecutionFailed)); assert_eq!(t.contract.get_proposal(&id).status, ProposalStatus::Passed); @@ -1961,12 +1944,12 @@ fn test_execute_proposal_failure_emits_event() { t.env.ledger().set(ledger); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); let events_before = t.env.events().all().len(); let _ = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 11_000) + GovContract::execute_proposal(t.env.clone(), id) }); let events_after = t.env.events().all().len(); assert!( @@ -1990,8 +1973,8 @@ fn test_execute_proposal_success_still_marks_executed() { t.env.ledger().set(ledger); let res = t.env.as_contract(&t.contract.address, || { - GovContract::execute_proposal(t.env.clone(), id, 10_000)?; - GovContract::execute_proposal(t.env.clone(), id, 10_000) + GovContract::execute_proposal(t.env.clone(), id)?; + GovContract::execute_proposal(t.env.clone(), id) }); assert!(res.is_ok()); assert_eq!( diff --git a/contracts/iln_governance/src/tests_benchmarks.rs b/contracts/iln_governance/src/tests_benchmarks.rs index 1e2f71ba..957a99fa 100644 --- a/contracts/iln_governance/src/tests_benchmarks.rs +++ b/contracts/iln_governance/src/tests_benchmarks.rs @@ -55,7 +55,7 @@ fn setup_benchmark_env() -> BaseBenchEnv { let contract_id = env.register_contract(None, GovContract); let contract = GovContractClient::new(&env, &contract_id); - contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin); + contract.initialize(&iln_contract, &dist_contract, &token_addr, &admin, &10_000); BaseBenchEnv { env, diff --git a/contracts/invoice_liquidity/src/invoice.rs b/contracts/invoice_liquidity/src/invoice.rs index 72484309..567c0aed 100644 --- a/contracts/invoice_liquidity/src/invoice.rs +++ b/contracts/invoice_liquidity/src/invoice.rs @@ -885,66 +885,40 @@ pub fn add_volume(env: &Env, token: &Address, amount: i128) { ¤t_per_token.saturating_add(amount), ); - // Preserve legacy aggregate token counters for compatibility. - if let Some(config) = crate::storage::get_config(env) { - if token == &config.xlm_sac_address { - let current: i128 = env - .storage() - .persistent() - .get(&StorageKey::TotalVolumeXlm) - .unwrap_or(0); - env.storage() - .persistent() - .set(&StorageKey::TotalVolumeXlm, ¤t.saturating_add(amount)); - return; - } - } - - let token_list: soroban_sdk::Vec
= env - .storage() - .persistent() - .get(&StorageKey::TokenList) - .unwrap_or(soroban_sdk::Vec::new(env)); - - if !token_list.is_empty() { - if let Some(usdc_addr) = token_list.get(0) { - if token == &usdc_addr { - let current: i128 = env - .storage() - .persistent() - .get(&StorageKey::TotalVolumeUsdc) - .unwrap_or(0); - env.storage() - .persistent() - .set(&StorageKey::TotalVolumeUsdc, ¤t.saturating_add(amount)); - } - } - } - if let Some(config) = crate::storage::get_config(env) { - if token == &config.xlm_sac_address { - let current: i128 = env - .storage() - .persistent() - .get(&StorageKey::TotalVolumeXlm) - .unwrap_or(0); - env.storage() - .persistent() - .set(&StorageKey::TotalVolumeXlm, ¤t.saturating_add(amount)); - } - } - if token_list.len() > 2 { - if let Some(eurc_addr) = token_list.get(2) { - if token == &eurc_addr { - let current: i128 = env - .storage() - .persistent() - .get(&StorageKey::TotalVolumeEurc) - .unwrap_or(0); - env.storage() - .persistent() - .set(&StorageKey::TotalVolumeEurc, ¤t.saturating_add(amount)); - } - } + // Preserve legacy aggregate token counters for compatibility. Match by + // the token's actual configured SAC address — never by TokenList + // position (Issue #620: a hardcoded index silently misattributes volume + // whenever the list is reordered or a token is removed) — and increment + // at most one counter per call (the previous code re-checked XLM a + // second time after the first check's early return, which could + // double-count it if the two checks ever disagreed). + if crate::is_xlm_token(env, token) { + let current: i128 = env + .storage() + .persistent() + .get(&StorageKey::TotalVolumeXlm) + .unwrap_or(0); + env.storage() + .persistent() + .set(&StorageKey::TotalVolumeXlm, ¤t.saturating_add(amount)); + } else if crate::is_usdc_token(env, token) { + let current: i128 = env + .storage() + .persistent() + .get(&StorageKey::TotalVolumeUsdc) + .unwrap_or(0); + env.storage() + .persistent() + .set(&StorageKey::TotalVolumeUsdc, ¤t.saturating_add(amount)); + } else if crate::is_eurc_token(env, token) { + let current: i128 = env + .storage() + .persistent() + .get(&StorageKey::TotalVolumeEurc) + .unwrap_or(0); + env.storage() + .persistent() + .set(&StorageKey::TotalVolumeEurc, ¤t.saturating_add(amount)); } } diff --git a/contracts/invoice_liquidity/src/lib.rs b/contracts/invoice_liquidity/src/lib.rs index c20c69bb..b3020fcb 100644 --- a/contracts/invoice_liquidity/src/lib.rs +++ b/contracts/invoice_liquidity/src/lib.rs @@ -2019,14 +2019,22 @@ impl InvoiceLiquidityContract { // Total amount funded by primary LP let primary_lp_funded = funders.get(0).unwrap().1; - // LP payout after settlement distribution + // LP payout after settlement distribution. A genuine multiplication + // overflow here must surface as an error, not silently collapse to a + // corrupting zero payout (Issue #619). let primary_lp_payout = distribute_amount .checked_mul(primary_lp_funded) - .unwrap_or(0) + .ok_or(ContractError::ArithmeticOverflow)? / invoice.amount; - // LP earnings - let lp_earned = primary_lp_payout.saturating_sub(primary_lp_funded); + // LP earnings. Protocol-fee deduction plus integer-division + // truncation can make primary_lp_payout fall slightly below + // primary_lp_funded when distribute_amount is close to + // invoice.amount — use checked_sub (never a bare `-`) so this can + // never panic in debug builds or silently wrap to an enormous value + // in release builds. The LP simply earns zero on that edge, never a + // negative or wrapped amount (Issue #619). + let lp_earned = primary_lp_payout.checked_sub(primary_lp_funded).unwrap_or(0); // CEI: update state before external token transfers invoice.status = InvoiceStatus::Paid; @@ -2848,7 +2856,6 @@ fn normalize_xlm_amount(amount: i128) -> i128 { } /// Check if a token address is the USDC address -#[allow(dead_code)] fn is_usdc_token(env: &Env, token: &Address) -> bool { if let Some(config) = crate::storage::get_config(env) { token == &config.usdc_sac_address diff --git a/contracts/invoice_liquidity/src/test.rs b/contracts/invoice_liquidity/src/test.rs index fa30ba82..5f9e3a77 100644 --- a/contracts/invoice_liquidity/src/test.rs +++ b/contracts/invoice_liquidity/src/test.rs @@ -757,6 +757,109 @@ fn test_mark_paid_releases_full_amount_to_lp() { ); } +// ── Issue #619: mark_paid's internal LP payout/earnings math must never +// panic (debug) or wrap to a corrupting value (release) when protocol-fee +// deduction plus integer-division truncation makes the payout fall below +// what the LP originally funded. Settlement must still succeed and pay out +// the correct, non-negative, non-wrapped amount. ────────────────────────── + +#[test] +fn test_mark_paid_settles_correctly_when_distribute_amount_slightly_less_than_invoice_amount() { + let t = setup(); + let id = submit_standard_invoice(&t); + + // update_fee_rate is rate-limited (ECONOMIC_PARAM_COOLDOWN_LEDGERS = 360) + // relative to a fresh RateLimit record's default last-ledger of 0, and + // setup()'s baseline sequence_number (100) is below that — advance past + // the cooldown first, same as test_update_fee_rate_rate_limited does. + let mut ledger_info = t.env.ledger().get(); + ledger_info.sequence_number += 400; + t.env.ledger().set(ledger_info); + + // Small protocol fee (0.1%) makes distribute_amount fall just under + // invoice.amount, which is exactly the edge that used to underflow + // lp_earned via a bare subtraction. + t.contract.update_fee_rate(&10_u32); + + t.contract + .fund_invoice(&t.funder, &id, &INVOICE_AMOUNT, &false); + + let funder_balance_before = t.token.balance(&t.funder); + t.contract.mark_paid(&id, &INVOICE_AMOUNT); + let funder_balance_after = t.token.balance(&t.funder); + + let protocol_fee = INVOICE_AMOUNT * 10 / 10_000; + let expected_distribution = INVOICE_AMOUNT - protocol_fee; + + assert_eq!( + funder_balance_after - funder_balance_before, + expected_distribution, + "settlement must succeed and pay the correct (non-negative, non-wrapped) amount \ + even when the protocol fee pushes distribute_amount below invoice.amount" + ); +} + +#[test] +fn test_mark_paid_settles_correctly_when_distribute_amount_equals_invoice_amount_no_fee() { + let t = setup(); + let id = submit_standard_invoice(&t); + + let mut ledger_info = t.env.ledger().get(); + ledger_info.sequence_number += 400; + t.env.ledger().set(ledger_info); + + // Explicit no-fee edge: distribute_amount == invoice.amount exactly, so + // primary_lp_payout == primary_lp_funded and lp_earned == 0 exactly — + // not underflowed, not a coincidental clamp. + t.contract.update_fee_rate(&0_u32); + + t.contract + .fund_invoice(&t.funder, &id, &INVOICE_AMOUNT, &false); + + let funder_balance_before = t.token.balance(&t.funder); + t.contract.mark_paid(&id, &INVOICE_AMOUNT); + let funder_balance_after = t.token.balance(&t.funder); + + assert_eq!( + funder_balance_after - funder_balance_before, + INVOICE_AMOUNT, + "with no protocol fee, the LP must receive the full invoice amount" + ); +} + +#[test] +fn test_mark_paid_settles_correctly_when_distribute_amount_significantly_less_than_invoice_amount() +{ + let t = setup(); + let id = submit_standard_invoice(&t); + + let mut ledger_info = t.env.ledger().get(); + ledger_info.sequence_number += 400; + t.env.ledger().set(ledger_info); + + // Large protocol fee (40%) makes distribute_amount fall significantly + // below invoice.amount — the same edge as the first test, but far from + // the boundary rather than adjacent to it. + t.contract.update_fee_rate(&4_000_u32); + + t.contract + .fund_invoice(&t.funder, &id, &INVOICE_AMOUNT, &false); + + let funder_balance_before = t.token.balance(&t.funder); + t.contract.mark_paid(&id, &INVOICE_AMOUNT); + let funder_balance_after = t.token.balance(&t.funder); + + let protocol_fee = INVOICE_AMOUNT * 4_000 / 10_000; + let expected_distribution = INVOICE_AMOUNT - protocol_fee; + + assert_eq!( + funder_balance_after - funder_balance_before, + expected_distribution, + "settlement must succeed and pay the correct (non-negative, non-wrapped) amount \ + even when a large protocol fee pushes distribute_amount well below invoice.amount" + ); +} + #[test] fn test_mark_paid_updates_status() { let t = setup(); diff --git a/contracts/invoice_liquidity/src/tests_multi_token.rs b/contracts/invoice_liquidity/src/tests_multi_token.rs index c95d215e..c61ba0f2 100644 --- a/contracts/invoice_liquidity/src/tests_multi_token.rs +++ b/contracts/invoice_liquidity/src/tests_multi_token.rs @@ -177,6 +177,62 @@ fn test_full_lifecycle_xlm_sac_token_path() { assert_full_lifecycle_for_token("XLM SAC", &env.xlm, &env, 70_000_000); } +// ── Issue #620: add_volume must attribute per-token volume by SAC address, +// never by TokenList position, and must never double-count a single funding +// call into more than one aggregate counter. ────────────────────────────── + +#[test] +fn test_add_volume_attributes_each_token_without_cross_contamination() { + let env = setup(); + + let usdc_amount = 1_000_000_000_i128; + let eurc_amount = 25_000_000_i128; + let xlm_amount = 70_000_000_i128; + + let usdc_id = submit_invoice(&env, &env.usdc, usdc_amount); + env.contract.fund_invoice(&env.lp, &usdc_id, &usdc_amount, &false); + + let eurc_id = submit_invoice(&env, &env.eurc, eurc_amount); + env.contract.fund_invoice(&env.lp, &eurc_id, &eurc_amount, &false); + + let xlm_id = submit_invoice(&env, &env.xlm, xlm_amount); + env.contract.fund_invoice(&env.lp, &xlm_id, &xlm_amount, &false); + + let stats = env.contract.get_contract_stats(); + + assert_eq!(stats.total_volume_usdc, usdc_amount, "USDC volume must equal the single USDC funding, not doubled or zero"); + assert_eq!(stats.total_volume_eurc, eurc_amount, "EURC volume must equal the single EURC funding, not doubled or zero"); + assert_eq!(stats.total_volume_xlm, xlm_amount, "XLM volume must equal the single XLM funding, not doubled or zero"); +} + +#[test] +fn test_add_volume_attributes_eurc_correctly_after_token_list_reordering() { + let env = setup(); + + // Give admin a USDC balance so add_token's balance-verification transfer + // can succeed, then remove and re-add USDC — this moves USDC from index + // 0 to the end of TokenList, shifting EURC away from the hardcoded + // index 2 the old buggy code relied on. + env.usdc.admin_client.mint(&env.admin, &1_000_000_i128); + env.contract.remove_token(&env.usdc.address); + env.contract.add_token(&env.usdc.address, &6_u32); + + let eurc_amount = 25_000_000_i128; + let eurc_id = submit_invoice(&env, &env.eurc, eurc_amount); + env.contract.fund_invoice(&env.lp, &eurc_id, &eurc_amount, &false); + + let stats = env.contract.get_contract_stats(); + + assert_eq!( + stats.total_volume_eurc, eurc_amount, + "EURC volume must be attributed correctly by SAC address even after TokenList order changes" + ); + assert_eq!( + stats.total_volume_usdc, 0, + "reordering TokenList and funding an EURC invoice must not misattribute volume to USDC" + ); +} + #[test] fn test_submit_with_unapproved_token_is_rejected() { let env = setup(); diff --git a/contracts/tests/governance_lifecycle_test.rs b/contracts/tests/governance_lifecycle_test.rs index bdb733a6..cd66ae55 100644 --- a/contracts/tests/governance_lifecycle_test.rs +++ b/contracts/tests/governance_lifecycle_test.rs @@ -235,7 +235,7 @@ fn full_lifecycle_updates_parameter_and_emits_events() { ledger.timestamp = proposal.voting_end + 1; t.env.ledger().set(ledger); - t.governance.execute_proposal(&proposal_id, &20_000); + t.governance.execute_proposal(&proposal_id); let exec_events = t .env .events() @@ -272,7 +272,7 @@ fn quorum_not_met_rejects_proposal_without_executing_update() { t.env.ledger().set(ledger); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - t.governance.execute_proposal(&proposal_id, &20_000); + t.governance.execute_proposal(&proposal_id); })); assert!( result.is_err(), @@ -295,7 +295,7 @@ fn execution_before_voting_window_ends_is_rejected() { t.governance.cast_vote(&t.proposer, &proposal_id, &true); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - t.governance.execute_proposal(&proposal_id, &20_000); + t.governance.execute_proposal(&proposal_id); })); assert!( result.is_err(), diff --git a/contracts/tests/governance_main_integration_test.rs b/contracts/tests/governance_main_integration_test.rs index 7939a658..2d764023 100644 --- a/contracts/tests/governance_main_integration_test.rs +++ b/contracts/tests/governance_main_integration_test.rs @@ -33,7 +33,7 @@ const INVOICE_AMOUNT: i128 = 1_000_000_000; // 100 USDC (7-decimal) const DISCOUNT_RATE: u32 = 300; // 3 % const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; // 30 days const LEDGER_TIMESTAMP: u64 = 1_700_000_000; -const GOV_TOTAL_SUPPLY: i128 = 20_000; // used in execute_proposal() +const GOV_TOTAL_SUPPLY: i128 = 20_000; // seeded via initialize()'s gov_token_total_supply param struct GovIntegrationEnv { env: Env, @@ -135,10 +135,10 @@ fn pass_and_execute(t: &GovIntegrationEnv, proposal_id: u64) { // Active → Passed t.governance - .execute_proposal(&proposal_id, &GOV_TOTAL_SUPPLY); + .execute_proposal(&proposal_id); // Passed → Executed (zero-delay timelock: eta_ledger == current_sequence) t.governance - .execute_proposal(&proposal_id, &GOV_TOTAL_SUPPLY); + .execute_proposal(&proposal_id); } // ── Test 1 ──────────────────────────────────────────────────────────────────── @@ -286,7 +286,7 @@ fn test_veto_proposal_prevents_execution() { // Attempting to execute a vetoed proposal must return AlreadyResolved. let execute_result = t .governance - .try_execute_proposal(&proposal_id, &GOV_TOTAL_SUPPLY); + .try_execute_proposal(&proposal_id); assert_eq!( execute_result, Err(Ok(GovernanceError::AlreadyResolved)), diff --git a/sdk/src/methods/governance.ts b/sdk/src/methods/governance.ts index 8ee65d84..175c6069 100644 --- a/sdk/src/methods/governance.ts +++ b/sdk/src/methods/governance.ts @@ -282,6 +282,14 @@ export async function castVote( /** * Execute a proposal that has passed its vote. + * + * Issue #622: the contract no longer accepts a caller-supplied `total_supply` + * — it queries the real governance token's on-chain supply itself, so the + * only argument here is the proposal id. (The previous version of this call + * also mismatched the contract's actual `execute_proposal(proposal_id, + * total_supply)` signature by sending an address in place of `proposal_id` + * and omitting `total_supply` entirely — that mismatch is fixed as part of + * this change too.) */ export async function executeProposal( server: SorobanRpc.Server, @@ -294,7 +302,6 @@ export async function executeProposal( const contract = new Contract(contractAddress); const op = contract.call( "execute_proposal", - nativeToScVal(sourceAccount.accountId(), { type: "address" }), nativeToScVal(proposalId, { type: "u64" }) );