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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
1 change: 1 addition & 0 deletions investment_vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
104 changes: 59 additions & 45 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, &registry_addr);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<Address>, 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)
}
}
}
Expand All @@ -1997,47 +2017,27 @@ fn require_admin_approval(env: &Env, approvals: Vec<Address>) {
.instance()
.get(&VaultKey::MultiSigThreshold)
.unwrap_or(0);
if threshold == 0 {
stellar_access::ownable::get_owner(env)
.unwrap()
.require_auth();
return;
}

let signers: Vec<Address> = 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);
}
}

Expand All @@ -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);
}
}
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions investment_vault/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions libs/multisig/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "multisig"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["rlib"]

[dependencies]
soroban-sdk = { workspace = true }
96 changes: 96 additions & 0 deletions libs/multisig/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Address>,
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<Address>,
approvals: Vec<Address>,
) -> 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
}
1 change: 1 addition & 0 deletions project_registry/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
Loading
Loading