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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0ae89d5b..124e172e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -159,3 +159,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
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/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)
+}
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
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);
}
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/index.html b/index.html
index d0b5ad02..209e7001 100644
--- a/index.html
+++ b/index.html
@@ -5,6 +5,9 @@
HelPhone
+
+
+
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 (
+
+ );
+}
diff --git a/src/lib/contract.ts b/src/lib/contract.ts
index 0e3f5e2e..3bd4afc4 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,
@@ -1268,6 +1269,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/resourceHints.ts b/src/lib/resourceHints.ts
new file mode 100644
index 00000000..172ff714
--- /dev/null
+++ b/src/lib/resourceHints.ts
@@ -0,0 +1,181 @@
+/**
+ * Dynamic resource hint injector & intent-based module preloading (#542).
+ *
+ * - `injectHint` adds deduplicated ``
+ * 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/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/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/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 91d8e837..7c2f9f48 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -269,6 +269,34 @@ export interface WatermarkVerification {
}
+// --- 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'
+
// --- Bounded Expert Verification History (contract ring buffer, #531) ---
/**
* A wallet's verification history is a fixed-capacity ring buffer. Entries have
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/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/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/);
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index b82559d4..170bc498 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -159,6 +159,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) {