diff --git a/.github/workflows/release-nirium-vault.yml b/.github/workflows/release-nirium-vault.yml new file mode 100644 index 00000000..d2b83488 --- /dev/null +++ b/.github/workflows/release-nirium-vault.yml @@ -0,0 +1,39 @@ +# Verified build of the NiriumVault contract (SEP-0055). +# +# On a `nirium-vault-v*` tag, stellar-expert/soroban-build-workflow builds the +# `nirium-contracts` package from this exact commit, stamps +# source_repo=github:nirium-protocol/nirium-core and home_domain=nirium.xyz into +# the WASM metadata, publishes a GitHub release with the WASM attached, attests +# that same file, and registers the hash with stellar.expert's contract +# validation. The WASM that gets deployed is the one attached to that release, +# never a local rebuild: a hash that differs by one byte matches no attestation. +# +# Tag prefix is `nirium-vault-v`, not `v`: release.yml (desktop app) already +# runs on every `v*` tag, and "vault-v1" would match it too. +# +# Pinned to the commit of release v25.1.0 (published 2026-03-09), reviewed on +# 2026-11-08: it checks out the tagged commit, builds from source with +# `stellar contract build --optimize`, and attests the same file it uploads. +# The pin freezes release.yml only. The actions it calls (actions/checkout@v4, +# stellar/stellar-cli@v25.1.0, actions/attest-build-provenance@v1) resolve their +# own tags at run time; fork the workflow if that residual risk is unacceptable. +name: Build and Release NiriumVault + +on: + push: + tags: ["nirium-vault-v*"] + +permissions: # inherited by the called workflow, which cannot elevate them + contents: write # create the release, attach the WASM + id-token: write # sign the attestation + attestations: write # publish it + +jobs: + release: + uses: stellar-expert/soroban-build-workflow/.github/workflows/release.yml@e43b241f114bcf02151c30d61088506c6ad3ae0b # v25.1.0 + with: + release_name: ${{ github.ref_name }} + package: nirium-contracts + home_domain: nirium.xyz + secrets: + release_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/nirium-soroban-contracts/protocol/src/lib.rs b/nirium-soroban-contracts/protocol/src/lib.rs index d2f2c99b..ec1a538e 100644 --- a/nirium-soroban-contracts/protocol/src/lib.rs +++ b/nirium-soroban-contracts/protocol/src/lib.rs @@ -273,6 +273,12 @@ impl NiriumProtocolContract { ) -> u64 { creator.require_auth(); + // subscribe() rejects fee <= 0, so such a listing could never be + // subscribed to: reject it here instead of storing it (FZN-2026-NIR-02). + if subscription_fee_usdc <= 0 { + panic!("invalid fee"); + } + let id: u64 = env.storage().instance().get(&DataKey::SNextId).unwrap_or(1); let listing = StrategyListing { id, @@ -466,3 +472,43 @@ impl NiriumProtocolContract { env.storage().instance().set(&DataKey::UsdcToken, &usdc_token); } } + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + + fn setup() -> (Env, NiriumProtocolContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(NiriumProtocolContract, ()); + let client = NiriumProtocolContractClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &Address::generate(&env), &Address::generate(&env)); + (env, client, admin) + } + + #[test] + #[should_panic(expected = "invalid fee")] + fn publish_rejects_zero_fee() { + let (env, client, _) = setup(); + let creator = Address::generate(&env); + client.publish_strategy(&creator, &String::from_str(&env, "s"), &String::from_str(&env, "cid"), &0i128); + } + + #[test] + #[should_panic(expected = "invalid fee")] + fn publish_rejects_negative_fee() { + let (env, client, _) = setup(); + let creator = Address::generate(&env); + client.publish_strategy(&creator, &String::from_str(&env, "s"), &String::from_str(&env, "cid"), &-1i128); + } + + #[test] + fn publish_accepts_positive_fee() { + let (env, client, _) = setup(); + let creator = Address::generate(&env); + let id = client.publish_strategy(&creator, &String::from_str(&env, "s"), &String::from_str(&env, "cid"), &1_000_000i128); + assert_eq!(id, 1); + } +} diff --git a/nirium-soroban-contracts/src/nirium_vault.rs b/nirium-soroban-contracts/src/nirium_vault.rs index 81f2c03c..00df2181 100644 --- a/nirium-soroban-contracts/src/nirium_vault.rs +++ b/nirium-soroban-contracts/src/nirium_vault.rs @@ -16,9 +16,12 @@ //! 3. Single-Invocation Flash Loans — this contract's pattern use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, + contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, String, Vec, }; +/// `Asset::Native` serialized as XDR: the AssetType discriminant 0, no body. +const NATIVE_ASSET_XDR: [u8; 4] = [0, 0, 0, 0]; + // ═══════════════════════════════════════════════════════════════ // ERROR CODES // ═══════════════════════════════════════════════════════════════ @@ -72,6 +75,9 @@ pub enum DataKey { /// Multisig cosigners for emergency operations (2-of-3 pattern) Cosigner1, Cosigner2, + /// Running total executed by an agent against its delegation budget + /// (vault_id, agent_address) → i128. Reset when the owner re-delegates. + AgentSpent(u64, Address), } // ═══════════════════════════════════════════════════════════════ @@ -148,7 +154,9 @@ impl NiriumVaultContract { // ─── Emergency Pause (IR-001) ──────────────────────────── /// Check that the contract is not paused. Called at the top of every - /// state-mutating function. Panics if paused. + /// state-mutating function, agent executions included (via verify_agent). + /// Deliberate exceptions: pause/unpause themselves and revoke_agent, so an + /// owner can always cut an agent off during an emergency stop. fn check_not_paused(env: &Env) { let paused: bool = env .storage() @@ -160,9 +168,20 @@ impl NiriumVaultContract { } } + /// Canonical native XLM asset contract for the network this contract runs + /// on. Deterministic, so no storage and no admin knob to get wrong. + fn native_xlm(env: &Env) -> Address { + env.deployer() + .with_stellar_asset(Bytes::from_array(env, &NATIVE_ASSET_XDR)) + .deployed_address() + } + /// Set cosigners for multisig emergency operations (2-of-3 pattern). /// Only the admin can register cosigners. Typically: Admin, Legal, CTO. pub fn set_cosigners(env: Env, admin: Address, cosigner1: Address, cosigner2: Address) { + // Blocked while paused: otherwise the admin alone could swap in a + // cosigner it controls and unpause, bypassing the 2-of-3 requirement. + Self::check_not_paused(&env); let stored_admin: Address = env .storage() .instance() @@ -300,7 +319,13 @@ impl NiriumVaultContract { Self::check_not_paused(&env); owner.require_auth(); - // Platform fee: 12.5 XLM (always charged in XLM regardless of Vault's base currency) + // Platform fee: 12.5 XLM (always charged in XLM regardless of Vault's base currency). + // `xlm_address` stays in the signature for compatibility, but must be the + // canonical native XLM contract for this network: a caller-chosen token + // would let anyone pay the fee in a worthless asset (FZN-2026-NIR-02). + if xlm_address != Self::native_xlm(&env) { + panic!("xlm_address must be the native XLM asset contract"); + } let treasury: Address = env.storage().instance().get(&DataKey::Treasury).unwrap(); let xlm_client = token::Client::new(&env, &xlm_address); xlm_client.transfer(&owner, &treasury, &DEPLOYMENT_FEE); @@ -379,6 +404,12 @@ impl NiriumVaultContract { vault.owner.require_auth(); + // Closed vaults take no new funds. Withdraw stays open on purpose: + // an owner must always be able to get money out. + if !vault.is_active { + panic!("E_NOT_ACTIVE: vault is closed"); + } + let xlm_client = token::Client::new(&env, &vault.token_address); xlm_client.transfer(&vault.owner, &env.current_contract_address(), &amount); @@ -526,6 +557,13 @@ impl NiriumVaultContract { vault.owner.require_auth(); + if !vault.is_active { + panic!("E_NOT_ACTIVE: vault is closed"); + } + if max_execution_amount <= 0 { + panic!("max_execution_amount must be positive"); + } + let delegation = AgentDelegation { vault_id, agent: agent_address.clone(), @@ -547,6 +585,10 @@ impl NiriumVaultContract { TTL_LEDGERS, TTL_LEDGERS, ); + // A new delegation is a new budget: the running total starts over. + let spent_key = DataKey::AgentSpent(vault_id, agent_address.clone()); + env.storage().persistent().set(&spent_key, &0i128); + env.storage().persistent().extend_ttl(&spent_key, TTL_LEDGERS, TTL_LEDGERS); env.events().publish( (symbol_short!("agent"), symbol_short!("delegate")), @@ -597,10 +639,29 @@ impl NiriumVaultContract { // ─── Internal: Verify Agent ────────────────────────────── - /// Verify an agent is delegated and within execution limits. + /// Verify an agent is delegated and within its execution budget. + /// + /// `max_execution_amount` is a cumulative budget for the life of the + /// delegation, not a per-call cap: a per-call check alone let an agent + /// stay under the limit on every call while the total grew without bound + /// (FZN-2026-NIR-02). The owner re-delegates to grant a fresh budget. fn verify_agent(env: &Env, vault_id: u64, agent: &Address, amount: i128) -> AgentDelegation { + Self::check_not_paused(env); agent.require_auth(); + if amount <= 0 { + panic!("amount must be positive"); + } + + let vault: Vault = env + .storage() + .persistent() + .get(&DataKey::Vault(vault_id)) + .expect("vault not found"); + if !vault.is_active { + panic!("E_NOT_ACTIVE: vault is closed"); + } + let delegation: AgentDelegation = env .storage() .persistent() @@ -610,13 +671,28 @@ impl NiriumVaultContract { if !delegation.is_active { panic!("agent delegation has been revoked"); } - if amount > delegation.max_execution_amount { + + let spent_key = DataKey::AgentSpent(vault_id, agent.clone()); + let spent: i128 = env.storage().persistent().get(&spent_key).unwrap_or(0); + let new_spent = spent.checked_add(amount).expect("spent overflow"); + if new_spent > delegation.max_execution_amount { panic!("exceeds agent max execution amount"); } + // Any later panic in the calling function reverts this write too. + env.storage().persistent().set(&spent_key, &new_spent); + env.storage().persistent().extend_ttl(&spent_key, TTL_LEDGERS, TTL_LEDGERS); delegation } + /// Amount an agent has executed so far against its current delegation. + pub fn get_agent_spent(env: Env, vault_id: u64, agent: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::AgentSpent(vault_id, agent)) + .unwrap_or(0) + } + // ─── Mock Pool Operations ──────────────────────────────── /// Create a testnet mock liquidity pool. @@ -630,6 +706,7 @@ impl NiriumVaultContract { quote_amount: i128, fee_bps: u32, ) -> MockPool { + Self::check_not_paused(&env); creator.require_auth(); // SC-POOL-01: Restrict pool creation to admin only. diff --git a/nirium-soroban-contracts/tests/vault_tests.rs b/nirium-soroban-contracts/tests/vault_tests.rs index 432e18a8..b3530792 100644 --- a/nirium-soroban-contracts/tests/vault_tests.rs +++ b/nirium-soroban-contracts/tests/vault_tests.rs @@ -12,10 +12,18 @@ #![cfg(test)] +use std::rc::Rc; + use soroban_sdk::{ symbol_short, testutils::{Address as _, Events}, - token, vec, Address, Env, IntoVal, String, + token, vec, + xdr::{ + AccountEntry, AccountEntryExt, AccountId, LedgerEntry, LedgerEntryData, LedgerEntryExt, + LedgerKey, LedgerKeyAccount, PublicKey, ScAddress, SequenceNumber, String32, Thresholds, + Uint256, VecM, + }, + Address, Bytes, Env, IntoVal, String, TryFromVal, }; // `crate::` doesn't work here: integration tests (files under tests/) are @@ -44,14 +52,50 @@ fn create_token(env: &Env) -> (Address, token::StellarAssetClient<'_>) { (token_address.address(), token_client) } +/// The native XLM asset contract, deployed at its canonical address. The +/// vault rejects any other `xlm_address`, so tests have to use the real one. +fn native_xlm(env: &Env) -> Address { + let deployer = env.deployer().with_stellar_asset(Bytes::from_array(env, &[0u8; 4])); + let addr = deployer.deployed_address(); + if token::Client::new(env, &addr).try_symbol().is_err() { + deployer.deploy(); + } + addr +} + +/// A classic G-account holding native XLM, written straight into the test +/// ledger: the native asset has no admin, so it can't be minted. +fn funded_native_account(env: &Env, seed: u8, stroops: i64) -> Address { + let account_id = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([seed; 32]))); + let key = Rc::new(LedgerKey::Account(LedgerKeyAccount { account_id: account_id.clone() })); + let entry = Rc::new(LedgerEntry { + last_modified_ledger_seq: 0, + data: LedgerEntryData::Account(AccountEntry { + account_id: account_id.clone(), + balance: stroops, + seq_num: SequenceNumber(0), + num_sub_entries: 0, + inflation_dest: None, + flags: 0, + home_domain: String32::default(), + thresholds: Thresholds([1, 0, 0, 0]), + signers: VecM::default(), + ext: AccountEntryExt::V0, + }), + ext: LedgerEntryExt::V0, + }); + env.host().add_ledger_entry(&key, &entry, None).unwrap(); + Address::try_from_val(env, &ScAddress::Account(account_id)).unwrap() +} + /// create_vault() charges its 12.5 XLM deployment fee (DEPLOYMENT_FEE = -/// 125_000_000 stroops) via a separate xlm_address parameter, distinct from -/// the vault's own base token -- funds owner with well above that so every -/// call site doesn't have to reason about the exact fee amount itself. +/// 125_000_000 stroops) in native XLM, distinct from the vault's own base +/// token. Funds owner with well above that from a funded classic account. fn create_xlm(env: &Env, owner: &Address) -> Address { - let (xlm_addr, xlm_admin) = create_token(env); - xlm_admin.mint(owner, &1_000_000_000); - xlm_addr + let xlm = native_xlm(env); + let faucet = funded_native_account(env, 7, 100_000_000_000); + token::Client::new(env, &xlm).transfer(&faucet, owner, &1_000_000_000); + xlm } // ═══════════════════════════════════════════════════════════════ @@ -609,3 +653,147 @@ fn test_pool_creation_excessive_fee() { &1000u32, ); } + +// ═══════════════════════════════════════════════════════════════ +// FZN-2026-NIR-02 REGRESSION TESTS +// ═══════════════════════════════════════════════════════════════ + +fn vault_with_agent(budget: i128) -> (Env, NiriumVaultContractClient<'static>, u64, Address, Address) { + let (env, _contract_id, client) = setup_env(); + let owner = Address::generate(&env); + let agent = Address::generate(&env); + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + + let (vault_token, vault_token_admin) = create_token(&env); + vault_token_admin.mint(&owner, &100_000_000_000); + let xlm_addr = create_xlm(&env, &owner); + + client.initialize(&treasury, &admin); + let vault = client.create_vault(&owner, &vault_token, &String::from_str(&env, "Test Vault"), &xlm_addr); + client.delegate_agent(&vault.vault_id, &agent, &budget); + (env, client, vault.vault_id, agent, admin) +} + +#[test] +#[should_panic(expected = "xlm_address must be the native XLM asset contract")] +fn test_create_vault_rejects_spoofed_fee_token() { + let (env, _contract_id, client) = setup_env(); + let owner = Address::generate(&env); + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + + let (vault_token, _) = create_token(&env); + let (fake_xlm, fake_admin) = create_token(&env); + fake_admin.mint(&owner, &1_000_000_000); + + client.initialize(&treasury, &admin); + client.create_vault(&owner, &vault_token, &String::from_str(&env, "Spoof"), &fake_xlm); +} + +#[test] +fn test_create_vault_fee_goes_to_treasury_in_native_xlm() { + let (env, _contract_id, client) = setup_env(); + let owner = Address::generate(&env); + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + + let (vault_token, _) = create_token(&env); + let xlm = create_xlm(&env, &owner); + let xlm_client = token::Client::new(&env, &xlm); + + client.initialize(&treasury, &admin); + client.create_vault(&owner, &vault_token, &String::from_str(&env, "Fee"), &xlm); + + assert_eq!(xlm_client.balance(&treasury), 125_000_000); + assert_eq!(client.get_total_fees(), 125_000_000); +} + +#[test] +#[should_panic(expected = "exceeds agent max execution amount")] +fn test_agent_budget_is_cumulative() { + let (_env, client, vault_id, agent, _admin) = vault_with_agent(1_000); + client.execute_blend_yield(&agent, &vault_id, &true, &600i128); + assert_eq!(client.get_agent_spent(&vault_id, &agent), 600); + // Under the per-call cap, but over the total budget. + client.execute_blend_yield(&agent, &vault_id, &true, &600i128); +} + +#[test] +fn test_redelegation_resets_budget() { + let (_env, client, vault_id, agent, _admin) = vault_with_agent(1_000); + client.execute_blend_yield(&agent, &vault_id, &true, &600i128); + client.delegate_agent(&vault_id, &agent, &1_000i128); + assert_eq!(client.get_agent_spent(&vault_id, &agent), 0); + client.execute_blend_yield(&agent, &vault_id, &true, &600i128); + assert_eq!(client.get_agent_spent(&vault_id, &agent), 600); +} + +#[test] +#[should_panic(expected = "amount must be positive")] +fn test_agent_amount_must_be_positive() { + let (_env, client, vault_id, agent, _admin) = vault_with_agent(1_000); + // A negative amount must not be able to wind the running total back. + client.execute_blend_yield(&agent, &vault_id, &true, &-500i128); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_agent_execution() { + let (_env, client, vault_id, agent, admin) = vault_with_agent(1_000); + client.pause(&admin, &admin); + client.execute_blend_yield(&agent, &vault_id, &true, &100i128); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_flash_loan() { + let (env, client, vault_id, agent, admin) = vault_with_agent(1_000_000_000); + let (base_token, _) = create_token(&env); + let (quote_token, _) = create_token(&env); + let pool = client.create_pool(&admin, &base_token, "e_token, &1_000_000_000i128, &1_000_000_000i128, &30u32); + client.pause(&admin, &admin); + client.flash_loan_execute(&agent, &vault_id, &pool.pool_id, &1_000i128, &0i128); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_create_pool() { + let (env, client, _vault_id, _agent, admin) = vault_with_agent(1_000); + let (base_token, _) = create_token(&env); + let (quote_token, _) = create_token(&env); + client.pause(&admin, &admin); + client.create_pool(&admin, &base_token, "e_token, &1_000i128, &1_000i128, &30u32); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_set_cosigners() { + let (env, client, _vault_id, _agent, admin) = vault_with_agent(1_000); + client.pause(&admin, &admin); + client.set_cosigners(&admin, &Address::generate(&env), &Address::generate(&env)); +} + +#[test] +fn test_revoke_still_works_while_paused() { + let (_env, client, vault_id, agent, admin) = vault_with_agent(1_000); + client.pause(&admin, &admin); + client.revoke_agent(&vault_id, &agent); + assert!(!client.get_agent_delegation(&vault_id, &agent).is_active); +} + +#[test] +#[should_panic(expected = "E_NOT_ACTIVE")] +fn test_closed_vault_rejects_deposit() { + let (_env, client, vault_id, _agent, _admin) = vault_with_agent(1_000); + client.close_vault(&vault_id); + client.deposit(&vault_id, &1_000i128); +} + +#[test] +#[should_panic(expected = "E_NOT_ACTIVE")] +fn test_closed_vault_rejects_agent_execution() { + let (_env, client, vault_id, agent, _admin) = vault_with_agent(1_000); + client.close_vault(&vault_id); + client.execute_blend_yield(&agent, &vault_id, &true, &100i128); +}