# Orion Finance SDK This guide shows how to install the [Orion SDK](https://pypi.org/project/orion-finance-sdk-py/) locally. The source code is publicly available on [GitHub](https://github.com/OrionFinanceAI/orion-finance-sdk-py). --- ## Workflow Overview The typical workflow for using the Orion Finance SDK: 1. **Install SDK** - Install the Orion Finance SDK package (see below); 2. **Deploy Vault** - Create a new vault using `orion deploy-vault`; 3. **Set Strategist** (if needed) - If you want to submit intents as a manager, set yourself as the strategist for your vault; 4. **Submit Intents** - Submit portfolio allocation intents using `orion submit-order`. --- ## Install The package CLI can be installed simply running: ```bash curl -sSfL https://sdk.orionfinance.ai/cli/install.sh | sh ``` Check available CLI commands any time: ```bash orion --help ``` ### Install from PyPI Alternatively, install the latest stable version from PyPI: ```bash pip install "orion-finance-sdk-py>=1.3.1" ``` ## Configure Environment Create a `.env` file in your project directory with the following variables: ### Required for Vault Deployment - **`RPC_URL`** (optional) - Chain RPC endpoint. If not set, the SDK uses a default public RPC (e.g. 1rpc.io, 0xrpc.io, or publicnode). Set this only if you want to use your own endpoint. - **`MANAGER_PRIVATE_KEY`** - Manager private key for signing vault deployment transactions; - **`MANAGER_ADDRESS`** - Manager address for fees/ownership (must match the address derived from `MANAGER_PRIVATE_KEY`). ### Quick Start Example usage of the Orion Finance SDK: ```python from orion_finance_sdk_py.contracts import OrionConfig config = OrionConfig() print(f"Risk-free Rate: {config.risk_free_rate}") ``` ### Required for Intent Submission - **`ORION_VAULT_ADDRESS`** - Address of the deployed Orion vault to interact with (obtained after vault deployment); > **Note:** Keep your `.env` file private and never commit it to version control. ## Vault Deployment Deploy a new vault using the Orion CLI. ## Who Can Create Vaults? - **Managers** can create vaults using the Orion CLI. ## What is an RPC URL? An **RPC URL** is the endpoint the SDK uses to communicate with contracts on the blockchain network. It’s provided by a node or node service provider (e.g., [Alchemy](https://alchemy.com/)) and allows the SDK to send transactions and query blockchain data. **You do not have to set one** — if `RPC_URL` is omitted, the SDK uses a default public RPC. Set it only if you want to use your own endpoint (e.g. for higher rate limits or a specific chain). ## Getting an RPC URL (optional) If you want to use your own RPC endpoint, you can get one from multiple providers. Below are two popular options: ### **1. Alchemy** 1. Go to [Alchemy](https://alchemy.com/) and create a free account. 2. Click **"Create App"** in your dashboard. 3. Select: - **Chain** - Ethereum; - **Network** - Sepolia Testnet. 4. Once created, click your app and copy the **HTTP URL** — this is your RPC URL. 5. Add it to your `.env` file (optional): ```bash RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY ``` ### **2. Infura** 1. Go to [Infura](https://infura.io/) and sign up. 2. Create a new project in your dashboard. 3. Select the Sepolia network. 4. Copy the HTTPS endpoint and add it to your `.env` file (optional): ```bash RPC_URL=https://sepolia.infura.io/v3/YOUR_API_KEY ``` --- ## Deploy a Transparent Vault ```bash orion deploy-vault \ --name "Algorithmic Liquidity Provision & Hedging Agent" \ --symbol "ALPHA" \ --fee-type hard_hurdle \ --performance-fee 100 \ --management-fee 10 \ --strategist-address 0x... ``` **What this does:** - Deploys an ERC-7540 vault smart contract; - Registers the manager address set in your `.env`; - Sets the fee type and fees amounts; - Makes allocations visible onchain. --- ## Verify Deployment - The CLI outputs the **vault contract address**; - Add it to your manager records and share it with users. ## Submit Rebalancing Order Intents Submit portfolio allocation intents that the protocol executes on the next rebalancing cycle. ### Prerequisites - A deployed vault; - Portfolio file generated by your strategy. ### Who Can Submit Intents? - **Strategists** can submit portfolio intents for vaults they are assigned to. - **Managers** who have set themselves as the strategist for their vault can also submit intents directly. --- ### Intent Submission Use **`--order-intent`** (alias **`--order-intent-path`**) with either a **file** or an **inline** intent: - **JSON file:** a single object mapping token addresses to weights (fractions that sum to **1**). - **CSV / Parquet:** tabular format; see column names below. Parquet needs **pyarrow** (`pip install 'orion-finance-sdk-py[parquet]'`). - **Inline string:** same object as JSON, or a Python `dict` literal, e.g. `'{"0x...": 0.5, "0x...": 0.5}'`. ```bash orion submit-order --order-intent order_intent.json orion submit-order --order-intent '{"0x...": 0.5, "0x...": 0.5}' ``` ### Notes - Intents are collected and executed at the **next rebalance**, enabling bundling, batching, and netting. - Ensure portfolio inputs match the **expected schema** for your strategy/vault. ### Expected Portfolio File Schema | Column Name | Type | Description | | ------------------- | ------- | ---------------------------------------------------- | | `address` | string | Token contract address (checksummed). | | `percentage_of_tvl` | decimal | Percentage of total vault value to allocate (0-100). | For CSV/Parquet you can also use **`token`** / **`addr`** for the address column, and **`weight`**, **`value`**, or **`percentage`** for weights. Weights in a **`percentage_of_tvl`** (or **`percentage`**) column are treated as **0–100** and normalized to fractions. ```json { "0x...": 0.25, "0x...": 0.02, "0x...": 0.015, "0x...": 0.0255, "0x...": 0.06, "0x...": 0.4, "0x...": 0.22, "0x...": 0.0095 } ``` > **Note:** > > - For **transparent vaults**, these values will be visible onchain once submitted. > - For **private vaults**, values are encrypted and only known to managers. ### API Reference ```{toctree} :maxdepth: 2 api ``` ***************** Source Code Files ***************** This section contains source code files from the project repository. These files are included to provide implementation context and technical details that complement the documentation above. **Files included:** .. code-block:: text __init__.py __main__.py cli.py contracts.py encrypt.py order_intent_io.py types.py utils.py __init__.py =========== .. code-block:: python """Orion Finance Python SDK.""" import importlib.metadata from orion_finance_sdk_py.cli import deploy_vault, submit_order from orion_finance_sdk_py.order_intent_io import load_order_intent __version__ = importlib.metadata.version("orion-finance-sdk-py") __all__ = ["deploy_vault", "load_order_intent", "submit_order"] __main__.py =========== .. code-block:: python """Main entry point for the Orion Finance Python SDK.""" from .cli import entry_point if __name__ == "__main__": entry_point() cli.py ====== .. code-block:: python """Command line interface for the Orion Finance Python SDK.""" import os import sys import questionary import typer from dotenv import load_dotenv from rich.console import Console from .contracts import ( OrionConfig, OrionTransparentVault, VaultFactory, ) from .order_intent_io import load_order_intent from .types import ( ZERO_ADDRESS, FeeType, VaultType, fee_type_to_int, ) from .utils import ( BASIS_POINTS_FACTOR, ensure_env_file, format_transaction_logs, validate_order, validate_var, ) ORION_BANNER = r""" ██████╗ ██████╗ ██╗ ██████╗ ███╗ ██╗ ███████╗██╗███╗ ██╗ █████╗ ███╗ ██╗ ██████╗███████╗ ██╔═══██╗██╔══██╗██║██╔═══██╗████╗ ██║ ██╔════╝██║████╗ ██║██╔══██╗████╗ ██║██╔════╝██╔════╝ ██║ ██║██████╔╝██║██║ ██║██╔██╗ ██║ █████╗ ██║██╔██╗ ██║███████║██╔██╗ ██║██║ █████╗ ██║ ██║██╔══██╗██║██║ ██║██║╚██╗██║ ██╔══╝ ██║██║╚██╗██║██╔══██║██║╚██╗██║██║ ██╔══╝ ╚██████╔╝██║ ██║██║╚██████╔╝██║ ╚████║ ██║ ██║██║ ╚████║██║ ██║██║ ╚████║╚██████╗███████╗ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚══╝ ╚═════╝╚══════╝ """ app = typer.Typer(help="Orion Finance SDK CLI") def _deploy_vault_logic( vault_type: str, strategist_address: str, name: str, symbol: str, fee_type_value: int, performance_fee_bp: int, management_fee_bp: int, deposit_access_control: str, ): """Logic for deploying a vault.""" vault_factory = VaultFactory(vault_type=vault_type) tx_result = vault_factory.create_orion_vault( strategist_address=strategist_address, name=name, symbol=symbol, fee_type=fee_type_value, performance_fee=performance_fee_bp, management_fee=management_fee_bp, deposit_access_control=deposit_access_control, ) format_transaction_logs(tx_result, "Vault deployment transaction completed!") vault_address = vault_factory.get_vault_address_from_result(tx_result) if vault_address: print( f"\n📍 ORION_VAULT_ADDRESS={vault_address} <------------------- COPY THIS TO YOUR .env FILE TO INTERACT WITH THE VAULT." ) else: print("\n❌ Could not extract vault address from transaction") def _submit_order_logic(order_intent_source: str): """Logic for submitting an order. ``order_intent_source`` may be a path to ``.json`` / ``.csv`` / ``.parquet``, or an inline JSON / Python dict literal string mapping addresses to weights. """ vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message=( "ORION_VAULT_ADDRESS environment variable is missing or invalid. " "Please set ORION_VAULT_ADDRESS in your .env file or as an environment variable. " ), ) order_intent = load_order_intent(order_intent_source) config = OrionConfig() if vault_address in config.orion_transparent_vaults: output_order_intent = validate_order(order_intent=order_intent) vault = OrionTransparentVault() tx_result = vault.submit_order_intent(order_intent=output_order_intent) else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") format_transaction_logs(tx_result, "Order intent submitted successfully!") def _update_strategist_logic(new_strategist_address: str): """Logic for updating strategist.""" vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message=( "ORION_VAULT_ADDRESS environment variable is missing or invalid. " "Please set ORION_VAULT_ADDRESS in your .env file or as an environment variable. " ), ) config = OrionConfig() if vault_address in config.orion_transparent_vaults: vault = OrionTransparentVault() else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") tx_result = vault.update_strategist(new_strategist_address) format_transaction_logs(tx_result, "Strategist address updated successfully!") def _update_fee_model_logic( fee_type_value: int, performance_fee_bp: int, management_fee_bp: int ): """Logic for updating fee model.""" vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message=( "ORION_VAULT_ADDRESS environment variable is missing or invalid. " "Please set ORION_VAULT_ADDRESS in your .env file or as an environment variable. " ), ) config = OrionConfig() if vault_address in config.orion_transparent_vaults: vault = OrionTransparentVault() else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") tx_result = vault.update_fee_model( fee_type=fee_type_value, performance_fee=performance_fee_bp, management_fee=management_fee_bp, ) format_transaction_logs(tx_result, "Fee model updated successfully!") def _update_deposit_access_control_logic(new_dac_address: str): """Logic for updating deposit access control.""" vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message="ORION_VAULT_ADDRESS environment variable is missing or invalid.", ) config = OrionConfig() if vault_address in config.orion_transparent_vaults: vault = OrionTransparentVault() else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") tx_result = vault.set_deposit_access_control(new_dac_address) format_transaction_logs(tx_result, "Deposit access control updated successfully!") def _claim_fees_logic(amount: int): """Logic for claiming fees.""" vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message="ORION_VAULT_ADDRESS environment variable is missing or invalid.", ) config = OrionConfig() if vault_address in config.orion_transparent_vaults: vault = OrionTransparentVault() tx_result = vault.transfer_manager_fees(amount) format_transaction_logs(tx_result, "Manager fees claimed successfully!") else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") def _get_pending_fees_logic(): """Logic for fetching pending vault fees.""" vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message="ORION_VAULT_ADDRESS environment variable is missing or invalid.", ) config = OrionConfig() if vault_address in config.orion_transparent_vaults: vault = OrionTransparentVault() else: raise ValueError(f"Vault address {vault_address} not in OrionConfig contract.") fees = vault.pending_vault_fees print(f"\n Pending Vault Fees: {fees}") def _list_whitelisted_assets_logic(): """Logic for listing whitelisted assets from OrionConfig.""" console = Console() config = OrionConfig() with console.status("[bold green]Fetching whitelisted assets from chain..."): assets = config.whitelisted_assets try: names = [n.strip() for n in config.whitelisted_asset_names] except Exception: # Fallback if the contract doesn't support names yet names = ["Unknown"] * len(assets) print("\n" + "=" * 60) # Calculate alignment based on longest name max_name_len = max((len(name) for name in names), default=10) for name, address in zip(names, assets, strict=True): print(f"{name: <{max_name_len}} | {address}") print("\n" + "=" * 60) print(f"Total: {len(assets)} whitelisted assets") print("=" * 60 + "\n") def ask_or_exit(question): """Ask a questionary question and exit/return if cancelled.""" result = question.ask() if result is None: raise KeyboardInterrupt return result def validate_int_input(val: str) -> bool | str: """Validate integer input.""" try: if int(val) > 0: return True return "Amount must be positive" except ValueError: return "Please enter a valid integer" def validate_name(val: str) -> bool | str: """Validate vault name length (max 26 bytes).""" if len(val.encode("utf-8")) > 26: return "Name too long (max 26 bytes)" if not val: return "Name cannot be empty" return True def validate_symbol(val: str) -> bool | str: """Validate vault symbol length (max 4 bytes).""" if len(val.encode("utf-8")) > 4: return "Symbol too long (max 4 bytes)" if not val: return "Symbol cannot be empty" return True def interactive_menu(): """Launch the interactive TUI menu.""" print(ORION_BANNER, file=sys.stderr) while True: # Force reload environment variables to pick up changes (e.g. newly deployed vault address) load_dotenv(override=True) try: choice = ask_or_exit( questionary.select( "What would you like to do?", choices=[ "Deploy Vault", "Submit Order", "Update Strategist", "Update Fee Model", "Update Deposit Access Control", "Claim Fees", "Get Pending Fees", "List Whitelisted Assets", "Exit", ], instruction="[ ↑↓ to scroll | Enter to select ]", ) ) if choice == "Exit": break if choice == "Deploy Vault": # Always deploy transparent vaults from CLI vault_type = VaultType.TRANSPARENT.value strategist_address = ask_or_exit( questionary.text("Strategist Address:") ) name = ask_or_exit( questionary.text("Vault Name:", validate=validate_name) ) symbol = ask_or_exit( questionary.text("Vault Symbol:", validate=validate_symbol) ) fee_type_str = ask_or_exit( questionary.select( "Fee Type:", choices=[t.value for t in FeeType], instruction="[ ↑↓ to scroll | Enter to select ]", ) ) perf_fee_str = ask_or_exit( questionary.text( "Performance Fee (%):", default="", ) ) perf_fee = float(perf_fee_str) if perf_fee_str else 0.0 mgmt_fee_str = ask_or_exit( questionary.text( "Management Fee (%):", default="", ) ) mgmt_fee = float(mgmt_fee_str) if mgmt_fee_str else 0.0 dac = ask_or_exit( questionary.text("Deposit Access Control (Address):", default="") ) if not dac: dac = ZERO_ADDRESS _deploy_vault_logic( vault_type, strategist_address, name, symbol, fee_type_to_int[fee_type_str], int(perf_fee * BASIS_POINTS_FACTOR), int(mgmt_fee * BASIS_POINTS_FACTOR), dac, ) elif choice == "Submit Order": path = ask_or_exit( questionary.text( "Order intent: path to .json/.csv/.parquet or inline JSON object:", ) ) _submit_order_logic(path) elif choice == "Update Strategist": addr = ask_or_exit(questionary.text("New Strategist Address:")) _update_strategist_logic(addr) elif choice == "Update Fee Model": fee_type_str = ask_or_exit( questionary.select( "Fee Type:", choices=[t.value for t in FeeType], instruction="[ ↑↓ to scroll | Enter to select ]", ) ) perf_fee_str = ask_or_exit( questionary.text( "Performance Fee (%):", default="", ) ) perf_fee = float(perf_fee_str) if perf_fee_str else 0.0 mgmt_fee_str = ask_or_exit( questionary.text( "Management Fee (%):", default="", ) ) mgmt_fee = float(mgmt_fee_str) if mgmt_fee_str else 0.0 _update_fee_model_logic( fee_type_to_int[fee_type_str], int(perf_fee * BASIS_POINTS_FACTOR), int(mgmt_fee * BASIS_POINTS_FACTOR), ) elif choice == "Update Deposit Access Control": addr = ask_or_exit(questionary.text("New Access Control Address:")) _update_deposit_access_control_logic(addr) elif choice == "Claim Fees": amount = int( ask_or_exit( questionary.text( "Amount to Claim (units):", validate=validate_int_input ) ) ) _claim_fees_logic(amount) elif choice == "Get Pending Fees": _get_pending_fees_logic() elif choice == "List Whitelisted Assets": _list_whitelisted_assets_logic() input("\nPress Enter to continue...") except KeyboardInterrupt: print("\nOperation cancelled.") continue # Go back to main menu loop except Exception as e: print(f"\n❌ Error: {e}") input("\nPress Enter to continue...") @app.callback(invoke_without_command=True) def main(ctx: typer.Context): """Orion Finance CLI.""" ensure_env_file() if ctx.invoked_subcommand is None: interactive_menu() def entry_point(): """Entry point for the CLI.""" try: app() except ValueError as e: Console().print(str(e)) sys.exit(1) @app.command() def deploy_vault( strategist_address: str = typer.Option( ..., help="Strategist address to set for the vault" ), name: str = typer.Option(..., help="Name of the vault"), symbol: str = typer.Option(..., help="Symbol of the vault"), fee_type: FeeType = typer.Option(..., help="Type of the fee"), performance_fee: float = typer.Option( ..., help="Performance fee in percentage i.e. 10.2 (maximum 30%)" ), management_fee: float = typer.Option( ..., help="Management fee in percentage i.e. 2.1 (maximum 3%)" ), deposit_access_control: str = typer.Option( ZERO_ADDRESS, help="Address of the deposit access control contract" ), ): """Deploy an Orion vault with customizable fee structure, name, and symbol. The vault defaults to transparent.""" fee_type_int = fee_type_to_int[fee_type.value] _deploy_vault_logic( VaultType.TRANSPARENT.value, strategist_address, name, symbol, fee_type_int, int(performance_fee * BASIS_POINTS_FACTOR), int(management_fee * BASIS_POINTS_FACTOR), deposit_access_control, ) @app.command() def submit_order( order_intent: str = typer.Option( ..., "--order-intent", "--order-intent-path", help=( "Path to .json (object), .csv, or .parquet order intent; or inline JSON / " "Python dict literal, e.g. '{\"0xabc...\": 0.5, ...}'" ), ), ) -> None: """Submit an order intent to an Orion vault (transparent vaults: JSON/CSV/Parquet file or inline dict).""" _submit_order_logic(order_intent) @app.command() def update_strategist( new_strategist_address: str = typer.Option( ..., help="New strategist address to set for the vault" ), ) -> None: """Update the strategist address for an Orion vault.""" _update_strategist_logic(new_strategist_address) @app.command() def update_fee_model( fee_type: FeeType = typer.Option( ..., help="Type of the fee. Options: absolute, soft_hurdle, hard_hurdle, high_water_mark, hurdle_hwm", ), performance_fee: float = typer.Option( ..., help="Performance fee in percentage i.e. 10.2 (maximum 30%)" ), management_fee: float = typer.Option( ..., help="Management fee in percentage i.e. 2.1 (maximum 3%)" ), ) -> None: """Update the fee model for an Orion vault.""" fee_type_int = fee_type_to_int[fee_type.value] _update_fee_model_logic( fee_type_int, int(performance_fee * BASIS_POINTS_FACTOR), int(management_fee * BASIS_POINTS_FACTOR), ) @app.command() def get_pending_fees() -> None: """Get pending fees for the current vault.""" _get_pending_fees_logic() @app.command() def list_whitelisted_assets() -> None: """List all whitelisted assets from OrionConfig.""" _list_whitelisted_assets_logic() contracts.py ============ .. code-block:: python """Interactions with the Orion Finance protocol contracts.""" import json import os from dataclasses import dataclass from importlib import resources from typing import Any, Iterable, cast from dotenv import load_dotenv from web3 import Web3 from web3.types import HexStr, TxReceipt from .types import CHAIN_CONFIG, ZERO_ADDRESS, VaultType from .utils import ( MAX_MANAGEMENT_FEE, MAX_PERFORMANCE_FEE, validate_var, ) load_dotenv() # Gas limit for eth_call (view) when using Hardhat fork (node cap 16M) _VIEW_CALL_GAS = 15_000_000 _VIEW_CALL_TX = {"gas": _VIEW_CALL_GAS} def _get_view_call_tx(): """Return tx dict for view calls: gas override only when ORION_FORCE_VIEW_GAS is set (e.g. fork tests).""" if os.getenv("ORION_FORCE_VIEW_GAS"): return _VIEW_CALL_TX return {} def _call_view(contract_fn): """Execute a view/pure contract call (uses gas override in fork/dev when ORION_FORCE_VIEW_GAS is set).""" return contract_fn.call(_get_view_call_tx()) @dataclass class TransactionResult: """Result of a transaction including receipt and extracted logs.""" tx_hash: str receipt: TxReceipt decoded_logs: list[dict] | None = None class SystemNotIdleError(RuntimeError): """Raised when the protocol is not idle for the requested operation.""" def load_contract_abi(contract_name: str) -> list[dict]: """Load the ABI for a given contract.""" try: # Try to load from package data (when installed from PyPI) with ( resources.files("orion_finance_sdk_py") .joinpath("abis", f"{contract_name}.json") .open() as f ): return json.load(f)["abi"] except (FileNotFoundError, AttributeError): # Fallback to local development path script_dir = os.path.dirname(os.path.abspath(__file__)) abi_path = os.path.join(script_dir, "..", "abis", f"{contract_name}.json") with open(abi_path) as f: return json.load(f)["abi"] class OrionSmartContract: """Base class for Orion smart contracts.""" def __init__(self, contract_name: str, contract_address: str): """Initialize a smart contract.""" rpc_url = os.getenv("RPC_URL") if not rpc_url: # Try loading from current directory explicitly load_dotenv(os.getcwd() + "/.env") rpc_url = os.getenv("RPC_URL") # Fork tests: load_dotenv() above can restore RPC_URL from disk; ORION_USE_APE_PROVIDER # forces Ape's Hardhat web3 before the HTTP branch (see tests/test_fork.py sepolia_fork). use_ape_provider = os.getenv("ORION_USE_APE_PROVIDER", "").strip().lower() in ( "1", "true", "yes", ) if use_ape_provider: ape_error = None try: from ape import networks if networks.active_provider: self.w3 = networks.active_provider.web3 self.chain_id = self.w3.eth.chain_id self.contract_name = contract_name self.contract_address = contract_address self.contract = self.w3.eth.contract( address=Web3.to_checksum_address(self.contract_address), abi=load_contract_abi(self.contract_name), ) return except (ImportError, AttributeError): pass except Exception as e: ape_error = e msg = ( "ORION_USE_APE_PROVIDER is set but Ape has no usable active_provider. " "Run inside an Ape network context (e.g. sepolia_fork Hardhat)." ) if ape_error is not None: msg += f" ({ape_error})" raise ValueError(msg) if rpc_url: rpc_url = validate_var( rpc_url, error_message=( "RPC_URL environment variable is missing or invalid. " "Please set RPC_URL in your .env file or as an environment variable. " ), ) self.w3 = Web3(Web3.HTTPProvider(rpc_url)) self.chain_id = self.w3.eth.chain_id env_chain_id = os.getenv("CHAIN_ID") if env_chain_id: try: env_chain_id_int = int(env_chain_id) if env_chain_id_int != self.chain_id: print( f"⚠️ Warning: CHAIN_ID in env ({env_chain_id}) does not match RPC chain ID ({self.chain_id})" ) except ValueError: print(f"⚠️ Warning: Invalid CHAIN_ID in env: {env_chain_id}") self.contract_name = contract_name self.contract_address = contract_address self.contract = self.w3.eth.contract( address=Web3.to_checksum_address(self.contract_address), abi=load_contract_abi(self.contract_name), ) return ape_error = None try: from ape import networks if networks.active_provider: self.w3 = networks.active_provider.web3 self.chain_id = self.w3.eth.chain_id self.contract_name = contract_name self.contract_address = contract_address self.contract = self.w3.eth.contract( address=Web3.to_checksum_address(self.contract_address), abi=load_contract_abi(self.contract_name), ) return except (ImportError, AttributeError): pass except Exception as e: ape_error = e msg = ( "RPC_URL environment variable is missing or invalid. " "Please set RPC_URL in your .env file or as an environment variable. " ) if ape_error is not None: msg += f" (Ape provider failed: {ape_error})" raise ValueError(msg) from ape_error raise ValueError(msg) def _wait_for_transaction_receipt( self, tx_hash: str, timeout: int = 120 ) -> TxReceipt: """Wait for a transaction to be processed and return the receipt.""" return self.w3.eth.wait_for_transaction_receipt( cast(HexStr, tx_hash), timeout=timeout ) def _decode_logs(self, receipt: TxReceipt) -> list[dict]: """Decode logs from a transaction receipt.""" decoded_logs = [] for log in receipt["logs"]: # Only process logs from this contract if log["address"].lower() != self.contract_address.lower(): continue # Try to decode the log with each event in the contract for event in cast(Iterable[Any], self.contract.events): try: decoded_log = event.process_log(log) decoded_logs.append( { "event": decoded_log.event, "args": dict(decoded_log.args), "address": decoded_log.address, "blockHash": decoded_log.blockHash.hex(), "blockNumber": decoded_log.blockNumber, "logIndex": decoded_log.logIndex, "transactionHash": decoded_log.transactionHash.hex(), "transactionIndex": decoded_log.transactionIndex, } ) break # Successfully decoded, move to next log except Exception: # This event doesn't match this log, try the next event continue return decoded_logs class OrionConfig(OrionSmartContract): """OrionConfig contract.""" def __init__(self): """Initialize the OrionConfig contract.""" # Check for manual address override first contract_address = os.getenv("ORION_CONFIG_ADDRESS") if not contract_address: # Default to Sepolia if not specified, but prefer env var chain_id = int(os.getenv("CHAIN_ID", "11155111")) if chain_id in CHAIN_CONFIG: contract_address = CHAIN_CONFIG[chain_id]["OrionConfig"] else: raise ValueError( f"Unsupported CHAIN_ID: {chain_id}. Please check CHAIN_CONFIG in types.py or set CHAIN_ID env var correctly." ) super().__init__( contract_name="OrionConfig", contract_address=contract_address, ) @property def underlying_asset(self) -> str: """Fetch the underlying asset address.""" return _call_view(self.contract.functions.underlyingAsset()) @property def strategist_intent_decimals(self) -> int: """Fetch the strategist intent decimals from the OrionConfig contract.""" return _call_view(self.contract.functions.strategistIntentDecimals()) @property def manager_intent_decimals(self) -> int: """Alias for strategist_intent_decimals.""" return self.strategist_intent_decimals def token_decimals(self, token_address: str) -> int: """Fetch the decimals of a token address.""" return _call_view(self.contract.functions.getTokenDecimals(token_address)) @property def risk_free_rate(self) -> int: """Fetch the risk free rate from the OrionConfig contract.""" return _call_view(self.contract.functions.riskFreeRate()) @property def whitelisted_assets(self) -> list[str]: """Fetch all whitelisted asset addresses from the OrionConfig contract.""" return _call_view(self.contract.functions.getAllWhitelistedAssets()) @property def whitelisted_asset_names(self) -> list[str]: """Fetch all whitelisted asset names from the OrionConfig contract.""" return _call_view(self.contract.functions.getAllWhitelistedAssetNames()) @property def get_investment_universe(self) -> list[str]: """Alias for whitelisted_assets (Investment Universe).""" return self.whitelisted_assets def is_whitelisted(self, token_address: str) -> bool: """Check if a token address is whitelisted.""" return _call_view( self.contract.functions.isWhitelisted( Web3.to_checksum_address(token_address) ) ) def is_whitelisted_manager(self, manager_address: str) -> bool: """Check if a manager address is whitelisted.""" return _call_view( self.contract.functions.isWhitelistedManager( Web3.to_checksum_address(manager_address) ) ) def is_orion_vault(self, vault_address: str) -> bool: """Check if an address is a registered Orion vault.""" return _call_view( self.contract.functions.isOrionVault( Web3.to_checksum_address(vault_address) ) ) @property def orion_transparent_vaults(self) -> list[str]: """Fetch all Orion transparent vault addresses from the OrionConfig contract.""" return _call_view(self.contract.functions.getAllOrionVaults(0)) @property def min_deposit_amount(self) -> int: """Fetch the minimum deposit amount from the OrionConfig contract.""" return _call_view(self.contract.functions.minDepositAmount()) @property def min_redeem_amount(self) -> int: """Fetch the minimum redeem amount from the OrionConfig contract.""" return _call_view(self.contract.functions.minRedeemAmount()) @property def v_fee_coefficient(self) -> int: """Fetch the volume fee coefficient from the OrionConfig contract.""" return _call_view(self.contract.functions.vFeeCoefficient()) @property def rs_fee_coefficient(self) -> int: """Fetch the revenue share fee coefficient from the OrionConfig contract.""" return _call_view(self.contract.functions.rsFeeCoefficient()) @property def fee_change_cooldown_duration(self) -> int: """Fetch the fee change cooldown duration in seconds.""" return _call_view(self.contract.functions.feeChangeCooldownDuration()) @property def max_fulfill_batch_size(self) -> int: """Fetch the maximum fulfill batch size.""" return _call_view(self.contract.functions.maxFulfillBatchSize()) def is_system_idle(self) -> bool: """Check if the system is in idle state, required for vault deployment.""" return _call_view(self.contract.functions.isSystemIdle()) class LiquidityOrchestrator(OrionSmartContract): """LiquidityOrchestrator contract.""" def __init__(self): """Initialize the LiquidityOrchestrator contract.""" config = OrionConfig() contract_address = _call_view(config.contract.functions.liquidityOrchestrator()) super().__init__( contract_name="LiquidityOrchestrator", contract_address=contract_address, ) @property def target_buffer_ratio(self) -> int: """Fetch the target buffer ratio.""" return _call_view(self.contract.functions.targetBufferRatio()) @property def slippage_tolerance(self) -> int: """Fetch the slippage tolerance.""" return _call_view(self.contract.functions.slippageTolerance()) @property def epoch_duration(self) -> int: """Fetch the epoch duration in seconds.""" return _call_view(self.contract.functions.epochDuration()) class VaultFactory(OrionSmartContract): """VaultFactory contract.""" def __init__( self, vault_type: str, contract_address: str | None = None, ): """Initialize the VaultFactory contract.""" if contract_address is None: config = OrionConfig() if vault_type == VaultType.TRANSPARENT: contract_address = _call_view( config.contract.functions.transparentVaultFactory() ) else: raise ValueError(f"Unsupported vault type: {vault_type}") super().__init__( contract_name="TransparentVaultFactory", contract_address=contract_address, ) def create_orion_vault( self, strategist_address: str, name: str, symbol: str, fee_type: int, performance_fee: int, management_fee: int, deposit_access_control: str = ZERO_ADDRESS, ) -> TransactionResult: """Create an Orion vault for a given strategist address.""" config = OrionConfig() strategist_address = validate_var( strategist_address, error_message=( "STRATEGIST_ADDRESS is invalid. " "Please provide a valid strategist address." ), ) manager_private_key = validate_var( os.getenv("MANAGER_PRIVATE_KEY"), error_message=( "MANAGER_PRIVATE_KEY environment variable is missing or invalid. " "Please set MANAGER_PRIVATE_KEY in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) account = self.w3.eth.account.from_key(manager_private_key) validate_var( account.address, error_message="Invalid MANAGER_PRIVATE_KEY.", ) if not config.is_whitelisted_manager(account.address): raise ValueError( f"Manager {account.address} is not whitelisted to create vaults. " "Please contact the Orion Finance team to get whitelisted." ) if len(name.encode("utf-8")) > 26: raise ValueError(f"Vault name '{name}' exceeds maximum length of 26 bytes.") if len(symbol.encode("utf-8")) > 4: raise ValueError( f"Vault symbol '{symbol}' exceeds maximum length of 4 bytes." ) if performance_fee > MAX_PERFORMANCE_FEE: raise ValueError( f"Performance fee {performance_fee} exceeds maximum {MAX_PERFORMANCE_FEE}" ) if management_fee > MAX_MANAGEMENT_FEE: raise ValueError( f"Management fee {management_fee} exceeds maximum {MAX_MANAGEMENT_FEE}" ) if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot deploy vault at this time." ) account = self.w3.eth.account.from_key(manager_private_key) nonce = self.w3.eth.get_transaction_count(account.address) # Estimate gas needed for the transaction gas_estimate = self.contract.functions.createVault( strategist_address, name, symbol, fee_type, performance_fee, management_fee, Web3.to_checksum_address(deposit_access_control), ).estimate_gas({"from": account.address, "nonce": nonce}) # Add 20% buffer to gas estimate gas_limit = int(gas_estimate * 1.2) gas_price = self.w3.eth.gas_price estimated_cost = gas_limit * gas_price balance = self.w3.eth.get_balance(account.address) if balance < estimated_cost: required_eth = self.w3.from_wei(estimated_cost, "ether") available_eth = self.w3.from_wei(balance, "ether") raise ValueError( f"Insufficient ETH balance. Required: {required_eth} ETH, Available: {available_eth} ETH" ) tx = self.contract.functions.createVault( strategist_address, name, symbol, fee_type, performance_fee, management_fee, Web3.to_checksum_address(deposit_access_control), ).build_transaction( { "from": account.address, "nonce": nonce, "gas": gas_limit, "gasPrice": self.w3.eth.gas_price, } ) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) tx_hash_hex = tx_hash.hex() try: receipt = self._wait_for_transaction_receipt(tx_hash_hex) except Exception as e: error_str = str(e) if "0xea8e4eb5" in error_str: raise ValueError( f"Transaction reverted: Manager {account.address} is not whitelisted to create vaults." ) raise e # Check if transaction was successful if receipt["status"] != 1: raise Exception(f"Transaction failed with status: {receipt['status']}") # Decode logs from the transaction receipt decoded_logs = self._decode_logs(receipt) return TransactionResult( tx_hash=tx_hash_hex, receipt=receipt, decoded_logs=decoded_logs ) def get_vault_address_from_result(self, result: TransactionResult) -> str | None: """Extract the vault address from OrionVaultCreated event in the transaction result.""" if not result.decoded_logs: return None for log in result.decoded_logs: if log.get("event") == "OrionVaultCreated": return log["args"].get("vault") return None class OrionVault(OrionSmartContract): """OrionVault contract.""" def __init__(self, contract_name: str): """Initialize the OrionVault contract.""" contract_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message=( "ORION_VAULT_ADDRESS environment variable is missing or invalid. " "Please set ORION_VAULT_ADDRESS in your .env file or as an environment variable. " "Please follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) # Validate that the address is a valid Orion Vault config = OrionConfig() is_transparent = config.is_orion_vault(contract_address) if not is_transparent: raise ValueError( f"The address {contract_address} is NOT a valid Orion Transparent Vault registered in the OrionConfig contract. " "Please check your ORION_VAULT_ADDRESS." ) super().__init__(contract_name, contract_address) @property def max_performance_fee(self) -> int: """Fetch the maximum performance fee allowed from the vault contract.""" return _call_view(self.contract.functions.MAX_PERFORMANCE_FEE()) @property def max_management_fee(self) -> int: """Fetch the maximum management fee allowed from the vault contract.""" return _call_view(self.contract.functions.MAX_MANAGEMENT_FEE()) @property def manager_address(self) -> str: """Fetch the manager address.""" return _call_view(self.contract.functions.manager()) @property def strategist_address(self) -> str: """Fetch the strategist address.""" return _call_view(self.contract.functions.strategist()) @property def is_decommissioning(self) -> bool: """Check if the vault is in decommissioning mode.""" return _call_view(self.contract.functions.isDecommissioning()) @property def active_fee_model(self) -> dict: """Fetch the currently active fee model (struct FeeModel).""" model = _call_view(self.contract.functions.activeFeeModel()) return { "feeType": model[0], "performanceFee": model[1], "managementFee": model[2], "highWaterMark": model[3], } def pending_deposit(self, fulfill_batch_size: int | None = None) -> int: """Get total pending deposit amount across all users.""" if fulfill_batch_size is None: config = OrionConfig() fulfill_batch_size = config.max_fulfill_batch_size return _call_view(self.contract.functions.pendingDeposit(fulfill_batch_size)) def pending_redeem(self, fulfill_batch_size: int | None = None) -> int: """Get total pending redemption shares across all users.""" if fulfill_batch_size is None: config = OrionConfig() fulfill_batch_size = config.max_fulfill_batch_size return _call_view(self.contract.functions.pendingRedeem(fulfill_batch_size)) def _execute_vault_tx( self, contract_fn_call, key_env: str = "MANAGER_PRIVATE_KEY", error_msg: str = "Private key missing for transaction.", gas_limit: int | None = None, ) -> TransactionResult: """Execute a vault transaction with the given contract function call. Args: contract_fn_call: The contract function call (e.g., self.contract.functions.requestDeposit(assets)) key_env: Environment variable name for the private key (default: "MANAGER_PRIVATE_KEY") error_msg: Error message for validation gas_limit: Optional gas limit for the transaction Returns: TransactionResult with transaction hash, receipt, and decoded logs """ private_key = validate_var(os.getenv(key_env), error_msg) account = self.w3.eth.account.from_key(private_key) nonce = self.w3.eth.get_transaction_count(account.address) tx_params = { "from": account.address, "nonce": nonce, } if gas_limit is not None: tx_params["gas"] = gas_limit tx = contract_fn_call.build_transaction(tx_params) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) receipt = self._wait_for_transaction_receipt(tx_hash.hex()) if receipt["status"] != 1: raise Exception(f"Transaction failed with status: {receipt['status']}") decoded_logs = self._decode_logs(receipt) return TransactionResult( tx_hash=tx_hash.hex(), receipt=receipt, decoded_logs=decoded_logs, ) def request_deposit(self, assets: int) -> TransactionResult: """Submit an asynchronous deposit request.""" return self._execute_vault_tx( self.contract.functions.requestDeposit(assets), error_msg="Private key missing for deposit request.", ) def cancel_deposit_request(self, amount: int) -> TransactionResult: """Cancel a previously submitted deposit request.""" return self._execute_vault_tx( self.contract.functions.cancelDepositRequest(amount), error_msg="Private key missing for cancellation.", ) def request_redeem(self, shares: int) -> TransactionResult: """Submit a redemption request.""" return self._execute_vault_tx( self.contract.functions.requestRedeem(shares), error_msg="Private key missing for redeem request.", ) def cancel_redeem_request(self, shares: int) -> TransactionResult: """Cancel a previously submitted redemption request.""" return self._execute_vault_tx( self.contract.functions.cancelRedeemRequest(shares), error_msg="Private key missing for cancellation.", ) def update_strategist(self, new_strategist_address: str) -> TransactionResult: """Update the strategist address for the vault.""" config = OrionConfig() if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot update strategist at this time." ) manager_private_key = validate_var( os.getenv("MANAGER_PRIVATE_KEY"), error_message=( "MANAGER_PRIVATE_KEY environment variable is missing or invalid. " "Please set MANAGER_PRIVATE_KEY in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) account = self.w3.eth.account.from_key(manager_private_key) # Validate that the signer is the manager if account.address != self.manager_address: raise ValueError( f"Signer {account.address} is not the vault manager {self.manager_address}. Cannot update strategist." ) nonce = self.w3.eth.get_transaction_count(account.address) tx = self.contract.functions.updateStrategist( new_strategist_address ).build_transaction({"from": account.address, "nonce": nonce}) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) tx_hash_hex = tx_hash.hex() receipt = self._wait_for_transaction_receipt(tx_hash_hex) if receipt["status"] != 1: raise Exception(f"Transaction failed with status: {receipt['status']}") decoded_logs = self._decode_logs(receipt) return TransactionResult( tx_hash=tx_hash_hex, receipt=receipt, decoded_logs=decoded_logs ) def update_fee_model( self, fee_type: int, performance_fee: int, management_fee: int ) -> TransactionResult: """Update the fee model for the vault.""" config = OrionConfig() if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot update fee model at this time." ) if performance_fee > self.max_performance_fee: raise ValueError( f"Performance fee {performance_fee} exceeds maximum {self.max_performance_fee}" ) if management_fee > self.max_management_fee: raise ValueError( f"Management fee {management_fee} exceeds maximum {self.max_management_fee}" ) manager_private_key = validate_var( os.getenv("MANAGER_PRIVATE_KEY"), error_message=( "MANAGER_PRIVATE_KEY environment variable is missing or invalid. " "Please set MANAGER_PRIVATE_KEY in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) account = self.w3.eth.account.from_key(manager_private_key) # Validate that the signer is the manager if account.address != self.manager_address: raise ValueError( f"Signer {account.address} is not the vault manager {self.manager_address}. Cannot update fee model." ) nonce = self.w3.eth.get_transaction_count(account.address) tx = self.contract.functions.updateFeeModel( fee_type, performance_fee, management_fee ).build_transaction({"from": account.address, "nonce": nonce}) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) tx_hash_hex = tx_hash.hex() receipt = self._wait_for_transaction_receipt(tx_hash_hex) if receipt["status"] != 1: raise Exception(f"Transaction failed with status: {receipt['status']}") decoded_logs = self._decode_logs(receipt) return TransactionResult( tx_hash=tx_hash_hex, receipt=receipt, decoded_logs=decoded_logs ) @property def total_assets(self) -> int: """Fetch the total assets of the vault.""" return _call_view(self.contract.functions.totalAssets()) @property def pending_vault_fees(self) -> float: """Fetch the pending vault fees in the underlying asset.""" config = OrionConfig() decimals = config.token_decimals(config.underlying_asset) return _call_view(self.contract.functions.pendingVaultFees()) / 10**decimals @property def share_price(self) -> int: """Fetch the current share price (value of 1 share unit).""" decimals = _call_view(self.contract.functions.decimals()) return _call_view(self.contract.functions.convertToAssets(10**decimals)) def convert_to_assets(self, shares: int) -> int: """Convert shares to assets.""" return _call_view(self.contract.functions.convertToAssets(shares)) def get_portfolio(self) -> dict: """Get the vault portfolio.""" tokens, values = _call_view(self.contract.functions.getPortfolio()) return dict(zip(tokens, values, strict=True)) def set_deposit_access_control( self, access_control_address: str ) -> TransactionResult: """Set the deposit access control contract address.""" config = OrionConfig() if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot set deposit access control at this time." ) manager_private_key = validate_var( os.getenv("MANAGER_PRIVATE_KEY"), error_message="MANAGER_PRIVATE_KEY environment variable is missing or invalid.", ) account = self.w3.eth.account.from_key(manager_private_key) # Validate that the signer is the manager if account.address != self.manager_address: raise ValueError( f"Signer {account.address} is not the vault manager {self.manager_address}. Cannot set deposit access control." ) nonce = self.w3.eth.get_transaction_count(account.address) tx = self.contract.functions.setDepositAccessControl( Web3.to_checksum_address(access_control_address) ).build_transaction({"from": account.address, "nonce": nonce}) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) tx_hash_hex = tx_hash.hex() receipt = self._wait_for_transaction_receipt(tx_hash_hex) return TransactionResult( tx_hash=tx_hash_hex, receipt=receipt, decoded_logs=self._decode_logs(receipt), ) def max_deposit(self, receiver: str) -> int: """Fetch the maximum deposit amount for a receiver.""" return _call_view( self.contract.functions.maxDeposit(Web3.to_checksum_address(receiver)) ) def can_request_deposit(self, user: str) -> bool: """Check if a user is allowed to request a deposit. This method queries the vault's depositAccessControl contract. If no access control is set (zero address), it returns True. """ try: access_control_address = _call_view( self.contract.functions.depositAccessControl() ) except (AttributeError, ValueError): # If function doesn't exist in ABI or call fails due to missing method return True if access_control_address == ZERO_ADDRESS: return True # Minimal ABI for IOrionAccessControl to check permissions access_control_abi = [ { "inputs": [ {"internalType": "address", "name": "sender", "type": "address"} ], "name": "canRequestDeposit", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function", } ] access_control = self.w3.eth.contract( address=access_control_address, abi=access_control_abi ) return _call_view( access_control.functions.canRequestDeposit(Web3.to_checksum_address(user)) ) class OrionTransparentVault(OrionVault): """OrionTransparentVault contract.""" def __init__(self): """Initialize the OrionTransparentVault contract.""" super().__init__("OrionTransparentVault") def transfer_manager_fees(self, amount: int) -> TransactionResult: """Transfer manager fees (claimVaultFees).""" config = OrionConfig() if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot transfer manager fees at this time." ) manager_private_key = validate_var( os.getenv("MANAGER_PRIVATE_KEY"), error_message=( "MANAGER_PRIVATE_KEY environment variable is missing or invalid. " "Please set MANAGER_PRIVATE_KEY in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) account = self.w3.eth.account.from_key(manager_private_key) # Validate that the signer is the manager if account.address != self.manager_address: raise ValueError( f"Signer {account.address} is not the vault manager {self.manager_address}. Cannot claim fees." ) nonce = self.w3.eth.get_transaction_count(account.address) tx = self.contract.functions.claimVaultFees(amount).build_transaction( {"from": account.address, "nonce": nonce} ) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) receipt = self._wait_for_transaction_receipt(tx_hash.hex()) return TransactionResult( tx_hash=tx_hash.hex(), receipt=receipt, decoded_logs=self._decode_logs(receipt), ) def submit_order_intent( self, order_intent: dict[str, int], ) -> TransactionResult: """Submit a portfolio order intent. Args: order_intent: Dictionary mapping token addresses to values Returns: TransactionResult """ config = OrionConfig() if not config.is_system_idle(): raise SystemNotIdleError( "System is not idle. Cannot submit order intent at this time." ) strategist_private_key = validate_var( os.getenv("STRATEGIST_PRIVATE_KEY"), error_message=( "STRATEGIST_PRIVATE_KEY environment variable is missing or invalid. " "Please set STRATEGIST_PRIVATE_KEY in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) account = self.w3.eth.account.from_key(strategist_private_key) # Validate that the signer is the strategist if account.address != self.strategist_address: raise ValueError( f"Signer {account.address} is not the vault strategist {self.strategist_address}. Cannot submit order." ) nonce = self.w3.eth.get_transaction_count(account.address) items = [ {"token": Web3.to_checksum_address(token), "weight": value} for token, value in order_intent.items() ] # Estimate gas needed for the transaction gas_estimate = self.contract.functions.submitIntent(items).estimate_gas( {"from": account.address, "nonce": nonce} ) # Add 20% buffer to gas estimate gas_limit = int(gas_estimate * 1.2) tx = self.contract.functions.submitIntent(items).build_transaction( { "from": account.address, "nonce": nonce, "gas": gas_limit, "gasPrice": self.w3.eth.gas_price, } ) signed = account.sign_transaction(tx) tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) tx_hash_hex = tx_hash.hex() receipt = self._wait_for_transaction_receipt(tx_hash_hex) if receipt["status"] != 1: raise Exception(f"Transaction failed with status: {receipt['status']}") decoded_logs = self._decode_logs(receipt) return TransactionResult( tx_hash=tx_hash_hex, receipt=receipt, decoded_logs=decoded_logs ) encrypt.py ========== .. code-block:: python """Encryption operations for the Orion Finance Python SDK.""" import json import os import subprocess import sys from importlib.resources import files from .utils import validate_var def encrypt_order_intent(order_intent: dict[str, int]) -> tuple[dict[str, bytes], str]: """Encrypt an order intent.""" if not check_npm_available(): print_installation_guide() sys.exit(1) curator_address = validate_var( os.getenv("CURATOR_ADDRESS"), error_message=( "CURATOR_ADDRESS environment variable is missing or invalid. " "Please set CURATOR_ADDRESS in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) vault_address = validate_var( os.getenv("ORION_VAULT_ADDRESS"), error_message=( "ORION_VAULT_ADDRESS environment variable is missing or invalid. " "Please set ORION_VAULT_ADDRESS in your .env file or as an environment variable. " "Follow the SDK Installation instructions to get one: https://sdk.orionfinance.ai/" ), ) tokens = [token for token in order_intent.keys()] values = [value for value in order_intent.values()] payload = { "vaultAddress": vault_address, "curatorAddress": curator_address, "values": values, } js_entry = files("orion_finance_sdk_py.js_sdk").joinpath("bundle.js") result = subprocess.run( ["node", str(js_entry)], input=json.dumps(payload), capture_output=True, text=True, ) if result.returncode != 0: raise RuntimeError(f"Encryption failed: {result.stderr}") data = json.loads(result.stdout) encrypted_values = data["encryptedValues"] encrypted_intent = dict(zip(tokens, encrypted_values)) input_proof = data["inputProof"] return encrypted_intent, input_proof def print_installation_guide(): """Print installation guide for npm.""" print("=" * 80) print("ERROR: Curation of Encrypted Vaults requires npm to be installed.") print("=" * 80) print() print("npm is not available on your system.") print("Please install Node.js and npm first:") print() print(" Visit: https://nodejs.org/") print(" OR use a package manager:") print(" macOS: brew install node") print(" Ubuntu/Debian: sudo apt install nodejs npm") print(" Windows: Download from https://nodejs.org/") print() print("=" * 80) def check_npm_available() -> bool: """Check if npm is available on the system.""" try: result = subprocess.run( ["npm", "--version"], capture_output=True, text=True, check=False, ) return result.returncode == 0 except (subprocess.SubprocessError, FileNotFoundError): return False order_intent_io.py ================== .. code-block:: python """Load order intents from JSON, CSV, Parquet, inline strings, or dicts. No third-party I/O helpers (e.g. readwrite): JSON/CSV use the stdlib; Parquet uses optional ``pyarrow`` (``pip install 'orion-finance-sdk-py[parquet]'``). """ from __future__ import annotations import ast import csv import json import os from pathlib import Path from typing import Any # Tabular: token column aliases (case-insensitive) _ADDRESS_KEYS = frozenset({"address", "token", "addr"}) # Value columns: percentage_of_tvl is documented as 0–100 _PERCENT_KEYS = frozenset({"percentage_of_tvl", "percentage", "pct", "percent"}) _WEIGHT_KEYS = frozenset({"weight", "value", "allocation"}) def _strip_bom(text: str) -> str: if text.startswith("\ufeff"): return text[1:] return text def _normalize_header(name: str) -> str: return name.strip().lower().replace(" ", "_") def _pick_tabular_columns( headers: list[str], ) -> tuple[str, str, str]: """Pick (raw_addr_header, raw_value_header, mode). mode is ``percent`` (0–100) or ``weight`` (infer fraction vs percent from data). """ norm = {_normalize_header(h): h for h in headers} addr_h = None for key in _ADDRESS_KEYS: if key in norm: addr_h = norm[key] break if addr_h is None: raise ValueError( "CSV/Parquet must include an address column " f"(one of: {', '.join(sorted(_ADDRESS_KEYS))}). " f"Found columns: {headers!r}" ) val_h = None mode = "weight" for key in _PERCENT_KEYS: if key in norm: val_h = norm[key] mode = "percent" break if val_h is None: for key in _WEIGHT_KEYS: if key in norm: val_h = norm[key] mode = "weight" break if val_h is None: raise ValueError( "CSV/Parquet must include a weight column " f"(one of: {', '.join(sorted(_PERCENT_KEYS | _WEIGHT_KEYS))}). " f"Found columns: {headers!r}" ) return addr_h, val_h, mode def _weights_from_percent_column(raw: dict[str, float]) -> dict[str, float]: return {k: float(v) / 100.0 for k, v in raw.items()} def _weights_from_weight_column(raw: dict[str, float]) -> dict[str, float]: values = list(raw.values()) if not values: raise ValueError("Order intent has no rows") s = sum(values) if s <= 0: raise ValueError("Sum of weights must be positive") # Documented table uses 0–100; if values look like fractions already, leave them if max(values) <= 1.0 + 1e-9 and abs(s - 1.0) <= 0.02: return {k: float(v) for k, v in raw.items()} if 99.0 <= s <= 101.0 or max(values) > 1.0: return {k: float(v) / 100.0 for k, v in raw.items()} raise ValueError( "Could not interpret weight column: values should sum to ~1 (fractions) " "or ~100 (percentages). " f"Got sum={s!r}, max={max(values)!r}" ) def _tabular_rows_to_dict( rows: list[dict[str, str]], headers: list[str] ) -> dict[str, float]: addr_h, val_h, mode = _pick_tabular_columns(headers) out: dict[str, float] = {} for row in rows: addr = (row.get(addr_h) or "").strip() if not addr: continue cell = (row.get(val_h) or "").strip() try: val = float(cell) except ValueError as e: raise ValueError(f"Invalid numeric weight for {addr!r}: {cell!r}") from e if addr in out: raise ValueError(f"Duplicate address in table: {addr!r}") out[addr] = val if mode == "percent": return _weights_from_percent_column(out) if mode == "weight": return _weights_from_weight_column(out) raise RuntimeError(f"unknown mode {mode!r}") def _load_json_path(path: Path) -> Any: with path.open(encoding="utf-8") as f: return json.load(f) def _load_csv_path(path: Path) -> dict[str, float]: with path.open(newline="", encoding="utf-8") as f: reader = csv.DictReader(f) if reader.fieldnames is None: raise ValueError("CSV has no header row") headers = list(reader.fieldnames) rows = [dict(row) for row in reader] return _tabular_rows_to_dict(rows, headers) def _load_parquet_path(path: Path) -> dict[str, float]: try: import pyarrow.parquet as pq except ImportError as e: raise ImportError( "Reading Parquet requires pyarrow. Install with: " "pip install 'orion-finance-sdk-py[parquet]'" ) from e table = pq.read_table(path) names = table.column_names headers = list(names) rows: list[dict[str, str]] = [] n = table.num_rows for i in range(n): row: dict[str, str] = {} for name in names: col = table.column(name) v = col[i].as_py() if v is None: row[name] = "" else: row[name] = str(v) rows.append(row) return _tabular_rows_to_dict(rows, headers) def _coerce_mapping(data: Any) -> dict[str, float]: if isinstance(data, list): # List of {address, percentage_of_tvl} objects if not data: raise ValueError("Order intent list is empty") if isinstance(data[0], dict): rows = [] headers: set[str] = set() for item in data: if not isinstance(item, dict): raise ValueError( "List items must be objects with address/weight fields" ) rows.append({str(k): str(v) for k, v in item.items()}) headers.update(item.keys()) return _tabular_rows_to_dict(rows, sorted(headers)) raise ValueError("Unsupported JSON list format for order intent") if not isinstance(data, dict): raise ValueError( f"Order intent must be a JSON object mapping addresses to weights, " f"got {type(data).__name__}" ) # Strict: all keys str-like, all values numeric out: dict[str, float] = {} for k, v in data.items(): key = str(k).strip() if not key: continue try: out[key] = float(v) except (TypeError, ValueError) as e: raise ValueError(f"Non-numeric weight for {key!r}: {v!r}") from e if not out: raise ValueError("Order intent object is empty") return out def _load_path(path: Path) -> dict[str, float]: suffix = path.suffix.lower() if suffix == ".json": data = _load_json_path(path) return _coerce_mapping(data) if suffix == ".csv": return _load_csv_path(path) if suffix in (".parquet", ".pq"): return _load_parquet_path(path) raise ValueError( f"Unsupported file type {suffix!r} for order intent " "(supported: .json, .csv, .parquet, .pq)" ) def _parse_inline_object(text: str) -> dict[str, float]: t = text.strip() if not t: raise ValueError("Order intent string is empty") try: data = json.loads(t) except json.JSONDecodeError: try: data = ast.literal_eval(t) except (ValueError, SyntaxError) as e: raise ValueError( "Could not parse order intent as JSON object or Python literal dict" ) from e return _coerce_mapping(data) def _looks_like_inline_dict(text: str) -> bool: t = _strip_bom(text.strip()) return bool(t) and t[0] in "{[" def load_order_intent( source: str | dict[str, Any] | os.PathLike[str], ) -> dict[str, float]: """Load an order intent as ``address -> weight`` fractions (summing to ~1). * **dict** — must map token address strings to numeric weights (same as JSON object). * **path** — existing file: ``.json`` (object), ``.csv`` / ``.parquet`` (tabular), see docs for column names. * **str** — if it is not an existing file path, parsed as inline JSON object or Python ``dict`` literal (e.g. ``'{"0x...": 0.5, ...}'``). Args: source: File path, inline JSON/dict string, or mapping. Returns: Mapping of checksummable address strings to float weights (before :func:`validate_order` scaling). Raises: ValueError: Invalid shape, missing columns, or parse error. ImportError: Parquet requested without ``pyarrow`` installed. """ if isinstance(source, dict): return _coerce_mapping(source) if isinstance(source, (str, os.PathLike)): raw = os.fspath(source) raw = _strip_bom(raw.strip()) if not raw: raise ValueError("Order intent source is empty") expanded = os.path.expanduser(raw) p = Path(expanded) if p.is_file(): return _load_path(p.resolve()) if _looks_like_inline_dict(raw): return _parse_inline_object(raw) raise ValueError( f"Order intent is not an existing file and not a JSON object string: {raw!r}" ) raise TypeError(f"Unsupported order intent source type: {type(source).__name__}") types.py ======== .. code-block:: python """Type definitions for the Orion Finance Python SDK.""" from enum import Enum ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" # Configuration for supported chains CHAIN_CONFIG = { 11155111: { # Sepolia "OrionConfig": "0xbDe3025d08681a02a1c6cf70375baBe2152DD06f", "Explorer": "https://sepolia.etherscan.io", } } class VaultType(str, Enum): """Type of the vault.""" TRANSPARENT = "transparent" ENCRYPTED = "encrypted" class FeeType(str, Enum): """Type of the fee.""" ABSOLUTE = "absolute" # Fee based on the latest return, no hurdles or high water mark (HWM) SOFT_HURDLE = "soft_hurdle" # Fee unlocked after hurdle rate is reached HARD_HURDLE = "hard_hurdle" # Fee only above a fixed hurdle rate HIGH_WATER_MARK = "high_water_mark" # Fee only on gains above the previous peak HURDLE_HWM = "hurdle_hwm" # Combination of (hard) hurdle rate and HWM fee_type_to_int = { "absolute": 0, "soft_hurdle": 1, "hard_hurdle": 2, "high_water_mark": 3, "hurdle_hwm": 4, } utils.py ======== .. code-block:: python """Utility functions for the Orion Finance Python SDK.""" import os import random import uuid from collections.abc import Mapping from pathlib import Path import numpy as np from rich.console import Console from .types import CHAIN_CONFIG, ZERO_ADDRESS random.seed(uuid.uuid4().int) # uuid-based random seed for irreproducibility. # Validation constants matching smart contract requirements MAX_PERFORMANCE_FEE = 3000 # 30% in basis points MAX_MANAGEMENT_FEE = 300 # 3% in basis points BASIS_POINTS_FACTOR = 100 # 100 to convert percentage to basis points def ensure_env_file(env_file_path: Path = Path.cwd() / ".env"): """Check if .env file exists in the directory, create it with template if not. Args: env_file_path: Path to the .env file """ if not env_file_path.exists(): # Create .env file with template env_template = """# Orion Finance SDK Environment Variables # RPC URL for blockchain connection RPC_URL= # Private key for manager operations MANAGER_PRIVATE_KEY= # Private key for strategist operations STRATEGIST_PRIVATE_KEY= # Vault address # ORION_VAULT_ADDRESS= """ try: with open(env_file_path, "w") as f: f.write(env_template) print(f"✅ Created .env file at {env_file_path}") print( "📝 Please update the .env file with your actual configuration values" ) except Exception: pass def validate_var(var: str | None, error_message: str) -> str: """Validate that the environment variable is not None or zero; return the value.""" if not var or var == ZERO_ADDRESS: raise ValueError(error_message) return var def validate_performance_fee(performance_fee: int) -> None: """Validate that the performance fee is within acceptable bounds.""" if performance_fee > MAX_PERFORMANCE_FEE: raise ValueError( f"Performance fee {performance_fee} basis points exceeds maximum allowed value of {MAX_PERFORMANCE_FEE}" ) def validate_management_fee(management_fee: int) -> None: """Validate that the management fee is within acceptable bounds.""" if management_fee > MAX_MANAGEMENT_FEE: raise ValueError( f"Management fee {management_fee} basis points exceeds maximum allowed value of {MAX_MANAGEMENT_FEE}" ) def validate_order( order_intent: Mapping[str, int | float], ) -> dict[str, int]: """Validate an order intent (fractional weights per token, summing to ~1).""" from .contracts import OrionConfig orion_config = OrionConfig() # Validate all tokens are whitelisted for token_address in order_intent.keys(): if not orion_config.is_whitelisted(token_address): raise ValueError(f"Token {token_address} is not whitelisted") # Validate all amounts are positive if any(weight <= 0 for weight in order_intent.values()): raise ValueError("All amounts must be positive") # Validate the sum of amounts is approximately 1 (within tolerance for floating point error) TOLERANCE = 1e-10 if not np.isclose(sum(order_intent.values()), 1, atol=TOLERANCE): raise ValueError( "The sum of amounts is not 1 (within floating point tolerance)." ) strategist_intent_decimals = orion_config.strategist_intent_decimals order_intent = { token: weight * 10**strategist_intent_decimals for token, weight in order_intent.items() } rounded_values = round_with_fixed_sum( list(order_intent.values()), 10**strategist_intent_decimals ) order_intent = dict(zip(order_intent.keys(), rounded_values)) return order_intent def round_with_fixed_sum( values: list[float], target_sum: int | None = None ) -> list[int]: """Round a list of values to a fixed sum.""" arr = np.asarray(values, dtype=np.float64) if target_sum is None: target_sum = int(round(np.sum(arr))) floored = np.floor(arr).astype(int) remainder = int(round(target_sum - np.sum(floored))) # Get the fractional parts and their indices fractional_parts = arr - floored indices = np.argsort(-fractional_parts) # Descending order # Allocate the remaining units result = floored.copy() result[indices[:remainder]] += 1 return result.tolist() def format_transaction_logs( tx_result, success_message: str = "Transaction completed successfully!" ): """Format transaction logs in a human-readable way. Args: tx_result: Transaction result object with tx_hash and decoded_logs attributes success_message: Custom success message to display at the end """ console = Console() # Get chain ID and explorer URL chain_id = int(os.getenv("CHAIN_ID", "11155111")) explorer_url = "https://sepolia.etherscan.io" # Default fallback if chain_id in CHAIN_CONFIG and "Explorer" in CHAIN_CONFIG[chain_id]: explorer_url = CHAIN_CONFIG[chain_id]["Explorer"] # Normalize tx hash (ensure 0x prefix) tx_hash = tx_result.tx_hash if not tx_hash.startswith("0x"): tx_hash = f"0x{tx_hash}" # Print success message and link immediately to console console.print(f"\n[bold green]✅ {success_message}[/bold green]") console.print(f"🔗 {explorer_url}/tx/{tx_hash}\n")