diff --git a/Cargo.lock b/Cargo.lock index 694be0b..d0fe82f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,6 +918,7 @@ checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" name = "investment-vault" version = "0.1.0" dependencies = [ + "multisig", "proptest", "sha2", "soroban-sdk", @@ -1014,6 +1015,13 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "multisig" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1147,6 +1155,7 @@ name = "project-registry" version = "0.1.0" dependencies = [ "investment-vault", + "multisig", "proptest", "sha2", "soroban-sdk", diff --git a/Cargo.toml b/Cargo.toml index 07a4fd8..1e19b8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["project_registry", "investment_vault"] +members = ["project_registry", "investment_vault", "libs/multisig"] [workspace.dependencies] soroban-sdk = { version = "=26.1.0" } diff --git a/investment_vault/Cargo.toml b/investment_vault/Cargo.toml index fa56f9c..51cc195 100644 --- a/investment_vault/Cargo.toml +++ b/investment_vault/Cargo.toml @@ -11,6 +11,7 @@ soroban-sdk = { workspace = true } stellar-tokens = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } +multisig = { path = "../libs/multisig" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index aa573a9..622ac77 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -384,6 +384,7 @@ impl InvestmentVault { if usdc_amount > MAX_DEPOSIT { panic_with_error!(&env, VaultError::DepositExceedsMaximum); } + check_max_transaction_amount(&env, usdc_amount); // Deduct insurance premium before share calculation (#135) let premium = usdc_amount * INSURANCE_PREMIUM_BPS / 10_000; @@ -550,6 +551,7 @@ impl InvestmentVault { } let usdc_returned = Self::convert_to_assets(env.clone(), shares_amount); + check_max_transaction_amount(&env, usdc_returned); let usdc_sac: Address = env.storage().instance().get(&VaultKey::UsdcSac).unwrap(); let liquid = soroban_sdk::token::TokenClient::new(&env, &usdc_sac) @@ -1268,6 +1270,9 @@ impl InvestmentVault { if amount <= 0 { panic!("amount must be positive"); } + if Base::total_supply(&env) + amount > MAX_HBS_SUPPLY { + panic_with_error!(&env, VaultError::MaxSupplyExceeded); + } Base::mint(&env, &to, amount); lock_deposit(&env, &to); events::bridge_mint(&env, &to, amount); @@ -1385,6 +1390,9 @@ impl InvestmentVault { .set(&BridgeDataKey::ConsumedVaa(digest), &true); let to = wormhole::bytes32_to_address(&env, &transfer.recipient); + if Base::total_supply(&env) + transfer.amount > MAX_HBS_SUPPLY { + panic_with_error!(&env, VaultError::MaxSupplyExceeded); + } Base::mint(&env, &to, transfer.amount); lock_deposit(&env, &to); events::bridge_transfer_completed( @@ -1472,6 +1480,9 @@ impl InvestmentVault { let vault = env.current_contract_address(); + if Base::total_supply(&env) + amount + fee > MAX_HBS_SUPPLY { + panic_with_error!(&env, VaultError::MaxSupplyExceeded); + } Base::mint(&env, &borrower, amount + fee); let client = FlashLoanReceiverClient::new(&env, &borrower); @@ -1505,6 +1516,12 @@ impl InvestmentVault { } /// Set the price per carbon credit (carbon oracle only) (#184). + /// + /// Informational/reserved only (issue #456): this value is not read by + /// `calculate_carbon_credits`/`issue_carbon_credits` — credit amounts are + /// computed purely from `project.green_impact`. It is stored and surfaced + /// via `export_regulatory_data` for off-chain/future use, not as an + /// on-chain input to credit issuance. pub fn set_carbon_credit_price(env: Env, price: i128) { require_current_state(&env); let oracle: Address = env @@ -1794,6 +1811,7 @@ fn fund_project_internal(env: Env, project_id: u32, amount: i128) { if project_id == 0 { panic_with_error!(&env, VaultError::ProjectNotFound); } + check_max_transaction_amount(&env, amount); let registry_addr: Address = env.storage().instance().get(&VaultKey::Registry).unwrap(); let registry = registry_interface::Client::new(&env, ®istry_addr); @@ -1940,6 +1958,7 @@ fn claim_insurance_internal(env: Env, project_id: u32, recipient: Address, amoun if amount <= 0 { panic_with_error!(&env, VaultError::ClaimAmountNotPositive); } + check_max_transaction_amount(&env, amount); let already_claimed: bool = env .storage() .persistent() @@ -1974,18 +1993,19 @@ fn claim_insurance_internal(env: Env, project_id: u32, recipient: Address, amoun events::insurance_claimed(&env, project_id, &recipient, amount); } +/// Thin wrapper around the shared `multisig` crate (#459) mapping its +/// generic errors onto this contract's own `VaultError` codes. fn validate_multisig_config(env: &Env, signers: &Vec
, threshold: u32) { - if signers.len() > MAX_MULTISIG_SIGNERS { - panic_with_error!(env, VaultError::TooManyMultiSigSigners); - } - if threshold == 0 || threshold > signers.len() { - panic_with_error!(env, VaultError::InvalidMultiSigThreshold); - } - for i in 0..signers.len() { - let signer = signers.get(i).unwrap(); - for j in (i + 1)..signers.len() { - if signer == signers.get(j).unwrap() { - panic_with_error!(env, VaultError::DuplicateApproval); + if let Err(e) = multisig::validate_multisig_config(signers, threshold, MAX_MULTISIG_SIGNERS) { + match e { + multisig::ConfigError::TooManySigners => { + panic_with_error!(env, VaultError::TooManyMultiSigSigners) + } + multisig::ConfigError::InvalidThreshold => { + panic_with_error!(env, VaultError::InvalidMultiSigThreshold) + } + multisig::ConfigError::DuplicateSigner => { + panic_with_error!(env, VaultError::DuplicateApproval) } } } @@ -1997,47 +2017,27 @@ fn require_admin_approval(env: &Env, approvals: Vec
) { .instance() .get(&VaultKey::MultiSigThreshold) .unwrap_or(0); - if threshold == 0 { - stellar_access::ownable::get_owner(env) - .unwrap() - .require_auth(); - return; - } - let signers: Vec
= env .storage() .instance() .get(&VaultKey::MultiSigSigners) .unwrap_or_else(|| Vec::new(env)); - if threshold > signers.len() { - panic_with_error!(env, VaultError::InvalidMultiSigThreshold); - } - - let mut approved = 0u32; - for i in 0..approvals.len() { - let approver = approvals.get(i).unwrap(); - for j in 0..i { - if approver == approvals.get(j).unwrap() { - panic_with_error!(env, VaultError::DuplicateApproval); + let owner = stellar_access::ownable::get_owner(env).unwrap(); + if let Err(e) = multisig::require_admin_approval(&owner, threshold, &signers, approvals) { + match e { + multisig::ApprovalError::InvalidThreshold => { + panic_with_error!(env, VaultError::InvalidMultiSigThreshold) } - } - - let mut is_signer = false; - for signer in signers.iter() { - if approver == signer { - is_signer = true; - break; + multisig::ApprovalError::DuplicateApproval => { + panic_with_error!(env, VaultError::DuplicateApproval) + } + multisig::ApprovalError::NotSigner => { + panic_with_error!(env, VaultError::NotMultiSigSigner) + } + multisig::ApprovalError::InsufficientApprovals => { + panic_with_error!(env, VaultError::InsufficientApprovals) } } - if !is_signer { - panic_with_error!(env, VaultError::NotMultiSigSigner); - } - approver.require_auth(); - approved += 1; - } - - if approved < threshold { - panic_with_error!(env, VaultError::InsufficientApprovals); } } @@ -2047,7 +2047,7 @@ fn require_multisig_disabled(env: &Env) { .instance() .get(&VaultKey::MultiSigThreshold) .unwrap_or(0); - if threshold > 0 { + if !multisig::is_multisig_disabled(threshold) { panic_with_error!(env, VaultError::InsufficientApprovals); } } @@ -2065,6 +2065,20 @@ fn require_current_state(env: &Env) { } } +/// Enforce the configured compliance transaction limit, if any (#457). +/// `0` (the default) means "no limit configured" — matches the documented +/// convention for `MaxTransactionAmount`. +fn check_max_transaction_amount(env: &Env, amount: i128) { + let max: i128 = env + .storage() + .instance() + .get(&VaultKey::MaxTransactionAmount) + .unwrap_or(0); + if max > 0 && amount > max { + panic_with_error!(env, VaultError::ExceedsMaxTransactionAmount); + } +} + fn require_not_paused(env: &Env) { let paused: bool = env .storage() diff --git a/investment_vault/src/types.rs b/investment_vault/src/types.rs index 4fa1758..03a75b3 100644 --- a/investment_vault/src/types.rs +++ b/investment_vault/src/types.rs @@ -93,6 +93,8 @@ pub enum VaultError { FundingRoundActive = 42, /// Funding would push cumulative investment in a project above its per-project cap (#32). InvestmentCapExceeded = 43, + /// Requested amount exceeds the configured MaxTransactionAmount compliance limit (#457). + ExceedsMaxTransactionAmount = 44, } #[contracttype] diff --git a/libs/multisig/Cargo.toml b/libs/multisig/Cargo.toml new file mode 100644 index 0000000..1959d2c --- /dev/null +++ b/libs/multisig/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "multisig" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["rlib"] + +[dependencies] +soroban-sdk = { workspace = true } diff --git a/libs/multisig/src/lib.rs b/libs/multisig/src/lib.rs new file mode 100644 index 0000000..3df7826 --- /dev/null +++ b/libs/multisig/src/lib.rs @@ -0,0 +1,96 @@ +#![no_std] + +//! Shared multi-sig admin approval logic (issue #459), used by both +//! `investment_vault` and `project_registry`. Each caller maps the returned +//! error variant to its own `contracterror` enum, so this crate is agnostic +//! to which contract's error codes end up on-chain. + +use soroban_sdk::{Address, Vec}; + +pub enum ConfigError { + TooManySigners, + InvalidThreshold, + DuplicateSigner, +} + +pub enum ApprovalError { + InvalidThreshold, + DuplicateApproval, + NotSigner, + InsufficientApprovals, +} + +/// Validates a proposed signer set + threshold before storing it. +pub fn validate_multisig_config( + signers: &Vec
, + threshold: u32, + max_signers: u32, +) -> Result<(), ConfigError> { + if signers.len() > max_signers { + return Err(ConfigError::TooManySigners); + } + if threshold == 0 || threshold > signers.len() { + return Err(ConfigError::InvalidThreshold); + } + for i in 0..signers.len() { + let signer = signers.get(i).unwrap(); + for j in (i + 1)..signers.len() { + if signer == signers.get(j).unwrap() { + return Err(ConfigError::DuplicateSigner); + } + } + } + Ok(()) +} + +/// Enforces multi-sig approval for an admin action. When `threshold` is 0 +/// (multi-sig disabled), falls back to requiring `owner`'s auth directly. +/// Calls `require_auth()` on each distinct approver that is a registered +/// signer. +pub fn require_admin_approval( + owner: &Address, + threshold: u32, + signers: &Vec
, + approvals: Vec
, +) -> Result<(), ApprovalError> { + if threshold == 0 { + owner.require_auth(); + return Ok(()); + } + if threshold > signers.len() { + return Err(ApprovalError::InvalidThreshold); + } + + let mut approved = 0u32; + for i in 0..approvals.len() { + let approver = approvals.get(i).unwrap(); + for j in 0..i { + if approver == approvals.get(j).unwrap() { + return Err(ApprovalError::DuplicateApproval); + } + } + + let mut is_signer = false; + for signer in signers.iter() { + if approver == signer { + is_signer = true; + break; + } + } + if !is_signer { + return Err(ApprovalError::NotSigner); + } + approver.require_auth(); + approved += 1; + } + + if approved < threshold { + return Err(ApprovalError::InsufficientApprovals); + } + Ok(()) +} + +/// Returns `false` (multi-sig is enabled) when `threshold > 0`. +pub fn is_multisig_disabled(threshold: u32) -> bool { + threshold == 0 +} diff --git a/project_registry/Cargo.toml b/project_registry/Cargo.toml index de50cb3..07b608a 100644 --- a/project_registry/Cargo.toml +++ b/project_registry/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] soroban-sdk = { workspace = true } stellar-access = { workspace = true } stellar-macros = { workspace = true } +multisig = { path = "../libs/multisig" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/project_registry/src/lib.rs b/project_registry/src/lib.rs index f0d10a5..37b64e3 100644 --- a/project_registry/src/lib.rs +++ b/project_registry/src/lib.rs @@ -1145,18 +1145,19 @@ fn liquidate_collateral_internal(env: Env, project_id: u32, token: Address, reci events::collateral_liquidated(&env, project_id, &token, &recipient, balance); } +/// Thin wrapper around the shared `multisig` crate (#459) mapping its +/// generic errors onto this contract's own `RegistryError` codes. fn validate_multisig_config(env: &Env, signers: &Vec
, threshold: u32) { - if signers.len() > MAX_MULTISIG_SIGNERS { - panic_with_error!(env, RegistryError::TooManyMultiSigSigners); - } - if threshold == 0 || threshold > signers.len() { - panic_with_error!(env, RegistryError::InvalidMultiSigThreshold); - } - for i in 0..signers.len() { - let signer = signers.get(i).unwrap(); - for j in (i + 1)..signers.len() { - if signer == signers.get(j).unwrap() { - panic_with_error!(env, RegistryError::DuplicateApproval); + if let Err(e) = multisig::validate_multisig_config(signers, threshold, MAX_MULTISIG_SIGNERS) { + match e { + multisig::ConfigError::TooManySigners => { + panic_with_error!(env, RegistryError::TooManyMultiSigSigners) + } + multisig::ConfigError::InvalidThreshold => { + panic_with_error!(env, RegistryError::InvalidMultiSigThreshold) + } + multisig::ConfigError::DuplicateSigner => { + panic_with_error!(env, RegistryError::DuplicateApproval) } } } @@ -1168,47 +1169,27 @@ fn require_admin_approval(env: &Env, approvals: Vec
) { .instance() .get(&DataKey::MultiSigThreshold) .unwrap_or(0); - if threshold == 0 { - stellar_access::ownable::get_owner(env) - .unwrap() - .require_auth(); - return; - } - let signers: Vec
= env .storage() .instance() .get(&DataKey::MultiSigSigners) .unwrap_or_else(|| Vec::new(env)); - if threshold > signers.len() { - panic_with_error!(env, RegistryError::InvalidMultiSigThreshold); - } - - let mut approved = 0u32; - for i in 0..approvals.len() { - let approver = approvals.get(i).unwrap(); - for j in 0..i { - if approver == approvals.get(j).unwrap() { - panic_with_error!(env, RegistryError::DuplicateApproval); + let owner = stellar_access::ownable::get_owner(env).unwrap(); + if let Err(e) = multisig::require_admin_approval(&owner, threshold, &signers, approvals) { + match e { + multisig::ApprovalError::InvalidThreshold => { + panic_with_error!(env, RegistryError::InvalidMultiSigThreshold) } - } - - let mut is_signer = false; - for signer in signers.iter() { - if approver == signer { - is_signer = true; - break; + multisig::ApprovalError::DuplicateApproval => { + panic_with_error!(env, RegistryError::DuplicateApproval) + } + multisig::ApprovalError::NotSigner => { + panic_with_error!(env, RegistryError::NotMultiSigSigner) + } + multisig::ApprovalError::InsufficientApprovals => { + panic_with_error!(env, RegistryError::InsufficientApprovals) } } - if !is_signer { - panic_with_error!(env, RegistryError::NotMultiSigSigner); - } - approver.require_auth(); - approved += 1; - } - - if approved < threshold { - panic_with_error!(env, RegistryError::InsufficientApprovals); } } @@ -1218,7 +1199,7 @@ fn require_multisig_disabled(env: &Env) { .instance() .get(&DataKey::MultiSigThreshold) .unwrap_or(0); - if threshold > 0 { + if !multisig::is_multisig_disabled(threshold) { panic_with_error!(env, RegistryError::InsufficientApprovals); } }