From fea92965b7377feb32eea40a44e9f226b60c1bd7 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 1/7] build(contracts): make root Cargo.toml a valid workspace Root Cargo.toml was a stray comment, so no contract crate could build. Declare aegis_vault and helphone_dao as members and move the release profile to the workspace root. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- Cargo.lock | 7 ------- Cargo.toml | 14 +++++++++++++- contracts/aegis_vault/Cargo.toml | 7 ------- contracts/helphone_dao/Cargo.toml | 7 ------- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b369da7a..9aa3812e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1110,13 +1110,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "maintainer_vault" -version = "0.1.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "memchr" version = "2.8.3" diff --git a/Cargo.toml b/Cargo.toml index 98ccb9d8..53b89b64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1 +1,13 @@ -// Implementation added +[workspace] +resolver = "2" +members = ["contracts/aegis_vault", "contracts/helphone_dao"] +# Standalone crates that keep their own lockfiles / workspaces. +exclude = ["contract", "contracts/emergency_vault", "contracts/maintainer_vault"] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true +overflow-checks = true diff --git a/contracts/aegis_vault/Cargo.toml b/contracts/aegis_vault/Cargo.toml index f9fda7dd..6bf8bd4b 100644 --- a/contracts/aegis_vault/Cargo.toml +++ b/contracts/aegis_vault/Cargo.toml @@ -23,10 +23,3 @@ soroban-env-host = "26.1.3" [features] testutils = [] -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -panic = "abort" -strip = true -overflow-checks = true diff --git a/contracts/helphone_dao/Cargo.toml b/contracts/helphone_dao/Cargo.toml index a991ac79..93f2f900 100644 --- a/contracts/helphone_dao/Cargo.toml +++ b/contracts/helphone_dao/Cargo.toml @@ -17,10 +17,3 @@ soroban-env-host = "26.1.3" [features] testutils = [] -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -panic = "abort" -strip = true -overflow-checks = true From 1536c00223830a930ff8f14da8e2de9b6c35d3f9 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 2/7] feat(aegis-vault): multi-asset treasury with daily disbursement limits (Closes #541) Rebuild aegis_vault and add treasury.rs: per-asset reserves, daily caps enforced in claim_aid and treasury_withdraw, target weights and a read-only rebalance plan. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- contracts/aegis_vault/src/lib.rs | 309 +++++++++++++++++++++++- contracts/aegis_vault/src/test.rs | 333 ++++++++++++++++++++++++++ contracts/aegis_vault/src/treasury.rs | 170 +++++++++++++ 3 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 contracts/aegis_vault/src/treasury.rs diff --git a/contracts/aegis_vault/src/lib.rs b/contracts/aegis_vault/src/lib.rs index 98ccb9d8..613474e9 100644 --- a/contracts/aegis_vault/src/lib.rs +++ b/contracts/aegis_vault/src/lib.rs @@ -1 +1,308 @@ -// Implementation added +#![no_std] + +//! Aegis Vault — ZK-gated aid disbursement with a multi-asset treasury. +//! +//! Campaign funds are held per campaign; every asset the vault touches is also +//! tracked as a treasury reserve (see [`treasury`]) so disbursements can be +//! capped per day and reserves can be rebalanced toward target weights. + +mod treasury; + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, vec, Address, Bytes, BytesN, Env, + IntoVal, Symbol, Vec, +}; + +/// 50 tokens at 7 decimals. +pub const DEFAULT_PAYOUT_STROOP: i128 = 500_000_000; +/// Default per-asset daily disbursement cap: 10 default payouts. +pub const DEFAULT_DAILY_LIMIT_STROOP: i128 = 10 * DEFAULT_PAYOUT_STROOP; +/// 5 × 32-byte big-endian public inputs up to and including `campaign_id`. +pub const CAMPAIGN_INPUTS_LEN: usize = 160; +/// 7 × 32-byte big-endian public inputs (…, recipient, nullifier). +pub const PUBLIC_INPUTS_LEN: usize = 224; + +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VaultError { + NotAdmin = 1, + InvalidAmount = 2, + InvalidPublicInputs = 3, + AlreadyClaimed = 4, + VerificationFailed = 5, + InsufficientFunds = 6, + NotInitialized = 7, + Overflow = 8, + DailyLimitExceeded = 9, + UnknownAsset = 10, + InvalidWeights = 11, + InvalidPrices = 12, +} + +#[derive(Clone)] +#[contracttype] +enum DataKey { + Verifier, + Token, + Admin, + Payout, + Campaign(BytesN<32>), + Claimed(BytesN<32>), +} + +fn get>( + env: &Env, + key: &DataKey, +) -> Result { + env.storage() + .instance() + .get(key) + .ok_or(VaultError::NotInitialized) +} + +fn require_admin(env: &Env, admin: &Address) -> Result<(), VaultError> { + let stored: Address = get(env, &DataKey::Admin)?; + if *admin != stored { + return Err(VaultError::NotAdmin); + } + admin.require_auth(); + Ok(()) +} + +#[contract] +pub struct AegisVault; + +#[contractimpl] +impl AegisVault { + pub fn __constructor(env: Env, verifier: Address, token: Address, admin: Address) { + let s = env.storage().instance(); + s.set(&DataKey::Verifier, &verifier); + s.set(&DataKey::Token, &token); + s.set(&DataKey::Admin, &admin); + s.set(&DataKey::Payout, &DEFAULT_PAYOUT_STROOP); + treasury::register_asset(&env, &token); + treasury::set_daily_limit(&env, &token, DEFAULT_DAILY_LIMIT_STROOP); + } + + // ── Admin / config ───────────────────────────────────────────── + pub fn get_admin(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) + } + + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), VaultError> { + let admin: Address = get(&env, &DataKey::Admin)?; + admin.require_auth(); + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + pub fn payout_amount(env: Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::Payout) + .unwrap_or(DEFAULT_PAYOUT_STROOP) + } + + pub fn set_payout_amount(env: Env, admin: Address, amount: i128) -> Result<(), VaultError> { + require_admin(&env, &admin)?; + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + env.storage().instance().set(&DataKey::Payout, &amount); + Ok(()) + } + + // ── Campaigns ────────────────────────────────────────────────── + pub fn campaign_balance(env: Env, campaign_id: BytesN<32>) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Campaign(campaign_id)) + .unwrap_or(0) + } + + pub fn is_claimed(env: Env, nullifier: BytesN<32>) -> bool { + env.storage().persistent().has(&DataKey::Claimed(nullifier)) + } + + pub fn fund_zone( + env: Env, + funder: Address, + public_inputs_prefix: Bytes, + amount: i128, + ) -> Result<(), VaultError> { + funder.require_auth(); + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + if public_inputs_prefix.len() as usize != CAMPAIGN_INPUTS_LEN { + return Err(VaultError::InvalidPublicInputs); + } + let campaign_id: BytesN<32> = public_inputs_prefix.slice(128..160).try_into().unwrap(); + let asset: Address = get(&env, &DataKey::Token)?; + token::Client::new(&env, &asset).transfer( + &funder, + &env.current_contract_address(), + &amount, + ); + let key = DataKey::Campaign(campaign_id); + let cur: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let next = cur.checked_add(amount).ok_or(VaultError::Overflow)?; + env.storage().persistent().set(&key, &next); + treasury::credit(&env, &asset, amount) + } + + pub fn claim_aid( + env: Env, + recipient: Address, + public_inputs: Bytes, + proof_bytes: Bytes, + ) -> Result<(), VaultError> { + recipient.require_auth(); + if public_inputs.len() as usize != PUBLIC_INPUTS_LEN { + return Err(VaultError::InvalidPublicInputs); + } + let nullifier: BytesN<32> = public_inputs.slice(192..224).try_into().unwrap(); + let campaign_id: BytesN<32> = public_inputs.slice(128..160).try_into().unwrap(); + + let claimed_key = DataKey::Claimed(nullifier); + if env.storage().persistent().has(&claimed_key) { + return Err(VaultError::AlreadyClaimed); + } + + let verifier: Address = get(&env, &DataKey::Verifier)?; + let ok: bool = env.invoke_contract( + &verifier, + &Symbol::new(&env, "verify_proof"), + vec![&env, public_inputs.into_val(&env), proof_bytes.into_val(&env)], + ); + if !ok { + return Err(VaultError::VerificationFailed); + } + + let payout = Self::payout_amount(env.clone()); + let asset: Address = get(&env, &DataKey::Token)?; + let campaign_key = DataKey::Campaign(campaign_id); + let balance: i128 = env.storage().persistent().get(&campaign_key).unwrap_or(0); + if balance < payout { + return Err(VaultError::InsufficientFunds); + } + + // Daily cap first: a rejected claim leaves nullifier + balances untouched. + treasury::debit_disbursement(&env, &asset, payout)?; + env.storage().persistent().set(&campaign_key, &(balance - payout)); + env.storage().persistent().set(&claimed_key, &true); + token::Client::new(&env, &asset).transfer( + &env.current_contract_address(), + &recipient, + &payout, + ); + Ok(()) + } + + // ── Treasury (multi-asset reserves) ──────────────────────────── + pub fn treasury_assets(env: Env) -> Vec
{ + treasury::assets(&env) + } + + pub fn treasury_reserve(env: Env, asset: Address) -> i128 { + treasury::reserve(&env, &asset) + } + + /// Adds an asset to the treasury so it can hold reserves. + pub fn add_treasury_asset(env: Env, admin: Address, asset: Address) -> Result<(), VaultError> { + require_admin(&env, &admin)?; + treasury::register_asset(&env, &asset); + Ok(()) + } + + /// Pulls `amount` of a registered asset from `from` into the treasury reserve. + pub fn treasury_deposit( + env: Env, + from: Address, + asset: Address, + amount: i128, + ) -> Result<(), VaultError> { + from.require_auth(); + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + if !treasury::is_registered(&env, &asset) { + return Err(VaultError::UnknownAsset); + } + token::Client::new(&env, &asset).transfer(&from, &env.current_contract_address(), &amount); + treasury::credit(&env, &asset, amount) + } + + /// Admin withdrawal from the treasury; counts against the daily cap. + pub fn treasury_withdraw( + env: Env, + admin: Address, + asset: Address, + to: Address, + amount: i128, + ) -> Result<(), VaultError> { + require_admin(&env, &admin)?; + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + treasury::debit_disbursement(&env, &asset, amount)?; + token::Client::new(&env, &asset).transfer(&env.current_contract_address(), &to, &amount); + Ok(()) + } + + /// Sets the max amount of `asset` that may leave the vault per UTC day. + pub fn set_daily_limit( + env: Env, + admin: Address, + asset: Address, + limit: i128, + ) -> Result<(), VaultError> { + require_admin(&env, &admin)?; + if limit <= 0 { + return Err(VaultError::InvalidAmount); + } + if !treasury::is_registered(&env, &asset) { + return Err(VaultError::UnknownAsset); + } + treasury::set_daily_limit(&env, &asset, limit); + Ok(()) + } + + pub fn daily_limit(env: Env, asset: Address) -> i128 { + treasury::daily_limit(&env, &asset) + } + + pub fn spent_today(env: Env, asset: Address) -> i128 { + treasury::spent_today(&env, &asset) + } + + pub fn remaining_today(env: Env, asset: Address) -> i128 { + treasury::remaining_today(&env, &asset) + } + + /// Sets target reserve weights in basis points (sum must be ≤ 10_000). + pub fn set_target_weights( + env: Env, + admin: Address, + assets: Vec
, + weights_bps: Vec, + ) -> Result<(), VaultError> { + require_admin(&env, &admin)?; + treasury::set_target_weights(&env, assets, weights_bps) + } + + pub fn target_weight(env: Env, asset: Address) -> u32 { + treasury::target_weight(&env, &asset) + } + + /// Read-only rebalance plan. `prices` are per-asset prices (same order as + /// `treasury_assets`) in a common quote unit; returns, per asset, the signed + /// amount (asset units) to buy (+) or sell (−) to reach its target weight. + /// Swaps are executed off-chain / by a DEX adapter; the vault never trades. + pub fn rebalance_plan(env: Env, prices: Vec) -> Result, VaultError> { + treasury::rebalance_plan(&env, prices) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/aegis_vault/src/test.rs b/contracts/aegis_vault/src/test.rs index cfca1793..d57acc02 100644 --- a/contracts/aegis_vault/src/test.rs +++ b/contracts/aegis_vault/src/test.rs @@ -206,3 +206,336 @@ fn claim_aid_rejects_when_not_claimed() { let nullifier = BytesN::from_array(&env, &[99u8; 32]); assert!(!client.is_claimed(&nullifier)); } + +// ── Treasury / disbursement (#541) ───────────────────────────────── + +use soroban_sdk::testutils::Ledger; +use soroban_sdk::{contract, contractimpl, token::StellarAssetClient, token::TokenClient, vec}; + +#[contract] +struct MockVerifier; + +#[contractimpl] +impl MockVerifier { + /// Accepts a proof iff its first byte is 1. + pub fn verify_proof(_env: Env, _public_inputs: Bytes, proof: Bytes) -> bool { + proof.get(0) == Some(1) + } +} + +const DAY: u64 = 86_400; + +struct Ctx<'a> { + env: Env, + client: AegisVaultClient<'a>, + admin: Address, + token: Address, + campaign: BytesN<32>, +} + +fn new_token(env: &Env) -> Address { + env.register_stellar_asset_contract_v2(Address::generate(env)) + .address() +} + +fn ctx<'a>() -> Ctx<'a> { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(10 * DAY); + let verifier = env.register(MockVerifier, ()); + let token = new_token(&env); + let admin = Address::generate(&env); + let id = env.register(AegisVault, (verifier, token.clone(), admin.clone())); + let client = AegisVaultClient::new(&env, &id); + let campaign = BytesN::from_array(&env, &[9u8; 32]); + Ctx { env, client, admin, token, campaign } +} + +fn fund(c: &Ctx, amount: i128) { + let funder = Address::generate(&c.env); + StellarAssetClient::new(&c.env, &c.token).mint(&funder, &amount); + c.client.fund_zone(&funder, &prefix(&c.env, &c.campaign), &amount); +} + +fn prefix(env: &Env, campaign: &BytesN<32>) -> Bytes { + let mut raw = [0u8; CAMPAIGN_INPUTS_LEN]; + raw[128..160].copy_from_slice(&campaign.to_array()); + Bytes::from_slice(env, &raw) +} + +fn inputs(env: &Env, campaign: &BytesN<32>, nullifier: u8) -> Bytes { + let mut raw = [0u8; PUBLIC_INPUTS_LEN]; + raw[128..160].copy_from_slice(&campaign.to_array()); + raw[192..224].copy_from_slice(&[nullifier; 32]); + Bytes::from_slice(env, &raw) +} + +fn good_proof(env: &Env) -> Bytes { + Bytes::from_slice(env, &[1u8; 8]) +} + +#[test] +fn fund_zone_credits_campaign_and_treasury_reserve() { + let c = ctx(); + fund(&c, 3 * DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.campaign_balance(&c.campaign), 3 * DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.treasury_reserve(&c.token), 3 * DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.treasury_assets(), vec![&c.env, c.token.clone()]); +} + +#[test] +fn fund_zone_rejects_non_positive_amounts() { + let c = ctx(); + let funder = Address::generate(&c.env); + let p = prefix(&c.env, &c.campaign); + assert_eq!(c.client.try_fund_zone(&funder, &p, &0), Err(Ok(VaultError::InvalidAmount))); + assert_eq!(c.client.try_fund_zone(&funder, &p, &-5), Err(Ok(VaultError::InvalidAmount))); + let short = Bytes::from_slice(&c.env, &[0u8; 10]); + assert_eq!( + c.client.try_fund_zone(&funder, &short, &5), + Err(Ok(VaultError::InvalidPublicInputs)) + ); +} + +#[test] +fn claim_aid_pays_recipient_and_updates_books() { + let c = ctx(); + fund(&c, 2 * DEFAULT_PAYOUT_STROOP); + let recipient = Address::generate(&c.env); + let pi = inputs(&c.env, &c.campaign, 1); + + c.client.claim_aid(&recipient, &pi, &good_proof(&c.env)); + + assert_eq!(TokenClient::new(&c.env, &c.token).balance(&recipient), DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.campaign_balance(&c.campaign), DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.treasury_reserve(&c.token), DEFAULT_PAYOUT_STROOP); + assert_eq!(c.client.spent_today(&c.token), DEFAULT_PAYOUT_STROOP); + assert!(c.client.is_claimed(&BytesN::from_array(&c.env, &[1u8; 32]))); +} + +#[test] +fn claim_aid_rejects_replayed_nullifier() { + let c = ctx(); + fund(&c, 3 * DEFAULT_PAYOUT_STROOP); + let recipient = Address::generate(&c.env); + let pi = inputs(&c.env, &c.campaign, 1); + c.client.claim_aid(&recipient, &pi, &good_proof(&c.env)); + assert_eq!( + c.client.try_claim_aid(&recipient, &pi, &good_proof(&c.env)), + Err(Ok(VaultError::AlreadyClaimed)) + ); +} + +#[test] +fn claim_aid_rejects_invalid_proof() { + let c = ctx(); + fund(&c, DEFAULT_PAYOUT_STROOP); + let recipient = Address::generate(&c.env); + let bad = Bytes::from_slice(&c.env, &[0u8; 8]); + assert_eq!( + c.client.try_claim_aid(&recipient, &inputs(&c.env, &c.campaign, 1), &bad), + Err(Ok(VaultError::VerificationFailed)) + ); + assert!(!c.client.is_claimed(&BytesN::from_array(&c.env, &[1u8; 32]))); +} + +#[test] +fn claim_aid_rejects_underfunded_campaign() { + let c = ctx(); + fund(&c, DEFAULT_PAYOUT_STROOP - 1); + let recipient = Address::generate(&c.env); + assert_eq!( + c.client.try_claim_aid(&recipient, &inputs(&c.env, &c.campaign, 1), &good_proof(&c.env)), + Err(Ok(VaultError::InsufficientFunds)) + ); +} + +#[test] +fn daily_limit_caps_disbursements_and_resets_next_day() { + let c = ctx(); + fund(&c, 3 * DEFAULT_PAYOUT_STROOP); + c.client.set_daily_limit(&c.admin, &c.token, &(2 * DEFAULT_PAYOUT_STROOP)); + assert_eq!(c.client.daily_limit(&c.token), 2 * DEFAULT_PAYOUT_STROOP); + let recipient = Address::generate(&c.env); + + c.client.claim_aid(&recipient, &inputs(&c.env, &c.campaign, 1), &good_proof(&c.env)); + assert_eq!(c.client.remaining_today(&c.token), DEFAULT_PAYOUT_STROOP); + c.client.claim_aid(&recipient, &inputs(&c.env, &c.campaign, 2), &good_proof(&c.env)); + assert_eq!(c.client.remaining_today(&c.token), 0); + + let third = inputs(&c.env, &c.campaign, 3); + assert_eq!( + c.client.try_claim_aid(&recipient, &third, &good_proof(&c.env)), + Err(Ok(VaultError::DailyLimitExceeded)) + ); + // A rejected claim must not burn the nullifier or move funds. + assert!(!c.client.is_claimed(&BytesN::from_array(&c.env, &[3u8; 32]))); + assert_eq!(c.client.campaign_balance(&c.campaign), DEFAULT_PAYOUT_STROOP); + + c.env.ledger().set_timestamp(11 * DAY); + assert_eq!(c.client.spent_today(&c.token), 0); + c.client.claim_aid(&recipient, &third, &good_proof(&c.env)); + assert_eq!(c.client.campaign_balance(&c.campaign), 0); +} + +#[test] +fn constructor_sets_default_daily_limit() { + let c = ctx(); + assert_eq!(c.client.daily_limit(&c.token), DEFAULT_DAILY_LIMIT_STROOP); + // Unregistered assets are uncapped until registered/configured. + assert_eq!(c.client.daily_limit(&Address::generate(&c.env)), i128::MAX); +} + +#[test] +fn daily_limit_admin_only_and_validated() { + let c = ctx(); + let other = Address::generate(&c.env); + assert_eq!( + c.client.try_set_daily_limit(&other, &c.token, &1), + Err(Ok(VaultError::NotAdmin)) + ); + assert_eq!( + c.client.try_set_daily_limit(&c.admin, &c.token, &0), + Err(Ok(VaultError::InvalidAmount)) + ); + assert_eq!( + c.client.try_set_daily_limit(&c.admin, &Address::generate(&c.env), &1), + Err(Ok(VaultError::UnknownAsset)) + ); +} + +#[test] +fn treasury_holds_multiple_assets() { + let c = ctx(); + let usdc = new_token(&c.env); + let depositor = Address::generate(&c.env); + StellarAssetClient::new(&c.env, &usdc).mint(&depositor, &1_000); + + // Unregistered asset is refused until an admin adds it. + assert_eq!( + c.client.try_treasury_deposit(&depositor, &usdc, &500), + Err(Ok(VaultError::UnknownAsset)) + ); + assert_eq!( + c.client.try_add_treasury_asset(&Address::generate(&c.env), &usdc), + Err(Ok(VaultError::NotAdmin)) + ); + c.client.add_treasury_asset(&c.admin, &usdc); + c.client.add_treasury_asset(&c.admin, &usdc); // idempotent + c.client.treasury_deposit(&depositor, &usdc, &500); + + assert_eq!(c.client.treasury_reserve(&usdc), 500); + assert_eq!(c.client.treasury_assets().len(), 2); + assert_eq!( + c.client.try_treasury_deposit(&depositor, &usdc, &0), + Err(Ok(VaultError::InvalidAmount)) + ); +} + +#[test] +fn treasury_withdraw_is_admin_only_and_capped() { + let c = ctx(); + let usdc = new_token(&c.env); + let depositor = Address::generate(&c.env); + StellarAssetClient::new(&c.env, &usdc).mint(&depositor, &1_000); + c.client.add_treasury_asset(&c.admin, &usdc); + c.client.treasury_deposit(&depositor, &usdc, &1_000); + c.client.set_daily_limit(&c.admin, &usdc, &600); + let to = Address::generate(&c.env); + + assert_eq!( + c.client.try_treasury_withdraw(&Address::generate(&c.env), &usdc, &to, &1), + Err(Ok(VaultError::NotAdmin)) + ); + assert_eq!( + c.client.try_treasury_withdraw(&c.admin, &usdc, &to, &0), + Err(Ok(VaultError::InvalidAmount)) + ); + c.client.treasury_withdraw(&c.admin, &usdc, &to, &400); + assert_eq!(TokenClient::new(&c.env, &usdc).balance(&to), 400); + assert_eq!(c.client.treasury_reserve(&usdc), 600); + assert_eq!( + c.client.try_treasury_withdraw(&c.admin, &usdc, &to, &201), + Err(Ok(VaultError::DailyLimitExceeded)) + ); + // Within the cap but above the reserve. + c.client.set_daily_limit(&c.admin, &usdc, &10_000); + assert_eq!( + c.client.try_treasury_withdraw(&c.admin, &usdc, &to, &601), + Err(Ok(VaultError::InsufficientFunds)) + ); +} + +fn two_asset_treasury(c: &Ctx) -> Address { + let usdc = new_token(&c.env); + c.client.add_treasury_asset(&c.admin, &usdc); + let who = Address::generate(&c.env); + StellarAssetClient::new(&c.env, &usdc).mint(&who, &100); + StellarAssetClient::new(&c.env, &c.token).mint(&who, &100); + c.client.treasury_deposit(&who, &usdc, &100); + c.client.treasury_deposit(&who, &c.token, &100); + usdc +} + +#[test] +fn target_weights_validated_and_stored() { + let c = ctx(); + let usdc = two_asset_treasury(&c); + let assets = vec![&c.env, c.token.clone(), usdc.clone()]; + + assert_eq!( + c.client.try_set_target_weights(&Address::generate(&c.env), &assets, &vec![&c.env, 5_000u32, 5_000]), + Err(Ok(VaultError::NotAdmin)) + ); + assert_eq!( + c.client.try_set_target_weights(&c.admin, &assets, &vec![&c.env, 5_000u32]), + Err(Ok(VaultError::InvalidWeights)) + ); + assert_eq!( + c.client.try_set_target_weights(&c.admin, &assets, &vec![&c.env, 6_000u32, 4_001]), + Err(Ok(VaultError::InvalidWeights)) + ); + let stranger = vec![&c.env, Address::generate(&c.env)]; + assert_eq!( + c.client.try_set_target_weights(&c.admin, &stranger, &vec![&c.env, 100u32]), + Err(Ok(VaultError::UnknownAsset)) + ); + + c.client.set_target_weights(&c.admin, &assets, &vec![&c.env, 7_500u32, 2_500]); + assert_eq!(c.client.target_weight(&c.token), 7_500); + assert_eq!(c.client.target_weight(&usdc), 2_500); +} + +#[test] +fn rebalance_plan_moves_reserves_toward_targets() { + let c = ctx(); + let usdc = two_asset_treasury(&c); + let assets = vec![&c.env, c.token.clone(), usdc]; + c.client.set_target_weights(&c.admin, &assets, &vec![&c.env, 7_500u32, 2_500]); + + // Equal prices: 200 total value → target 150 / 50. + let plan = c.client.rebalance_plan(&vec![&c.env, 1i128, 1]); + assert_eq!(plan, vec![&c.env, 50i128, -50]); + + // Second asset worth 3× the first: 100 + 300 = 400 → targets 300 / 100 value. + let plan = c.client.rebalance_plan(&vec![&c.env, 1i128, 3]); + assert_eq!(plan, vec![&c.env, 200i128, -66]); +} + +#[test] +fn rebalance_plan_validates_prices() { + let c = ctx(); + two_asset_treasury(&c); + assert_eq!( + c.client.try_rebalance_plan(&vec![&c.env, 1i128]), + Err(Ok(VaultError::InvalidPrices)) + ); + assert_eq!( + c.client.try_rebalance_plan(&vec![&c.env, 1i128, 0]), + Err(Ok(VaultError::InvalidPrices)) + ); + assert_eq!( + c.client.try_rebalance_plan(&vec![&c.env, i128::MAX, 1]), + Err(Ok(VaultError::Overflow)) + ); +} diff --git a/contracts/aegis_vault/src/treasury.rs b/contracts/aegis_vault/src/treasury.rs new file mode 100644 index 00000000..026dbc75 --- /dev/null +++ b/contracts/aegis_vault/src/treasury.rs @@ -0,0 +1,170 @@ +//! Multi-asset treasury: per-asset reserves, daily disbursement caps and +//! target-weight rebalance planning. Pure bookkeeping — token movement stays +//! in `lib.rs`. + +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::VaultError; + +const SECONDS_PER_DAY: u64 = 86_400; +const BPS_DENOMINATOR: i128 = 10_000; + +#[derive(Clone)] +#[contracttype] +enum TreasuryKey { + Assets, + Reserve(Address), + DailyLimit(Address), + /// (asset, UTC day index) -> amount disbursed that day. + Spent(Address, u64), + Target(Address), +} + +fn day(env: &Env) -> u64 { + env.ledger().timestamp() / SECONDS_PER_DAY +} + +pub fn assets(env: &Env) -> Vec
{ + env.storage() + .instance() + .get(&TreasuryKey::Assets) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn is_registered(env: &Env, asset: &Address) -> bool { + assets(env).contains(asset) +} + +pub fn register_asset(env: &Env, asset: &Address) { + let mut list = assets(env); + if !list.contains(asset) { + list.push_back(asset.clone()); + env.storage().instance().set(&TreasuryKey::Assets, &list); + } +} + +pub fn reserve(env: &Env, asset: &Address) -> i128 { + env.storage() + .persistent() + .get(&TreasuryKey::Reserve(asset.clone())) + .unwrap_or(0) +} + +fn set_reserve(env: &Env, asset: &Address, amount: i128) { + env.storage() + .persistent() + .set(&TreasuryKey::Reserve(asset.clone()), &amount); +} + +pub fn credit(env: &Env, asset: &Address, amount: i128) -> Result<(), VaultError> { + register_asset(env, asset); + let next = reserve(env, asset) + .checked_add(amount) + .ok_or(VaultError::Overflow)?; + set_reserve(env, asset, next); + Ok(()) +} + +pub fn daily_limit(env: &Env, asset: &Address) -> i128 { + env.storage() + .instance() + .get(&TreasuryKey::DailyLimit(asset.clone())) + .unwrap_or(i128::MAX) +} + +pub fn set_daily_limit(env: &Env, asset: &Address, limit: i128) { + env.storage() + .instance() + .set(&TreasuryKey::DailyLimit(asset.clone()), &limit); +} + +pub fn spent_today(env: &Env, asset: &Address) -> i128 { + env.storage() + .temporary() + .get(&TreasuryKey::Spent(asset.clone(), day(env))) + .unwrap_or(0) +} + +pub fn remaining_today(env: &Env, asset: &Address) -> i128 { + (daily_limit(env, asset) - spent_today(env, asset)).max(0) +} + +/// Debits the reserve for an outgoing transfer and records it against today's +/// cap. Fails without mutating state when the cap or reserve is exceeded. +pub fn debit_disbursement(env: &Env, asset: &Address, amount: i128) -> Result<(), VaultError> { + let spent = spent_today(env, asset) + .checked_add(amount) + .ok_or(VaultError::Overflow)?; + if spent > daily_limit(env, asset) { + return Err(VaultError::DailyLimitExceeded); + } + let held = reserve(env, asset); + if held < amount { + return Err(VaultError::InsufficientFunds); + } + set_reserve(env, asset, held - amount); + let key = TreasuryKey::Spent(asset.clone(), day(env)); + env.storage().temporary().set(&key, &spent); + // Keep the counter for two days so late readers still see today's spend. + env.storage().temporary().extend_ttl(&key, 0, 2 * 17_280); + Ok(()) +} + +pub fn target_weight(env: &Env, asset: &Address) -> u32 { + env.storage() + .instance() + .get(&TreasuryKey::Target(asset.clone())) + .unwrap_or(0) +} + +pub fn set_target_weights( + env: &Env, + list: Vec
, + weights_bps: Vec, +) -> Result<(), VaultError> { + if list.len() != weights_bps.len() { + return Err(VaultError::InvalidWeights); + } + let mut total: i128 = 0; + for w in weights_bps.iter() { + total += w as i128; + } + if total > BPS_DENOMINATOR { + return Err(VaultError::InvalidWeights); + } + for a in list.iter() { + if !is_registered(env, &a) { + return Err(VaultError::UnknownAsset); + } + } + for (a, w) in list.iter().zip(weights_bps.iter()) { + env.storage().instance().set(&TreasuryKey::Target(a), &w); + } + Ok(()) +} + +/// Signed per-asset amounts (asset units) to reach target weights by value. +pub fn rebalance_plan(env: &Env, prices: Vec) -> Result, VaultError> { + let list = assets(env); + if prices.len() != list.len() { + return Err(VaultError::InvalidPrices); + } + let mut total_value: i128 = 0; + for (a, p) in list.iter().zip(prices.iter()) { + if p <= 0 { + return Err(VaultError::InvalidPrices); + } + let v = reserve(env, &a).checked_mul(p).ok_or(VaultError::Overflow)?; + total_value = total_value.checked_add(v).ok_or(VaultError::Overflow)?; + } + let mut plan = Vec::new(env); + for (a, p) in list.iter().zip(prices.iter()) { + let target_value = total_value + .checked_mul(target_weight(env, &a) as i128) + .ok_or(VaultError::Overflow)? + / BPS_DENOMINATOR; + let current_value = reserve(env, &a) * p; + plan.push_back((target_value - current_value) / p); + } + Ok(plan) +} From 6f651529e62978cb89407971036259ae25d3b742 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 3/7] feat(dao): SEP-40 price oracle adapter with stale-rate guard (Closes #543) Add oracle.rs, quote_conversion and disburse_aid. Prices older than 1 hour abort with StalePrice. Also fixes compile errors in the DAO crate and adds governance tests. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- contracts/helphone_dao/src/lib.rs | 135 ++++++++-- contracts/helphone_dao/src/oracle.rs | 81 ++++++ contracts/helphone_dao/src/test.rs | 386 ++++++++++++++++++++++++--- 3 files changed, 550 insertions(+), 52 deletions(-) create mode 100644 contracts/helphone_dao/src/oracle.rs diff --git a/contracts/helphone_dao/src/lib.rs b/contracts/helphone_dao/src/lib.rs index d3e164b4..2f730e96 100644 --- a/contracts/helphone_dao/src/lib.rs +++ b/contracts/helphone_dao/src/lib.rs @@ -1,18 +1,29 @@ #![no_std] +mod oracle; + use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, contracttype, - symbol_short, Address, Env, IntoVal, Symbol, Val, - Vec as SorobanVec, + contract, contractclient, contracterror, contractevent, contractimpl, contracttype, + symbol_short, Address, Env, Symbol, Vec, }; +pub use oracle::{OracleAsset, PriceData, MAX_PRICE_AGE_SECS}; + // ── Constants ────────────────────────────────────────────────────── -const MAX_PROPOSALS: u32 = 100; +const MAX_PROPOSALS: u64 = 100; const VOTING_PERIOD_SECS: u64 = 3 * 24 * 60 * 60; // 3 days const EXECUTION_DELAY_SECS: u64 = 1 * 24 * 60 * 60; // 1 day timelock const QUORUM_THRESHOLD_PCT: u32 = 20; // 20% of total supply must vote const PASS_THRESHOLD_PCT: u32 = 50; // >50% of votes to pass +/// Governance-token surface the DAO reads (`total_supply` is not part of the +/// generic SEP-41 client, so it is declared here). +#[contractclient(name = "GovTokenClient")] +pub trait GovToken { + fn balance(env: Env, id: Address) -> i128; + fn total_supply(env: Env) -> i128; +} + // ── Data Keys ────────────────────────────────────────────────────── #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -25,6 +36,7 @@ pub enum DataKey { TokenSnapshot(u64), // proposal_id -> TokenSnapshot TotalSupplyAt(u64), // proposal_id -> total token supply at snapshot ExecutedProposals, + Oracle, } // ── Types ────────────────────────────────────────────────────────── @@ -91,7 +103,7 @@ pub struct TokenSnapshot { // ── Errors ───────────────────────────────────────────────────────── #[contracterror] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DaoError { NotAdmin = 1, ProposalNotFound = 2, @@ -105,6 +117,13 @@ pub enum DaoError { TimelockNotExpired = 10, ExecutionFailed = 11, ProposalLimitReached = 12, + OracleNotSet = 13, + /// Oracle price is older than `MAX_PRICE_AGE_SECS`. + StalePrice = 14, + PriceUnavailable = 15, + InvalidPrice = 16, + InvalidAmount = 17, + Overflow = 18, } // ── Events ───────────────────────────────────────────────────────── @@ -125,6 +144,14 @@ pub struct VoteCastEvent<'a> { pub weight: &'a i128, } +#[contractevent(topics = ["disbursed"], data_format = "map")] +pub struct AidDisbursedEvent<'a> { + #[topic] + pub recipient: &'a Address, + pub source_amount: &'a i128, + pub payout_amount: &'a i128, +} + #[contractevent(topics = ["executed"], data_format = "map")] pub struct ProposalExecutedEvent<'a> { #[topic] @@ -192,7 +219,7 @@ impl HelPhoneDao { ) -> Result { proposer.require_auth(); - let count = Self::get_proposal_count(&env); + let count = Self::get_proposal_count(env.clone()); if count >= MAX_PROPOSALS { return Err(DaoError::ProposalLimitReached); } @@ -220,7 +247,7 @@ impl HelPhoneDao { let token_addr: Address = env .storage().instance().get(&key_token()) .ok_or(DaoError::InvalidProposal)?; - let token_client = soroban_sdk::token::TokenClient::new(&env, &token_addr); + let token_client = GovTokenClient::new(&env, &token_addr); let total_supply = token_client.total_supply(); let snapshot = TokenSnapshot { @@ -276,7 +303,7 @@ impl HelPhoneDao { let token_addr: Address = env .storage().instance().get(&key_token()) .ok_or(DaoError::InvalidProposal)?; - let token_client = soroban_sdk::token::TokenClient::new(&env, &token_addr); + let token_client = GovTokenClient::new(&env, &token_addr); // Use the snapshot ledger for historical balance let snapshot: TokenSnapshot = env @@ -310,7 +337,7 @@ impl HelPhoneDao { proposal_id: &proposal_id, voter: &voter, direction: &direction, - &weight: &weight, + weight: &weight, } .publish(&env); @@ -381,7 +408,7 @@ impl HelPhoneDao { if now <= proposal.voting_ends { return Err(DaoError::VotingClosed); } - let status = Self::finalize_proposal(&env, proposal_id)?; + let status = Self::finalize_proposal(env.clone(), proposal_id)?; if status != ProposalStatus::Passed { return Err(DaoError::NotPassed); } @@ -404,15 +431,15 @@ impl HelPhoneDao { env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); // Track executed set - let mut executed: SorobanVec = env + let mut executed: Vec = env .storage().instance().get(&key_executed_set()) - .unwrap_or(SorobanVec::new(&env)); + .unwrap_or(Vec::new(&env)); executed.push_back(proposal_id); env.storage().instance().set(&key_executed_set(), &executed); ProposalExecutedEvent { proposal_id: &proposal_id, - &success: &true, + success: &true, } .publish(&env); @@ -461,8 +488,8 @@ impl HelPhoneDao { } /// Read: get list of executed proposal IDs. - pub fn get_executed_proposals(env: Env) -> SorobanVec { - env.storage().instance().get(&key_executed_set()).unwrap_or(SorobanVec::new(&env)) + pub fn get_executed_proposals(env: Env) -> Vec { + env.storage().instance().get(&key_executed_set()).unwrap_or(Vec::new(&env)) } /// Admin: update the governance token address. @@ -482,6 +509,84 @@ impl HelPhoneDao { Ok(()) } + // ── Price oracle (#543) ──────────────────────────────────────── + /// Admin: set the SEP-40 price oracle contract (e.g. Reflector). + pub fn set_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), DaoError> { + let stored_admin: Address = env + .storage().instance().get(&key_admin()) + .ok_or(DaoError::NotAdmin)?; + if admin != stored_admin { + return Err(DaoError::NotAdmin); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Oracle, &oracle); + Ok(()) + } + + pub fn get_oracle(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::Oracle) + } + + /// Live conversion of `amount` from one token to another (rounded down), + /// e.g. XLM -> USDC. Aborts with `StalePrice` if either feed is > 1 hour old. + pub fn quote_conversion( + env: Env, + from_token: Address, + to_token: Address, + amount: i128, + ) -> Result { + let oracle: Address = env + .storage().instance().get(&DataKey::Oracle) + .ok_or(DaoError::OracleNotSet)?; + Ok(oracle::convert( + &env, + &oracle, + &OracleAsset::Stellar(from_token), + &OracleAsset::Stellar(to_token), + amount, + )) + } + + /// Admin: pay `recipient` the `payout_token` equivalent of `source_amount` + /// of `source_token` at the current oracle rate. Returns the amount paid. + pub fn disburse_aid( + env: Env, + admin: Address, + recipient: Address, + source_token: Address, + payout_token: Address, + source_amount: i128, + ) -> Result { + let stored_admin: Address = env + .storage().instance().get(&key_admin()) + .ok_or(DaoError::NotAdmin)?; + if admin != stored_admin { + return Err(DaoError::NotAdmin); + } + admin.require_auth(); + let payout = Self::quote_conversion( + env.clone(), + source_token, + payout_token.clone(), + source_amount, + )?; + if payout <= 0 { + return Err(DaoError::InvalidAmount); + } + soroban_sdk::token::TokenClient::new(&env, &payout_token).transfer( + &env.current_contract_address(), + &recipient, + &payout, + ); + AidDisbursedEvent { + recipient: &recipient, + source_amount: &source_amount, + payout_amount: &payout, + } + .publish(&env); + Ok(payout) + } + /// Admin: transfer admin role. pub fn transfer_admin( env: Env, diff --git a/contracts/helphone_dao/src/oracle.rs b/contracts/helphone_dao/src/oracle.rs new file mode 100644 index 00000000..14fd7a06 --- /dev/null +++ b/contracts/helphone_dao/src/oracle.rs @@ -0,0 +1,81 @@ +//! Price-oracle adapter for SEP-40 compatible feeds (e.g. Reflector). +//! +//! Prices are quoted by the oracle in its base asset (usually USD) with +//! `decimals()` fractional digits, so converting `amount` of A into B is +//! `amount * price(A) / price(B)`. Every read is guarded against stale, +//! missing, non-positive or future-dated prices. + +use soroban_sdk::{contractclient, contracttype, panic_with_error, Address, Env, Symbol}; + +use crate::DaoError; + +/// Maximum age of an oracle price before it is rejected: 1 hour. +pub const MAX_PRICE_AGE_SECS: u64 = 60 * 60; +/// Tolerated clock skew for prices stamped slightly in the future. +pub const MAX_FUTURE_SKEW_SECS: u64 = 60; + +/// SEP-40 asset identifier. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub enum OracleAsset { + Stellar(Address), + Other(Symbol), +} + +/// SEP-40 price record. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct PriceData { + pub price: i128, + pub timestamp: u64, +} + +/// Minimal SEP-40 surface used by the DAO. +#[allow(dead_code)] +#[contractclient(name = "PriceOracleClient")] +pub trait PriceOracle { + fn decimals(env: Env) -> u32; + fn lastprice(env: Env, asset: OracleAsset) -> Option; +} + +/// Latest price for `asset`; aborts with `StalePrice` when the feed has not +/// updated within [`MAX_PRICE_AGE_SECS`]. +pub fn fetch_price(env: &Env, oracle: &Address, asset: &OracleAsset) -> i128 { + let data = PriceOracleClient::new(env, oracle) + .lastprice(asset) + .unwrap_or_else(|| panic_with_error!(env, DaoError::PriceUnavailable)); + if data.price <= 0 { + panic_with_error!(env, DaoError::InvalidPrice); + } + let now = env.ledger().timestamp(); + if data.timestamp > now.saturating_add(MAX_FUTURE_SKEW_SECS) { + panic_with_error!(env, DaoError::InvalidPrice); + } + if now.saturating_sub(data.timestamp) > MAX_PRICE_AGE_SECS { + panic_with_error!(env, DaoError::StalePrice); + } + data.price +} + +/// `amount` of `from` expressed in `to`, rounded down. Both prices come from +/// the same feed so its decimals cancel out. +pub fn convert( + env: &Env, + oracle: &Address, + from: &OracleAsset, + to: &OracleAsset, + amount: i128, +) -> i128 { + if amount <= 0 { + panic_with_error!(env, DaoError::InvalidAmount); + } + if from == to { + return amount; + } + let p_from = fetch_price(env, oracle, from); + let p_to = fetch_price(env, oracle, to); + amount + .checked_mul(p_from) + .unwrap_or_else(|| panic_with_error!(env, DaoError::Overflow)) + / p_to +} diff --git a/contracts/helphone_dao/src/test.rs b/contracts/helphone_dao/src/test.rs index 9297f87e..ac24583d 100644 --- a/contracts/helphone_dao/src/test.rs +++ b/contracts/helphone_dao/src/test.rs @@ -1,65 +1,377 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Env, String}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{contract, contractimpl, contracttype, token::StellarAssetClient, token::TokenClient, Bytes, Env, String}; -fn create_test_env() -> (Env, Address, Address) { +const T0: u64 = 1_000_000; + +// ── Mocks ────────────────────────────────────────────────────────── + +/// SEP-40 style oracle whose prices are set directly by the tests. +#[contract] +struct MockOracle; + +#[contracttype] +enum OracleKey { + Price(OracleAsset), +} + +#[contractimpl] +impl MockOracle { + pub fn decimals(_env: Env) -> u32 { + 14 + } + pub fn set_price(env: Env, asset: OracleAsset, price: i128, timestamp: u64) { + env.storage() + .instance() + .set(&OracleKey::Price(asset), &PriceData { price, timestamp }); + } + pub fn lastprice(env: Env, asset: OracleAsset) -> Option { + env.storage().instance().get(&OracleKey::Price(asset)) + } +} + +/// Governance token exposing the two calls the DAO makes. +#[contract] +struct MockGovToken; + +#[contractimpl] +impl MockGovToken { + pub fn set_balance(env: Env, who: Address, amount: i128) { + env.storage().instance().set(&who, &amount); + } + pub fn balance(env: Env, id: Address) -> i128 { + env.storage().instance().get(&id).unwrap_or(0) + } + pub fn total_supply(_env: Env) -> i128 { + 1_000 + } +} + +struct Ctx<'a> { + env: Env, + dao: HelPhoneDaoClient<'a>, + admin: Address, + gov: Address, +} + +fn ctx<'a>() -> Ctx<'a> { let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(T0); let admin = Address::generate(&env); - let token = Address::generate(&env); - (env, admin, token) + let gov = env.register(MockGovToken, ()); + let id = env.register(HelPhoneDao, (admin.clone(), gov.clone())); + let dao = HelPhoneDaoClient::new(&env, &id); + Ctx { env, dao, admin, gov } +} + +fn sac(env: &Env) -> Address { + env.register_stellar_asset_contract_v2(Address::generate(env)).address() } +// ── Governance (constructor + proposal lifecycle) ────────────────── + #[test] fn constructor_sets_admin_and_token() { - let (env, admin, token) = create_test_env(); - env.mock_all_auths(); + let c = ctx(); + assert_eq!(c.dao.get_admin(), Some(c.admin.clone())); + assert_eq!(c.dao.get_governance_token(), Some(c.gov.clone())); + assert_eq!(c.dao.get_proposal_count(), 0); + assert_eq!(c.dao.get_governance_params(), (3 * 86_400, 86_400, 20, 50)); + assert_eq!(c.dao.get_oracle(), None); +} - let contract = HelPhoneDao; - env.register_contract(&Address::generate(&env), contract); +fn propose(c: &Ctx, proposer: &Address) -> u64 { + c.dao.create_proposal( + proposer, + &String::from_str(&c.env, "Fund zone"), + &String::from_str(&c.env, "desc"), + &ProposalType::FundAllocation, + &Bytes::new(&c.env), + ) +} - let contract_addr = Address::generate(&env); - env.register_contract(&contract_addr, HelPhoneDao); +fn holder(c: &Ctx, weight: i128) -> Address { + let a = Address::generate(&c.env); + MockGovTokenClient::new(&c.env, &c.gov).set_balance(&a, &weight); + a +} - HelPhoneDao::__constructor(env.clone(), admin.clone(), token.clone()).unwrap(); +#[test] +fn proposal_passes_and_executes_after_timelock() { + let c = ctx(); + let voter = holder(&c, 300); + let id = propose(&c, &voter); + assert_eq!(id, 1); + assert_eq!(c.dao.get_total_supply_at(&id), 1_000); + + c.dao.cast_vote(&voter, &id, &VoteDirection::For); + assert_eq!(c.dao.get_vote(&id, &voter).unwrap().weight, 300); + assert_eq!(c.dao.get_proposal(&id).unwrap().for_votes, 300); + + // Voting still open, so finalization is refused. + assert_eq!(c.dao.try_finalize_proposal(&id), Err(Ok(DaoError::VotingClosed))); - assert_eq!(HelPhoneDao::get_admin(env.clone()), Some(admin)); - assert_eq!(HelPhoneDao::get_governance_token(env.clone()), Some(token)); - assert_eq!(HelPhoneDao::get_proposal_count(env.clone()), 0); + let ends = c.dao.get_proposal(&id).unwrap().voting_ends; + c.env.ledger().set_timestamp(ends + 1); + assert_eq!(c.dao.finalize_proposal(&id), ProposalStatus::Passed); + // Finalizing again is a no-op that reports the settled status. + assert_eq!(c.dao.finalize_proposal(&id), ProposalStatus::Passed); + + assert_eq!(c.dao.try_execute_proposal(&id), Err(Ok(DaoError::TimelockNotExpired))); + c.env.ledger().set_timestamp(ends + 86_400); + c.dao.execute_proposal(&id); + assert_eq!(c.dao.get_proposal(&id).unwrap().status, ProposalStatus::Executed); + assert_eq!(c.dao.get_executed_proposals().len(), 1); + assert_eq!(c.dao.try_execute_proposal(&id), Err(Ok(DaoError::AlreadyExecuted))); } #[test] -fn governance_params_are_correct() { - let env = Env::default(); - let (voting_period, execution_delay, quorum, pass_threshold) = - HelPhoneDao::get_governance_params(env); +fn execute_finalizes_an_unsettled_passed_proposal() { + let c = ctx(); + let voter = holder(&c, 300); + let id = propose(&c, &voter); + c.dao.cast_vote(&voter, &id, &VoteDirection::For); + assert_eq!(c.dao.try_execute_proposal(&id), Err(Ok(DaoError::VotingClosed))); + let ends = c.dao.get_proposal(&id).unwrap().voting_ends; + c.env.ledger().set_timestamp(ends + 86_400 + 1); + c.dao.execute_proposal(&id); + assert_eq!(c.dao.get_proposal(&id).unwrap().status, ProposalStatus::Executed); +} + +#[test] +fn proposal_without_quorum_or_majority_fails() { + let c = ctx(); + let small = holder(&c, 100); // 10% < 20% quorum + let id = propose(&c, &small); + c.dao.cast_vote(&small, &id, &VoteDirection::For); + let ends = c.dao.get_proposal(&id).unwrap().voting_ends; + c.env.ledger().set_timestamp(ends + 1); + assert_eq!(c.dao.finalize_proposal(&id), ProposalStatus::Failed); + assert_eq!(c.dao.try_execute_proposal(&id), Err(Ok(DaoError::NotPassed))); + + c.env.ledger().set_timestamp(T0); + let big = holder(&c, 400); + let id2 = propose(&c, &big); + c.dao.cast_vote(&big, &id2, &VoteDirection::Against); + c.env.ledger().set_timestamp(ends + 1); + assert_eq!(c.dao.finalize_proposal(&id2), ProposalStatus::Failed); +} + +#[test] +fn voting_rules_are_enforced() { + let c = ctx(); + let voter = holder(&c, 300); + let nobody = Address::generate(&c.env); + let id = propose(&c, &voter); + + assert_eq!(c.dao.try_cast_vote(&voter, &99, &VoteDirection::For), Err(Ok(DaoError::ProposalNotFound))); + assert_eq!(c.dao.try_cast_vote(&nobody, &id, &VoteDirection::For), Err(Ok(DaoError::NotTokenHolder))); + c.dao.cast_vote(&voter, &id, &VoteDirection::Abstain); + assert_eq!(c.dao.get_proposal(&id).unwrap().abstain_votes, 300); + assert_eq!(c.dao.try_cast_vote(&voter, &id, &VoteDirection::For), Err(Ok(DaoError::AlreadyVoted))); + + let ends = c.dao.get_proposal(&id).unwrap().voting_ends; + c.env.ledger().set_timestamp(ends + 1); + let late = holder(&c, 10); + assert_eq!(c.dao.try_cast_vote(&late, &id, &VoteDirection::For), Err(Ok(DaoError::VotingClosed))); +} + +#[test] +fn cancel_is_limited_to_proposer_or_admin() { + let c = ctx(); + let proposer = holder(&c, 300); + let id = propose(&c, &proposer); + let stranger = Address::generate(&c.env); + assert_eq!(c.dao.try_cancel_proposal(&stranger, &id), Err(Ok(DaoError::NotAdmin))); + c.dao.cancel_proposal(&c.admin, &id); + assert_eq!(c.dao.get_proposal(&id).unwrap().status, ProposalStatus::Cancelled); + assert_eq!(c.dao.try_cancel_proposal(&proposer, &id), Err(Ok(DaoError::VotingClosed))); + assert_eq!(c.dao.try_cancel_proposal(&proposer, &42), Err(Ok(DaoError::ProposalNotFound))); +} + +#[test] +fn admin_controls_are_admin_only() { + let c = ctx(); + let other = Address::generate(&c.env); + let new_token = Address::generate(&c.env); + + assert_eq!(c.dao.try_set_governance_token(&other, &new_token), Err(Ok(DaoError::NotAdmin))); + c.dao.set_governance_token(&c.admin, &new_token); + assert_eq!(c.dao.get_governance_token(), Some(new_token)); + + assert_eq!(c.dao.try_transfer_admin(&other, &other), Err(Ok(DaoError::NotAdmin))); + c.dao.transfer_admin(&c.admin, &other); + assert_eq!(c.dao.get_admin(), Some(other)); +} + +// ── Price oracle adapter (#543) ──────────────────────────────────── + +struct Oracle<'a> { + c: Ctx<'a>, + feed: MockOracleClient<'a>, + xlm: Address, + usdc: Address, +} + +fn oracle<'a>() -> Oracle<'a> { + let c = ctx(); + let feed_id = c.env.register(MockOracle, ()); + let feed = MockOracleClient::new(&c.env, &feed_id); + let xlm = sac(&c.env); + let usdc = sac(&c.env); + c.dao.set_oracle(&c.admin, &feed_id); + // 1 XLM = 0.12 USD, 1 USDC = 1.00 USD (feed decimals cancel out). + feed.set_price(&OracleAsset::Stellar(xlm.clone()), &12_000_000_000_000, &T0); + feed.set_price(&OracleAsset::Stellar(usdc.clone()), &100_000_000_000_000, &T0); + Oracle { c, feed, xlm, usdc } +} + +#[test] +fn set_oracle_is_admin_only() { + let c = ctx(); + let feed = Address::generate(&c.env); + assert_eq!(c.dao.try_set_oracle(&Address::generate(&c.env), &feed), Err(Ok(DaoError::NotAdmin))); + c.dao.set_oracle(&c.admin, &feed); + assert_eq!(c.dao.get_oracle(), Some(feed)); +} + +#[test] +fn quote_requires_an_oracle() { + let c = ctx(); + let a = Address::generate(&c.env); + let b = Address::generate(&c.env); + assert_eq!(c.dao.try_quote_conversion(&a, &b, &100), Err(Ok(DaoError::OracleNotSet))); +} - assert_eq!(voting_period, 3 * 24 * 60 * 60); // 3 days - assert_eq!(execution_delay, 1 * 24 * 60 * 60); // 1 day - assert_eq!(quorum, 20); // 20% - assert_eq!(pass_threshold, 50); // 50% +#[test] +fn converts_xlm_to_usdc_and_back_at_live_rates() { + let o = oracle(); + // 1_000 XLM (7dp) → 120 USDC + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), 1_200_000_000); + // 120 USDC → 1_000 XLM + assert_eq!(o.c.dao.quote_conversion(&o.usdc, &o.xlm, &1_200_000_000), 10_000_000_000); + // Rounds down: 1 stroop of XLM is worth 0.12 stroop of USDC. + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &1), 0); } #[test] -fn proposal_status_values() { - assert_eq!(ProposalStatus::Active, ProposalStatus::Active); - assert_eq!(ProposalStatus::Passed, ProposalStatus::Passed); - assert_eq!(ProposalStatus::Failed, ProposalStatus::Failed); - assert_eq!(ProposalStatus::Executed, ProposalStatus::Executed); - assert_eq!(ProposalStatus::Cancelled, ProposalStatus::Cancelled); +fn same_asset_needs_no_price_and_bad_amounts_are_rejected() { + let o = oracle(); + let unpriced = Address::generate(&o.c.env); + assert_eq!(o.c.dao.quote_conversion(&unpriced, &unpriced, &55), 55); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &0), Err(Ok(DaoError::InvalidAmount))); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &-1), Err(Ok(DaoError::InvalidAmount))); } #[test] -fn vote_direction_values() { - assert_eq!(VoteDirection::For, VoteDirection::For); - assert_eq!(VoteDirection::Against, VoteDirection::Against); - assert_eq!(VoteDirection::Abstain, VoteDirection::Abstain); +fn tracks_price_updates() { + let o = oracle(); + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &24_000_000_000_000, &T0); + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), 2_400_000_000); } #[test] -fn proposal_type_values() { - assert_eq!(ProposalType::ProtocolUpgrade, ProposalType::ProtocolUpgrade); - assert_eq!(ProposalType::FundAllocation, ProposalType::FundAllocation); - assert_eq!(ProposalType::ParameterChange, ProposalType::ParameterChange); - assert_eq!(ProposalType::General, ProposalType::General); +fn rejects_prices_older_than_one_hour() { + let o = oracle(); + // Exactly one hour old is still acceptable… + o.c.env.ledger().set_timestamp(T0 + MAX_PRICE_AGE_SECS); + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), 1_200_000_000); + // …one second more is stale. + o.c.env.ledger().set_timestamp(T0 + MAX_PRICE_AGE_SECS + 1); + assert_eq!( + o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), + Err(Ok(DaoError::StalePrice)) + ); + // A refreshed feed recovers. + let now = T0 + MAX_PRICE_AGE_SECS + 1; + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &12_000_000_000_000, &now); + o.feed.set_price(&OracleAsset::Stellar(o.usdc.clone()), &100_000_000_000_000, &now); + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), 1_200_000_000); +} + +#[test] +fn a_single_stale_leg_is_enough_to_reject() { + let o = oracle(); + o.c.env.ledger().set_timestamp(T0 + 2 * 3_600); + o.feed.set_price(&OracleAsset::Stellar(o.usdc.clone()), &100_000_000_000_000, &(T0 + 2 * 3_600)); + assert_eq!( + o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &1_000), + Err(Ok(DaoError::StalePrice)) + ); +} + +#[test] +fn rejects_missing_zero_negative_and_future_prices() { + let o = oracle(); + let unpriced = Address::generate(&o.c.env); + assert_eq!(o.c.dao.try_quote_conversion(&unpriced, &o.usdc, &10), Err(Ok(DaoError::PriceUnavailable))); + + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &0, &T0); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &10), Err(Ok(DaoError::InvalidPrice))); + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &-5, &T0); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &10), Err(Ok(DaoError::InvalidPrice))); + + // Small clock skew is tolerated; large future timestamps are not. + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &12_000_000_000_000, &(T0 + 60)); + assert_eq!(o.c.dao.quote_conversion(&o.xlm, &o.usdc, &10_000_000_000), 1_200_000_000); + o.feed.set_price(&OracleAsset::Stellar(o.xlm.clone()), &12_000_000_000_000, &(T0 + 61)); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &10), Err(Ok(DaoError::InvalidPrice))); +} + +#[test] +fn conversion_overflow_is_reported() { + let o = oracle(); + assert_eq!(o.c.dao.try_quote_conversion(&o.xlm, &o.usdc, &i128::MAX), Err(Ok(DaoError::Overflow))); +} + +#[test] +fn supports_non_stellar_oracle_symbols() { + let o = oracle(); + let btc = OracleAsset::Other(soroban_sdk::Symbol::new(&o.c.env, "BTC")); + o.feed.set_price(&btc, &6_000_000_000_000_000, &T0); + // The DAO addresses assets by token contract, but the adapter handles both variants. + let feed = o.c.dao.get_oracle().unwrap(); + let usdc = OracleAsset::Stellar(o.usdc.clone()); + o.c.env.as_contract(&o.c.dao.address, || { + assert_eq!(oracle::convert(&o.c.env, &feed, &btc, &usdc, 2), 120); + }); +} + +#[test] +fn disburse_aid_pays_the_converted_amount() { + let o = oracle(); + let recipient = Address::generate(&o.c.env); + StellarAssetClient::new(&o.c.env, &o.usdc).mint(&o.c.dao.address, &5_000_000_000); + + let paid = o.c.dao.disburse_aid(&o.c.admin, &recipient, &o.xlm, &o.usdc, &10_000_000_000); + + assert_eq!(paid, 1_200_000_000); + assert_eq!(TokenClient::new(&o.c.env, &o.usdc).balance(&recipient), 1_200_000_000); + assert_eq!(TokenClient::new(&o.c.env, &o.usdc).balance(&o.c.dao.address), 3_800_000_000); +} + +#[test] +fn disburse_aid_guards_admin_staleness_and_dust() { + let o = oracle(); + let recipient = Address::generate(&o.c.env); + StellarAssetClient::new(&o.c.env, &o.usdc).mint(&o.c.dao.address, &5_000_000_000); + + assert_eq!( + o.c.dao.try_disburse_aid(&recipient, &recipient, &o.xlm, &o.usdc, &10), + Err(Ok(DaoError::NotAdmin)) + ); + // Rounds to zero: refuse rather than emit an empty payout. + assert_eq!( + o.c.dao.try_disburse_aid(&o.c.admin, &recipient, &o.xlm, &o.usdc, &1), + Err(Ok(DaoError::InvalidAmount)) + ); + o.c.env.ledger().set_timestamp(T0 + MAX_PRICE_AGE_SECS + 1); + assert_eq!( + o.c.dao.try_disburse_aid(&o.c.admin, &recipient, &o.xlm, &o.usdc, &10_000_000_000), + Err(Ok(DaoError::StalePrice)) + ); + assert_eq!(TokenClient::new(&o.c.env, &o.usdc).balance(&recipient), 0); } From fc30e6317bc852ab2e3e47c750b9d699cb18555f Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 4/7] feat(vault-ui): treasury reserves dashboard and oracle quotes (#541, #543) Add treasury and oracle client reads, error mapping, TreasuryPanel and a live quote form on VaultDashboard. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- .env.example | 3 + src/components/TreasuryPanel.jsx | 113 +++++++++++++++++++ src/lib/contract.ts | 101 +++++++++++++++++ src/lib/treasury.ts | 80 ++++++++++++++ src/pages/VaultDashboard.jsx | 27 +++++ src/types/index.ts | 28 +++++ test/price-oracle.test.js | 142 ++++++++++++++++++++++++ test/treasury-vault.test.js | 183 +++++++++++++++++++++++++++++++ 8 files changed, 677 insertions(+) create mode 100644 src/components/TreasuryPanel.jsx create mode 100644 src/lib/treasury.ts create mode 100644 test/price-oracle.test.js create mode 100644 test/treasury-vault.test.js diff --git a/.env.example b/.env.example index eba06731..8f605e18 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ SUPABASE_SERVICE_KEY=your-service-key # Logging LOG_LEVEL=info + + +VITE_HELPHONE_DAO_ID= diff --git a/src/components/TreasuryPanel.jsx b/src/components/TreasuryPanel.jsx new file mode 100644 index 00000000..3cc2616d --- /dev/null +++ b/src/components/TreasuryPanel.jsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { STROOPS, formatStroops, disbursementUsage } from "../lib/treasury"; + +const card = { + background: "#1c2c24", + borderRadius: "16px", + border: "1px solid rgba(255,255,255,0.08)", + padding: "20px", + marginBottom: "12px", + color: "#F2ECDC", +}; +const muted = { color: "rgba(242,236,220,0.5)", fontSize: "12px" }; + +function shortAddr(a) { + return a.length > 12 ? `${a.slice(0, 5)}…${a.slice(-4)}` : a; +} + +/** Real-time multi-asset treasury reserves with daily disbursement usage. */ +export function TreasuryPanel({ rows, loading = false }) { + return ( +
+

Treasury reserves

+ {loading && rows.length === 0 ? ( +

Loading treasury…

+ ) : rows.length === 0 ? ( +

No treasury assets yet.

+ ) : ( +
    + {rows.map((row) => { + const usage = disbursementUsage(row.dailyLimit, row.spentToday); + return ( +
  • +
    + {shortAddr(row.asset)} + {formatStroops(row.reserve)} +
    +
    +
    +
    +
    + {usage.unlimited + ? "No daily cap" + : `${formatStroops(row.spentToday)} / ${formatStroops(row.dailyLimit)} today`} + {usage.exhausted && " · daily limit reached"} + {row.targetWeightBps > 0 && ` · target ${(row.targetWeightBps / 100).toFixed(1)}%`} +
    +
  • + ); + })} +
+ )} +
+ ); +} + +/** Oracle-backed conversion quote (e.g. XLM -> USDC). */ +export function OracleQuoteForm({ getQuote }) { + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [amount, setAmount] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e) { + e.preventDefault(); + setResult(null); + setError(""); + const units = Math.round(parseFloat(amount) * STROOPS); + if (!from.trim() || !to.trim() || !(units > 0)) { + setError("Enter both token addresses and a positive amount."); + return; + } + setBusy(true); + try { + setResult(await getQuote(from.trim(), to.trim(), units)); + } catch (err) { + setError(err?.message || "Could not fetch a conversion quote."); + } finally { + setBusy(false); + } + } + + return ( +
+

Live conversion quote

+ setFrom(e.target.value)} style={{ width: "100%", marginBottom: "8px" }} /> + setTo(e.target.value)} style={{ width: "100%", marginBottom: "8px" }} /> + setAmount(e.target.value)} style={{ width: "100%", marginBottom: "8px" }} /> + + {result && ( +

+ {formatStroops(result.amountIn)} → {formatStroops(result.amountOut)} +

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/src/lib/contract.ts b/src/lib/contract.ts index acdf7310..af41f999 100644 --- a/src/lib/contract.ts +++ b/src/lib/contract.ts @@ -18,6 +18,7 @@ import { } from "@stellar/stellar-sdk"; import { parseEndpointList } from "./networkEstimator"; import { initRpcHealth, reportRpcFailure } from "./rpcHealth"; +import { toAssetRow, toAmount, classifyOracleError, ORACLE_ERROR_MESSAGES } from "./treasury"; import type { HelpRequest, Responder, @@ -1241,6 +1242,106 @@ export async function upgradeAegisVault(newWasmHash, wallet) { return await sendWrite(tx, wallet, "upgrade"); } +// ── Multi-asset treasury (Aegis Vault, #541) ─────────────────── +function aegisCall(fn, ...args) { + return new Contract(AEGIS_VAULT_ID).call(fn, ...args); +} + +async function readNative(call, fallback) { + const sim = await simulateRead(call); + if (!sim?.result) return fallback; + return scValToNative(sim.result.retval); +} + +/** Per-asset reserve, daily cap usage and target weight for every treasury asset. */ +export async function getTreasurySnapshot() { + if (!AEGIS_VAULT_ID) return []; + const assets = (await readNative(aegisCall("treasury_assets"), [])) || []; + return Promise.all( + assets.map(async (asset) => { + const arg = scv(asset, { type: "address" }); + const [reserve, limit, spent, remaining, weight] = await Promise.all([ + readNative(aegisCall("treasury_reserve", arg), 0n), + readNative(aegisCall("daily_limit", arg), 0n), + readNative(aegisCall("spent_today", arg), 0n), + readNative(aegisCall("remaining_today", arg), 0n), + readNative(aegisCall("target_weight", arg), 0), + ]); + return toAssetRow(asset, { reserve, limit, spent, remaining, weight }); + }), + ); +} + +/** Signed buy(+)/sell(-) amounts per treasury asset to reach target weights. + * `prices` follows the order of `treasury_assets`. */ +export async function getTreasuryRebalancePlan(prices) { + if (!AEGIS_VAULT_ID) return []; + const arg = nativeToScVal( + prices.map((p) => BigInt(p)), + { type: "i128" }, + ); + const plan = await readNative(aegisCall("rebalance_plan", arg), []); + return (plan || []).map(toAmount); +} + +export async function setTreasuryDailyLimit(asset, limit, wallet) { + if (!AEGIS_VAULT_ID) throw new Error("VITE_AEGIS_VAULT_ID not configured"); + const signerAddress = await resolveWalletAddress(wallet); + if (!signerAddress) throw new Error("Wallet address is not available yet"); + await ensureAccountFunded(signerAddress); + const account = await server.getAccount(signerAddress); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK, + }) + .addOperation( + Operation.invokeContractFunction({ + contract: AEGIS_VAULT_ID, + function: "set_daily_limit", + args: [ + scv(signerAddress, { type: "address" }), + scv(asset, { type: "address" }), + scv(BigInt(limit), { type: "i128" }), + ], + }), + ) + .setTimeout(30) + .build(); + return await sendWrite(tx, wallet, "set_daily_limit"); +} + +// ── Price oracle conversions (HelPhone DAO, #543) ────────────── +const DAO_CONTRACT_ID = import.meta.env?.VITE_HELPHONE_DAO_ID || ""; + +/** Live token conversion via the DAO's oracle adapter (e.g. XLM -> USDC). + * Throws an Error with a user-facing message; a feed older than 1 hour + * surfaces as the "stale" message. */ +export async function getOracleQuote(fromToken, toToken, amount) { + if (!DAO_CONTRACT_ID) throw new Error("VITE_HELPHONE_DAO_ID not configured"); + const call = new Contract(DAO_CONTRACT_ID).call( + "quote_conversion", + scv(fromToken, { type: "address" }), + scv(toToken, { type: "address" }), + scv(BigInt(amount), { type: "i128" }), + ); + let sim; + try { + sim = await simulateRead(call); + } catch (err) { + throw new Error(ORACLE_ERROR_MESSAGES[classifyOracleError(err)]); + } + if (sim?.error) { + throw new Error(ORACLE_ERROR_MESSAGES[classifyOracleError(sim.error)]); + } + if (!sim?.result) throw new Error(ORACLE_ERROR_MESSAGES.unknown); + return { + fromToken, + toToken, + amountIn: Number(amount), + amountOut: toAmount(scValToNative(sim.result.retval)), + }; +} + export async function withdrawProtocolFees( tokenAddress, recipient, diff --git a/src/lib/treasury.ts b/src/lib/treasury.ts new file mode 100644 index 00000000..de3faca0 --- /dev/null +++ b/src/lib/treasury.ts @@ -0,0 +1,80 @@ +import type { + TreasuryAssetRow, + DisbursementUsage, + OracleErrorKind, +} from "../types/index"; + +/** Soroban token amounts use 7 decimals ("stroops"). */ +export const STROOPS = 10_000_000; + +/** Converts an on-chain i128 to a JS number; values beyond 2^53 (e.g. the + * i128::MAX "no cap" sentinel) become Infinity instead of losing precision. */ +export function toAmount(val: unknown): number { + if (typeof val === "bigint") { + return val > BigInt(Number.MAX_SAFE_INTEGER) ? Infinity : Number(val); + } + const n = Number(val); + return Number.isFinite(n) ? n : 0; +} + +export function formatStroops(value: number, digits = 2): string { + if (!Number.isFinite(value)) return "∞"; + return (value / STROOPS).toFixed(digits); +} + +export function disbursementUsage(limit: number, spent: number): DisbursementUsage { + if (!Number.isFinite(limit)) { + return { unlimited: true, pct: 0, remaining: Infinity, exhausted: false }; + } + const remaining = Math.max(0, limit - spent); + const pct = limit > 0 ? Math.min(100, Math.round((spent / limit) * 100)) : 100; + return { unlimited: false, pct, remaining, exhausted: remaining === 0 }; +} + +export function toAssetRow( + asset: string, + raw: { + reserve: unknown; + limit: unknown; + spent: unknown; + remaining: unknown; + weight: unknown; + }, +): TreasuryAssetRow { + return { + asset, + reserve: toAmount(raw.reserve), + dailyLimit: toAmount(raw.limit), + spentToday: toAmount(raw.spent), + remainingToday: toAmount(raw.remaining), + targetWeightBps: toAmount(raw.weight), + }; +} + +/** Maps a DaoError contract code (see contracts/helphone_dao) to a UI category. */ +export function classifyOracleError(err: unknown): OracleErrorKind { + const msg = String((err as { message?: string })?.message ?? err ?? ""); + const code = /Error\(Contract, #(\d+)\)/.exec(msg)?.[1]; + switch (code) { + case "13": + return "not-configured"; + case "14": + return "stale"; + case "15": + return "unavailable"; + case "16": + case "17": + case "18": + return "invalid"; + default: + return "unknown"; + } +} + +export const ORACLE_ERROR_MESSAGES: Record = { + stale: "Price feed is more than 1 hour old. Try again once the oracle updates.", + unavailable: "The oracle has no price for one of these assets.", + invalid: "The oracle returned an invalid price, or the amount is invalid.", + "not-configured": "No price oracle is configured for this DAO.", + unknown: "Could not fetch a conversion quote.", +}; diff --git a/src/pages/VaultDashboard.jsx b/src/pages/VaultDashboard.jsx index a846bda6..2a599c0f 100644 --- a/src/pages/VaultDashboard.jsx +++ b/src/pages/VaultDashboard.jsx @@ -3,12 +3,15 @@ import { Link } from "react-router-dom"; import { StellarWalletsKit } from "@creit-tech/stellar-wallets-kit/sdk"; import { KitEventType } from "@creit-tech/stellar-wallets-kit/types"; import useDocumentTitle from "../lib/useDocumentTitle"; +import { TreasuryPanel, OracleQuoteForm } from "../components/TreasuryPanel"; import { getAegisCampaignBalance, getAegisPayoutAmount, getAegisIsClaimed, claimAid, fundZone, + getTreasurySnapshot, + getOracleQuote, sanitizeWalletAddress, buildLocationProofZone, } from "../lib/contract"; @@ -265,6 +268,28 @@ export default function VaultDashboard() { const [contributing, setContributing] = useState(false); const [message, setMessage] = useState(""); const [messageType, setMessageType] = useState("info"); + const [treasury, setTreasury] = useState([]); + const [treasuryLoading, setTreasuryLoading] = useState(true); + + // Real-time multi-asset treasury reserves (#541): refresh every 15s. + useEffect(() => { + let cancelled = false; + async function refresh() { + try { + const rows = await getTreasurySnapshot(); + if (!cancelled) setTreasury(rows); + } catch { + } finally { + if (!cancelled) setTreasuryLoading(false); + } + } + refresh(); + const timer = setInterval(refresh, 15_000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, []); useEffect(() => { let cancelled = false; @@ -710,6 +735,8 @@ export default function VaultDashboard() {

)} + + ); diff --git a/src/types/index.ts b/src/types/index.ts index 1b4710f2..94ee7aa0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -244,3 +244,31 @@ export interface WatermarkVerification { record?: LedgerWatermarkRecord; } + +// --- Multi-Asset Treasury (#541) & Price Oracle (#543) --- +export interface TreasuryAssetRow { + asset: string + reserve: number + /** Infinity when no cap is configured (i128::MAX on-chain). */ + dailyLimit: number + spentToday: number + remainingToday: number + targetWeightBps: number +} + +export interface DisbursementUsage { + unlimited: boolean + /** 0-100, capped. */ + pct: number + remaining: number + exhausted: boolean +} + +export interface OracleQuote { + fromToken: string + toToken: string + amountIn: number + amountOut: number +} + +export type OracleErrorKind = 'stale' | 'unavailable' | 'invalid' | 'not-configured' | 'unknown' diff --git a/test/price-oracle.test.js b/test/price-oracle.test.js new file mode 100644 index 00000000..4cfb4bad --- /dev/null +++ b/test/price-oracle.test.js @@ -0,0 +1,142 @@ +import { createElement as h } from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { nativeToScVal, StrKey } from "@stellar/stellar-sdk"; +import { + classifyOracleError, + ORACLE_ERROR_MESSAGES, +} from "../src/lib/treasury.ts"; +import { OracleQuoteForm } from "../src/components/TreasuryPanel.jsx"; + +// #543 — Oracle quote client, error mapping and the dashboard quote form. + +const state = vi.hoisted(() => ({ servers: [] })); +const FAKE_PK = "GB6Q7N7EHW5H6HZKAIIO4R2VTB7JEBX5XN4FOXXT6YTDA36Z7ALA656J"; + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + class Server { + constructor() { + this.simulateTransaction = vi.fn(); + state.servers.push(this); + } + } + return { + ...actual, + rpc: { ...actual.rpc, Server }, + Keypair: { ...actual.Keypair, random: () => ({ publicKey: () => FAKE_PK, sign: (d) => d }) }, + }; +}); + +const DAO = StrKey.encodeContract(Buffer.alloc(32, 9)); +const XLM = StrKey.encodeContract(Buffer.alloc(32, 1)); +const USDC = StrKey.encodeContract(Buffer.alloc(32, 2)); + +describe("classifyOracleError", () => { + it.each([ + ["Error(Contract, #13)", "not-configured"], + ["HostError: Error(Contract, #14)", "stale"], + ["Error(Contract, #15)", "unavailable"], + ["Error(Contract, #16)", "invalid"], + ["Error(Contract, #17)", "invalid"], + ["Error(Contract, #18)", "invalid"], + ["Error(Contract, #99)", "unknown"], + ["boom", "unknown"], + ])("%s -> %s", (msg, kind) => { + expect(classifyOracleError(new Error(msg))).toBe(kind); + expect(classifyOracleError(msg)).toBe(kind); + }); + it("handles nullish input", () => { + expect(classifyOracleError(undefined)).toBe("unknown"); + }); + it("has a message for every kind", () => { + for (const kind of ["stale", "unavailable", "invalid", "not-configured", "unknown"]) + expect(ORACLE_ERROR_MESSAGES[kind]).toBeTruthy(); + }); +}); + +describe("contract.ts — getOracleQuote", () => { + let lib; + let server; + beforeEach(async () => { + vi.resetModules(); + vi.stubEnv("VITE_HELPHONE_DAO_ID", DAO); + lib = await import("../src/lib/contract.ts"); + server = state.servers.at(-1); + }); + + it("returns the converted amount from quote_conversion", async () => { + server.simulateTransaction.mockResolvedValue({ + result: { retval: nativeToScVal(1_200_000_000n, { type: "i128" }) }, + }); + const q = await lib.getOracleQuote(XLM, USDC, 10_000_000_000); + expect(q).toEqual({ fromToken: XLM, toToken: USDC, amountIn: 10_000_000_000, amountOut: 1_200_000_000 }); + const tx = server.simulateTransaction.mock.calls[0][0]; + expect(tx.operations[0].func.invokeContract().functionName().toString()).toBe("quote_conversion"); + }); + + it("surfaces a stale-feed simulation error as a friendly message", async () => { + server.simulateTransaction.mockResolvedValue({ error: "HostError: Error(Contract, #14)" }); + await expect(lib.getOracleQuote(XLM, USDC, 1)).rejects.toThrow(/more than 1 hour old/); + }); + + it("maps thrown RPC errors the same way", async () => { + server.simulateTransaction.mockRejectedValue(new Error("Error(Contract, #15)")); + await expect(lib.getOracleQuote(XLM, USDC, 1)).rejects.toThrow(/no price/); + }); + + it("errors when the simulation has no result", async () => { + server.simulateTransaction.mockResolvedValue({}); + await expect(lib.getOracleQuote(XLM, USDC, 1)).rejects.toThrow(/Could not fetch/); + }); + + it("requires the DAO contract id", async () => { + vi.resetModules(); + vi.stubEnv("VITE_HELPHONE_DAO_ID", ""); + const bare = await import("../src/lib/contract.ts"); + await expect(bare.getOracleQuote(XLM, USDC, 1)).rejects.toThrow(/not configured/); + }); +}); + +describe("", () => { + const fill = (from, to, amount) => { + fireEvent.change(screen.getByLabelText("From token"), { target: { value: from } }); + fireEvent.change(screen.getByLabelText("To token"), { target: { value: to } }); + fireEvent.change(screen.getByLabelText("Amount"), { target: { value: amount } }); + fireEvent.submit(screen.getByRole("form", { name: "Oracle conversion quote" })); + }; + + it("requests a quote in stroops and shows the converted amount", async () => { + const getQuote = vi.fn().mockResolvedValue({ amountIn: 10_000_000_000, amountOut: 1_200_000_000 }); + render(h(OracleQuoteForm, { getQuote })); + fill(` ${XLM} `, USDC, "1000"); + await waitFor(() => expect(screen.getByRole("status").textContent).toContain("120.00")); + expect(getQuote).toHaveBeenCalledWith(XLM, USDC, 10_000_000_000); + cleanup(); + }); + + it("validates input before calling the oracle", () => { + const getQuote = vi.fn(); + render(h(OracleQuoteForm, { getQuote })); + fill("", USDC, "5"); + expect(screen.getByRole("alert").textContent).toMatch(/positive amount/); + fill(XLM, USDC, "0"); + expect(getQuote).not.toHaveBeenCalled(); + cleanup(); + }); + + it("shows oracle failures (e.g. stale price) to the user", async () => { + const getQuote = vi.fn().mockRejectedValue(new Error(ORACLE_ERROR_MESSAGES.stale)); + render(h(OracleQuoteForm, { getQuote })); + fill(XLM, USDC, "1"); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/1 hour old/)); + cleanup(); + }); + + it("falls back to a generic message for errors without text", async () => { + render(h(OracleQuoteForm, { getQuote: vi.fn().mockRejectedValue({}) })); + fill(XLM, USDC, "1"); + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/Could not fetch/)); + cleanup(); + }); +}); diff --git a/test/treasury-vault.test.js b/test/treasury-vault.test.js new file mode 100644 index 00000000..95624be2 --- /dev/null +++ b/test/treasury-vault.test.js @@ -0,0 +1,183 @@ +import { createElement as h } from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { nativeToScVal, StrKey, Account } from "@stellar/stellar-sdk"; +import { + STROOPS, + toAmount, + formatStroops, + disbursementUsage, + toAssetRow, +} from "../src/lib/treasury.ts"; +import { TreasuryPanel } from "../src/components/TreasuryPanel.jsx"; + +// #541 — Treasury helpers, dashboard panel, and contract client reads. + +const state = vi.hoisted(() => ({ servers: [] })); +const FAKE_PK = "GB6Q7N7EHW5H6HZKAIIO4R2VTB7JEBX5XN4FOXXT6YTDA36Z7ALA656J"; + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + class Server { + constructor() { + this.simulateTransaction = vi.fn(); + this.getAccount = vi.fn(); + this.sendTransaction = vi.fn(); + this.getTransaction = vi.fn(); + state.servers.push(this); + } + } + return { + ...actual, + rpc: { ...actual.rpc, Server, assembleTransaction: (tx) => ({ build: () => tx }) }, + Keypair: { ...actual.Keypair, random: () => ({ publicKey: () => FAKE_PK, sign: (d) => d }) }, + }; +}); + +const AEGIS = StrKey.encodeContract(Buffer.alloc(32, 7)); +const ASSET_A = StrKey.encodeContract(Buffer.alloc(32, 1)); +const ASSET_B = StrKey.encodeContract(Buffer.alloc(32, 2)); + +describe("treasury helpers", () => { + it("toAmount converts bigint/number and maps the no-cap sentinel to Infinity", () => { + expect(toAmount(5n)).toBe(5); + expect(toAmount((1n << 127n) - 1n)).toBe(Infinity); + expect(toAmount("12")).toBe(12); + expect(toAmount(undefined)).toBe(0); + }); + + it("formatStroops renders 7-decimal amounts and infinity", () => { + expect(formatStroops(500_000_000)).toBe("50.00"); + expect(formatStroops(12_345_678, 4)).toBe("1.2346"); + expect(formatStroops(Infinity)).toBe("∞"); + }); + + it("disbursementUsage reports pct, remaining and exhaustion", () => { + expect(disbursementUsage(1000, 250)).toEqual({ unlimited: false, pct: 25, remaining: 750, exhausted: false }); + expect(disbursementUsage(1000, 1000)).toMatchObject({ pct: 100, remaining: 0, exhausted: true }); + expect(disbursementUsage(1000, 5000)).toMatchObject({ pct: 100, remaining: 0, exhausted: true }); + expect(disbursementUsage(0, 0)).toMatchObject({ pct: 100, exhausted: true }); + expect(disbursementUsage(Infinity, 99)).toEqual({ unlimited: true, pct: 0, remaining: Infinity, exhausted: false }); + }); + + it("toAssetRow normalizes raw contract values", () => { + expect( + toAssetRow("CX", { reserve: 10n, limit: (1n << 127n) - 1n, spent: 2n, remaining: 8n, weight: 2500 }), + ).toEqual({ asset: "CX", reserve: 10, dailyLimit: Infinity, spentToday: 2, remainingToday: 8, targetWeightBps: 2500 }); + }); +}); + +describe("", () => { + const row = (o = {}) => ({ + asset: ASSET_A, + reserve: 5 * STROOPS, + dailyLimit: 10 * STROOPS, + spentToday: 4 * STROOPS, + remainingToday: 6 * STROOPS, + targetWeightBps: 0, + ...o, + }); + + it("shows loading, then empty state", () => { + const { rerender } = render(h(TreasuryPanel, { rows: [], loading: true })); + expect(screen.getByRole("status").textContent).toMatch(/Loading/); + rerender(h(TreasuryPanel, { rows: [] })); + expect(screen.getByText("No treasury assets yet.")).toBeTruthy(); + cleanup(); + }); + + it("renders reserves with daily usage and target weights", () => { + render(h(TreasuryPanel, { rows: [row({ targetWeightBps: 2500 }), row({ asset: ASSET_B, dailyLimit: Infinity })] })); + const rows = screen.getAllByTestId("treasury-row"); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain("5.00"); + expect(rows[0].textContent).toContain("4.00 / 10.00 today"); + expect(rows[0].textContent).toContain("target 25.0%"); + expect(screen.getAllByRole("progressbar")[0].getAttribute("aria-valuenow")).toBe("40"); + expect(rows[1].textContent).toContain("No daily cap"); + cleanup(); + }); + + it("flags an exhausted daily limit", () => { + render(h(TreasuryPanel, { rows: [row({ spentToday: 10 * STROOPS })] })); + expect(screen.getByTestId("treasury-row").textContent).toContain("daily limit reached"); + cleanup(); + }); +}); + +describe("contract.ts — treasury reads", () => { + let contractLib; + let server; + + beforeEach(async () => { + vi.resetModules(); + vi.stubEnv("VITE_AEGIS_VAULT_ID", AEGIS); + contractLib = await import("../src/lib/contract.ts"); + server = state.servers.at(-1); + }); + + const respond = (byFn) => + server.simulateTransaction.mockImplementation(async (tx) => { + const fn = tx.operations[0].func.invokeContract().functionName().toString(); + return { result: { retval: byFn[fn]() } }; + }); + + it("getTreasurySnapshot assembles one row per asset", async () => { + respond({ + treasury_assets: () => nativeToScVal([ASSET_A, ASSET_B].map((a) => nativeToScVal(a, { type: "address" }))), + treasury_reserve: () => nativeToScVal(100n, { type: "i128" }), + daily_limit: () => nativeToScVal(500n, { type: "i128" }), + spent_today: () => nativeToScVal(200n, { type: "i128" }), + remaining_today: () => nativeToScVal(300n, { type: "i128" }), + target_weight: () => nativeToScVal(5000, { type: "u32" }), + }); + const rows = await contractLib.getTreasurySnapshot(); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ + asset: ASSET_A, + reserve: 100, + dailyLimit: 500, + spentToday: 200, + remainingToday: 300, + targetWeightBps: 5000, + }); + }); + + it("getTreasuryRebalancePlan converts signed i128 results", async () => { + respond({ rebalance_plan: () => nativeToScVal([50n, -50n].map((v) => nativeToScVal(v, { type: "i128" }))) }); + expect(await contractLib.getTreasuryRebalancePlan([1, 1])).toEqual([50, -50]); + }); + + it("returns empty results when a simulation has no result", async () => { + server.simulateTransaction.mockResolvedValue({}); + expect(await contractLib.getTreasurySnapshot()).toEqual([]); + expect(await contractLib.getTreasuryRebalancePlan([1])).toEqual([]); + }); + + it("setTreasuryDailyLimit builds, signs and submits set_daily_limit", async () => { + server.getAccount.mockResolvedValue(new Account(FAKE_PK, "100")); + server.simulateTransaction.mockResolvedValue({}); + server.sendTransaction.mockResolvedValue({ status: "PENDING", hash: "h" }); + server.getTransaction.mockResolvedValue({ status: "SUCCESS", hash: "h" }); + const wallet = { getAddress: async () => ({ address: FAKE_PK }), signTransaction: vi.fn(async (xdr) => xdr) }; + await contractLib.setTreasuryDailyLimit(ASSET_A, 1000, wallet); + const tx = server.simulateTransaction.mock.calls[0][0]; + expect(tx.operations[0].func.invokeContract().functionName().toString()).toBe("set_daily_limit"); + expect(wallet.signTransaction).toHaveBeenCalledTimes(1); + }); + + it("setTreasuryDailyLimit requires a wallet address", async () => { + await expect(contractLib.setTreasuryDailyLimit(ASSET_A, 1, {})).rejects.toThrow(/Wallet address/); + }); +}); + +describe("contract.ts — treasury without a configured vault", () => { + it("returns empty data and refuses writes", async () => { + vi.resetModules(); + vi.stubEnv("VITE_AEGIS_VAULT_ID", ""); + const lib = await import("../src/lib/contract.ts"); + expect(await lib.getTreasurySnapshot()).toEqual([]); + expect(await lib.getTreasuryRebalancePlan([1])).toEqual([]); + await expect(lib.setTreasuryDailyLimit("C", 1, {})).rejects.toThrow(/not configured/); + }); +}); From 7850b09328f3f79ccc7dc7456ec2e5d2d9f9e36f Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 5/7] feat(perf): dynamic resource hints and intent-based module preloading (Closes #542) Inject dns-prefetch/preconnect/modulepreload at runtime, prefetch route chunks on hover/focus/touch, and drop heavy chunks from the entry HTML preload list. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- index.html | 3 + src/lib/resourceHints.ts | 181 ++++++++++++++++++++++++++++++ src/main.tsx | 25 ++++- test/resource-hints.test.js | 213 ++++++++++++++++++++++++++++++++++++ vite.config.ts | 9 ++ 5 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 src/lib/resourceHints.ts create mode 100644 test/resource-hints.test.js diff --git a/index.html b/index.html index d0b5ad02..209e7001 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ HelPhone + + + ` + * tags to at runtime. + * - `attachIntentPrefetch` warms a route's code-split chunk when the user shows + * intent to navigate (hover / focus / touch on an anchor), so the click lands + * on an already-downloaded module. + */ + +export type HintRel = "dns-prefetch" | "preconnect" | "modulepreload"; + +export interface HintOptions { + /** Emit `crossorigin` (required for preconnect to CORS resources such as fonts). */ + crossOrigin?: boolean; + /** Document to inject into (defaults to the global one). */ + doc?: Document; +} + +/** Third-party origins the app talks to on most sessions. */ +export const DEFAULT_ORIGINS: ReadonlyArray<{ + origin: string; + preconnect: boolean; + crossOrigin?: boolean; +}> = [ + { origin: "https://fonts.gstatic.com", preconnect: true, crossOrigin: true }, + { origin: "https://api.mapbox.com", preconnect: false }, + { origin: "https://soroban-testnet.stellar.org", preconnect: false }, +]; + +export type RouteLoader = () => Promise; + +const routeLoaders = new Map(); +const warmed = new Map>(); + +/** Returns a normalized origin/URL string, or null when the URL is not allowed for `rel`. */ +export function normalizeHref( + rel: HintRel, + href: string, + base: string = typeof location !== "undefined" ? location.href : "http://localhost/", +): string | null { + let url: URL; + try { + url = new URL(href, base); + } catch { + return null; + } + if (rel === "modulepreload") { + // Module preloads must stay same-origin: never fetch remote code eagerly. + return url.origin === new URL(base).origin ? url.pathname + url.search : null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + return url.origin; +} + +/** Injects a hint; returns the (possibly pre-existing) element, or null if rejected. */ +export function injectHint( + rel: HintRel, + href: string, + { crossOrigin = false, doc = document }: HintOptions = {}, +): HTMLLinkElement | null { + const value = normalizeHref(rel, href, doc.baseURI); + if (!value) return null; + const existing = Array.from( + doc.head.querySelectorAll(`link[rel="${rel}"]`), + ).find((l) => l.getAttribute("href") === value); + if (existing) return existing; + const link = doc.createElement("link"); + link.rel = rel; + link.href = value; + if (crossOrigin || rel === "modulepreload") link.crossOrigin = ""; + doc.head.appendChild(link); + return link; +} + +export const injectDnsPrefetch = (origin: string, opts?: HintOptions) => + injectHint("dns-prefetch", origin, opts); +export const injectPreconnect = (origin: string, opts?: HintOptions) => + injectHint("preconnect", origin, opts); +export const injectModulePreload = (href: string, opts?: HintOptions) => + injectHint("modulepreload", href, opts); + +/** Adds dns-prefetch for every default origin (and preconnect where flagged). */ +export function initResourceHints(doc: Document = document): HTMLLinkElement[] { + const added: HTMLLinkElement[] = []; + for (const { origin, preconnect, crossOrigin } of DEFAULT_ORIGINS) { + const dns = injectDnsPrefetch(origin, { doc }); + if (dns) added.push(dns); + if (preconnect) { + const pc = injectPreconnect(origin, { doc, crossOrigin }); + if (pc) added.push(pc); + } + } + return added; +} + +export function registerRouteLoaders(loaders: Record): void { + for (const [path, loader] of Object.entries(loaders)) routeLoaders.set(path, loader); +} + +export function resetRouteLoaders(): void { + routeLoaders.clear(); + warmed.clear(); +} + +/** True when the connection should not spend bytes on speculative fetches. */ +export function shouldSkipPrefetch( + nav: { connection?: { saveData?: boolean; effectiveType?: string } } | undefined = + typeof navigator !== "undefined" ? (navigator as never) : undefined, +): boolean { + const c = nav?.connection; + return Boolean(c?.saveData || (c?.effectiveType && /(^|-)2g$/.test(c.effectiveType))); +} + +/** Loads a route's chunk once; later calls reuse the same promise. */ +export function prefetchRoute(path: string): Promise | null { + const loader = routeLoaders.get(path); + if (!loader || shouldSkipPrefetch()) return null; + let p = warmed.get(path); + if (!p) { + p = loader().catch((err) => { + warmed.delete(path); // allow a retry on the next intent signal + throw err; + }); + warmed.set(path, p); + } + return p; +} + +export interface IntentOptions { + root?: Document | HTMLElement; + /** Hover dwell before prefetching, filters out cursor fly-bys. */ + delayMs?: number; +} + +function anchorPath(target: EventTarget | null, origin: string): string | null { + const a = (target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null; + if (!a || a.target === "_blank" || a.hasAttribute("download")) return null; + let url: URL; + try { + url = new URL(a.getAttribute("href") as string, origin + "/"); + } catch { + return null; + } + return url.origin === origin ? url.pathname : null; +} + +/** + * Delegated intent listeners: mouseover (after `delayMs` dwell), focusin and + * touchstart trigger `prefetchRoute` for the anchor's path. Returns a disposer. + */ +export function attachIntentPrefetch({ + root = document, + delayMs = 65, +}: IntentOptions = {}): () => void { + const origin = location.origin; + let timer: ReturnType | undefined; + + const warm = (target: EventTarget | null) => { + const path = anchorPath(target, origin); + if (path) prefetchRoute(path)?.catch(() => {}); + }; + const onOver = (e: Event) => { + clearTimeout(timer); + timer = setTimeout(() => warm(e.target), delayMs); + }; + const onOut = () => clearTimeout(timer); + const onImmediate = (e: Event) => warm(e.target); + + root.addEventListener("mouseover", onOver); + root.addEventListener("mouseout", onOut); + root.addEventListener("focusin", onImmediate); + root.addEventListener("touchstart", onImmediate, { passive: true }); + return () => { + clearTimeout(timer); + root.removeEventListener("mouseover", onOver); + root.removeEventListener("mouseout", onOut); + root.removeEventListener("focusin", onImmediate); + root.removeEventListener("touchstart", onImmediate); + }; +} diff --git a/src/main.tsx b/src/main.tsx index 6f1706e0..854bb4ff 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,11 @@ import { initThemeEngine } from "./styles/themeEngine"; import { bootstrapMultiTabSync } from "./lib/swChannel"; import { initHelpStoreChannelSync } from "./stores/helpStore"; import { scheduleKeyDerivationBenchmark } from "./lib/pbkdf2Key"; +import { + initResourceHints, + registerRouteLoaders, + attachIntentPrefetch, +} from "./lib/resourceHints"; import App from "./App"; import "./App.css"; import "./styles/theme.css"; @@ -16,9 +21,12 @@ import "./styles/theme.css"; // Heavy routes (Mapbox GL, ZK/WASM prover, Stellar RPC) are code-split so they // are only fetched when the user actually navigates to them, keeping the // initial bundle and Time-To-Interactive low. -const Help = lazy(() => import("./pages/Help")); -const Ranking = lazy(() => import("./pages/Ranking")); -const Admin = lazy(() => import("./pages/Admin")); +const loadHelp = () => import("./pages/Help"); +const loadRanking = () => import("./pages/Ranking"); +const loadAdmin = () => import("./pages/Admin"); +const Help = lazy(loadHelp); +const Ranking = lazy(loadRanking); +const Admin = lazy(loadAdmin); // #608 spike: WebGPU spatial-clustering prototype + benchmark harness (ADR-008). const ClusterLab = lazy(() => import("./components/WebGPUMap")); // Binary telemetry protocol spike: decode/GC benchmark harness (ADR-014). @@ -48,6 +56,17 @@ initThemeEngine(); // Measure PBKDF2 latency off the critical path so a slow device is surfaced early. scheduleKeyDerivationBenchmark(); +// #542: runtime resource hints + hover/focus/touch intent prefetching of the +// code-split route chunks (same loaders as the lazy() routes above, so the +// chunk is fetched exactly once). +initResourceHints(); +registerRouteLoaders({ + "/help": loadHelp, + "/ranking": loadRanking, + "/admin": loadAdmin, +}); +attachIntentPrefetch(); + function render() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/test/resource-hints.test.js b/test/resource-hints.test.js new file mode 100644 index 00000000..73bf01fd --- /dev/null +++ b/test/resource-hints.test.js @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + normalizeHref, + injectHint, + injectDnsPrefetch, + injectPreconnect, + injectModulePreload, + initResourceHints, + DEFAULT_ORIGINS, + registerRouteLoaders, + resetRouteLoaders, + shouldSkipPrefetch, + prefetchRoute, + attachIntentPrefetch, +} from "../src/lib/resourceHints.ts"; + +// #542 — Dynamic resource hint injector & intent-based module preloading. + +const links = (rel) => Array.from(document.head.querySelectorAll(`link[rel="${rel}"]`)); + +beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + resetRouteLoaders(); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("normalizeHref", () => { + it("reduces dns-prefetch/preconnect hrefs to their origin", () => { + expect(normalizeHref("dns-prefetch", "https://api.mapbox.com/styles/v1?x=1")).toBe( + "https://api.mapbox.com", + ); + expect(normalizeHref("preconnect", "http://localhost:3000/a")).toBe("http://localhost:3000"); + }); + it("rejects non-http(s) and unparsable URLs", () => { + expect(normalizeHref("preconnect", "javascript:alert(1)")).toBeNull(); + expect(normalizeHref("dns-prefetch", "data:text/plain,hi")).toBeNull(); + expect(normalizeHref("preconnect", "http://[bad")).toBeNull(); + }); + it("keeps modulepreload same-origin only", () => { + expect(normalizeHref("modulepreload", "/assets/a.js?v=1", "http://localhost/")).toBe( + "/assets/a.js?v=1", + ); + expect(normalizeHref("modulepreload", "https://evil.example/a.js", "http://localhost/")).toBeNull(); + }); + it("falls back to a default base when location is unavailable", () => { + vi.stubGlobal("location", undefined); + expect(normalizeHref("modulepreload", "/a.js")).toBe("/a.js"); + }); +}); + +describe("injectHint", () => { + it("adds a link tag to head", () => { + const el = injectDnsPrefetch("https://api.mapbox.com"); + expect(el.rel).toBe("dns-prefetch"); + expect(el.getAttribute("href")).toBe("https://api.mapbox.com"); + expect(links("dns-prefetch")).toHaveLength(1); + }); + it("deduplicates identical hints and returns the existing element", () => { + const a = injectPreconnect("https://fonts.gstatic.com", { crossOrigin: true }); + const b = injectPreconnect("https://fonts.gstatic.com/other/path"); + expect(b).toBe(a); + expect(links("preconnect")).toHaveLength(1); + }); + it("allows the same href under different rels", () => { + injectDnsPrefetch("https://a.example"); + injectPreconnect("https://a.example"); + expect(document.head.querySelectorAll("link")).toHaveLength(2); + }); + it("sets crossorigin only when requested (always for modulepreload)", () => { + expect(injectPreconnect("https://a.example").hasAttribute("crossorigin")).toBe(false); + expect(injectPreconnect("https://b.example", { crossOrigin: true }).hasAttribute("crossorigin")).toBe(true); + expect(injectModulePreload("/assets/x.js").hasAttribute("crossorigin")).toBe(true); + }); + it("returns null for rejected hrefs and adds nothing", () => { + expect(injectHint("modulepreload", "https://evil.example/x.js")).toBeNull(); + expect(injectHint("preconnect", "ftp://x")).toBeNull(); + expect(document.head.children).toHaveLength(0); + }); + it("honors a custom document", () => { + const other = document.implementation.createHTMLDocument("x"); + injectDnsPrefetch("https://a.example", { doc: other }); + expect(other.head.querySelectorAll("link")).toHaveLength(1); + expect(document.head.children).toHaveLength(0); + }); +}); + +describe("initResourceHints", () => { + it("injects dns-prefetch for every default origin and preconnect where flagged", () => { + const added = initResourceHints(); + const preconnects = DEFAULT_ORIGINS.filter((o) => o.preconnect).length; + expect(links("dns-prefetch")).toHaveLength(DEFAULT_ORIGINS.length); + expect(links("preconnect")).toHaveLength(preconnects); + expect(added).toHaveLength(DEFAULT_ORIGINS.length + preconnects); + }); + it("is idempotent", () => { + initResourceHints(); + initResourceHints(); + expect(links("dns-prefetch")).toHaveLength(DEFAULT_ORIGINS.length); + }); +}); + +describe("shouldSkipPrefetch", () => { + it("skips on save-data and 2g connections only", () => { + expect(shouldSkipPrefetch({ connection: { saveData: true } })).toBe(true); + expect(shouldSkipPrefetch({ connection: { effectiveType: "2g" } })).toBe(true); + expect(shouldSkipPrefetch({ connection: { effectiveType: "slow-2g" } })).toBe(true); + expect(shouldSkipPrefetch({ connection: { effectiveType: "4g" } })).toBe(false); + expect(shouldSkipPrefetch({})).toBe(false); + expect(shouldSkipPrefetch(undefined)).toBe(false); + }); +}); + +describe("prefetchRoute", () => { + it("returns null for unknown routes", () => { + expect(prefetchRoute("/nope")).toBeNull(); + }); + it("loads a route once and memoizes the promise", async () => { + const loader = vi.fn().mockResolvedValue({}); + registerRouteLoaders({ "/help": loader }); + const a = prefetchRoute("/help"); + const b = prefetchRoute("/help"); + await a; + expect(b).toBe(a); + expect(loader).toHaveBeenCalledTimes(1); + }); + it("allows a retry after a failed load", async () => { + const loader = vi.fn().mockRejectedValueOnce(new Error("net")).mockResolvedValue({}); + registerRouteLoaders({ "/help": loader }); + await expect(prefetchRoute("/help")).rejects.toThrow("net"); + await prefetchRoute("/help"); + expect(loader).toHaveBeenCalledTimes(2); + }); + it("does nothing on data-saver connections", () => { + const loader = vi.fn(); + registerRouteLoaders({ "/help": loader }); + vi.stubGlobal("navigator", { connection: { saveData: true } }); + expect(prefetchRoute("/help")).toBeNull(); + expect(loader).not.toHaveBeenCalled(); + }); +}); + +describe("attachIntentPrefetch", () => { + const fire = (el, type) => el.dispatchEvent(new Event(type, { bubbles: true })); + let loader; + beforeEach(() => { + loader = vi.fn().mockResolvedValue({}); + registerRouteLoaders({ "/help": loader }); + document.body.innerHTML = ` + Help + ext + blank + dl + bad + unknown +

no link

`; + }); + + it("prefetches after a hover dwell, including from nested children", () => { + vi.useFakeTimers(); + const off = attachIntentPrefetch({ delayMs: 50 }); + fire(document.getElementById("inner"), "mouseover"); + vi.advanceTimersByTime(49); + expect(loader).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(loader).toHaveBeenCalledTimes(1); + off(); + }); + + it("cancels the prefetch when the pointer leaves before the dwell", () => { + vi.useFakeTimers(); + const off = attachIntentPrefetch({ delayMs: 50 }); + fire(document.getElementById("help"), "mouseover"); + fire(document.getElementById("help"), "mouseout"); + vi.advanceTimersByTime(200); + expect(loader).not.toHaveBeenCalled(); + off(); + }); + + it("prefetches immediately on focus and touch", () => { + const off = attachIntentPrefetch(); + fire(document.getElementById("help"), "focusin"); + fire(document.getElementById("help"), "touchstart"); + expect(loader).toHaveBeenCalledTimes(1); // memoized across signals + off(); + }); + + it("ignores external, _blank, download, invalid, unknown and non-link targets", () => { + const off = attachIntentPrefetch(); + for (const id of ["ext", "blank", "dl", "bad", "unknown", "text"]) + fire(document.getElementById(id), "focusin"); + expect(loader).not.toHaveBeenCalled(); + off(); + }); + + it("swallows loader failures instead of throwing", async () => { + loader.mockRejectedValue(new Error("offline")); + const off = attachIntentPrefetch(); + fire(document.getElementById("help"), "focusin"); + await Promise.resolve(); + off(); + }); + + it("stops listening after dispose", () => { + const off = attachIntentPrefetch(); + off(); + fire(document.getElementById("help"), "focusin"); + expect(loader).not.toHaveBeenCalled(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 3e2e5b94..99db24c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -154,6 +154,15 @@ export default defineConfig(({ mode }) => ({ }, build: { chunkSizeWarningLimit: 500, + // #542 FCP: keep heavy, route-specific chunks (Mapbox GL, ZK/WASM prover) + // out of the entry HTML's modulepreload list; they are fetched on intent + // (see src/lib/resourceHints.ts) or on navigation instead. + modulePreload: { + resolveDependencies: (_file, deps, { hostType }) => + hostType === "html" + ? deps.filter((d) => !/(^|\/)(mapbox|zk)-[^/]*\.js$/.test(d)) + : deps, + }, rollupOptions: { output: { manualChunks(id) { From e29f7f943f11b1f51a573f4669f5f5a2fec76959 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:18 +0100 Subject: [PATCH 6/7] docs: document treasury, oracle and resource hint subsystems Covers #541, #542 and #543. Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- docs/performance-optimization.md | 41 ++++++++++++++++++++++++++ soroban-contract.md | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/docs/performance-optimization.md b/docs/performance-optimization.md index d5083bb8..64c7fc5a 100644 --- a/docs/performance-optimization.md +++ b/docs/performance-optimization.md @@ -46,3 +46,44 @@ Usage: pass `overlays` (an array of `{ id, x, y, kind }` in map viewBox units, ` ### Frame rate The loop is driven by `requestAnimationFrame`, so it runs at the display refresh rate (60 Hz on most screens). The rate is measured, not assumed: `FpsMeter` reports `{ fps, frames, windowMs }` once per second through `onRenderStats`. A device that cannot hold 60 FPS shows a lower number instead of a false claim. The unit tests verify the scheduling, the drawing and the message protocol with a fake clock. They do not measure real frame rates, which need a browser and a profile of the actual overlay count. + +## Resource hints & module preloading (#542) + +Goal: lower First Contentful Paint (FCP) on every route and make in-app +navigation feel instant, without spending bandwidth on code most sessions never run. + +### Layers + +| Layer | Where | What | +| --- | --- | --- | +| Static hints | `index.html` | `preconnect` to `fonts.googleapis.com` / `fonts.gstatic.com` (crossorigin), `dns-prefetch` to `api.mapbox.com` and `soroban-testnet.stellar.org`. Available before any JS runs. | +| Runtime hints | `src/lib/resourceHints.ts` → `initResourceHints()` | Deduplicated `` injection. Called once from `src/main.tsx`. `modulepreload` is restricted to same-origin URLs; other rels accept only `http(s)`. | +| Intent prefetch | `attachIntentPrefetch()` | Delegated `mouseover` (65 ms dwell), `focusin` and `touchstart` listeners. On an anchor whose path has a registered loader, the route's `import()` runs once (memoized), so Vite fetches the chunk and its `modulepreload` dependencies before the click. | +| Build | `vite.config.ts` `build.modulePreload.resolveDependencies` | Removes the heavy `mapbox-*` and `zk-*` chunks from the entry HTML's preload list, so the landing page does not compete with them for bandwidth. They load on intent or on navigation. | + +### Guard rails + +- No speculative fetch when `navigator.connection.saveData` is set or the + effective connection type is `2g` / `slow-2g`. +- Only same-origin, non-`_blank`, non-`download` anchors are considered. +- A failed prefetch is forgotten so the next intent signal retries it, and + never surfaces as an unhandled rejection. +- Hint injection is idempotent; calling it repeatedly (HMR, StrictMode) adds no tags. + +### Adding a route + +Register the same loader used by `lazy()` in `src/main.tsx`: + +```ts +const loadThing = () => import("./pages/Thing"); +const Thing = lazy(loadThing); +registerRouteLoaders({ "/thing": loadThing }); +``` + +### Verifying + +- Unit tests: `npx vitest run test/resource-hints.test.js`. +- In DevTools → Network, hover a nav link: the route chunk appears (Initiator: + `resourceHints`) before the click; the landing page's initial requests no + longer include the `mapbox` / `zk` chunks. +- Lighthouse (mobile, throttled): compare FCP before/after on `/`, `/help`, `/ranking`. diff --git a/soroban-contract.md b/soroban-contract.md index 5128e110..60403de4 100644 --- a/soroban-contract.md +++ b/soroban-contract.md @@ -7,3 +7,53 @@ signer creates an admin-transfer proposal with `create_admin_proposal`; each sig may call `approve_admin_proposal` once. `execute_admin_proposal` rejects execution until approvals meet the threshold and permanently marks executed proposals to prevent replay. The constructor defaults to a backwards-compatible 1-of-1 set. + +## Multi-asset treasury — `aegis_vault` (#541) + +`contracts/aegis_vault/src/treasury.rs` tracks a reserve per asset and enforces +per-asset daily disbursement caps. Reserves are credited by `fund_zone` and +`treasury_deposit`, and debited by `claim_aid` and `treasury_withdraw`. + +| Function | Auth | Notes | +| --- | --- | --- | +| `treasury_assets()` / `treasury_reserve(asset)` | – | Registered assets and their reserves | +| `add_treasury_asset(admin, asset)` | admin | Registers an asset (idempotent) | +| `treasury_deposit(from, asset, amount)` | `from` | Asset must be registered | +| `treasury_withdraw(admin, asset, to, amount)` | admin | Counts against the daily cap | +| `set_daily_limit(admin, asset, limit)` / `daily_limit` / `spent_today` / `remaining_today` | admin (set) | Cap resets each UTC day (`timestamp / 86400`). Constructor sets the vault token to 10 default payouts (500 tokens) | +| `set_target_weights(admin, assets, weights_bps)` / `target_weight` | admin (set) | Sum ≤ 10 000 bps | +| `rebalance_plan(prices)` | – | Read-only: signed buy(+)/sell(−) per asset to reach the targets. `prices` are in `treasury_assets` order. The vault never swaps; execution is off-chain or via a DEX adapter | + +A claim that would exceed the daily cap fails with `DailyLimitExceeded` (9) +before the nullifier is burned or any balance moves, so the claimant can retry +the next day. Other new errors: `UnknownAsset` (10), `InvalidWeights` (11), +`InvalidPrices` (12). `fund_zone` takes the 160-byte public-inputs prefix +(campaign id = bytes 128..160), as before. + +Frontend: `getTreasurySnapshot`, `getTreasuryRebalancePlan`, +`setTreasuryDailyLimit` in `src/lib/contract.ts`; reserves render in +`VaultDashboard.jsx` via `TreasuryPanel` (refreshed every 15 s). + +## Price oracle adapter — `helphone_dao` (#543) + +`contracts/helphone_dao/src/oracle.rs` reads SEP-40 feeds (e.g. Reflector) via +`lastprice(asset)`. Prices are quoted against the feed's base asset, so +`convert(amount, from, to) = amount * price(from) / price(to)`, rounded down. + +- **Stale guard:** a price older than `MAX_PRICE_AGE_SECS` (3600 s) aborts with + `StalePrice` (14). One stale leg rejects the whole conversion. Prices dated + more than 60 s in the future, or `<= 0`, abort with `InvalidPrice` (16); a + missing price aborts with `PriceUnavailable` (15). +- **Entry points:** `set_oracle(admin, oracle)`, `get_oracle()`, + `quote_conversion(from_token, to_token, amount)` and + `disburse_aid(admin, recipient, source_token, payout_token, source_amount)`, + which pays the converted amount from the DAO's balance. +- **Other errors:** `OracleNotSet` (13), `InvalidAmount` (17), `Overflow` (18). + +Frontend: `getOracleQuote(from, to, amount)` (needs `VITE_HELPHONE_DAO_ID`) +maps these codes to user-facing messages (`src/lib/treasury.ts`); the dashboard +has a live quote form. + +The DAO crate previously did not compile (syntax errors in `cast_vote` / +`execute_proposal`, missing `total_supply` client, non-`Copy` error enum); +those are fixed so the oracle work is testable, and governance now has tests. From 1e8b614236d05422c741b2a3ef9d48bc0a72d366 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:23:27 +0100 Subject: [PATCH 7/7] ci(contracts): run aegis_vault and helphone_dao tests Signed-off-by: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29bc5db0..25d9386f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,3 +150,20 @@ jobs: - name: Audit dependencies run: node scripts/audit-deps.js --check + + # #541 / #543 — Soroban contract unit tests (aegis_vault treasury, DAO oracle). + contracts: + name: Soroban Contract Tests + runs-on: ubuntu-latest + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Run contract tests + run: cargo test --workspace --locked