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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/release-nirium-vault.yml
Original file line number Diff line number Diff line change
@@ -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 }}
46 changes: 46 additions & 0 deletions nirium-soroban-contracts/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
87 changes: 82 additions & 5 deletions nirium-soroban-contracts/src/nirium_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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),
}

// ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(),
Expand All @@ -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")),
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading