Skip to content
Closed
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
13 changes: 13 additions & 0 deletions INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Multi-sig errors:
| `stored_state_version()` | none | `u32` | Schema version recorded in instance storage; 0 for unversioned deployments. |
| `migrate_state(from_version: u32)` | owner | `u32` | Migrates storage from `from_version` to the current schema version. |
| `upgrade(new_wasm_hash: BytesN<32>)` | owner | none | Deploys new contract code at the current address. |
| `update_impact_scores_batch(updates: Vec<(u32, u32, u32)>)` | owner | none | Batch update multiple project scores atomically. |

## InvestmentVault

Expand Down Expand Up @@ -197,6 +198,18 @@ Compliance/reporting types: `ComplianceEventData`, `ReportingSnapshotData`,
| `health_check()` | none | `HealthStatus` | Consolidated status snapshot for monitoring integrations (#77). |
| `state_version()` | none | `u32` | Schema version supported by this contract build. |
| `stored_state_version()` | none | `u32` | Schema version recorded in instance storage; 0 for unversioned deployments. |
| `is_funding_round_active()` | none | `bool` | Whether a funding round is currently active (#38). |
| `start_funding_round()` | owner | none | Opens a funding round, blocking share transfers (#38). |
| `end_funding_round()` | owner | none | Closes the active funding round, re-enabling share transfers (#38). |
| `set_withdrawal_window(ledgers: u32)` | owner | none | Configures minimum ledgers between deposit and withdrawal; 0 disables (#36). |
| `get_withdrawal_window()` | none | `u32` | Returns configured withdrawal window in ledgers (#36). |
| `set_volume_fee_tier(threshold: i128, discounted_bps: u32)` | owner | none | Configures volume-discount tier for management fees (#39). |
| `get_volume_fee_tier()` | none | `(i128, u32)` | Returns (threshold, discounted_bps); (0,0) when inactive (#39). |
| `set_max_investment_per_project(cap: i128)` | owner | none | Sets max USDC per project; 0 restores default (#32). |
| `investment_capacity(project_id: u32)` | none | `i128` | Returns remaining USDC capacity before hitting per-project cap (#32). |
| `get_deposit_lock_expiry(account: Address)` | none | `u64` | Returns the Unix timestamp when the account deposit lock expires; 0 if never deposited. |
| `get_all_project_investments()` | none | `Vec<(u32, i128)>` | Returns (project_id, invested) for all projects 1..total_projects (#35). |
| `get_project_investments_batch(project_ids: Vec<u32>)` | none | `Vec<i128>` | Returns investment amounts for requested IDs in order (#35). |
| `migrate_state(from_version: u32)` | owner | `u32` | Migrates storage from `from_version` to the current schema version. |
| `upgrade(new_wasm_hash: BytesN<32>)` | owner | none | Deploys new contract code at the current address. |

Expand Down
2 changes: 2 additions & 0 deletions investment_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ pub struct WithdrawalWindowSet {

pub fn withdrawal_window_set(env: &Env, ledgers: u32) {
WithdrawalWindowSet { ledgers }.publish(env);
}
/// Emitted when the admin opens a funding round (#38).
#[contractevent]
pub struct FundingRoundStarted {}
Expand All @@ -518,6 +519,7 @@ pub struct FundingRoundEnded {}

pub fn funding_round_ended(env: &Env) {
FundingRoundEnded {}.publish(env);
}
/// Emitted when the admin changes the per-project investment cap (#32).
#[contractevent]
pub struct InvestmentCapSet {
Expand Down
34 changes: 17 additions & 17 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ impl InvestmentVault {
require_admin_approval(&env, approvals);
let mut seen = Vec::new(&env);
for funding in fundings.iter() {
if seen.contains(&funding.0) {
if seen.contains(funding.0) {
panic_with_error!(&env, VaultError::DuplicateProjectId);
}
seen.push_back(funding.0);
Expand Down Expand Up @@ -291,8 +291,7 @@ impl InvestmentVault {
.unwrap_or(0);
if investment > 0 {
let project = registry.get_project(&i);
let score_rate =
project.credit_quality as i128 + project.green_impact as i128;
let score_rate = project.credit_quality as i128 + project.green_impact as i128;

let funded_at: u64 = env
.storage()
Expand All @@ -303,8 +302,7 @@ impl InvestmentVault {
if funded_at > 0 && now > funded_at {
// Time-weighted: accrue interest over elapsed time (#34).
let elapsed = (now - funded_at) as i128;
expected +=
investment * score_rate * elapsed / (200 * ANNUAL_PERIOD_SECS);
expected += investment * score_rate * elapsed / (200 * ANNUAL_PERIOD_SECS);
} else {
// Static fallback for pre-existing investments without a timestamp.
expected += investment * score_rate / 200;
Expand Down Expand Up @@ -401,7 +399,7 @@ impl InvestmentVault {
env.storage().instance().get(&VaultKey::VolumeTierThreshold);
let volume_tier_bps: Option<u32> =
env.storage().instance().get(&VaultKey::VolumeTierFeeBps);
let effective_fee_bps = logic::logic::calculate_dynamic_fee_bps(
let effective_fee_bps = logic::calculate_dynamic_fee_bps(
usdc_amount,
fee_bps,
volume_threshold,
Expand Down Expand Up @@ -1127,6 +1125,7 @@ impl InvestmentVault {
.instance()
.get(&VaultKey::WithdrawalWindowLedgers)
.unwrap_or(1)
}
// ── Dynamic fee structure (#39) ───────────────────────────────────────────

/// Configure a two-tier volume-discount fee schedule for deposits (#39).
Expand All @@ -1151,9 +1150,7 @@ impl InvestmentVault {
env.storage()
.instance()
.remove(&VaultKey::VolumeTierThreshold);
env.storage()
.instance()
.remove(&VaultKey::VolumeTierFeeBps);
env.storage().instance().remove(&VaultKey::VolumeTierFeeBps);
return;
}
env.storage()
Expand All @@ -1179,6 +1176,7 @@ impl InvestmentVault {
.get(&VaultKey::VolumeTierFeeBps)
.unwrap_or(0);
(threshold, bps)
}
// ── Per-project investment cap (#32) ──────────────────────────────────────

/// Set the maximum total USDC the vault may invest in any single project. Admin-only.
Expand All @@ -1191,7 +1189,11 @@ impl InvestmentVault {
if cap < 0 {
panic_with_error!(&env, VaultError::AmountNotPositive);
}
let stored_cap = if cap == 0 { MAX_INVESTMENT_PER_PROJECT } else { cap };
let stored_cap = if cap == 0 {
MAX_INVESTMENT_PER_PROJECT
} else {
cap
};
env.storage()
.instance()
.set(&VaultKey::MaxInvestmentPerProject, &stored_cap);
Expand All @@ -1215,7 +1217,11 @@ impl InvestmentVault {
.get(&VaultKey::ProjectInvestment(project_id))
.unwrap_or(0);
let remaining = cap - invested;
if remaining < 0 { 0 } else { remaining }
if remaining < 0 {
0
} else {
remaining
}
}

// ── Deposit lock-up expiry query (#33) ────────────────────────────────────
Expand Down Expand Up @@ -2092,12 +2098,6 @@ fn check_deposit_lock(env: &Env, address: &Address) {
.persistent()
.get::<_, u64>(&VaultKey::LastDeposit(address.clone()))
{
let window: u32 = env
.storage()
.instance()
.get(&VaultKey::WithdrawalWindowLedgers)
.unwrap_or(1);
if env.ledger().sequence() < last_seq.saturating_add(window) {
if env.ledger().timestamp() < deposited_at + MIN_LOCK_PERIOD {
panic_with_error!(env, VaultError::DepositLocked);
}
Expand Down
60 changes: 29 additions & 31 deletions investment_vault/src/logic.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
pub mod logic {
/// Calculate the performance/management fee accrued on a given yield or deposit amount.
///
/// # Formula
/// `fee_amount = (yield_amount * fee_bps) / 10_000`
///
/// - `yield_amount`: The base yield or deposit amount in USDC (7 decimals).
/// - `fee_bps`: Basis points representing the fee percentage (where 10,000 bps = 100%, 500 bps = 5%).
///
/// Returns the computed fee amount in USDC units.
pub fn calculate_performance_fee(yield_amount: i128, fee_bps: u32) -> i128 {
(yield_amount * (fee_bps as i128)) / 10000
}
/// Calculate the performance/management fee accrued on a given yield or deposit amount.
///
/// # Formula
/// `fee_amount = (yield_amount * fee_bps) / 10_000`
///
/// - `yield_amount`: The base yield or deposit amount in USDC (7 decimals).
/// - `fee_bps`: Basis points representing the fee percentage (where 10,000 bps = 100%, 500 bps = 5%).
///
/// Returns the computed fee amount in USDC units.
pub fn calculate_performance_fee(yield_amount: i128, fee_bps: u32) -> i128 {
(yield_amount * (fee_bps as i128)) / 10000
}

/// Determine the effective management fee rate (in basis points) for a deposit.
///
/// When a volume-discount tier is configured (`volume_threshold` and `discounted_bps`
/// are both `Some`), large deposits at or above the threshold pay the lower
/// `discounted_bps` rate instead of the flat `base_bps` rate. This implements a
/// simple two-tier dynamic fee schedule (#39).
///
/// Returns `base_bps` when no tier is active or the deposit is below the threshold.
pub fn calculate_dynamic_fee_bps(
deposit_amount: i128,
base_bps: u32,
volume_threshold: Option<i128>,
discounted_bps: Option<u32>,
) -> u32 {
match (volume_threshold, discounted_bps) {
(Some(threshold), Some(discounted)) if deposit_amount >= threshold => discounted,
_ => base_bps,
}
/// Determine the effective management fee rate (in basis points) for a deposit.
///
/// When a volume-discount tier is configured (`volume_threshold` and `discounted_bps`
/// are both `Some`), large deposits at or above the threshold pay the lower
/// `discounted_bps` rate instead of the flat `base_bps` rate. This implements a
/// simple two-tier dynamic fee schedule (#39).
///
/// Returns `base_bps` when no tier is active or the deposit is below the threshold.
pub fn calculate_dynamic_fee_bps(
deposit_amount: i128,
base_bps: u32,
volume_threshold: Option<i128>,
discounted_bps: Option<u32>,
) -> u32 {
match (volume_threshold, discounted_bps) {
(Some(threshold), Some(discounted)) if deposit_amount >= threshold => discounted,
_ => base_bps,
}
}
1 change: 1 addition & 0 deletions investment_vault/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub fn read_usdc_sac(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::UsdcSac).unwrap()
}

#[allow(dead_code)]
pub fn read_registry(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::Registry).unwrap()
}
23 changes: 13 additions & 10 deletions investment_vault/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#![cfg(test)]
#![allow(clippy::inconsistent_digit_grouping)]
#![allow(unnameable_test_items)]
extern crate std;
use super::*;
use proptest::prelude::*;
Expand Down Expand Up @@ -2284,17 +2285,13 @@ fn test_get_project_investments_batch_returns_correct_amounts() {
let pid1 = registry_client.create_project(
&creator1,
&String::from_str(&s.env, "Alpha"),
&String::from_str(&s.env, "desc"),
&100u32,
&80u32,
&0u64,
&test_metadata_hash(&s.env),
);
let pid2 = registry_client.create_project(
&creator2,
&String::from_str(&s.env, "Beta"),
&String::from_str(&s.env, "desc"),
&90u32,
&70u32,
&0u64,
&test_metadata_hash(&s.env),
);

Expand All @@ -2313,7 +2310,8 @@ fn test_get_project_investments_batch_returns_correct_amounts() {

#[test]
fn test_get_all_project_investments_returns_all() {
// ── Issue #176: deposit() must reject a zero-amount deposit ──────────────────
// ── Issue #176: deposit() must reject a zero-amount deposit ──────────────────
}

#[test]
#[should_panic(expected = "Error(Contract, #1)")]
Expand Down Expand Up @@ -2394,9 +2392,7 @@ fn test_claim_queued_is_idempotent_against_double_claim() {
let pid = registry_client.create_project(
&creator,
&String::from_str(&s.env, "Gamma"),
&String::from_str(&s.env, "desc"),
&100u32,
&100u32,
&0u64,
&test_metadata_hash(&s.env),
);

Expand Down Expand Up @@ -2461,6 +2457,12 @@ fn test_get_set_withdrawal_window() {
s.vault_client.set_withdrawal_window(&10u32);
assert_eq!(s.vault_client.get_withdrawal_window(), 10u32);
}

#[test]
fn test_claim_idempotent_returns_zero_when_queue_empty() {
let s = setup();
let investor = Address::generate(&s.env);
let creator = Address::generate(&s.env);
mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128);
let shares = s.vault_client.deposit(&investor, &1_000_0000000i128);

Expand Down Expand Up @@ -2586,6 +2588,7 @@ fn test_volume_fee_tier_is_admin_only() {
},
}]);
s.vault_client.set_volume_fee_tier(&500_0000000i128, &50u32);
}
// ── #179: convert_to_shares() overflow guard on extremely large deposits ──────

/// Verify that `convert_to_shares` panics (rather than silently wrapping) when
Expand Down
4 changes: 2 additions & 2 deletions investment_vault/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ pub enum VaultError {
/// batch_deposit received an empty investor list (#178).
EmptyBatchDeposit = 41,
/// Share transfers are blocked because a funding round is active (#38).
FundingRoundActive = 41,
FundingRoundActive = 42,
/// Funding would push cumulative investment in a project above its per-project cap (#32).
InvestmentCapExceeded = 41,
InvestmentCapExceeded = 43,
}

#[contracttype]
Expand Down
42 changes: 21 additions & 21 deletions notification-service/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ describe("GET /notifications/history", () => {

// ── Issue #218: input validation for malformed payloads ─────────────────────

describe("PUT /preferences/:address input validation", () => {
describe("CORS configuration", () => {
let store: Store;

Expand Down Expand Up @@ -190,6 +189,17 @@ describe("CORS configuration", () => {
});

it("returns 400 when body is an array", async () => {
store = makeStore();
const app = createApi(store);

const res = await request(app)
.put("/preferences/GINVESTOR")
.send([{ email: "test@example.com" }]);

expect(res.status).toBe(400);
expect(res.body.error).toMatch(/JSON object/);
});

it("sets Access-Control-Allow-Origin for a matching origin", async () => {
store = makeStore();
const app = createApi(store, {
Expand Down Expand Up @@ -237,25 +247,22 @@ describe("CORS configuration", () => {
const app = createApi(store);

const res = await request(app)
.put("/preferences/GINVESTOR")
.send([{ email: "test@example.com" }]);
.get("/health")
.set("Origin", "https://heliobond.io");

expect(res.status).toBe(400);
expect(res.body.error).toMatch(/JSON object/);
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});

it("accepts a valid payload and returns 200", async () => {
store = makeStore();
const app = createApi(store);

const res = await request(app)
.put("/preferences/GINVESTOR")
.send({
email: "test@example.com",
webhook_url: "https://example.com/webhook",
enabled: true,
min_delta: 5,
});
const res = await request(app).put("/preferences/GINVESTOR").send({
email: "test@example.com",
webhook_url: "https://example.com/webhook",
enabled: true,
min_delta: 5,
});

expect(res.status).toBe(200);
expect(res.body.email).toBe("test@example.com");
Expand All @@ -267,14 +274,6 @@ describe("CORS configuration", () => {

// ── Issue #219: health-check with DB connectivity ──────────────────────────

describe("GET /health with DB connectivity", () => {
.get("/health")
.set("Origin", "https://heliobond.io");

expect(res.headers["access-control-allow-origin"]).toBeUndefined();
});
});

describe("GET /metrics", () => {
let store: Store;

Expand Down Expand Up @@ -305,6 +304,7 @@ describe("GET /metrics", () => {

expect(res.status).toBe(503);
expect(res.body.status).toBe("degraded");
});
it("returns snapshot from the provided Metrics instance", async () => {
store = makeStore();
const metrics = new Metrics();
Expand Down
Loading
Loading