From 5ca470f7c96c9103fbb09fad013f8d54aa997f29 Mon Sep 17 00:00:00 2001 From: NickNut <68846529+Se76@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:32:49 +0200 Subject: [PATCH 1/3] feat: enable phoenix perps --- container/Dockerfile | 35 +- container/agent-runner/src/index.ts | 14 + container/skills/phoenix-perps/SKILL.md | 257 + container/vendor/.gitignore | 1 + container/vendor/rise/.gitignore | 7 + container/vendor/rise/AGENTS.md | 210 + container/vendor/rise/CLAUDE.md | 1 + container/vendor/rise/README.md | 247 + container/vendor/rise/lefthook.yml | 13 + container/vendor/rise/rust/Cargo.lock | 4518 ++++++++++++ container/vendor/rise/rust/Cargo.toml | 47 + container/vendor/rise/rust/cli/Cargo.toml | 16 + .../rust/cli/scripts/smoke_http_client.sh | 146 + container/vendor/rise/rust/cli/src/main.rs | 483 ++ container/vendor/rise/rust/ix/Cargo.toml | 13 + .../vendor/rise/rust/ix/src/cancel_orders.rs | 346 + .../rise/rust/ix/src/cancel_stop_loss.rs | 242 + .../vendor/rise/rust/ix/src/constants.rs | 323 + .../vendor/rise/rust/ix/src/create_ata.rs | 86 + .../vendor/rise/rust/ix/src/deposit_funds.rs | 420 ++ .../vendor/rise/rust/ix/src/ember_deposit.rs | 335 + .../vendor/rise/rust/ix/src/ember_withdraw.rs | 375 + container/vendor/rise/rust/ix/src/error.rs | 49 + container/vendor/rise/rust/ix/src/lib.rs | 79 + .../vendor/rise/rust/ix/src/limit_order.rs | 448 ++ .../vendor/rise/rust/ix/src/market_order.rs | 491 ++ .../rise/rust/ix/src/multi_limit_order.rs | 370 + .../vendor/rise/rust/ix/src/order_packet.rs | 279 + .../rise/rust/ix/src/register_trader.rs | 391 + .../vendor/rise/rust/ix/src/spl_approve.rs | 211 + .../vendor/rise/rust/ix/src/stop_loss.rs | 463 ++ .../rise/rust/ix/src/sync_parent_to_child.rs | 296 + .../rise/rust/ix/src/transfer_collateral.rs | 694 ++ container/vendor/rise/rust/ix/src/types.rs | 190 + .../vendor/rise/rust/ix/src/withdraw_funds.rs | 459 ++ container/vendor/rise/rust/math/Cargo.toml | 28 + .../vendor/rise/rust/math/src/direction.rs | 63 + container/vendor/rise/rust/math/src/errors.rs | 53 + container/vendor/rise/rust/math/src/fixed.rs | 188 + .../vendor/rise/rust/math/src/funding.rs | 172 + .../rise/rust/math/src/leverage_tiers.rs | 323 + container/vendor/rise/rust/math/src/lib.rs | 39 + .../rise/rust/math/src/limit_order_state.rs | 46 + container/vendor/rise/rust/math/src/margin.rs | 587 ++ .../vendor/rise/rust/math/src/margin_calc.rs | 446 ++ .../vendor/rise/rust/math/src/market_math.rs | 616 ++ .../rise/rust/math/src/perp_metadata.rs | 273 + .../vendor/rise/rust/math/src/portfolio.rs | 273 + container/vendor/rise/rust/math/src/price.rs | 361 + .../rise/rust/math/src/quantities/errors.rs | 70 + .../rise/rust/math/src/quantities/macros.rs | 1377 ++++ .../rise/rust/math/src/quantities/mod.rs | 668 ++ .../rise/rust/math/src/quantities/traits.rs | 85 + .../rise/rust/math/src/quantities/types.rs | 603 ++ container/vendor/rise/rust/math/src/risk.rs | 205 + .../rise/rust/math/src/trader_position.rs | 80 + container/vendor/rise/rust/sdk/AGENTS.md | 122 + container/vendor/rise/rust/sdk/Cargo.toml | 44 + .../rise/rust/sdk/examples/cancel_order.rs | 91 + .../rust/sdk/examples/cancel_stop_loss.rs | 86 + .../sdk/examples/compute_trader_margin.rs | 329 + .../rise/rust/sdk/examples/deposit_funds.rs | 138 + .../rise/rust/sdk/examples/http_client.rs | 365 + .../rust/sdk/examples/isolated_limit_order.rs | 196 + .../examples/isolated_market_order_client.rs | 434 ++ .../examples/isolated_market_order_server.rs | 133 + .../rise/rust/sdk/examples/market_maker.rs | 343 + .../rise/rust/sdk/examples/phoenix_client.rs | 131 + .../rise/rust/sdk/examples/register_trader.rs | 28 + .../rust/sdk/examples/send_limit_order.rs | 98 + .../rust/sdk/examples/send_market_order.rs | 133 + .../rust/sdk/examples/subscribe_candles.rs | 68 + .../rust/sdk/examples/subscribe_l2_book.rs | 104 + .../sdk/examples/subscribe_market_stats.rs | 97 + .../sdk/examples/subscribe_trader_state.rs | 90 + .../rust/sdk/examples/subscribe_trades.rs | 55 + .../rise/rust/sdk/examples/ws_debug_cli.rs | 450 ++ .../rust/sdk/examples/ws_debug_config.toml | 31 + .../vendor/rise/rust/sdk/src/api/candles.rs | 43 + .../rise/rust/sdk/src/api/collateral.rs | 74 + .../vendor/rise/rust/sdk/src/api/exchange.rs | 51 + .../vendor/rise/rust/sdk/src/api/funding.rs | 77 + .../vendor/rise/rust/sdk/src/api/invite.rs | 74 + .../vendor/rise/rust/sdk/src/api/markets.rs | 52 + container/vendor/rise/rust/sdk/src/api/mod.rs | 19 + .../vendor/rise/rust/sdk/src/api/orders.rs | 226 + .../vendor/rise/rust/sdk/src/api/traders.rs | 81 + .../vendor/rise/rust/sdk/src/api/trades.rs | 68 + container/vendor/rise/rust/sdk/src/client.rs | 990 +++ container/vendor/rise/rust/sdk/src/env.rs | 166 + .../vendor/rise/rust/sdk/src/http_client.rs | 535 ++ container/vendor/rise/rust/sdk/src/lib.rs | 86 + .../vendor/rise/rust/sdk/src/tx_builder.rs | 1237 ++++ .../vendor/rise/rust/sdk/src/ws_client.rs | 804 +++ .../rise/rust/sdk/tests/trader_state_tests.rs | 457 ++ container/vendor/rise/rust/types/Cargo.toml | 22 + .../vendor/rise/rust/types/src/candles.rs | 196 + .../vendor/rise/rust/types/src/client.rs | 224 + .../vendor/rise/rust/types/src/conversions.rs | 80 + container/vendor/rise/rust/types/src/core.rs | 66 + .../vendor/rise/rust/types/src/exchange.rs | 271 + .../vendor/rise/rust/types/src/http_error.rs | 33 + container/vendor/rise/rust/types/src/ix.rs | 115 + .../rise/rust/types/src/js_safe_ints.rs | 124 + .../vendor/rise/rust/types/src/l2book.rs | 241 + container/vendor/rise/rust/types/src/lib.rs | 69 + .../vendor/rise/rust/types/src/market.rs | 327 + .../rise/rust/types/src/market_state.rs | 212 + .../rise/rust/types/src/market_stats.rs | 131 + .../vendor/rise/rust/types/src/metadata.rs | 190 + .../rise/rust/types/src/subscription_key.rs | 106 + .../vendor/rise/rust/types/src/trader.rs | 377 + .../vendor/rise/rust/types/src/trader_http.rs | 663 ++ .../vendor/rise/rust/types/src/trader_key.rs | 106 + .../rise/rust/types/src/trader_state.rs | 433 ++ .../vendor/rise/rust/types/src/trades.rs | 206 + container/vendor/rise/rust/types/src/ws.rs | 300 + .../vendor/rise/rust/types/src/ws_error.rs | 47 + container/vendor/rise/rustfmt.toml | 12 + .../rise/scripts/publish_repo_snapshot.py | 285 + container/vendor/vulcan/.config/nextest.toml | 6 + .../actions/cargo-binstall/action.yaml | 44 + .../.github/actions/rust-cache/action.yaml | 36 + .../actions/setup-r2-credentials/action.yml | 18 + .../vendor/vulcan/.github/dependabot.yml | 19 + container/vendor/vulcan/.github/release.yml | 11 + .../vulcan/.github/workflows/build.yaml | 107 + .../vulcan/.github/workflows/cross-build.yaml | 150 + .../vulcan/.github/workflows/publish.yaml | 118 + container/vendor/vulcan/.gitignore | 25 + container/vendor/vulcan/AGENTS.md | 246 + container/vendor/vulcan/CLAUDE.md | 231 + container/vendor/vulcan/CONTEXT.md | 143 + container/vendor/vulcan/Cargo.lock | 6410 +++++++++++++++++ container/vendor/vulcan/Cargo.toml | 67 + container/vendor/vulcan/README.md | 178 + container/vendor/vulcan/agents/README.md | 20 + .../vendor/vulcan/agents/error-catalog.json | 316 + container/vendor/vulcan/agents/system.md | 139 + .../vendor/vulcan/agents/tool-catalog.json | 511 ++ .../vulcan/agents/workflows/onboarding.md | 95 + .../vulcan/agents/workflows/portfolio.md | 74 + .../vendor/vulcan/agents/workflows/risk.md | 54 + .../vendor/vulcan/agents/workflows/trade.md | 106 + container/vendor/vulcan/deny.toml | 23 + container/vendor/vulcan/flake.nix | 159 + container/vendor/vulcan/rust-toolchain.toml | 2 + container/vendor/vulcan/skills/INDEX.md | 64 + .../skills/recipe-close-and-withdraw/SKILL.md | 66 + .../skills/recipe-emergency-flatten/SKILL.md | 52 + .../recipe-funding-rate-harvest/SKILL.md | 69 + .../recipe-morning-portfolio-check/SKILL.md | 55 + .../recipe-open-hedged-position/SKILL.md | 72 + .../recipe-scale-into-position/SKILL.md | 64 + .../skills/vulcan-error-recovery/SKILL.md | 94 + .../skills/vulcan-grid-trading/SKILL.md | 196 + .../vulcan-lot-size-calculator/SKILL.md | 94 + .../skills/vulcan-margin-operations/SKILL.md | 90 + .../skills/vulcan-market-intel/SKILL.md | 76 + .../vulcan/skills/vulcan-onboarding/SKILL.md | 98 + .../skills/vulcan-portfolio-intel/SKILL.md | 75 + .../vulcan-position-management/SKILL.md | 102 + .../skills/vulcan-risk-management/SKILL.md | 94 + .../vulcan/skills/vulcan-shared/SKILL.md | 75 + .../skills/vulcan-tpsl-management/SKILL.md | 92 + .../skills/vulcan-trade-execution/SKILL.md | 143 + .../skills/vulcan-twap-execution/SKILL.md | 155 + container/vendor/vulcan/vulcan-lib/Cargo.toml | 67 + .../vulcan/vulcan-lib/src/cli/account.rs | 30 + .../vulcan/vulcan-lib/src/cli/history.rs | 53 + .../vulcan/vulcan-lib/src/cli/margin.rs | 61 + .../vulcan/vulcan-lib/src/cli/market.rs | 65 + .../vendor/vulcan/vulcan-lib/src/cli/mod.rs | 114 + .../vulcan/vulcan-lib/src/cli/position.rs | 41 + .../vendor/vulcan/vulcan-lib/src/cli/trade.rs | 147 + .../vulcan/vulcan-lib/src/cli/wallet.rs | 70 + .../vulcan/vulcan-lib/src/commands/account.rs | 403 ++ .../vulcan/vulcan-lib/src/commands/margin.rs | 559 ++ .../vulcan/vulcan-lib/src/commands/market.rs | 597 ++ .../vulcan/vulcan-lib/src/commands/mod.rs | 13 + .../vulcan-lib/src/commands/position.rs | 746 ++ .../vulcan/vulcan-lib/src/commands/setup.rs | 377 + .../vulcan/vulcan-lib/src/commands/status.rs | 245 + .../vulcan/vulcan-lib/src/commands/trade.rs | 1395 ++++ .../vulcan/vulcan-lib/src/commands/wallet.rs | 466 ++ .../vulcan/vulcan-lib/src/config/config.rs | 136 + .../vulcan/vulcan-lib/src/config/mod.rs | 6 + .../vendor/vulcan/vulcan-lib/src/context.rs | 101 + .../vulcan-lib/src/crypto/encryption.rs | 175 + .../vulcan/vulcan-lib/src/crypto/mod.rs | 8 + .../vendor/vulcan/vulcan-lib/src/error.rs | 170 + container/vendor/vulcan/vulcan-lib/src/lib.rs | 15 + .../vendor/vulcan/vulcan-lib/src/mcp/mod.rs | 5 + .../vulcan/vulcan-lib/src/mcp/registry.rs | 641 ++ .../vulcan/vulcan-lib/src/mcp/server.rs | 763 ++ .../vulcan-lib/src/mcp/session_wallet.rs | 59 + .../vulcan/vulcan-lib/src/output/json.rs | 56 + .../vulcan/vulcan-lib/src/output/mod.rs | 54 + .../vulcan/vulcan-lib/src/output/table.rs | 14 + .../vulcan/vulcan-lib/src/wallet/keypair.rs | 203 + .../vulcan/vulcan-lib/src/wallet/mod.rs | 10 + .../vulcan/vulcan-lib/src/wallet/store.rs | 114 + .../vendor/vulcan/vulcan-lib/src/watch.rs | 98 + container/vendor/vulcan/vulcan/Cargo.toml | 20 + container/vendor/vulcan/vulcan/src/main.rs | 150 + 205 files changed, 51927 insertions(+), 7 deletions(-) create mode 100644 container/skills/phoenix-perps/SKILL.md create mode 100644 container/vendor/.gitignore create mode 100644 container/vendor/rise/.gitignore create mode 100644 container/vendor/rise/AGENTS.md create mode 100644 container/vendor/rise/CLAUDE.md create mode 100644 container/vendor/rise/README.md create mode 100644 container/vendor/rise/lefthook.yml create mode 100644 container/vendor/rise/rust/Cargo.lock create mode 100644 container/vendor/rise/rust/Cargo.toml create mode 100644 container/vendor/rise/rust/cli/Cargo.toml create mode 100755 container/vendor/rise/rust/cli/scripts/smoke_http_client.sh create mode 100644 container/vendor/rise/rust/cli/src/main.rs create mode 100644 container/vendor/rise/rust/ix/Cargo.toml create mode 100644 container/vendor/rise/rust/ix/src/cancel_orders.rs create mode 100644 container/vendor/rise/rust/ix/src/cancel_stop_loss.rs create mode 100644 container/vendor/rise/rust/ix/src/constants.rs create mode 100644 container/vendor/rise/rust/ix/src/create_ata.rs create mode 100644 container/vendor/rise/rust/ix/src/deposit_funds.rs create mode 100644 container/vendor/rise/rust/ix/src/ember_deposit.rs create mode 100644 container/vendor/rise/rust/ix/src/ember_withdraw.rs create mode 100644 container/vendor/rise/rust/ix/src/error.rs create mode 100644 container/vendor/rise/rust/ix/src/lib.rs create mode 100644 container/vendor/rise/rust/ix/src/limit_order.rs create mode 100644 container/vendor/rise/rust/ix/src/market_order.rs create mode 100644 container/vendor/rise/rust/ix/src/multi_limit_order.rs create mode 100644 container/vendor/rise/rust/ix/src/order_packet.rs create mode 100644 container/vendor/rise/rust/ix/src/register_trader.rs create mode 100644 container/vendor/rise/rust/ix/src/spl_approve.rs create mode 100644 container/vendor/rise/rust/ix/src/stop_loss.rs create mode 100644 container/vendor/rise/rust/ix/src/sync_parent_to_child.rs create mode 100644 container/vendor/rise/rust/ix/src/transfer_collateral.rs create mode 100644 container/vendor/rise/rust/ix/src/types.rs create mode 100644 container/vendor/rise/rust/ix/src/withdraw_funds.rs create mode 100644 container/vendor/rise/rust/math/Cargo.toml create mode 100644 container/vendor/rise/rust/math/src/direction.rs create mode 100644 container/vendor/rise/rust/math/src/errors.rs create mode 100644 container/vendor/rise/rust/math/src/fixed.rs create mode 100644 container/vendor/rise/rust/math/src/funding.rs create mode 100644 container/vendor/rise/rust/math/src/leverage_tiers.rs create mode 100644 container/vendor/rise/rust/math/src/lib.rs create mode 100644 container/vendor/rise/rust/math/src/limit_order_state.rs create mode 100644 container/vendor/rise/rust/math/src/margin.rs create mode 100644 container/vendor/rise/rust/math/src/margin_calc.rs create mode 100644 container/vendor/rise/rust/math/src/market_math.rs create mode 100644 container/vendor/rise/rust/math/src/perp_metadata.rs create mode 100644 container/vendor/rise/rust/math/src/portfolio.rs create mode 100644 container/vendor/rise/rust/math/src/price.rs create mode 100644 container/vendor/rise/rust/math/src/quantities/errors.rs create mode 100644 container/vendor/rise/rust/math/src/quantities/macros.rs create mode 100644 container/vendor/rise/rust/math/src/quantities/mod.rs create mode 100644 container/vendor/rise/rust/math/src/quantities/traits.rs create mode 100644 container/vendor/rise/rust/math/src/quantities/types.rs create mode 100644 container/vendor/rise/rust/math/src/risk.rs create mode 100644 container/vendor/rise/rust/math/src/trader_position.rs create mode 100644 container/vendor/rise/rust/sdk/AGENTS.md create mode 100644 container/vendor/rise/rust/sdk/Cargo.toml create mode 100644 container/vendor/rise/rust/sdk/examples/cancel_order.rs create mode 100644 container/vendor/rise/rust/sdk/examples/cancel_stop_loss.rs create mode 100644 container/vendor/rise/rust/sdk/examples/compute_trader_margin.rs create mode 100644 container/vendor/rise/rust/sdk/examples/deposit_funds.rs create mode 100644 container/vendor/rise/rust/sdk/examples/http_client.rs create mode 100644 container/vendor/rise/rust/sdk/examples/isolated_limit_order.rs create mode 100644 container/vendor/rise/rust/sdk/examples/isolated_market_order_client.rs create mode 100644 container/vendor/rise/rust/sdk/examples/isolated_market_order_server.rs create mode 100644 container/vendor/rise/rust/sdk/examples/market_maker.rs create mode 100644 container/vendor/rise/rust/sdk/examples/phoenix_client.rs create mode 100644 container/vendor/rise/rust/sdk/examples/register_trader.rs create mode 100644 container/vendor/rise/rust/sdk/examples/send_limit_order.rs create mode 100644 container/vendor/rise/rust/sdk/examples/send_market_order.rs create mode 100644 container/vendor/rise/rust/sdk/examples/subscribe_candles.rs create mode 100644 container/vendor/rise/rust/sdk/examples/subscribe_l2_book.rs create mode 100644 container/vendor/rise/rust/sdk/examples/subscribe_market_stats.rs create mode 100644 container/vendor/rise/rust/sdk/examples/subscribe_trader_state.rs create mode 100644 container/vendor/rise/rust/sdk/examples/subscribe_trades.rs create mode 100644 container/vendor/rise/rust/sdk/examples/ws_debug_cli.rs create mode 100644 container/vendor/rise/rust/sdk/examples/ws_debug_config.toml create mode 100644 container/vendor/rise/rust/sdk/src/api/candles.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/collateral.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/exchange.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/funding.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/invite.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/markets.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/mod.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/orders.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/traders.rs create mode 100644 container/vendor/rise/rust/sdk/src/api/trades.rs create mode 100644 container/vendor/rise/rust/sdk/src/client.rs create mode 100644 container/vendor/rise/rust/sdk/src/env.rs create mode 100644 container/vendor/rise/rust/sdk/src/http_client.rs create mode 100644 container/vendor/rise/rust/sdk/src/lib.rs create mode 100644 container/vendor/rise/rust/sdk/src/tx_builder.rs create mode 100644 container/vendor/rise/rust/sdk/src/ws_client.rs create mode 100644 container/vendor/rise/rust/sdk/tests/trader_state_tests.rs create mode 100644 container/vendor/rise/rust/types/Cargo.toml create mode 100644 container/vendor/rise/rust/types/src/candles.rs create mode 100644 container/vendor/rise/rust/types/src/client.rs create mode 100644 container/vendor/rise/rust/types/src/conversions.rs create mode 100644 container/vendor/rise/rust/types/src/core.rs create mode 100644 container/vendor/rise/rust/types/src/exchange.rs create mode 100644 container/vendor/rise/rust/types/src/http_error.rs create mode 100644 container/vendor/rise/rust/types/src/ix.rs create mode 100644 container/vendor/rise/rust/types/src/js_safe_ints.rs create mode 100644 container/vendor/rise/rust/types/src/l2book.rs create mode 100644 container/vendor/rise/rust/types/src/lib.rs create mode 100644 container/vendor/rise/rust/types/src/market.rs create mode 100644 container/vendor/rise/rust/types/src/market_state.rs create mode 100644 container/vendor/rise/rust/types/src/market_stats.rs create mode 100644 container/vendor/rise/rust/types/src/metadata.rs create mode 100644 container/vendor/rise/rust/types/src/subscription_key.rs create mode 100644 container/vendor/rise/rust/types/src/trader.rs create mode 100644 container/vendor/rise/rust/types/src/trader_http.rs create mode 100644 container/vendor/rise/rust/types/src/trader_key.rs create mode 100644 container/vendor/rise/rust/types/src/trader_state.rs create mode 100644 container/vendor/rise/rust/types/src/trades.rs create mode 100644 container/vendor/rise/rust/types/src/ws.rs create mode 100644 container/vendor/rise/rust/types/src/ws_error.rs create mode 100644 container/vendor/rise/rustfmt.toml create mode 100755 container/vendor/rise/scripts/publish_repo_snapshot.py create mode 100644 container/vendor/vulcan/.config/nextest.toml create mode 100644 container/vendor/vulcan/.github/actions/cargo-binstall/action.yaml create mode 100644 container/vendor/vulcan/.github/actions/rust-cache/action.yaml create mode 100644 container/vendor/vulcan/.github/actions/setup-r2-credentials/action.yml create mode 100644 container/vendor/vulcan/.github/dependabot.yml create mode 100644 container/vendor/vulcan/.github/release.yml create mode 100644 container/vendor/vulcan/.github/workflows/build.yaml create mode 100644 container/vendor/vulcan/.github/workflows/cross-build.yaml create mode 100644 container/vendor/vulcan/.github/workflows/publish.yaml create mode 100644 container/vendor/vulcan/.gitignore create mode 100644 container/vendor/vulcan/AGENTS.md create mode 100644 container/vendor/vulcan/CLAUDE.md create mode 100644 container/vendor/vulcan/CONTEXT.md create mode 100644 container/vendor/vulcan/Cargo.lock create mode 100644 container/vendor/vulcan/Cargo.toml create mode 100644 container/vendor/vulcan/README.md create mode 100644 container/vendor/vulcan/agents/README.md create mode 100644 container/vendor/vulcan/agents/error-catalog.json create mode 100644 container/vendor/vulcan/agents/system.md create mode 100644 container/vendor/vulcan/agents/tool-catalog.json create mode 100644 container/vendor/vulcan/agents/workflows/onboarding.md create mode 100644 container/vendor/vulcan/agents/workflows/portfolio.md create mode 100644 container/vendor/vulcan/agents/workflows/risk.md create mode 100644 container/vendor/vulcan/agents/workflows/trade.md create mode 100644 container/vendor/vulcan/deny.toml create mode 100644 container/vendor/vulcan/flake.nix create mode 100644 container/vendor/vulcan/rust-toolchain.toml create mode 100644 container/vendor/vulcan/skills/INDEX.md create mode 100644 container/vendor/vulcan/skills/recipe-close-and-withdraw/SKILL.md create mode 100644 container/vendor/vulcan/skills/recipe-emergency-flatten/SKILL.md create mode 100644 container/vendor/vulcan/skills/recipe-funding-rate-harvest/SKILL.md create mode 100644 container/vendor/vulcan/skills/recipe-morning-portfolio-check/SKILL.md create mode 100644 container/vendor/vulcan/skills/recipe-open-hedged-position/SKILL.md create mode 100644 container/vendor/vulcan/skills/recipe-scale-into-position/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-error-recovery/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-grid-trading/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-lot-size-calculator/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-margin-operations/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-market-intel/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-onboarding/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-portfolio-intel/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-position-management/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-risk-management/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-shared/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-tpsl-management/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-trade-execution/SKILL.md create mode 100644 container/vendor/vulcan/skills/vulcan-twap-execution/SKILL.md create mode 100644 container/vendor/vulcan/vulcan-lib/Cargo.toml create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/account.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/history.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/margin.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/market.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/position.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/trade.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/cli/wallet.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/account.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/margin.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/market.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/position.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/setup.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/status.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/trade.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/commands/wallet.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/config/config.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/config/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/context.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/crypto/encryption.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/crypto/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/error.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/lib.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/mcp/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/mcp/registry.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/mcp/server.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/mcp/session_wallet.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/output/json.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/output/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/output/table.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/wallet/keypair.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/wallet/mod.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/wallet/store.rs create mode 100644 container/vendor/vulcan/vulcan-lib/src/watch.rs create mode 100644 container/vendor/vulcan/vulcan/Cargo.toml create mode 100644 container/vendor/vulcan/vulcan/src/main.rs diff --git a/container/Dockerfile b/container/Dockerfile index a3d47c6f6ff..3d88cee5a53 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,6 +1,20 @@ # SolClaw Agent Container -# Runs Claude Agent SDK in isolated Linux VM with browser automation +# Runs multi-model agent in isolated Linux VM with browser automation +# ── Stage 1: Build Vulcan CLI (Rust) ──────────────────────────────────────── +FROM rust:1-slim AS vulcan-builder + +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +# Copy vendored source (vulcan + rise dependency) +COPY vendor/vulcan /build/vulcan +COPY vendor/rise /build/rise + +WORKDIR /build/vulcan +RUN cargo build --release --bin vulcan 2>&1 \ + && strip target/release/vulcan + +# ── Stage 2: Runtime container ────────────────────────────────────────────── FROM node:22-slim # Install system dependencies for Chromium @@ -29,8 +43,11 @@ RUN apt-get update && apt-get install -y \ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium -# Install agent-browser, claude-code, and tsx globally (before NODE_OPTIONS is set) -RUN npm install -g agent-browser @anthropic-ai/claude-code tsx +# Install Vulcan binary from builder stage +COPY --from=vulcan-builder /build/vulcan/target/release/vulcan /usr/local/bin/vulcan + +# Install agent-browser and tsx globally (before NODE_OPTIONS is set) +RUN npm install -g agent-browser tsx # Create app directory WORKDIR /app @@ -56,21 +73,25 @@ RUN ln -s $(npm root -g)/tsx node_modules/tsx # ESM interop for all node processes (safe, no tsx dependency) ENV NODE_OPTIONS="--experimental-require-module --require /app/solana-tx-preload.cjs" -# Create workspace and IPC directories -RUN mkdir -p /workspace/group /workspace/global /workspace/extra /data/ipc/messages /data/ipc/tasks /data/ipc/input /data/ipc/transactions +# Create workspace and IPC directories (conversation/ needed for multi-model persistence) +RUN mkdir -p /workspace/group /workspace/global /workspace/extra /data/ipc/messages /data/ipc/tasks /data/ipc/input /data/ipc/transactions /data/ipc/conversation # Backward-compat symlink: legacy scripts may still reference /workspace/ipc RUN ln -sf /data/ipc /workspace/ipc +# Vulcan config directory and default wallet password (container is the security boundary) +ENV VULCAN_WALLET_PASSWORD=solclaw +RUN mkdir -p /home/node/.vulcan + # Create entrypoint script # Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it # Follow-up messages arrive via IPC files in /data/ipc/input/ RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode --import tsx /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories -RUN chown -R node:node /workspace /data && chmod 777 /home/node +RUN chown -R node:node /workspace /data /home/node/.vulcan && chmod 777 /home/node -# Switch to non-root user (required for --dangerously-skip-permissions) +# Switch to non-root user USER node # Set working directory to group workspace diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 60829213c0c..42d23bc6f71 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -686,6 +686,20 @@ async function main(): Promise { sdkEnv[key] = value; } + // Selectively expose tool-specific secrets to process.env so CLI tools + // invoked via Bash can read them (e.g. vulcan reads VULCAN_WALLET_PASSWORD). + // LLM API keys are intentionally excluded. + const TOOL_ENV_KEYS = [ + 'DFLOW_API_KEY', + 'JUPITER_API_KEY', + 'BREEZE_API_KEY', + 'HELIUS_API_KEY', + ]; + for (const key of TOOL_ENV_KEYS) { + const val = containerInput.secrets?.[key]; + if (val) process.env[key] = val; + } + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); diff --git a/container/skills/phoenix-perps/SKILL.md b/container/skills/phoenix-perps/SKILL.md new file mode 100644 index 00000000000..249fbad871a --- /dev/null +++ b/container/skills/phoenix-perps/SKILL.md @@ -0,0 +1,257 @@ +--- +name: phoenix-perps +creator: phoenix +description: Trade perpetual futures on Phoenix DEX (Solana) via the vulcan CLI. Market data, order execution, position/margin management. Use when the user wants to trade perps, check perp positions, or interact with Phoenix DEX. +--- + +# Phoenix Perpetual Futures — Vulcan CLI + +**This tool executes real financial transactions on Solana mainnet. Always confirm with the user before executing trades.** + +You have access to the `vulcan` CLI for trading perpetual futures on Phoenix DEX. + +## Invocation + +Always use JSON output for machine parsing: + +```bash +vulcan [args...] -o json +``` + +- `stdout` is the only data channel (JSON). +- `stderr` is diagnostics only. +- Exit code `0` = success, non-zero = failure with JSON error envelope. +- Use `--yes` to skip interactive confirmation prompts (required for agent use). +- Use `--dry-run` to simulate without submitting transactions. + +## Authentication + +Wallet password is pre-configured in the container. No manual auth needed. + +## Symbol Format + +Uppercase ticker only: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. + +No `-PERP` suffix. Discover active markets with: + +```bash +vulcan market list -o json +``` + +## Size Units — Base Lots (CRITICAL) + +The `size` parameter is in **base lots**, NOT tokens or USD. Getting this wrong means trading 100x more or less than intended. + +**Before every trade**, fetch market info: + +```bash +vulcan market info SOL -o json +``` + +Extract `base_lots_decimals` from the response, then convert: + +``` +base_lots = desired_tokens * 10^base_lots_decimals +``` + +### Worked Examples + +| Market | base_lots_decimals | Want | Calculation | Size param | +|--------|-------------------|------|-------------|------------| +| SOL | 2 | 0.5 SOL | 0.5 * 100 | 50 | +| SOL | 2 | 1 SOL | 1 * 100 | 100 | +| BTC | 4 | 0.001 BTC | 0.001 * 10000 | 10 | +| ETH | 3 | 0.1 ETH | 0.1 * 1000 | 100 | + +### USD to base lots + +``` +tokens = usd_amount / mark_price +base_lots = tokens * 10^base_lots_decimals +``` + +Round to nearest integer. Base lots must be whole numbers. + +## Commands Reference + +### Market Data (safe, read-only) + +```bash +vulcan market list -o json # all markets +vulcan market ticker SOL -o json # price, funding rate, 24h volume +vulcan market info SOL -o json # lot sizes, fees, leverage tiers +vulcan market orderbook SOL -o json # L2 bids/asks +vulcan market orderbook SOL --depth 20 -o json # deeper book +vulcan market candles SOL -o json # OHLCV (default 1h, 50 candles) +vulcan market candles SOL --interval 5m --limit 100 -o json +``` + +### Trading (DANGEROUS — always confirm with user) + +```bash +# Market orders +vulcan trade market-buy SOL 50 --yes -o json +vulcan trade market-sell SOL 50 --yes -o json + +# Market order with take-profit and stop-loss +vulcan trade market-buy SOL 50 --tp 160.0 --sl 140.0 --yes -o json + +# Limit orders +vulcan trade limit-buy SOL 50 --price 145.00 --yes -o json +vulcan trade limit-sell SOL 50 --price 155.00 --yes -o json + +# List open orders +vulcan trade orders -o json +vulcan trade orders --symbol SOL -o json + +# Cancel orders +vulcan trade cancel SOL --order-ids id1,id2 --yes -o json +vulcan trade cancel-all SOL --yes -o json + +# TP/SL management +vulcan trade set-tpsl SOL --tp 160.0 --sl 140.0 --yes -o json +vulcan trade cancel-tpsl SOL --yes -o json +``` + +### Position Management + +```bash +vulcan position list -o json # all open positions +vulcan position show SOL -o json # detailed: PnL, liquidation price, TP/SL +vulcan position close SOL --yes -o json # close entire position +vulcan position reduce SOL 25 --yes -o json # reduce by 25 base lots +vulcan position tp-sl SOL --tp 160 --sl 140 --yes -o json # attach TP/SL to existing +``` + +### Margin & Collateral + +```bash +vulcan margin status -o json # collateral, PnL, risk state +vulcan margin deposit 100 --yes -o json # deposit 100 USDC +vulcan margin withdraw 50 --yes -o json # withdraw 50 USDC +vulcan margin leverage-tiers SOL -o json # max leverage per size tier +``` + +### Account & Wallet + +```bash +vulcan account info -o json # trader account status +vulcan wallet balance -o json # SOL and USDC balance +vulcan status -o json # health check: config, wallet, RPC +``` + +## Safe Order Flow (5 steps) + +**Always follow this pattern before placing a trade:** + +1. **Market info** — get lot sizes and fees: + ```bash + vulcan market info SOL -o json + ``` + +2. **Price check** — current mark price and funding: + ```bash + vulcan market ticker SOL -o json + ``` + +3. **Margin check** — ensure sufficient collateral: + ```bash + vulcan margin status -o json + ``` + +4. **Position check** — know existing exposure: + ```bash + vulcan position list -o json + ``` + +5. **Execute** (after user confirmation): + ```bash + vulcan trade market-buy SOL 50 --yes -o json + ``` + +6. **Verify** — confirm position opened: + ```bash + vulcan position list -o json + ``` + +Report the transaction signature to the user. + +## Risk Management + +### Margin Health States + +| State | Meaning | Action | +|-------|---------|--------| +| `Healthy` | Sufficient collateral | Safe to trade | +| `HighRisk` | Margin getting thin | Warn user before new trades | +| `Liquidatable` | At risk of liquidation | Do NOT open new positions | + +### When to Warn the User + +- Risk state is anything other than Healthy +- Trade would use >50% of available margin +- Liquidation price is within 10% of mark price +- Funding rate is elevated (>0.01% per interval) +- Orderbook spread is wide (>10bps) +- Increasing an already-large position + +### TP/SL Direction Rules + +- **Long (buy):** TP must be ABOVE entry, SL must be BELOW entry +- **Short (sell):** TP must be BELOW entry, SL must be ABOVE entry + +## Emergency Flatten + +Cancel all orders and close all positions: + +```bash +# 1. Cancel all orders per market +vulcan trade cancel-all SOL --yes -o json +vulcan trade cancel-all BTC --yes -o json + +# 2. Close all positions +vulcan position close SOL --yes -o json +vulcan position close BTC --yes -o json + +# 3. Verify flat +vulcan position list -o json +vulcan trade orders -o json +vulcan margin status -o json +``` + +## Error Handling + +Errors return JSON with structured categories: + +```json +{ + "ok": false, + "error": { + "category": "validation", + "code": "UNKNOWN_MARKET", + "message": "Market not found", + "retryable": false + } +} +``` + +Route on `.error.category`: + +| Category | Action | +|----------|--------| +| `validation` | Fix inputs, do not retry | +| `auth` | Check wallet/password | +| `config` | Run `vulcan setup` | +| `network` | Retry with backoff | +| `rate_limit` | Wait and retry | +| `tx_failed` | **Check position state before retrying** — never blind-retry | +| `dangerous_gate` | Add `--yes` flag | + +## Hard Rules + +1. **Always call `vulcan market info` before trading** — never guess lot sizes. +2. **Always call `vulcan margin status` before opening positions** — ensure collateral. +3. **Always call `vulcan position list` before trading** — know existing exposure. +4. **Never execute trades without user confirmation** unless they explicitly opted into auto-execute mode. +5. **Report all transaction signatures** to the user for on-chain verification. +6. **On `tx_failed`, verify state before retrying** — the tx may have partially succeeded. diff --git a/container/vendor/.gitignore b/container/vendor/.gitignore new file mode 100644 index 00000000000..f4ceea78560 --- /dev/null +++ b/container/vendor/.gitignore @@ -0,0 +1 @@ +**/target/ diff --git a/container/vendor/rise/.gitignore b/container/vendor/rise/.gitignore new file mode 100644 index 00000000000..29a8355a31f --- /dev/null +++ b/container/vendor/rise/.gitignore @@ -0,0 +1,7 @@ +.claude +.context +**/target +**/__pycache__ + +.zed +dist diff --git a/container/vendor/rise/AGENTS.md b/container/vendor/rise/AGENTS.md new file mode 100644 index 00000000000..dd7065ad30a --- /dev/null +++ b/container/vendor/rise/AGENTS.md @@ -0,0 +1,210 @@ +# phoenix-sdk + +SDK for the Phoenix perpetuals exchange on Solana. + +## Directory Structure + +``` +rust/ +├── Cargo.toml # Workspace manifest +├── cli/ # phoenix-sdk-cli crate (smoke-test CLI) +│ ├── Cargo.toml +│ ├── src/ +│ │ └── main.rs # Clap-based CLI for HTTP + WebSocket smoke testing +│ └── scripts/ +│ └── smoke_http_client.sh +├── ix/ # phoenix-ix crate (instruction builders) +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Crate root, re-exports instruction builders +│ ├── constants.rs # Program IDs, discriminants, PDA derivation +│ ├── types.rs # AccountMeta, Instruction, Side, OrderFlags, IsolatedCollateralFlow, etc. +│ ├── error.rs # PhoenixIxError enum +│ ├── limit_order.rs # Limit order instruction builder (cross and isolated) +│ ├── market_order.rs # Market order instruction builder (cross and isolated) +│ ├── order_packet.rs # OrderPacket serialization for on-chain instruction data +│ ├── cancel_orders.rs # Cancel orders instruction builder +│ ├── deposit_funds.rs # Deposit funds instruction builder +│ ├── withdraw_funds.rs # Withdraw funds instruction builder +│ ├── register_trader.rs # Register trader (subaccount) instruction builder +│ ├── stop_loss.rs # Stop-loss order instruction builder +│ ├── transfer_collateral.rs # Cross-to-isolated and child-to-parent collateral transfers +│ ├── sync_parent_to_child.rs # Sync parent trader state to isolated child subaccount +│ ├── ember_deposit.rs # Ember USDC->Phoenix token deposit +│ ├── ember_withdraw.rs # Ember Phoenix token->USDC withdraw +│ ├── spl_approve.rs # SPL Token approve instruction builder +│ └── create_ata.rs # Idempotent ATA creation instruction +├── math/ # phoenix-math-utils crate +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Crate root, re-exports math utilities +│ ├── direction.rs # Direction and stop-loss order types +│ ├── errors.rs # Application-level error types +│ ├── fixed.rs # I80F48 fixed-point arithmetic wrapper +│ ├── funding.rs # Funding rate calculations +│ ├── leverage_tiers.rs # Position-size-dependent margin requirements +│ ├── limit_order_state.rs # Limit order margin state aggregation +│ ├── margin.rs # Per-market margin computation +│ ├── margin_calc.rs # Core margin calculation formulas +│ ├── market_math.rs # MarketCalculator for price/lot conversions +│ ├── perp_metadata.rs # Simplified perpetual asset metadata +│ ├── portfolio.rs # Portfolio-level aggregation across markets +│ ├── price.rs # Price quantization and tick conversions +│ ├── risk.rs # Risk assessment types and margin state +│ ├── trader_position.rs # Trader position in a perp market +│ └── quantities/ # Type-safe quantity system (BaseLots, QuoteLots, Ticks, etc.) +├── sdk/ # phoenix-sdk crate +│ ├── Cargo.toml +│ ├── src/ +│ │ ├── lib.rs # Crate root, re-exports main types +│ │ ├── client.rs # PhoenixClient unified client with reconnection and callbacks +│ │ ├── env.rs # Environment configuration with defaults +│ │ ├── http_client.rs # HTTP client for REST API (markets, traders, candles) +│ │ ├── tx_builder.rs # Transaction builder for orders and deposits +│ │ └── ws_client.rs # WebSocket client with auto-reconnect +│ ├── examples/ +│ │ ├── phoenix_client.rs +│ │ ├── subscribe_trader_state.rs +│ │ ├── subscribe_market_stats.rs +│ │ ├── subscribe_l2_book.rs +│ │ ├── subscribe_candles.rs +│ │ ├── subscribe_trades.rs +│ │ ├── send_market_order.rs +│ │ ├── send_limit_order.rs +│ │ ├── isolated_market_order_client.rs # Isolated margin market order (client-side construction) +│ │ ├── isolated_market_order_server.rs # Isolated margin market order (server-side HTTP endpoint) +│ │ ├── isolated_limit_order.rs # Isolated margin limit order +│ │ ├── cancel_order.rs +│ │ ├── deposit_funds.rs +│ │ ├── register_trader.rs +│ │ ├── compute_trader_margin.rs +│ │ ├── http_client.rs +│ │ ├── market_maker.rs +│ │ └── ws_debug_cli.rs +│ └── tests/ +│ └── trader_state_tests.rs +└── types/ # phoenix-types crate + ├── Cargo.toml + └── src/ + ├── lib.rs # Crate root, re-exports all types + ├── candles.rs # Candle types (Timeframe, ApiCandle, CandleData) + ├── client.rs # Client-side types for higher-level SDK clients + ├── conversions.rs # Conversion utilities for building margin calc types + ├── core.rs # Core primitives (Decimal, Price, Side, PaginatedResponse) + ├── exchange.rs # Exchange keys and configuration + ├── http_error.rs # HTTP error types + ├── js_safe_ints.rs # Big integers serialized as strings for JS compatibility + ├── l2book.rs # L2 orderbook state container + ├── market.rs # Market config, status, orderbook, statistics + ├── market_state.rs # Combined market state (statistics + orderbook) + ├── market_stats.rs # Market statistics state container + ├── metadata.rs # Exchange metadata caching + ├── subscription_key.rs # Subscription key for message routing + ├── trader.rs # WebSocket protocol types (snapshots, deltas, capabilities) + ├── trader_http.rs # HTTP API types (TraderView, order/collateral/funding history) + ├── trader_key.rs # TraderKey identification and PDA derivation + ├── trader_state.rs # Trader state container with snapshot/delta handling + ├── ix.rs # Server-side instruction request types (PlaceIsolatedLimitOrderRequest, PlaceIsolatedMarketOrderRequest) + ├── trades.rs # Trade event records + ├── ws.rs # WebSocket protocol types (subscriptions, client/server messages) + └── ws_error.rs # WebSocket error types +``` + +## Build Commands + +All commands run from `rust/` directory: + +```bash +cargo build # Build both crates +cargo test # Run all tests + +# Examples optionally use environment variables: +# PHOENIX_API_URL=https://perp-api.phoenix.trade (optional; for HTTP/RPC and WS derivation) +# PHOENIX_WS_URL=wss://perp-api.phoenix.trade/ws (optional; overrides derived URL) +# PHOENIX_API_KEY=your_api_key (optional; sent as x-api-key when set) + +cargo run -p phoenix-sdk --example subscribe_trader_state +cargo run -p phoenix-sdk --example subscribe_l2_book -- SOL +cargo run -p phoenix-sdk --example subscribe_candles -- SOL-PERP 1m +``` + +## Crates + +### phoenix-ix + +Solana instruction builders for Phoenix perpetuals exchange. Supports both cross-margin and isolated margin orders: +- **constants** - Program IDs (Phoenix, Ember, SPL Token), instruction discriminants, PDA derivation functions +- **limit_order** - `LimitOrderParams` / `IsolatedLimitOrderParams` builders and `create_place_limit_order_ix` function +- **market_order** - `MarketOrderParams` / `IsolatedMarketOrderParams` builders and `create_place_market_order_ix` function +- **order_packet** - `OrderPacket` serialization for on-chain instruction data +- **cancel_orders** - `CancelOrdersByIdParams` builder and `create_cancel_orders_by_id_ix` function +- **deposit_funds** - `DepositFundsParams` builder and `create_deposit_funds_ix` function for depositing Phoenix tokens into the protocol +- **withdraw_funds** - `WithdrawFundsParams` builder and `create_withdraw_funds_ix` function for withdrawing Phoenix tokens from the protocol +- **register_trader** - `RegisterTraderParams` builder and `create_register_trader_ix` function for registering trader subaccounts +- **stop_loss** - `StopLossParams` builder and `create_place_stop_loss_ix` function +- **transfer_collateral** - `TransferCollateralParams` / `TransferCollateralChildToParentParams` for moving collateral between cross-margin and isolated subaccounts +- **sync_parent_to_child** - `SyncParentToChildParams` and `create_sync_parent_to_child_ix` for syncing parent state to isolated child subaccounts +- **ember_deposit** - `EmberDepositParams` builder and `create_ember_deposit_ix` function for converting USDC to Phoenix tokens +- **ember_withdraw** - `EmberWithdrawParams` builder and `create_ember_withdraw_ix` function for converting Phoenix tokens to USDC +- **spl_approve** - `SplApproveParams` builder and `create_spl_approve_ix` function for SPL Token approve delegation +- **create_ata** - `create_associated_token_account_idempotent_ix` for creating ATAs + +### phoenix-math-utils + +Type-safe math utilities for the Phoenix perpetuals exchange: +- **fixed** - `I80F48` fixed-point arithmetic wrapper around the `fixed` crate +- **funding** - Funding rate calculations and conversions +- **market_math** - `MarketCalculator` for converting between prices, ticks, base lots, and quote lots +- **price** - Price quantization and tick conversion utilities +- **quantities** - Type-safe newtype wrappers (`BaseLots`, `QuoteLots`, `Ticks`, etc.) preventing arithmetic errors at compile time +- **direction** - Direction and stop-loss order types for price comparisons +- **errors** - Application-level error types +- **leverage_tiers** - Leverage tiers for position-size-dependent margin requirements +- **limit_order_state** - Limit order margin state aggregation for margin calculations +- **margin** - Core margin types and per-market margin computation +- **margin_calc** - Core margin calculation formulas for perpetual futures positions +- **perp_metadata** - Simplified perpetual asset metadata for margin calculations +- **portfolio** - Portfolio-level types and aggregation across multiple markets +- **risk** - Risk assessment types and margin state +- **trader_position** - `TraderPosition` representing a trader's position in a perp market + +### phoenix-types + +Minimal serde types matching the Phoenix API wire formats. No runtime dependencies beyond serde. +- **core** - Fundamental primitives (`Decimal`, `Price`, `Side`, `PaginatedResponse`) +- **trader** - WebSocket protocol types for real-time state synchronization (snapshots, deltas, capabilities) +- **trader_http** - HTTP API types for views and history (`TraderView`, `OrderHistoryItem`, `CollateralEvent`, `FundingHistoryEvent`) +- **market** - Market configuration, status enums, orderbook, and statistics +- **exchange** - Exchange keys and authority configuration +- **candles** - Candlestick (OHLCV) data types +- **trades** - Trade event records for WebSocket and HTTP +- **ws** - WebSocket protocol (subscriptions, client/server message envelopes) +- **client** - Client-side types for higher-level SDK clients (`PhoenixSubscription`, `PhoenixClientEvent`, `MarginTrigger`) +- **conversions** - Conversion utilities for building margin calculation types from HTTP/WebSocket data +- **ix** - Server-side instruction request types (`PlaceIsolatedLimitOrderRequest`, `PlaceIsolatedMarketOrderRequest`, `TpSlOrderConfig`) +- **http_error** - HTTP error types for the Phoenix SDK +- **js_safe_ints** - Safe big integers that serialize as strings for JSON/JavaScript compatibility +- **l2book** - L2 orderbook state container for Phoenix markets +- **market_state** - Combined market state container (statistics + orderbook) +- **market_stats** - Market statistics state container +- **metadata** - Exchange metadata caching for the SDK +- **subscription_key** - Subscription key for routing messages to the correct subscriber +- **trader_key** - `TraderKey` identification and PDA derivation +- **trader_state** - Trader state container with snapshot and delta handling +- **ws_error** - WebSocket error types (`PhoenixWsError`) + +### phoenix-sdk + +- **client** - `PhoenixClient` unified client wrapping WS and HTTP clients with automatic reconnection, lock-free single-owner runtime state, receiver-based `subscribe(...)` API (`PhoenixSubscription`), dependency-aware unsubscribe, and composite subscriptions (including market bundles and trader margin updates) +- **env** - `PhoenixEnv` environment configuration loading with defaults for API URL, WebSocket URL, and API key +- **http_client** - `PhoenixHttpClient` for REST API calls (exchange config, markets, traders, candles, collateral history, funding history); also provides `build_isolated_limit_order_tx` and `build_isolated_market_order_tx` for server-side isolated order construction +- **tx_builder** - `PhoenixTxBuilder` builds Solana instructions from `PhoenixMetadata`; provides `build_market_order`, `build_limit_order`, `build_cancel_orders`, `build_deposit_funds`, `build_withdraw_funds` for cross-margin, and `build_isolated_market_order`, `build_isolated_limit_order` for isolated margin (with subaccount registration, collateral transfer, and sync) +- **ws_client** - `PhoenixWSClient` handles WebSocket connection, auto-reconnect with exponential backoff, and message routing to subscribers; `SubscriptionHandle` returned from subscribe methods enables unsubscription by dropping + +### phoenix-sdk-cli + +Clap-based smoke-test CLI for exercising the HTTP and WebSocket clients. Supports all HTTP endpoints and WebSocket subscriptions via subcommands. + +## Additional Agent Docs + +- [`rust/sdk/AGENTS.md`](./rust/sdk/AGENTS.md) — Architecture guide for `PhoenixClient`, `PhoenixWSClient`, and `PhoenixHttpClient`. Covers the three-layer client hierarchy, callback-based subscription patterns, cached state getters, lifecycle management, and internal design (command channels, `SubscriptionHandles`, `AggChannels`). diff --git a/container/vendor/rise/CLAUDE.md b/container/vendor/rise/CLAUDE.md new file mode 100644 index 00000000000..9a1a7d6b5bc --- /dev/null +++ b/container/vendor/rise/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md) for codebase documentation. diff --git a/container/vendor/rise/README.md b/container/vendor/rise/README.md new file mode 100644 index 00000000000..d7141ccabe1 --- /dev/null +++ b/container/vendor/rise/README.md @@ -0,0 +1,247 @@ +# Rise - the Phoenix Perps SDK + +SDK for the Phoenix perpetuals exchange on Solana. Fetch real-time market and trader data via websocket and HTTP and place orders via RPC. + +## Features + +- **Real-time WebSocket subscriptions** - L2 orderbook, market stats, candles, trader state, fills +- **Unified PhoenixClient** - Auto-reconnect, dependency-aware subscriptions, and receiver-based events +- **Local state management** - Automatic snapshot/delta reconciliation with sequence ordering +- **Order execution** - Market orders, limit orders, and cancellations via Solana RPC (cross-margin and isolated margin) +- **Type-safe** - Strongly typed message routing and state containers + +## Architecture + +``` +rust/ +├── sdk/ phoenix-sdk High-level client, WebSocket/HTTP +├── types/ phoenix-types Wire format + shared client model types +├── math/ phoenix-math-utils Margin calculations, risk, fixed-point math +├── ix/ phoenix-ix Solana instruction builders for order placement +└── cli/ phoenix-sdk-cli Smoke-test CLI for HTTP + WebSocket +``` + +## Quick Start + +### Subscribe to L2 Orderbook + +```rust +use phoenix_sdk::{PhoenixWSClient, L2Book}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Uses optional PHOENIX_WS_URL / PHOENIX_API_URL and PHOENIX_API_KEY env vars + let client = PhoenixWSClient::new_from_env()?; + let (mut rx, _handle) = client.subscribe_to_orderbook("SOL".into())?; + let mut book = L2Book::new("SOL".into()); + + while let Some(msg) = rx.recv().await { + book.apply_update(&msg); + println!("Best bid: {:?}, Best ask: {:?}", book.best_bid(), book.best_ask()); + } + Ok(()) +} +``` + +### Subscribe to Trader State + +```rust +use phoenix_sdk::{PhoenixWSClient, Trader, TraderKey}; +use solana_pubkey::Pubkey; +use std::str::FromStr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Uses optional PHOENIX_WS_URL / PHOENIX_API_URL and PHOENIX_API_KEY env vars + let client = PhoenixWSClient::new_from_env()?; + + let authority = Pubkey::from_str("YOUR_AUTHORITY_PUBKEY")?; + let key = TraderKey::from_authority(authority); + let mut trader = Trader::new(key.clone()); + let (mut rx, _handle) = client.subscribe_to_trader_state(&authority)?; + + while let Some(msg) = rx.recv().await { + trader.apply_update(&msg); + println!("Collateral: {}", trader.total_collateral()); + for pos in trader.all_positions() { + println!(" {} {} lots @ {}", pos.symbol, pos.base_position_lots, pos.entry_price_usd); + } + } + Ok(()) +} +``` + +### Unified PhoenixClient Subscription + +```rust +use phoenix_sdk::{PhoenixClient, PhoenixSubscription, PhoenixClientEvent}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = PhoenixClient::new_from_env().await?; + let (mut rx, _handle) = client + .subscribe(PhoenixSubscription::market("SOL")).await?; + + while let Some(event) = rx.recv().await { + if let PhoenixClientEvent::MarketUpdate { symbol, update, .. } = event { + println!("{} mark={}", symbol, update.mark_price); + } + } + + Ok(()) +} +``` + +### Place Orders + +```rust +use phoenix_sdk::{PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, TraderKey, Side}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let keypair = read_keypair_file("~/.config/solana/id.json")?; + let trader = TraderKey::new(keypair.pubkey()); + + // Fetch exchange metadata (uses optional PHOENIX_API_URL/PHOENIX_API_KEY env vars) + let http = PhoenixHttpClient::new_from_env(); + let metadata = PhoenixMetadata::new(http.get_exchange().await?.into()); + let builder = PhoenixTxBuilder::new(&metadata); + + // Build market order instructions + let ixs = builder.build_market_order( + trader.authority(), trader.pda(), "SOL", Side::Bid, 100, + )?; + + // Send via Solana RPC + let rpc = RpcClient::new_with_commitment( + "https://api.mainnet-beta.solana.com".into(), + CommitmentConfig::confirmed(), + ); + let blockhash = rpc.get_latest_blockhash().await?; + let tx = Transaction::new_signed_with_payer( + &ixs, Some(&keypair.pubkey()), &[&keypair], blockhash, + ); + let sig = rpc.send_and_confirm_transaction(&tx).await?; + + Ok(()) +} +``` + +### Trader registration + +Register a trader with the SDK, through the UI, or with the following curl command: + +`curl -sS -X POST 'https://perp-api.phoenix.trade/v1/invite/activate' -H "Content-Type: application/json" -d '{"authority":"'"$PUBKEY"'","code": "'"$CODE"'"}'` + +## Crates + +### phoenix-sdk + +Main SDK with WebSocket client and state containers: + +| Type | Description | +|------|-------------| +| `PhoenixWSClient` | WebSocket connection with subscription management | +| `Trader` | Tracks positions, orders, splines, and collateral across subaccounts | +| `L2Book` | Orderbook state with bid/ask accessors, spread, and liquidity metrics | +| `MarketStats` | Mark price, oracle price, funding rates, volume | +| `Market` | Combined L2Book + MarketStats container | +| `PhoenixTxBuilder` | Transaction builder for orders (cross and isolated margin), deposits, withdrawals | +| `PhoenixHttpClient` | REST API for exchange configuration | + +### phoenix-types + +Serde types matching the Phoenix WebSocket wire format, plus shared generic +client model types used by `phoenix-sdk` (`PhoenixSubscription`, +`PhoenixClientEvent`, `MarginTrigger`, and related command/handle types). + +- `ServerMessage` - Enum of all server message types +- `TraderStateServerMessage` - Snapshots and deltas for trader state +- `L2BookUpdate`, `MarketStatsUpdate`, `CandleData`, `FillsMessage` +- `PhoenixSubscription`, `PhoenixClientEvent`, `MarginTrigger` +- Subscription request types + +### phoenix-ix + +Solana instruction builders for the Phoenix program (cross-margin and isolated margin): + +- `create_place_limit_order_ix` - Build limit order instructions +- `create_place_market_order_ix` - Build market order instructions +- `create_cancel_orders_by_id_ix` - Build cancel instructions +- `create_place_stop_loss_ix` - Build stop-loss order instructions +- `create_register_trader_ix` - Register trader subaccounts +- `create_transfer_collateral_ix` - Transfer collateral between cross and isolated subaccounts +- `create_sync_parent_to_child_ix` - Sync parent state to isolated child subaccounts + +## Examples + +Run from the `rust/` directory: + +```bash +# Optional environment variables +export PHOENIX_API_URL=https://public-api.phoenix.trade +export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +export PHOENIX_API_KEY=your_api_key + +# Trader state updates +cargo run -p phoenix-sdk --example subscribe_trader_state + +# L2 orderbook +cargo run -p phoenix-sdk --example subscribe_l2_book -- SOL + +# Market stats +cargo run -p phoenix-sdk --example subscribe_market_stats -- SOL + +# Candles (1s, 5s, 1m, 5m, 15m, 30m, 1h, 4h, 1d) +cargo run -p phoenix-sdk --example subscribe_candles -- SOL 1m + +# Trade events +cargo run -p phoenix-sdk --example subscribe_trades -- SOL + +# Compute trader margin +cargo run -p phoenix-sdk --example compute_trader_margin + +# HTTP client usage +cargo run -p phoenix-sdk --example http_client + +# Isolated margin market order (client-side) +cargo run -p phoenix-sdk --example isolated_market_order_client + +# Isolated margin market order (server-side) +cargo run -p phoenix-sdk --example isolated_market_order_server + +# Isolated margin limit order +cargo run -p phoenix-sdk --example isolated_limit_order + +# Register trader subaccount +cargo run -p phoenix-sdk --example register_trader + +# Market maker example +cargo run -p phoenix-sdk --example market_maker + +# WebSocket debug CLI +cargo run -p phoenix-sdk --example ws_debug_cli +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `PHOENIX_API_URL` | Optional Phoenix REST API URL (defaults to `https://public-api.phoenix.trade`) | +| `PHOENIX_WS_URL` | Optional Phoenix WebSocket URL (defaults to `wss://public-api.phoenix.trade/ws`) | +| `PHOENIX_API_KEY` | Optional Phoenix API key (sent as `x-api-key` when set) | + +## Build + +```bash +cd rust +cargo build +cargo test +``` + +Requires Rust 1.86.0+. diff --git a/container/vendor/rise/lefthook.yml b/container/vendor/rise/lefthook.yml new file mode 100644 index 00000000000..6e5a23150c9 --- /dev/null +++ b/container/vendor/rise/lefthook.yml @@ -0,0 +1,13 @@ +min_version: 1.11.12 + +output: + - failure + +pre-commit: + parallel: true + commands: + rustfmt: + glob: "*.rs" + run: rustfmt +nightly --edition 2021 {staged_files} + tags: [lint, fmt, rust] + stage_fixed: true diff --git a/container/vendor/rise/rust/Cargo.lock b/container/vendor/rise/rust/Cargo.lock new file mode 100644 index 00000000000..bcc3dfa29be --- /dev/null +++ b/container/vendor/rise/rust/Cargo.lock @@ -0,0 +1,4518 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agave-feature-set" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a2c365c0245cbb8959de725fc2b44c754b673fdf34c9a7f9d4a25c35a7bf1" +dependencies = [ + "ahash 0.8.12", + "solana-epoch-schedule", + "solana-hash", + "solana-pubkey", + "solana-sha256-hasher", + "solana-svm-feature-set", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" +dependencies = [ + "feature-probe", + "serde", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek 3.2.0", + "ed25519", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "five8" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75b8549488b4715defcb0d8a8a1c1c76a80661b5fa106b4ca0e7fce59d7d875" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26dec3da8bc3ef08f2c04f61eab298c3ab334523e55f076354d6d6f613799a7b" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" + +[[package]] +name = "fixed" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c566da967934c6c7ee0458a9773de9b2a685bd2ce26a3b28ddfc740e640182f5" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc-core" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f7f76aef2d054868398427f6c54943cf3d1caa9a7ec7d0c38d69df97a965eb" +dependencies = [ + "futures", + "futures-executor", + "futures-util", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phoenix-ix" +version = "0.1.0" +dependencies = [ + "borsh", + "sha2 0.10.9", + "solana-instruction", + "solana-pubkey", + "thiserror 2.0.18", +] + +[[package]] +name = "phoenix-math-utils" +version = "0.1.0" +dependencies = [ + "borsh", + "bytemuck", + "fixed", + "pastey", + "proptest", + "rand 0.9.2", + "rust_decimal", + "serde", + "sha2-const-stable", + "solana-pubkey", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "phoenix-sdk" +version = "0.1.0" +dependencies = [ + "futures-util", + "parking_lot", + "phoenix-ix", + "phoenix-math-utils", + "phoenix-types", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "solana-commitment-config", + "solana-instruction", + "solana-keypair", + "solana-pubkey", + "solana-rpc-client", + "solana-signer", + "solana-transaction", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "phoenix-sdk-cli" +version = "0.1.0" +dependencies = [ + "clap", + "phoenix-sdk", + "serde", + "serde_json", + "solana-pubkey", + "tokio", + "url", +] + +[[package]] +name = "phoenix-types" +version = "0.1.0" +dependencies = [ + "chrono", + "phoenix-math-utils", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "serde_with", + "solana-pubkey", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "url", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "solana-account" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258" +dependencies = [ + "bincode", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-sysvar", +] + +[[package]] +name = "solana-account-decoder-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5519e8343325b707f17fbed54fcefb325131b692506d0af9e08a539d15e4f8cf" +dependencies = [ + "base64", + "bs58", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-pubkey", + "zstd", +] + +[[package]] +name = "solana-account-info" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" +dependencies = [ + "solana-program-error", + "solana-program-memory", + "solana-pubkey", +] + +[[package]] +name = "solana-atomic-u64" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52e52720efe60465b052b9e7445a01c17550666beec855cce66f44766697bc2" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "solana-bincode" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" +dependencies = [ + "bincode", + "serde", + "solana-instruction", +] + +[[package]] +name = "solana-clock" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb482ab70fced82ad3d7d3d87be33d466a3498eb8aa856434ff3c0dfc2e2e31" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-commitment-config" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac49c4dde3edfa832de1697e9bcdb7c3b3f7cb7a1981b7c62526c8bb6700fb73" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-cpi" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11" +dependencies = [ + "solana-account-info", + "solana-define-syscall", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-stable-layout", +] + +[[package]] +name = "solana-decode-error" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c781686a18db2f942e70913f7ca15dc120ec38dcab42ff7557db2c70c625a35" +dependencies = [ + "num-traits", +] + +[[package]] +name = "solana-define-syscall" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" + +[[package]] +name = "solana-epoch-info" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ef6f0b449290b0b9f32973eefd95af35b01c5c0c34c569f936c34c5b20d77b" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-epoch-rewards" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-epoch-schedule" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-feature-gate-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d" +dependencies = [ + "serde", + "serde_derive", + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-fee-calculator" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89bc408da0fb3812bc3008189d148b4d3e08252c79ad810b245482a3f70cd8d" +dependencies = [ + "log", + "serde", + "serde_derive", +] + +[[package]] +name = "solana-hash" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b96e9f0300fa287b545613f007dfe20043d7812bee255f418c1eb649c93b63" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "five8", + "js-sys", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-sanitize", + "wasm-bindgen", +] + +[[package]] +name = "solana-inflation" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23eef6a09eb8e568ce6839573e4966850e85e9ce71e6ae1a6c930c1c43947de3" + +[[package]] +name = "solana-instruction" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" +dependencies = [ + "bincode", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "serde", + "serde_derive", + "serde_json", + "solana-define-syscall", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" +dependencies = [ + "bitflags 2.10.0", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-serialize-utils", + "solana-sysvar-id", +] + +[[package]] +name = "solana-keypair" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd3f04aa1a05c535e93e121a95f66e7dcccf57e007282e8255535d24bf1e98bb" +dependencies = [ + "ed25519-dalek", + "five8", + "rand 0.7.3", + "solana-pubkey", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "wasm-bindgen", +] + +[[package]] +name = "solana-last-restart-slot" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-message" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b" +dependencies = [ + "bincode", + "lazy_static", + "serde", + "serde_derive", + "solana-bincode", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-system-interface", + "solana-transaction-error", + "wasm-bindgen", +] + +[[package]] +name = "solana-msg" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-program-entrypoint" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd" +dependencies = [ + "solana-account-info", + "solana-msg", + "solana-program-error", + "solana-pubkey", +] + +[[package]] +name = "solana-program-error" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee2e0217d642e2ea4bee237f37bd61bb02aec60da3647c48ff88f6556ade775" +dependencies = [ + "num-traits", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-pubkey", +] + +[[package]] +name = "solana-program-memory" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-pubkey" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b62adb9c3261a052ca1f999398c388f1daf558a1b492f60a6d9e64857db4ff1" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "five8", + "five8_const", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-decode-error", + "solana-define-syscall", + "solana-sanitize", + "solana-sha256-hasher", + "wasm-bindgen", +] + +[[package]] +name = "solana-rent" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-reward-info" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18205b69139b1ae0ab8f6e11cdcb627328c0814422ad2482000fa2ca54ae4a2f" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-rpc-client" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d3161ac0918178e674c1f7f1bfac40de3e7ed0383bd65747d63113c156eaeb" +dependencies = [ + "async-trait", + "base64", + "bincode", + "bs58", + "futures", + "indicatif", + "log", + "reqwest", + "reqwest-middleware", + "semver", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-account-decoder-client-types", + "solana-clock", + "solana-commitment-config", + "solana-epoch-info", + "solana-epoch-schedule", + "solana-feature-gate-interface", + "solana-hash", + "solana-instruction", + "solana-message", + "solana-pubkey", + "solana-rpc-client-api", + "solana-signature", + "solana-transaction", + "solana-transaction-error", + "solana-transaction-status-client-types", + "solana-version", + "solana-vote-interface", + "tokio", +] + +[[package]] +name = "solana-rpc-client-api" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dbc138685c79d88a766a8fd825057a74ea7a21e1dd7f8de275ada899540fff7" +dependencies = [ + "anyhow", + "jsonrpc-core", + "reqwest", + "reqwest-middleware", + "serde", + "serde_derive", + "serde_json", + "solana-account-decoder-client-types", + "solana-clock", + "solana-rpc-client-types", + "solana-signer", + "solana-transaction-error", + "solana-transaction-status-client-types", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-rpc-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea428a81729255d895ea47fba9b30fd4dacbfe571a080448121bd0592751676" +dependencies = [ + "base64", + "bs58", + "semver", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-account-decoder-client-types", + "solana-clock", + "solana-commitment-config", + "solana-fee-calculator", + "solana-inflation", + "solana-pubkey", + "solana-transaction-error", + "solana-transaction-status-client-types", + "solana-version", + "spl-generic-token", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-sanitize" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" + +[[package]] +name = "solana-sdk-ids" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f" +dependencies = [ + "solana-pubkey", +] + +[[package]] +name = "solana-sdk-macro" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df" +dependencies = [ + "bs58", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "solana-seed-phrase" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15" +dependencies = [ + "hmac", + "pbkdf2", + "sha2 0.10.9", +] + +[[package]] +name = "solana-serde-varint" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7e155eba458ecfb0107b98236088c3764a09ddf0201ec29e52a0be40857113" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-serialize-utils" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e" +dependencies = [ + "solana-instruction", + "solana-pubkey", + "solana-sanitize", +] + +[[package]] +name = "solana-sha256-hasher" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44" +dependencies = [ + "sha2 0.10.9", + "solana-define-syscall", + "solana-hash", +] + +[[package]] +name = "solana-short-vec" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c54c66f19b9766a56fa0057d060de8378676cb64987533fa088861858fc5a69" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-signature" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c" +dependencies = [ + "ed25519-dalek", + "five8", + "serde", + "serde-big-array", + "serde_derive", + "solana-sanitize", +] + +[[package]] +name = "solana-signer" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b" +dependencies = [ + "solana-pubkey", + "solana-signature", + "solana-transaction-error", +] + +[[package]] +name = "solana-slot-hashes" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-slot-history" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccc1b2067ca22754d5283afb2b0126d61eae734fc616d23871b0943b0d935e" +dependencies = [ + "bv", + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-stable-layout" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54" +dependencies = [ + "solana-instruction", + "solana-pubkey", +] + +[[package]] +name = "solana-stake-interface" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5269e89fde216b4d7e1d1739cf5303f8398a1ff372a81232abbee80e554a838c" +dependencies = [ + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-system-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-svm-feature-set" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f24b836eb4d74ec255217bdbe0f24f64a07adeac31aca61f334f91cd4a3b1d5" + +[[package]] +name = "solana-system-interface" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d7c18cb1a91c6be5f5a8ac9276a1d7c737e39a21beba9ea710ab4b9c63bc90" +dependencies = [ + "js-sys", + "num-traits", + "serde", + "serde_derive", + "solana-decode-error", + "solana-instruction", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-sysvar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db" +dependencies = [ + "base64", + "bincode", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash", + "solana-instruction", + "solana-instructions-sysvar", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey", + "solana-rent", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-sysvar-id" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1" +dependencies = [ + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-transaction" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80657d6088f721148f5d889c828ca60c7daeedac9a8679f9ec215e0c42bcbf41" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-bincode", + "solana-hash", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-signer", + "solana-system-interface", + "solana-transaction-error", + "wasm-bindgen", +] + +[[package]] +name = "solana-transaction-context" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a312304361987a85b2ef2293920558e6612876a639dd1309daf6d0d59ef2fe" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-instruction", + "solana-instructions-sysvar", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", +] + +[[package]] +name = "solana-transaction-error" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1" +dependencies = [ + "serde", + "serde_derive", + "solana-instruction", + "solana-sanitize", +] + +[[package]] +name = "solana-transaction-status-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f1d7c2387c35850848212244d2b225847666cb52d3bd59a5c409d2c300303d" +dependencies = [ + "base64", + "bincode", + "bs58", + "serde", + "serde_derive", + "serde_json", + "solana-account-decoder-client-types", + "solana-commitment-config", + "solana-message", + "solana-reward-info", + "solana-signature", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-version" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3324d46c7f7b7f5d34bf7dc71a2883bdc072c7b28ca81d0b2167ecec4cf8da9f" +dependencies = [ + "agave-feature-set", + "rand 0.8.5", + "semver", + "serde", + "serde_derive", + "solana-sanitize", + "solana-serde-varint", +] + +[[package]] +name = "solana-vote-interface" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" +dependencies = [ + "num-derive", + "num-traits", + "solana-clock", + "solana-decode-error", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-serialize-utils", +] + +[[package]] +name = "spl-generic-token" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741a62a566d97c58d33f9ed32337ceedd4e35109a686e31b1866c5dfa56abddc" +dependencies = [ + "bytemuck", + "solana-pubkey", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/container/vendor/rise/rust/Cargo.toml b/container/vendor/rise/rust/Cargo.toml new file mode 100644 index 00000000000..b4df70625cc --- /dev/null +++ b/container/vendor/rise/rust/Cargo.toml @@ -0,0 +1,47 @@ +[workspace] +members = ["cli", "ix", "math", "sdk", "types"] +resolver = "2" + +[workspace.dependencies] +phoenix-ix = { path = "../ix" } +phoenix-math-utils = { path = "../math" } +phoenix-types = { path = "../types" } + +base64 = "0.22" +bincode = "1.3" +borsh = { version = "1.5", features = ["derive"] } +bs58 = "0.5" +bytemuck = { version = "1.21", features = ["derive"] } +chrono = { version = "0.4", features = ["serde"] } +fixed = "1.30" +futures-util = "0.3" +parking_lot = "0.12" +pastey = "0.1" +reqwest = { version = "0.12", features = ["json"] } +rust_decimal = { version = "1.39.0", features = ["serde-str"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_with = "3.12" +sha2 = "0.10" +sha2-const-stable = "0.1" +solana-commitment-config = { version = "~2.2" } +solana-instruction = { version = "~2.3", default-features = false, features = [ + "std", +] } +solana-keypair = { version = "~2.2" } +solana-pubkey = { version = "~2.4", features = ["curve25519"] } +solana-rpc-client = "~2.3" +solana-rpc-client-api = "~2.3" +solana-signer = { version = "~2.2" } +solana-transaction = { version = "~2.2" } +thiserror = "2.0" +tokio = { version = "1.44", features = [ + "sync", + "rt-multi-thread", + "macros", + "time", +] } +tokio-tungstenite = { version = "0.26", features = ["native-tls"] } +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2.5" diff --git a/container/vendor/rise/rust/cli/Cargo.toml b/container/vendor/rise/rust/cli/Cargo.toml new file mode 100644 index 00000000000..744c6768542 --- /dev/null +++ b/container/vendor/rise/rust/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +edition = "2021" +name = "phoenix-sdk-cli" +publish = false +rust-version = "1.86.0" +version = "0.1.0" + +[dependencies] +phoenix-sdk = { path = "../sdk" } + +clap = { version = "4.5", features = ["derive"] } +serde = { workspace = true } +serde_json = { workspace = true } +solana-pubkey = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } diff --git a/container/vendor/rise/rust/cli/scripts/smoke_http_client.sh b/container/vendor/rise/rust/cli/scripts/smoke_http_client.sh new file mode 100755 index 00000000000..02d0ecb7b8e --- /dev/null +++ b/container/vendor/rise/rust/cli/scripts/smoke_http_client.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -u +set -o pipefail + +usage() { + cat < Trader authority pubkey for trader endpoints + (default: solana-keygen pubkey ~/.config/solana/id.json) + --api-url Phoenix API base URL (optional; falls back to CLI/SDK env defaults) + --api-key API key + --symbol Market symbol (default: SOL) + --timeframe Candle timeframe (default: 1m) + --limit Limit for history endpoints (default: 10) + --cli-cmd CLI invocation prefix (default: "cargo run -q -p phoenix-sdk-cli --") + If run from git repo root, script auto-runs cargo in ./rust. + +Example: + $(basename "$0") --symbol SOL + $(basename "$0") --authority 11111111111111111111111111111111 --symbol SOL +USAGE +} + +API_URL="" +AUTHORITY="" +API_KEY="" +SYMBOL="SOL" +TIMEFRAME="1m" +LIMIT="10" +CLI_CMD="cargo run -q -p phoenix-sdk-cli --" + +while [[ $# -gt 0 ]]; do + case "$1" in + --api-url) + API_URL="${2:-}" + shift 2 + ;; + --authority) + AUTHORITY="${2:-}" + shift 2 + ;; + --api-key) + API_KEY="${2:-}" + shift 2 + ;; + --symbol) + SYMBOL="${2:-}" + shift 2 + ;; + --timeframe) + TIMEFRAME="${2:-}" + shift 2 + ;; + --limit) + LIMIT="${2:-}" + shift 2 + ;; + --cli-cmd) + CLI_CMD="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$AUTHORITY" ]]; then + if ! command -v solana-keygen >/dev/null 2>&1; then + echo "--authority not provided and solana-keygen is not available" >&2 + usage >&2 + exit 1 + fi + + AUTHORITY="$(solana-keygen pubkey ~/.config/solana/id.json 2>/dev/null || true)" + if [[ -z "$AUTHORITY" ]]; then + echo "--authority not provided and failed to read ~/.config/solana/id.json via solana-keygen" >&2 + usage >&2 + exit 1 + fi + + echo "Using authority from ~/.config/solana/id.json: $AUTHORITY" +fi + +base=( $CLI_CMD ) +if [[ -n "$API_URL" ]]; then + base+=( --api-url "$API_URL" ) +fi +if [[ -n "$API_KEY" ]]; then + base+=( --api-key "$API_KEY" ) +fi + +RUN_DIR="$(pwd)" +if [[ ! -f "$RUN_DIR/Cargo.toml" ]] && command -v git >/dev/null 2>&1; then + GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -n "$GIT_ROOT" && -f "$GIT_ROOT/rust/Cargo.toml" ]]; then + RUN_DIR="$GIT_ROOT/rust" + echo "Detected repo layout; running checks from $RUN_DIR" + fi +fi + +failures=() + +run_check() { + local name="$1" + shift + + echo "==> $name" + if ( cd "$RUN_DIR" && "${base[@]}" "$@" >/dev/null ); then + echo " OK" + else + echo " FAIL" + failures+=("$name") + fi +} + +run_check "exchange-keys" http exchange-keys +run_check "markets" http markets +run_check "market" http market --symbol "$SYMBOL" +run_check "exchange" http exchange +run_check "traders" http traders --authority "$AUTHORITY" +run_check "collateral-history" http collateral-history --authority "$AUTHORITY" --pda-index 0 --limit "$LIMIT" +run_check "funding-history" http funding-history --authority "$AUTHORITY" --pda-index 0 --symbol "$SYMBOL" --limit "$LIMIT" +run_check "order-history" http order-history --authority "$AUTHORITY" --limit "$LIMIT" --trader-pda-index 0 --market-symbol "$SYMBOL" +run_check "candles" http candles --symbol "$SYMBOL" --timeframe "$TIMEFRAME" --limit "$LIMIT" +run_check "trade-history" http trade-history --authority "$AUTHORITY" --pda-index 0 --market-symbol "$SYMBOL" --limit "$LIMIT" + +echo +if [[ ${#failures[@]} -eq 0 ]]; then + echo "All HTTP smoke checks passed." + exit 0 +fi + +echo "HTTP smoke checks failed (${#failures[@]}):" +for item in "${failures[@]}"; do + echo " - $item" +done +exit 1 diff --git a/container/vendor/rise/rust/cli/src/main.rs b/container/vendor/rise/rust/cli/src/main.rs new file mode 100644 index 00000000000..93645252b0a --- /dev/null +++ b/container/vendor/rise/rust/cli/src/main.rs @@ -0,0 +1,483 @@ +use std::error::Error; +use std::str::FromStr; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use phoenix_sdk::{ + CandlesQueryParams, CollateralHistoryQueryParams, FundingHistoryQueryParams, + OrderHistoryQueryParams, PhoenixEnv, PhoenixHttpClient, PhoenixWSClient, ServerMessage, + Timeframe, TradeHistoryQueryParams, +}; +use serde::Serialize; +use solana_pubkey::Pubkey; +use tokio::time::timeout; +use url::Url; + +#[derive(Debug, Parser)] +#[command(name = "phoenix-sdk-cli")] +#[command(about = "Tiny smoke-test CLI for Phoenix SDK HTTP + WebSocket clients")] +struct Cli { + /// Base API URL (e.g. https://public-api.phoenix.trade) + #[arg(long, global = true)] + api_url: Option, + + /// Explicit WebSocket URL (defaults to derived from api_url) + #[arg(long, global = true)] + ws_url: Option, + + /// API key (optional) + #[arg(long, global = true)] + api_key: Option, + + /// Pretty-print JSON output + #[arg(long, global = true)] + pretty: bool, + + #[command(subcommand)] + command: RootCommand, +} + +#[derive(Debug, Subcommand)] +enum RootCommand { + Http { + #[command(subcommand)] + command: HttpCommand, + }, + Ws { + #[command(subcommand)] + command: WsCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum HttpCommand { + ExchangeKeys, + Markets, + Market { + #[arg(long)] + symbol: String, + }, + Exchange, + Traders { + #[arg(long)] + authority: String, + }, + CollateralHistory { + #[arg(long)] + authority: String, + #[arg(long, default_value_t = 0)] + pda_index: u8, + #[arg(long, default_value_t = 10)] + limit: i64, + #[arg(long)] + next_cursor: Option, + #[arg(long)] + prev_cursor: Option, + #[arg(long)] + cursor: Option, + }, + FundingHistory { + #[arg(long)] + authority: String, + #[arg(long, default_value_t = 0)] + pda_index: u8, + #[arg(long)] + symbol: Option, + #[arg(long)] + start_time: Option, + #[arg(long)] + end_time: Option, + #[arg(long)] + limit: Option, + #[arg(long)] + cursor: Option, + }, + OrderHistory { + #[arg(long)] + authority: String, + #[arg(long, default_value_t = 10)] + limit: i64, + #[arg(long)] + trader_pda_index: Option, + #[arg(long)] + market_symbol: Option, + #[arg(long)] + cursor: Option, + #[arg(long)] + privy_id: Option, + }, + Candles { + #[arg(long)] + symbol: String, + #[arg(long, default_value = "1m")] + timeframe: String, + #[arg(long)] + start_time: Option, + #[arg(long)] + end_time: Option, + #[arg(long)] + limit: Option, + }, + TradeHistory { + #[arg(long)] + authority: String, + #[arg(long, default_value_t = 0)] + pda_index: u8, + #[arg(long)] + market_symbol: Option, + #[arg(long)] + limit: Option, + #[arg(long)] + cursor: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum WsCommand { + AllMids { + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + FundingRate { + #[arg(long)] + symbol: String, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + Orderbook { + #[arg(long)] + symbol: String, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + Market { + #[arg(long)] + symbol: String, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + Trades { + #[arg(long)] + symbol: String, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + Candles { + #[arg(long)] + symbol: String, + #[arg(long, default_value = "1m")] + timeframe: String, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, + TraderState { + #[arg(long)] + authority: String, + #[arg(long, default_value_t = 0)] + trader_pda_index: u8, + #[arg(long, default_value_t = 10)] + timeout_secs: u64, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + let env = build_env(&cli)?; + + match cli.command { + RootCommand::Http { command } => run_http(command, &env, cli.pretty).await, + RootCommand::Ws { command } => run_ws(command, &env, cli.pretty).await, + } +} + +fn build_env(cli: &Cli) -> Result> { + let mut env = PhoenixEnv::load(); + + if let Some(api_url) = &cli.api_url { + env.api_url = api_url.clone(); + } + + if let Some(api_key) = &cli.api_key { + env.api_key = Some(api_key.clone()); + } + + env.ws_url = if let Some(ws_url) = &cli.ws_url { + ws_url.clone() + } else { + derive_ws_url(&env.api_url)? + }; + + Ok(env) +} + +fn derive_ws_url(api_url: &str) -> Result> { + let mut url = Url::parse(api_url)?; + let scheme = url.scheme(); + + if let Some(rest) = scheme.strip_prefix("http") { + let ws_scheme = format!("ws{}", rest); + let _ = url.set_scheme(&ws_scheme); + } else if scheme != "ws" && scheme != "wss" { + return Err(format!("unsupported URL scheme for api_url: {scheme}").into()); + } + + let mut segments: Vec<&str> = url + .path_segments() + .map(|s| s.filter(|seg| !seg.is_empty()).collect()) + .unwrap_or_default(); + + if segments.last().copied() != Some("ws") { + segments.push("ws"); + } + + url.set_path(&format!("/{}", segments.join("/"))); + url.set_query(None); + url.set_fragment(None); + + Ok(url.to_string()) +} + +async fn run_http(cmd: HttpCommand, env: &PhoenixEnv, pretty: bool) -> Result<(), Box> { + let client = PhoenixHttpClient::from_env(env.clone()); + + match cmd { + HttpCommand::ExchangeKeys => { + let response = client.get_exchange_keys().await?; + print_json(&response, pretty)?; + } + HttpCommand::Markets => { + let response = client.get_markets().await?; + print_json(&response, pretty)?; + } + HttpCommand::Market { symbol } => { + let response = client.get_market(&symbol.to_ascii_uppercase()).await?; + print_json(&response, pretty)?; + } + HttpCommand::Exchange => { + let response = client.get_exchange().await?; + print_json(&response, pretty)?; + } + HttpCommand::Traders { authority } => { + let authority = parse_pubkey(&authority)?; + let response = client.get_traders(&authority).await?; + print_json(&response, pretty)?; + } + HttpCommand::CollateralHistory { + authority, + pda_index, + limit, + next_cursor, + prev_cursor, + cursor, + } => { + let authority = parse_pubkey(&authority)?; + let mut params = CollateralHistoryQueryParams::new(limit).with_pda_index(pda_index); + if let Some(value) = next_cursor { + params = params.with_next_cursor(value); + } + if let Some(value) = prev_cursor { + params = params.with_prev_cursor(value); + } + if let Some(value) = cursor { + params.request.cursor = Some(value); + } + let response = client.get_collateral_history(&authority, params).await?; + print_json(&response, pretty)?; + } + HttpCommand::FundingHistory { + authority, + pda_index, + symbol, + start_time, + end_time, + limit, + cursor, + } => { + let authority = parse_pubkey(&authority)?; + let mut params = FundingHistoryQueryParams::new().with_pda_index(pda_index); + if let Some(value) = symbol { + params = params.with_symbol(value.to_ascii_uppercase()); + } + if let Some(value) = start_time { + params = params.with_start_time(value); + } + if let Some(value) = end_time { + params = params.with_end_time(value); + } + if let Some(value) = limit { + params = params.with_limit(value); + } + if let Some(value) = cursor { + params = params.with_cursor(value); + } + let response = client.get_funding_history(&authority, params).await?; + print_json(&response, pretty)?; + } + HttpCommand::OrderHistory { + authority, + limit, + trader_pda_index, + market_symbol, + cursor, + privy_id, + } => { + let authority = parse_pubkey(&authority)?; + let mut params = OrderHistoryQueryParams::new(limit); + if let Some(value) = trader_pda_index { + params = params.with_pda_index(value); + } + if let Some(value) = market_symbol { + params = params.with_market_symbol(value.to_ascii_uppercase()); + } + if let Some(value) = cursor { + params = params.with_cursor(value); + } + if let Some(value) = privy_id { + params = params.with_privy_id(value); + } + let response = client.get_order_history(&authority, params).await?; + print_json(&response, pretty)?; + } + HttpCommand::Candles { + symbol, + timeframe, + start_time, + end_time, + limit, + } => { + let timeframe = Timeframe::from_str(&timeframe) + .map_err(|e| format!("invalid timeframe '{timeframe}': {e}"))?; + let mut params = CandlesQueryParams::new(symbol.to_ascii_uppercase(), timeframe); + if let Some(value) = start_time { + params = params.with_start_time(value); + } + if let Some(value) = end_time { + params = params.with_end_time(value); + } + if let Some(value) = limit { + params = params.with_limit(value); + } + let response = client.get_candles(params).await?; + print_json(&response, pretty)?; + } + HttpCommand::TradeHistory { + authority, + pda_index, + market_symbol, + limit, + cursor, + } => { + let authority = parse_pubkey(&authority)?; + let mut params = TradeHistoryQueryParams::new().with_pda_index(pda_index); + if let Some(value) = market_symbol { + params = params.with_market_symbol(value.to_ascii_uppercase()); + } + if let Some(value) = limit { + params = params.with_limit(value); + } + if let Some(value) = cursor { + params = params.with_cursor(value); + } + let response = client.get_trade_history(&authority, params).await?; + print_json(&response, pretty)?; + } + } + + Ok(()) +} + +async fn run_ws(cmd: WsCommand, env: &PhoenixEnv, pretty: bool) -> Result<(), Box> { + let client = PhoenixWSClient::from_env_with_connection_status(env.clone())?; + + match cmd { + WsCommand::AllMids { timeout_secs } => { + let (mut rx, _handle) = client.subscribe_to_all_mids()?; + let message = recv_or_timeout(&mut rx, timeout_secs, "allMids").await?; + print_json(&ServerMessage::AllMids(message), pretty)?; + } + WsCommand::FundingRate { + symbol, + timeout_secs, + } => { + let (mut rx, _handle) = + client.subscribe_to_funding_rate(symbol.to_ascii_uppercase())?; + let message = recv_or_timeout(&mut rx, timeout_secs, "fundingRate").await?; + print_json(&ServerMessage::FundingRate(message), pretty)?; + } + WsCommand::Orderbook { + symbol, + timeout_secs, + } => { + let (mut rx, _handle) = client.subscribe_to_orderbook(symbol.to_ascii_uppercase())?; + let message = recv_or_timeout(&mut rx, timeout_secs, "orderbook").await?; + print_json(&ServerMessage::Orderbook(message), pretty)?; + } + WsCommand::Market { + symbol, + timeout_secs, + } => { + let (mut rx, _handle) = client.subscribe_to_market(symbol.to_ascii_uppercase())?; + let message = recv_or_timeout(&mut rx, timeout_secs, "market").await?; + print_json(&ServerMessage::Market(message), pretty)?; + } + WsCommand::Trades { + symbol, + timeout_secs, + } => { + let (mut rx, _handle) = client.subscribe_to_trades(symbol.to_ascii_uppercase())?; + let message = recv_or_timeout(&mut rx, timeout_secs, "trades").await?; + print_json(&ServerMessage::Trades(message), pretty)?; + } + WsCommand::Candles { + symbol, + timeframe, + timeout_secs, + } => { + let timeframe = Timeframe::from_str(&timeframe) + .map_err(|e| format!("invalid timeframe '{timeframe}': {e}"))?; + let (mut rx, _handle) = + client.subscribe_to_candles(symbol.to_ascii_uppercase(), timeframe)?; + let message = recv_or_timeout(&mut rx, timeout_secs, "candles").await?; + print_json(&ServerMessage::Candles(message), pretty)?; + } + WsCommand::TraderState { + authority, + trader_pda_index, + timeout_secs, + } => { + let authority = parse_pubkey(&authority)?; + let (mut rx, _handle) = + client.subscribe_to_trader_state_with_pda(&authority, trader_pda_index)?; + let message = recv_or_timeout(&mut rx, timeout_secs, "traderState").await?; + print_json(&ServerMessage::TraderState(message), pretty)?; + } + } + + Ok(()) +} + +async fn recv_or_timeout( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + timeout_secs: u64, + label: &str, +) -> Result> { + match timeout(Duration::from_secs(timeout_secs), rx.recv()).await { + Ok(Some(message)) => Ok(message), + Ok(None) => Err(format!("{label} channel closed before first message").into()), + Err(_) => Err(format!("timed out waiting for first {label} message").into()), + } +} + +fn parse_pubkey(input: &str) -> Result> { + Pubkey::from_str(input).map_err(|e| format!("invalid pubkey '{input}': {e}").into()) +} + +fn print_json(value: &T, pretty: bool) -> Result<(), Box> { + if pretty { + println!("{}", serde_json::to_string_pretty(value)?); + } else { + println!("{}", serde_json::to_string(value)?); + } + Ok(()) +} diff --git a/container/vendor/rise/rust/ix/Cargo.toml b/container/vendor/rise/rust/ix/Cargo.toml new file mode 100644 index 00000000000..1d1e5a09cd4 --- /dev/null +++ b/container/vendor/rise/rust/ix/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition = "2021" +name = "phoenix-ix" +publish = false +rust-version = "1.86.0" +version = "0.1.0" + +[dependencies] +borsh = { workspace = true } +sha2 = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +thiserror = { workspace = true } diff --git a/container/vendor/rise/rust/ix/src/cancel_orders.rs b/container/vendor/rise/rust/ix/src/cancel_orders.rs new file mode 100644 index 00000000000..a111c82129e --- /dev/null +++ b/container/vendor/rise/rust/ix/src/cancel_orders.rs @@ -0,0 +1,346 @@ +//! Cancel orders by ID instruction construction. + +use borsh::to_vec; +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + cancel_orders_by_id_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, CancelId, Instruction}; + +/// Maximum number of orders that can be cancelled in a single instruction. +pub const MAX_CANCEL_ORDER_IDS: usize = 100; + +/// Parameters for cancelling orders by ID. +#[derive(Debug, Clone)] +pub struct CancelOrdersByIdParams { + trader: Pubkey, + trader_account: Pubkey, + perp_asset_map: Pubkey, + orderbook: Pubkey, + spline_collection: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + order_ids: Vec, +} + +impl CancelOrdersByIdParams { + /// Start building with the builder pattern. + pub fn builder() -> CancelOrdersByIdParamsBuilder { + CancelOrdersByIdParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn orderbook(&self) -> Pubkey { + self.orderbook + } + + pub fn spline_collection(&self) -> Pubkey { + self.spline_collection + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn order_ids(&self) -> &[CancelId] { + &self.order_ids + } +} + +/// Builder for `CancelOrdersByIdParams`. +#[derive(Default)] +pub struct CancelOrdersByIdParamsBuilder { + trader: Option, + trader_account: Option, + perp_asset_map: Option, + orderbook: Option, + spline_collection: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + order_ids: Option>, +} + +impl CancelOrdersByIdParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn orderbook(mut self, orderbook: Pubkey) -> Self { + self.orderbook = Some(orderbook); + self + } + + pub fn spline_collection(mut self, spline_collection: Pubkey) -> Self { + self.spline_collection = Some(spline_collection); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn order_ids(mut self, order_ids: Vec) -> Self { + self.order_ids = Some(order_ids); + self + } + + pub fn build(self) -> Result { + Ok(CancelOrdersByIdParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + orderbook: self + .orderbook + .ok_or(PhoenixIxError::MissingField("orderbook"))?, + spline_collection: self + .spline_collection + .ok_or(PhoenixIxError::MissingField("spline_collection"))?, + global_trader_index: self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?, + active_trader_buffer: self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?, + order_ids: self + .order_ids + .ok_or(PhoenixIxError::MissingField("order_ids"))?, + }) + } +} + +/// Create a cancel orders by ID instruction. +/// +/// # Arguments +/// +/// * `params` - The cancel order parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if: +/// - No order IDs are provided +/// - Too many order IDs (> 100) +/// - Required account arrays are empty +pub fn create_cancel_orders_by_id_ix( + params: CancelOrdersByIdParams, +) -> Result { + validate(¶ms)?; + + let data = encode_cancel_orders(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &CancelOrdersByIdParams) -> Result<(), PhoenixIxError> { + if params.global_trader_index().is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + if params.active_trader_buffer().is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + if params.order_ids().is_empty() { + return Err(PhoenixIxError::NoOrderIds); + } + if params.order_ids().len() > MAX_CANCEL_ORDER_IDS { + return Err(PhoenixIxError::TooManyOrderIds); + } + Ok(()) +} + +fn encode_cancel_orders(params: &CancelOrdersByIdParams) -> Vec { + let mut data = Vec::new(); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&cancel_orders_by_id_discriminant()); + + // Array length as u32 (Borsh array encoding) + let len = params.order_ids().len() as u32; + data.extend_from_slice(&len.to_le_bytes()); + + // Each CancelId + for cancel_id in params.order_ids() { + data.extend_from_slice(&to_vec(cancel_id).expect("serialization should not fail")); + } + + data +} + +fn build_accounts(params: &CancelOrdersByIdParams) -> Vec { + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts (2 accounts) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // CancelOrdersByIdInstructionGroupAccounts + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::readonly_signer(params.trader())); + accounts.push(AccountMeta::writable(params.trader_account())); + accounts.push(AccountMeta::writable(params.perp_asset_map())); + + // Global trader index addresses + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // Active trader buffer addresses + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts.push(AccountMeta::writable(params.orderbook())); + accounts.push(AccountMeta::writable(params.spline_collection())); + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_cancel_orders_ix() { + let params = CancelOrdersByIdParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .order_ids(vec![CancelId::new(50000, 12345)]) + .build() + .unwrap(); + + let ix = create_cancel_orders_by_id_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log accounts + 4 base accounts + 1 global trader index + 1 active trader + // buffer + 2 market accounts = 10 + assert_eq!(ix.accounts.len(), 10); + // Data should start with discriminant + assert_eq!(&ix.data[..8], &cancel_orders_by_id_discriminant()); + } + + #[test] + fn test_cancel_multiple_orders() { + let params = CancelOrdersByIdParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .order_ids(vec![ + CancelId::new(50000, 1), + CancelId::new(50100, 2), + CancelId::new(49900, 3), + ]) + .build() + .unwrap(); + + let ix = create_cancel_orders_by_id_ix(params).unwrap(); + + // Verify data contains array length + let array_len = u32::from_le_bytes([ix.data[8], ix.data[9], ix.data[10], ix.data[11]]); + assert_eq!(array_len, 3); + } + + #[test] + fn test_empty_order_ids_fails() { + let params = CancelOrdersByIdParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .order_ids(vec![]) + .build() + .unwrap(); + + let result = create_cancel_orders_by_id_ix(params); + assert!(matches!(result, Err(PhoenixIxError::NoOrderIds))); + } + + #[test] + fn test_too_many_order_ids_fails() { + let params = CancelOrdersByIdParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .order_ids((0..101).map(|i| CancelId::new(50000, i)).collect()) + .build() + .unwrap(); + + let result = create_cancel_orders_by_id_ix(params); + assert!(matches!(result, Err(PhoenixIxError::TooManyOrderIds))); + } + + #[test] + fn test_builder_missing_required_field() { + let result = CancelOrdersByIdParams::builder() + .trader(Pubkey::new_unique()) + // Missing other required fields + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/cancel_stop_loss.rs b/container/vendor/rise/rust/ix/src/cancel_stop_loss.rs new file mode 100644 index 00000000000..da18390f0c1 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/cancel_stop_loss.rs @@ -0,0 +1,242 @@ +//! Cancel stop loss instruction construction. +//! +//! Cancels an active stop-loss or take-profit order for a given market and +//! execution direction. If both directions are inactive after cancellation, +//! the on-chain account is closed and rent is reclaimed to the funder. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, SYSTEM_PROGRAM_ID, + cancel_stop_loss_discriminant, get_stop_loss_address, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Direction, Instruction}; + +/// Parameters for cancelling a stop loss order. +#[derive(Debug, Clone)] +pub struct CancelStopLossParams { + funder: Pubkey, + trader_account: Pubkey, + position_authority: Pubkey, + asset_id: u64, + execution_direction: Direction, +} + +impl CancelStopLossParams { + pub fn builder() -> CancelStopLossParamsBuilder { + CancelStopLossParamsBuilder::new() + } + + pub fn funder(&self) -> Pubkey { + self.funder + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn position_authority(&self) -> Pubkey { + self.position_authority + } + + pub fn asset_id(&self) -> u64 { + self.asset_id + } + + pub fn execution_direction(&self) -> Direction { + self.execution_direction + } +} + +/// Builder for `CancelStopLossParams`. +#[derive(Default)] +pub struct CancelStopLossParamsBuilder { + funder: Option, + trader_account: Option, + position_authority: Option, + asset_id: Option, + execution_direction: Option, +} + +impl CancelStopLossParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn funder(mut self, funder: Pubkey) -> Self { + self.funder = Some(funder); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn position_authority(mut self, position_authority: Pubkey) -> Self { + self.position_authority = Some(position_authority); + self + } + + pub fn asset_id(mut self, asset_id: u64) -> Self { + self.asset_id = Some(asset_id); + self + } + + pub fn execution_direction(mut self, execution_direction: Direction) -> Self { + self.execution_direction = Some(execution_direction); + self + } + + pub fn build(self) -> Result { + Ok(CancelStopLossParams { + funder: self.funder.ok_or(PhoenixIxError::MissingField("funder"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + position_authority: self + .position_authority + .ok_or(PhoenixIxError::MissingField("position_authority"))?, + asset_id: self + .asset_id + .ok_or(PhoenixIxError::MissingField("asset_id"))?, + execution_direction: self + .execution_direction + .ok_or(PhoenixIxError::MissingField("execution_direction"))?, + }) + } +} + +/// Create a cancel stop loss instruction. +pub fn create_cancel_stop_loss_ix( + params: CancelStopLossParams, +) -> Result { + let data = encode_cancel_stop_loss(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_cancel_stop_loss(params: &CancelStopLossParams) -> Vec { + let mut data = Vec::with_capacity(9); + + // 8 bytes: discriminant + data.extend_from_slice(&cancel_stop_loss_discriminant()); + // 1 byte: execution_direction + data.push(params.execution_direction as u8); + + data +} + +fn build_accounts(params: &CancelStopLossParams) -> Vec { + let stop_loss_pda = get_stop_loss_address(¶ms.trader_account, params.asset_id); + + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // CancelStopLossInstructionGroupAccounts + accounts.push(AccountMeta::readonly(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::writable_signer(params.funder)); + accounts.push(AccountMeta::readonly(params.trader_account)); + accounts.push(AccountMeta::readonly_signer(params.position_authority)); + accounts.push(AccountMeta::writable(stop_loss_pda)); + accounts.push(AccountMeta::readonly(SYSTEM_PROGRAM_ID)); + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_params() -> CancelStopLossParams { + CancelStopLossParams::builder() + .funder(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .position_authority(Pubkey::new_unique()) + .asset_id(1) + .execution_direction(Direction::LessThan) + .build() + .unwrap() + } + + #[test] + fn test_create_cancel_stop_loss_ix() { + let params = test_params(); + let ix = create_cancel_stop_loss_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log + 6 (global_config, funder, trader, authority, stop_loss, system) = 8 + assert_eq!(ix.accounts.len(), 8); + assert_eq!(&ix.data[..8], &cancel_stop_loss_discriminant()); + } + + #[test] + fn test_cancel_stop_loss_data_encoding() { + let params = test_params(); + let data = encode_cancel_stop_loss(¶ms); + + // 8 discriminant + 1 direction = 9 + assert_eq!(data.len(), 9); + assert_eq!(data[8], Direction::LessThan as u8); + } + + #[test] + fn test_cancel_stop_loss_account_positions() { + let params = test_params(); + let accounts = build_accounts(¶ms); + + // Position 0: program id (readonly) + assert_eq!(accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!accounts[0].is_signer); + assert!(!accounts[0].is_writable); + + // Position 1: log authority (readonly) + assert_eq!(accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!accounts[1].is_signer); + + // Position 2: global config (readonly) + assert_eq!(accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(!accounts[2].is_writable); + + // Position 3: funder (writable signer) + assert_eq!(accounts[3].pubkey, params.funder); + assert!(accounts[3].is_signer); + assert!(accounts[3].is_writable); + + // Position 4: trader_account (readonly) + assert_eq!(accounts[4].pubkey, params.trader_account); + assert!(!accounts[4].is_writable); + + // Position 5: position_authority (readonly signer) + assert_eq!(accounts[5].pubkey, params.position_authority); + assert!(accounts[5].is_signer); + assert!(!accounts[5].is_writable); + + // Position 6: stop_loss_account (writable) + let expected_sl_pda = get_stop_loss_address(¶ms.trader_account, params.asset_id); + assert_eq!(accounts[6].pubkey, expected_sl_pda); + assert!(accounts[6].is_writable); + + // Position 7: system_program (readonly) + assert_eq!(accounts[7].pubkey, SYSTEM_PROGRAM_ID); + assert!(!accounts[7].is_signer); + assert!(!accounts[7].is_writable); + } + + #[test] + fn test_builder_missing_required_field() { + let result = CancelStopLossParams::builder() + .funder(Pubkey::new_unique()) + .build(); + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/constants.rs b/container/vendor/rise/rust/ix/src/constants.rs new file mode 100644 index 00000000000..40ec66f3712 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/constants.rs @@ -0,0 +1,323 @@ +//! Phoenix program constants and addresses. + +use sha2::{Digest, Sha256}; +use solana_pubkey::Pubkey; + +/// The Phoenix program ID (mainnet). +pub const PHOENIX_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih"); + +/// The Phoenix log authority address (mainnet). +pub const PHOENIX_LOG_AUTHORITY: Pubkey = + solana_pubkey::pubkey!("GdxfTLSsdSY37G6fZoYtdGDSfgFnbT2EmRpuePZxWShS"); + +/// The Phoenix global configuration address (mainnet). +pub const PHOENIX_GLOBAL_CONFIGURATION: Pubkey = + solana_pubkey::pubkey!("2zskx2iyCvb6Stg7RBZkt1f6MrF4dpYtMG3yMvKwqtUZ"); + +/// The Ember program ID (for USDC -> Phoenix token conversion). +pub const EMBER_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("EMBERpYNE6ehWmXymZZS2skiFmCa9V5dp14e1iduM5qy"); + +/// USDC mint address (mainnet). +pub const USDC_MINT: Pubkey = + solana_pubkey::pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + +/// SPL Token program ID. +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + +/// Associated Token program ID. +pub const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); + +/// System program ID. +pub const SYSTEM_PROGRAM_ID: Pubkey = solana_pubkey::pubkey!("11111111111111111111111111111111"); + +/// Compute the instruction discriminant using SHA-256. +/// Takes the first 8 bytes of SHA-256 hash of the input string. +pub fn compute_discriminant(input: &str) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + let mut discriminant = [0u8; 8]; + discriminant.copy_from_slice(&result[..8]); + discriminant +} + +/// Instruction discriminant for place_limit_order. +pub fn place_limit_order_discriminant() -> [u8; 8] { + compute_discriminant("global:place_limit_order") +} + +/// Instruction discriminant for place_market_order. +pub fn place_market_order_discriminant() -> [u8; 8] { + compute_discriminant("global:place_market_order") +} + +/// Instruction discriminant for cancel_orders_by_id. +pub fn cancel_orders_by_id_discriminant() -> [u8; 8] { + compute_discriminant("global:cancel_orders_by_id") +} + +/// Instruction discriminant for deposit_funds. +pub fn deposit_funds_discriminant() -> [u8; 8] { + compute_discriminant("global:deposit_funds") +} + +/// Instruction discriminant for ember deposit. +pub fn ember_deposit_discriminant() -> [u8; 8] { + compute_discriminant("global:deposit") +} + +/// Instruction discriminant for withdraw_funds. +pub fn withdraw_funds_discriminant() -> [u8; 8] { + compute_discriminant("global:withdraw_funds") +} + +/// Instruction discriminant for ember withdraw. +pub fn ember_withdraw_discriminant() -> [u8; 8] { + compute_discriminant("global:withdraw") +} + +/// Instruction discriminant for register_trader. +pub fn register_trader_discriminant() -> [u8; 8] { + compute_discriminant("global:register_trader") +} + +/// Instruction discriminant for transfer_collateral. +pub fn transfer_collateral_discriminant() -> [u8; 8] { + compute_discriminant("global:transfer_collateral") +} + +/// Instruction discriminant for transfer_collateral_child_to_parent. +pub fn transfer_collateral_child_to_parent_discriminant() -> [u8; 8] { + compute_discriminant("global:transfer_collateral_child_to_parent") +} + +/// Instruction discriminant for sync_parent_to_child. +pub fn sync_parent_to_child_discriminant() -> [u8; 8] { + compute_discriminant("global:sync_parent_to_child") +} + +/// Instruction discriminant for place_stop_loss. +pub fn place_stop_loss_discriminant() -> [u8; 8] { + compute_discriminant("global:place_stop_loss") +} + +/// Instruction discriminant for place_multi_limit_order. +pub fn place_multi_limit_order_discriminant() -> [u8; 8] { + compute_discriminant("global:place_multi_limit_order") +} + +/// Instruction discriminant for cancel_stop_loss. +pub fn cancel_stop_loss_discriminant() -> [u8; 8] { + compute_discriminant("global:cancel_stop_loss") +} + +/// Derives the stop loss PDA for a given trader account and asset ID. +/// +/// Seeds: ["stoploss", trader_account, &asset_id.to_le_bytes()] +pub fn get_stop_loss_address(trader_account: &Pubkey, asset_id: u64) -> Pubkey { + let (pda, _bump) = Pubkey::find_program_address( + &[ + b"stoploss", + trader_account.as_ref(), + &asset_id.to_le_bytes(), + ], + &PHOENIX_PROGRAM_ID, + ); + pda +} + +/// Derives the spline collection PDA for a given market (orderbook) address. +/// +/// Seeds: ["spline", market_address] +pub fn get_spline_collection_address(market: &Pubkey) -> Pubkey { + let (pda, _bump) = + Pubkey::find_program_address(&[b"spline", market.as_ref()], &PHOENIX_PROGRAM_ID); + pda +} + +/// Derives the Ember state PDA. +/// +/// Seeds: [phoenix_program_id, "state"] against Ember program +pub fn get_ember_state_address() -> Pubkey { + let (pda, _bump) = + Pubkey::find_program_address(&[PHOENIX_PROGRAM_ID.as_ref(), b"state"], &EMBER_PROGRAM_ID); + pda +} + +/// Derives the Ember vault PDA. +/// +/// Seeds: [phoenix_program_id, "vault"] against Ember program +pub fn get_ember_vault_address() -> Pubkey { + let (pda, _bump) = + Pubkey::find_program_address(&[PHOENIX_PROGRAM_ID.as_ref(), b"vault"], &EMBER_PROGRAM_ID); + pda +} + +/// Derives the global vault PDA for a given mint. +/// +/// Seeds: ["vault", mint] against Phoenix program +pub fn get_global_vault_address(mint: &Pubkey) -> Pubkey { + let (pda, _bump) = + Pubkey::find_program_address(&[b"vault", mint.as_ref()], &PHOENIX_PROGRAM_ID); + pda +} + +/// Derives the associated token address for an owner and mint. +/// +/// This follows the standard SPL ATA derivation. +pub fn get_associated_token_address(owner: &Pubkey, mint: &Pubkey) -> Pubkey { + let (pda, _bump) = Pubkey::find_program_address( + &[owner.as_ref(), SPL_TOKEN_PROGRAM_ID.as_ref(), mint.as_ref()], + &ASSOCIATED_TOKEN_PROGRAM_ID, + ); + pda +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discriminant_computation() { + // These values should match the TypeScript SDK + let limit_disc = place_limit_order_discriminant(); + let market_disc = place_market_order_discriminant(); + let cancel_disc = cancel_orders_by_id_discriminant(); + + // Discriminants should be 8 bytes and non-zero + assert_ne!(limit_disc, [0u8; 8]); + assert_ne!(market_disc, [0u8; 8]); + assert_ne!(cancel_disc, [0u8; 8]); + + // Each discriminant should be unique + assert_ne!(limit_disc, market_disc); + assert_ne!(limit_disc, cancel_disc); + assert_ne!(market_disc, cancel_disc); + } + + #[test] + fn test_spline_collection_pda_derivation() { + // Test that PDA derivation is deterministic + let market = Pubkey::new_unique(); + let pda1 = get_spline_collection_address(&market); + let pda2 = get_spline_collection_address(&market); + assert_eq!(pda1, pda2); + + // Different markets should produce different PDAs + let market2 = Pubkey::new_unique(); + let pda3 = get_spline_collection_address(&market2); + assert_ne!(pda1, pda3); + } + + #[test] + fn test_deposit_discriminants() { + let deposit_disc = deposit_funds_discriminant(); + let ember_disc = ember_deposit_discriminant(); + + // Discriminants should be non-zero and unique + assert_ne!(deposit_disc, [0u8; 8]); + assert_ne!(ember_disc, [0u8; 8]); + assert_ne!(deposit_disc, ember_disc); + } + + #[test] + fn test_register_trader_discriminant() { + let disc = register_trader_discriminant(); + assert_ne!(disc, [0u8; 8]); + assert_ne!(disc, place_limit_order_discriminant()); + assert_ne!(disc, place_market_order_discriminant()); + assert_ne!(disc, cancel_orders_by_id_discriminant()); + assert_ne!(disc, deposit_funds_discriminant()); + assert_ne!(disc, withdraw_funds_discriminant()); + } + + #[test] + fn test_withdraw_discriminants() { + let withdraw_disc = withdraw_funds_discriminant(); + let ember_withdraw_disc = ember_withdraw_discriminant(); + let deposit_disc = deposit_funds_discriminant(); + let ember_deposit_disc = ember_deposit_discriminant(); + + // Discriminants should be non-zero + assert_ne!(withdraw_disc, [0u8; 8]); + assert_ne!(ember_withdraw_disc, [0u8; 8]); + + // All discriminants should be unique + assert_ne!(withdraw_disc, ember_withdraw_disc); + assert_ne!(withdraw_disc, deposit_disc); + assert_ne!(ember_withdraw_disc, ember_deposit_disc); + } + + #[test] + fn test_ember_pda_derivation() { + // Ember PDAs should be deterministic + let state1 = get_ember_state_address(); + let state2 = get_ember_state_address(); + assert_eq!(state1, state2); + + let vault1 = get_ember_vault_address(); + let vault2 = get_ember_vault_address(); + assert_eq!(vault1, vault2); + + // State and vault should be different + assert_ne!(state1, vault1); + } + + #[test] + fn test_global_vault_pda_derivation() { + let mint = Pubkey::new_unique(); + let vault1 = get_global_vault_address(&mint); + let vault2 = get_global_vault_address(&mint); + assert_eq!(vault1, vault2); + + // Different mints should produce different vaults + let mint2 = Pubkey::new_unique(); + let vault3 = get_global_vault_address(&mint2); + assert_ne!(vault1, vault3); + } + + #[test] + fn test_stop_loss_discriminant() { + let disc = place_stop_loss_discriminant(); + assert_ne!(disc, [0u8; 8]); + assert_ne!(disc, place_limit_order_discriminant()); + assert_ne!(disc, place_market_order_discriminant()); + assert_ne!(disc, cancel_orders_by_id_discriminant()); + } + + #[test] + fn test_stop_loss_pda_derivation() { + let trader_account = Pubkey::new_unique(); + let asset_id: u64 = 42; + let pda1 = get_stop_loss_address(&trader_account, asset_id); + let pda2 = get_stop_loss_address(&trader_account, asset_id); + assert_eq!(pda1, pda2); + + // Different asset_id should produce different PDA + let pda3 = get_stop_loss_address(&trader_account, 99); + assert_ne!(pda1, pda3); + + // Different trader should produce different PDA + let trader2 = Pubkey::new_unique(); + let pda4 = get_stop_loss_address(&trader2, asset_id); + assert_ne!(pda1, pda4); + } + + #[test] + fn test_ata_derivation() { + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata1 = get_associated_token_address(&owner, &mint); + let ata2 = get_associated_token_address(&owner, &mint); + assert_eq!(ata1, ata2); + + // Different owner or mint should produce different ATA + let owner2 = Pubkey::new_unique(); + let ata3 = get_associated_token_address(&owner2, &mint); + assert_ne!(ata1, ata3); + } +} diff --git a/container/vendor/rise/rust/ix/src/create_ata.rs b/container/vendor/rise/rust/ix/src/create_ata.rs new file mode 100644 index 00000000000..2398815be1b --- /dev/null +++ b/container/vendor/rise/rust/ix/src/create_ata.rs @@ -0,0 +1,86 @@ +//! Create Associated Token Account instruction construction. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + ASSOCIATED_TOKEN_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, + get_associated_token_address, +}; +use crate::types::{AccountMeta, Instruction}; + +/// Create an idempotent Associated Token Account instruction. +/// +/// This instruction creates an ATA for the owner if it doesn't exist, +/// and does nothing if it already exists. +/// +/// # Arguments +/// +/// * `payer` - The account that will pay for the ATA creation +/// * `owner` - The owner of the ATA +/// * `mint` - The token mint +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +pub fn create_associated_token_account_idempotent_ix( + payer: Pubkey, + owner: Pubkey, + mint: Pubkey, +) -> Instruction { + let ata = get_associated_token_address(&owner, &mint); + + let accounts = vec![ + AccountMeta::writable_signer(payer), + AccountMeta::writable(ata), + AccountMeta::readonly(owner), + AccountMeta::readonly(mint), + AccountMeta::readonly(SYSTEM_PROGRAM_ID), + AccountMeta::readonly(SPL_TOKEN_PROGRAM_ID), + ]; + + // CreateIdempotent discriminant is 1 + let data = vec![1u8]; + + Instruction { + program_id: ASSOCIATED_TOKEN_PROGRAM_ID, + accounts, + data, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_ata_idempotent_ix() { + let payer = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + + let ix = create_associated_token_account_idempotent_ix(payer, owner, mint); + + assert_eq!(ix.program_id, ASSOCIATED_TOKEN_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 6); + assert_eq!(ix.data, vec![1u8]); + + // Verify account order + assert_eq!(ix.accounts[0].pubkey, payer); + assert!(ix.accounts[0].is_signer); + assert!(ix.accounts[0].is_writable); + + // ATA should be writable but not signer + let expected_ata = get_associated_token_address(&owner, &mint); + assert_eq!(ix.accounts[1].pubkey, expected_ata); + assert!(ix.accounts[1].is_writable); + assert!(!ix.accounts[1].is_signer); + + // Owner should be readonly + assert_eq!(ix.accounts[2].pubkey, owner); + assert!(!ix.accounts[2].is_writable); + + // Mint should be readonly + assert_eq!(ix.accounts[3].pubkey, mint); + assert!(!ix.accounts[3].is_writable); + } +} diff --git a/container/vendor/rise/rust/ix/src/deposit_funds.rs b/container/vendor/rise/rust/ix/src/deposit_funds.rs new file mode 100644 index 00000000000..324387583b3 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/deposit_funds.rs @@ -0,0 +1,420 @@ +//! Deposit funds instruction construction. +//! +//! This module provides instruction building for depositing Phoenix tokens +//! into the Phoenix protocol. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, + deposit_funds_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for depositing Phoenix tokens into the protocol. +#[derive(Debug, Clone)] +pub struct DepositFundsParams { + /// The trader's authority (wallet) - must sign. + trader: Pubkey, + /// The trader's PDA account. + trader_account: Pubkey, + /// The canonical mint (Phoenix token mint). + canonical_mint: Pubkey, + /// The global vault (Phoenix protocol vault for the mint). + global_vault: Pubkey, + /// The trader's token account (ATA for Phoenix tokens). + trader_token_account: Pubkey, + /// Global trader index addresses (header + arenas). + global_trader_index: Vec, + /// Active trader buffer addresses (header + arenas). + active_trader_buffer: Vec, + /// Amount to deposit in token base units. + amount: u64, +} + +impl DepositFundsParams { + /// Start building with the builder pattern. + pub fn builder() -> DepositFundsParamsBuilder { + DepositFundsParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn canonical_mint(&self) -> Pubkey { + self.canonical_mint + } + + pub fn global_vault(&self) -> Pubkey { + self.global_vault + } + + pub fn trader_token_account(&self) -> Pubkey { + self.trader_token_account + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Builder for `DepositFundsParams`. +#[derive(Default)] +pub struct DepositFundsParamsBuilder { + trader: Option, + trader_account: Option, + canonical_mint: Option, + global_vault: Option, + trader_token_account: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + amount: Option, +} + +impl DepositFundsParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn canonical_mint(mut self, canonical_mint: Pubkey) -> Self { + self.canonical_mint = Some(canonical_mint); + self + } + + pub fn global_vault(mut self, global_vault: Pubkey) -> Self { + self.global_vault = Some(global_vault); + self + } + + pub fn trader_token_account(mut self, trader_token_account: Pubkey) -> Self { + self.trader_token_account = Some(trader_token_account); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + let amount = self.amount.ok_or(PhoenixIxError::MissingField("amount"))?; + if amount == 0 { + return Err(PhoenixIxError::InvalidDepositAmount); + } + + let global_trader_index = self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?; + if global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + + let active_trader_buffer = self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?; + if active_trader_buffer.is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + + Ok(DepositFundsParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + canonical_mint: self + .canonical_mint + .ok_or(PhoenixIxError::MissingField("canonical_mint"))?, + global_vault: self + .global_vault + .ok_or(PhoenixIxError::MissingField("global_vault"))?, + trader_token_account: self + .trader_token_account + .ok_or(PhoenixIxError::MissingField("trader_token_account"))?, + global_trader_index, + active_trader_buffer, + amount, + }) + } +} + +/// Create a deposit funds instruction. +/// +/// This instruction deposits Phoenix tokens from the trader's token account +/// into the Phoenix protocol. +/// +/// # Arguments +/// +/// * `params` - The deposit funds parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing, amount is zero, +/// or trader index arrays are empty. +pub fn create_deposit_funds_ix(params: DepositFundsParams) -> Result { + let data = encode_deposit_funds(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_deposit_funds(params: &DepositFundsParams) -> Vec { + let mut data = Vec::with_capacity(16); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&deposit_funds_discriminant()); + + // Amount (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.amount().to_le_bytes()); + + data +} + +fn build_accounts(params: &DepositFundsParams) -> Vec { + let mut accounts = Vec::new(); + + // 1. phoenix_program (readonly) - Log accounts + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + // 2. phoenix_log_authority (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + // 3. global_configuration_account (writable) + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + // 4. trader_wallet (signer, readonly) + accounts.push(AccountMeta::readonly_signer(params.trader())); + // 5. trader_token_account (writable) - Owner's Phoenix token ATA + accounts.push(AccountMeta::writable(params.trader_token_account())); + // 6. trader_account (writable) - Trader PDA + accounts.push(AccountMeta::writable(params.trader_account())); + // 7. global_vault (writable) + accounts.push(AccountMeta::writable(params.global_vault())); + // 8. token_program (readonly) + accounts.push(AccountMeta::readonly(SPL_TOKEN_PROGRAM_ID)); + + // 9-N. global_trader_index addresses (all writable) + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // N+1-M. active_trader_buffer addresses (all writable) + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_deposit_funds_ix() { + let params = DepositFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(100_000_000) + .build() + .unwrap(); + + let ix = create_deposit_funds_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 8 base accounts + 1 global_trader_index + 1 active_trader_buffer = 10 + assert_eq!(ix.accounts.len(), 10); + + // Verify data encoding + assert_eq!(&ix.data[..8], &deposit_funds_discriminant()); + let amount_bytes: [u8; 8] = ix.data[8..16].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_deposit_funds_zero_amount() { + let result = DepositFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(0) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::InvalidDepositAmount))); + } + + #[test] + fn test_deposit_funds_empty_global_trader_index() { + let result = DepositFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_deposit_funds_empty_active_trader_buffer() { + let result = DepositFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![]) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyActiveTraderBuffer) + )); + } + + #[test] + fn test_deposit_funds_account_order() { + let trader = Pubkey::new_unique(); + let trader_account = Pubkey::new_unique(); + let canonical_mint = Pubkey::new_unique(); + let global_vault = Pubkey::new_unique(); + let trader_token_account = Pubkey::new_unique(); + let gti = Pubkey::new_unique(); + let atb = Pubkey::new_unique(); + + let params = DepositFundsParams::builder() + .trader(trader) + .trader_account(trader_account) + .canonical_mint(canonical_mint) + .global_vault(global_vault) + .trader_token_account(trader_token_account) + .global_trader_index(vec![gti]) + .active_trader_buffer(vec![atb]) + .amount(1) + .build() + .unwrap(); + + let ix = create_deposit_funds_ix(params).unwrap(); + + // Verify account order + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_writable); + + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_writable); + + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(ix.accounts[2].is_writable); + + assert_eq!(ix.accounts[3].pubkey, trader); + assert!(ix.accounts[3].is_signer); + assert!(!ix.accounts[3].is_writable); + + assert_eq!(ix.accounts[4].pubkey, trader_token_account); + assert!(ix.accounts[4].is_writable); + + assert_eq!(ix.accounts[5].pubkey, trader_account); + assert!(ix.accounts[5].is_writable); + + assert_eq!(ix.accounts[6].pubkey, global_vault); + assert!(ix.accounts[6].is_writable); + + assert_eq!(ix.accounts[7].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!ix.accounts[7].is_writable); + + assert_eq!(ix.accounts[8].pubkey, gti); + assert!(ix.accounts[8].is_writable); + + assert_eq!(ix.accounts[9].pubkey, atb); + assert!(ix.accounts[9].is_writable); + } + + #[test] + fn test_deposit_funds_multiple_index_accounts() { + let gti1 = Pubkey::new_unique(); + let gti2 = Pubkey::new_unique(); + let atb1 = Pubkey::new_unique(); + let atb2 = Pubkey::new_unique(); + + let params = DepositFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![gti1, gti2]) + .active_trader_buffer(vec![atb1, atb2]) + .amount(1) + .build() + .unwrap(); + + let ix = create_deposit_funds_ix(params).unwrap(); + + // 8 base accounts + 2 gti + 2 atb = 12 + assert_eq!(ix.accounts.len(), 12); + + // Verify gti accounts + assert_eq!(ix.accounts[8].pubkey, gti1); + assert_eq!(ix.accounts[9].pubkey, gti2); + + // Verify atb accounts + assert_eq!(ix.accounts[10].pubkey, atb1); + assert_eq!(ix.accounts[11].pubkey, atb2); + } +} diff --git a/container/vendor/rise/rust/ix/src/ember_deposit.rs b/container/vendor/rise/rust/ix/src/ember_deposit.rs new file mode 100644 index 00000000000..1f56369150b --- /dev/null +++ b/container/vendor/rise/rust/ix/src/ember_deposit.rs @@ -0,0 +1,335 @@ +//! Ember deposit instruction construction. +//! +//! This module provides instruction building for depositing USDC and receiving +//! Phoenix tokens via the Ember program. + +use solana_pubkey::Pubkey; + +use crate::constants::{EMBER_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, ember_deposit_discriminant}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for depositing USDC and receiving Phoenix tokens via Ember. +#[derive(Debug, Clone)] +pub struct EmberDepositParams { + /// The trader's authority (wallet) - must sign. + trader: Pubkey, + /// Ember state PDA. + ember_state: Pubkey, + /// Ember vault PDA (holds USDC). + ember_vault: Pubkey, + /// USDC mint. + usdc_mint: Pubkey, + /// Phoenix token mint (canonical mint). + canonical_mint: Pubkey, + /// Trader's USDC token account (source). + trader_usdc_account: Pubkey, + /// Trader's Phoenix token account (destination). + trader_phoenix_account: Pubkey, + /// Amount of USDC to deposit (in USDC base units, 6 decimals). + amount: u64, +} + +impl EmberDepositParams { + /// Start building with the builder pattern. + pub fn builder() -> EmberDepositParamsBuilder { + EmberDepositParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn ember_state(&self) -> Pubkey { + self.ember_state + } + + pub fn ember_vault(&self) -> Pubkey { + self.ember_vault + } + + pub fn usdc_mint(&self) -> Pubkey { + self.usdc_mint + } + + pub fn canonical_mint(&self) -> Pubkey { + self.canonical_mint + } + + pub fn trader_usdc_account(&self) -> Pubkey { + self.trader_usdc_account + } + + pub fn trader_phoenix_account(&self) -> Pubkey { + self.trader_phoenix_account + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Builder for `EmberDepositParams`. +#[derive(Default)] +pub struct EmberDepositParamsBuilder { + trader: Option, + ember_state: Option, + ember_vault: Option, + usdc_mint: Option, + canonical_mint: Option, + trader_usdc_account: Option, + trader_phoenix_account: Option, + amount: Option, +} + +impl EmberDepositParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn ember_state(mut self, ember_state: Pubkey) -> Self { + self.ember_state = Some(ember_state); + self + } + + pub fn ember_vault(mut self, ember_vault: Pubkey) -> Self { + self.ember_vault = Some(ember_vault); + self + } + + pub fn usdc_mint(mut self, usdc_mint: Pubkey) -> Self { + self.usdc_mint = Some(usdc_mint); + self + } + + pub fn canonical_mint(mut self, canonical_mint: Pubkey) -> Self { + self.canonical_mint = Some(canonical_mint); + self + } + + pub fn trader_usdc_account(mut self, trader_usdc_account: Pubkey) -> Self { + self.trader_usdc_account = Some(trader_usdc_account); + self + } + + pub fn trader_phoenix_account(mut self, trader_phoenix_account: Pubkey) -> Self { + self.trader_phoenix_account = Some(trader_phoenix_account); + self + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + let amount = self.amount.ok_or(PhoenixIxError::MissingField("amount"))?; + if amount == 0 { + return Err(PhoenixIxError::InvalidDepositAmount); + } + + Ok(EmberDepositParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + ember_state: self + .ember_state + .ok_or(PhoenixIxError::MissingField("ember_state"))?, + ember_vault: self + .ember_vault + .ok_or(PhoenixIxError::MissingField("ember_vault"))?, + usdc_mint: self + .usdc_mint + .ok_or(PhoenixIxError::MissingField("usdc_mint"))?, + canonical_mint: self + .canonical_mint + .ok_or(PhoenixIxError::MissingField("canonical_mint"))?, + trader_usdc_account: self + .trader_usdc_account + .ok_or(PhoenixIxError::MissingField("trader_usdc_account"))?, + trader_phoenix_account: self + .trader_phoenix_account + .ok_or(PhoenixIxError::MissingField("trader_phoenix_account"))?, + amount, + }) + } +} + +/// Create an Ember deposit instruction. +/// +/// This instruction deposits USDC into the Ember program and mints Phoenix +/// tokens to the trader's account. +/// +/// # Arguments +/// +/// * `params` - The Ember deposit parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing or amount is zero. +pub fn create_ember_deposit_ix(params: EmberDepositParams) -> Result { + let data = encode_ember_deposit(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: EMBER_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_ember_deposit(params: &EmberDepositParams) -> Vec { + let mut data = Vec::with_capacity(16); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&ember_deposit_discriminant()); + + // Amount (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.amount().to_le_bytes()); + + data +} + +fn build_accounts(params: &EmberDepositParams) -> Vec { + vec![ + // 1. owner (signer, readonly) + AccountMeta::readonly_signer(params.trader()), + // 2. ember_state (readonly) + AccountMeta::readonly(params.ember_state()), + // 3. input_mint (readonly) - USDC + AccountMeta::readonly(params.usdc_mint()), + // 4. output_mint (writable) - Phoenix token + AccountMeta::writable(params.canonical_mint()), + // 5. input_token_account (writable) - owner's USDC ATA + AccountMeta::writable(params.trader_usdc_account()), + // 6. output_token_account (writable) - owner's Phoenix token ATA + AccountMeta::writable(params.trader_phoenix_account()), + // 7. ember_vault (writable) + AccountMeta::writable(params.ember_vault()), + // 8. spl_token (readonly) + AccountMeta::readonly(SPL_TOKEN_PROGRAM_ID), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_ember_deposit_ix() { + let params = EmberDepositParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .amount(100_000_000) // $100 USDC + .build() + .unwrap(); + + let ix = create_ember_deposit_ix(params).unwrap(); + + assert_eq!(ix.program_id, EMBER_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 8); + + // Verify data encoding + assert_eq!(&ix.data[..8], &ember_deposit_discriminant()); + let amount_bytes: [u8; 8] = ix.data[8..16].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_ember_deposit_missing_amount() { + let result = EmberDepositParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::MissingField("amount")) + )); + } + + #[test] + fn test_ember_deposit_zero_amount() { + let result = EmberDepositParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .amount(0) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::InvalidDepositAmount))); + } + + #[test] + fn test_ember_deposit_account_order() { + let trader = Pubkey::new_unique(); + let ember_state = Pubkey::new_unique(); + let ember_vault = Pubkey::new_unique(); + let usdc_mint = Pubkey::new_unique(); + let canonical_mint = Pubkey::new_unique(); + let trader_usdc = Pubkey::new_unique(); + let trader_phoenix = Pubkey::new_unique(); + + let params = EmberDepositParams::builder() + .trader(trader) + .ember_state(ember_state) + .ember_vault(ember_vault) + .usdc_mint(usdc_mint) + .canonical_mint(canonical_mint) + .trader_usdc_account(trader_usdc) + .trader_phoenix_account(trader_phoenix) + .amount(1) + .build() + .unwrap(); + + let ix = create_ember_deposit_ix(params).unwrap(); + + // Verify account order and properties + assert_eq!(ix.accounts[0].pubkey, trader); + assert!(ix.accounts[0].is_signer); + assert!(!ix.accounts[0].is_writable); + + assert_eq!(ix.accounts[1].pubkey, ember_state); + assert!(!ix.accounts[1].is_signer); + assert!(!ix.accounts[1].is_writable); + + assert_eq!(ix.accounts[2].pubkey, usdc_mint); + assert!(!ix.accounts[2].is_writable); + + assert_eq!(ix.accounts[3].pubkey, canonical_mint); + assert!(ix.accounts[3].is_writable); + + assert_eq!(ix.accounts[4].pubkey, trader_usdc); + assert!(ix.accounts[4].is_writable); + + assert_eq!(ix.accounts[5].pubkey, trader_phoenix); + assert!(ix.accounts[5].is_writable); + + assert_eq!(ix.accounts[6].pubkey, ember_vault); + assert!(ix.accounts[6].is_writable); + + assert_eq!(ix.accounts[7].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!ix.accounts[7].is_writable); + } +} diff --git a/container/vendor/rise/rust/ix/src/ember_withdraw.rs b/container/vendor/rise/rust/ix/src/ember_withdraw.rs new file mode 100644 index 00000000000..756c38cf945 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/ember_withdraw.rs @@ -0,0 +1,375 @@ +//! Ember withdraw instruction construction. +//! +//! This module provides instruction building for withdrawing Phoenix tokens +//! and receiving USDC via the Ember program. + +use solana_pubkey::Pubkey; + +use crate::constants::{EMBER_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, ember_withdraw_discriminant}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for withdrawing Phoenix tokens and receiving USDC via Ember. +#[derive(Debug, Clone)] +pub struct EmberWithdrawParams { + /// The trader's authority (wallet) - must sign. + trader: Pubkey, + /// Ember state PDA. + ember_state: Pubkey, + /// USDC mint (input - what user receives). + usdc_mint: Pubkey, + /// Phoenix token mint (output - what user gives). + canonical_mint: Pubkey, + /// Trader's USDC token account (destination). + trader_usdc_account: Pubkey, + /// Trader's Phoenix token account (source). + trader_phoenix_account: Pubkey, + /// Ember vault PDA (holds USDC). + ember_vault: Pubkey, + /// Amount of Phoenix tokens to withdraw (None = full withdrawal). + amount: Option, +} + +impl EmberWithdrawParams { + /// Start building with the builder pattern. + pub fn builder() -> EmberWithdrawParamsBuilder { + EmberWithdrawParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn ember_state(&self) -> Pubkey { + self.ember_state + } + + pub fn usdc_mint(&self) -> Pubkey { + self.usdc_mint + } + + pub fn canonical_mint(&self) -> Pubkey { + self.canonical_mint + } + + pub fn trader_usdc_account(&self) -> Pubkey { + self.trader_usdc_account + } + + pub fn trader_phoenix_account(&self) -> Pubkey { + self.trader_phoenix_account + } + + pub fn ember_vault(&self) -> Pubkey { + self.ember_vault + } + + pub fn amount(&self) -> Option { + self.amount + } +} + +/// Builder for `EmberWithdrawParams`. +#[derive(Default)] +pub struct EmberWithdrawParamsBuilder { + trader: Option, + ember_state: Option, + usdc_mint: Option, + canonical_mint: Option, + trader_usdc_account: Option, + trader_phoenix_account: Option, + ember_vault: Option, + amount: Option>, +} + +impl EmberWithdrawParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn ember_state(mut self, ember_state: Pubkey) -> Self { + self.ember_state = Some(ember_state); + self + } + + pub fn usdc_mint(mut self, usdc_mint: Pubkey) -> Self { + self.usdc_mint = Some(usdc_mint); + self + } + + pub fn canonical_mint(mut self, canonical_mint: Pubkey) -> Self { + self.canonical_mint = Some(canonical_mint); + self + } + + pub fn trader_usdc_account(mut self, trader_usdc_account: Pubkey) -> Self { + self.trader_usdc_account = Some(trader_usdc_account); + self + } + + pub fn trader_phoenix_account(mut self, trader_phoenix_account: Pubkey) -> Self { + self.trader_phoenix_account = Some(trader_phoenix_account); + self + } + + pub fn ember_vault(mut self, ember_vault: Pubkey) -> Self { + self.ember_vault = Some(ember_vault); + self + } + + /// Set the withdrawal amount. Pass `Some(amount)` for a specific amount, + /// or `None` for a full withdrawal. + pub fn amount(mut self, amount: Option) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + let amount = self.amount.ok_or(PhoenixIxError::MissingField("amount"))?; + + // Validate amount if specified + if let Some(amt) = amount { + if amt == 0 { + return Err(PhoenixIxError::InvalidWithdrawAmount); + } + } + + Ok(EmberWithdrawParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + ember_state: self + .ember_state + .ok_or(PhoenixIxError::MissingField("ember_state"))?, + usdc_mint: self + .usdc_mint + .ok_or(PhoenixIxError::MissingField("usdc_mint"))?, + canonical_mint: self + .canonical_mint + .ok_or(PhoenixIxError::MissingField("canonical_mint"))?, + trader_usdc_account: self + .trader_usdc_account + .ok_or(PhoenixIxError::MissingField("trader_usdc_account"))?, + trader_phoenix_account: self + .trader_phoenix_account + .ok_or(PhoenixIxError::MissingField("trader_phoenix_account"))?, + ember_vault: self + .ember_vault + .ok_or(PhoenixIxError::MissingField("ember_vault"))?, + amount, + }) + } +} + +/// Create an Ember withdraw instruction. +/// +/// This instruction burns Phoenix tokens and releases USDC from the Ember vault +/// to the trader's account. +/// +/// # Arguments +/// +/// * `params` - The Ember withdraw parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing or amount is zero. +pub fn create_ember_withdraw_ix( + params: EmberWithdrawParams, +) -> Result { + let data = encode_ember_withdraw(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: EMBER_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_ember_withdraw(params: &EmberWithdrawParams) -> Vec { + // Capacity: 8 (discriminant) + 1 (Option tag) + 8 (amount if Some) = 17 max + let mut data = Vec::with_capacity(17); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&ember_withdraw_discriminant()); + + // Amount as Borsh-encoded Option + match params.amount() { + Some(amount) => { + data.push(1); // Some variant tag + data.extend_from_slice(&amount.to_le_bytes()); + } + None => { + data.push(0); // None variant tag + } + } + + data +} + +fn build_accounts(params: &EmberWithdrawParams) -> Vec { + vec![ + // 1. owner (signer, readonly) + AccountMeta::readonly_signer(params.trader()), + // 2. ember_state (readonly) + AccountMeta::readonly(params.ember_state()), + // 3. input_mint (readonly) - USDC + AccountMeta::readonly(params.usdc_mint()), + // 4. output_mint (writable) - Phoenix token + AccountMeta::writable(params.canonical_mint()), + // 5. input_token_account (writable) - owner's USDC ATA + AccountMeta::writable(params.trader_usdc_account()), + // 6. output_token_account (writable) - owner's Phoenix token ATA + AccountMeta::writable(params.trader_phoenix_account()), + // 7. ember_vault (writable) + AccountMeta::writable(params.ember_vault()), + // 8. spl_token (readonly) + AccountMeta::readonly(SPL_TOKEN_PROGRAM_ID), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_ember_withdraw_ix_with_amount() { + let params = EmberWithdrawParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .amount(Some(100_000_000)) + .build() + .unwrap(); + + let ix = create_ember_withdraw_ix(params).unwrap(); + + assert_eq!(ix.program_id, EMBER_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 8); + + // Verify data encoding + assert_eq!(&ix.data[..8], &ember_withdraw_discriminant()); + assert_eq!(ix.data[8], 1); // Some variant + let amount_bytes: [u8; 8] = ix.data[9..17].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_create_ember_withdraw_ix_full_withdrawal() { + let params = EmberWithdrawParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .amount(None) // Full withdrawal + .build() + .unwrap(); + + let ix = create_ember_withdraw_ix(params).unwrap(); + + // Verify data encoding for full withdrawal + assert_eq!(&ix.data[..8], &ember_withdraw_discriminant()); + assert_eq!(ix.data[8], 0); // None variant + assert_eq!(ix.data.len(), 9); // 8 bytes discriminant + 1 byte None tag + } + + #[test] + fn test_ember_withdraw_missing_amount() { + let result = EmberWithdrawParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::MissingField("amount")) + )); + } + + #[test] + fn test_ember_withdraw_zero_amount() { + let result = EmberWithdrawParams::builder() + .trader(Pubkey::new_unique()) + .ember_state(Pubkey::new_unique()) + .ember_vault(Pubkey::new_unique()) + .usdc_mint(Pubkey::new_unique()) + .canonical_mint(Pubkey::new_unique()) + .trader_usdc_account(Pubkey::new_unique()) + .trader_phoenix_account(Pubkey::new_unique()) + .amount(Some(0)) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::InvalidWithdrawAmount))); + } + + #[test] + fn test_ember_withdraw_account_order() { + let trader = Pubkey::new_unique(); + let ember_state = Pubkey::new_unique(); + let ember_vault = Pubkey::new_unique(); + let usdc_mint = Pubkey::new_unique(); + let canonical_mint = Pubkey::new_unique(); + let trader_usdc = Pubkey::new_unique(); + let trader_phoenix = Pubkey::new_unique(); + + let params = EmberWithdrawParams::builder() + .trader(trader) + .ember_state(ember_state) + .ember_vault(ember_vault) + .usdc_mint(usdc_mint) + .canonical_mint(canonical_mint) + .trader_usdc_account(trader_usdc) + .trader_phoenix_account(trader_phoenix) + .amount(Some(1)) + .build() + .unwrap(); + + let ix = create_ember_withdraw_ix(params).unwrap(); + + // Verify account order and properties + assert_eq!(ix.accounts[0].pubkey, trader); + assert!(ix.accounts[0].is_signer); + assert!(!ix.accounts[0].is_writable); + + assert_eq!(ix.accounts[1].pubkey, ember_state); + assert!(!ix.accounts[1].is_signer); + assert!(!ix.accounts[1].is_writable); + + assert_eq!(ix.accounts[2].pubkey, usdc_mint); + assert!(!ix.accounts[2].is_writable); + + assert_eq!(ix.accounts[3].pubkey, canonical_mint); + assert!(ix.accounts[3].is_writable); + + assert_eq!(ix.accounts[4].pubkey, trader_usdc); + assert!(ix.accounts[4].is_writable); + + assert_eq!(ix.accounts[5].pubkey, trader_phoenix); + assert!(ix.accounts[5].is_writable); + + assert_eq!(ix.accounts[6].pubkey, ember_vault); + assert!(ix.accounts[6].is_writable); + + assert_eq!(ix.accounts[7].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!ix.accounts[7].is_writable); + } +} diff --git a/container/vendor/rise/rust/ix/src/error.rs b/container/vendor/rise/rust/ix/src/error.rs new file mode 100644 index 00000000000..3463c26208a --- /dev/null +++ b/container/vendor/rise/rust/ix/src/error.rs @@ -0,0 +1,49 @@ +//! Error types for Phoenix instruction construction. + +use thiserror::Error; + +/// Errors that can occur when building Phoenix instructions. +#[derive(Debug, Error)] +pub enum PhoenixIxError { + #[error("Trader wallet is required")] + MissingTrader, + + #[error("Trader account is required")] + MissingTraderAccount, + + #[error("Perp asset map is required")] + MissingPerpAssetMap, + + #[error("Orderbook is required")] + MissingOrderbook, + + #[error("Spline collection is required")] + MissingSplineCollection, + + #[error("Active trader buffer array is required and must not be empty")] + EmptyActiveTraderBuffer, + + #[error("Global trader index array is required and must not be empty")] + EmptyGlobalTraderIndex, + + #[error("At least one order ID is required")] + NoOrderIds, + + #[error("Too many order IDs (maximum 100)")] + TooManyOrderIds, + + #[error("Missing required field: {0}")] + MissingField(&'static str), + + #[error("Invalid deposit amount (must be greater than 0)")] + InvalidDepositAmount, + + #[error("Invalid withdraw amount (must be greater than 0)")] + InvalidWithdrawAmount, + + #[error("Invalid subaccount index for isolated margin (must be 0-100)")] + InvalidSubaccountIndex, + + #[error("Invalid transfer amount (must be greater than 0)")] + InvalidTransferAmount, +} diff --git a/container/vendor/rise/rust/ix/src/lib.rs b/container/vendor/rise/rust/ix/src/lib.rs new file mode 100644 index 00000000000..033b44ac016 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/lib.rs @@ -0,0 +1,79 @@ +//! Phoenix instruction construction for Rust. +//! +//! This crate provides functions for building Solana instructions +//! to interact with the Phoenix perpetuals exchange. +//! +//! # Example +//! +//! ```no_run +//! use phoenix_ix::{LimitOrderParams, Side, create_place_limit_order_ix}; +//! use solana_pubkey::Pubkey; +//! +//! let params = LimitOrderParams::builder() +//! .trader(Pubkey::new_unique()) +//! .trader_account(Pubkey::new_unique()) +//! .perp_asset_map(Pubkey::new_unique()) +//! .orderbook(Pubkey::new_unique()) +//! .spline_collection(Pubkey::new_unique()) +//! .global_trader_index(vec![Pubkey::new_unique()]) +//! .active_trader_buffer(vec![Pubkey::new_unique()]) +//! .side(Side::Bid) +//! .price_in_ticks(50000) +//! .num_base_lots(1000) +//! .build() +//! .unwrap(); +//! +//! let ix = create_place_limit_order_ix(params); +//! ``` + +mod cancel_orders; +mod cancel_stop_loss; +mod constants; +mod create_ata; +mod deposit_funds; +mod ember_deposit; +mod ember_withdraw; +mod error; +mod limit_order; +mod market_order; +mod multi_limit_order; +mod order_packet; +mod register_trader; +mod spl_approve; +mod stop_loss; +mod sync_parent_to_child; +mod transfer_collateral; +mod types; +mod withdraw_funds; + +pub use cancel_orders::{CancelOrdersByIdParams, create_cancel_orders_by_id_ix}; +pub use cancel_stop_loss::{CancelStopLossParams, create_cancel_stop_loss_ix}; +pub use constants::*; +pub use create_ata::create_associated_token_account_idempotent_ix; +pub use deposit_funds::{DepositFundsParams, create_deposit_funds_ix}; +pub use ember_deposit::{EmberDepositParams, create_ember_deposit_ix}; +pub use ember_withdraw::{EmberWithdrawParams, create_ember_withdraw_ix}; +pub use error::PhoenixIxError; +pub use limit_order::{ + IsolatedLimitOrderParams, LimitOrderParams, LimitOrderParamsBuilder, + create_place_limit_order_ix, +}; +pub use market_order::{ + IsolatedMarketOrderParams, MarketOrderParams, create_place_market_order_ix, +}; +pub use multi_limit_order::{ + MultiLimitOrderParams, MultiLimitOrderParamsBuilder, create_place_multi_limit_order_ix, +}; +pub use order_packet::{ + CondensedOrder, MultipleOrderPacket, OrderPacket, client_order_id_to_bytes, +}; +pub use register_trader::{RegisterTraderParams, create_register_trader_ix}; +pub use spl_approve::{SplApproveParams, create_spl_approve_ix}; +pub use stop_loss::{StopLossParams, StopLossParamsBuilder, create_place_stop_loss_ix}; +pub use sync_parent_to_child::{SyncParentToChildParams, create_sync_parent_to_child_ix}; +pub use transfer_collateral::{ + TransferCollateralChildToParentParams, TransferCollateralParams, + create_transfer_collateral_child_to_parent_ix, create_transfer_collateral_ix, +}; +pub use types::*; +pub use withdraw_funds::{WithdrawFundsParams, create_withdraw_funds_ix}; diff --git a/container/vendor/rise/rust/ix/src/limit_order.rs b/container/vendor/rise/rust/ix/src/limit_order.rs new file mode 100644 index 00000000000..0fb07257513 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/limit_order.rs @@ -0,0 +1,448 @@ +//! Place limit order instruction construction. + +use borsh::to_vec; +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + place_limit_order_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::order_packet::{OrderPacket, client_order_id_to_bytes}; +use crate::types::{ + AccountMeta, Instruction, IsolatedCollateralFlow, OrderFlags, SelfTradeBehavior, Side, +}; + +/// Parameters for placing a limit order. +#[derive(Debug, Clone)] +pub struct LimitOrderParams { + trader: Pubkey, + trader_account: Pubkey, + perp_asset_map: Pubkey, + orderbook: Pubkey, + spline_collection: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + side: Side, + price_in_ticks: u64, + num_base_lots: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: u128, + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + /// Market symbol (e.g. "SOL"). Not serialized into the instruction. + symbol: String, + /// Subaccount index (0 = cross-margin, 1+ = isolated). Not serialized. + subaccount_index: u8, +} + +impl LimitOrderParams { + /// Start building with the builder pattern. + pub fn builder() -> LimitOrderParamsBuilder { + LimitOrderParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn orderbook(&self) -> Pubkey { + self.orderbook + } + + pub fn spline_collection(&self) -> Pubkey { + self.spline_collection + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn side(&self) -> Side { + self.side + } + + pub fn price_in_ticks(&self) -> u64 { + self.price_in_ticks + } + + pub fn num_base_lots(&self) -> u64 { + self.num_base_lots + } + + pub fn self_trade_behavior(&self) -> SelfTradeBehavior { + self.self_trade_behavior + } + + pub fn match_limit(&self) -> Option { + self.match_limit + } + + pub fn client_order_id(&self) -> u128 { + self.client_order_id + } + + pub fn last_valid_slot(&self) -> Option { + self.last_valid_slot + } + + pub fn order_flags(&self) -> OrderFlags { + self.order_flags + } + + pub fn cancel_existing(&self) -> bool { + self.cancel_existing + } + + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn subaccount_index(&self) -> u8 { + self.subaccount_index + } +} + +/// Builder for `LimitOrderParams`. +#[derive(Default)] +pub struct LimitOrderParamsBuilder { + trader: Option, + trader_account: Option, + perp_asset_map: Option, + orderbook: Option, + spline_collection: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + side: Option, + price_in_ticks: Option, + num_base_lots: Option, + self_trade_behavior: Option, + match_limit: Option, + client_order_id: Option, + last_valid_slot: Option, + order_flags: Option, + cancel_existing: Option, + symbol: Option, + subaccount_index: Option, +} + +impl LimitOrderParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn orderbook(mut self, orderbook: Pubkey) -> Self { + self.orderbook = Some(orderbook); + self + } + + pub fn spline_collection(mut self, spline_collection: Pubkey) -> Self { + self.spline_collection = Some(spline_collection); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn side(mut self, side: Side) -> Self { + self.side = Some(side); + self + } + + pub fn price_in_ticks(mut self, price_in_ticks: u64) -> Self { + self.price_in_ticks = Some(price_in_ticks); + self + } + + pub fn num_base_lots(mut self, num_base_lots: u64) -> Self { + self.num_base_lots = Some(num_base_lots); + self + } + + pub fn self_trade_behavior(mut self, self_trade_behavior: SelfTradeBehavior) -> Self { + self.self_trade_behavior = Some(self_trade_behavior); + self + } + + pub fn match_limit(mut self, match_limit: u64) -> Self { + self.match_limit = Some(match_limit); + self + } + + pub fn client_order_id(mut self, client_order_id: u128) -> Self { + self.client_order_id = Some(client_order_id); + self + } + + pub fn last_valid_slot(mut self, last_valid_slot: u64) -> Self { + self.last_valid_slot = Some(last_valid_slot); + self + } + + pub fn order_flags(mut self, order_flags: OrderFlags) -> Self { + self.order_flags = Some(order_flags); + self + } + + pub fn cancel_existing(mut self, cancel_existing: bool) -> Self { + self.cancel_existing = Some(cancel_existing); + self + } + + pub fn symbol(mut self, symbol: impl Into) -> Self { + self.symbol = Some(symbol.into()); + self + } + + pub fn subaccount_index(mut self, subaccount_index: u8) -> Self { + self.subaccount_index = Some(subaccount_index); + self + } + + pub fn build(self) -> Result { + Ok(LimitOrderParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + orderbook: self + .orderbook + .ok_or(PhoenixIxError::MissingField("orderbook"))?, + spline_collection: self + .spline_collection + .ok_or(PhoenixIxError::MissingField("spline_collection"))?, + global_trader_index: self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?, + active_trader_buffer: self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?, + side: self.side.ok_or(PhoenixIxError::MissingField("side"))?, + price_in_ticks: self + .price_in_ticks + .ok_or(PhoenixIxError::MissingField("price_in_ticks"))?, + num_base_lots: self + .num_base_lots + .ok_or(PhoenixIxError::MissingField("num_base_lots"))?, + self_trade_behavior: self.self_trade_behavior.unwrap_or(SelfTradeBehavior::Abort), + match_limit: self.match_limit, + client_order_id: self.client_order_id.unwrap_or(0), + last_valid_slot: self.last_valid_slot, + order_flags: self.order_flags.unwrap_or(OrderFlags::None), + cancel_existing: self.cancel_existing.unwrap_or(false), + symbol: self.symbol.unwrap_or_default(), + subaccount_index: self.subaccount_index.unwrap_or(0), + }) + } +} + +/// Create a place limit order instruction. +/// +/// # Arguments +/// +/// * `params` - The limit order parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing. +pub fn create_place_limit_order_ix( + params: LimitOrderParams, +) -> Result { + validate(¶ms)?; + + let data = encode_limit_order(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &LimitOrderParams) -> Result<(), PhoenixIxError> { + if params.global_trader_index().is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + if params.active_trader_buffer().is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + Ok(()) +} + +fn encode_limit_order(params: &LimitOrderParams) -> Vec { + let mut data = Vec::new(); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&place_limit_order_discriminant()); + + // Build the order packet using proper Borsh serialization + let packet = OrderPacket::limit( + params.side(), + params.price_in_ticks(), + params.num_base_lots(), + params.self_trade_behavior(), + params.match_limit(), + client_order_id_to_bytes(params.client_order_id()), + params.last_valid_slot(), + params.order_flags(), + params.cancel_existing(), + ); + + data.extend_from_slice(&to_vec(&packet.kind).expect("serialization should not fail")); + + data +} + +fn build_accounts(params: &LimitOrderParams) -> Vec { + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts (2 accounts) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // MarketActionInstructionGroupAccounts + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::readonly_signer(params.trader())); + accounts.push(AccountMeta::writable(params.trader_account())); + accounts.push(AccountMeta::writable(params.perp_asset_map())); + + // Global trader index addresses + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // Active trader buffer addresses + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts.push(AccountMeta::writable(params.orderbook())); + accounts.push(AccountMeta::writable(params.spline_collection())); + + accounts +} + +/// Parameters for an isolated margin limit order. +pub struct IsolatedLimitOrderParams { + pub side: Side, + pub price_in_ticks: u64, + pub num_base_lots: u64, + pub self_trade_behavior: SelfTradeBehavior, + pub match_limit: Option, + pub client_order_id: u128, + pub last_valid_slot: Option, + pub order_flags: OrderFlags, + pub cancel_existing: bool, + pub allow_cross_and_isolated: bool, + pub collateral: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_limit_order_ix() { + let params = LimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .side(Side::Bid) + .price_in_ticks(50000) + .num_base_lots(1000) + .self_trade_behavior(SelfTradeBehavior::CancelProvide) + .client_order_id(123) + .build() + .unwrap(); + + let ix = create_place_limit_order_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log accounts + 4 base accounts + 1 global trader index + 1 active trader + // buffer + 2 market accounts = 10 + assert_eq!(ix.accounts.len(), 10); + // Data should start with discriminant + assert_eq!(&ix.data[..8], &place_limit_order_discriminant()); + } + + #[test] + fn test_empty_global_trader_index_fails() { + let params = LimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .side(Side::Bid) + .price_in_ticks(50000) + .num_base_lots(1000) + .build() + .unwrap(); + + let result = create_place_limit_order_ix(params); + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_builder_missing_required_field() { + let result = LimitOrderParams::builder() + .trader(Pubkey::new_unique()) + // Missing other required fields + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/market_order.rs b/container/vendor/rise/rust/ix/src/market_order.rs new file mode 100644 index 00000000000..e87fdd96581 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/market_order.rs @@ -0,0 +1,491 @@ +//! Place market order instruction construction. + +use borsh::to_vec; +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + place_market_order_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::order_packet::{OrderPacket, client_order_id_to_bytes}; +use crate::types::{ + AccountMeta, Instruction, IsolatedCollateralFlow, OrderFlags, SelfTradeBehavior, Side, +}; + +/// Parameters for placing a market order. +#[derive(Debug, Clone)] +pub struct MarketOrderParams { + trader: Pubkey, + trader_account: Pubkey, + perp_asset_map: Pubkey, + orderbook: Pubkey, + spline_collection: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + side: Side, + price_in_ticks: Option, + num_base_lots: u64, + num_quote_lots: Option, + min_base_lots_to_fill: u64, + min_quote_lots_to_fill: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: u128, + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + /// Market symbol (e.g. "SOL"). Not serialized into the instruction. + symbol: String, + /// Subaccount index (0 = cross-margin, 1+ = isolated). Not serialized. + subaccount_index: u8, +} + +impl MarketOrderParams { + /// Start building with the builder pattern. + pub fn builder() -> MarketOrderParamsBuilder { + MarketOrderParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn orderbook(&self) -> Pubkey { + self.orderbook + } + + pub fn spline_collection(&self) -> Pubkey { + self.spline_collection + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn side(&self) -> Side { + self.side + } + + pub fn price_in_ticks(&self) -> Option { + self.price_in_ticks + } + + pub fn num_base_lots(&self) -> u64 { + self.num_base_lots + } + + pub fn num_quote_lots(&self) -> Option { + self.num_quote_lots + } + + pub fn min_base_lots_to_fill(&self) -> u64 { + self.min_base_lots_to_fill + } + + pub fn min_quote_lots_to_fill(&self) -> u64 { + self.min_quote_lots_to_fill + } + + pub fn self_trade_behavior(&self) -> SelfTradeBehavior { + self.self_trade_behavior + } + + pub fn match_limit(&self) -> Option { + self.match_limit + } + + pub fn client_order_id(&self) -> u128 { + self.client_order_id + } + + pub fn last_valid_slot(&self) -> Option { + self.last_valid_slot + } + + pub fn order_flags(&self) -> OrderFlags { + self.order_flags + } + + pub fn cancel_existing(&self) -> bool { + self.cancel_existing + } + + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn subaccount_index(&self) -> u8 { + self.subaccount_index + } +} + +/// Builder for `MarketOrderParams`. +#[derive(Default)] +pub struct MarketOrderParamsBuilder { + trader: Option, + trader_account: Option, + perp_asset_map: Option, + orderbook: Option, + spline_collection: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + side: Option, + price_in_ticks: Option, + num_base_lots: Option, + num_quote_lots: Option, + min_base_lots_to_fill: Option, + min_quote_lots_to_fill: Option, + self_trade_behavior: Option, + match_limit: Option, + client_order_id: Option, + last_valid_slot: Option, + order_flags: Option, + cancel_existing: Option, + symbol: Option, + subaccount_index: Option, +} + +impl MarketOrderParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn orderbook(mut self, orderbook: Pubkey) -> Self { + self.orderbook = Some(orderbook); + self + } + + pub fn spline_collection(mut self, spline_collection: Pubkey) -> Self { + self.spline_collection = Some(spline_collection); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn side(mut self, side: Side) -> Self { + self.side = Some(side); + self + } + + pub fn price_in_ticks(mut self, price_in_ticks: u64) -> Self { + self.price_in_ticks = Some(price_in_ticks); + self + } + + pub fn num_base_lots(mut self, num_base_lots: u64) -> Self { + self.num_base_lots = Some(num_base_lots); + self + } + + pub fn num_quote_lots(mut self, num_quote_lots: u64) -> Self { + self.num_quote_lots = Some(num_quote_lots); + self + } + + pub fn min_base_lots_to_fill(mut self, min_base_lots_to_fill: u64) -> Self { + self.min_base_lots_to_fill = Some(min_base_lots_to_fill); + self + } + + pub fn min_quote_lots_to_fill(mut self, min_quote_lots_to_fill: u64) -> Self { + self.min_quote_lots_to_fill = Some(min_quote_lots_to_fill); + self + } + + pub fn self_trade_behavior(mut self, self_trade_behavior: SelfTradeBehavior) -> Self { + self.self_trade_behavior = Some(self_trade_behavior); + self + } + + pub fn match_limit(mut self, match_limit: u64) -> Self { + self.match_limit = Some(match_limit); + self + } + + pub fn client_order_id(mut self, client_order_id: u128) -> Self { + self.client_order_id = Some(client_order_id); + self + } + + pub fn last_valid_slot(mut self, last_valid_slot: u64) -> Self { + self.last_valid_slot = Some(last_valid_slot); + self + } + + pub fn order_flags(mut self, order_flags: OrderFlags) -> Self { + self.order_flags = Some(order_flags); + self + } + + pub fn cancel_existing(mut self, cancel_existing: bool) -> Self { + self.cancel_existing = Some(cancel_existing); + self + } + + pub fn symbol(mut self, symbol: impl Into) -> Self { + self.symbol = Some(symbol.into()); + self + } + + pub fn subaccount_index(mut self, subaccount_index: u8) -> Self { + self.subaccount_index = Some(subaccount_index); + self + } + + pub fn build(self) -> Result { + Ok(MarketOrderParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + orderbook: self + .orderbook + .ok_or(PhoenixIxError::MissingField("orderbook"))?, + spline_collection: self + .spline_collection + .ok_or(PhoenixIxError::MissingField("spline_collection"))?, + global_trader_index: self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?, + active_trader_buffer: self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?, + side: self.side.ok_or(PhoenixIxError::MissingField("side"))?, + price_in_ticks: self.price_in_ticks, + num_base_lots: self + .num_base_lots + .ok_or(PhoenixIxError::MissingField("num_base_lots"))?, + num_quote_lots: self.num_quote_lots, + min_base_lots_to_fill: self.min_base_lots_to_fill.unwrap_or(0), + min_quote_lots_to_fill: self.min_quote_lots_to_fill.unwrap_or(0), + self_trade_behavior: self.self_trade_behavior.unwrap_or(SelfTradeBehavior::Abort), + match_limit: self.match_limit, + client_order_id: self.client_order_id.unwrap_or(0), + last_valid_slot: self.last_valid_slot, + order_flags: self.order_flags.unwrap_or(OrderFlags::None), + cancel_existing: self.cancel_existing.unwrap_or(false), + symbol: self.symbol.unwrap_or_default(), + subaccount_index: self.subaccount_index.unwrap_or(0), + }) + } +} + +/// Create a place market order instruction. +/// +/// Market orders use the ImmediateOrCancel order type internally. +/// +/// # Arguments +/// +/// * `params` - The market order parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing. +pub fn create_place_market_order_ix( + params: MarketOrderParams, +) -> Result { + validate(¶ms)?; + + let data = encode_market_order(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &MarketOrderParams) -> Result<(), PhoenixIxError> { + if params.global_trader_index().is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + if params.active_trader_buffer().is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + Ok(()) +} + +fn encode_market_order(params: &MarketOrderParams) -> Vec { + let mut data = Vec::new(); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&place_market_order_discriminant()); + + // Build the order packet using proper Borsh serialization + let packet = OrderPacket::immediate_or_cancel( + params.side(), + params.price_in_ticks(), + params.num_base_lots(), + params.num_quote_lots(), + params.min_base_lots_to_fill(), + params.min_quote_lots_to_fill(), + params.self_trade_behavior(), + params.match_limit(), + client_order_id_to_bytes(params.client_order_id()), + params.last_valid_slot(), + params.order_flags(), + params.cancel_existing(), + ); + + data.extend_from_slice(&to_vec(&packet.kind).expect("serialization should not fail")); + + data +} + +fn build_accounts(params: &MarketOrderParams) -> Vec { + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts (2 accounts) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // MarketActionInstructionGroupAccounts + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::readonly_signer(params.trader())); + accounts.push(AccountMeta::writable(params.trader_account())); + accounts.push(AccountMeta::writable(params.perp_asset_map())); + + // Global trader index addresses + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // Active trader buffer addresses + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts.push(AccountMeta::writable(params.orderbook())); + accounts.push(AccountMeta::writable(params.spline_collection())); + + accounts +} + +/// Parameters for an isolated margin market order. +pub struct IsolatedMarketOrderParams { + pub side: Side, + pub price_in_ticks: Option, + pub num_base_lots: u64, + pub num_quote_lots: Option, + pub min_base_lots_to_fill: u64, + pub min_quote_lots_to_fill: u64, + pub self_trade_behavior: SelfTradeBehavior, + pub match_limit: Option, + pub client_order_id: u128, + pub last_valid_slot: Option, + pub order_flags: OrderFlags, + pub cancel_existing: bool, + pub allow_cross_and_isolated: bool, + pub collateral: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_market_order_ix() { + let params = MarketOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .side(Side::Bid) + .price_in_ticks(50000) + .num_base_lots(1000) + .min_base_lots_to_fill(1000) + .min_quote_lots_to_fill(1) + .build() + .unwrap(); + + let ix = create_place_market_order_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log accounts + 4 base accounts + 1 global trader index + 1 active trader + // buffer + 2 market accounts = 10 + assert_eq!(ix.accounts.len(), 10); + // Data should start with discriminant + assert_eq!(&ix.data[..8], &place_market_order_discriminant()); + } + + #[test] + fn test_market_order_without_price_limit() { + let params = MarketOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .side(Side::Ask) + .num_base_lots(500) + .min_base_lots_to_fill(500) + .min_quote_lots_to_fill(1) + .build() + .unwrap(); + + let ix = create_place_market_order_ix(params).unwrap(); + + // Should still create a valid instruction + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + assert!(!ix.data.is_empty()); + } + + #[test] + fn test_builder_missing_required_field() { + let result = MarketOrderParams::builder() + .trader(Pubkey::new_unique()) + // Missing other required fields + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/multi_limit_order.rs b/container/vendor/rise/rust/ix/src/multi_limit_order.rs new file mode 100644 index 00000000000..7314c7dfa6c --- /dev/null +++ b/container/vendor/rise/rust/ix/src/multi_limit_order.rs @@ -0,0 +1,370 @@ +//! Place multi-limit-order instruction construction. + +use borsh::to_vec; +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + place_multi_limit_order_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::order_packet::{CondensedOrder, MultipleOrderPacket, client_order_id_to_bytes}; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for placing multiple limit orders in a single instruction. +#[derive(Debug, Clone)] +pub struct MultiLimitOrderParams { + trader: Pubkey, + trader_account: Pubkey, + perp_asset_map: Pubkey, + orderbook: Pubkey, + spline_collection: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + bids: Vec, + asks: Vec, + client_order_id: Option, + slide: bool, + /// Market symbol (e.g. "SOL"). Not serialized into the instruction. + symbol: String, +} + +impl MultiLimitOrderParams { + pub fn builder() -> MultiLimitOrderParamsBuilder { + MultiLimitOrderParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn orderbook(&self) -> Pubkey { + self.orderbook + } + + pub fn spline_collection(&self) -> Pubkey { + self.spline_collection + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn bids(&self) -> &[CondensedOrder] { + &self.bids + } + + pub fn asks(&self) -> &[CondensedOrder] { + &self.asks + } + + pub fn client_order_id(&self) -> Option { + self.client_order_id + } + + pub fn slide(&self) -> bool { + self.slide + } + + pub fn symbol(&self) -> &str { + &self.symbol + } +} + +/// Builder for `MultiLimitOrderParams`. +#[derive(Default)] +pub struct MultiLimitOrderParamsBuilder { + trader: Option, + trader_account: Option, + perp_asset_map: Option, + orderbook: Option, + spline_collection: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + bids: Vec, + asks: Vec, + client_order_id: Option, + slide: bool, + symbol: Option, +} + +impl MultiLimitOrderParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn orderbook(mut self, orderbook: Pubkey) -> Self { + self.orderbook = Some(orderbook); + self + } + + pub fn spline_collection(mut self, spline_collection: Pubkey) -> Self { + self.spline_collection = Some(spline_collection); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn bids(mut self, bids: Vec) -> Self { + self.bids = bids; + self + } + + pub fn asks(mut self, asks: Vec) -> Self { + self.asks = asks; + self + } + + pub fn add_bid(mut self, order: CondensedOrder) -> Self { + self.bids.push(order); + self + } + + pub fn add_ask(mut self, order: CondensedOrder) -> Self { + self.asks.push(order); + self + } + + pub fn client_order_id(mut self, client_order_id: u128) -> Self { + self.client_order_id = Some(client_order_id); + self + } + + pub fn slide(mut self, slide: bool) -> Self { + self.slide = slide; + self + } + + pub fn symbol(mut self, symbol: impl Into) -> Self { + self.symbol = Some(symbol.into()); + self + } + + pub fn build(self) -> Result { + Ok(MultiLimitOrderParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + orderbook: self + .orderbook + .ok_or(PhoenixIxError::MissingField("orderbook"))?, + spline_collection: self + .spline_collection + .ok_or(PhoenixIxError::MissingField("spline_collection"))?, + global_trader_index: self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?, + active_trader_buffer: self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?, + bids: self.bids, + asks: self.asks, + client_order_id: self.client_order_id, + slide: self.slide, + symbol: self.symbol.unwrap_or_default(), + }) + } +} + +/// Create a place multi-limit-order instruction. +/// +/// This instruction places multiple post-only limit orders (bids and asks) in a +/// single transaction. Individual orders that fail (e.g. too many orders, +/// post-only cross) are skipped without failing the entire transaction. +pub fn create_place_multi_limit_order_ix( + params: MultiLimitOrderParams, +) -> Result { + validate(¶ms)?; + + let data = encode_multi_limit_order(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &MultiLimitOrderParams) -> Result<(), PhoenixIxError> { + if params.global_trader_index().is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + if params.active_trader_buffer().is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + Ok(()) +} + +fn encode_multi_limit_order(params: &MultiLimitOrderParams) -> Vec { + let mut data = Vec::new(); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&place_multi_limit_order_discriminant()); + + let client_order_id = params + .client_order_id() + .map(client_order_id_to_bytes); + + let packet = MultipleOrderPacket { + bids: params.bids().to_vec(), + asks: params.asks().to_vec(), + client_order_id, + slide: params.slide(), + }; + + data.extend_from_slice(&to_vec(&packet).expect("serialization should not fail")); + + data +} + +fn build_accounts(params: &MultiLimitOrderParams) -> Vec { + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts (2 accounts) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // MarketActionInstructionGroupAccounts + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::readonly_signer(params.trader())); + accounts.push(AccountMeta::writable(params.trader_account())); + accounts.push(AccountMeta::writable(params.perp_asset_map())); + + // Global trader index addresses + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // Active trader buffer addresses + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts.push(AccountMeta::writable(params.orderbook())); + accounts.push(AccountMeta::writable(params.spline_collection())); + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_multi_limit_order_ix() { + let params = MultiLimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .add_bid(CondensedOrder { + price_in_ticks: 50000, + size_in_base_lots: 1000, + last_valid_slot: None, + }) + .add_ask(CondensedOrder { + price_in_ticks: 51000, + size_in_base_lots: 1000, + last_valid_slot: None, + }) + .slide(true) + .build() + .unwrap(); + + let ix = create_place_multi_limit_order_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log + 4 base + 1 global trader index + 1 active trader buffer + 2 market = 10 + assert_eq!(ix.accounts.len(), 10); + assert_eq!(&ix.data[..8], &place_multi_limit_order_discriminant()); + } + + #[test] + fn test_empty_orders_allowed() { + let params = MultiLimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .build() + .unwrap(); + + let ix = create_place_multi_limit_order_ix(params); + assert!(ix.is_ok()); + } + + #[test] + fn test_empty_global_trader_index_fails() { + let params = MultiLimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .build() + .unwrap(); + + let result = create_place_multi_limit_order_ix(params); + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_builder_missing_required_field() { + let result = MultiLimitOrderParams::builder() + .trader(Pubkey::new_unique()) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/order_packet.rs b/container/vendor/rise/rust/ix/src/order_packet.rs new file mode 100644 index 00000000000..3f9350a60e8 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/order_packet.rs @@ -0,0 +1,279 @@ +//! OrderPacket types for Phoenix instruction serialization. +//! +//! These types match the wire format expected by the Phoenix program, +//! using proper Borsh serialization with `Option` types. + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::types::{OrderFlags, SelfTradeBehavior, Side}; + +/// An order packet for Phoenix instructions. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct OrderPacket { + pub(crate) kind: OrderPacketKind, +} + +impl OrderPacket { + /// Create a new post-only order packet. + pub fn post_only( + side: Side, + price_in_ticks: u64, + num_base_lots: u64, + client_order_id: [u8; 16], + slide: bool, + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + ) -> Self { + Self { + kind: OrderPacketKind::PostOnly { + side, + price_in_ticks, + num_base_lots, + client_order_id, + slide, + last_valid_slot, + order_flags, + cancel_existing, + }, + } + } + + /// Create a new limit order packet. + pub fn limit( + side: Side, + price_in_ticks: u64, + num_base_lots: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: [u8; 16], + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + ) -> Self { + Self { + kind: OrderPacketKind::Limit { + side, + price_in_ticks, + num_base_lots, + self_trade_behavior, + match_limit, + client_order_id, + last_valid_slot, + order_flags, + cancel_existing, + }, + } + } + + /// Create a new immediate-or-cancel order packet (used for market orders). + pub fn immediate_or_cancel( + side: Side, + price_in_ticks: Option, + num_base_lots: u64, + num_quote_lots: Option, + min_base_lots_to_fill: u64, + min_quote_lots_to_fill: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: [u8; 16], + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + ) -> Self { + Self { + kind: OrderPacketKind::ImmediateOrCancel { + side, + price_in_ticks, + num_base_lots, + num_quote_lots, + min_base_lots_to_fill, + min_quote_lots_to_fill, + self_trade_behavior, + match_limit, + client_order_id, + last_valid_slot, + order_flags, + cancel_existing, + }, + } + } +} + +/// The kind of order packet, matching the Phoenix program's enum. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub(crate) enum OrderPacketKind { + /// Post-only order that will not match against existing orders. + PostOnly { + side: Side, + price_in_ticks: u64, + num_base_lots: u64, + client_order_id: [u8; 16], + slide: bool, + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + }, + /// Limit order that can match against existing orders. + Limit { + side: Side, + price_in_ticks: u64, + num_base_lots: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: [u8; 16], + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + }, + /// Immediate-or-cancel order (used for market orders). + ImmediateOrCancel { + side: Side, + price_in_ticks: Option, + num_base_lots: u64, + num_quote_lots: Option, + min_base_lots_to_fill: u64, + min_quote_lots_to_fill: u64, + self_trade_behavior: SelfTradeBehavior, + match_limit: Option, + client_order_id: [u8; 16], + last_valid_slot: Option, + order_flags: OrderFlags, + cancel_existing: bool, + }, +} + +/// Convert a u128 client order ID to the [u8; 16] format expected by the +/// program. +pub fn client_order_id_to_bytes(id: u128) -> [u8; 16] { + id.to_le_bytes() +} + +/// A condensed order for use in multi-limit-order instructions. +/// +/// Contains only price, size, and optional expiry — the minimal data +/// needed per order in a batch. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct CondensedOrder { + pub price_in_ticks: u64, + pub size_in_base_lots: u64, + pub last_valid_slot: Option, +} + +/// A batch of post-only limit orders (bids and asks) sent in a single +/// instruction. +#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)] +pub struct MultipleOrderPacket { + pub bids: Vec, + pub asks: Vec, + pub client_order_id: Option<[u8; 16]>, + /// Whether orders should slide to the top of the book if they would cross. + pub slide: bool, +} + +#[cfg(test)] +mod tests { + use borsh::to_vec; + + use super::*; + + #[test] + fn test_ioc_serialization_with_none_price() { + let packet = OrderPacket::immediate_or_cancel( + Side::Bid, + None, // price_in_ticks + 1000, + None, // num_quote_lots + 0, + 0, + SelfTradeBehavior::Abort, + None, // match_limit + [0u8; 16], + None, // last_valid_slot + OrderFlags::None, + false, + ); + + let bytes = to_vec(&packet.kind).unwrap(); + + // Verify the discriminant is 2 (ImmediateOrCancel) + assert_eq!(bytes[0], 2); + + // Verify side is 0 (Bid) + assert_eq!(bytes[1], 0); + + // Verify price_in_ticks Option discriminant is 0 (None) + assert_eq!(bytes[2], 0); + // After None, next field should start immediately (no 8-byte value) + } + + #[test] + fn test_ioc_serialization_with_some_price() { + let packet = OrderPacket::immediate_or_cancel( + Side::Ask, + Some(50000), + 1000, + None, + 0, + 0, + SelfTradeBehavior::Abort, + None, + [0u8; 16], + None, + OrderFlags::None, + false, + ); + + let bytes = to_vec(&packet.kind).unwrap(); + + // Verify the discriminant is 2 (ImmediateOrCancel) + assert_eq!(bytes[0], 2); + + // Verify side is 1 (Ask) + assert_eq!(bytes[1], 1); + + // Verify price_in_ticks Option discriminant is 1 (Some) + assert_eq!(bytes[2], 1); + + // Verify price value (50000 as little-endian u64) + let price_bytes = &bytes[3..11]; + let price = u64::from_le_bytes(price_bytes.try_into().unwrap()); + assert_eq!(price, 50000); + } + + #[test] + fn test_limit_serialization() { + let packet = OrderPacket::limit( + Side::Bid, + 50000, + 1000, + SelfTradeBehavior::CancelProvide, + None, + [0u8; 16], + None, + OrderFlags::None, + false, + ); + + let bytes = to_vec(&packet.kind).unwrap(); + + // Verify the discriminant is 1 (Limit) + assert_eq!(bytes[0], 1); + + // Verify side is 0 (Bid) + assert_eq!(bytes[1], 0); + } + + #[test] + fn test_client_order_id_to_bytes() { + let id: u128 = 0x0102030405060708090a0b0c0d0e0f10; + let bytes = client_order_id_to_bytes(id); + assert_eq!( + bytes, + [ + 0x10, 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, + 0x02, 0x01 + ] + ); + } +} diff --git a/container/vendor/rise/rust/ix/src/register_trader.rs b/container/vendor/rise/rust/ix/src/register_trader.rs new file mode 100644 index 00000000000..c5182e3d91f --- /dev/null +++ b/container/vendor/rise/rust/ix/src/register_trader.rs @@ -0,0 +1,391 @@ +//! Register trader instruction construction. +//! +//! This module provides instruction building for registering a new trader +//! account on the Phoenix protocol. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, SYSTEM_PROGRAM_ID, + register_trader_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for registering a new trader account. +#[derive(Debug, Clone)] +pub struct RegisterTraderParams { + /// The payer for account creation (writable signer). + payer: Pubkey, + /// The trader authority (readonly). + trader: Pubkey, + /// The trader PDA account to be created (writable). + trader_account: Pubkey, + /// Maximum number of positions the account can hold. + /// Cross margin: 128, Isolated margin: 1. + max_positions: u64, + /// The PDA index for trader account derivation (0-255). + trader_pda_index: u8, + /// The subaccount index. + /// 0 for cross-margin, 1-100 for isolated margin. + subaccount_index: u8, +} + +impl RegisterTraderParams { + /// Start building with the builder pattern. + pub fn builder() -> RegisterTraderParamsBuilder { + RegisterTraderParamsBuilder::new() + } + + pub fn payer(&self) -> Pubkey { + self.payer + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn max_positions(&self) -> u64 { + self.max_positions + } + + pub fn trader_pda_index(&self) -> u8 { + self.trader_pda_index + } + + pub fn subaccount_index(&self) -> u8 { + self.subaccount_index + } +} + +/// Builder for `RegisterTraderParams`. +#[derive(Default)] +pub struct RegisterTraderParamsBuilder { + payer: Option, + trader: Option, + trader_account: Option, + max_positions: Option, + trader_pda_index: Option, + subaccount_index: Option, +} + +impl RegisterTraderParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn payer(mut self, payer: Pubkey) -> Self { + self.payer = Some(payer); + self + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn max_positions(mut self, max_positions: u64) -> Self { + self.max_positions = Some(max_positions); + self + } + + pub fn trader_pda_index(mut self, trader_pda_index: u8) -> Self { + self.trader_pda_index = Some(trader_pda_index); + self + } + + pub fn subaccount_index(mut self, subaccount_index: u8) -> Self { + self.subaccount_index = Some(subaccount_index); + self + } + + pub fn build(self) -> Result { + Ok(RegisterTraderParams { + payer: self.payer.ok_or(PhoenixIxError::MissingField("payer"))?, + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + max_positions: self + .max_positions + .ok_or(PhoenixIxError::MissingField("max_positions"))?, + trader_pda_index: self + .trader_pda_index + .ok_or(PhoenixIxError::MissingField("trader_pda_index"))?, + subaccount_index: self + .subaccount_index + .ok_or(PhoenixIxError::MissingField("subaccount_index"))?, + }) + } +} + +/// Create a register trader instruction. +/// +/// Registers a new trader account on the Phoenix protocol. The payer +/// covers rent for account creation. +/// +/// # Arguments +/// +/// * `params` - The register trader parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +pub fn create_register_trader_ix( + params: RegisterTraderParams, +) -> Result { + validate(¶ms)?; + + let data = encode_register_trader(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &RegisterTraderParams) -> Result<(), PhoenixIxError> { + if params.max_positions() == 0 || params.max_positions() > 128 { + return Err(PhoenixIxError::MissingField( + "max_positions must be between 1 and 128", + )); + } + if params.subaccount_index() > 100 { + return Err(PhoenixIxError::InvalidSubaccountIndex); + } + Ok(()) +} + +fn encode_register_trader(params: &RegisterTraderParams) -> Vec { + let mut data = Vec::with_capacity(18); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(®ister_trader_discriminant()); + + // max_positions (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.max_positions().to_le_bytes()); + + // trader_pda_index (1 byte) + data.push(params.trader_pda_index()); + + // subaccount_index (1 byte) + data.push(params.subaccount_index()); + + data +} + +fn build_accounts(params: &RegisterTraderParams) -> Vec { + vec![ + // LogAccountGroupAccounts (2 accounts) + AccountMeta::readonly(PHOENIX_PROGRAM_ID), + AccountMeta::readonly(PHOENIX_LOG_AUTHORITY), + // RegisterTraderInstructionGroupAccounts + AccountMeta::readonly(PHOENIX_GLOBAL_CONFIGURATION), + AccountMeta::writable_signer(params.payer()), + AccountMeta::readonly(params.trader()), + AccountMeta::writable(params.trader_account()), + AccountMeta::readonly(SYSTEM_PROGRAM_ID), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_cross_margin_params() -> RegisterTraderParams { + RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(128) + .trader_pda_index(0) + .subaccount_index(0) + .build() + .unwrap() + } + + fn build_isolated_margin_params() -> RegisterTraderParams { + RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(1) + .trader_pda_index(0) + .subaccount_index(1) + .build() + .unwrap() + } + + #[test] + fn test_create_register_trader_ix_cross_margin() { + let params = build_cross_margin_params(); + let ix = create_register_trader_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 7); + assert_eq!(&ix.data[..8], ®ister_trader_discriminant()); + } + + #[test] + fn test_create_register_trader_ix_isolated_margin() { + let params = build_isolated_margin_params(); + let ix = create_register_trader_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 7); + assert_eq!(&ix.data[..8], ®ister_trader_discriminant()); + } + + #[test] + fn test_register_trader_data_encoding() { + let params = RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(128) + .trader_pda_index(0) + .subaccount_index(5) + .build() + .unwrap(); + + let ix = create_register_trader_ix(params).unwrap(); + + // Total data: 8 (discriminant) + 8 (max_positions) + 1 (pda_index) + 1 + // (subaccount_index) = 18 + assert_eq!(ix.data.len(), 18); + + // max_positions = 128 as u64 LE + assert_eq!(&ix.data[8..16], &128u64.to_le_bytes()); + + // trader_pda_index = 0 + assert_eq!(ix.data[16], 0); + + // subaccount_index = 5 + assert_eq!(ix.data[17], 5); + } + + #[test] + fn test_register_trader_account_order() { + let payer = Pubkey::new_unique(); + let trader = Pubkey::new_unique(); + let trader_account = Pubkey::new_unique(); + + let params = RegisterTraderParams::builder() + .payer(payer) + .trader(trader) + .trader_account(trader_account) + .max_positions(128) + .trader_pda_index(0) + .subaccount_index(0) + .build() + .unwrap(); + + let ix = create_register_trader_ix(params).unwrap(); + + // Account 0: PHOENIX_PROGRAM_ID (readonly) + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_signer); + assert!(!ix.accounts[0].is_writable); + + // Account 1: PHOENIX_LOG_AUTHORITY (readonly) + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_signer); + assert!(!ix.accounts[1].is_writable); + + // Account 2: PHOENIX_GLOBAL_CONFIGURATION (readonly) + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(!ix.accounts[2].is_signer); + assert!(!ix.accounts[2].is_writable); + + // Account 3: payer (writable signer) + assert_eq!(ix.accounts[3].pubkey, payer); + assert!(ix.accounts[3].is_signer); + assert!(ix.accounts[3].is_writable); + + // Account 4: trader (readonly) + assert_eq!(ix.accounts[4].pubkey, trader); + assert!(!ix.accounts[4].is_signer); + assert!(!ix.accounts[4].is_writable); + + // Account 5: trader_account (writable) + assert_eq!(ix.accounts[5].pubkey, trader_account); + assert!(!ix.accounts[5].is_signer); + assert!(ix.accounts[5].is_writable); + + // Account 6: SYSTEM_PROGRAM_ID (readonly) + assert_eq!(ix.accounts[6].pubkey, SYSTEM_PROGRAM_ID); + assert!(!ix.accounts[6].is_signer); + assert!(!ix.accounts[6].is_writable); + } + + #[test] + fn test_builder_missing_required_field() { + let result = RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } + + #[test] + fn test_invalid_subaccount_index() { + let params = RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(1) + .trader_pda_index(0) + .subaccount_index(101) + .build() + .unwrap(); + + let result = create_register_trader_ix(params); + assert!(matches!( + result, + Err(PhoenixIxError::InvalidSubaccountIndex) + )); + } + + #[test] + fn test_invalid_max_positions_zero() { + let params = RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(0) + .trader_pda_index(0) + .subaccount_index(0) + .build() + .unwrap(); + + let result = create_register_trader_ix(params); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_max_positions_too_large() { + let params = RegisterTraderParams::builder() + .payer(Pubkey::new_unique()) + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .max_positions(129) + .trader_pda_index(0) + .subaccount_index(0) + .build() + .unwrap(); + + let result = create_register_trader_ix(params); + assert!(result.is_err()); + } +} diff --git a/container/vendor/rise/rust/ix/src/spl_approve.rs b/container/vendor/rise/rust/ix/src/spl_approve.rs new file mode 100644 index 00000000000..5eb74034510 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/spl_approve.rs @@ -0,0 +1,211 @@ +//! SPL Token approve instruction construction. +//! +//! This module provides instruction building for approving a delegate +//! to spend tokens from a token account. + +use solana_pubkey::Pubkey; + +use crate::constants::SPL_TOKEN_PROGRAM_ID; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for SPL Token approve instruction. +#[derive(Debug, Clone)] +pub struct SplApproveParams { + /// The source token account to approve spending from. + source: Pubkey, + /// The delegate account that will be allowed to spend tokens. + delegate: Pubkey, + /// The owner of the source token account (must sign). + owner: Pubkey, + /// Amount of tokens to approve for spending. + amount: u64, +} + +impl SplApproveParams { + /// Start building with the builder pattern. + pub fn builder() -> SplApproveParamsBuilder { + SplApproveParamsBuilder::new() + } + + pub fn source(&self) -> Pubkey { + self.source + } + + pub fn delegate(&self) -> Pubkey { + self.delegate + } + + pub fn owner(&self) -> Pubkey { + self.owner + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Builder for `SplApproveParams`. +#[derive(Default)] +pub struct SplApproveParamsBuilder { + source: Option, + delegate: Option, + owner: Option, + amount: Option, +} + +impl SplApproveParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn source(mut self, source: Pubkey) -> Self { + self.source = Some(source); + self + } + + pub fn delegate(mut self, delegate: Pubkey) -> Self { + self.delegate = Some(delegate); + self + } + + pub fn owner(mut self, owner: Pubkey) -> Self { + self.owner = Some(owner); + self + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + Ok(SplApproveParams { + source: self.source.ok_or(PhoenixIxError::MissingField("source"))?, + delegate: self + .delegate + .ok_or(PhoenixIxError::MissingField("delegate"))?, + owner: self.owner.ok_or(PhoenixIxError::MissingField("owner"))?, + amount: self.amount.ok_or(PhoenixIxError::MissingField("amount"))?, + }) + } +} + +/// Create an SPL Token approve instruction. +/// +/// This instruction approves a delegate to spend tokens from a token account. +/// +/// # Arguments +/// +/// * `params` - The approve parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing. +pub fn create_spl_approve_ix(params: SplApproveParams) -> Result { + let data = encode_spl_approve(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: SPL_TOKEN_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_spl_approve(params: &SplApproveParams) -> Vec { + let mut data = Vec::with_capacity(9); + + // SPL Token Approve instruction discriminant + data.push(4); + + // Amount (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.amount().to_le_bytes()); + + data +} + +fn build_accounts(params: &SplApproveParams) -> Vec { + vec![ + // 1. source_token_account (writable) + AccountMeta::writable(params.source()), + // 2. delegate (readonly) + AccountMeta::readonly(params.delegate()), + // 3. owner (signer, readonly) + AccountMeta::readonly_signer(params.owner()), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_spl_approve_ix() { + let params = SplApproveParams::builder() + .source(Pubkey::new_unique()) + .delegate(Pubkey::new_unique()) + .owner(Pubkey::new_unique()) + .amount(100_000_000) + .build() + .unwrap(); + + let ix = create_spl_approve_ix(params).unwrap(); + + assert_eq!(ix.program_id, SPL_TOKEN_PROGRAM_ID); + assert_eq!(ix.accounts.len(), 3); + + // Verify data encoding + assert_eq!(ix.data[0], 4); // SPL Token Approve discriminant + let amount_bytes: [u8; 8] = ix.data[1..9].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_spl_approve_missing_source() { + let result = SplApproveParams::builder() + .delegate(Pubkey::new_unique()) + .owner(Pubkey::new_unique()) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::MissingField("source")) + )); + } + + #[test] + fn test_spl_approve_account_order() { + let source = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let params = SplApproveParams::builder() + .source(source) + .delegate(delegate) + .owner(owner) + .amount(1) + .build() + .unwrap(); + + let ix = create_spl_approve_ix(params).unwrap(); + + // Verify account order and properties + assert_eq!(ix.accounts[0].pubkey, source); + assert!(ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); + + assert_eq!(ix.accounts[1].pubkey, delegate); + assert!(!ix.accounts[1].is_writable); + assert!(!ix.accounts[1].is_signer); + + assert_eq!(ix.accounts[2].pubkey, owner); + assert!(!ix.accounts[2].is_writable); + assert!(ix.accounts[2].is_signer); + } +} diff --git a/container/vendor/rise/rust/ix/src/stop_loss.rs b/container/vendor/rise/rust/ix/src/stop_loss.rs new file mode 100644 index 00000000000..a25c2e3588e --- /dev/null +++ b/container/vendor/rise/rust/ix/src/stop_loss.rs @@ -0,0 +1,463 @@ +//! Place stop loss instruction construction. +//! +//! Used for both stop-loss and take-profit bracket leg orders. +//! The `execution_direction` field determines trigger behavior: +//! - `LessThan`: triggers when price drops below threshold (stop-loss on longs) +//! - `GreaterThan`: triggers when price rises above threshold (take-profit on +//! longs) + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, SYSTEM_PROGRAM_ID, + get_stop_loss_address, place_stop_loss_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Direction, Instruction, Side, StopLossOrderKind}; + +/// Parameters for placing a stop loss order (used for SL and TP bracket legs). +#[derive(Debug, Clone)] +pub struct StopLossParams { + funder: Pubkey, + trader_account: Pubkey, + position_authority: Pubkey, + perp_asset_map: Pubkey, + orderbook: Pubkey, + spline_collection: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + asset_id: u64, + trigger_price: u64, + execution_price: u64, + trade_side: Side, + execution_direction: Direction, + order_kind: StopLossOrderKind, +} + +impl StopLossParams { + pub fn builder() -> StopLossParamsBuilder { + StopLossParamsBuilder::new() + } + + pub fn funder(&self) -> Pubkey { + self.funder + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn position_authority(&self) -> Pubkey { + self.position_authority + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn orderbook(&self) -> Pubkey { + self.orderbook + } + + pub fn spline_collection(&self) -> Pubkey { + self.spline_collection + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn asset_id(&self) -> u64 { + self.asset_id + } + + pub fn trigger_price(&self) -> u64 { + self.trigger_price + } + + pub fn execution_price(&self) -> u64 { + self.execution_price + } + + pub fn trade_side(&self) -> Side { + self.trade_side + } + + pub fn execution_direction(&self) -> Direction { + self.execution_direction + } + + pub fn order_kind(&self) -> StopLossOrderKind { + self.order_kind + } +} + +/// Builder for `StopLossParams`. +#[derive(Default)] +pub struct StopLossParamsBuilder { + funder: Option, + trader_account: Option, + position_authority: Option, + perp_asset_map: Option, + orderbook: Option, + spline_collection: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + asset_id: Option, + trigger_price: Option, + execution_price: Option, + trade_side: Option, + execution_direction: Option, + order_kind: Option, +} + +impl StopLossParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn funder(mut self, funder: Pubkey) -> Self { + self.funder = Some(funder); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn position_authority(mut self, position_authority: Pubkey) -> Self { + self.position_authority = Some(position_authority); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn orderbook(mut self, orderbook: Pubkey) -> Self { + self.orderbook = Some(orderbook); + self + } + + pub fn spline_collection(mut self, spline_collection: Pubkey) -> Self { + self.spline_collection = Some(spline_collection); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn asset_id(mut self, asset_id: u64) -> Self { + self.asset_id = Some(asset_id); + self + } + + pub fn trigger_price(mut self, trigger_price: u64) -> Self { + self.trigger_price = Some(trigger_price); + self + } + + pub fn execution_price(mut self, execution_price: u64) -> Self { + self.execution_price = Some(execution_price); + self + } + + pub fn trade_side(mut self, trade_side: Side) -> Self { + self.trade_side = Some(trade_side); + self + } + + pub fn execution_direction(mut self, execution_direction: Direction) -> Self { + self.execution_direction = Some(execution_direction); + self + } + + pub fn order_kind(mut self, order_kind: StopLossOrderKind) -> Self { + self.order_kind = Some(order_kind); + self + } + + pub fn build(self) -> Result { + Ok(StopLossParams { + funder: self.funder.ok_or(PhoenixIxError::MissingField("funder"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + position_authority: self + .position_authority + .ok_or(PhoenixIxError::MissingField("position_authority"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + orderbook: self + .orderbook + .ok_or(PhoenixIxError::MissingField("orderbook"))?, + spline_collection: self + .spline_collection + .ok_or(PhoenixIxError::MissingField("spline_collection"))?, + global_trader_index: self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?, + active_trader_buffer: self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?, + asset_id: self + .asset_id + .ok_or(PhoenixIxError::MissingField("asset_id"))?, + trigger_price: self + .trigger_price + .ok_or(PhoenixIxError::MissingField("trigger_price"))?, + execution_price: self + .execution_price + .ok_or(PhoenixIxError::MissingField("execution_price"))?, + trade_side: self + .trade_side + .ok_or(PhoenixIxError::MissingField("trade_side"))?, + execution_direction: self + .execution_direction + .ok_or(PhoenixIxError::MissingField("execution_direction"))?, + order_kind: self + .order_kind + .ok_or(PhoenixIxError::MissingField("order_kind"))?, + }) + } +} + +/// Create a place stop loss instruction. +pub fn create_place_stop_loss_ix(params: StopLossParams) -> Result { + validate(¶ms)?; + + let data = encode_stop_loss(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn validate(params: &StopLossParams) -> Result<(), PhoenixIxError> { + if params.global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + if params.active_trader_buffer.is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + Ok(()) +} + +fn encode_stop_loss(params: &StopLossParams) -> Vec { + let mut data = Vec::with_capacity(35); + + // 8 bytes: discriminant + data.extend_from_slice(&place_stop_loss_discriminant()); + // 8 bytes: trigger_price + data.extend_from_slice(¶ms.trigger_price.to_le_bytes()); + // 8 bytes: execution_price + data.extend_from_slice(¶ms.execution_price.to_le_bytes()); + // 8 bytes: _trade_size = 0 + data.extend_from_slice(&0u64.to_le_bytes()); + // 1 byte: trade_side + data.push(params.trade_side as u8); + // 1 byte: execution_direction + data.push(params.execution_direction as u8); + // 1 byte: order_kind + data.push(params.order_kind as u8); + + data +} + +fn build_accounts(params: &StopLossParams) -> Vec { + let stop_loss_pda = get_stop_loss_address(¶ms.trader_account, params.asset_id); + + let mut accounts = Vec::new(); + + // LogAccountGroupAccounts + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + + // PlaceStopLossInstructionGroupAccounts + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + accounts.push(AccountMeta::writable_signer(params.funder)); + + // MatchingEngineAccountGroupAccounts (same ordering as limit_order.rs) + accounts.push(AccountMeta::writable(params.trader_account)); + accounts.push(AccountMeta::writable(params.perp_asset_map)); + for addr in ¶ms.global_trader_index { + accounts.push(AccountMeta::writable(*addr)); + } + for addr in ¶ms.active_trader_buffer { + accounts.push(AccountMeta::writable(*addr)); + } + accounts.push(AccountMeta::writable(params.orderbook)); + accounts.push(AccountMeta::writable(params.spline_collection)); + + // Trailing accounts + accounts.push(AccountMeta::readonly_signer(params.position_authority)); + accounts.push(AccountMeta::writable(stop_loss_pda)); + accounts.push(AccountMeta::readonly(SYSTEM_PROGRAM_ID)); + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_params() -> StopLossParams { + StopLossParams::builder() + .funder(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .position_authority(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .asset_id(1) + .trigger_price(50000) + .execution_price(50000) + .trade_side(Side::Ask) + .execution_direction(Direction::LessThan) + .order_kind(StopLossOrderKind::IOC) + .build() + .unwrap() + } + + #[test] + fn test_create_stop_loss_ix() { + let params = test_params(); + let ix = create_place_stop_loss_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 2 log + 2 (global_config, funder) + 2 (trader_account, perp_asset_map) + // + 1 gti + 1 atb + 2 (orderbook, spline) + 3 (authority, stop_loss, system) = + // 13 + assert_eq!(ix.accounts.len(), 13); + assert_eq!(&ix.data[..8], &place_stop_loss_discriminant()); + } + + #[test] + fn test_stop_loss_data_encoding() { + let params = test_params(); + let data = encode_stop_loss(¶ms); + + // 8 discriminant + 8 trigger + 8 execution + 8 trade_size + 1 side + 1 dir + 1 + // kind = 35 + assert_eq!(data.len(), 35); + + let trigger = u64::from_le_bytes(data[8..16].try_into().unwrap()); + assert_eq!(trigger, 50000); + + let execution = u64::from_le_bytes(data[16..24].try_into().unwrap()); + assert_eq!(execution, 50000); + + let trade_size = u64::from_le_bytes(data[24..32].try_into().unwrap()); + assert_eq!(trade_size, 0); + + assert_eq!(data[32], Side::Ask as u8); + assert_eq!(data[33], Direction::LessThan as u8); + assert_eq!(data[34], StopLossOrderKind::IOC as u8); + } + + #[test] + fn test_stop_loss_account_positions() { + let params = test_params(); + let accounts = build_accounts(¶ms); + + // Position 0: program id (readonly) + assert_eq!(accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!accounts[0].is_signer); + assert!(!accounts[0].is_writable); + + // Position 1: log authority (readonly) + assert_eq!(accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!accounts[1].is_signer); + + // Position 2: global config (writable) + assert_eq!(accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(accounts[2].is_writable); + + // Position 3: funder (writable signer) + assert_eq!(accounts[3].pubkey, params.funder); + assert!(accounts[3].is_signer); + assert!(accounts[3].is_writable); + + // Position 4: trader_account (writable) + assert_eq!(accounts[4].pubkey, params.trader_account); + assert!(accounts[4].is_writable); + + // Position 5: perp_asset_map (writable) + assert_eq!(accounts[5].pubkey, params.perp_asset_map); + assert!(accounts[5].is_writable); + + // Positions 6..N-4: gti + atb (writable) + // Position N-4: orderbook (writable) + // Position N-3: spline_collection (writable) + let n = accounts.len(); + + // Position N-3: position_authority (readonly signer) + assert_eq!(accounts[n - 3].pubkey, params.position_authority); + assert!(accounts[n - 3].is_signer); + assert!(!accounts[n - 3].is_writable); + + // Position N-2: stop_loss_account (writable) + let expected_sl_pda = get_stop_loss_address(¶ms.trader_account, params.asset_id); + assert_eq!(accounts[n - 2].pubkey, expected_sl_pda); + assert!(accounts[n - 2].is_writable); + + // Position N-1: system_program (readonly) + assert_eq!(accounts[n - 1].pubkey, SYSTEM_PROGRAM_ID); + assert!(!accounts[n - 1].is_signer); + assert!(!accounts[n - 1].is_writable); + } + + #[test] + fn test_empty_global_trader_index_fails() { + let params = StopLossParams::builder() + .funder(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .position_authority(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .orderbook(Pubkey::new_unique()) + .spline_collection(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .asset_id(1) + .trigger_price(50000) + .execution_price(50000) + .trade_side(Side::Ask) + .execution_direction(Direction::LessThan) + .order_kind(StopLossOrderKind::IOC) + .build() + .unwrap(); + + let result = create_place_stop_loss_ix(params); + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_builder_missing_required_field() { + let result = StopLossParams::builder() + .funder(Pubkey::new_unique()) + .build(); + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } +} diff --git a/container/vendor/rise/rust/ix/src/sync_parent_to_child.rs b/container/vendor/rise/rust/ix/src/sync_parent_to_child.rs new file mode 100644 index 00000000000..17fb8266210 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/sync_parent_to_child.rs @@ -0,0 +1,296 @@ +//! Sync parent-to-child instruction construction. +//! +//! This module provides instruction building for syncing a parent trader +//! account's state to a child (isolated) subaccount, including global trader +//! index updates. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + sync_parent_to_child_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for syncing parent state to a child subaccount. +#[derive(Debug, Clone)] +pub struct SyncParentToChildParams { + /// The trader wallet authority (readonly signer). + trader_wallet: Pubkey, + /// The parent trader account (readonly) - source of state. + parent_trader_account: Pubkey, + /// The child trader account (writable) - destination. + child_trader_account: Pubkey, + /// Global trader index addresses (header + arenas). + global_trader_index: Vec, +} + +impl SyncParentToChildParams { + pub fn builder() -> SyncParentToChildParamsBuilder { + SyncParentToChildParamsBuilder::new() + } + + pub fn trader_wallet(&self) -> Pubkey { + self.trader_wallet + } + + pub fn parent_trader_account(&self) -> Pubkey { + self.parent_trader_account + } + + pub fn child_trader_account(&self) -> Pubkey { + self.child_trader_account + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } +} + +/// Builder for `SyncParentToChildParams`. +#[derive(Default)] +pub struct SyncParentToChildParamsBuilder { + trader_wallet: Option, + parent_trader_account: Option, + child_trader_account: Option, + global_trader_index: Option>, +} + +impl SyncParentToChildParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader_wallet(mut self, trader_wallet: Pubkey) -> Self { + self.trader_wallet = Some(trader_wallet); + self + } + + pub fn parent_trader_account(mut self, parent_trader_account: Pubkey) -> Self { + self.parent_trader_account = Some(parent_trader_account); + self + } + + pub fn child_trader_account(mut self, child_trader_account: Pubkey) -> Self { + self.child_trader_account = Some(child_trader_account); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn build(self) -> Result { + let parent_trader_account = self + .parent_trader_account + .ok_or(PhoenixIxError::MissingField("parent_trader_account"))?; + let child_trader_account = self + .child_trader_account + .ok_or(PhoenixIxError::MissingField("child_trader_account"))?; + + if parent_trader_account == child_trader_account { + return Err(PhoenixIxError::MissingField( + "parent and child trader accounts must be different", + )); + } + + let global_trader_index = self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?; + if global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + + Ok(SyncParentToChildParams { + trader_wallet: self + .trader_wallet + .ok_or(PhoenixIxError::MissingField("trader_wallet"))?, + parent_trader_account, + child_trader_account, + global_trader_index, + }) + } +} + +/// Create a sync parent-to-child instruction. +/// +/// Syncs a parent trader account's state to a child (isolated) subaccount. +/// The trader wallet must be the authority for both accounts. +pub fn create_sync_parent_to_child_ix( + params: SyncParentToChildParams, +) -> Result { + let data = sync_parent_to_child_discriminant().to_vec(); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn build_accounts(params: &SyncParentToChildParams) -> Vec { + let mut accounts = vec![ + // LogAccountGroupAccounts (2 accounts) + AccountMeta::readonly(PHOENIX_PROGRAM_ID), + AccountMeta::readonly(PHOENIX_LOG_AUTHORITY), + // SyncParentToChildInstructionGroupAccounts + AccountMeta::readonly(PHOENIX_GLOBAL_CONFIGURATION), + AccountMeta::readonly_signer(params.trader_wallet()), + AccountMeta::readonly(params.parent_trader_account()), + AccountMeta::writable(params.child_trader_account()), + ]; + + // global_trader_index addresses (all writable) + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_params() -> SyncParentToChildParams { + SyncParentToChildParams::builder() + .trader_wallet(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .build() + .unwrap() + } + + #[test] + fn test_create_sync_parent_to_child_ix() { + let params = build_params(); + let ix = create_sync_parent_to_child_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 6 base accounts + 1 global_trader_index = 7 + assert_eq!(ix.accounts.len(), 7); + assert_eq!(&ix.data[..8], &sync_parent_to_child_discriminant()); + } + + #[test] + fn test_data_is_discriminant_only() { + let params = build_params(); + let ix = create_sync_parent_to_child_ix(params).unwrap(); + + assert_eq!(ix.data.len(), 8); + } + + #[test] + fn test_account_order() { + let trader_wallet = Pubkey::new_unique(); + let parent = Pubkey::new_unique(); + let child = Pubkey::new_unique(); + let gti = Pubkey::new_unique(); + + let params = SyncParentToChildParams::builder() + .trader_wallet(trader_wallet) + .parent_trader_account(parent) + .child_trader_account(child) + .global_trader_index(vec![gti]) + .build() + .unwrap(); + + let ix = create_sync_parent_to_child_ix(params).unwrap(); + + // Account 0: PHOENIX_PROGRAM_ID (readonly) + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_signer); + assert!(!ix.accounts[0].is_writable); + + // Account 1: PHOENIX_LOG_AUTHORITY (readonly) + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_signer); + assert!(!ix.accounts[1].is_writable); + + // Account 2: PHOENIX_GLOBAL_CONFIGURATION (readonly) + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(!ix.accounts[2].is_signer); + assert!(!ix.accounts[2].is_writable); + + // Account 3: trader_wallet (readonly signer) + assert_eq!(ix.accounts[3].pubkey, trader_wallet); + assert!(ix.accounts[3].is_signer); + assert!(!ix.accounts[3].is_writable); + + // Account 4: parent_trader_account (readonly) + assert_eq!(ix.accounts[4].pubkey, parent); + assert!(!ix.accounts[4].is_signer); + assert!(!ix.accounts[4].is_writable); + + // Account 5: child_trader_account (writable) + assert_eq!(ix.accounts[5].pubkey, child); + assert!(!ix.accounts[5].is_signer); + assert!(ix.accounts[5].is_writable); + + // Account 6: global_trader_index (writable) + assert_eq!(ix.accounts[6].pubkey, gti); + assert!(ix.accounts[6].is_writable); + } + + #[test] + fn test_multiple_global_trader_index() { + let gti1 = Pubkey::new_unique(); + let gti2 = Pubkey::new_unique(); + + let params = SyncParentToChildParams::builder() + .trader_wallet(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .global_trader_index(vec![gti1, gti2]) + .build() + .unwrap(); + + let ix = create_sync_parent_to_child_ix(params).unwrap(); + + // 6 base + 2 gti = 8 + assert_eq!(ix.accounts.len(), 8); + assert_eq!(ix.accounts[6].pubkey, gti1); + assert_eq!(ix.accounts[7].pubkey, gti2); + } + + #[test] + fn test_builder_missing_required_field() { + let result = SyncParentToChildParams::builder() + .trader_wallet(Pubkey::new_unique()) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::MissingField(_)))); + } + + #[test] + fn test_same_parent_and_child_rejected() { + let account = Pubkey::new_unique(); + let result = SyncParentToChildParams::builder() + .trader_wallet(Pubkey::new_unique()) + .parent_trader_account(account) + .child_trader_account(account) + .global_trader_index(vec![Pubkey::new_unique()]) + .build(); + + assert!(result.is_err()); + } + + #[test] + fn test_empty_global_trader_index() { + let result = SyncParentToChildParams::builder() + .trader_wallet(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .global_trader_index(vec![]) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } +} diff --git a/container/vendor/rise/rust/ix/src/transfer_collateral.rs b/container/vendor/rise/rust/ix/src/transfer_collateral.rs new file mode 100644 index 00000000000..afa1441f2f7 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/transfer_collateral.rs @@ -0,0 +1,694 @@ +//! Transfer collateral instruction construction. +//! +//! This module provides instruction building for transferring collateral +//! between subaccounts (e.g., cross-margin to isolated margin). + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, + transfer_collateral_child_to_parent_discriminant, transfer_collateral_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for transferring collateral between subaccounts. +#[derive(Debug, Clone)] +pub struct TransferCollateralParams { + /// The trader's authority (wallet) — must sign. + trader: Pubkey, + /// The source trader PDA account (writable). + src_trader_account: Pubkey, + /// The destination trader PDA account (writable). + dst_trader_account: Pubkey, + /// The perp asset map account (readonly). + perp_asset_map: Pubkey, + /// Global trader index addresses (header + arenas). + global_trader_index: Vec, + /// Active trader buffer addresses (header + arenas). + active_trader_buffer: Vec, + /// Amount to transfer in token base units. + amount: u64, +} + +impl TransferCollateralParams { + /// Start building with the builder pattern. + pub fn builder() -> TransferCollateralParamsBuilder { + TransferCollateralParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn src_trader_account(&self) -> Pubkey { + self.src_trader_account + } + + pub fn dst_trader_account(&self) -> Pubkey { + self.dst_trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Builder for `TransferCollateralParams`. +#[derive(Default)] +pub struct TransferCollateralParamsBuilder { + trader: Option, + src_trader_account: Option, + dst_trader_account: Option, + perp_asset_map: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + amount: Option, +} + +impl TransferCollateralParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn src_trader_account(mut self, src_trader_account: Pubkey) -> Self { + self.src_trader_account = Some(src_trader_account); + self + } + + pub fn dst_trader_account(mut self, dst_trader_account: Pubkey) -> Self { + self.dst_trader_account = Some(dst_trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + let amount = self.amount.ok_or(PhoenixIxError::MissingField("amount"))?; + if amount == 0 { + return Err(PhoenixIxError::InvalidTransferAmount); + } + + let global_trader_index = self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?; + if global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + + let active_trader_buffer = self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?; + if active_trader_buffer.is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + + Ok(TransferCollateralParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + src_trader_account: self + .src_trader_account + .ok_or(PhoenixIxError::MissingField("src_trader_account"))?, + dst_trader_account: self + .dst_trader_account + .ok_or(PhoenixIxError::MissingField("dst_trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + global_trader_index, + active_trader_buffer, + amount, + }) + } +} + +/// Create a transfer collateral instruction. +/// +/// Transfers collateral between two subaccounts (e.g., from cross-margin +/// subaccount 0 to an isolated margin subaccount). +/// +/// # Arguments +/// +/// * `params` - The transfer collateral parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +pub fn create_transfer_collateral_ix( + params: TransferCollateralParams, +) -> Result { + let data = encode_transfer_collateral(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_transfer_collateral(params: &TransferCollateralParams) -> Vec { + let mut data = Vec::with_capacity(16); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&transfer_collateral_discriminant()); + + // Amount (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.amount().to_le_bytes()); + + data +} + +fn build_accounts(params: &TransferCollateralParams) -> Vec { + let mut accounts = Vec::new(); + + // 1. phoenix_program (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + // 2. phoenix_log_authority (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + // 3. global_configuration (readonly — differs from deposit which is writable) + accounts.push(AccountMeta::readonly(PHOENIX_GLOBAL_CONFIGURATION)); + // 4. trader (readonly signer) + accounts.push(AccountMeta::readonly_signer(params.trader())); + // 5. src_trader_account (writable) + accounts.push(AccountMeta::writable(params.src_trader_account())); + // 6. dst_trader_account (writable) + accounts.push(AccountMeta::writable(params.dst_trader_account())); + // 7. perp_asset_map (readonly) + accounts.push(AccountMeta::readonly(params.perp_asset_map())); + + // 8-N. global_trader_index addresses (all writable) + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // N+1-M. active_trader_buffer addresses (all writable) + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts +} + +/// Parameters for transferring all collateral from a child subaccount back to +/// the parent (subaccount 0). +#[derive(Debug, Clone)] +pub struct TransferCollateralChildToParentParams { + /// The trader's authority (wallet) — must sign. + trader: Pubkey, + /// The child trader PDA account (writable). + child_trader_account: Pubkey, + /// The parent trader PDA account (writable). + parent_trader_account: Pubkey, + /// The perp asset map account (readonly). + perp_asset_map: Pubkey, + /// Global trader index addresses (header + arenas). + global_trader_index: Vec, + /// Active trader buffer addresses (header + arenas). + active_trader_buffer: Vec, +} + +impl TransferCollateralChildToParentParams { + pub fn builder() -> TransferCollateralChildToParentParamsBuilder { + TransferCollateralChildToParentParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn child_trader_account(&self) -> Pubkey { + self.child_trader_account + } + + pub fn parent_trader_account(&self) -> Pubkey { + self.parent_trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } +} + +/// Builder for `TransferCollateralChildToParentParams`. +#[derive(Default)] +pub struct TransferCollateralChildToParentParamsBuilder { + trader: Option, + child_trader_account: Option, + parent_trader_account: Option, + perp_asset_map: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, +} + +impl TransferCollateralChildToParentParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn child_trader_account(mut self, child_trader_account: Pubkey) -> Self { + self.child_trader_account = Some(child_trader_account); + self + } + + pub fn parent_trader_account(mut self, parent_trader_account: Pubkey) -> Self { + self.parent_trader_account = Some(parent_trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn build(self) -> Result { + let global_trader_index = self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?; + if global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + + let active_trader_buffer = self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?; + if active_trader_buffer.is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + + Ok(TransferCollateralChildToParentParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + child_trader_account: self + .child_trader_account + .ok_or(PhoenixIxError::MissingField("child_trader_account"))?, + parent_trader_account: self + .parent_trader_account + .ok_or(PhoenixIxError::MissingField("parent_trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + global_trader_index, + active_trader_buffer, + }) + } +} + +/// Create a transfer collateral child-to-parent instruction. +/// +/// Transfers **all** collateral from a child subaccount back to the parent +/// (subaccount 0). No-ops if the child has open positions, open orders, or +/// zero collateral. +pub fn create_transfer_collateral_child_to_parent_ix( + params: TransferCollateralChildToParentParams, +) -> Result { + let data = transfer_collateral_child_to_parent_discriminant().to_vec(); + let accounts = build_child_to_parent_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn build_child_to_parent_accounts( + params: &TransferCollateralChildToParentParams, +) -> Vec { + let mut accounts = Vec::new(); + + // 1. phoenix_program (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + // 2. phoenix_log_authority (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + // 3. global_configuration (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_GLOBAL_CONFIGURATION)); + // 4. trader (readonly signer) + accounts.push(AccountMeta::readonly_signer(params.trader())); + // 5. child_trader_account (writable) + accounts.push(AccountMeta::writable(params.child_trader_account())); + // 6. parent_trader_account (writable) + accounts.push(AccountMeta::writable(params.parent_trader_account())); + // 7. perp_asset_map (readonly) + accounts.push(AccountMeta::readonly(params.perp_asset_map())); + + // 8-N. global_trader_index addresses (all writable) + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // N+1-M. active_trader_buffer addresses (all writable) + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_transfer_collateral_ix() { + let params = TransferCollateralParams::builder() + .trader(Pubkey::new_unique()) + .src_trader_account(Pubkey::new_unique()) + .dst_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(100_000_000) + .build() + .unwrap(); + + let ix = create_transfer_collateral_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 7 base accounts + 1 global_trader_index + 1 active_trader_buffer = 9 + assert_eq!(ix.accounts.len(), 9); + + // Verify data encoding + assert_eq!(&ix.data[..8], &transfer_collateral_discriminant()); + let amount_bytes: [u8; 8] = ix.data[8..16].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_transfer_collateral_zero_amount() { + let result = TransferCollateralParams::builder() + .trader(Pubkey::new_unique()) + .src_trader_account(Pubkey::new_unique()) + .dst_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(0) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::InvalidTransferAmount))); + } + + #[test] + fn test_transfer_collateral_empty_global_trader_index() { + let result = TransferCollateralParams::builder() + .trader(Pubkey::new_unique()) + .src_trader_account(Pubkey::new_unique()) + .dst_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_transfer_collateral_empty_active_trader_buffer() { + let result = TransferCollateralParams::builder() + .trader(Pubkey::new_unique()) + .src_trader_account(Pubkey::new_unique()) + .dst_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![]) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyActiveTraderBuffer) + )); + } + + #[test] + fn test_transfer_collateral_account_order() { + let trader = Pubkey::new_unique(); + let src = Pubkey::new_unique(); + let dst = Pubkey::new_unique(); + let pam = Pubkey::new_unique(); + let gti = Pubkey::new_unique(); + let atb = Pubkey::new_unique(); + + let params = TransferCollateralParams::builder() + .trader(trader) + .src_trader_account(src) + .dst_trader_account(dst) + .perp_asset_map(pam) + .global_trader_index(vec![gti]) + .active_trader_buffer(vec![atb]) + .amount(1) + .build() + .unwrap(); + + let ix = create_transfer_collateral_ix(params).unwrap(); + + // Account 0: PHOENIX_PROGRAM_ID (readonly) + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_writable); + + // Account 1: PHOENIX_LOG_AUTHORITY (readonly) + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_writable); + + // Account 2: PHOENIX_GLOBAL_CONFIGURATION (readonly — not writable) + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(!ix.accounts[2].is_writable); + + // Account 3: trader (readonly signer) + assert_eq!(ix.accounts[3].pubkey, trader); + assert!(ix.accounts[3].is_signer); + assert!(!ix.accounts[3].is_writable); + + // Account 4: src_trader_account (writable) + assert_eq!(ix.accounts[4].pubkey, src); + assert!(ix.accounts[4].is_writable); + + // Account 5: dst_trader_account (writable) + assert_eq!(ix.accounts[5].pubkey, dst); + assert!(ix.accounts[5].is_writable); + + // Account 6: perp_asset_map (readonly) + assert_eq!(ix.accounts[6].pubkey, pam); + assert!(!ix.accounts[6].is_writable); + + // Account 7: gti (writable) + assert_eq!(ix.accounts[7].pubkey, gti); + assert!(ix.accounts[7].is_writable); + + // Account 8: atb (writable) + assert_eq!(ix.accounts[8].pubkey, atb); + assert!(ix.accounts[8].is_writable); + } + + #[test] + fn test_transfer_collateral_multiple_index_accounts() { + let gti1 = Pubkey::new_unique(); + let gti2 = Pubkey::new_unique(); + let atb1 = Pubkey::new_unique(); + let atb2 = Pubkey::new_unique(); + + let params = TransferCollateralParams::builder() + .trader(Pubkey::new_unique()) + .src_trader_account(Pubkey::new_unique()) + .dst_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![gti1, gti2]) + .active_trader_buffer(vec![atb1, atb2]) + .amount(1) + .build() + .unwrap(); + + let ix = create_transfer_collateral_ix(params).unwrap(); + + // 7 base accounts + 2 gti + 2 atb = 11 + assert_eq!(ix.accounts.len(), 11); + + // Verify gti accounts + assert_eq!(ix.accounts[7].pubkey, gti1); + assert_eq!(ix.accounts[8].pubkey, gti2); + + // Verify atb accounts + assert_eq!(ix.accounts[9].pubkey, atb1); + assert_eq!(ix.accounts[10].pubkey, atb2); + } + + #[test] + fn test_create_transfer_collateral_child_to_parent_ix() { + let params = TransferCollateralChildToParentParams::builder() + .trader(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .build() + .unwrap(); + + let ix = create_transfer_collateral_child_to_parent_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 7 base accounts + 1 global_trader_index + 1 active_trader_buffer = 9 + assert_eq!(ix.accounts.len(), 9); + + // Data is discriminant only (8 bytes, no payload) + assert_eq!(ix.data.len(), 8); + assert_eq!( + &ix.data[..8], + &transfer_collateral_child_to_parent_discriminant() + ); + } + + #[test] + fn test_child_to_parent_account_order() { + let trader = Pubkey::new_unique(); + let child = Pubkey::new_unique(); + let parent = Pubkey::new_unique(); + let pam = Pubkey::new_unique(); + let gti = Pubkey::new_unique(); + let atb = Pubkey::new_unique(); + + let params = TransferCollateralChildToParentParams::builder() + .trader(trader) + .child_trader_account(child) + .parent_trader_account(parent) + .perp_asset_map(pam) + .global_trader_index(vec![gti]) + .active_trader_buffer(vec![atb]) + .build() + .unwrap(); + + let ix = create_transfer_collateral_child_to_parent_ix(params).unwrap(); + + // Account 0: PHOENIX_PROGRAM_ID (readonly) + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_writable); + + // Account 1: PHOENIX_LOG_AUTHORITY (readonly) + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_writable); + + // Account 2: PHOENIX_GLOBAL_CONFIGURATION (readonly) + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(!ix.accounts[2].is_writable); + + // Account 3: trader (readonly signer) + assert_eq!(ix.accounts[3].pubkey, trader); + assert!(ix.accounts[3].is_signer); + assert!(!ix.accounts[3].is_writable); + + // Account 4: child_trader_account (writable) + assert_eq!(ix.accounts[4].pubkey, child); + assert!(ix.accounts[4].is_writable); + + // Account 5: parent_trader_account (writable) + assert_eq!(ix.accounts[5].pubkey, parent); + assert!(ix.accounts[5].is_writable); + + // Account 6: perp_asset_map (readonly) + assert_eq!(ix.accounts[6].pubkey, pam); + assert!(!ix.accounts[6].is_writable); + + // Account 7: gti (writable) + assert_eq!(ix.accounts[7].pubkey, gti); + assert!(ix.accounts[7].is_writable); + + // Account 8: atb (writable) + assert_eq!(ix.accounts[8].pubkey, atb); + assert!(ix.accounts[8].is_writable); + } + + #[test] + fn test_child_to_parent_empty_global_trader_index() { + let result = TransferCollateralChildToParentParams::builder() + .trader(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_child_to_parent_empty_active_trader_buffer() { + let result = TransferCollateralChildToParentParams::builder() + .trader(Pubkey::new_unique()) + .child_trader_account(Pubkey::new_unique()) + .parent_trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![]) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyActiveTraderBuffer) + )); + } +} diff --git a/container/vendor/rise/rust/ix/src/types.rs b/container/vendor/rise/rust/ix/src/types.rs new file mode 100644 index 00000000000..512ed8aa767 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/types.rs @@ -0,0 +1,190 @@ +//! Common types for Phoenix instruction construction. + +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_pubkey::Pubkey; + +/// Side of an order - either Bid (buy) or Ask (sell). +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(use_discriminant = true)] +#[repr(u8)] +pub enum Side { + Bid = 0, + Ask = 1, +} + +impl Side { + /// Returns the API wire string for this side (`"buy"` or `"sell"`). + pub fn to_api_string(self) -> &'static str { + match self { + Side::Bid => "buy", + Side::Ask => "sell", + } + } +} + +/// Order flags for specifying order behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(use_discriminant = true)] +#[repr(u8)] +pub enum OrderFlags { + /// No special flags. + None = 0, + /// Reduce only flag - order can only reduce existing position. + ReduceOnly = 128, // 1 << 7 +} + +/// Self-trade behavior for orders that can match against existing orders. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(use_discriminant = true)] +#[repr(u8)] +pub enum SelfTradeBehavior { + /// Abort the new order if it would self-trade. + Abort = 0, + /// Cancel the existing order and provide the new order. + CancelProvide = 1, + /// Decrement the existing order and provide the new order. + DecrementTake = 2, +} + +/// A FIFO order ID, used to uniquely identify an order on the book. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct FifoOrderId { + /// The price of the order, in ticks. + pub price_in_ticks: u64, + /// The order sequence number. + pub order_sequence_number: u64, +} + +/// A cancel ID, used to specify an order to cancel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct CancelId { + /// Optional node pointer (0 if not specified). + pub node_pointer: u32, + /// The order ID to cancel. + pub order_id: FifoOrderId, +} + +impl CancelId { + /// Create a new CancelId from price and sequence number. + pub fn new(price_in_ticks: u64, order_sequence_number: u64) -> Self { + Self { + node_pointer: 0, + order_id: FifoOrderId { + price_in_ticks, + order_sequence_number, + }, + } + } +} + +/// Direction for price comparison triggers (used in stop-loss/take-profit +/// orders). +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(use_discriminant = true)] +#[repr(u8)] +pub enum Direction { + /// Trigger when price moves above threshold. + GreaterThan = 0, + /// Trigger when price moves below threshold. + LessThan = 1, +} + +/// Execution kind for stop-loss/take-profit orders. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(use_discriminant = true)] +#[repr(u8)] +pub enum StopLossOrderKind { + /// Immediate-or-cancel execution. + IOC = 0, + /// Limit order execution. + Limit = 1, +} + +/// How to fund an isolated subaccount before placing an order. +/// +/// Collateral amounts are in **quote lots** (native USDC base units, +/// i.e. 1 USDC = 1 000 000 quote lots). +pub enum IsolatedCollateralFlow { + /// Desired total collateral level — only the delta above existing + /// collateral is transferred from cross-margin. + TransferFromCrossMargin { collateral: u64 }, + /// Deposit fresh USDC directly into the isolated subaccount. + Deposit { usdc_amount: u64 }, +} + +/// Account metadata for Solana instructions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountMeta { + pub pubkey: Pubkey, + pub is_signer: bool, + pub is_writable: bool, +} + +impl AccountMeta { + /// Create a readonly account. + pub fn readonly(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: false, + is_writable: false, + } + } + + /// Create a writable account. + pub fn writable(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: false, + is_writable: true, + } + } + + /// Create a readonly signer account. + pub fn readonly_signer(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: true, + is_writable: false, + } + } + + /// Create a writable signer account. + pub fn writable_signer(pubkey: Pubkey) -> Self { + Self { + pubkey, + is_signer: true, + is_writable: true, + } + } +} + +/// A Solana instruction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Instruction { + /// The program ID to invoke. + pub program_id: Pubkey, + /// The accounts required by the instruction. + pub accounts: Vec, + /// The instruction data. + pub data: Vec, +} + +impl From for solana_instruction::AccountMeta { + fn from(meta: AccountMeta) -> Self { + Self { + pubkey: meta.pubkey, + is_signer: meta.is_signer, + is_writable: meta.is_writable, + } + } +} + +impl From for solana_instruction::Instruction { + fn from(ix: Instruction) -> Self { + Self { + program_id: ix.program_id, + accounts: ix.accounts.into_iter().map(Into::into).collect(), + data: ix.data, + } + } +} diff --git a/container/vendor/rise/rust/ix/src/withdraw_funds.rs b/container/vendor/rise/rust/ix/src/withdraw_funds.rs new file mode 100644 index 00000000000..46b7f516ad9 --- /dev/null +++ b/container/vendor/rise/rust/ix/src/withdraw_funds.rs @@ -0,0 +1,459 @@ +//! Withdraw funds instruction construction. +//! +//! This module provides instruction building for withdrawing Phoenix tokens +//! from the Phoenix protocol. + +use solana_pubkey::Pubkey; + +use crate::constants::{ + PHOENIX_GLOBAL_CONFIGURATION, PHOENIX_LOG_AUTHORITY, PHOENIX_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID, + withdraw_funds_discriminant, +}; +use crate::error::PhoenixIxError; +use crate::types::{AccountMeta, Instruction}; + +/// Parameters for withdrawing Phoenix tokens from the protocol. +#[derive(Debug, Clone)] +pub struct WithdrawFundsParams { + /// The trader's authority (wallet) - must sign. + trader: Pubkey, + /// The trader's PDA account. + trader_account: Pubkey, + /// The perp asset map account. + perp_asset_map: Pubkey, + /// The global vault (Phoenix protocol vault for the mint). + global_vault: Pubkey, + /// The trader's token account (ATA for Phoenix tokens) - destination. + trader_token_account: Pubkey, + /// Global trader index addresses (header + arenas). + global_trader_index: Vec, + /// Active trader buffer addresses (header + arenas). + active_trader_buffer: Vec, + /// The withdraw queue account. + withdraw_queue: Pubkey, + /// Amount to withdraw in token base units. + amount: u64, +} + +impl WithdrawFundsParams { + /// Start building with the builder pattern. + pub fn builder() -> WithdrawFundsParamsBuilder { + WithdrawFundsParamsBuilder::new() + } + + pub fn trader(&self) -> Pubkey { + self.trader + } + + pub fn trader_account(&self) -> Pubkey { + self.trader_account + } + + pub fn perp_asset_map(&self) -> Pubkey { + self.perp_asset_map + } + + pub fn global_vault(&self) -> Pubkey { + self.global_vault + } + + pub fn trader_token_account(&self) -> Pubkey { + self.trader_token_account + } + + pub fn global_trader_index(&self) -> &[Pubkey] { + &self.global_trader_index + } + + pub fn active_trader_buffer(&self) -> &[Pubkey] { + &self.active_trader_buffer + } + + pub fn withdraw_queue(&self) -> Pubkey { + self.withdraw_queue + } + + pub fn amount(&self) -> u64 { + self.amount + } +} + +/// Builder for `WithdrawFundsParams`. +#[derive(Default)] +pub struct WithdrawFundsParamsBuilder { + trader: Option, + trader_account: Option, + perp_asset_map: Option, + global_vault: Option, + trader_token_account: Option, + global_trader_index: Option>, + active_trader_buffer: Option>, + withdraw_queue: Option, + amount: Option, +} + +impl WithdrawFundsParamsBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn trader(mut self, trader: Pubkey) -> Self { + self.trader = Some(trader); + self + } + + pub fn trader_account(mut self, trader_account: Pubkey) -> Self { + self.trader_account = Some(trader_account); + self + } + + pub fn perp_asset_map(mut self, perp_asset_map: Pubkey) -> Self { + self.perp_asset_map = Some(perp_asset_map); + self + } + + pub fn global_vault(mut self, global_vault: Pubkey) -> Self { + self.global_vault = Some(global_vault); + self + } + + pub fn trader_token_account(mut self, trader_token_account: Pubkey) -> Self { + self.trader_token_account = Some(trader_token_account); + self + } + + pub fn global_trader_index(mut self, global_trader_index: Vec) -> Self { + self.global_trader_index = Some(global_trader_index); + self + } + + pub fn active_trader_buffer(mut self, active_trader_buffer: Vec) -> Self { + self.active_trader_buffer = Some(active_trader_buffer); + self + } + + pub fn withdraw_queue(mut self, withdraw_queue: Pubkey) -> Self { + self.withdraw_queue = Some(withdraw_queue); + self + } + + pub fn amount(mut self, amount: u64) -> Self { + self.amount = Some(amount); + self + } + + pub fn build(self) -> Result { + let amount = self.amount.ok_or(PhoenixIxError::MissingField("amount"))?; + if amount == 0 { + return Err(PhoenixIxError::InvalidWithdrawAmount); + } + + let global_trader_index = self + .global_trader_index + .ok_or(PhoenixIxError::MissingField("global_trader_index"))?; + if global_trader_index.is_empty() { + return Err(PhoenixIxError::EmptyGlobalTraderIndex); + } + + let active_trader_buffer = self + .active_trader_buffer + .ok_or(PhoenixIxError::MissingField("active_trader_buffer"))?; + if active_trader_buffer.is_empty() { + return Err(PhoenixIxError::EmptyActiveTraderBuffer); + } + + Ok(WithdrawFundsParams { + trader: self.trader.ok_or(PhoenixIxError::MissingField("trader"))?, + trader_account: self + .trader_account + .ok_or(PhoenixIxError::MissingField("trader_account"))?, + perp_asset_map: self + .perp_asset_map + .ok_or(PhoenixIxError::MissingField("perp_asset_map"))?, + global_vault: self + .global_vault + .ok_or(PhoenixIxError::MissingField("global_vault"))?, + trader_token_account: self + .trader_token_account + .ok_or(PhoenixIxError::MissingField("trader_token_account"))?, + global_trader_index, + active_trader_buffer, + withdraw_queue: self + .withdraw_queue + .ok_or(PhoenixIxError::MissingField("withdraw_queue"))?, + amount, + }) + } +} + +/// Create a withdraw funds instruction. +/// +/// This instruction withdraws Phoenix tokens from the Phoenix protocol +/// to the trader's token account. +/// +/// # Arguments +/// +/// * `params` - The withdraw funds parameters +/// +/// # Returns +/// +/// A Solana instruction ready to be included in a transaction. +/// +/// # Errors +/// +/// Returns an error if required parameters are missing, amount is zero, +/// or trader index arrays are empty. +pub fn create_withdraw_funds_ix( + params: WithdrawFundsParams, +) -> Result { + let data = encode_withdraw_funds(¶ms); + let accounts = build_accounts(¶ms); + + Ok(Instruction { + program_id: PHOENIX_PROGRAM_ID, + accounts, + data, + }) +} + +fn encode_withdraw_funds(params: &WithdrawFundsParams) -> Vec { + let mut data = Vec::with_capacity(16); + + // Instruction discriminant (8 bytes) + data.extend_from_slice(&withdraw_funds_discriminant()); + + // Amount (8 bytes, little-endian u64) + data.extend_from_slice(¶ms.amount().to_le_bytes()); + + data +} + +fn build_accounts(params: &WithdrawFundsParams) -> Vec { + let mut accounts = Vec::new(); + + // 1. phoenix_program (readonly) - Log accounts + accounts.push(AccountMeta::readonly(PHOENIX_PROGRAM_ID)); + // 2. phoenix_log_authority (readonly) + accounts.push(AccountMeta::readonly(PHOENIX_LOG_AUTHORITY)); + // 3. global_configuration_account (writable) + accounts.push(AccountMeta::writable(PHOENIX_GLOBAL_CONFIGURATION)); + // 4. trader_wallet (signer, readonly) + accounts.push(AccountMeta::readonly_signer(params.trader())); + // 5. trader_account (writable) - Trader PDA + accounts.push(AccountMeta::writable(params.trader_account())); + // 6. perp_asset_map (writable) + accounts.push(AccountMeta::writable(params.perp_asset_map())); + // 7. global_vault (writable) + accounts.push(AccountMeta::writable(params.global_vault())); + // 8. destination_token_account (writable) - Owner's Phoenix token ATA + accounts.push(AccountMeta::writable(params.trader_token_account())); + // 9. token_program (readonly) + accounts.push(AccountMeta::readonly(SPL_TOKEN_PROGRAM_ID)); + + // 10-N. global_trader_index addresses (all writable) + for addr in params.global_trader_index() { + accounts.push(AccountMeta::writable(*addr)); + } + + // N+1-M. active_trader_buffer addresses (all writable) + for addr in params.active_trader_buffer() { + accounts.push(AccountMeta::writable(*addr)); + } + + // M+1. withdraw_queue (writable) + accounts.push(AccountMeta::writable(params.withdraw_queue())); + + accounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_withdraw_funds_ix() { + let params = WithdrawFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .withdraw_queue(Pubkey::new_unique()) + .amount(100_000_000) + .build() + .unwrap(); + + let ix = create_withdraw_funds_ix(params).unwrap(); + + assert_eq!(ix.program_id, PHOENIX_PROGRAM_ID); + // 9 base accounts + 1 global_trader_index + 1 active_trader_buffer + 1 + // withdraw_queue = 12 + assert_eq!(ix.accounts.len(), 12); + + // Verify data encoding + assert_eq!(&ix.data[..8], &withdraw_funds_discriminant()); + let amount_bytes: [u8; 8] = ix.data[8..16].try_into().unwrap(); + assert_eq!(u64::from_le_bytes(amount_bytes), 100_000_000); + } + + #[test] + fn test_withdraw_funds_zero_amount() { + let result = WithdrawFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .withdraw_queue(Pubkey::new_unique()) + .amount(0) + .build(); + + assert!(matches!(result, Err(PhoenixIxError::InvalidWithdrawAmount))); + } + + #[test] + fn test_withdraw_funds_empty_global_trader_index() { + let result = WithdrawFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![]) + .active_trader_buffer(vec![Pubkey::new_unique()]) + .withdraw_queue(Pubkey::new_unique()) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyGlobalTraderIndex) + )); + } + + #[test] + fn test_withdraw_funds_empty_active_trader_buffer() { + let result = WithdrawFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![Pubkey::new_unique()]) + .active_trader_buffer(vec![]) + .withdraw_queue(Pubkey::new_unique()) + .amount(100) + .build(); + + assert!(matches!( + result, + Err(PhoenixIxError::EmptyActiveTraderBuffer) + )); + } + + #[test] + fn test_withdraw_funds_account_order() { + let trader = Pubkey::new_unique(); + let trader_account = Pubkey::new_unique(); + let perp_asset_map = Pubkey::new_unique(); + let global_vault = Pubkey::new_unique(); + let trader_token_account = Pubkey::new_unique(); + let gti = Pubkey::new_unique(); + let atb = Pubkey::new_unique(); + let withdraw_queue = Pubkey::new_unique(); + + let params = WithdrawFundsParams::builder() + .trader(trader) + .trader_account(trader_account) + .perp_asset_map(perp_asset_map) + .global_vault(global_vault) + .trader_token_account(trader_token_account) + .global_trader_index(vec![gti]) + .active_trader_buffer(vec![atb]) + .withdraw_queue(withdraw_queue) + .amount(1) + .build() + .unwrap(); + + let ix = create_withdraw_funds_ix(params).unwrap(); + + // Verify account order + assert_eq!(ix.accounts[0].pubkey, PHOENIX_PROGRAM_ID); + assert!(!ix.accounts[0].is_writable); + + assert_eq!(ix.accounts[1].pubkey, PHOENIX_LOG_AUTHORITY); + assert!(!ix.accounts[1].is_writable); + + assert_eq!(ix.accounts[2].pubkey, PHOENIX_GLOBAL_CONFIGURATION); + assert!(ix.accounts[2].is_writable); + + assert_eq!(ix.accounts[3].pubkey, trader); + assert!(ix.accounts[3].is_signer); + assert!(!ix.accounts[3].is_writable); + + assert_eq!(ix.accounts[4].pubkey, trader_account); + assert!(ix.accounts[4].is_writable); + + assert_eq!(ix.accounts[5].pubkey, perp_asset_map); + assert!(ix.accounts[5].is_writable); + + assert_eq!(ix.accounts[6].pubkey, global_vault); + assert!(ix.accounts[6].is_writable); + + assert_eq!(ix.accounts[7].pubkey, trader_token_account); + assert!(ix.accounts[7].is_writable); + + assert_eq!(ix.accounts[8].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!ix.accounts[8].is_writable); + + assert_eq!(ix.accounts[9].pubkey, gti); + assert!(ix.accounts[9].is_writable); + + assert_eq!(ix.accounts[10].pubkey, atb); + assert!(ix.accounts[10].is_writable); + + assert_eq!(ix.accounts[11].pubkey, withdraw_queue); + assert!(ix.accounts[11].is_writable); + } + + #[test] + fn test_withdraw_funds_multiple_index_accounts() { + let gti1 = Pubkey::new_unique(); + let gti2 = Pubkey::new_unique(); + let atb1 = Pubkey::new_unique(); + let atb2 = Pubkey::new_unique(); + + let params = WithdrawFundsParams::builder() + .trader(Pubkey::new_unique()) + .trader_account(Pubkey::new_unique()) + .perp_asset_map(Pubkey::new_unique()) + .global_vault(Pubkey::new_unique()) + .trader_token_account(Pubkey::new_unique()) + .global_trader_index(vec![gti1, gti2]) + .active_trader_buffer(vec![atb1, atb2]) + .withdraw_queue(Pubkey::new_unique()) + .amount(1) + .build() + .unwrap(); + + let ix = create_withdraw_funds_ix(params).unwrap(); + + // 9 base accounts + 2 gti + 2 atb + 1 withdraw_queue = 14 + assert_eq!(ix.accounts.len(), 14); + + // Verify gti accounts + assert_eq!(ix.accounts[9].pubkey, gti1); + assert_eq!(ix.accounts[10].pubkey, gti2); + + // Verify atb accounts + assert_eq!(ix.accounts[11].pubkey, atb1); + assert_eq!(ix.accounts[12].pubkey, atb2); + + // Verify withdraw_queue is last + assert!(ix.accounts[13].is_writable); + } +} diff --git a/container/vendor/rise/rust/math/Cargo.toml b/container/vendor/rise/rust/math/Cargo.toml new file mode 100644 index 00000000000..4c1ca77851b --- /dev/null +++ b/container/vendor/rise/rust/math/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition = "2021" +name = "phoenix-math-utils" +publish = false +rust-version = "1.86.0" +version = "0.1.0" + +[features] +rust_decimal = ["dep:rust_decimal"] + +[dependencies] +borsh = { workspace = true } +bytemuck = { workspace = true } +fixed = { workspace = true } +pastey = { workspace = true } +rust_decimal = { workspace = true, optional = true } +serde = { workspace = true } +sha2-const-stable = { workspace = true } +solana-pubkey = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +proptest = "1.6" +rand = "0.9" + +[lints.clippy] +uninlined_format_args = "allow" diff --git a/container/vendor/rise/rust/math/src/direction.rs b/container/vendor/rise/rust/math/src/direction.rs new file mode 100644 index 00000000000..87d973fa7a5 --- /dev/null +++ b/container/vendor/rise/rust/math/src/direction.rs @@ -0,0 +1,63 @@ +//! Direction and stop loss order types +//! +//! This module provides the Direction enum for price comparison directions +//! and StopLossOrderKind for stop loss order types. + +use std::fmt::Display; + +use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; + +/// Direction for price comparisons (used in stop loss orders) +#[derive(Clone, Copy, BorshDeserialize, BorshSerialize, Debug, Eq, PartialEq, Hash)] +pub enum Direction { + /// Greater than comparison + GreaterThan, + /// Less than comparison + LessThan, +} + +impl Direction { + /// Get the opposite direction + pub fn opposite(&self) -> Direction { + match self { + Direction::GreaterThan => Direction::LessThan, + Direction::LessThan => Direction::GreaterThan, + } + } +} + +impl Display for Direction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Direction::GreaterThan => write!(f, "gt"), + Direction::LessThan => write!(f, "lt"), + } + } +} + +/// Stop loss order execution kind +#[derive(Clone, Copy, BorshDeserialize, BorshSerialize, Debug, Eq, PartialEq, Hash)] +pub enum StopLossOrderKind { + /// Immediate-or-cancel order + IOC, + /// Limit order + Limit, +} + +impl Display for StopLossOrderKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StopLossOrderKind::IOC => write!(f, "ioc"), + StopLossOrderKind::Limit => write!(f, "limit"), + } + } +} + +/// Order side (Bid or Ask). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Bid, + Ask, +} diff --git a/container/vendor/rise/rust/math/src/errors.rs b/container/vendor/rise/rust/math/src/errors.rs new file mode 100644 index 00000000000..82ceacce9a1 --- /dev/null +++ b/container/vendor/rise/rust/math/src/errors.rs @@ -0,0 +1,53 @@ +//! Application-level error types + +use crate::quantities::MathError; +use crate::risk::{MarginError, ProgramError}; + +#[derive(Debug, thiserror::Error)] +pub enum PhoenixStateError { + #[error("Market {symbol} not found. Available markets: [{markets:?}]")] + MarketNotFound { + symbol: String, + markets: Vec, + }, + + #[error("Trader {trader} not found.")] + TraderNotFound { trader: String }, + + #[error("Oracle {oracle} not found for market {market}.")] + OracleNotFound { oracle: String, market: String }, + + #[error("Failed to deserialize pubkey: {pubkey}")] + PubkeyDeserializeError { pubkey: String }, + + #[error("Orderbook sequence number for market {symbol} out of order: {expected} != {actual}")] + OrderbookSequenceNumberOutOfOrder { + symbol: String, + expected: u64, + actual: u64, + }, + + #[error("Duplicate extension registered")] + DuplicateExtensionType, + + #[error("Market ID {market_id} not found. Available market IDs: [{market_ids:?}]")] + MarketIdNotFound { + market_id: u32, + market_ids: Vec, + }, + + #[error("Stop loss for asset {symbol} not found. Available stop losses: [{symbols:?}]")] + StopLossNotFound { + symbol: String, + symbols: Vec, + }, + + #[error("Margin error: {0}")] + MarginError(#[from] MarginError), + + #[error("Math error: {0}")] + MathError(#[from] MathError), + + #[error("Mark price error: {0:?}")] + MarkPriceError(ProgramError), +} diff --git a/container/vendor/rise/rust/math/src/fixed.rs b/container/vendor/rise/rust/math/src/fixed.rs new file mode 100644 index 00000000000..5c359a35646 --- /dev/null +++ b/container/vendor/rise/rust/math/src/fixed.rs @@ -0,0 +1,188 @@ +use std::fmt::{Debug, Display, Formatter}; +use std::ops::{Add, AddAssign, Mul, Sub}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use bytemuck::{Pod, Zeroable}; + +type FixedI80F48 = fixed::types::I80F48; + +#[repr(C)] +#[derive(Default, Clone, Copy, Zeroable, Pod, BorshDeserialize, BorshSerialize)] +pub struct I80F48 { + inner: i128, +} + +impl I80F48 { + pub const ZERO: Self = Self { inner: 0 }; + + pub fn from_num(value: T) -> Self + where + FixedI80F48: From, + { + let value = FixedI80F48::from(value); + Self { + inner: value.to_bits(), + } + } + + pub fn from_f64(value: f64) -> Self { + let value = FixedI80F48::from_num(value); + Self { + inner: value.to_bits(), + } + } + + pub fn from_fraction(numerator: u64, denominator: u64) -> Self { + let value = FixedI80F48::from_num(numerator) / FixedI80F48::from_num(denominator); + Self { + inner: value.to_bits(), + } + } + + pub fn floor(&self) -> u64 { + let value = FixedI80F48::from_bits(self.inner); + value.floor().to_num() + } + + pub fn to_bits(&self) -> i128 { + self.inner + } + + pub fn from_bits(bits: i128) -> Self { + Self { inner: bits } + } +} + +impl PartialEq for I80F48 { + fn eq(&self, rhs: &Self) -> bool { + let lhs = FixedI80F48::from_bits(self.inner); + let rhs = FixedI80F48::from_bits(rhs.inner); + lhs == rhs + } +} + +impl PartialOrd for I80F48 { + fn partial_cmp(&self, rhs: &Self) -> Option { + let lhs = FixedI80F48::from_bits(self.inner); + let rhs = FixedI80F48::from_bits(rhs.inner); + lhs.partial_cmp(&rhs) + } +} + +impl AddAssign for I80F48 { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl Add for I80F48 { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let lhs = FixedI80F48::from_bits(self.inner); + let rhs = FixedI80F48::from_bits(rhs.inner); + let sum = lhs + rhs; + Self { + inner: sum.to_bits(), + } + } +} + +impl Sub for I80F48 { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let lhs = FixedI80F48::from_bits(self.inner); + let rhs = FixedI80F48::from_bits(rhs.inner); + let diff = lhs - rhs; + Self { + inner: diff.to_bits(), + } + } +} + +impl Mul for I80F48 { + type Output = Self; + + fn mul(self, rhs: Self) -> Self { + let lhs = FixedI80F48::from_bits(self.inner); + let rhs = FixedI80F48::from_bits(rhs.inner); + let product = lhs * rhs; + Self { + inner: product.to_bits(), + } + } +} + +impl Display for I80F48 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let value = FixedI80F48::from_bits(self.inner); + write!(f, "{value}") + } +} + +impl Debug for I80F48 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let value = FixedI80F48::from_bits(self.inner); + write!(f, "{value:?}") + } +} + +#[cfg(test)] +mod tests { + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + #[test] + fn seeded_fixed_fuzz_test() { + use fixed::types::I80F48 as FixedI80F48; + + use crate::fixed::I80F48; + + let mut r = StdRng::seed_from_u64(42); + + for _ in 0..1000 { + let a = r.random::() as i128; + let b = r.random::() as i128; + for &(a, b) in &[(a, b), (b, a)] { + let af_lib = FixedI80F48::from_bits(a); + let bf_lib = FixedI80F48::from_bits(b); + let af = I80F48::from_bits(a); + let bf = I80F48::from_bits(b); + + // Test addition matches lib + assert_eq!((af + bf).to_bits(), (af_lib + bf_lib).to_bits()); + // Test substraction matches lib + assert_eq!((af - bf).to_bits(), (af_lib - bf_lib).to_bits()); + // Test multiplication matches lib + assert_eq!((af * bf).to_bits(), (af_lib * bf_lib).to_bits()); + + let mut c_lib = FixedI80F48::ZERO; + let mut c = I80F48::ZERO; + + // Test add assign + c_lib += af_lib; + c += af; + assert_eq!(c.to_bits(), c_lib.to_bits()); + + // Test PartialEq + assert_eq!(c, af); + assert_eq!(c_lib, af_lib); + } + } + } + + #[test] + fn test_floor() { + use crate::fixed::I80F48; + let a = I80F48::from_fraction(1, 2); + assert_eq!(a.floor(), 0); + let b = I80F48::from_fraction(3, 2); + assert_eq!(b.floor(), 1); + let c = I80F48::from_fraction(5, 2); + assert_eq!(c.floor(), 2); + + assert!(a < b); + assert!(c > b); + } +} diff --git a/container/vendor/rise/rust/math/src/funding.rs b/container/vendor/rise/rust/math/src/funding.rs new file mode 100644 index 00000000000..6152dc11067 --- /dev/null +++ b/container/vendor/rise/rust/math/src/funding.rs @@ -0,0 +1,172 @@ +//! Funding rate helpers shared by off-chain consumers. +//! +//! The funding accumulator on-chain tracks `∑ (mark - index) * dt` in +//! `SignedQuoteLotsPerBaseLotUpcasted` units. To display a percentage, we +//! convert to quote/base *seconds* per funding period, clamp to the market +//! maximum, and express as a percentage of notional using the current mark +//! price. + +use crate::{ + FundingRateUnitInSeconds, SignedQuoteLotsPerBaseLot, SignedQuoteLotsPerBaseLotUpcasted, + WrapperNum, +}; + +/// Convenience calculator for funding percentages. +#[derive(Debug, Clone, Copy)] +pub struct FundingCalculator { + base_lot_decimals: i8, + quote_lot_decimals: u8, + funding_period_seconds: FundingRateUnitInSeconds, + funding_interval_seconds: FundingRateUnitInSeconds, + max_funding_rate_per_interval: SignedQuoteLotsPerBaseLot, +} + +impl FundingCalculator { + /// Create a calculator using the market's decimals and funding parameters. + pub fn new( + base_lot_decimals: i8, + funding_period_seconds: FundingRateUnitInSeconds, + funding_interval_seconds: FundingRateUnitInSeconds, + max_funding_rate_per_interval: SignedQuoteLotsPerBaseLot, + ) -> Self { + Self { + base_lot_decimals, + quote_lot_decimals: 6, + funding_period_seconds, + funding_interval_seconds, + max_funding_rate_per_interval, + } + } + + /// Current interval funding as a percentage of notional. + /// + /// `accumulated_funding` is the in-interval accumulator + /// (quote_lots_per_base_lot * seconds). We divide by the funding period to + /// project the interval contribution, clamp to the configured max, convert + /// to quote units per base unit, and scale by the current mark. + /// + /// Math (units shown): + /// - `rate_raw = acc / T_period` (quote_lots / + /// base_lot) + /// - `rate_clamped = clamp(rate_raw, ±max_per_interval)` (quote_lots / + /// base_lot) + /// - `funding_usd_per_base_unit = rate_clamped * (10^{base_dec} / 10^6)` + /// where 10^6 converts quote lots → quote units (USD), and 10^{base_dec} + /// converts base lots → base units. Units: quote_units / base_unit. + /// - `funding_pct = (funding_usd_per_base_unit / mark_price) * 100` Units: + /// percent of notional for this interval. + pub fn current_rate_percentage( + &self, + accumulated_funding: SignedQuoteLotsPerBaseLotUpcasted, + mark_price: f64, + ) -> f64 { + let period = self.funding_period_seconds.as_inner() as f64; + if period == 0.0 || !mark_price.is_finite() || mark_price <= 0.0 { + return 0.0; + } + + // Do the clamp in integer space to avoid precision loss, then convert. + let acc_i128 = accumulated_funding.as_inner(); + let period_i128 = self.funding_period_seconds.as_inner() as i128; + if period_i128 == 0 { + return 0.0; + } + // Project using high-precision floats to preserve sub-lot values, but clamp + // using the i128 max to match on-chain bounds exactly. + let projected_f = acc_i128 as f64 / period_i128 as f64; // quote lots per base lot + let max_rate = self.max_funding_rate_per_interval.as_inner() as f64; + let clamped = projected_f.clamp(-max_rate as f64, max_rate as f64); + + // Convert to quote units per base unit. + let quote_lots_per_quote_unit = 10f64.powi(self.quote_lot_decimals as i32); + if quote_lots_per_quote_unit == 0.0 { + return 0.0; + } + let base_lots_per_base_unit = 10f64.powi(self.base_lot_decimals as i32); + + // (SignedQuoteLots / BaseLot) * (BaseLots / BaseUnit) / (QuoteLots / QuoteUnit) + // = SignedQuoteUnits / BaseUnit + let funding_quote_units_per_base_unit = + (clamped / quote_lots_per_quote_unit) * base_lots_per_base_unit; + + // Percentage of notional. + // (SignedQuoteUnits / BaseUnit) / (QuoteUnits / BaseUnit) * 100 = Percent of + // Notional + (funding_quote_units_per_base_unit / mark_price) * 100.0 + } + + /// Annualize an interval funding percentage. + pub fn annualized_rate_percentage(&self, interval_rate_percentage: f64) -> f64 { + let interval = self.funding_interval_seconds.as_inner() as f64; + let period = self.funding_period_seconds.as_inner() as f64; + if interval == 0.0 || period == 0.0 { + return 0.0; + } + + // Interval rate -> annual rate. seconds_per_year / interval_seconds. + let seconds_per_year = 31_536_000.0; // 365 days + let intervals_per_year = seconds_per_year / interval; + + // interval_rate_percentage already reflects (interval/period), so we only need + // intervals_per_year here. + interval_rate_percentage * intervals_per_year + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hourly_rate_to_percentage_and_annualized() { + // Mark 94_206, spot 94_209 => diff = -3 quote units + // Accumulator for one hour: diff_quote_lots * interval_seconds + let diff_quote_lots_per_base_lot = -3_000_000i128; // -3 USD with 6 quote decimals + let interval_seconds = FundingRateUnitInSeconds::new_const(3_600); + let period_seconds = FundingRateUnitInSeconds::new_const(86_400); + + let acc = SignedQuoteLotsPerBaseLotUpcasted::new_const( + diff_quote_lots_per_base_lot * interval_seconds.as_inner() as i128, + ); + + let calc = FundingCalculator::new( + 0, + period_seconds, + interval_seconds, + SignedQuoteLotsPerBaseLot::new_const(i64::MAX), + ); + let rate = calc.current_rate_percentage(acc, 94_206.0); + + // Expected: (-3 / 94_206) * (1/24) * 100 ≈ -0.0001327% + assert!( + (rate + 0.0001327).abs() < 1e-6, + "unexpected interval rate: {}", + rate + ); + + let annual = calc.annualized_rate_percentage(rate); + // Expected annual ≈ -1.162% (hourly * 8760) + assert!( + (annual + 1.1623).abs() < 0.01, + "unexpected annualized rate: {}", + annual + ); + } + + #[test] + fn clamps_to_max() { + let period = FundingRateUnitInSeconds::new_const(86_400); + let interval = FundingRateUnitInSeconds::new_const(3_600); + let max = SignedQuoteLotsPerBaseLot::new_const(500); // tiny max + + let calc = FundingCalculator::new(0, period, interval, max); + + let acc = SignedQuoteLotsPerBaseLotUpcasted::new_const(10_000_000); // large acc + let rate = calc.current_rate_percentage(acc, 10_000.0); + let annual = calc.annualized_rate_percentage(rate); + + // Should be finite and non-zero but limited by max + assert!(rate.is_finite()); + assert!(annual.is_finite()); + } +} diff --git a/container/vendor/rise/rust/math/src/leverage_tiers.rs b/container/vendor/rise/rust/math/src/leverage_tiers.rs new file mode 100644 index 00000000000..ec6847244a6 --- /dev/null +++ b/container/vendor/rise/rust/math/src/leverage_tiers.rs @@ -0,0 +1,323 @@ +//! Leverage tiers for position-size-dependent margin requirements +//! +//! Leverage tiers allow limiting leverage as position size increases, +//! implementing progressive margin requirements for larger positions. + +use crate::quantities::{BaseLots, BasisPoints, Constant, ScalarBounds, WrapperNum}; + +/// A single leverage tier defining maximum leverage for a position size range +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct LeverageTier { + /// For all position amounts less than or equal to this bound, the max + /// leverage is the indicated value + pub upper_bound_size: BaseLots, + /// The max leverage allowed for this quantity tier + pub max_leverage: Constant, + /// The risk factor for limit orders (basis points) + pub limit_order_risk_factor: BasisPoints, +} + +/// Collection of 4 leverage tiers with interpolation support +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct LeverageTiers { + tiers: [LeverageTier; 4], +} + +impl LeverageTiers { + /// Create new leverage tiers with validation + pub fn new(tiers: [LeverageTier; 4]) -> Result { + Self::validate(&tiers)?; + Ok(Self { tiers }) + } + + /// Create leverage tiers without validation (use with caution) + pub const fn new_unchecked(tiers: [LeverageTier; 4]) -> Self { + Self { tiers } + } + + /// Validate leverage tier configuration + pub fn validate(tiers: &[LeverageTier; 4]) -> Result<(), &'static str> { + for i in 1..tiers.len() { + let prev_tier = &tiers[i - 1]; + let curr_tier = &tiers[i]; + + if curr_tier.upper_bound_size == BaseLots::ZERO + || prev_tier.upper_bound_size == BaseLots::ZERO + { + return Err("Leverage tier upper_bound_size cannot be zero"); + } + + // Check that upper_bound_size is increasing + if curr_tier.upper_bound_size <= prev_tier.upper_bound_size { + return Err("Leverage tiers must have increasing upper_bound_size"); + } + + // Check that max_leverage is non-increasing + if curr_tier.max_leverage > prev_tier.max_leverage { + return Err("Leverage tiers must have non-increasing max_leverage"); + } + + // Check that limit_order_risk_factor is non-decreasing + if curr_tier.limit_order_risk_factor < prev_tier.limit_order_risk_factor { + return Err("Leverage tiers must have non-decreasing limit_order_risk_factor"); + } + } + + Ok(()) + } + + /// Get interpolated leverage constant for a given position size + /// + /// Linearly interpolates between tier boundaries to provide smooth leverage + /// scaling + pub fn get_leverage_constant(&self, position_size: BaseLots) -> Constant { + for (i, tier) in self.tiers.iter().enumerate() { + if position_size <= tier.upper_bound_size { + if i == 0 { + // First tier: no interpolation needed + return tier.max_leverage; + } + + // Interpolate between previous tier and current tier + let prev_tier = &self.tiers[i - 1]; + return interpolate_leverage( + prev_tier.upper_bound_size, + prev_tier.max_leverage, + tier.upper_bound_size, + tier.max_leverage, + position_size, + ); + } + } + // Position exceeds all tiers - use minimum leverage (1x) + Constant::new(1) + } + + /// Get interpolated limit order risk factor for a given position size + /// + /// Linearly interpolates between tier boundaries + pub fn get_limit_order_risk_factor(&self, position_size: BaseLots) -> BasisPoints { + for (i, tier) in self.tiers.iter().enumerate() { + if position_size <= tier.upper_bound_size { + if i == 0 { + // First tier: no interpolation needed + return tier.limit_order_risk_factor; + } + + // Interpolate between previous tier and current tier + let prev_tier = &self.tiers[i - 1]; + return interpolate_limit_order_risk_factor( + prev_tier.upper_bound_size, + prev_tier.limit_order_risk_factor, + tier.upper_bound_size, + tier.limit_order_risk_factor, + position_size, + ); + } + } + // Position exceeds all tiers - use maximum risk factor (100%) + BasisPoints::UPPER_BOUND.into() + } + + /// Get a reference to a specific tier + pub fn get(&self, index: usize) -> Option<&LeverageTier> { + self.tiers.get(index) + } + + /// Iterator over all tiers + pub fn iter(&self) -> impl Iterator { + self.tiers.iter() + } + + /// Number of tiers (always 4) + pub const fn len(&self) -> usize { + 4 + } + + /// Always false (always has 4 tiers) + pub const fn is_empty(&self) -> bool { + false + } +} + +impl Default for LeverageTiers { + fn default() -> Self { + // Default: 20x leverage across all sizes, no limit order discount + Self::new_unchecked([ + LeverageTier { + upper_bound_size: BaseLots::new(1_000_000), + max_leverage: Constant::new(20), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(10_000_000), + max_leverage: Constant::new(10), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(100_000_000), + max_leverage: Constant::new(5), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(u32::MAX as u64), + max_leverage: Constant::new(1), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + ]) + } +} + +// ============================================================================ +// Internal Interpolation Helpers +// ============================================================================ + +/// Core interpolation logic working with raw u64 values +/// +/// Calculates the "percentage" of the way between x1 and x2 for a given x value +/// and returns the corresponding y value. Does not assume x2 > x1 or y2 > y1. +fn interpolate_u64(x1: u64, y1: u64, x2: u64, y2: u64, x: u64) -> u64 { + // Handle degenerate cases + if x1 == x2 || y1 == y2 { + return y1; + } + + // Linear interpolation: y = y1 + (y2 - y1) * (x - x1) / (x2 - x1) + let x_range = x2 as f64 - x1 as f64; + let y_range = y2 as f64 - y1 as f64; + let x_offset = x as f64 - x1 as f64; + + // Clamp percentage to [0, 1] + let percent_of_x_range = (x_offset / x_range).clamp(0.0, 1.0); + + let interpolated_value = (y1 as f64) + percent_of_x_range * y_range; + interpolated_value as u64 +} + +fn interpolate_leverage( + x1: BaseLots, + y1: Constant, + x2: BaseLots, + y2: Constant, + x: BaseLots, +) -> Constant { + let result = interpolate_u64( + x1.as_inner(), + y1.as_inner(), + x2.as_inner(), + y2.as_inner(), + x.as_inner(), + ); + Constant::new(result) +} + +fn interpolate_limit_order_risk_factor( + x1: BaseLots, + y1: BasisPoints, + x2: BaseLots, + y2: BasisPoints, + x: BaseLots, +) -> BasisPoints { + let result = interpolate_u64( + x1.as_inner(), + y1.as_inner(), + x2.as_inner(), + y2.as_inner(), + x.as_inner(), + ); + BasisPoints::new(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interpolation_at_boundaries() { + let tiers = LeverageTiers::default(); + + // At first boundary + let leverage = tiers.get_leverage_constant(BaseLots::new(1_000_000)); + assert_eq!(leverage, Constant::new(20)); + + // At second boundary + let leverage = tiers.get_leverage_constant(BaseLots::new(10_000_000)); + assert_eq!(leverage, Constant::new(10)); + } + + #[test] + fn test_interpolation_between_tiers() { + let tiers = LeverageTiers::default(); + + // Midpoint between first and second tier should be ~15x + let mid_point = (1_000_000 + 10_000_000) / 2; + let leverage = tiers.get_leverage_constant(BaseLots::new(mid_point)); + // Should be between 10 and 20 + assert!(leverage.as_inner() >= 10 && leverage.as_inner() <= 20); + } + + #[test] + fn test_exceeds_all_tiers() { + let tiers = LeverageTiers::default(); + + // Beyond all tiers should return 1x + let leverage = tiers.get_leverage_constant(BaseLots::new(u32::MAX as u64 + 1)); + assert_eq!(leverage, Constant::new(1)); + } + + #[test] + fn test_validation_increasing_sizes() { + let invalid_tiers = [ + LeverageTier { + upper_bound_size: BaseLots::new(10_000), + max_leverage: Constant::new(20), + limit_order_risk_factor: BasisPoints::new(5_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(5_000), // Decreasing! + max_leverage: Constant::new(10), + limit_order_risk_factor: BasisPoints::new(5_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(20_000), + max_leverage: Constant::new(5), + limit_order_risk_factor: BasisPoints::new(7_500), + }, + LeverageTier { + upper_bound_size: BaseLots::new(100_000), + max_leverage: Constant::new(1), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + ]; + + assert!(LeverageTiers::new(invalid_tiers).is_err()); + } + + #[test] + fn test_validation_non_increasing_leverage() { + let invalid_tiers = [ + LeverageTier { + upper_bound_size: BaseLots::new(1_000), + max_leverage: Constant::new(10), + limit_order_risk_factor: BasisPoints::new(5_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(10_000), + max_leverage: Constant::new(20), // Increasing! + limit_order_risk_factor: BasisPoints::new(5_000), + }, + LeverageTier { + upper_bound_size: BaseLots::new(20_000), + max_leverage: Constant::new(5), + limit_order_risk_factor: BasisPoints::new(7_500), + }, + LeverageTier { + upper_bound_size: BaseLots::new(100_000), + max_leverage: Constant::new(1), + limit_order_risk_factor: BasisPoints::new(10_000), + }, + ]; + + assert!(LeverageTiers::new(invalid_tiers).is_err()); + } +} diff --git a/container/vendor/rise/rust/math/src/lib.rs b/container/vendor/rise/rust/math/src/lib.rs new file mode 100644 index 00000000000..e59393edf66 --- /dev/null +++ b/container/vendor/rise/rust/math/src/lib.rs @@ -0,0 +1,39 @@ +pub mod direction; +pub mod errors; +pub mod fixed; +pub mod funding; +pub mod leverage_tiers; +pub mod limit_order_state; +pub mod margin; +pub mod margin_calc; +pub mod market_math; +pub mod perp_metadata; +pub mod portfolio; +pub mod price; +pub mod quantities; +pub mod risk; +pub mod trader_position; + +pub use direction::*; +pub use errors::*; +pub use fixed::*; +pub use funding::*; +pub use leverage_tiers::*; +pub use limit_order_state::*; +pub use margin::*; +pub use margin_calc::*; +pub use market_math::*; +pub use perp_metadata::*; +pub use portfolio::*; +pub use price::*; +pub use quantities::*; +pub use risk::*; +use sha2_const_stable::Sha256; +pub use trader_position::*; + +pub const fn sha2_const(input: &[u8]) -> u64 { + let hash = Sha256::new().update(input).finalize(); + u64::from_le_bytes([ + hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], + ]) +} diff --git a/container/vendor/rise/rust/math/src/limit_order_state.rs b/container/vendor/rise/rust/math/src/limit_order_state.rs new file mode 100644 index 00000000000..fbf68ac0ad0 --- /dev/null +++ b/container/vendor/rise/rust/math/src/limit_order_state.rs @@ -0,0 +1,46 @@ +//! Limit order margin state for margin calculations +//! +//! This module provides the LimitOrderMarginState struct which aggregates +//! limit order information needed for margin calculations. + +use crate::quantities::BaseLots; + +/// Aggregated state of limit orders for margin calculations +/// +/// Tracks the number of ask/bid orders and total non-reduce-only +/// base lots on each side. +#[repr(C)] +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct LimitOrderMarginState { + pub num_ask_orders: u32, + pub num_bid_orders: u32, + pub total_non_reduce_only_ask_base_lots: BaseLots, + pub total_non_reduce_only_bid_base_lots: BaseLots, +} + +impl LimitOrderMarginState { + /// Create a new limit order margin state + pub fn new( + num_ask_orders: u32, + num_bid_orders: u32, + total_non_reduce_only_ask_base_lots: BaseLots, + total_non_reduce_only_bid_base_lots: BaseLots, + ) -> Self { + Self { + num_ask_orders, + num_bid_orders, + total_non_reduce_only_ask_base_lots, + total_non_reduce_only_bid_base_lots, + } + } + + /// Create an empty limit order margin state + pub const fn empty() -> Self { + Self { + num_ask_orders: 0, + num_bid_orders: 0, + total_non_reduce_only_ask_base_lots: BaseLots::ZERO, + total_non_reduce_only_bid_base_lots: BaseLots::ZERO, + } + } +} diff --git a/container/vendor/rise/rust/math/src/margin.rs b/container/vendor/rise/rust/math/src/margin.rs new file mode 100644 index 00000000000..06b6a76155b --- /dev/null +++ b/container/vendor/rise/rust/math/src/margin.rs @@ -0,0 +1,587 @@ +//! Core margin types and per-market margin computation +//! +//! This module provides types for representing margin requirements and +//! computing per-market margin from positions and limit orders. + +use std::iter::Sum; +use std::ops::Add; + +use crate::direction::Side; +use crate::errors::PhoenixStateError; +use crate::limit_order_state::LimitOrderMarginState; +use crate::margin_calc::{ + initial_margin_for_asset, initial_margin_for_asset_for_withdrawals, margin_increase_for_asks, + margin_increase_for_bids, position_backstop_margin, position_cancel_margin, + position_high_risk_margin, position_maintenance_margin, +}; +use crate::market_math::MarketCalculator; +use crate::perp_metadata::PerpAssetMetadata; +use crate::portfolio::PerpMetadataProvider; +use crate::quantities::{ + BaseLots, BasisPoints, MathError, QuoteLots, QuoteLotsPerBaseLotPerTick, ScalarBounds, + SignedBaseLots, SignedQuoteLots, Ticks, UPnlRiskFactor, WrapperNum, +}; +use crate::risk::{MarginError, RiskAction, RiskTier}; +use crate::trader_position::TraderPosition; + +pub(crate) fn unrealized_pnl_for_position( + base_lot_position: SignedBaseLots, + virtual_quote_lot_position: SignedQuoteLots, + settlement_price: Ticks, + tick_size_in_quote_lots_per_base_lot: QuoteLotsPerBaseLotPerTick, +) -> SignedQuoteLots { + let calculator = MarketCalculator::new(0, tick_size_in_quote_lots_per_base_lot); + virtual_quote_lot_position + + calculator.position_value_for_position(base_lot_position, settlement_price) +} + +pub(crate) fn discounted_unrealized_pnl_for_position_for_withdrawals( + base_lot_position: SignedBaseLots, + virtual_quote_lot_position: SignedQuoteLots, + settlement_price: Ticks, + tick_size_in_quote_lots_per_base_lot: QuoteLotsPerBaseLotPerTick, + perp_asset_metadata: &PerpAssetMetadata, +) -> Result { + let raw_pnl = unrealized_pnl_for_position( + base_lot_position, + virtual_quote_lot_position, + settlement_price, + tick_size_in_quote_lots_per_base_lot, + ); + + // Apply withdrawal risk factor penalty only to positive uPnL + if raw_pnl > SignedQuoteLots::ZERO { + let raw_pnl_unsigned = raw_pnl.checked_as_unsigned()?; + let discounted = perp_asset_metadata + .upnl_risk_factor(RiskAction::Withdrawal { + current_slot: crate::quantities::Slot::ZERO, + }) + .apply_to_quote_lots_ceil(raw_pnl_unsigned) + .ok_or(MathError::Overflow)?; + discounted.checked_as_signed() + } else { + Ok(raw_pnl) // No penalty for negative uPnL + } +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] +pub struct Margin { + /// Total initial margin requirement including both positions and limit + /// orders. + pub initial_margin: QuoteLots, + + /// Maintenance margin (liquidation margin) threshold. + pub maintenance_margin: QuoteLots, + + /// Initial margin requirement used specifically for withdrawal validation. + pub initial_margin_for_withdrawals: QuoteLots, + + /// Margin requirement specifically from outstanding limit orders. + pub limit_order_margin: QuoteLots, + + /// Backstop liquidation margin threshold. + pub backstop_requirement: QuoteLots, + + /// High-risk margin threshold. + pub high_risk_margin: QuoteLots, + + /// At-risk margin threshold - same as initial margin (100%). + pub at_risk_margin: QuoteLots, + + /// Cancellation margin threshold. + pub cancel_margin: QuoteLots, + + /// Raw unrealized profit or loss based on current mark price. + pub unrealized_pnl: SignedQuoteLots, + + /// Unrealized PnL with risk-based discounting applied. + pub discounted_unrealized_pnl: SignedQuoteLots, + + /// Unrealized PnL with stricter discounting for withdrawal validation. + pub discounted_pnl_for_withdrawals: SignedQuoteLots, + + /// Funding payments that have not yet been settled to the trader's account. + pub unsettled_funding: SignedQuoteLots, + + /// Funding payments that have been accrued but not yet settled. + pub accumulated_funding: SignedQuoteLots, + + /// Position value at current mark price + pub position_value: SignedQuoteLots, +} + +impl Margin { + /// Initial margin requirement for positions only, excluding limit orders. + pub fn position_only_initial_margin(&self) -> QuoteLots { + self.initial_margin - self.limit_order_margin + } + + pub fn position_only_maintenance_margin( + &self, + limit_order_risk_factor: BasisPoints, + ) -> QuoteLots { + let discounted_limit_order_margin = limit_order_risk_factor + .apply_to_quote_lots(self.limit_order_margin) + .expect("limit order risk factor application should not overflow"); + self.maintenance_margin + .checked_sub(discounted_limit_order_margin) + .expect("maintenance margin should be >= discounted limit order margin") + } + + pub fn risk_tier( + &self, + effective_collateral: SignedQuoteLots, + ) -> Result { + if effective_collateral < SignedQuoteLots::ZERO { + return Ok(RiskTier::HighRisk); + } + let effective_collateral = effective_collateral + .checked_as_unsigned() + .map_err(|_| MarginError::Overflow)?; + + Ok(if effective_collateral < self.high_risk_margin { + RiskTier::HighRisk + } else if effective_collateral < self.backstop_requirement { + RiskTier::BackstopLiquidatable + } else if effective_collateral < self.maintenance_margin { + RiskTier::Liquidatable + } else if effective_collateral < self.cancel_margin { + RiskTier::Cancellable + } else if effective_collateral < self.at_risk_margin { + RiskTier::AtRisk + } else { + RiskTier::Safe + }) + } +} + +impl Add for Margin { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + maintenance_margin: self.maintenance_margin + other.maintenance_margin, + initial_margin: self.initial_margin + other.initial_margin, + initial_margin_for_withdrawals: self.initial_margin_for_withdrawals + + other.initial_margin_for_withdrawals, + + limit_order_margin: self.limit_order_margin + other.limit_order_margin, + backstop_requirement: self.backstop_requirement + other.backstop_requirement, + high_risk_margin: self.high_risk_margin + other.high_risk_margin, + at_risk_margin: self.at_risk_margin + other.at_risk_margin, + cancel_margin: self.cancel_margin + other.cancel_margin, + + unrealized_pnl: self.unrealized_pnl + other.unrealized_pnl, + discounted_unrealized_pnl: self.discounted_unrealized_pnl + + other.discounted_unrealized_pnl, + discounted_pnl_for_withdrawals: self.discounted_pnl_for_withdrawals + + other.discounted_pnl_for_withdrawals, + unsettled_funding: self.unsettled_funding + other.unsettled_funding, + accumulated_funding: self.accumulated_funding + other.accumulated_funding, + position_value: self.position_value + other.position_value, + } + } +} + +impl Sum for Margin { + fn sum>(iter: I) -> Self { + iter.fold(Self::default(), |acc, item| acc + item) + } +} + +/// Individual limit order details. +/// Represents a single resting order in the orderbook. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LimitOrder { + /// Order price in ticks + pub price: Ticks, + /// Order side (Bid or Ask) + pub side: Side, + /// Unique order sequence number + pub order_sequence_number: u64, + /// Order size in base lots + pub base_lot_size: BaseLots, + /// Initial trade size when order was placed + pub initial_trade_size: BaseLots, + /// Whether the order was placed with the reduce-only flag + pub reduce_only: bool, + /// Whether the order was placed from a stop loss + pub is_stop_loss: bool, +} + +impl LimitOrder { + /// Aggregate a list of orders into a LimitOrderMarginState + pub fn aggregate_margin_state(orders: &[LimitOrder]) -> LimitOrderMarginState { + let mut total_non_reduce_only_ask_base_lots = BaseLots::ZERO; + let mut total_non_reduce_only_bid_base_lots = BaseLots::ZERO; + + for order in orders { + match order.side { + Side::Ask => { + if !order.reduce_only { + total_non_reduce_only_ask_base_lots += order.base_lot_size; + } + } + Side::Bid => { + if !order.reduce_only { + total_non_reduce_only_bid_base_lots += order.base_lot_size; + } + } + } + } + + LimitOrderMarginState::new( + orders.len() as u32, + orders.len() as u32, + total_non_reduce_only_ask_base_lots, + total_non_reduce_only_bid_base_lots, + ) + } +} + +/// Order with computed margin requirement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OrderMargin { + /// Order price in ticks + pub price: Ticks, + /// Order side (Bid or Ask) + pub side: Side, + /// Unique order sequence number + pub order_sequence_number: u64, + /// Initial order size when placed + pub initial_trade_size: BaseLots, + /// Remaining unfilled size + pub trade_size_remaining: BaseLots, + /// Margin required for this specific order. + pub margin_requirement: QuoteLots, + /// The margin factor applied to this order's notional value. + pub margin_factor: BasisPoints, + /// Whether the originating order was reduce-only + pub reduce_only: bool, + /// Whether the originating order was placed from a stop-loss trigger + pub is_stop_loss: bool, +} + +/// Raw position and limit order data for a single market. +/// Contains no computed margin or PnL. +pub struct MarketPosition { + pub position: Option, + /// Individual limit orders for this market + pub limit_orders: Vec, +} + +impl MarketPosition { + /// Get aggregated limit order margin state (calculated from individual + /// orders) + pub fn limit_order_margin(&self) -> Option { + if self.limit_orders.is_empty() { + return None; + } + Some(LimitOrder::aggregate_margin_state(&self.limit_orders)) + } + + /// Compute margin requirements for each individual limit order. + pub(crate) fn compute_limit_orders_margin( + &self, + perp_asset_metadata: &PerpAssetMetadata, + ) -> Result, PhoenixStateError> { + let mark_price = perp_asset_metadata + .try_get_mark_price(RiskAction::View) + .map_err(PhoenixStateError::MarkPriceError)?; + let asset_unit_price = mark_price * perp_asset_metadata.tick_size(); + + let trader_position = self + .position + .map(|p| p.base_lot_position) + .unwrap_or(SignedBaseLots::ZERO); + + let mut bids: Vec<&LimitOrder> = self + .limit_orders + .iter() + .filter(|o| o.side == Side::Bid) + .collect(); + let mut asks: Vec<&LimitOrder> = self + .limit_orders + .iter() + .filter(|o| o.side == Side::Ask) + .collect(); + + // Sort bids by price (descending) then sequence number (ascending) + bids.sort_by(|a, b| { + b.price + .cmp(&a.price) + .then_with(|| a.order_sequence_number.cmp(&b.order_sequence_number)) + }); + + // Sort asks by price (ascending) then sequence number (ascending) + asks.sort_by(|a, b| { + a.price + .cmp(&b.price) + .then_with(|| a.order_sequence_number.cmp(&b.order_sequence_number)) + }); + + let mut result = Vec::new(); + + // Process bids + for order in bids { + let remaining_base_lots = if order.reduce_only { + BaseLots::ZERO + } else { + order.base_lot_size + }; + let order_size = remaining_base_lots.as_signed(); + + let margin_req = margin_increase_for_bids( + trader_position, + order_size, + asset_unit_price, + perp_asset_metadata, + )?; + + let limit_order_risk_factor = if margin_req == QuoteLots::ZERO { + BasisPoints::ZERO + } else { + let total_exposure_signed = trader_position + .checked_add(order_size) + .ok_or(MathError::Overflow)?; + let total_exposure = total_exposure_signed.abs_as_unsigned(); + perp_asset_metadata + .leverage_tiers() + .get_limit_order_risk_factor(total_exposure) + }; + + result.push(OrderMargin { + price: order.price, + side: order.side, + order_sequence_number: order.order_sequence_number, + initial_trade_size: order.initial_trade_size, + trade_size_remaining: order.base_lot_size, + margin_requirement: margin_req, + margin_factor: limit_order_risk_factor, + reduce_only: order.reduce_only, + is_stop_loss: order.is_stop_loss, + }); + } + + // Process asks + for order in asks { + let remaining_base_lots = if order.reduce_only { + BaseLots::ZERO + } else { + order.base_lot_size + }; + let order_size = remaining_base_lots.as_signed(); + + let margin_req = margin_increase_for_asks( + trader_position, + order_size, + asset_unit_price, + perp_asset_metadata, + )?; + + let limit_order_risk_factor = if margin_req == QuoteLots::ZERO { + BasisPoints::ZERO + } else { + let total_exposure_signed = trader_position + .checked_sub(order_size) + .ok_or(MathError::Overflow)?; + let total_exposure = total_exposure_signed.abs_as_unsigned(); + perp_asset_metadata + .leverage_tiers() + .get_limit_order_risk_factor(total_exposure) + }; + + result.push(OrderMargin { + price: order.price, + side: order.side, + order_sequence_number: order.order_sequence_number, + initial_trade_size: order.initial_trade_size, + trade_size_remaining: order.base_lot_size, + margin_requirement: margin_req, + margin_factor: limit_order_risk_factor, + reduce_only: order.reduce_only, + is_stop_loss: order.is_stop_loss, + }); + } + + Ok(result) + } + + /// Compute margin and PnL margin for this market position using the + /// provided metadata. + pub fn compute_margin( + &self, + symbol: &str, + provider: &impl PerpMetadataProvider, + ) -> Result { + let perp_asset_metadata = provider.get_perp_metadata(symbol).ok_or_else(|| { + PhoenixStateError::MarketNotFound { + symbol: symbol.to_string(), + markets: vec![], + } + })?; + + let position = self.position.unwrap_or_default(); + let limit_order_state = self.limit_order_margin().unwrap_or_default(); + compute_market_margin(position, limit_order_state, perp_asset_metadata) + } +} + +/// Market position with computed margin and PnL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketMargin { + /// The trader's position in this market. + pub position: Option, + /// Individual limit orders with their margin requirements + pub limit_orders: Vec, + /// The trader's margin requirements for this market. + pub margin: Margin, +} + +impl MarketMargin { + pub fn limit_order_margin(&self) -> LimitOrderMarginState { + let total_ask = self + .limit_orders + .iter() + .filter(|o| o.side == Side::Ask && !o.reduce_only) + .map(|o| o.trade_size_remaining) + .sum(); + let total_bid = self + .limit_orders + .iter() + .filter(|o| o.side == Side::Bid && !o.reduce_only) + .map(|o| o.trade_size_remaining) + .sum(); + LimitOrderMarginState::new( + self.limit_orders.len() as u32, + self.limit_orders.len() as u32, + total_ask, + total_bid, + ) + } + + pub fn recompute_margin( + &mut self, + perp_asset_metadata: &PerpAssetMetadata, + ) -> Result<(), PhoenixStateError> { + let position = self.position.unwrap_or_default(); + let limit_order_margin = self.limit_order_margin(); + self.margin = compute_market_margin(position, limit_order_margin, perp_asset_metadata)?; + + Ok(()) + } +} + +pub(crate) fn compute_market_margin( + position: TraderPosition, + limit_order_margin: LimitOrderMarginState, + perp_asset_metadata: &PerpAssetMetadata, +) -> Result { + let mark_price = perp_asset_metadata + .try_get_mark_price(RiskAction::View) + .map_err(PhoenixStateError::MarkPriceError)?; + + let unrealized_pnl = unrealized_pnl_for_position( + position.base_lot_position, + position.virtual_quote_lot_position, + mark_price, + perp_asset_metadata.tick_size(), + ); + + let discounted_unrealized_pnl = if unrealized_pnl > SignedQuoteLots::ZERO { + let upnl_risk_factor = perp_asset_metadata + .upnl_risk_factor(RiskAction::View) + .as_inner() as u128; + let numerator = (unrealized_pnl.as_inner() as u128).saturating_mul(upnl_risk_factor); + let denom = UPnlRiskFactor::UPPER_BOUND as u128; + let discounted_u128 = numerator + .saturating_add(denom.saturating_sub(1)) + .saturating_div(denom); + let discounted_u64 = discounted_u128.min(u64::MAX as u128) as u64; + QuoteLots::new(discounted_u64) + .checked_as_signed() + .map_err(PhoenixStateError::MathError)? + } else { + unrealized_pnl + }; + + let discounted_pnl_for_withdrawals = discounted_unrealized_pnl_for_position_for_withdrawals( + position.base_lot_position, + position.virtual_quote_lot_position, + mark_price, + perp_asset_metadata.tick_size(), + perp_asset_metadata, + )?; + + let total_initial_margin = initial_margin_for_asset( + perp_asset_metadata, + &position, + &limit_order_margin, + RiskAction::View, + ) + .map_err(PhoenixStateError::MarginError)?; + + let position_only_initial_margin = initial_margin_for_asset( + perp_asset_metadata, + &position, + &LimitOrderMarginState::default(), + RiskAction::View, + ) + .map_err(PhoenixStateError::MarginError)?; + + let initial_margin_for_withdrawals = initial_margin_for_asset_for_withdrawals( + perp_asset_metadata, + &position, + &limit_order_margin, + RiskAction::View, + ) + .map_err(PhoenixStateError::MarginError)?; + + let limit_order_margin_amount = + total_initial_margin.saturating_sub(position_only_initial_margin); + + let maintenance_margin_amount = + position_maintenance_margin(perp_asset_metadata, total_initial_margin)?; + + let backstop_requirement_amount = + position_backstop_margin(perp_asset_metadata, total_initial_margin)?; + + let high_risk_margin_amount = + position_high_risk_margin(perp_asset_metadata, total_initial_margin)?; + + let cancel_margin_requirement = + position_cancel_margin(perp_asset_metadata, total_initial_margin)?; + + let unsettled_funding = (perp_asset_metadata.cumulative_funding_rate() + - position.cumulative_funding_snapshot) + * position.base_lot_position; + + let accumulated_funding: SignedQuoteLots = + position.accumulated_funding_for_active_position.into(); + + let calculator = MarketCalculator::new( + perp_asset_metadata.base_lot_decimals(), + perp_asset_metadata.tick_size(), + ); + let position_value = + calculator.position_value_for_position(position.base_lot_position, mark_price); + + Ok(Margin { + maintenance_margin: maintenance_margin_amount, + initial_margin: total_initial_margin, + initial_margin_for_withdrawals, + + limit_order_margin: limit_order_margin_amount, + backstop_requirement: backstop_requirement_amount, + high_risk_margin: high_risk_margin_amount, + at_risk_margin: total_initial_margin, + cancel_margin: cancel_margin_requirement, + + unrealized_pnl, + discounted_unrealized_pnl, + discounted_pnl_for_withdrawals, + unsettled_funding, + accumulated_funding, + position_value, + }) +} diff --git a/container/vendor/rise/rust/math/src/margin_calc.rs b/container/vendor/rise/rust/math/src/margin_calc.rs new file mode 100644 index 00000000000..ef6a2d77519 --- /dev/null +++ b/container/vendor/rise/rust/math/src/margin_calc.rs @@ -0,0 +1,446 @@ +//! Core margin calculation functions +//! +//! This module contains the formulas for computing margin requirements, +//! including initial margin, maintenance margin, and risk-tier-specific +//! margins for perpetual futures positions. + +use crate::limit_order_state::LimitOrderMarginState; +use crate::perp_metadata::PerpAssetMetadata; +use crate::quantities::{ + BaseLots, Constant, MathError, QuoteLots, QuoteLotsPerBaseLot, SignedBaseLots, +}; +use crate::risk::{MarginError, RiskAction, RiskTier}; +use crate::trader_position::TraderPosition; + +// ============================================================================ +// Position Margin Functions +// ============================================================================ + +/// Calculate cancel margin threshold for a position +/// +/// This is the margin level at which risk-increasing orders can be +/// force-cancelled. Typically higher than maintenance margin (e.g., 55% vs +/// 50%). +pub fn position_cancel_margin( + perp_asset_metadata: &PerpAssetMetadata, + position_initial_margin: QuoteLots, +) -> Result { + perp_asset_metadata + .cancel_order_risk_factor() + .apply_to_quote_lots(position_initial_margin) + .ok_or(MarginError::Overflow) +} + +/// Calculate maintenance margin (liquidation threshold) for a position +/// +/// When effective collateral falls below this level, the position +/// becomes liquidatable via market orders. +pub fn position_maintenance_margin( + perp_asset_metadata: &PerpAssetMetadata, + position_initial_margin: QuoteLots, +) -> Result { + perp_asset_metadata + .get_risk_factor(RiskTier::Liquidatable) + .apply_to_quote_lots(position_initial_margin) + .ok_or(MarginError::Overflow) +} + +/// Calculate backstop margin threshold for a position +/// +/// Second-tier liquidation threshold, typically ~40% of initial margin. +pub fn position_backstop_margin( + perp_asset_metadata: &PerpAssetMetadata, + position_initial_margin: QuoteLots, +) -> Result { + perp_asset_metadata + .get_risk_factor(RiskTier::BackstopLiquidatable) + .apply_to_quote_lots(position_initial_margin) + .ok_or(MarginError::Overflow) +} + +/// Calculate high-risk margin threshold for a position +/// +/// Lowest margin threshold before insurance fund intervention, typically ~30%. +pub fn position_high_risk_margin( + perp_asset_metadata: &PerpAssetMetadata, + position_initial_margin: QuoteLots, +) -> Result { + perp_asset_metadata + .get_risk_factor(RiskTier::HighRisk) + .apply_to_quote_lots(position_initial_margin) + .ok_or(MarginError::Overflow) +} + +// ============================================================================ +// Initial Margin Calculation +// ============================================================================ + +/// Calculate initial margin for an asset position during normal trading +/// operations +/// +/// This function calculates the standard margin requirements for position +/// opening, order placement, and regular margin checks. It uses leverage-based +/// calculations and applies risk factor discounts to limit orders for capital +/// efficiency. +/// +/// For withdrawal validation, use `initial_margin_for_asset_for_withdrawals` +/// instead, which enforces stricter requirements to prevent +/// undercollateralization. +/// +/// # Returns +/// The minimum collateral required to maintain the position and limit orders +pub fn initial_margin_for_asset( + perp_asset_metadata: &PerpAssetMetadata, + position_state: &TraderPosition, + limit_order_state: &LimitOrderMarginState, + risk_action: RiskAction, +) -> Result { + initial_margin_for_asset_internal( + perp_asset_metadata, + position_state, + limit_order_state, + false, + risk_action, + ) +} + +/// Calculate initial margin for an asset position when validating withdrawals +/// +/// This function enforces stricter margin requirements than normal trading +/// operations to ensure traders cannot withdraw funds that would leave them +/// undercollateralized. It uses the MAXIMUM of leverage-based and +/// risk-factor-based requirements. +pub fn initial_margin_for_asset_for_withdrawals( + perp_asset_metadata: &PerpAssetMetadata, + position_state: &TraderPosition, + limit_order_state: &LimitOrderMarginState, + risk_action: RiskAction, +) -> Result { + initial_margin_for_asset_internal( + perp_asset_metadata, + position_state, + limit_order_state, + true, + risk_action, + ) +} + +fn existing_position_margin( + position: SignedBaseLots, + asset_unit_price: QuoteLotsPerBaseLot, + perp_asset_metadata: &PerpAssetMetadata, +) -> QuoteLots { + if position == SignedBaseLots::ZERO { + return QuoteLots::ZERO; + } + let absolute_position_size = position.abs_as_unsigned(); + let absolute_book_value = asset_unit_price * absolute_position_size; + let leverage = perp_asset_metadata + .leverage_tiers() + .get_leverage_constant(absolute_position_size); + absolute_book_value.div_ceil::(leverage) +} + +/// Internal function for calculating initial margin requirements for a position +/// +/// This function implements two distinct calculation modes controlled by the +/// `bypass_risk_factor` parameter, which determines the strictness of margin +/// requirements. +/// +/// # Parameters +/// - `perp_asset_metadata`: Market configuration including leverage tiers and +/// risk factors +/// - `position_state`: Current position size and state +/// - `limit_order_state`: Outstanding limit orders requiring margin +/// - `bypass_risk_factor`: Controls calculation mode (see below) +/// - `risk_action`: Context for the risk check (View, Withdrawal, etc.) +/// +/// # Calculation Modes +/// +/// ## Normal Operations (`bypass_risk_factor = false`) +/// Used for: Position opening, order placement, regular margin checks +/// - Calculates leverage-based margin using the position's leverage tier +/// - Applies risk factor discounts to limit orders (allows more capital +/// efficiency) +/// - Formula: `position_value / max_leverage` +/// +/// ## Withdrawal Validation (`bypass_risk_factor = true`) +/// Used for: Validating that withdrawals won't leave trader undercollateralized +/// - Does NOT apply risk factor discounts to limit orders (maximum strictness) +/// - Leverage margin: `position_value with risk increasing limit orders filled +/// / max_leverage` +/// +/// # Strictness Guarantees +/// - All division operations use ceiling division (`div_ceil`) to round up +/// - Risk factor applications use `apply_to_quote_lots_ceil` for maximum +/// strictness +/// - No safety buffers or tolerances - exact calculations only +fn initial_margin_for_asset_internal( + perp_asset_metadata: &PerpAssetMetadata, + position_state: &TraderPosition, + limit_order_state: &LimitOrderMarginState, + bypass_risk_factor: bool, + risk_action: RiskAction, +) -> Result { + // Early return if no positions AND no non-reduce-only limit orders + // This fixes the bug where traders with closed positions couldn't withdraw + // due to margin calculations being performed on zero positions + if position_state.base_lot_position == SignedBaseLots::ZERO + && limit_order_state.total_non_reduce_only_bid_base_lots == BaseLots::ZERO + && limit_order_state.total_non_reduce_only_ask_base_lots == BaseLots::ZERO + { + return Ok(QuoteLots::ZERO); + } + + let asset_unit_price = perp_asset_metadata + .try_get_mark_price(risk_action) + .map_err(MarginError::MarkPrice)? + * perp_asset_metadata.tick_size(); + let position_margin = existing_position_margin( + position_state.base_lot_position, + asset_unit_price, + perp_asset_metadata, + ); + let mut collateral_required = position_margin; + + // Calculate margin increase due to the bid limit orders + let margin_bid = if limit_order_state.total_non_reduce_only_bid_base_lots > BaseLots::ZERO { + margin_increase_for_bids_internal( + position_state.base_lot_position, + limit_order_state + .total_non_reduce_only_bid_base_lots + .as_signed(), + asset_unit_price, + perp_asset_metadata, + position_margin, + bypass_risk_factor, + ) + .map_err(MarginError::from)? + } else { + QuoteLots::ZERO + }; + + // Calculate margin increase due to the ask limit orders + let margin_ask = if limit_order_state.total_non_reduce_only_ask_base_lots > BaseLots::ZERO { + margin_increase_for_asks_internal( + position_state.base_lot_position, + limit_order_state + .total_non_reduce_only_ask_base_lots + .as_signed(), + asset_unit_price, + perp_asset_metadata, + position_margin, + bypass_risk_factor, + ) + .map_err(MarginError::from)? + } else { + QuoteLots::ZERO + }; + + // Calculate margin increase caused by limit orders + collateral_required = collateral_required + .checked_add(if margin_bid > margin_ask { + margin_bid + } else { + margin_ask + }) + .ok_or(MarginError::Overflow)?; + + Ok(collateral_required) +} + +// ============================================================================ +// Limit Order Margin Helpers +// ============================================================================ + +/// Compute incremental margin for bid orders (public interface) +pub fn margin_increase_for_bids( + position: SignedBaseLots, + bid_size: SignedBaseLots, + asset_unit_price: QuoteLotsPerBaseLot, + perp_asset_metadata: &PerpAssetMetadata, +) -> Result { + let existing_position_margin_offset = + existing_position_margin(position, asset_unit_price, perp_asset_metadata); + margin_increase_for_bids_internal( + position, + bid_size, + asset_unit_price, + perp_asset_metadata, + existing_position_margin_offset, + false, + ) +} + +/// Calculates the net change (i.e., increase) in margin required for bid orders +/// +/// Formula: CV_bid = max(N_bid + x_i - |x_i|, 0) * p_i^mark * r_i^LO +fn margin_increase_for_bids_internal( + position: SignedBaseLots, + bid_size: SignedBaseLots, + asset_unit_price: QuoteLotsPerBaseLot, + perp_asset_metadata: &PerpAssetMetadata, + existing_position_margin_offset: QuoteLots, + bypass_risk_factor: bool, +) -> Result { + // CV_bid = max(N_bid + x_i - |x_i|, 0) * p_i^mark * r_i^LO + let new_exposure_signed = bid_size + .checked_add(position) + .ok_or(MathError::Overflow)? + .checked_sub(position.abs()) + .ok_or(MathError::Overflow)?; + + if new_exposure_signed <= SignedBaseLots::ZERO { + return Ok(QuoteLots::ZERO); + } + + let total_exposure_signed = position.checked_add(bid_size).ok_or(MathError::Overflow)?; + let total_exposure = total_exposure_signed.abs_as_unsigned(); + let total_gross_value = asset_unit_price * total_exposure; + let total_leverage = perp_asset_metadata + .leverage_tiers() + .get_leverage_constant(total_exposure); + let total_margin = total_gross_value.div_ceil::(total_leverage); + + let incremental_margin = total_margin + .checked_sub(existing_position_margin_offset) + .unwrap_or(QuoteLots::ZERO); + + if bypass_risk_factor { + Ok(incremental_margin) + } else { + let bid_risk_factor = perp_asset_metadata + .leverage_tiers() + .get_limit_order_risk_factor(total_exposure); + bid_risk_factor + .apply_to_quote_lots_ceil(incremental_margin) + .ok_or(MathError::Overflow) + } +} + +/// Compute incremental margin for ask orders (public interface) +pub fn margin_increase_for_asks( + position: SignedBaseLots, + ask_size: SignedBaseLots, + asset_unit_price: QuoteLotsPerBaseLot, + perp_asset_metadata: &PerpAssetMetadata, +) -> Result { + let existing_position_margin_offset = + existing_position_margin(position, asset_unit_price, perp_asset_metadata); + margin_increase_for_asks_internal( + position, + ask_size, + asset_unit_price, + perp_asset_metadata, + existing_position_margin_offset, + false, + ) +} + +/// Calculates the net change (i.e., increase) in margin required for ask orders +/// +/// Formula: margin_ask = max(N_ask - x_i - |x_i|, 0) * p_i^mark * r_i^LO +fn margin_increase_for_asks_internal( + position: SignedBaseLots, + ask_size: SignedBaseLots, + asset_unit_price: QuoteLotsPerBaseLot, + perp_asset_metadata: &PerpAssetMetadata, + existing_position_margin_offset: QuoteLots, + bypass_risk_factor: bool, +) -> Result { + // If ask_size - position - |position| <= 0, the order reduces risk → no margin. + // Otherwise: total_exposure = |position - ask_size|, and margin is + // (total_margin(total_exposure) - existing_position_margin) * + // risk_factor(total_exposure) + let new_exposure_signed = ask_size + .checked_sub(position) + .ok_or(MathError::Overflow)? + .checked_sub(position.abs()) + .ok_or(MathError::Overflow)?; + + if new_exposure_signed <= SignedBaseLots::ZERO { + return Ok(QuoteLots::ZERO); + } + + let total_exposure_signed = position.checked_sub(ask_size).ok_or(MathError::Overflow)?; + let total_exposure = total_exposure_signed.abs_as_unsigned(); + let total_gross_value = asset_unit_price * total_exposure; + let total_leverage = perp_asset_metadata + .leverage_tiers() + .get_leverage_constant(total_exposure); + let total_margin = total_gross_value.div_ceil::(total_leverage); + + let incremental_margin = total_margin + .checked_sub(existing_position_margin_offset) + .unwrap_or(QuoteLots::ZERO); + + if bypass_risk_factor { + Ok(incremental_margin) + } else { + let ask_risk_factor = perp_asset_metadata + .leverage_tiers() + .get_limit_order_risk_factor(total_exposure); + ask_risk_factor + .apply_to_quote_lots_ceil(incremental_margin) + .ok_or(MathError::Overflow) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quantities::WrapperNum; + + fn create_test_metadata() -> PerpAssetMetadata { + PerpAssetMetadata::default() + } + + #[test] + fn test_no_position_no_orders() { + let metadata = create_test_metadata(); + let position = TraderPosition::new(); + let orders = LimitOrderMarginState::empty(); + + let margin = initial_margin_for_asset(&metadata, &position, &orders, RiskAction::View) + .expect("Should calculate margin"); + + assert_eq!(margin, QuoteLots::ZERO); + } + + #[test] + fn test_position_initial_margin() { + let metadata = create_test_metadata(); + let mut position = TraderPosition::new(); + position.base_lot_position = SignedBaseLots::new(1_000_000); // 1M base lots + let orders = LimitOrderMarginState::empty(); + + let margin = initial_margin_for_asset(&metadata, &position, &orders, RiskAction::View) + .expect("Should calculate margin"); + + assert!(margin > QuoteLots::ZERO); + } + + #[test] + fn test_risk_factor_margins() { + let metadata = create_test_metadata(); + let initial_margin = QuoteLots::new(10_000); + + // Maintenance margin should be less than initial + let maintenance = position_maintenance_margin(&metadata, initial_margin) + .expect("Should calculate maintenance margin"); + assert!(maintenance < initial_margin); + assert_eq!(maintenance, QuoteLots::new(5_000)); // 50% of initial + + // Backstop should be less than maintenance + let backstop = position_backstop_margin(&metadata, initial_margin) + .expect("Should calculate backstop margin"); + assert!(backstop < maintenance); + assert_eq!(backstop, QuoteLots::new(4_000)); // 40% of initial + + // High risk should be less than backstop + let high_risk = position_high_risk_margin(&metadata, initial_margin) + .expect("Should calculate high risk margin"); + assert!(high_risk < backstop); + assert_eq!(high_risk, QuoteLots::new(3_000)); // 30% of initial + } +} diff --git a/container/vendor/rise/rust/math/src/market_math.rs b/container/vendor/rise/rust/math/src/market_math.rs new file mode 100644 index 00000000000..7b6c6954ad5 --- /dev/null +++ b/container/vendor/rise/rust/math/src/market_math.rs @@ -0,0 +1,616 @@ +use crate::quantities::{ + BaseLots, BaseLotsPerTick, MathError, QuoteLots, QuoteLotsPerBaseLotPerTick, SignedBaseLots, + SignedQuoteLots, SignedTicks, Ticks, WrapperNum, +}; + +/// Rounding behavior for floating-point conversions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoundingMode { + Floor, + Ceil, + Nearest, +} + +impl RoundingMode { + fn apply(self, value: f64) -> Result { + if !value.is_finite() { + return Err(MathError::Overflow); + } + if value < 0.0 { + return Err(MathError::Underflow); + } + let rounded = match self { + RoundingMode::Floor => value.floor(), + RoundingMode::Ceil => value.ceil(), + RoundingMode::Nearest => value.round(), + }; + if rounded < 0.0 || rounded > u64::MAX as f64 { + return Err(MathError::Overflow); + } + Ok(rounded as u64) + } +} + +/// Utility struct that encapsulates per-market math conversions. +/// +/// ## Field semantics +/// - `base_lot_decimals`: exponent translating 1 *human* base unit into +/// `10^base_lot_decimals` base lots. Negative values mean each base lot is a +/// bundle of units (e.g. `-4` ⇒ 1 lot = 10_000 units). +/// - `quote_lot_decimals`: exponent for quote lots; currently fixed to 6 +/// (micro‑USD). +/// - `tick_size`: `QuoteLotsPerBaseLotPerTick`, the quote-lot value of one tick +/// for a single base lot. +/// +/// ## Handy formulas (implemented as methods) +/// - Price → ticks: `ticks = (price * 10^{quote_dec}) / (tick_size * +/// 10^{base_dec})` → `price_to_ticks`. +/// - Ticks → price: `price = ticks * tick_size * 10^{base_dec} / +/// 10^{quote_dec}` → `ticks_to_price`. +/// - Base units → lots: `lots = base_units * 10^{base_dec}` (or divide when +/// `base_dec` is negative) → `base_units_to_lots`. +/// - Quote USD → quote lots: `ql = usd * 10^{quote_dec}` → +/// `quote_usd_to_quote_lots`. +/// - Quote budget → base lots at price: `base_lots = (quote_usd / price) * +/// 10^{base_dec}` → `quote_budget_to_base_lots`. +/// - Quote units → ticks (alias for price-to-ticks): `quote_units_to_ticks`. +/// - Depth density (lots per tick) → base units per quote unit: +/// `base_lots_density_to_f64`. +/// +/// ## Example +/// ``` +/// use phoenix_math_utils::{ +/// MarketCalculator, QuoteLotsPerBaseLotPerTick, RoundingMode, WrapperNum, +/// }; +/// +/// let calc = MarketCalculator::new(4, QuoteLotsPerBaseLotPerTick::new(100)); // BTC-like +/// let ticks = calc.price_to_ticks(50_000.0).unwrap(); +/// let lots = calc +/// .base_units_to_lots(0.25, RoundingMode::Nearest) +/// .unwrap(); +/// let usd = calc.quote_lots_to_usd(calc.quote_usd_to_quote_lots(123.45).unwrap()); +/// ``` +/// +/// All helpers perform bounds/finite checks and return `MathError` on overflow, +/// underflow, or invalid input, so call sites stay lightweight. +#[derive(Debug, Clone, Copy)] +pub struct MarketCalculator { + pub base_lot_decimals: i8, + pub quote_lot_decimals: u8, + pub tick_size: QuoteLotsPerBaseLotPerTick, +} + +impl MarketCalculator { + pub fn new(base_lot_decimals: i8, tick_size: QuoteLotsPerBaseLotPerTick) -> Self { + Self { + base_lot_decimals, + quote_lot_decimals: 6, + tick_size, + } + } + + fn base_lots_per_base_unit(&self) -> f64 { + 10f64.powi(self.base_lot_decimals as i32) + } + + fn quote_lots_per_quote_unit(&self) -> f64 { + 10f64.powi(self.quote_lot_decimals as i32) + } + + #[cfg(feature = "rust_decimal")] + fn pow10_decimal(exp: i32) -> rust_decimal::Decimal { + use rust_decimal::Decimal; + // 10^exp using integer power to avoid reliance on Decimal::powu. + if exp == 0 { + return Decimal::ONE; + } + if exp > 0 { + match 10i128.checked_pow(exp as u32) { + Some(val) => Decimal::from(val), + None => Decimal::MAX, + } + } else { + // Negative exponent -> 1 / 10^{-exp} + match 10i128.checked_pow((-exp) as u32) { + Some(val) if val != 0 => Decimal::ONE / Decimal::from(val), + _ => Decimal::ZERO, + } + } + } + + #[cfg(feature = "rust_decimal")] + fn base_lots_per_base_unit_decimal(&self) -> rust_decimal::Decimal { + Self::pow10_decimal(self.base_lot_decimals as i32) + } + + #[cfg(feature = "rust_decimal")] + fn quote_lots_per_quote_unit_decimal(&self) -> rust_decimal::Decimal { + Self::pow10_decimal(self.quote_lot_decimals as i32) + } + + /// Convert ticks → price in quote units (e.g. USD). + pub fn ticks_to_price(&self, ticks: Ticks) -> f64 { + let ticks_f = ticks.as_inner() as f64; + let tick_size_f = self.tick_size.as_inner() as f64; + ticks_f * tick_size_f * self.base_lots_per_base_unit() / self.quote_lots_per_quote_unit() + } + + /// Convert signed ticks → price difference in quote units (e.g. USD). + /// Used for values like EMA of price differences that can be negative. + pub fn signed_ticks_to_price_diff(&self, ticks: SignedTicks) -> f64 { + let ticks_f = ticks.as_inner() as f64; + let tick_size_f = self.tick_size.as_inner() as f64; + ticks_f * tick_size_f * self.base_lots_per_base_unit() / self.quote_lots_per_quote_unit() + } + + #[cfg(feature = "rust_decimal")] + pub fn ticks_to_decimal(&self, ticks: Ticks) -> rust_decimal::Decimal { + let ticks_dec = rust_decimal::Decimal::from(ticks.as_inner()); + (ticks_dec + * rust_decimal::Decimal::from(self.tick_size.as_inner()) + * self.base_lots_per_base_unit_decimal()) + / self.quote_lots_per_quote_unit_decimal() + } + + /// Convert price in quote units (e.g. USD) → ticks (rounded to nearest). + pub fn price_to_ticks(&self, price: f64) -> Result { + if price <= 0.0 || !price.is_finite() { + return Err(MathError::Underflow); + } + let numerator = price * self.quote_lots_per_quote_unit(); + let denominator = self.tick_size.as_inner() as f64 * self.base_lots_per_base_unit(); + if denominator == 0.0 { + return Err(MathError::DivisionByZero); + } + let ticks = (numerator / denominator).round(); + if ticks < 0.0 || ticks > u64::MAX as f64 { + return Err(MathError::Overflow); + } + Ticks::new_checked(ticks as u64).map_err(|_| MathError::Overflow) + } + + /// Synonym for `price_to_ticks` when callers conceptually start from a + /// quote amount (quote units per base unit). + pub fn quote_units_to_ticks(&self, quote_units: f64) -> Result { + self.price_to_ticks(quote_units) + } + + /// Convert human-sized base units → base lots with selectable rounding. + pub fn base_units_to_lots( + &self, + base_units: f64, + rounding: RoundingMode, + ) -> Result { + if base_units <= 0.0 || !base_units.is_finite() { + return Err(MathError::Underflow); + } + let lots = base_units * self.base_lots_per_base_unit(); + let lots_u64 = rounding.apply(lots)?; + BaseLots::new_checked(lots_u64) + } + + /// Convert base lots back to human base units. + pub fn base_lots_to_units(&self, lots: BaseLots) -> f64 { + let divisor = self.base_lots_per_base_unit(); + if divisor == 0.0 { + return 0.0; + } + lots.as_inner() as f64 / divisor + } + + /// Convert signed base lots back to human base units (preserves sign). + pub fn signed_base_lots_to_units(&self, lots: SignedBaseLots) -> f64 { + let divisor = self.base_lots_per_base_unit(); + if divisor == 0.0 { + return 0.0; + } + lots.as_inner() as f64 / divisor + } + + #[cfg(feature = "rust_decimal")] + pub fn base_lots_to_decimal(&self, lots: BaseLots) -> rust_decimal::Decimal { + let lots_dec = rust_decimal::Decimal::from(lots.as_inner()); + let denom = self.base_lots_per_base_unit_decimal(); + if denom.is_zero() { + rust_decimal::Decimal::ZERO + } else { + lots_dec / denom + } + } + + /// Convert quote units (e.g. USD) → quote lots. + pub fn quote_usd_to_quote_lots(&self, usd: f64) -> Result { + if usd < 0.0 || !usd.is_finite() { + return Err(MathError::Underflow); + } + let lots = usd * self.quote_lots_per_quote_unit(); + let lots_u64 = RoundingMode::Nearest.apply(lots)?; + QuoteLots::new_checked(lots_u64) + } + + /// Convert quote lots → quote units (e.g. USD). + pub fn quote_lots_to_usd(&self, lots: QuoteLots) -> f64 { + lots.as_inner() as f64 / self.quote_lots_per_quote_unit() + } + + pub fn signed_quote_lots_to_usd(&self, lots: SignedQuoteLots) -> f64 { + lots.as_inner() as f64 / self.quote_lots_per_quote_unit() + } + + #[cfg(feature = "rust_decimal")] + pub fn quote_lots_to_decimal(&self, lots: QuoteLots) -> rust_decimal::Decimal { + let lots_dec = rust_decimal::Decimal::from(lots.as_inner()); + lots_dec / self.quote_lots_per_quote_unit_decimal() + } + + #[cfg(feature = "rust_decimal")] + pub fn signed_quote_lots_to_decimal(&self, lots: SignedQuoteLots) -> rust_decimal::Decimal { + let lots_dec = rust_decimal::Decimal::from(lots.as_inner()); + lots_dec / self.quote_lots_per_quote_unit_decimal() + } + + /// Given a quote budget and price, compute base lots purchasable. + pub fn quote_budget_to_base_lots( + &self, + quote_usd: f64, + price: f64, + rounding: RoundingMode, + ) -> Result { + if quote_usd <= 0.0 || price <= 0.0 { + return Err(MathError::Underflow); + } + let base_units = quote_usd / price; + self.base_units_to_lots(base_units, rounding) + } + + /// USD-per-tick multiplier (helpful for formatting book depths). + pub fn tick_size_multiplier(&self) -> f64 { + self.quote_lots_per_quote_unit() + / (self.base_lots_per_base_unit() * self.tick_size.as_inner() as f64) + } + + #[cfg(feature = "rust_decimal")] + pub fn tick_size_multiplier_decimal(&self) -> rust_decimal::Decimal { + use rust_decimal::Decimal; + let denom = + self.base_lots_per_base_unit_decimal() * Decimal::from(self.tick_size.as_inner()); + if denom.is_zero() { + Decimal::ZERO + } else { + self.quote_lots_per_quote_unit_decimal() / denom + } + } + + /// Convert a base-lot density (lots per tick) into a human-friendly ratio + /// of base units per quote unit. + pub fn base_lots_density_to_f64(&self, base_lots_density: BaseLotsPerTick) -> f64 { + let base_units_per_tick = + self.base_lots_to_units(BaseLots::new(base_lots_density.as_inner())); + let dollars_per_tick = self.ticks_to_price(Ticks::new(1)); + if dollars_per_tick == 0.0 { + 0.0 + } else { + base_units_per_tick / dollars_per_tick + } + } + + #[cfg(feature = "rust_decimal")] + pub fn base_lots_density_to_decimal( + &self, + base_lots_density: BaseLotsPerTick, + ) -> rust_decimal::Decimal { + use rust_decimal::Decimal; + let base_units_per_tick = + self.base_lots_to_decimal(BaseLots::new(base_lots_density.as_inner())); + let dollars_per_tick = self.ticks_to_decimal(Ticks::new(1)); + if dollars_per_tick.is_zero() { + Decimal::ZERO + } else { + base_units_per_tick / dollars_per_tick + } + } + + /// Calculate price in USD from base lots and quote lots. + /// Returns the price per base unit in USD. + /// Returns 0.0 if base_lots is zero to avoid division by zero. + pub fn price_from_lots(&self, base_lots: BaseLots, quote_lots: QuoteLots) -> f64 { + let base_units = self.base_lots_to_units(base_lots); + if base_units == 0.0 { + return 0.0; + } + let quote_usd = self.quote_lots_to_usd(quote_lots); + quote_usd / base_units + } + + /// Value of a signed base-lot position at the given settlement price in + /// quote lots. + pub fn position_value_for_position( + &self, + base_lot_position: SignedBaseLots, + settlement_price: Ticks, + ) -> SignedQuoteLots { + let sign = SignedQuoteLots::new(base_lot_position.signum().as_inner()); + let absolute_base_lots = base_lot_position.abs_as_unsigned(); + let unsigned_value = absolute_base_lots * (self.tick_size * settlement_price); + let signed_value = unsigned_value + .checked_as_signed() + // tick_size and settlement_price are bounded and absolute_base_lots is u64, + // so overflow here would signal a misconfigured market + .expect("quote lot value fits in SignedQuoteLots"); + signed_value * sign + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quantities::QuoteLotsPerBaseLotPerTick; + + #[test] + fn price_tick_round_trip_positive_decimals() { + let calc = MarketCalculator::new(4, QuoteLotsPerBaseLotPerTick::new(100)); + let price = 42_000.25; + let ticks = calc.price_to_ticks(price).unwrap(); + let back = calc.ticks_to_price(ticks); + assert!((back - price).abs() < 1.0); + } + + #[test] + fn quote_units_to_ticks_matches_price_to_ticks() { + let calc = MarketCalculator::new(2, QuoteLotsPerBaseLotPerTick::new(50)); + let price = 123.45; + let a = calc.price_to_ticks(price).unwrap(); + let b = calc.quote_units_to_ticks(price).unwrap(); + assert_eq!(a.as_inner(), b.as_inner()); + } + + #[test] + fn base_lots_density_conversion_behaves() { + let calc = MarketCalculator::new(3, QuoteLotsPerBaseLotPerTick::new(100)); + let density = BaseLotsPerTick::new(500); // 0.5 base units per tick + let ratio = calc.base_lots_density_to_f64(density); + assert!(ratio > 0.0); + } + + #[test] + fn position_value_matches_sign() { + let calc = MarketCalculator::new(2, QuoteLotsPerBaseLotPerTick::new(100)); + let price_ticks = Ticks::new(10); // arbitrary + let long_val = calc + .position_value_for_position(SignedBaseLots::new(5), price_ticks) + .as_inner(); + let short_val = calc + .position_value_for_position(SignedBaseLots::new(-5), price_ticks) + .as_inner(); + assert_eq!(long_val, -(short_val)); + assert!(long_val > 0); + } + + #[test] + fn price_tick_round_trip_negative_decimals() { + let calc = MarketCalculator::new(-4, QuoteLotsPerBaseLotPerTick::new(1)); + let price = 0.00001146; + let ticks = calc.price_to_ticks(price).unwrap(); + let back = calc.ticks_to_price(ticks); + assert!((back - price).abs() < 1e-12); + } + + #[test] + fn base_unit_conversions_negative_decimals() { + let calc = MarketCalculator::new(-4, QuoteLotsPerBaseLotPerTick::new(1)); + let base_units = 20_000.0; + let lots = calc + .base_units_to_lots(base_units, RoundingMode::Nearest) + .unwrap(); + assert_eq!(lots.as_inner(), 2); + let units_back = calc.base_lots_to_units(lots); + assert!((units_back - 20_000.0).abs() < 1e-6); + } + + #[test] + fn quote_budget_to_lots_matches_expectation() { + // 1000.0 / 0.00001146 = 87260034.904 + // 87260034.904 / e-4 = 8726 + let calc = MarketCalculator::new(-4, QuoteLotsPerBaseLotPerTick::new(1)); + let lots = calc + .quote_budget_to_base_lots(1000.0, 0.00001146, RoundingMode::Floor) + .unwrap(); + println!("lots: {:?}", lots.as_inner()); + assert_eq!(lots.as_inner(), 8726); + } + + #[test] + fn btc_mainnet_config_behaves_as_expected() { + let calc = MarketCalculator::new(4, QuoteLotsPerBaseLotPerTick::new(100)); + let price = 50_000.0; + let ticks = calc.price_to_ticks(price).unwrap(); + assert!(ticks.as_inner() > 0); + let price_back = calc.ticks_to_price(ticks); + assert!((price_back - price).abs() < 1.0); + + let lots = calc + .base_units_to_lots(0.1234, RoundingMode::Nearest) + .unwrap(); + assert_eq!(lots.as_inner(), 1234); + + let quote_budget_lots = calc + .quote_budget_to_base_lots(1000.0, price, RoundingMode::Floor) + .unwrap(); + assert_eq!(quote_budget_lots.as_inner(), 200); + } + + #[test] + fn eth_mainnet_config_behaves_as_expected() { + let calc = MarketCalculator::new(3, QuoteLotsPerBaseLotPerTick::new(100)); + let price = 3_500.0; + let ticks = calc.price_to_ticks(price).unwrap(); + let price_back = calc.ticks_to_price(ticks); + assert!((price_back - price).abs() < 0.5); + + let lots = calc.base_units_to_lots(1.5, RoundingMode::Nearest).unwrap(); + assert_eq!(lots.as_inner(), 1500); + } + + #[test] + fn sol_mainnet_config_behaves_as_expected() { + let calc = MarketCalculator::new(2, QuoteLotsPerBaseLotPerTick::new(100)); + let price = 150.0; + let ticks = calc.price_to_ticks(price).unwrap(); + let back = calc.ticks_to_price(ticks); + assert!((back - price).abs() < 0.05); + + let lots = calc + .base_units_to_lots(25.25, RoundingMode::Nearest) + .unwrap(); + assert_eq!(lots.as_inner(), 2525); + } + + fn calc(bl_dec: i8, tick_q_per_bl_per_tick: u64) -> MarketCalculator { + MarketCalculator::new( + bl_dec, + QuoteLotsPerBaseLotPerTick::new(tick_q_per_bl_per_tick), + ) + } + + #[test] + fn base_units_round_trip_various() { + let c = calc(3, 10); + let size = 1.2345; + let lots = c.base_units_to_lots(size, RoundingMode::Nearest).unwrap(); + let size_back = c.base_lots_to_units(lots); + assert!((size_back - size).abs() < 0.001); + } + + #[test] + fn quote_lots_round_trip() { + let c = calc(4, 100); + let usd = 123.456789; + let ql = c.quote_usd_to_quote_lots(usd).unwrap(); + let usd_back = c.quote_lots_to_usd(ql); + assert!((usd_back - usd).abs() < 0.000001); + } + + #[test] + fn rounding_behavior_base_units() { + let c = calc(2, 1); + let size = 1.7; + let r = c.base_units_to_lots(size, RoundingMode::Nearest).unwrap(); + assert_eq!(r.as_inner(), 170); + + let size_frac = 1.234; + let r_frac = c + .base_units_to_lots(size_frac, RoundingMode::Nearest) + .unwrap(); + assert_eq!(r_frac.as_inner(), 123); + + let size_up = 1.236; + let r_up = c + .base_units_to_lots(size_up, RoundingMode::Nearest) + .unwrap(); + assert_eq!(r_up.as_inner(), 124); + } + + #[test] + fn invalid_inputs_should_error() { + let c = calc(4, 100); + assert!(c.price_to_ticks(-1.0).is_err()); + assert!(c.base_units_to_lots(-0.5, RoundingMode::Nearest).is_err()); + assert!(c.quote_usd_to_quote_lots(-10.0).is_err()); + assert!(c.price_to_ticks(f64::NAN).is_err()); + assert!( + c.base_units_to_lots(f64::INFINITY, RoundingMode::Nearest) + .is_err() + ); + } + + #[test] + fn zero_and_tiny_inputs() { + let c = calc(4, 100); + assert!(c.base_units_to_lots(0.0, RoundingMode::Nearest).is_err()); + assert_eq!(c.quote_usd_to_quote_lots(0.0).unwrap().as_inner(), 0); + let tiny = 0.000001; + assert!(c.price_to_ticks(tiny).is_ok()); + assert!(c.base_units_to_lots(tiny, RoundingMode::Nearest).is_ok()); + assert!(c.quote_usd_to_quote_lots(tiny).is_ok()); + } + + #[test] + fn extreme_prices_and_quantities_behave() { + let c = calc(6, 1000); + let price = 100_000_000.0; + let ticks = c.price_to_ticks(price).unwrap(); + let back = c.ticks_to_price(ticks); + let rel_err = (back - price).abs() / price; + assert!(rel_err < 1e-6); + + let big_price = 9_007_199_254_740.0; + assert!(c.price_to_ticks(big_price).is_err()); + + let doge = calc(8, 1); + let near_max_qty = 42.0; + let lots = doge + .base_units_to_lots(near_max_qty, RoundingMode::Nearest) + .unwrap(); + let back_units = doge.base_lots_to_units(lots); + assert!((back_units - near_max_qty).abs() / near_max_qty < 1e-8); + + let over_max_qty = 50.0; + assert!( + doge.base_units_to_lots(over_max_qty, RoundingMode::Nearest) + .is_err() + ); + } + + #[test] + fn cross_product_precision() { + let c = calc(6, 1000); + let price = 50_000.0; + let qty = 1_000.0; + let ticks = c.price_to_ticks(price).unwrap(); + let lots = c.base_units_to_lots(qty, RoundingMode::Nearest).unwrap(); + + let back_price = c.ticks_to_price(ticks); + let back_qty = c.base_lots_to_units(lots); + let notional = back_price * back_qty; + let expected = price * qty; + assert!((notional - expected).abs() / expected < 1e-6); + } + + #[test] + fn signed_ticks_to_price_diff_matches_unsigned_for_positive() { + // BTC config: 10^4 base lots per BTC, tick_size=100 + let c = MarketCalculator::new(4, QuoteLotsPerBaseLotPerTick::new(100)); + let unsigned_ticks = Ticks::new(1000); + let signed_ticks = SignedTicks::new(1000); + + let unsigned_price = c.ticks_to_price(unsigned_ticks); + let signed_price = c.signed_ticks_to_price_diff(signed_ticks); + + assert!((unsigned_price - signed_price).abs() < 1e-12); + } + + #[test] + fn signed_ticks_to_price_diff_negative() { + // SOL config: 10^2 base lots per SOL, tick_size=100 + // At this config, 1 tick = $0.01, so 500 ticks = $5.00 + let c = MarketCalculator::new(2, QuoteLotsPerBaseLotPerTick::new(100)); + let positive = SignedTicks::new(500); + let negative = SignedTicks::new(-500); + + let pos_price = c.signed_ticks_to_price_diff(positive); + let neg_price = c.signed_ticks_to_price_diff(negative); + + assert!(pos_price > 0.0); + assert!(neg_price < 0.0); + assert!((pos_price + neg_price).abs() < 1e-12); + // Verify actual dollar value + assert!((pos_price - 5.0).abs() < 1e-10); + } + + #[test] + fn signed_ticks_to_price_diff_zero() { + let c = MarketCalculator::new(4, QuoteLotsPerBaseLotPerTick::new(100)); + let zero = SignedTicks::new(0); + assert_eq!(c.signed_ticks_to_price_diff(zero), 0.0); + } +} diff --git a/container/vendor/rise/rust/math/src/perp_metadata.rs b/container/vendor/rise/rust/math/src/perp_metadata.rs new file mode 100644 index 00000000000..c88ac5d41e9 --- /dev/null +++ b/container/vendor/rise/rust/math/src/perp_metadata.rs @@ -0,0 +1,273 @@ +//! Simplified perpetual asset metadata for margin calculations +//! +//! This module provides a minimal PerpAssetMetadata struct containing only +//! the fields required for offline margin calculations, without the complex +//! oracle and funding logic from the on-chain program. + +use crate::leverage_tiers::LeverageTiers; +use crate::quantities::{ + BasisPoints, QuoteLotsPerBaseLotPerTick, SignedQuoteLotsPerBaseLot, Ticks, WrapperNum, +}; +use crate::risk::{ProgramError, RiskAction, RiskTier}; + +/// Simplified perpetual asset metadata for margin calculations +/// +/// This struct contains the minimal set of parameters needed to compute +/// margin requirements offline. It is designed to be constructed from +/// HTTP API data (ExchangeMarketConfig) combined with WebSocket updates +/// (MarketStatsUpdate for mark price). +/// +/// # Fields Required for Margin Calculation +/// +/// - **mark_price**: Current mark price in ticks (from WebSocket) +/// - **tick_size**: Conversion factor between ticks and quote lots +/// - **leverage_tiers**: Position-size-dependent maximum leverage +/// - **risk_factors**: Margin multipliers for different risk tiers +/// [maintenance, backstop, high_risk] +/// - **cancel_order_risk_factor**: Threshold for forced order cancellation +#[derive(Debug, Clone)] +pub struct PerpAssetMetadata { + /// Symbol identifier (e.g., "SOL", "BTC") + pub symbol: String, + + /// Asset identifier + pub asset_id: u64, + + /// Number of decimals for base lot conversions + pub base_lot_decimals: i8, + + /// Current mark price in ticks (updated from WebSocket) + pub mark_price: Ticks, + + /// Tick size: quote lots per base lot per tick + /// Static from HTTP API config + pub tick_size: QuoteLotsPerBaseLotPerTick, + + /// Leverage tiers defining max leverage by position size + /// Static from HTTP API config + pub leverage_tiers: LeverageTiers, + + /// Risk factors in basis points: [maintenance, backstop, high_risk] + /// Maintenance: ~5000 (50%) - liquidation threshold + /// Backstop: ~4000 (40%) - backstop liquidation + /// High_risk: ~3000 (30%) - high risk threshold + /// Static from HTTP API config + pub risk_factors: [u16; 3], + + /// Cancel order risk factor in basis points (e.g., 5500 = 55%) + /// Orders can be force-cancelled when margin falls below this threshold + /// Static from HTTP API config + pub cancel_order_risk_factor: u16, + + /// UPnL risk factor for normal operations (basis points) + /// Used to discount unrealized PnL in effective collateral calculation + pub upnl_risk_factor: u16, + + /// UPnL risk factor for withdrawals (basis points) + /// Typically stricter than normal operations to prevent + /// undercollateralization + pub upnl_risk_factor_for_withdrawals: u16, + + /// Cumulative funding rate for this market + pub cumulative_funding_rate: SignedQuoteLotsPerBaseLot, +} + +impl PerpAssetMetadata { + /// Create a new perpetual asset metadata + pub fn new( + symbol: String, + asset_id: u64, + base_lot_decimals: i8, + mark_price: Ticks, + tick_size: QuoteLotsPerBaseLotPerTick, + leverage_tiers: LeverageTiers, + risk_factors: [u16; 3], + cancel_order_risk_factor: u16, + upnl_risk_factor: u16, + upnl_risk_factor_for_withdrawals: u16, + ) -> Self { + Self { + symbol, + asset_id, + base_lot_decimals, + mark_price, + tick_size, + leverage_tiers, + risk_factors, + cancel_order_risk_factor, + upnl_risk_factor, + upnl_risk_factor_for_withdrawals, + cumulative_funding_rate: SignedQuoteLotsPerBaseLot::ZERO, + } + } + + /// Get the current mark price + #[inline(always)] + pub fn try_get_mark_price(&self, _risk_action: RiskAction) -> Result { + Ok(self.mark_price) + } + + /// Get the base lot decimals + #[inline(always)] + pub fn base_lot_decimals(&self) -> i8 { + self.base_lot_decimals + } + + /// Get the asset identifier + #[inline(always)] + pub fn asset_id(&self) -> u64 { + self.asset_id + } + + /// Get the cumulative funding rate + #[inline(always)] + pub fn cumulative_funding_rate(&self) -> SignedQuoteLotsPerBaseLot { + self.cumulative_funding_rate + } + + /// Update the mark price (call when WebSocket update received) + #[inline] + pub fn set_mark_price(&mut self, new_price: Ticks) { + self.mark_price = new_price; + } + + /// Get the tick size + #[inline] + pub fn tick_size(&self) -> QuoteLotsPerBaseLotPerTick { + self.tick_size + } + + /// Get leverage tiers + #[inline] + pub fn leverage_tiers(&self) -> &LeverageTiers { + &self.leverage_tiers + } + + /// Get risk factor for a specific risk tier + /// + /// # Risk Tier to Index Mapping + /// - Liquidatable (Tier 3) -> risk_factors[0] (maintenance margin) + /// - BackstopLiquidatable (Tier 4) -> risk_factors[1] (backstop margin) + /// - HighRisk (Tier 5) -> risk_factors[2] (high risk margin) + #[inline] + pub fn get_risk_factor(&self, risk_tier: RiskTier) -> BasisPoints { + let index = match risk_tier { + RiskTier::Liquidatable => 0, // Maintenance margin + RiskTier::BackstopLiquidatable => 1, // Backstop margin + RiskTier::HighRisk => 2, // High risk margin + _ => return BasisPoints::new(10_000), // Other tiers use 100% + }; + + BasisPoints::from_u16(self.risk_factors[index]).unwrap_or_else(|| BasisPoints::new(10_000)) + } + + /// Get cancel order risk factor + #[inline] + pub fn cancel_order_risk_factor(&self) -> BasisPoints { + BasisPoints::from_u16(self.cancel_order_risk_factor) + .unwrap_or_else(|| BasisPoints::new(10_000)) + } + + /// Get UPnL risk factor based on risk action + #[inline] + pub fn upnl_risk_factor(&self, risk_action: RiskAction) -> BasisPoints { + let factor = match risk_action { + RiskAction::Withdrawal { .. } => self.upnl_risk_factor_for_withdrawals, + _ => self.upnl_risk_factor, + }; + BasisPoints::from_u16(factor).unwrap_or_else(|| BasisPoints::new(10_000)) + } +} + +impl Default for PerpAssetMetadata { + fn default() -> Self { + Self { + symbol: String::from("UNKNOWN"), + asset_id: 0, + base_lot_decimals: 0, + mark_price: Ticks::new(1_000_000), // Default to reasonable price + tick_size: QuoteLotsPerBaseLotPerTick::new(1), + leverage_tiers: LeverageTiers::default(), + risk_factors: [5_000, 4_000, 3_000], // 50%, 40%, 30% + cancel_order_risk_factor: 5_500, // 55% + upnl_risk_factor: 5_000, // 50% + upnl_risk_factor_for_withdrawals: 7_500, // 75% + cumulative_funding_rate: SignedQuoteLotsPerBaseLot::ZERO, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_try_get_mark_price() { + let metadata = PerpAssetMetadata::default(); + let price = metadata + .try_get_mark_price(RiskAction::View) + .expect("Should return mark price"); + assert_eq!(price, Ticks::new(1_000_000)); + } + + #[test] + fn test_set_mark_price() { + let mut metadata = PerpAssetMetadata::default(); + metadata.set_mark_price(Ticks::new(2_000_000)); + assert_eq!(metadata.mark_price, Ticks::new(2_000_000)); + } + + #[test] + fn test_get_risk_factor() { + let metadata = PerpAssetMetadata::default(); + + // Maintenance margin (50%) + assert_eq!( + metadata.get_risk_factor(RiskTier::Liquidatable), + BasisPoints::new(5_000) + ); + + // Backstop margin (40%) + assert_eq!( + metadata.get_risk_factor(RiskTier::BackstopLiquidatable), + BasisPoints::new(4_000) + ); + + // High risk margin (30%) + assert_eq!( + metadata.get_risk_factor(RiskTier::HighRisk), + BasisPoints::new(3_000) + ); + + // Other tiers return 100% + assert_eq!( + metadata.get_risk_factor(RiskTier::Safe), + BasisPoints::new(10_000) + ); + } + + #[test] + fn test_cancel_order_risk_factor() { + let metadata = PerpAssetMetadata::default(); + assert_eq!(metadata.cancel_order_risk_factor(), BasisPoints::new(5_500)); + } + + #[test] + fn test_upnl_risk_factor() { + let metadata = PerpAssetMetadata::default(); + + // Normal operations use standard UPnL factor + assert_eq!( + metadata.upnl_risk_factor(RiskAction::View), + BasisPoints::new(5_000) + ); + + // Withdrawals use stricter factor + assert_eq!( + metadata.upnl_risk_factor(RiskAction::Withdrawal { + current_slot: crate::quantities::Slot::new(1000) + }), + BasisPoints::new(7_500) + ); + } +} diff --git a/container/vendor/rise/rust/math/src/portfolio.rs b/container/vendor/rise/rust/math/src/portfolio.rs new file mode 100644 index 00000000000..48b9764ccc1 --- /dev/null +++ b/container/vendor/rise/rust/math/src/portfolio.rs @@ -0,0 +1,273 @@ +//! Portfolio-level types and aggregation +//! +//! This module provides types for representing a trader's portfolio across +//! multiple markets, computing portfolio-level margin, and liquidation pricing. + +use std::collections::HashMap; + +use solana_pubkey::Pubkey; + +use crate::direction::{Direction, Side, StopLossOrderKind}; +use crate::errors::PhoenixStateError; +use crate::margin::{LimitOrder, Margin, MarketMargin, MarketPosition}; +use crate::perp_metadata::PerpAssetMetadata; +use crate::quantities::{QuoteLots, SignedQuoteLots, Ticks, WrapperNum}; +use crate::risk::{MarginError, MarginState, RiskState, RiskTier}; +use crate::trader_position::TraderPosition; + +/// Trait for providing perp asset metadata needed for margin calculations. +/// This allows different implementations (PhoenixState, PerpAssetMap, off-chain +/// caches) to provide the necessary market data for computing position margin. +pub trait PerpMetadataProvider { + /// Get the perp asset metadata for a given symbol + fn get_perp_metadata(&self, symbol: &str) -> Option<&PerpAssetMetadata>; + + fn get_all_markets(&self) -> Vec; +} + +impl PerpMetadataProvider for HashMap { + fn get_perp_metadata(&self, symbol: &str) -> Option<&PerpAssetMetadata> { + self.get(symbol) + } + + fn get_all_markets(&self) -> Vec { + self.keys().cloned().collect() + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct StopLossInfo { + pub(crate) funder_key: Pubkey, + pub(crate) trader_key: Pubkey, + pub(crate) asset_id: u64, + pub(crate) trigger_price: Ticks, + pub(crate) execution_price: Ticks, + pub(crate) slot: u64, + pub(crate) order_kind: StopLossOrderKind, + pub(crate) position_sequence_number: u8, + pub(crate) execution_direction: Direction, + pub(crate) trade_side: Side, +} + +/// A trader's complete portfolio across all markets. +/// Contains positions, limit orders, and collateral but no computed margin. +#[derive(Default, Debug, Clone)] +pub struct TraderPortfolio { + pub authority: Pubkey, + pub trader_pda_index: u8, + pub trader_subaccount_index: u8, + + pub quote_lot_collateral: SignedQuoteLots, + + pub positions: HashMap, + /// Individual limit orders per market + pub limit_orders: HashMap>, + pub stop_losses: Vec, +} + +/// Builder for constructing a [`TraderPortfolio`] incrementally. +#[derive(Default, Debug, Clone)] +pub struct TraderPortfolioBuilder { + authority: Pubkey, + trader_pda_index: u8, + trader_subaccount_index: u8, + quote_lot_collateral: SignedQuoteLots, + positions: HashMap, + limit_orders: HashMap>, + stop_losses: Vec, +} + +impl TraderPortfolioBuilder { + pub fn authority(mut self, authority: Pubkey) -> Self { + self.authority = authority; + self + } + + pub fn trader_pda_index(mut self, index: u8) -> Self { + self.trader_pda_index = index; + self + } + + pub fn trader_subaccount_index(mut self, index: u8) -> Self { + self.trader_subaccount_index = index; + self + } + + pub fn quote_lot_collateral(mut self, collateral: SignedQuoteLots) -> Self { + self.quote_lot_collateral = collateral; + self + } + + pub fn position(mut self, symbol: impl Into, position: TraderPosition) -> Self { + self.positions.insert(symbol.into(), position); + self + } + + pub fn limit_orders(mut self, symbol: impl Into, orders: Vec) -> Self { + self.limit_orders.insert(symbol.into(), orders); + self + } + + pub fn stop_loss(mut self, stop_loss: StopLossInfo) -> Self { + self.stop_losses.push(stop_loss); + self + } + + pub fn build(self) -> TraderPortfolio { + TraderPortfolio { + authority: self.authority, + trader_pda_index: self.trader_pda_index, + trader_subaccount_index: self.trader_subaccount_index, + quote_lot_collateral: self.quote_lot_collateral, + positions: self.positions, + limit_orders: self.limit_orders, + stop_losses: self.stop_losses, + } + } +} + +impl TraderPortfolio { + pub fn builder() -> TraderPortfolioBuilder { + TraderPortfolioBuilder::default() + } + + fn get_positions(&self) -> HashMap { + let mut trader_positions = HashMap::new(); + + let mut limit_orders = self.limit_orders.clone(); + for (symbol, position) in self.positions.iter() { + trader_positions.insert( + symbol.clone(), + MarketPosition { + position: Some(*position), + limit_orders: limit_orders.remove(symbol).unwrap_or_default(), + }, + ); + } + for (symbol, orders) in limit_orders { + trader_positions.insert( + symbol.clone(), + MarketPosition { + position: None, + limit_orders: orders, + }, + ); + } + + trader_positions + } + + /// Compute margin and PnL margin across all markets in this portfolio. + /// Uses the provided metadata provider to fetch market data. + pub fn compute_margin( + &self, + provider: &impl PerpMetadataProvider, + ) -> Result { + let positions: HashMap = self + .get_positions() + .into_iter() + .map(|(symbol, position)| { + let perp_asset_metadata = provider.get_perp_metadata(&symbol).ok_or_else(|| { + PhoenixStateError::MarketNotFound { + symbol: symbol.clone(), + markets: vec![], + } + })?; + + let margin = position.compute_margin(&symbol, provider)?; + let limit_orders_with_margin = + position.compute_limit_orders_margin(perp_asset_metadata)?; + + Ok(( + symbol.clone(), + MarketMargin { + position: position.position, + limit_orders: limit_orders_with_margin, + margin, + }, + )) + }) + .collect::, PhoenixStateError>>()?; + + let margin: Margin = positions.values().map(|p| p.margin).sum(); + + Ok(TraderPortfolioMargin { + authority: self.authority, + trader_pda_index: self.trader_pda_index, + trader_subaccount_index: self.trader_subaccount_index, + quote_lot_collateral: self.quote_lot_collateral, + margin, + positions, + stop_losses: self.stop_losses.clone(), + }) + } +} + +/// A trader's portfolio with computed margin and PnL across all markets. +/// Includes per-market breakdown and aggregated totals. +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct TraderPortfolioMargin { + pub authority: Pubkey, + pub trader_pda_index: u8, + pub trader_subaccount_index: u8, + + pub quote_lot_collateral: SignedQuoteLots, + + pub margin: Margin, + pub positions: HashMap, + pub stop_losses: Vec, +} + +impl TraderPortfolioMargin { + pub fn effective_collateral(&self) -> SignedQuoteLots { + self.quote_lot_collateral + self.margin.discounted_unrealized_pnl + } + + pub fn effective_collateral_for_withdrawals(&self) -> SignedQuoteLots { + self.quote_lot_collateral + self.margin.discounted_pnl_for_withdrawals + } + + pub fn portfolio_value(&self) -> SignedQuoteLots { + self.quote_lot_collateral + self.margin.unrealized_pnl + } + + pub fn initial_margin(&self) -> QuoteLots { + self.margin.initial_margin + } + + pub fn risk_state(&self) -> Result { + let effective_collateral = self.effective_collateral(); + let margin_state = MarginState::new(self.margin.initial_margin, effective_collateral); + margin_state.risk_state() + } + + pub fn risk_tier(&self) -> Result { + let effective_collateral = self.effective_collateral(); + self.margin.risk_tier(effective_collateral) + } + + pub fn calculate_transferable_collateral(&self) -> Result { + let total_collateral = self.quote_lot_collateral; + + // If trader has no positions or limit orders, all collateral is transferable + if self.positions.is_empty() { + return Ok(total_collateral.max(SignedQuoteLots::ZERO).as_inner() as u64); + } + + // Use the pre-calculated initial_margin_for_withdrawals which includes + // margin requirements for both positions AND open limit orders + let total_margin_required = self + .margin + .initial_margin_for_withdrawals + .checked_as_signed()?; + + // Transferable amount = total collateral - required margin + if total_collateral >= total_margin_required { + Ok((total_collateral - total_margin_required) + .max(SignedQuoteLots::ZERO) + .as_inner() as u64) + } else { + Ok(0) + } + } +} diff --git a/container/vendor/rise/rust/math/src/price.rs b/container/vendor/rise/rust/math/src/price.rs new file mode 100644 index 00000000000..09c4b6192ee --- /dev/null +++ b/container/vendor/rise/rust/math/src/price.rs @@ -0,0 +1,361 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +#[cfg(feature = "rust_decimal")] +use rust_decimal::RoundingStrategy; +#[cfg(feature = "rust_decimal")] +use rust_decimal::prelude::*; + +use crate::quantities::{MathError, Ticks}; + +/// Oracle-friendly price representation (mantissa + exponent). +/// +/// `value * 10^{-expo}` yields quote units per base unit. The exponent is +/// stored as a positive integer to match on-chain layouts (e.g., Pyth‑style +/// mantissa/exponent). +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize)] +pub struct Price { + /// Price mantissa + pub value: u64, + /// Decimal exponent (number of fractional digits) + pub expo: u8, +} + +impl Price { + /// Rescale the mantissa to `target_decimals` fractional digits. + pub fn to_scaled_value(&self, target_decimals: u8) -> Result { + if self.expo < target_decimals { + let factor = 10u64 + .checked_pow((target_decimals - self.expo) as u32) + .ok_or(MathError::Overflow)?; + self.value.checked_mul(factor).ok_or(MathError::Overflow) + } else if self.expo > target_decimals { + let factor = 10u64 + .checked_pow((self.expo - target_decimals) as u32) + .ok_or(MathError::Overflow)?; + self.value + .checked_div(factor) + .ok_or(MathError::DivisionByZero) + } else { + Ok(self.value) + } + } + + /// Convert this price into ticks given the market's tick size and decimals. + pub fn to_ticks( + &self, + tick_size_in_quote_lots_per_base_lot: u64, + base_lot_decimals: i8, + quote_decimals: u8, + ) -> Result { + let price_in_quote_lots_per_base_unit = self.to_scaled_value(quote_decimals)?; + if tick_size_in_quote_lots_per_base_lot == 0 { + return Err(MathError::DivisionByZero); + } + + if base_lot_decimals >= 0 { + let base_lots_per_base_unit = 10u64 + .checked_pow(base_lot_decimals as u32) + .ok_or(MathError::Overflow)?; + price_in_quote_lots_per_base_unit + .checked_div( + tick_size_in_quote_lots_per_base_lot + .checked_mul(base_lots_per_base_unit) + .ok_or(MathError::Overflow)?, + ) + .ok_or(MathError::DivisionByZero) + } else { + let base_units_per_base_lot = 10u64 + .checked_pow((-base_lot_decimals) as u32) + .ok_or(MathError::Overflow)?; + price_in_quote_lots_per_base_unit + .checked_mul(base_units_per_base_lot) + .ok_or(MathError::Overflow)? + .checked_div(tick_size_in_quote_lots_per_base_lot) + .ok_or(MathError::DivisionByZero) + } + } + + /// Helper to wrap the tick result in the newtype. + pub fn to_ticks_wrapped( + &self, + tick_size_in_quote_lots_per_base_lot: u64, + base_lot_decimals: i8, + quote_decimals: u8, + ) -> Result { + let t = self.to_ticks( + tick_size_in_quote_lots_per_base_lot, + base_lot_decimals, + quote_decimals, + )?; + Ticks::new_checked(t).map_err(|_| MathError::Overflow) + } +} + +impl Price { + /// Convert a positive `f64` into `Price` with a caller-provided decimal + /// cap. + pub fn from_f64_with_max_decimals(value: f64, max_decimals: u8) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err(MathError::Underflow); + } + + let expo = dynamic_price_decimals(value).min(max_decimals); + let scale = 10f64.powi(expo as i32); + let scaled = (value * scale).round(); + + if !scaled.is_finite() { + return Err(MathError::Overflow); + } + if scaled <= 0.0 { + return Err(MathError::Underflow); + } + if scaled > u64::MAX as f64 { + return Err(MathError::Overflow); + } + + Ok(Price { + value: scaled as u64, + expo, + }) + } + + /// Convert using the default `DEFAULT_MAX_DYNAMIC_DECIMALS` cap. + pub fn from_f64(value: f64) -> Result { + Self::from_f64_with_max_decimals(value, DEFAULT_MAX_DYNAMIC_DECIMALS) + } + + /// High-precision conversion from `rust_decimal::Decimal`, keeping as much + /// precision as possible without overflowing `u64` while avoiding + /// over-scaling for large prices. Available when the `rust_decimal` feature + /// is enabled. + #[cfg(feature = "rust_decimal")] + pub fn from_decimal(decimal: Decimal) -> Result { + Self::from_decimal_with_max_decimals(decimal, DEFAULT_MAX_DYNAMIC_DECIMALS) + } + + /// Variant that allows configuring the max decimals cap. + #[cfg(feature = "rust_decimal")] + pub fn from_decimal_with_max_decimals( + decimal: Decimal, + max_decimals: u8, + ) -> Result { + if decimal.is_sign_negative() || decimal.is_zero() { + return Err(MathError::Underflow); + } + + let value_f64 = decimal.to_f64().ok_or(MathError::Overflow)?; + let dynamic_expo = dynamic_price_decimals(value_f64) as u32; + let source_scale = decimal.scale() as u32; + + // Target exponent: keep at least the source scale, respect the dynamic + // heuristic, and never exceed `max_decimals`. + let target_expo = std::cmp::min( + max_decimals as u32, + std::cmp::max(source_scale, dynamic_expo), + ); + + let mut mantissa = decimal.mantissa(); + let mut scale = source_scale; + + if scale < target_expo { + // Increase precision by appending zeros to the mantissa. + let diff = target_expo - scale; + let factor = ten_pow_i128(diff).ok_or(MathError::Overflow)?; + mantissa = mantissa.checked_mul(factor).ok_or(MathError::Overflow)?; + scale = target_expo; + } else if scale > target_expo { + // Round rather than truncate when reducing precision. + let rounded = + decimal.round_dp_with_strategy(target_expo, RoundingStrategy::MidpointAwayFromZero); + mantissa = rounded.mantissa(); + scale = rounded.scale(); + } + + debug_assert_eq!(scale, target_expo); + + if mantissa <= 0 { + return Err(MathError::Underflow); + } + if mantissa as i128 > u64::MAX as i128 { + return Err(MathError::Overflow); + } + + Ok(Price { + value: mantissa as u64, + expo: target_expo as u8, + }) + } +} + +/// Choose a reasonable number of decimals for a positive price to avoid +/// over/under-scaling while preserving precision for micro assets. +pub fn dynamic_price_decimals(value: f64) -> u8 { + if value <= 0.0 || !value.is_finite() { + return 6; + } + + if value >= 1.0 { + // Large prices: reduce decimals as magnitude grows, but keep at least 4 to + // avoid under-reporting precision for high-value assets. + let floor_log = value.log10().floor() as i32; + let decimals = 6 - floor_log - 1; + return decimals.clamp(4, 6) as u8; + } + + // Sub-dollar: add decimals as the value shrinks, but cap to keep mantissas + // reasonable. Micro assets can afford a wider cap to preserve precision. + let magnitude = (-value.log10()).ceil() as i32; + let decimals = 6 + magnitude; + decimals.clamp(6, DEFAULT_MAX_DYNAMIC_DECIMALS as i32) as u8 +} + +const DEFAULT_MAX_DYNAMIC_DECIMALS: u8 = 12; + +#[cfg(feature = "rust_decimal")] +fn ten_pow_i128(exp: u32) -> Option { + 10i128.checked_pow(exp) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quantities::traits::WrapperNum; + + #[test] + fn price_to_scaled_value_rounds_correctly() { + let p = Price { + value: 15_000, + expo: 0, + }; + assert_eq!(p.to_scaled_value(6).unwrap(), 15_000_000_000); + let p = Price { + value: 15_000_000, + expo: 3, + }; + assert_eq!(p.to_scaled_value(6).unwrap(), 15_000_000_000); + let p = Price { + value: 15_000_000_000, + expo: 9, + }; + assert_eq!(p.to_scaled_value(6).unwrap(), 15_000_000); + } + + #[test] + fn price_to_ticks_behaves_for_positive_and_negative_decimals() { + let p = Price { + value: 15_000_000, + expo: 3, + }; + assert_eq!(p.to_ticks(10_000_000, 3, 6).unwrap(), 1); + + let p2 = Price { + value: 150, + expo: 0, + }; + assert_eq!(p2.to_ticks(1_000_000, 0, 6).unwrap(), 150); + + let p3 = Price { + value: 11_460, + expo: 8, + }; + // price = 0.00011460, base_lot_decimals = -4 + let ticks = p3.to_ticks(1, -4, 6).unwrap(); + assert!(ticks > 0); + } + + #[test] + fn dynamic_price_decimals_limits() { + assert_eq!(dynamic_price_decimals(0.000009706), 12); + assert_eq!(dynamic_price_decimals(50_000.0), 4); + assert_eq!(dynamic_price_decimals(1.0), 5); + assert_eq!(dynamic_price_decimals(-1.0), 6); + } + + #[test] + fn quantize_price_micro_asset() { + let price = 0.000009706; + let q = Price::from_f64(price).unwrap(); + assert_eq!(q.expo, 12); + assert_eq!(q.value, 9_706_000); + + let ticks = q.to_ticks(1, /* tick size */ 0, /* base lot dec */ 6).err(); + assert!(ticks.is_none()); + + let ticks_wrapped = q.to_ticks_wrapped(1, 0, 6).expect("ticks should compute"); + assert_eq!(ticks_wrapped.as_inner(), 9); + } + + #[test] + fn quantize_price_large_value_rounds() { + let price = 50_000.1234; + let p = Price::from_f64(price).unwrap(); + assert_eq!(p.expo, 4); // reduced decimals with floor at 4 for large price + let ticks = p + .to_ticks_wrapped( + 100, // tick size quote lots per base lot + 4, // base lot dec + 6, + ) + .unwrap(); + assert!(ticks.as_inner() > 0); + } + + #[test] + fn rounding_boundaries_half_up_behavior() { + // Value slightly below the .5 boundary should round down + let expo = 5; + let scale = 10f64.powi(expo); + let just_below = (123_456_f64 + 0.4999) / scale; + let p_down = Price::from_f64_with_max_decimals(just_below, expo as u8).unwrap(); + assert_eq!(p_down.value, 123_456); + + // Value slightly above the .5 boundary should round up + let just_above = (123_456_f64 + 0.5001) / scale; + let p_up = Price::from_f64_with_max_decimals(just_above, expo as u8).unwrap(); + assert_eq!(p_up.value, 123_457); + } + + #[test] + fn sub_dollar_micro_precision_scaling() { + // Very small price should keep high precision and sensible ticks + let price = 0.000009706_f64; + let p = Price::from_f64(price).unwrap(); + assert_eq!(p.expo, 12); + assert_eq!(p.value, 9_706_000); + + // Convert to ticks for a 1-quote-lot tick, base lot decimals 0, quote_dec=6 + let ticks = p.to_ticks_wrapped(1, 0, 6).unwrap(); + assert_eq!(ticks.as_inner(), 9); + + // Slightly larger price should produce strictly greater ticks + let price_up = 0.0000101_f64; // enough to move to tick 10 + let p_up = Price::from_f64(price_up).unwrap(); + let ticks_up = p_up.to_ticks_wrapped(1, 0, 6).unwrap(); + assert!(ticks_up.as_inner() > ticks.as_inner()); + } + + #[cfg(feature = "rust_decimal")] + #[test] + fn from_decimal_micro_price_scales_up_to_cap() { + let d = rust_decimal::Decimal::from_str("0.000009706").unwrap(); + let p = Price::from_decimal(d).unwrap(); + assert_eq!(p.expo, 12); + assert_eq!(p.value, 9_706_000); + } + + #[cfg(feature = "rust_decimal")] + #[test] + fn from_decimal_respects_dynamic_and_max() { + let d = rust_decimal::Decimal::from_str("99443.1002232312").unwrap(); + let p = Price::from_decimal_with_max_decimals(d, 12).unwrap(); + // Source scale is 10; dynamic suggests 4; we keep source scale (10) + assert_eq!(p.expo, 10); + assert!(p.value > 0); + } + + #[test] + fn quantize_price_overflow_rejected() { + let too_large = f64::MAX; + assert!(Price::from_f64(too_large).is_err()); + } +} diff --git a/container/vendor/rise/rust/math/src/quantities/errors.rs b/container/vendor/rise/rust/math/src/quantities/errors.rs new file mode 100644 index 00000000000..bae69291d91 --- /dev/null +++ b/container/vendor/rise/rust/math/src/quantities/errors.rs @@ -0,0 +1,70 @@ +//! Error types for safe arithmetic operations. +//! +//! This module provides custom error types for handling arithmetic failures +//! in a type-safe manner, replacing panics with explicit error handling. + +use thiserror::Error; + +/// Errors that can occur during arithmetic operations on quantity types. +#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum MathError { + /// Division by zero was attempted. + #[error("Division by zero")] + DivisionByZero, + + /// Arithmetic operation would overflow the type's maximum value. + #[error("Arithmetic overflow")] + Overflow, + + /// Arithmetic operation would underflow below the type's minimum value. + #[error("Arithmetic underflow")] + Underflow, + + /// Value exceeds the defined bounds for the type. + /// Uses i128 to properly handle both signed and unsigned values. + #[error("Value {value} is out of bounds [{min}, {max}]")] + OutOfBounds { value: i128, min: i128, max: i128 }, +} + +impl MathError { + /// Creates an OutOfBounds error for u64 types. + pub fn out_of_bounds_u64(value: u64, min: u64, max: u64) -> Self { + Self::OutOfBounds { + value: value as i128, + min: min as i128, + max: max as i128, + } + } + + /// Creates an OutOfBounds error for i64 types. + pub fn out_of_bounds_i64(value: i64, min: i64, max: i64) -> Self { + Self::OutOfBounds { + value: value as i128, + min: min as i128, + max: max as i128, + } + } + + /// Creates an OutOfBounds error for i128 types. + pub fn out_of_bounds_i128(value: i128, min: i128, max: i128) -> Self { + Self::OutOfBounds { value, min, max } + } + + /// Creates an OutOfBounds error for u32 types. + pub fn out_of_bounds_u32(value: u32, min: u32, max: u32) -> Self { + Self::OutOfBounds { + value: value as i128, + min: min as i128, + max: max as i128, + } + } + + /// Creates an OutOfBounds error for i64 types. + pub fn out_of_bounds_i32(value: i32, min: i32, max: i32) -> Self { + Self::OutOfBounds { + value: value as i128, + min: min as i128, + max: max as i128, + } + } +} diff --git a/container/vendor/rise/rust/math/src/quantities/macros.rs b/container/vendor/rise/rust/math/src/quantities/macros.rs new file mode 100644 index 00000000000..4d6b6865fbe --- /dev/null +++ b/container/vendor/rise/rust/math/src/quantities/macros.rs @@ -0,0 +1,1377 @@ +//! Macro definitions for generating type-safe numeric wrapper structs. +//! +//! These macros eliminate boilerplate while ensuring consistent implementation +//! of arithmetic operations with proper overflow handling and type safety. + +/// Creates a basic u64 wrapper struct with safe arithmetic operations. +/// +/// # Generated Methods +/// +/// - `new(value: u64)`: Constructor +/// - `as_inner() -> u64`: Access underlying value +/// - `checked_add/sub`: Returns None on overflow +/// - `saturating_add/sub`: Saturates at MIN/MAX +/// - `div_ceil`: Division with ceiling rounding +/// +/// # Safety Features +/// +/// All arithmetic operations preserve type safety and provide +/// explicit overflow handling options. +#[macro_export] +macro_rules! basic_u64_struct { + ($type_name:ident) => { + #[derive(Clone, Copy, PartialOrd, Ord, Zeroable, Pod, BorshDeserialize, BorshSerialize)] + #[repr(transparent)] + pub struct $type_name { + inner: u64, + } + + impl $type_name { + pub fn div_ceil>(self, other: Divisor) -> Self { + match self.checked_div_ceil(other) { + Some(result) => result, + None => panic!("Overflow or division by zero in div_ceil"), + } + } + + pub fn checked_div_ceil>( + self, + other: Divisor, + ) -> Option { + let divisor = other.as_inner(); + if divisor == 0 { + None + } else { + // Use built-in div_ceil method which handles overflow correctly + Some($type_name::new(self.inner.div_ceil(divisor))) + } + } + } + + basic_num!($type_name, u64); + + impl $type_name { + pub fn as_u128(&self) -> u128 { + self.inner as u128 + } + + pub fn is_non_negative(&self) -> bool { + true + } + } + }; +} + +/// Creates a u64 wrapper with enforced value bounds. +/// +/// # Overflow Prevention +/// +/// By restricting values to a subset of u64's range, this macro +/// helps prevent overflow errors before they occur. +/// +/// # Example +/// +/// ```ignore +/// basic_u64_struct_with_bounds!(Ticks, 0, u32::MAX as u64); +/// // Ticks can only hold values from 0 to u32::MAX +/// ``` +#[macro_export] +macro_rules! basic_u64_struct_with_bounds { + ($type_name:ident, $lower_bound:expr, $upper_bound:expr) => { + basic_u64_struct!($type_name); + impl ScalarBounds for $type_name { + const LOWER_BOUND: u64 = $lower_bound; + const UPPER_BOUND: u64 = $upper_bound; + } + + impl $type_name { + /// Creates a new instance with bounds checking. + /// Returns an error if the value is outside the valid range. + /// + /// # Example + /// ```ignore + /// let valid = BaseLots::new_checked(100)?; + /// let invalid = BaseLots::new_checked(u64::MAX); // Returns Err + /// ``` + pub fn new_checked(value: u64) -> Result { + if !(Self::LOWER_BOUND..=Self::UPPER_BOUND).contains(&value) { + Err($crate::quantities::MathError::out_of_bounds_u64( + value, + Self::LOWER_BOUND, + Self::UPPER_BOUND, + )) + } else { + Ok(Self::new(value)) + } + } + + /// Creates a new instance, saturating at the bounds if necessary. + /// + /// # Example + /// ```ignore + /// let saturated = BaseLots::new_saturating(u64::MAX); + /// assert_eq!(saturated.as_inner(), u32::MAX as u64); + /// ``` + pub fn new_saturating(value: u64) -> Self { + let clamped = value.clamp(Self::LOWER_BOUND, Self::UPPER_BOUND); + Self::new(clamped) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: u8) -> Result { + Self::new_checked(value as u64) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: u16) -> Result { + Self::new_checked(value as u64) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: u32) -> Result { + Self::new_checked(value as u64) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: u128) -> Result { + if value > u64::MAX as u128 { + return Err($crate::quantities::MathError::Overflow); + } + Self::new_checked(value as u64) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: usize) -> Result { + Self::new_checked(value as u64) + } + } + }; +} + +/// Creates an unsigned u32 wrapper struct with safe arithmetic. +/// +/// # Generated Methods +/// +/// - `div_ceil`: Division with ceiling rounding +/// - `checked_div_ceil`: Returns None on division by zero +/// - `as_u64()`: Widen to u64 +/// - `is_non_negative()`: Always returns true for unsigned +#[macro_export] +macro_rules! basic_u32_struct { + ($type_name:ident) => { + #[derive(Clone, Copy, PartialOrd, Ord, Zeroable, Pod, BorshDeserialize, BorshSerialize)] + #[repr(transparent)] + pub struct $type_name { + inner: u32, + } + + impl $type_name { + pub fn div_ceil>(self, other: Divisor) -> Self { + match self.checked_div_ceil(other) { + Some(result) => result, + None => panic!("Overflow or division by zero in div_ceil"), + } + } + + pub fn checked_div_ceil>( + self, + other: Divisor, + ) -> Option { + let divisor = other.as_inner(); + if divisor == 0 { + None + } else { + // Use built-in div_ceil method which handles overflow correctly + Some($type_name::new(self.inner.div_ceil(divisor))) + } + } + + pub fn as_u64(&self) -> u64 { + self.inner as u64 + } + } + + basic_num!($type_name, u32); + + impl $type_name { + pub fn as_u128(&self) -> u128 { + self.inner as u128 + } + + pub fn is_non_negative(&self) -> bool { + true + } + } + }; +} + +#[macro_export] +macro_rules! basic_u32_struct_with_bounds { + ($type_name:ident, $lower_bound:expr, $upper_bound:expr) => { + basic_u32_struct!($type_name); + impl ScalarBounds for $type_name { + const LOWER_BOUND: u32 = $lower_bound; + const UPPER_BOUND: u32 = $upper_bound; + } + + impl $type_name { + pub fn new_checked(value: u32) -> Result { + if !(Self::LOWER_BOUND..=Self::UPPER_BOUND).contains(&value) { + Err($crate::quantities::MathError::out_of_bounds_u32( + value, + Self::LOWER_BOUND, + Self::UPPER_BOUND, + )) + } else { + Ok(Self::new(value)) + } + } + + pub fn new_saturating(value: u32) -> Self { + let clamped = value.clamp(Self::LOWER_BOUND, Self::UPPER_BOUND); + Self::new(clamped) + } + } + }; +} + +#[macro_export] +macro_rules! basic_i128_struct { + ($type_name:ident) => { + pastey::paste! { + #[derive( + Clone, Copy, PartialOrd, Ord, Zeroable, Pod, BorshDeserialize, BorshSerialize, + )] + #[repr(transparent)] + pub struct [<$type_name Upcasted>] { + inner: i128, + } + + basic_num!([<$type_name Upcasted>], i128); + + impl $type_name { + pub fn upcast(&self) -> [<$type_name Upcasted>] { + [<$type_name Upcasted>] { inner: self.inner as i128 } + } + } + + impl [<$type_name Upcasted>] { + pub fn downcast(&self) -> Option<$type_name> { + if self.inner > i64::MAX as i128 || self.inner < i64::MIN as i128 { + None + } else { + Some($type_name::new(self.inner as i64)) + } + } + + /// Checked conversion to u128. Returns error if negative. + pub fn checked_as_u128(&self) -> Result { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else { + Ok(self.inner as u128) + } + } + + pub fn is_non_negative(&self) -> bool { + self.inner >= 0 + } + } + } + }; +} + +/// Creates a signed i64 wrapper struct with safe arithmetic. +/// +/// Includes additional methods for signed operations: +/// - `abs()`: Absolute value +/// - `signum()`: Sign of the number +/// - `neg()`: Negation operator +/// - `is_non_negative()`: Check if >= 0 +#[macro_export] +macro_rules! basic_i64_struct { + ($type_name:ident) => { + #[derive(Clone, Copy, PartialOrd, Ord, Zeroable, Pod, BorshDeserialize, BorshSerialize)] + #[repr(transparent)] + pub struct $type_name { + inner: i64, + } + + basic_num!($type_name, i64); + + impl $type_name { + pub fn abs(self) -> Self { + $type_name::new( + self.inner + .checked_abs() + .expect("Overflow in abs for signed 64-bit wrapper"), + ) + } + + pub fn as_i128(&self) -> i128 { + self.inner as i128 + } + + /// Checked conversion to u128. Returns error if negative. + pub fn checked_as_u128(&self) -> Result { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else { + Ok(self.inner as u128) + } + } + + /// Absolute value as u128. Always succeeds since u128 can represent + /// abs(i64::MIN). + pub fn abs_as_u128(&self) -> u128 { + self.inner.unsigned_abs() as u128 + } + + pub fn signum(&self) -> Self { + $type_name::new(self.inner.signum()) + } + + pub fn is_non_negative(&self) -> bool { + self.inner >= 0 + } + } + + impl Neg for $type_name { + type Output = Self; + + fn neg(self) -> Self { + $type_name::new( + self.inner + .checked_neg() + .expect("Overflow in neg for signed 64-bit wrapper"), + ) + } + } + }; +} + +#[macro_export] +macro_rules! basic_i64_struct_with_bounds { + ($type_name:ident, $lower_bound:expr, $upper_bound:expr) => { + basic_i64_struct!($type_name); + impl ScalarBounds for $type_name { + const LOWER_BOUND: i64 = $lower_bound; + const UPPER_BOUND: i64 = $upper_bound; + } + + impl $type_name { + /// Creates a new instance with bounds checking. + /// Returns an error if the value is outside the valid range. + /// + /// # Example + /// ```ignore + /// let valid = BaseLots::new_checked(100)?; + /// let invalid = BaseLots::new_checked(i64::MAX); // Returns Err + /// ``` + pub fn new_checked(value: i64) -> Result { + if !(Self::LOWER_BOUND..=Self::UPPER_BOUND).contains(&value) { + Err($crate::quantities::MathError::out_of_bounds_i64( + value, + Self::LOWER_BOUND, + Self::UPPER_BOUND, + )) + } else { + Ok(Self::new(value)) + } + } + + /// Creates a new instance, saturating at the bounds if necessary. + /// + /// # Example + /// ```ignore + /// let saturated = BaseLots::new_saturating(i64::MAX); + /// assert_eq!(saturated.as_inner(), u32::MAX as u64); + /// ``` + pub fn new_saturating(value: i64) -> Self { + let clamped = value.clamp(Self::LOWER_BOUND, Self::UPPER_BOUND); + Self::new(clamped) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: i8) -> Result { + Self::new_checked(i64::from(value)) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: i16) -> Result { + Self::new_checked(i64::from(value)) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: i32) -> Result { + Self::new_checked(i64::from(value)) + } + } + + impl core::convert::TryFrom for $type_name { + type Error = $crate::quantities::MathError; + + fn try_from(value: i128) -> Result { + let converted = i64::try_from(value).map_err(|_| { + $crate::quantities::MathError::out_of_bounds_i128( + value, + $type_name::LOWER_BOUND as i128, + $type_name::UPPER_BOUND as i128, + ) + })?; + Self::new_checked(converted) + } + } + }; +} + +/// Creates a signed i32 wrapper struct with safe arithmetic. +/// +/// Includes additional methods for signed operations: +/// - `abs()`: Absolute value +/// - `signum()`: Sign of the number +/// - `neg()`: Negation operator +/// - `is_non_negative()`: Check if >= 0 +#[macro_export] +macro_rules! basic_i32_struct { + ($type_name:ident) => { + #[derive(Clone, Copy, PartialOrd, Ord, Zeroable, Pod, BorshDeserialize, BorshSerialize)] + #[repr(transparent)] + pub struct $type_name { + inner: i32, + } + + basic_num!($type_name, i32); + + impl $type_name { + pub fn as_i64(&self) -> i64 { + self.inner as i64 + } + + /// Checked conversion to u128. Returns error if negative. + pub fn checked_as_u128(&self) -> Result { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else { + Ok(self.inner as u128) + } + } + + pub fn abs(self) -> Self { + $type_name::new( + self.inner + .checked_abs() + .expect("Overflow in abs for signed 32-bit wrapper"), + ) + } + + pub fn signum(&self) -> Self { + $type_name::new(self.inner.signum()) + } + + pub fn is_non_negative(&self) -> bool { + self.inner >= 0 + } + } + + impl Neg for $type_name { + type Output = Self; + + fn neg(self) -> Self { + $type_name::new( + self.inner + .checked_neg() + .expect("Overflow in neg for signed 32-bit wrapper"), + ) + } + } + }; +} + +#[macro_export] +macro_rules! basic_i32_struct_with_bounds { + ($type_name:ident, $lower_bound:expr, $upper_bound:expr) => { + basic_i32_struct!($type_name); + impl ScalarBounds for $type_name { + const LOWER_BOUND: i32 = $lower_bound; + const UPPER_BOUND: i32 = $upper_bound; + } + + impl $type_name { + /// Creates a new instance with bounds checking. + /// Returns an error if the value is outside the valid range. + /// + /// # Example + /// ```ignore + /// let valid = BaseLots::new_checked(100)?; + /// let invalid = BaseLots::new_checked(i32::MAX); // Returns Err + /// ``` + pub fn new_checked(value: i32) -> Result { + if !(Self::LOWER_BOUND..=Self::UPPER_BOUND).contains(&value) { + Err($crate::quantities::MathError::out_of_bounds_i32( + value, + Self::LOWER_BOUND, + Self::UPPER_BOUND, + )) + } else { + Ok(Self::new(value)) + } + } + + /// Creates a new instance, saturating at the bounds if necessary. + /// + /// # Example + /// ```ignore + /// let saturated = BaseLots::new_saturating(i64::MAX); + /// assert_eq!(saturated.as_inner(), u32::MAX as u64); + /// ``` + pub fn new_saturating(value: i32) -> Self { + let clamped = value.clamp(Self::LOWER_BOUND, Self::UPPER_BOUND); + Self::new(clamped) + } + } + }; +} + +#[macro_export] +macro_rules! basic_num { + ($type_name:ident, $inner_type:ty) => { + impl WrapperNum<$inner_type> for $type_name { + type Inner = $inner_type; + + fn new(value: $inner_type) -> Self { + $type_name { inner: value } + } + + fn as_inner(&self) -> $inner_type { + self.inner + } + } + + impl $type_name { + pub const MAX: Self = $type_name { + inner: <$inner_type>::MAX, + }; + pub const MIN: Self = $type_name { + inner: <$inner_type>::MIN, + }; + pub const ONE: Self = $type_name { inner: 1 }; + pub const ZERO: Self = $type_name { inner: 0 }; + + pub const fn new_const(value: $inner_type) -> Self { + $type_name { inner: value } + } + + pub fn saturating_add(self, other: Self) -> Self { + $type_name::new(self.inner.saturating_add(other.inner)) + } + + pub fn saturating_sub(self, other: Self) -> Self { + $type_name::new(self.inner.saturating_sub(other.inner)) + } + + pub fn checked_add(self, other: Self) -> Option { + self.inner.checked_add(other.inner).map($type_name::new) + } + + pub fn checked_sub(self, other: Self) -> Option { + self.inner.checked_sub(other.inner).map($type_name::new) + } + + pub fn wrapping_add(self, other: Self) -> Self { + $type_name::new(self.inner.wrapping_add(other.inner)) + } + + pub fn wrapping_sub(self, other: Self) -> Self { + $type_name::new(self.inner.wrapping_sub(other.inner)) + } + + pub fn wrapping_mul(self, other: Self) -> Self { + $type_name::new(self.inner.wrapping_mul(other.inner)) + } + + pub fn unchecked_div< + Divisor: WrapperNum<$inner_type>, + Quotient: WrapperNum<$inner_type>, + >( + self, + other: Divisor, + ) -> Quotient { + Quotient::new(self.inner / other.as_inner()) + } + + pub fn checked_div(self, other: Self) -> Option { + if other.inner == 0 { + None + } else { + Some($type_name::new(self.inner / other.inner)) + } + } + + pub fn checked_div_generic< + Divisor: WrapperNum<$inner_type>, + Quotient: WrapperNum<$inner_type>, + >( + self, + other: Divisor, + ) -> Option { + let divisor = other.as_inner(); + if divisor == 0 { + None + } else { + Some(Quotient::new(self.inner / divisor)) + } + } + + pub fn leading_zeros(&self) -> u32 { + self.inner.leading_zeros() + } + } + + impl Debug for $type_name { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}({})", stringify!($type_name), self.inner) + } + } + + impl Display for $type_name { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.inner) + } + } + + impl Mul for $type_name { + type Output = Self; + + fn mul(self, other: Self) -> Self { + $type_name::new(self.inner * other.inner) + } + } + + impl Sum<$type_name> for $type_name { + fn sum>(iter: I) -> Self { + iter.fold($type_name::ZERO, |acc, x| acc + x) + } + } + + impl Add for $type_name { + type Output = Self; + + fn add(self, other: Self) -> Self { + $type_name::new(self.inner + other.inner) + } + } + + impl AddAssign for $type_name { + fn add_assign(&mut self, other: Self) { + *self = *self + other; + } + } + + impl Sub for $type_name { + type Output = Self; + + fn sub(self, other: Self) -> Self { + $type_name::new(self.inner - other.inner) + } + } + + impl SubAssign for $type_name { + fn sub_assign(&mut self, other: Self) { + *self = *self - other; + } + } + + // Only implement Default if not already derived + impl Default for $type_name { + fn default() -> Self { + Self::ZERO + } + } + + impl PartialEq for $type_name { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } + } + + impl From<$type_name> for $inner_type { + fn from(x: $type_name) -> $inner_type { + x.inner + } + } + + impl From<$inner_type> for $type_name { + fn from(x: $inner_type) -> $type_name { + $type_name::new(x) + } + } + + impl From<$type_name> for f64 { + fn from(x: $type_name) -> f64 { + x.inner as f64 + } + } + + impl Eq for $type_name {} + + // Below should only be used in tests. + impl PartialEq<$inner_type> for $type_name { + fn eq(&self, other: &$inner_type) -> bool { + self.inner == *other + } + } + + impl PartialEq<$type_name> for $inner_type { + fn eq(&self, other: &$type_name) -> bool { + *self == other.inner + } + } + }; +} + +/// Defines type-safe multiplication and division between wrapper types. +/// +/// # Type Safety +/// +/// This macro ensures dimensional correctness in multiplication: +/// - `Type1 * Type2 -> ResultType` +/// - `ResultType / Type1 -> Type2` +/// - `ResultType / Type2 -> Type1` +/// +/// # Example +/// +/// ```ignore +/// allow_multiply!(BaseUnits, BaseLotsPerBaseUnit, BaseLots); +/// // BaseUnits * BaseLotsPerBaseUnit = BaseLots +/// // BaseLots / BaseUnits = BaseLotsPerBaseUnit +/// ``` +#[macro_export] +macro_rules! allow_multiply { + ($type_1:ident, $type_2:ident, $type_result:ident) => { + impl Mul<$type_2> for $type_1 { + type Output = $type_result; + + fn mul(self, other: $type_2) -> $type_result { + $type_result::new(self.inner * other.inner) + } + } + + impl Mul<$type_1> for $type_2 { + type Output = $type_result; + + fn mul(self, other: $type_1) -> $type_result { + $type_result::new(self.inner * other.inner) + } + } + + impl Div<$type_1> for $type_result { + type Output = $type_2; + + #[track_caller] + fn div(self, other: $type_1) -> $type_2 { + $type_2::new(self.inner / other.inner) + } + } + + impl Div<$type_2> for $type_result { + type Output = $type_1; + + #[track_caller] + fn div(self, other: $type_2) -> $type_1 { + $type_1::new(self.inner / other.inner) + } + } + + // Generate checked division methods with unique names based on the types + pastey::paste! { + impl $type_result { + #[doc = concat!( + "Safely divides this `", stringify!($type_result), "` by a `", stringify!($type_1), "`.\n", + "\n", + "Returns `Some(", stringify!($type_2), ")` if the division is valid, or `None` if the divisor is zero.\n", + "\n", + "# Example\n", + "```ignore\n", + "let result = ", stringify!($type_result), "::new(100);\n", + "let divisor = ", stringify!($type_1), "::new(10);\n", + "assert_eq!(result.", stringify!([]), "(divisor), Some(", stringify!($type_2), "::new(10)));\n", + "\n", + "let zero = ", stringify!($type_1), "::new(0);\n", + "assert_eq!(result.", stringify!([]), "(zero), None);\n", + "```" + )] + pub fn [](self, other: $type_1) -> Option<$type_2> { + if other.inner == 0 { + None + } else { + Some($type_2::new(self.inner / other.inner)) + } + } + + #[doc = concat!( + "Safely divides this `", stringify!($type_result), "` by a `", stringify!($type_2), "`.\n", + "\n", + "Returns `Some(", stringify!($type_1), ")` if the division is valid, or `None` if the divisor is zero.\n", + "\n", + "# Example\n", + "```ignore\n", + "let result = ", stringify!($type_result), "::new(100);\n", + "let divisor = ", stringify!($type_2), "::new(10);\n", + "assert_eq!(result.", stringify!([]), "(divisor), Some(", stringify!($type_1), "::new(10)));\n", + "\n", + "let zero = ", stringify!($type_2), "::new(0);\n", + "assert_eq!(result.", stringify!([]), "(zero), None);\n", + "```" + )] + pub fn [](self, other: $type_2) -> Option<$type_1> { + if other.inner == 0 { + None + } else { + Some($type_1::new(self.inner / other.inner)) + } + } + } + } + + // Generate checked multiplication methods + pastey::paste! { + impl $type_1 { + #[doc = concat!( + "Safely multiplies this `", stringify!($type_1), "` by a `", stringify!($type_2), "`.\n", + "\n", + "Returns `Some(", stringify!($type_result), ")` if the multiplication doesn't overflow, or `None` if it does.\n" + )] + pub fn [](self, rhs: $type_2) -> Option<$type_result> { + self.inner.checked_mul(rhs.inner).map($type_result::new) + } + } + + impl $type_2 { + #[doc = concat!( + "Safely multiplies this `", stringify!($type_2), "` by a `", stringify!($type_1), "`.\n", + "\n", + "Returns `Some(", stringify!($type_result), ")` if the multiplication doesn't overflow, or `None` if it does.\n" + )] + pub fn [](self, rhs: $type_1) -> Option<$type_result> { + self.inner.checked_mul(rhs.inner).map($type_result::new) + } + } + } + }; +} + +/// Enables safe addition/subtraction between unsigned and signed 64-bit types. +/// +/// # Overflow Handling +/// +/// This macro carefully handles the interaction between signed and unsigned +/// types to prevent underflow when subtracting larger unsigned values. +/// +/// # Panic Safety +/// +/// Will panic on underflow when adding negative signed values to unsigned +/// types that would result in negative values. +#[macro_export] +macro_rules! allow_add_64bit { + ($unsigned_type:ident, $signed_type:ident) => { + impl $unsigned_type { + /// Infallible conversion to the signed type. Safe for pairs where the + /// unsigned bounds fit in the signed representation. + pub fn as_signed(&self) -> $signed_type { + $signed_type::new(self.inner as i64) + } + + /// Checked conversion to the signed type. Returns an error if the value + /// exceeds i64::MAX. + pub fn checked_as_signed(&self) -> Result<$signed_type, $crate::quantities::MathError> { + if self.inner <= i64::MAX as u64 { + Ok($signed_type::new(self.inner as i64)) + } else { + Err($crate::quantities::MathError::Overflow) + } + } + } + + impl $signed_type { + /// Checked conversion to the unsigned type. Errors if the value is + /// negative or exceeds the unsigned type's upper bound. + pub fn checked_as_unsigned( + &self, + ) -> Result<$unsigned_type, $crate::quantities::MathError> { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else if (self.inner as u64) + > <$unsigned_type as $crate::quantities::ScalarBounds>::UPPER_BOUND + { + Err($crate::quantities::MathError::Overflow) + } else { + Ok($unsigned_type::new(self.inner as u64)) + } + } + + /// Absolute value converted to unsigned. + pub fn abs_as_unsigned(&self) -> $unsigned_type { + $unsigned_type::new(self.inner.unsigned_abs()) + } + } + + impl Add<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn add(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner + other.inner as u64) + } else { + // For negative signed values, we need to handle potential underflow + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + $unsigned_type::new(self.inner - abs_value) + } else { + // Maintain backward compatibility - panic on underflow + panic!("Underflow in add operation"); + } + } + } + } + + impl $unsigned_type { + /// Safe addition with signed type that returns None on underflow. + pub fn checked_add_signed(self, other: $signed_type) -> Option<$unsigned_type> { + if other.is_non_negative() { + self.inner + .checked_add(other.inner as u64) + .map($unsigned_type::new) + } else { + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + Some($unsigned_type::new(self.inner - abs_value)) + } else { + None // Underflow + } + } + } + + /// Saturating addition with signed type that saturates at 0 on underflow. + pub fn saturating_add_signed(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner.saturating_add(other.inner as u64)) + } else { + let abs_value = other.inner.unsigned_abs(); + $unsigned_type::new(self.inner.saturating_sub(abs_value)) + } + } + } + + impl Sub<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn sub(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner - other.inner.unsigned_abs()) + } else { + // Subtracting a negative = adding the absolute value + let abs_value = other.inner.unsigned_abs(); + match self.inner.checked_add(abs_value) { + Some(result) => $unsigned_type::new(result), + None => panic!("Overflow in sub operation"), + } + } + } + } + + impl Add<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn add(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in add: unsigned value exceeds signed bounds"); + let sum = self + .inner + .checked_add(other_signed.as_inner()) + .expect("Overflow in add: result exceeds signed bounds"); + $signed_type::new(sum) + } + } + + impl Sub<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn sub(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in sub: unsigned value exceeds signed bounds"); + let diff = self + .inner + .checked_sub(other_signed.as_inner()) + .expect("Overflow in sub: result exceeds signed bounds"); + $signed_type::new(diff) + } + } + + impl AddAssign<$signed_type> for $unsigned_type { + fn add_assign(&mut self, other: $signed_type) { + *self = *self + other; + } + } + + impl SubAssign<$signed_type> for $unsigned_type { + fn sub_assign(&mut self, other: $signed_type) { + *self = *self - other; + } + } + + impl AddAssign<$unsigned_type> for $signed_type { + fn add_assign(&mut self, other: $unsigned_type) { + *self = *self + other; + } + } + + impl SubAssign<$unsigned_type> for $signed_type { + fn sub_assign(&mut self, other: $unsigned_type) { + *self = *self - other; + } + } + }; +} + +/// Enables safe addition/subtraction between unsigned and signed 64-bit types +/// where the unsigned type may exceed the signed type's representable range. +#[macro_export] +macro_rules! allow_checked_add_64bit { + ($unsigned_type:ident, $signed_type:ident) => { + impl $unsigned_type { + /// Checked conversion to the signed type. Returns overflow if the + /// unsigned value cannot fit in i64. + pub fn checked_as_signed(&self) -> Result<$signed_type, $crate::quantities::MathError> { + if self.inner <= i64::MAX as u64 { + Ok($signed_type::new(self.inner as i64)) + } else { + Err($crate::quantities::MathError::Overflow) + } + } + } + + impl $signed_type { + /// Checked conversion to the unsigned type. Errors if the value is + /// negative or exceeds the unsigned type's upper bound. + pub fn checked_as_unsigned( + &self, + ) -> Result<$unsigned_type, $crate::quantities::MathError> { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else if (self.inner as u64) + > <$unsigned_type as $crate::quantities::ScalarBounds>::UPPER_BOUND + { + Err($crate::quantities::MathError::Overflow) + } else { + Ok($unsigned_type::new(self.inner as u64)) + } + } + + /// Absolute value converted to unsigned. + pub fn abs_as_unsigned(&self) -> $unsigned_type { + $unsigned_type::new(self.inner.unsigned_abs()) + } + } + + impl Add<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn add(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner + other.inner as u64) + } else { + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + $unsigned_type::new(self.inner - abs_value) + } else { + panic!("Underflow in add operation"); + } + } + } + } + + impl $unsigned_type { + /// Safe addition with signed type that returns None on underflow. + pub fn checked_add_signed(self, other: $signed_type) -> Option<$unsigned_type> { + if other.is_non_negative() { + self.inner + .checked_add(other.inner as u64) + .map($unsigned_type::new) + } else { + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + Some($unsigned_type::new(self.inner - abs_value)) + } else { + None + } + } + } + + /// Saturating addition with signed type that saturates at 0 on underflow. + pub fn saturating_add_signed(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner.saturating_add(other.inner as u64)) + } else { + let abs_value = other.inner.unsigned_abs(); + $unsigned_type::new(self.inner.saturating_sub(abs_value)) + } + } + } + + impl Sub<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn sub(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner - other.inner.unsigned_abs()) + } else { + // Subtracting a negative = adding the absolute value + let abs_value = other.inner.unsigned_abs(); + match self.inner.checked_add(abs_value) { + Some(result) => $unsigned_type::new(result), + None => panic!("Overflow in sub operation"), + } + } + } + } + + impl Add<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn add(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in add: unsigned value exceeds signed bounds"); + let sum = self + .inner + .checked_add(other_signed.as_inner()) + .expect("Overflow in add: result exceeds signed bounds"); + $signed_type::new(sum) + } + } + + impl Sub<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn sub(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in sub: unsigned value exceeds signed bounds"); + let diff = self + .inner + .checked_sub(other_signed.as_inner()) + .expect("Overflow in sub: result exceeds signed bounds"); + $signed_type::new(diff) + } + } + + impl AddAssign<$signed_type> for $unsigned_type { + fn add_assign(&mut self, other: $signed_type) { + *self = *self + other; + } + } + + impl SubAssign<$signed_type> for $unsigned_type { + fn sub_assign(&mut self, other: $signed_type) { + *self = *self - other; + } + } + + impl AddAssign<$unsigned_type> for $signed_type { + fn add_assign(&mut self, other: $unsigned_type) { + *self = *self + other; + } + } + + impl SubAssign<$unsigned_type> for $signed_type { + fn sub_assign(&mut self, other: $unsigned_type) { + *self = *self - other; + } + } + }; +} + +/// Enables safe addition/subtraction between unsigned and signed types where +/// the signed representation is bounded by i32::MAX. +#[macro_export] +macro_rules! allow_checked_add_32bit { + ($unsigned_type:ident, $signed_type:ident) => { + impl $unsigned_type { + /// Checked conversion to the signed type. Returns overflow if the + /// unsigned value cannot fit in i32. + pub fn checked_as_signed(&self) -> Result<$signed_type, $crate::quantities::MathError> { + if self.inner <= i32::MAX as u64 { + Ok($signed_type::new(self.inner as i64)) + } else { + Err($crate::quantities::MathError::Overflow) + } + } + } + + impl $signed_type { + /// Checked conversion to the unsigned type. Errors if the value is + /// negative or exceeds the unsigned type's upper bound. + pub fn checked_as_unsigned( + &self, + ) -> Result<$unsigned_type, $crate::quantities::MathError> { + if self.inner < 0 { + Err($crate::quantities::MathError::Underflow) + } else if (self.inner as u64) + > <$unsigned_type as $crate::quantities::ScalarBounds>::UPPER_BOUND + { + Err($crate::quantities::MathError::Overflow) + } else { + Ok($unsigned_type::new(self.inner as u64)) + } + } + + /// Absolute value converted to unsigned. + pub fn abs_as_unsigned(&self) -> $unsigned_type { + $unsigned_type::new(self.inner.unsigned_abs()) + } + } + + impl Add<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn add(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner + other.inner as u64) + } else { + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + $unsigned_type::new(self.inner - abs_value) + } else { + panic!("Underflow in add operation"); + } + } + } + } + + impl $unsigned_type { + /// Safe addition with signed type that returns None on underflow. + pub fn checked_add_signed(self, other: $signed_type) -> Option<$unsigned_type> { + if other.is_non_negative() { + self.inner + .checked_add(other.inner as u64) + .map($unsigned_type::new) + } else { + let abs_value = other.inner.unsigned_abs(); + if self.inner >= abs_value { + Some($unsigned_type::new(self.inner - abs_value)) + } else { + None + } + } + } + + /// Saturating addition with signed type that saturates at 0 on underflow. + pub fn saturating_add_signed(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner.saturating_add(other.inner as u64)) + } else { + let abs_value = other.inner.unsigned_abs(); + $unsigned_type::new(self.inner.saturating_sub(abs_value)) + } + } + } + + impl Sub<$signed_type> for $unsigned_type { + type Output = $unsigned_type; + + fn sub(self, other: $signed_type) -> $unsigned_type { + if other.is_non_negative() { + $unsigned_type::new(self.inner - other.inner.unsigned_abs()) + } else { + // Subtracting a negative = adding the absolute value + let abs_value = other.inner.unsigned_abs(); + match self.inner.checked_add(abs_value) { + Some(result) => $unsigned_type::new(result), + None => panic!("Overflow in sub operation"), + } + } + } + } + + impl Add<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn add(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in add: unsigned value exceeds signed bounds"); + let sum = self + .inner + .checked_add(other_signed.as_inner()) + .expect("Overflow in add: result exceeds signed bounds"); + $signed_type::new(sum) + } + } + + impl Sub<$unsigned_type> for $signed_type { + type Output = $signed_type; + + fn sub(self, other: $unsigned_type) -> $signed_type { + let other_signed = other + .checked_as_signed() + .expect("Overflow in sub: unsigned value exceeds signed bounds"); + let diff = self + .inner + .checked_sub(other_signed.as_inner()) + .expect("Overflow in sub: result exceeds signed bounds"); + $signed_type::new(diff) + } + } + + impl AddAssign<$signed_type> for $unsigned_type { + fn add_assign(&mut self, other: $signed_type) { + *self = *self + other; + } + } + + impl SubAssign<$signed_type> for $unsigned_type { + fn sub_assign(&mut self, other: $signed_type) { + *self = *self - other; + } + } + + impl AddAssign<$unsigned_type> for $signed_type { + fn add_assign(&mut self, other: $unsigned_type) { + *self = *self + other; + } + } + + impl SubAssign<$unsigned_type> for $signed_type { + fn sub_assign(&mut self, other: $unsigned_type) { + *self = *self - other; + } + } + }; +} diff --git a/container/vendor/rise/rust/math/src/quantities/mod.rs b/container/vendor/rise/rust/math/src/quantities/mod.rs new file mode 100644 index 00000000000..427ff9daae1 --- /dev/null +++ b/container/vendor/rise/rust/math/src/quantities/mod.rs @@ -0,0 +1,668 @@ +//! Type-safe quantity system for preventing arithmetic errors and overflows. +//! +//! This module provides newtype wrappers around primitive numeric types (u64, +//! i64, i128) to enforce dimensional correctness and prevent common arithmetic +//! errors at compile time. +//! +//! # Benefits +//! +//! - **Type Safety**: Prevents mixing incompatible units (e.g., can't add +//! BaseLots to QuoteLots) +//! - **Overflow Protection**: Provides checked arithmetic operations with +//! explicit error handling +//! - **Bounds Enforcement**: Certain types enforce value ranges (e.g., BaseLots +//! limited to u32::MAX) +//! - **Zero-cost Abstraction**: All types are #[repr(transparent)] with no +//! runtime overhead +//! +//! # Example Usage +//! +//! ```ignore +//! use phoenix_math_utils::quantities::{BaseLots, QuoteLots, Ticks}; +//! +//! // Type system prevents incorrect operations +//! let base = BaseLots::new(100); +//! let quote = QuoteLots::new(1000); +//! // let invalid = base + quote; // Compile error! +//! +//! // Safe arithmetic with overflow handling +//! let a = BaseLots::new(u32::MAX as u64); +//! let b = BaseLots::new(1); +//! let sum = a.checked_add(b); // Returns None on overflow +//! let saturated = a.saturating_add(b); // Saturates at MAX +//! +//! // Bounds checking +//! let large = BaseLots::new(u64::MAX); +//! assert!(!large.is_in_bounds()); // BaseLots limited to u32::MAX +//! ``` +//! +//! # Architecture +//! +//! The module is organized into three sub-modules: +//! - `traits`: Core traits for wrapper types and bounds checking +//! - `macros`: Macro definitions for generating type-safe wrapper structs +//! - `types`: Concrete type definitions for various quantity types + +// Re-export the macros at the crate level +#[macro_use] +mod macros; + +pub mod errors; +pub mod traits; +pub mod types; + +// Re-export commonly used items at the module level +pub use errors::MathError; +pub use traits::{ScalarBounds, WrapperNum}; +pub use types::*; + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + #[test] + fn test_new_constructor_macro() { + let base_lots_1 = BaseLots::new(5); + let base_lots_2 = BaseLots::new(10); + + assert_eq!(base_lots_1 + base_lots_2, BaseLots::new(15)); + + // Below code (correctly) fails to compile. + // let quote_lots_1 = QuoteLots::new(5); + // let result = quote_lots_1 + base_lots_1; + } + + #[test] + fn test_multiply_macro() { + let base_units = BaseUnits::new(5); + let base_lots_per_base_unit = BaseLotsPerBaseUnit::new(100); + assert_eq!(base_units * base_lots_per_base_unit, BaseLots::new(500)); + + // Below code (correctly) fails to compile. + // let quote_units = QuoteUnits::new(5); + // let result = quote_units * base_lots_per_base_unit; + } + #[test] + fn test_bounds_range_quote_lots() { + let bounds = QuoteLots::bounds(); + assert_eq!(*bounds.start(), 0); + assert_eq!(*bounds.end(), u64::MAX); + assert_eq!(QuoteLots::lower_bound(), 0); + assert_eq!(QuoteLots::upper_bound(), u64::MAX); + } + + #[test] + fn test_bounds_range_base_lots() { + let bounds = BaseLots::bounds(); + assert_eq!(*bounds.start(), 0); + assert_eq!(*bounds.end(), u32::MAX as u64); + assert_eq!(BaseLots::lower_bound(), 0); + assert_eq!(BaseLots::upper_bound(), u32::MAX as u64); + } + + #[test] + fn test_bounds_range_ticks() { + let bounds = Ticks::bounds(); + assert_eq!(*bounds.start(), 0); + assert_eq!(*bounds.end(), u32::MAX as u64); + assert_eq!(Ticks::lower_bound(), 0); + assert_eq!(Ticks::upper_bound(), u32::MAX as u64); + } + + proptest! { + #[test] + fn test_quote_lots_is_in_bounds(value in 0..=u32::MAX as u64) { + let quote_lots = QuoteLots::new(value); + prop_assert!(quote_lots.is_in_bounds()); + prop_assert_eq!(quote_lots.as_inner(), value); + } + + #[test] + fn test_base_lots_is_in_bounds(value in 0..=u32::MAX as u64) { + let base_lots = BaseLots::new(value); + prop_assert!(base_lots.is_in_bounds()); + prop_assert_eq!(base_lots.as_inner(), value); + } + + #[test] + fn test_base_lots_out_of_bounds(value in (u32::MAX as u64 + 1)..=u64::MAX) { + let base_lots = BaseLots::new(value); + prop_assert!(!base_lots.is_in_bounds()); + } + + #[test] + fn test_ticks_is_in_bounds(value in 0..=u32::MAX as u64) { + let ticks = Ticks::new(value); + prop_assert!(ticks.is_in_bounds()); + prop_assert_eq!(ticks.as_inner(), value); + } + + #[test] + fn test_ticks_out_of_bounds(value in (u32::MAX as u64 + 1)..=u64::MAX) { + let ticks = Ticks::new(value); + prop_assert!(!ticks.is_in_bounds()); + } + + + + #[test] + fn test_arithmetic_preserves_bounds_add(a in 0..=u16::MAX as u64, b in 0..=u16::MAX as u64) { + let quote_lots_a = QuoteLots::new(a); + let quote_lots_b = QuoteLots::new(b); + let sum = quote_lots_a + quote_lots_b; + + // Sum of two values each <= u16::MAX should be <= u32::MAX + prop_assert!(sum.is_in_bounds()); + prop_assert_eq!(sum.as_inner(), a + b); + } + + #[test] + fn test_arithmetic_preserves_bounds_sub(a in 0..=u32::MAX as u64, b in 0..=u32::MAX as u64) { + // Skip test if b > a since we can't subtract in that case + prop_assume!(b <= a); + + let base_lots_a = BaseLots::new(a); + let base_lots_b = BaseLots::new(b); + let diff = base_lots_a - base_lots_b; + + prop_assert!(diff.is_in_bounds()); + prop_assert_eq!(diff.as_inner(), a - b); + } + + #[test] + fn test_saturating_sub_preserves_bounds(a in 0..=u32::MAX as u64, b in 0..=u32::MAX as u64) { + let base_lots_a = BaseLots::new(a); + let base_lots_b = BaseLots::new(b); + let result = base_lots_a.saturating_sub(base_lots_b); + + prop_assert!(result.is_in_bounds()); + prop_assert_eq!(result.as_inner(), a.saturating_sub(b)); + } + + #[test] + fn test_multiply_preserves_bounds(ticks in 0..=100u64, base_lots_per_tick in 0..=1000u64) { + let t = Ticks::new(ticks); + let blpt = BaseLotsPerTick::new(base_lots_per_tick); + + // Only test if the result would be in bounds + if ticks * base_lots_per_tick <= u32::MAX as u64 { + let result = blpt * t; + prop_assert!(result.is_in_bounds()); + prop_assert_eq!(result.as_inner(), ticks * base_lots_per_tick); + } + } + } + + #[test] + fn test_checked_division() { + let a = BaseLots::new(100); + let b = BaseLots::new(10); + let zero = BaseLots::new(0); + + // Normal division works + assert_eq!(a.checked_div(b), Some(BaseLots::new(10))); + + // Division by zero returns None + assert_eq!(a.checked_div(zero), None); + } + + #[test] + fn test_div_ceil_correct() { + let a = BaseLots::new(10); + let b = BaseLots::new(3); + let c = BaseLots::new(12); + + // 10 / 3 = 3.33... -> 4 + assert_eq!(a.div_ceil(b), BaseLots::new(4)); + + // 12 / 3 = 4 exactly -> 4 (not 5!) + assert_eq!(c.div_ceil(b), BaseLots::new(4)); + + // Test checked version + assert_eq!(a.checked_div_ceil(b), Some(BaseLots::new(4))); + assert_eq!(c.checked_div_ceil(b), Some(BaseLots::new(4))); + assert_eq!(a.checked_div_ceil(BaseLots::new(0)), None); + } + + #[test] + fn test_checked_add_signed() { + let unsigned = QuoteLots::new(100); + let positive = SignedQuoteLots::new(50); + let negative = SignedQuoteLots::new(-30); + let large_negative = SignedQuoteLots::new(-150); + + // Adding positive works + assert_eq!( + unsigned.checked_add_signed(positive), + Some(QuoteLots::new(150)) + ); + + // Subtracting smaller negative works + assert_eq!( + unsigned.checked_add_signed(negative), + Some(QuoteLots::new(70)) + ); + + // Subtracting larger negative returns None (underflow) + assert_eq!(unsigned.checked_add_signed(large_negative), None); + } + + #[test] + fn test_saturating_add_signed() { + let unsigned = QuoteLots::new(100); + let positive = SignedQuoteLots::new(50); + let negative = SignedQuoteLots::new(-30); + let large_negative = SignedQuoteLots::new(-150); + + // Adding positive works + assert_eq!( + unsigned.saturating_add_signed(positive), + QuoteLots::new(150) + ); + + // Subtracting smaller negative works + assert_eq!(unsigned.saturating_add_signed(negative), QuoteLots::new(70)); + + // Subtracting larger negative saturates at 0 + assert_eq!( + unsigned.saturating_add_signed(large_negative), + QuoteLots::new(0) + ); + } + + #[test] + fn test_overflow_safe_arithmetic() { + // In debug mode, overflow detection is enabled via debug_assert + // In release mode, operations saturate + + // Test checked methods always work + let max = QuoteLots::new(u64::MAX); + let one = QuoteLots::new(1); + + assert_eq!(max.checked_add(one), None); + assert_eq!(max.saturating_add(one), max); + + let zero = QuoteLots::new(0); + assert_eq!(zero.checked_sub(one), None); + assert_eq!(zero.saturating_sub(one), zero); + + // Test wrapping methods for when wrapping is desired + assert_eq!(max.wrapping_add(one).as_inner(), 0); + assert_eq!(zero.wrapping_sub(one).as_inner(), u64::MAX); + + // Test normal range operations still work + let mid = QuoteLots::new(1000); + let small = QuoteLots::new(100); + assert_eq!((mid + small).as_inner(), 1100); + assert_eq!((mid - small).as_inner(), 900); + } + + #[test] + fn test_checked_constructors() { + // Test new_checked for bounded types + assert_eq!(BaseLots::new_checked(100), Ok(BaseLots::new(100))); + assert_eq!( + BaseLots::new_checked(u32::MAX as u64), + Ok(BaseLots::new(u32::MAX as u64)) + ); + assert!(BaseLots::new_checked(u64::MAX).is_err()); + + // Test new_saturating for bounded types + assert_eq!(BaseLots::new_saturating(100).as_inner(), 100); + assert_eq!( + BaseLots::new_saturating(u64::MAX).as_inner(), + u32::MAX as u64 + ); + assert_eq!(Ticks::new_saturating(u64::MAX).as_inner(), u32::MAX as u64); + + // Test that QuoteLots with u64::MAX bounds doesn't have the issue + assert_eq!( + QuoteLots::new_checked(u64::MAX), + Ok(QuoteLots::new(u64::MAX)) + ); + } + + #[test] + fn test_checked_div_by_types() { + let tick_size = QuoteLotsPerBaseLotPerTick::new(5); + let ticks = Ticks::new(20); + + // QuoteLotsPerBaseLotPerTick * Ticks = QuoteLotsPerBaseLot + let price = tick_size * ticks; + + // Test the auto-generated checked division methods + // The macro generates snake_case method names from the type names + assert_eq!( + price.checked_div_by_quote_lots_per_base_lot_per_tick(tick_size), + Some(ticks) + ); + assert_eq!(price.checked_div_by_ticks(ticks), Some(tick_size)); + + // Test division by zero + assert_eq!( + price.checked_div_by_quote_lots_per_base_lot_per_tick(QuoteLotsPerBaseLotPerTick::new( + 0 + )), + None + ); + assert_eq!(price.checked_div_by_ticks(Ticks::new(0)), None); + } + + #[test] + #[should_panic(expected = "Underflow in add operation")] + fn test_add_signed_panic_compatibility() { + // Test that the old Add implementation still panics for backward compatibility + let unsigned = QuoteLots::new(50); + let large_negative = SignedQuoteLots::new(-100); + let _ = unsigned + large_negative; // Should panic + } + + // Property tests for div_ceil correctness + proptest! { + /// Property: div_ceil(a, b) should always be >= regular division + #[test] + fn test_div_ceil_always_gte_regular_division(a in 1u64..=u64::MAX/2, b in 1u64..=u64::MAX/2) { + let regular_div = a / b; + let ceil_div = a.div_ceil(b); + + prop_assert!(ceil_div >= regular_div, + "div_ceil({}, {}) = {} should be >= regular division = {}", + a, b, ceil_div, regular_div); + } + + /// Property: div_ceil(a, b) should be exactly regular division when a % b == 0 + #[test] + fn test_div_ceil_exact_when_no_remainder(a in 1u64..=1_000_000u64, b in 1u64..=1_000_000u64) { + if a % b == 0 { + let regular_div = a / b; + let ceil_div = a.div_ceil(b); + + prop_assert_eq!(ceil_div, regular_div, + "When {} % {} == 0, div_ceil should equal regular division", a, b); + } + } + + /// Property: div_ceil(a, b) should be regular division + 1 when a % b != 0 + #[test] + fn test_div_ceil_plus_one_when_remainder(a in 1u64..=1_000_000u64, b in 1u64..=1_000_000u64) { + if a % b != 0 { + let regular_div = a / b; + let ceil_div = a.div_ceil(b); + + prop_assert_eq!(ceil_div, regular_div + 1, + "When {} % {} != 0, div_ceil should be regular division + 1", a, b); + } + } + + /// Property: div_ceil(a, 1) should always equal a + #[test] + fn test_div_ceil_by_one_identity(a in 0u64..=u64::MAX) { + let result = a.div_ceil(1); + prop_assert_eq!(result, a, "div_ceil({}, 1) should equal {}", a, a); + } + + /// Property: div_ceil(0, b) should always be 0 + #[test] + fn test_div_ceil_zero_numerator(b in 1u64..=u64::MAX) { + let result = 0u64.div_ceil(b); + prop_assert_eq!(result, 0, "div_ceil(0, {}) should be 0", b); + } + + /// Property: For any a <= b, div_ceil(a, b) should be 0 or 1 + #[test] + fn test_div_ceil_small_numerator(a in 1u64..=1_000_000u64, b in 1u64..=1_000_000u64) { + if a <= b { + let result = a.div_ceil(b); + prop_assert!(result <= 1, + "When {} <= {}, div_ceil should be 0 or 1, got {}", a, b, result); + + // Should be 1 unless a == 0 + if a > 0 { + prop_assert_eq!(result, 1); + } + } + } + + /// Property: div_ceil should be consistent with mathematical definition + /// ceil(a/b) = floor((a + b - 1) / b) for positive integers + #[test] + fn test_div_ceil_mathematical_definition(a in 1u64..=u64::MAX/2, b in 1u64..=1_000_000u64) { + let ceil_div = a.div_ceil(b); + + // Alternative formula: (a + b - 1) / b + // But we need to check for overflow + if let Some(sum) = a.checked_add(b - 1) { + let alternative = sum / b; + prop_assert_eq!(ceil_div, alternative, + "div_ceil({}, {}) = {} should match alternative formula = {}", + a, b, ceil_div, alternative); + } + } + } + + // Regression test for the margin calculation bug + #[test] + fn test_margin_calculation_regression() { + // This test captures the exact scenario from the failing SDK test + let book_value = BaseLots::new(1_000_000_000); // $1000 in base lots + let leverage = Constant::new(1); + + // The correct margin should be exactly 1_000_000_000 + let margin = book_value.div_ceil(leverage); + assert_eq!( + margin.as_inner(), + 1_000_000_000, + "Margin for $1000 with leverage 1 should be exactly $1000, not $1000.000001" + ); + + // With the old buggy implementation, this would have been 1_000_000_001 + // which would make a trader with exactly $1000 collateral fail the + // margin check + } + + // Test the behavior difference between old and new implementations + #[test] + fn test_old_vs_new_div_ceil_behavior() { + // Old (buggy) implementation: a / b + 1 + let old_div_ceil = |a: u64, b: u64| -> u64 { a / b + 1 }; + + // New (correct) implementation + let new_div_ceil = |a: u64, b: u64| -> u64 { a.div_ceil(b) }; + + // Test exact divisions - these should NOT have +1 + assert_ne!( + old_div_ceil(10, 5), + new_div_ceil(10, 5), + "Old implementation incorrectly adds 1 for exact divisions" + ); + assert_eq!(new_div_ceil(10, 5), 2, "Correct result for 10/5"); + assert_eq!(old_div_ceil(10, 5), 3, "Old buggy result for 10/5"); + + // Test divisions with remainder - these should be the same + assert_eq!( + old_div_ceil(11, 5), + new_div_ceil(11, 5), + "Both should round up for divisions with remainder" + ); + assert_eq!(new_div_ceil(11, 5), 3, "Correct result for 11/5"); + } + + #[test] + fn test_signed_fee_rate_overflow_safety() { + use crate::quantities::types::{ + FeeRateMicro, QuoteLots, SignedFeeRateMicro, SignedQuoteLots, + }; + + // Test add_unsigned_checked with large unsigned value + let signed_fee = SignedFeeRateMicro::new(100); + let unsigned_fee = FeeRateMicro::new(u32::MAX); + + // Should return None because u64::MAX can't be converted to i64 + assert!(signed_fee.add_unsigned_checked(unsigned_fee).is_none()); + + // Test with value that fits in i64 but would overflow when added + let unsigned_fee_large = FeeRateMicro::new(i32::MAX as u32); + assert!( + signed_fee + .add_unsigned_checked(unsigned_fee_large) + .is_none(), + "Should fail due to addition overflow" + ); + + // Test with value that fits and doesn't overflow + let unsigned_fee_small = FeeRateMicro::new(1000); + assert!( + signed_fee + .add_unsigned_checked(unsigned_fee_small) + .is_some() + ); + assert_eq!( + signed_fee + .add_unsigned_checked(unsigned_fee_small) + .unwrap() + .as_inner(), + 1100 + ); + + // Test QuoteLots::saturating_add_signed with i64::MIN + let signed_lots = SignedQuoteLots::new(i64::MIN); + let base = QuoteLots::new(1000); + + // Should handle i64::MIN correctly using unsigned_abs() + let result = base.saturating_add_signed(signed_lots); + // i64::MIN has absolute value of 2^63, which will saturate when subtracting + // from 1000 + assert_eq!(result, QuoteLots::new(0)); + + // Test with normal negative value + let signed_lots_normal = SignedQuoteLots::new(-500); + let result_normal = base.saturating_add_signed(signed_lots_normal); + assert_eq!(result_normal, QuoteLots::new(500)); + } + + #[test] + fn test_quote_lots_checked_as_signed_bounds() { + let at_max = QuoteLots::new(i64::MAX as u64); + assert_eq!( + at_max.checked_as_signed(), + Ok(SignedQuoteLots::new(i64::MAX)) + ); + + let overflow = QuoteLots::new(i64::MAX as u64 + 1); + assert_eq!(overflow.checked_as_signed(), Err(MathError::Overflow)); + } + + #[test] + fn test_signed_quote_lots_checked_as_unsigned() { + let positive = SignedQuoteLots::new(10); + assert_eq!(positive.checked_as_unsigned(), Ok(QuoteLots::new(10))); + + let negative = SignedQuoteLots::new(-1); + assert_eq!(negative.checked_as_unsigned(), Err(MathError::Underflow)); + } + + #[test] + fn test_signed_quote_lots_abs_as_unsigned() { + let value = SignedQuoteLots::new(-10); + assert_eq!(value.abs_as_unsigned(), QuoteLots::new(10)); + } + + #[test] + fn test_signed_quote_lots_abs_as_unsigned_min_overflow() { + let min_value = SignedQuoteLots::new(i64::MIN); + assert_eq!( + min_value.abs_as_unsigned(), + QuoteLots::new(i64::MIN.unsigned_abs()) + ); + } + + #[test] + #[should_panic(expected = "Overflow in abs for signed 64-bit wrapper")] + fn test_signed_quote_lots_abs_panics_on_min() { + let _ = SignedQuoteLots::new(i64::MIN).abs(); + } + + #[test] + #[should_panic(expected = "Overflow in neg for signed 64-bit wrapper")] + fn test_signed_quote_lots_neg_panics_on_min() { + let _ = -SignedQuoteLots::new(i64::MIN); + } + + #[test] + #[should_panic(expected = "Overflow in abs for signed 32-bit wrapper")] + fn test_signed_fee_rate_abs_panics_on_min() { + let _ = SignedFeeRateMicro::new(i32::MIN).abs(); + } + + #[test] + #[should_panic(expected = "Overflow in neg for signed 32-bit wrapper")] + fn test_signed_fee_rate_neg_panics_on_min() { + let _ = -SignedFeeRateMicro::new(i32::MIN); + } + + #[test] + #[should_panic(expected = "Overflow in add: result exceeds signed bounds")] + fn test_signed_quote_lots_add_unsigned_panics_on_overflow() { + let signed = SignedQuoteLots::new(i64::MAX); + let _ = signed + QuoteLots::new(1); + } + + #[test] + #[should_panic(expected = "Overflow in sub: result exceeds signed bounds")] + fn test_signed_quote_lots_sub_unsigned_panics_on_underflow() { + let signed = SignedQuoteLots::new(i64::MIN); + let _ = signed - QuoteLots::new(1); + } + + #[test] + fn test_ticks_checked_as_signed_bounds() { + let in_range = Ticks::new(i32::MAX as u64); + assert!(in_range.checked_as_signed().is_ok()); + + let overflow = Ticks::new(i32::MAX as u64 + 1); + assert_eq!(overflow.checked_as_signed(), Err(MathError::Overflow)); + } + + #[test] + fn test_base_lots_checked_as_signed_bounds() { + // BaseLots is bounded to u32::MAX, which always fits in i64 + let at_bound = BaseLots::new(u32::MAX as u64); + assert!(at_bound.checked_as_signed().is_ok()); + assert_eq!( + at_bound.checked_as_signed(), + Ok(SignedBaseLots::new(u32::MAX as i64)) + ); + + // Test that an artificially large value (beyond i64::MAX) would fail. + // This verifies the check exists even though valid BaseLots can't reach this. + let overflow = BaseLots::new(i64::MAX as u64 + 1); + assert_eq!(overflow.checked_as_signed(), Err(MathError::Overflow)); + } + + #[test] + fn test_signed_base_lots_checked_as_unsigned_bounds() { + // Value within BaseLots bounds (u32::MAX) should succeed + let in_range = SignedBaseLots::new(u32::MAX as i64); + assert_eq!( + in_range.checked_as_unsigned(), + Ok(BaseLots::new(u32::MAX as u64)) + ); + + // Value exceeding BaseLots::UPPER_BOUND (u32::MAX) should fail + let overflow = SignedBaseLots::new(u32::MAX as i64 + 1); + assert_eq!(overflow.checked_as_unsigned(), Err(MathError::Overflow)); + + // Large i64 value should also fail + let large_overflow = SignedBaseLots::new(i64::MAX); + assert_eq!( + large_overflow.checked_as_unsigned(), + Err(MathError::Overflow) + ); + + // Negative value should fail with Underflow + let negative = SignedBaseLots::new(-1); + assert_eq!(negative.checked_as_unsigned(), Err(MathError::Underflow)); + } +} diff --git a/container/vendor/rise/rust/math/src/quantities/traits.rs b/container/vendor/rise/rust/math/src/quantities/traits.rs new file mode 100644 index 00000000000..c48133a2896 --- /dev/null +++ b/container/vendor/rise/rust/math/src/quantities/traits.rs @@ -0,0 +1,85 @@ +//! Core traits for type-safe numeric wrappers. +//! +//! This module defines the fundamental traits that enable type-safe arithmetic +//! and bounds checking for numeric wrapper types. + +use std::ops::RangeInclusive; + +/// Generic trait for creating newtype wrappers around numeric types. +/// +/// This trait provides the foundation for type-safe arithmetic by wrapping +/// primitive numeric types (u64, i64, i128) in strongly-typed structs. +/// +/// # Type Safety +/// +/// By implementing this trait for different wrapper types, we ensure that +/// only compatible types can be used in arithmetic operations, preventing +/// dimensional errors at compile time. +/// +/// # Example +/// +/// ```ignore +/// struct Price { inner: u64 } +/// impl WrapperNum for Price { +/// type Inner = u64; +/// fn new(value: u64) -> Self { Price { inner: value } } +/// fn as_inner(&self) -> u64 { self.inner } +/// } +/// ``` +pub trait WrapperNum { + type Inner; + fn new(value: T) -> Self; + fn as_inner(&self) -> T; +} + +/// Trait for enforcing value bounds on numeric wrapper types. +/// +/// This trait enables compile-time and runtime bounds checking for types +/// that need to restrict their valid value ranges. This is crucial for +/// preventing overflow errors and ensuring data integrity. +/// +/// # Overflow Prevention +/// +/// By defining upper bounds smaller than the underlying type's maximum, +/// we can catch potential overflows before they occur: +/// +/// ```ignore +/// // BaseLots is limited to u32::MAX even though it wraps u64 +/// impl ScalarBounds for BaseLots { +/// const LOWER_BOUND: u64 = 0; +/// const UPPER_BOUND: u64 = u32::MAX as u64; +/// } +/// +/// let valid = BaseLots::new(1000); +/// assert!(valid.is_in_bounds()); +/// +/// let too_large = BaseLots::new(u64::MAX); +/// assert!(!too_large.is_in_bounds()); // Caught before overflow! +/// ``` +pub trait ScalarBounds: WrapperNum +where + Inner: PartialOrd, +{ + const LOWER_BOUND: Inner; + const UPPER_BOUND: Inner; + + /// Checks if the current value is within the defined bounds. + /// + /// This should be called after arithmetic operations to ensure + /// the result hasn't exceeded the type's valid range. + fn is_in_bounds(&self) -> bool { + self.as_inner() >= Self::LOWER_BOUND && self.as_inner() <= Self::UPPER_BOUND + } + + fn bounds() -> RangeInclusive { + Self::LOWER_BOUND..=Self::UPPER_BOUND + } + + fn lower_bound() -> Inner { + Self::LOWER_BOUND + } + + fn upper_bound() -> Inner { + Self::UPPER_BOUND + } +} diff --git a/container/vendor/rise/rust/math/src/quantities/types.rs b/container/vendor/rise/rust/math/src/quantities/types.rs new file mode 100644 index 00000000000..4831ca209ee --- /dev/null +++ b/container/vendor/rise/rust/math/src/quantities/types.rs @@ -0,0 +1,603 @@ +//! Concrete type definitions for type-safe quantity arithmetic. +//! +//! This module contains all the specific quantity types used throughout +//! the Phoenix exchange system. Each type is designed to prevent common +//! arithmetic errors and ensure dimensional correctness. +//! +//! # Type Categories +//! +//! - **Lots**: Smallest tradeable units (BaseLots, QuoteLots) +//! - **Units**: Human-readable quantities (BaseUnits, QuoteUnits) +//! - **Prices**: Price representations (Ticks, QuoteLotsPerBaseLot) +//! - **Conversion Factors**: For converting between units and lots +//! - **Risk Factors**: For risk management calculations +//! +//! # Overflow Safety +//! +//! Many types have restricted ranges to prevent overflow: +//! - `BaseLots`: Limited to u32::MAX to prevent overflow in calculations +//! - `Ticks`: Limited to u32::MAX for safe price arithmetic +//! - Risk factors: Limited to 0-10,000 (representing 0.0 to 1.0) + +use std::fmt::{Debug, Display}; +use std::iter::Sum; +use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use bytemuck::{Pod, Zeroable}; + +use crate::quantities::traits::{ScalarBounds, WrapperNum}; + +// Slot +basic_u64_struct!(Slot); + +// Generic numeric constants +basic_u64_struct!(Constant); +basic_i64_struct!(SignedConstant); + +// Fundamental quantities +basic_u64_struct_with_bounds!(QuoteLots, 0, u64::MAX); +basic_u64_struct_with_bounds!(BaseLots, 0, u32::MAX as u64); +basic_i64_struct!(SignedBaseLots); +basic_i64_struct!(SignedQuoteLots); + +impl core::convert::TryFrom for SignedQuoteLots { + type Error = crate::quantities::MathError; + + fn try_from(value: i128) -> Result { + i64::try_from(value) + .map(Self::new) + .map_err(|_| crate::quantities::MathError::Overflow) + } +} + +impl core::convert::TryFrom for SignedBaseLots { + type Error = crate::quantities::MathError; + + fn try_from(value: i128) -> Result { + i64::try_from(value) + .map(Self::new) + .map_err(|_| crate::quantities::MathError::Overflow) + } +} + +// Creates SignedBaseLotsUpcasted and SignedQuoteLotsUpcasted +basic_i128_struct!(SignedBaseLots); +basic_i128_struct!(SignedQuoteLots); + +allow_checked_add_64bit!(QuoteLots, SignedQuoteLots); +allow_add_64bit!(BaseLots, SignedBaseLots); + +allow_multiply!(Slot, QuoteLots, QuoteLots); + +impl QuoteLots { + /// Multiply by a Constant (generic u64 wrapper type like leverage) + /// Returns None on overflow + pub fn checked_mul>(self, other: Multiplier) -> Option { + self.inner.checked_mul(other.as_inner()).map(Self::new) + } +} + +// Intermediate conversions for extracting quote lots from book orders +basic_u64_struct_with_bounds!(QuoteLotsPerBaseLot, 0, u32::MAX as u64); +basic_i64_struct!(SignedQuoteLotsBaseLots); +basic_i128_struct!(SignedQuoteLotsBaseLots); + +basic_i64_struct!(SignedQuoteLotsPerBaseLot); +basic_i128_struct!(SignedQuoteLotsPerBaseLot); +allow_add_64bit!(QuoteLotsPerBaseLot, SignedQuoteLotsPerBaseLot); + +impl QuoteLotsPerBaseLot { + /// Multiply by another value with overflow check + /// Returns None on overflow + pub fn checked_mul>(self, rhs: Multiplier) -> Option { + self.inner.checked_mul(rhs.as_inner()).map(Self::new) + } +} + +impl SignedQuoteLotsPerBaseLot { + pub fn checked_mul(self, rhs: Self) -> Option { + self.inner.checked_mul(rhs.inner).map(Self::new) + } + + pub fn clamp(self, min: Self, max: Self) -> Self { + Self::new(self.inner.clamp(min.inner, max.inner)) + } +} + +impl SignedQuoteLotsPerBaseLotUpcasted { + pub fn checked_mul(self, rhs: Self) -> Option { + self.inner.checked_mul(rhs.inner).map(Self::new) + } + + pub fn clamp(self, min: Self, max: Self) -> Self { + Self::new(self.inner.clamp(min.inner, max.inner)) + } +} + +allow_multiply!(SignedQuoteLotsPerBaseLot, SignedBaseLots, SignedQuoteLots); + +allow_multiply!( + SignedBaseLotsUpcasted, + SignedQuoteLotsUpcasted, + SignedQuoteLotsBaseLotsUpcasted +); + +allow_multiply!(SignedBaseLots, SignedQuoteLots, SignedQuoteLotsBaseLots); + +// Discrete price unit (quote quantity per base quantity) +basic_u64_struct_with_bounds!(Ticks, 0, u32::MAX as u64); +basic_i64_struct_with_bounds!(SignedTicks, -i32::MAX as i64, i32::MAX as i64); +allow_checked_add_32bit!(Ticks, SignedTicks); + +impl Ticks { + /// Multiply by another value with overflow check + /// Returns None on overflow + pub fn checked_mul>(self, rhs: Multiplier) -> Option { + self.inner.checked_mul(rhs.as_inner()).map(Self::new) + } +} + +// Quantities +basic_u64_struct!(QuoteUnits); +basic_u64_struct!(BaseUnits); + +// Dimensionless conversion factors +basic_u64_struct!(BaseLotsPerBaseUnit); +basic_u64_struct!(QuoteLotsPerQuoteUnit); + +// Dimensionless tick sizes +basic_u64_struct_with_bounds!(QuoteLotsPerBaseLotPerTick, 0, 10_000); + +// Basis points for general percentage calculations (0.0 to 1.0, where 10_000 = +// 100%) +basic_u64_struct_with_bounds!(BasisPoints, 0, 10_000); + +// Risk factor (discount) for positive uPnL (0.0 to 1.0) +// This is just an alias to BasisPoints since they're semantically identical. +pub type UPnlRiskFactor = BasisPoints; + +// Fee rates in micros (1/1,000,000). For example, 2500 = 0.25% fee +basic_u32_struct_with_bounds!(FeeRateMicro, 0, i32::MAX as u32); +basic_i32_struct!(SignedFeeRateMicro); + +impl BasisPoints { + /// The denominator for basis points (10,000 = 100%) + pub const DENOMINATOR: u64 = 10_000; + + /// Apply basis points to a QuoteLots value, returning the result + /// For example: 5000 basis points (50%) of 1000 QuoteLots = 500 QuoteLots + pub fn apply_to_quote_lots(&self, value: QuoteLots) -> Option { + let result = value + .as_inner() + .checked_mul(self.as_inner())? + .checked_div(Self::DENOMINATOR)?; + Some(QuoteLots::new(result)) + } + + /// Apply basis points to a QuoteLots value with ceiling division + /// For example: 5001 basis points of 1000 QuoteLots = 501 QuoteLots (rounds + /// up) + pub fn apply_to_quote_lots_ceil(&self, value: QuoteLots) -> Option { + // Try checked arithmetic first + if let Some(numerator) = value.as_inner().checked_mul(self.as_inner()) { + if let Some(result) = numerator.checked_add(Self::DENOMINATOR - 1) { + return Some(QuoteLots::new(result / Self::DENOMINATOR)); + } + } + + // Upcast to u128 for intermediate calculations + let numerator = value.as_inner() as u128 * self.as_inner() as u128; + let result = numerator.div_ceil(Self::DENOMINATOR as u128); + + // Try to downcast back to u64 + u64::try_from(result).ok().map(QuoteLots::new) + } + + /// Apply basis points to a Ticks value + pub fn apply_to_ticks(&self, value: Ticks) -> Option { + value + .as_inner() + .checked_mul(self.as_inner())? + .checked_div(Self::DENOMINATOR) + .map(Ticks::new) + } + + /// Create from a u16 value (common for risk factors stored as u16) + pub fn from_u16(value: u16) -> Option { + if value <= Self::UPPER_BOUND as u16 { + Some(Self::new(value as u64)) + } else { + None + } + } + + pub fn to_u16(&self) -> u16 { + if self.as_inner() > Self::UPPER_BOUND as u64 { + Self::UPPER_BOUND as u16 + } else { + self.as_inner() as u16 + } + } +} + +// Divisor for micro units (1,000,000) +basic_u64_struct_with_bounds!(MicroDivisor, 1_000_000, 1_000_000); + +// Funding rate unit in seconds +basic_u64_struct!(FundingRateUnitInSeconds); + +impl FundingRateUnitInSeconds { + pub fn checked_mul(self, rhs: Self) -> Option { + self.inner.checked_mul(rhs.inner).map(Self::new) + } +} + +impl MicroDivisor { + /// The standard micro divisor constant + pub const MICRO: Self = Self { inner: 1_000_000 }; +} + +impl FeeRateMicro { + pub fn to_signed_fee_rate_micro(self) -> SignedFeeRateMicro { + SignedFeeRateMicro::new(self.as_inner() as i32) + } + + /// Apply fee rate to quote lots, rounding up (ceiling division) + /// Returns the fee amount in quote lots + pub fn apply_to_quote_lots(self, quote_lots: QuoteLots) -> Option { + let fee = quote_lots + .as_inner() + .checked_mul(self.as_u64())? + .div_ceil(MicroDivisor::MICRO.as_inner()); + Some(QuoteLots::new(fee)) + } + + /// Apply fee rate using saturating arithmetic + pub fn apply_to_quote_lots_saturating(self, quote_lots: QuoteLots) -> QuoteLots { + let fee = quote_lots + .as_inner() + .saturating_mul(self.as_u64()) + .div_ceil(MicroDivisor::MICRO.as_inner()); + QuoteLots::new(fee) + } + + /// Adjust quote budget for fees (for buy orders) + pub fn adjust_quote_budget(self, quote_lot_budget: QuoteLots) -> QuoteLots { + let divisor = MicroDivisor::MICRO; + let fee_adjusted_budget = quote_lot_budget + .as_inner() + .saturating_mul(divisor.as_inner()) + .saturating_div(divisor.as_inner().saturating_add(self.as_u64())); + QuoteLots::new(fee_adjusted_budget) + } +} + +impl SignedFeeRateMicro { + pub fn from_i8_bps(bps: i8) -> Self { + Self::new(bps as i32 * 100) + } + + pub fn to_unsigned_fee_rate_micro(self) -> FeeRateMicro { + FeeRateMicro::new(self.as_inner() as u32) + } + + /// Apply signed fee rate to quote lots + /// For positive fees: rounds up (div_ceil) + /// For negative fees (rebates): rounds down (regular division) + pub fn apply_to_quote_lots(self, quote_lots: QuoteLots) -> Option { + let divisor_i64 = MicroDivisor::MICRO.as_inner() as i64; + let size_i64 = quote_lots.as_inner() as i64; + let product = size_i64.checked_mul(self.as_i64())?; + + let fee = if self.as_inner() >= 0 { + // Positive fee - round up + (product as u64).div_ceil(MicroDivisor::MICRO.as_inner()) as i64 + } else { + // Negative fee (rebate) - round down + product / divisor_i64 + }; + + Some(SignedQuoteLots::new(fee)) + } + + /// Add an unsigned fee rate to this signed fee rate, checking for negative + /// total + pub fn add_unsigned_checked(self, unsigned: FeeRateMicro) -> Option { + let unsigned_i32 = i32::try_from(unsigned.as_inner()).ok()?; + let total = self.as_inner().checked_add(unsigned_i32)?; + if total < 0 { + None + } else { + Some(SignedFeeRateMicro::new(total)) + } + } + + /// Multiply by a signed scalar, returning None on overflow + pub fn checked_mul_i32(self, scalar: i32) -> Option { + let product = (self.as_inner() as i64).checked_mul(scalar as i64)?; + i32::try_from(product).ok().map(SignedFeeRateMicro::new) + } + + /// Divide by a signed scalar, returning None on division by zero or + /// overflow + pub fn checked_div_i32(self, scalar: i32) -> Option { + if scalar == 0 { + return None; + } + let quotient = (self.as_inner() as i64).checked_div(scalar as i64)?; + i32::try_from(quotient).ok().map(SignedFeeRateMicro::new) + } +} + +// Conversions from units to lots +allow_multiply!(BaseUnits, BaseLotsPerBaseUnit, BaseLots); +allow_multiply!(QuoteUnits, QuoteLotsPerQuoteUnit, QuoteLots); + +// Conversion between units of tick size +allow_multiply!(QuoteLotsPerBaseLotPerTick, Ticks, QuoteLotsPerBaseLot); + +// Intermediate conversions for extracting quote lots from book orders +allow_multiply!(QuoteLotsPerBaseLot, BaseLots, QuoteLots); + +// Density of liquidity per tick in DMM +basic_u64_struct!(BaseLotsPerTick); + +allow_multiply!(BaseLotsPerTick, Ticks, BaseLots); + +#[repr(transparent)] +#[derive( + Pod, Zeroable, Debug, Default, Copy, Clone, PartialEq, BorshDeserialize, BorshSerialize, Eq, +)] +pub struct SequenceNumberU8(u8); + +impl SequenceNumberU8 { + pub fn from(value: u8) -> Self { + Self(value) + } + + pub fn as_u8(&self) -> u8 { + self.0 + } +} + +/// Error type for SignedQuoteLotsI56 conversions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignedQuoteLotsI56Error { + /// Value does not fit in 56-bit signed integer (exceeds ±2^55) + Overflow, +} + +impl core::fmt::Display for SignedQuoteLotsI56Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + SignedQuoteLotsI56Error::Overflow => { + write!(f, "Value does not fit in 56-bit signed integer") + } + } + } +} + +#[repr(C)] +#[derive( + Pod, Zeroable, Debug, Default, Copy, Clone, PartialEq, BorshDeserialize, BorshSerialize, Eq, +)] +pub struct SignedQuoteLotsI56 { + data: [u8; 7], +} + +impl SignedQuoteLotsI56 { + /// Convenience method: converts to SignedQuoteLots + pub fn to_signed_quote_lots(&self) -> SignedQuoteLots { + (*self).into() + } + + pub fn checked_add(self, quote_lots: SignedQuoteLots) -> Option { + let temp: SignedQuoteLots = self.into(); + let sum = temp.checked_add(quote_lots)?; + Self::try_from(sum).ok() + } + + pub fn clear(&mut self) { + self.data = [0; 7]; + } +} + +// Infallible conversion: i56 -> i64 (always safe) +impl From for SignedQuoteLots { + fn from(value: SignedQuoteLotsI56) -> Self { + let mut temp: [u8; 8] = [0; 8]; + temp[..7].copy_from_slice(&value.data); + // For signed little-endian, extended bytes should match MSB + temp[7] = if value.data[6] >> 7 > 0 { u8::MAX } else { 0 }; + SignedQuoteLots::new(i64::from_le_bytes(temp)) + } +} + +// Fallible conversion: i64 -> i56 (can overflow) +impl TryFrom for SignedQuoteLotsI56 { + type Error = SignedQuoteLotsI56Error; + + fn try_from(quote_lots: SignedQuoteLots) -> Result { + let raw_i64 = quote_lots.as_inner(); + // Check if value fits in 56 bits (±2^55) + if raw_i64.abs() >= (1 << 55) { + return Err(SignedQuoteLotsI56Error::Overflow); + } + let temp: [u8; 8] = raw_i64.to_le_bytes(); + Ok(Self { + data: [ + temp[0], temp[1], temp[2], temp[3], temp[4], temp[5], temp[6], + ], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_conversions_and_roundtrip() { + // Zero and default + let zero: SignedQuoteLotsI56 = Default::default(); + assert_eq!(zero.to_signed_quote_lots(), SignedQuoteLots::ZERO); + + // Comprehensive roundtrip testing including boundaries + let test_values = [ + 0, + 1, + -1, + 100, + -100, + 1000, + -1000, + 1_000_000, + -1_000_000, + 1_000_000_000, + -1_000_000_000, + 1_000_000_000_000, + -1_000_000_000_000, + (1i64 << 54), + -(1i64 << 54), + // Exact boundaries (max safe values) + (1i64 << 55) - 1, + -((1i64 << 55) - 1), + // Just within boundaries + (1i64 << 55) - 2, + -((1i64 << 55) - 2), + ]; + for &value in &test_values { + let quote_lots = SignedQuoteLots::new(value); + let i56: SignedQuoteLotsI56 = quote_lots.try_into().unwrap(); + assert_eq!( + i56.to_signed_quote_lots(), + quote_lots, + "Roundtrip failed for {}", + value + ); + } + + // Sign extension for negative values + let neg: SignedQuoteLotsI56 = SignedQuoteLots::new(-1).try_into().unwrap(); + assert_eq!(neg.to_signed_quote_lots().as_inner(), -1); + + let neg_pattern: SignedQuoteLotsI56 = SignedQuoteLots::new(-12345678).try_into().unwrap(); + assert_eq!( + neg_pattern.to_signed_quote_lots(), + SignedQuoteLots::new(-12345678) + ); + + // Reset + let mut i56: SignedQuoteLotsI56 = SignedQuoteLots::new(100).try_into().unwrap(); + i56.clear(); + assert_eq!(i56.to_signed_quote_lots(), SignedQuoteLots::ZERO); + } + + #[test] + fn test_pod_compatibility() { + let i56: SignedQuoteLotsI56 = Default::default(); + let bytes = bytemuck::bytes_of(&i56); + assert_eq!(bytes.len(), 7); + + let i56_from_bytes: &SignedQuoteLotsI56 = bytemuck::from_bytes(bytes); + assert_eq!(i56_from_bytes.to_signed_quote_lots(), SignedQuoteLots::ZERO); + } + + #[test] + #[should_panic(expected = "Overflow")] + fn test_overflow_positive_panic() { + let _: SignedQuoteLotsI56 = SignedQuoteLots::new(1i64 << 55).try_into().unwrap(); + } + + #[test] + #[should_panic(expected = "Overflow")] + fn test_overflow_negative_panic() { + let _: SignedQuoteLotsI56 = SignedQuoteLots::new(-(1i64 << 55)).try_into().unwrap(); + } + + #[test] + fn test_checked_conversions() { + // Valid conversions + assert!(SignedQuoteLotsI56::try_from(SignedQuoteLots::new(1000)).is_ok()); + assert!(SignedQuoteLotsI56::try_from(SignedQuoteLots::new((1i64 << 55) - 1)).is_ok()); + + // Overflow at boundaries + assert!(SignedQuoteLotsI56::try_from(SignedQuoteLots::new(1i64 << 55)).is_err()); + assert!(SignedQuoteLotsI56::try_from(SignedQuoteLots::new(-(1i64 << 55))).is_err()); + } + + #[test] + fn test_checked_add() { + // Basic addition + let a: SignedQuoteLotsI56 = SignedQuoteLots::new(100).try_into().unwrap(); + assert_eq!( + a.checked_add(SignedQuoteLots::new(200)) + .unwrap() + .to_signed_quote_lots(), + SignedQuoteLots::new(300) + ); + assert_eq!( + a.checked_add(SignedQuoteLots::new(-50)) + .unwrap() + .to_signed_quote_lots(), + SignedQuoteLots::new(50) + ); + + // Large values that fit + let large: SignedQuoteLotsI56 = SignedQuoteLots::new(1i64 << 54).try_into().unwrap(); + assert!( + large + .checked_add(SignedQuoteLots::new(1i64 << 53)) + .is_some() + ); + + // Boundary overflow - positive + let near_max: SignedQuoteLotsI56 = + SignedQuoteLots::new((1i64 << 55) - 2).try_into().unwrap(); + assert!( + near_max.checked_add(SignedQuoteLots::new(10)).is_none(), + "Should overflow at positive boundary" + ); + + // Boundary overflow - negative + let near_min: SignedQuoteLotsI56 = SignedQuoteLots::new(-((1i64 << 55) - 2)) + .try_into() + .unwrap(); + assert!( + near_min.checked_add(SignedQuoteLots::new(-10)).is_none(), + "Should overflow at negative boundary" + ); + } + + #[test] + fn test_trait_conversions() { + // Test From trait (infallible i56 -> i64) + let i56: SignedQuoteLotsI56 = SignedQuoteLots::new(12345).try_into().unwrap(); + let i64_result: SignedQuoteLots = i56.into(); + assert_eq!(i64_result, SignedQuoteLots::new(12345)); + + // Test TryFrom trait (fallible i64 -> i56) + let quote_lots = SignedQuoteLots::new(67890); + let i56_result: Result = quote_lots.try_into(); + assert!(i56_result.is_ok()); + assert_eq!(i56_result.unwrap().to_signed_quote_lots(), quote_lots); + + // Test TryFrom with overflow + let overflow_value = SignedQuoteLots::new(1i64 << 55); + let overflow_result: Result = overflow_value.try_into(); + assert!(overflow_result.is_err()); + assert_eq!( + overflow_result.unwrap_err(), + SignedQuoteLotsI56Error::Overflow + ); + + // Test turbofish syntax + let i56_turbofish = SignedQuoteLotsI56::try_from(SignedQuoteLots::new(-999)); + assert!(i56_turbofish.is_ok()); + assert_eq!( + i56_turbofish.unwrap().to_signed_quote_lots(), + SignedQuoteLots::new(-999) + ); + } +} diff --git a/container/vendor/rise/rust/math/src/risk.rs b/container/vendor/rise/rust/math/src/risk.rs new file mode 100644 index 00000000000..61f0f4c26c5 --- /dev/null +++ b/container/vendor/rise/rust/math/src/risk.rs @@ -0,0 +1,205 @@ +//! Risk assessment types and margin state +//! +//! This module provides types for risk management, margin calculations, +//! and risk tier assessment. + +use thiserror::Error; + +use crate::quantities::{MathError, QuoteLots, SignedQuoteLots, Slot}; + +/// Reasons the program may fail. +#[derive(Clone, Debug, Eq, PartialEq, Error)] +pub enum ProgramError { + #[error("Custom program error: {0:#x}")] + Custom(u32), + #[error("Invalid argument")] + InvalidArgument, + #[error("Invalid instruction data")] + InvalidInstructionData, + #[error("Invalid account data")] + InvalidAccountData, + #[error("Account data too small")] + AccountDataTooSmall, + #[error("Insufficient funds")] + InsufficientFunds, + #[error("Incorrect program id")] + IncorrectProgramId, + #[error("Missing required signature")] + MissingRequiredSignature, + #[error("Account already initialized")] + AccountAlreadyInitialized, + #[error("Uninitialized account")] + UninitializedAccount, + #[error("Not enough account keys")] + NotEnoughAccountKeys, + #[error("Account borrow failed")] + AccountBorrowFailed, + #[error("Max seed length exceeded")] + MaxSeedLengthExceeded, + #[error("Invalid seeds")] + InvalidSeeds, + #[error("Borsh IO error")] + BorshIoError, + #[error("Account not rent exempt")] + AccountNotRentExempt, + #[error("Unsupported sysvar")] + UnsupportedSysvar, + #[error("Illegal owner")] + IllegalOwner, + #[error("Max accounts data allocations exceeded")] + MaxAccountsDataAllocationsExceeded, + #[error("Invalid realloc")] + InvalidRealloc, + #[error("Max instruction trace length exceeded")] + MaxInstructionTraceLengthExceeded, + #[error("Builtin programs must consume compute units")] + BuiltinProgramsMustConsumeComputeUnits, + #[error("Invalid account owner")] + InvalidAccountOwner, + #[error("Arithmetic overflow")] + ArithmeticOverflow, + #[error("Account is immutable")] + Immutable, + #[error("Incorrect authority")] + IncorrectAuthority, +} + +/// Errors that can occur during margin calculations +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MarginError { + #[error("Insufficient funds")] + InsufficientFunds, + #[error("Invalid state change")] + InvalidStateChange, + #[error("Overflow")] + Overflow, + #[error("Math error: {0:?}")] + Math( + #[source] + #[from] + MathError, + ), + #[error("Mark price error: {0:?}")] + MarkPrice(ProgramError), +} + +/// Risk action context for margin calculations +/// +/// Specifies what action is being performed, which affects +/// price validity requirements and margin calculations. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum RiskAction { + /// View-only (no state changes) + View, + /// Liquidation action + Liquidation { current_slot: Slot }, + /// Placing an order + PlacingOrder { current_slot: Slot }, + /// Funding payment + Funding { current_slot: Slot }, + /// Withdrawal attempt + Withdrawal { current_slot: Slot }, + /// Auto-deleveraging + ADL { current_slot: Slot }, +} + +impl RiskAction { + /// Get the current slot for this risk action + #[inline] + pub const fn current_slot(&self) -> Slot { + match self { + Self::View => Slot::ZERO, + Self::Liquidation { current_slot } => *current_slot, + Self::PlacingOrder { current_slot } => *current_slot, + Self::Funding { current_slot } => *current_slot, + Self::Withdrawal { current_slot } => *current_slot, + Self::ADL { current_slot } => *current_slot, + } + } + + /// Get the index for this risk action type + #[inline] + pub const fn as_index(&self) -> usize { + match self { + Self::View => 0, + Self::Liquidation { .. } => 1, + Self::PlacingOrder { .. } => 2, + Self::Funding { .. } => 3, + Self::Withdrawal { .. } => 4, + Self::ADL { .. } => 5, + } + } +} + +/// Risk state of a trader based on collateral and margin requirements +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum RiskState { + /// Healthy: effective_collateral >= initial_margin + Healthy, + /// Unhealthy: effective_collateral < initial_margin (but > 0) + Unhealthy, + /// Underwater: effective_collateral <= 0 + Underwater, + /// Zero collateral with no positions + ZeroCollateralNoPositions, +} + +/// Risk tier determines liquidation priority and borrowing limits +/// +/// Higher risk tiers have stricter requirements and higher liquidation +/// priority. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum RiskTier { + /// Safe: effective_collateral >= initial_margin (100%) + Safe = 0, + /// AtRisk: effective_collateral >= initial_margin but close to threshold + AtRisk = 1, + /// Cancellable: Orders can be force-cancelled + Cancellable = 2, + /// Liquidatable: Below maintenance margin, subject to liquidation + Liquidatable = 3, + /// BackstopLiquidatable: Below backstop margin + BackstopLiquidatable = 4, + /// HighRisk: Below high risk margin, insurance fund territory + HighRisk = 5, +} + +/// Margin state combining collateral and margin requirements +/// +/// Used to determine if a trader can perform actions like withdrawals, +/// placing orders, etc. +#[derive(Debug, Clone)] +pub struct MarginState { + pub initial_margin: QuoteLots, + pub effective_collateral: SignedQuoteLots, +} + +impl MarginState { + /// Create a new margin state + pub fn new(initial_margin: QuoteLots, effective_collateral: SignedQuoteLots) -> Self { + Self { + initial_margin, + effective_collateral, + } + } + + /// Calculate risk state from margin and collateral values + pub fn risk_state(&self) -> Result { + let collateral = self.effective_collateral; + let initial_margin = self.initial_margin.checked_as_signed()?; + + if collateral < SignedQuoteLots::ZERO { + Ok(RiskState::Underwater) + } else if collateral == SignedQuoteLots::ZERO { + if self.initial_margin == QuoteLots::ZERO { + Ok(RiskState::ZeroCollateralNoPositions) + } else { + Ok(RiskState::Underwater) + } + } else if collateral >= initial_margin { + Ok(RiskState::Healthy) + } else { + Ok(RiskState::Unhealthy) + } + } +} diff --git a/container/vendor/rise/rust/math/src/trader_position.rs b/container/vendor/rise/rust/math/src/trader_position.rs new file mode 100644 index 00000000000..d8b60940a9c --- /dev/null +++ b/container/vendor/rise/rust/math/src/trader_position.rs @@ -0,0 +1,80 @@ +//! TraderPosition type for margin calculations +//! +//! This module provides the TraderPosition struct which represents a trader's +//! position in a perp market, tracking base lots, quote lots, and funding +//! snapshots. + +use borsh::{BorshDeserialize, BorshSerialize}; +use bytemuck::{Pod, Zeroable}; + +use crate::quantities::{ + BaseLots, QuoteLotsPerBaseLot, SequenceNumberU8, SignedBaseLots, SignedQuoteLots, + SignedQuoteLotsI56, SignedQuoteLotsPerBaseLot, +}; + +/// Represents a trader's position in a perp market +/// +/// A position tracks: +/// - Base lot position (positive = long, negative = short) +/// - Virtual quote lot position (average entry cost) +/// - Cumulative funding snapshot (for funding rate calculations) +/// - Position sequence number (for tracking position flips) +/// - Accumulated funding for active position +#[repr(C)] +#[derive( + Pod, Zeroable, Debug, Default, Copy, Clone, PartialEq, BorshDeserialize, BorshSerialize, Eq, +)] +pub struct TraderPosition { + pub base_lot_position: SignedBaseLots, + pub virtual_quote_lot_position: SignedQuoteLots, + /// The cumulative funding snapshot for the position. + pub cumulative_funding_snapshot: SignedQuoteLotsPerBaseLot, + pub position_sequence_number: SequenceNumberU8, + pub accumulated_funding_for_active_position: SignedQuoteLotsI56, +} + +impl TraderPosition { + /// Create a new empty position + pub fn new() -> Self { + Self { + base_lot_position: SignedBaseLots::ZERO, + virtual_quote_lot_position: SignedQuoteLots::ZERO, + cumulative_funding_snapshot: SignedQuoteLotsPerBaseLot::ZERO, + position_sequence_number: SequenceNumberU8::default(), + accumulated_funding_for_active_position: SignedQuoteLotsI56::default(), + } + } + + /// Get the effective entry price for this position + /// + /// Returns None if there is no position + pub fn effective_entry_price(&self) -> Option { + if self.base_lot_position == SignedBaseLots::ZERO { + None + } else { + self.virtual_quote_lot_position + .abs_as_unsigned() + .checked_div_by_base_lots(self.base_lot_position.abs_as_unsigned()) + } + } + + /// Check if the position is long (positive base lots) + pub fn is_long(&self) -> bool { + self.base_lot_position > SignedBaseLots::ZERO + } + + /// Check if the position is short (negative base lots) + pub fn is_short(&self) -> bool { + self.base_lot_position < SignedBaseLots::ZERO + } + + /// Check if there is no position + pub fn is_neutral(&self) -> bool { + self.base_lot_position == SignedBaseLots::ZERO + } + + /// Get the absolute size of the position in base lots + pub fn abs_size(&self) -> BaseLots { + self.base_lot_position.abs_as_unsigned() + } +} diff --git a/container/vendor/rise/rust/sdk/AGENTS.md b/container/vendor/rise/rust/sdk/AGENTS.md new file mode 100644 index 00000000000..8216e74a09d --- /dev/null +++ b/container/vendor/rise/rust/sdk/AGENTS.md @@ -0,0 +1,122 @@ +# phoenix-sdk Guide + +## Architecture Overview + +The SDK has three client layers, each building on the last: + +``` +PhoenixClient (high-level, stateful, auto-reconnect, receiver-based) + | + +-- PhoenixWSClient (low-level WS, no reconnect) + +-- PhoenixHttpClient (REST API) +``` + +Most consumers should use **`PhoenixClient`** directly. + +--- + +## PhoenixHttpClient (`http_client.rs`) + +Stateless REST client for the Phoenix perpetuals API. Methods include: + +- `get_exchange()` / `get_exchange_keys()` / `get_markets()` / `get_market(symbol)` +- `get_traders(authority)` — fetch trader state via HTTP +- `get_collateral_history(...)` / `get_funding_history(...)` / `get_order_history(...)` / `get_trade_history(...)` +- `get_candles(symbol, timeframe, ...)` +- `build_isolated_limit_order_tx(...)` / `build_isolated_market_order_tx(...)` — server-side isolated order construction + +Constructed via `PhoenixHttpClient::new_from_env()` or `PhoenixHttpClient::from_env(env)`. Reads `PHOENIX_API_URL` and optional `PHOENIX_API_KEY` from environment. + +Also accessible from `PhoenixClient` via `client.http()`. + +## PhoenixWSClient (`ws_client.rs`) + +Low-level WebSocket client. Handles connection, message parsing, and fan-out to subscribers. **Does not manage reconnection**. + +Subscribe methods return `(UnboundedReceiver, SubscriptionHandle)`. Drop the handle to unsubscribe. + +`SubscriptionKey` is public and canonical for identifying channels: + +- `SubscriptionKey::market(symbol)` +- `SubscriptionKey::orderbook(symbol)` +- `SubscriptionKey::trader(&authority, trader_pda_index)` +- `SubscriptionKey::funding_rate(symbol)` +- `SubscriptionKey::candles(symbol, timeframe)` +- `SubscriptionKey::trades(symbol)` +- `SubscriptionKey::all_mids()` + +Most consumers should **not** use `PhoenixWSClient` directly — use `PhoenixClient` instead. + +--- + +## PhoenixClient (`client.rs`) + +The primary interface. Wraps both WS and HTTP clients, providing: + +1. **Automatic reconnection** with exponential backoff +2. **Receiver-based subscriptions** (`subscribe(...)`) instead of callbacks +3. **Lock-free live state ownership** inside one background task +4. **Dependency-aware subscription refcounting** so transitive dependencies are not dropped early +5. **Resubscription** after reconnect for all active dependency keys + +### Construction + +```rust +let client = PhoenixClient::new_from_env().await?; +// or +let client = PhoenixClient::from_env(env).await?; +``` + +On construction, the client fetches exchange metadata via HTTP and spawns a background connection loop. + +### Subscribe API + +The high-level entry point is: + +```rust +let (mut rx, _handle) = client.subscribe(subscription).await?; +``` + +Drop `_handle` to unsubscribe that logical subscription. + +Supported subscriptions: + +- `PhoenixSubscription::Key(SubscriptionKey)` +- `PhoenixSubscription::Market { symbol, candle_timeframes, include_trades }` +- `PhoenixSubscription::TraderMargin { authority, trader_pda_index, subaccount_index, market_symbols }` + +### Event Model + +Receivers yield `PhoenixClientEvent`, including previous state snapshots before each update is applied: + +- `MarketUpdate { prev_market, update }` +- `OrderbookUpdate { prev_market, update }` +- `TraderUpdate { prev_trader, update }` +- `MidsUpdate { prev_mids, update }` +- `FundingRateUpdate { prev_funding_rate, update }` +- `CandleUpdate { prev_candle, update }` +- `TradesUpdate { prev_trades, update }` +- `MarginUpdate { trigger, margin, metadata, prev_trader }` + +`MarginUpdate` uses `MarginTrigger::{Market, Trader}` and emits recomputation results for trader-margin subscriptions. + +### Lifecycle + +```rust +tokio::signal::ctrl_c().await?; +client.shutdown(); +client.run().await; +``` + +### Internal Design + +The background task owns all mutable runtime state (metadata, markets, traders, mids, funding, candles, trades). + +It tracks: + +- logical subscriptions (user-facing subscribe handles) +- concrete `SubscriptionKey` dependencies +- dependency refcounts +- live WS `SubscriptionHandle`s and per-key receiver streams + +Incoming WS streams are raced via a keyed receiver collection (`StreamMap`) in `tokio::select!`. diff --git a/container/vendor/rise/rust/sdk/Cargo.toml b/container/vendor/rise/rust/sdk/Cargo.toml new file mode 100644 index 00000000000..cfcbafa98d8 --- /dev/null +++ b/container/vendor/rise/rust/sdk/Cargo.toml @@ -0,0 +1,44 @@ +[package] +edition = "2021" +name = "phoenix-sdk" +publish = false +rust-version = "1.86.0" +version = "0.1.0" + +[dependencies] +phoenix-ix = { path = "../ix" } +phoenix-math-utils = { path = "../math" } +phoenix-types = { path = "../types" } + +futures-util = { workspace = true } +parking_lot = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +serde_json = { workspace = true } +solana-instruction = { workspace = true } +solana-pubkey = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +tokio-stream = "0.1" +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +serde = { workspace = true } +solana-commitment-config = { workspace = true } +solana-instruction = { workspace = true } +solana-keypair = { workspace = true } +solana-rpc-client = { workspace = true } +solana-signer = { workspace = true } +solana-transaction = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } +toml = "0.8" +tracing-subscriber = { workspace = true } + +[lints.rust.unexpected_cfgs] +check-cfg = ['cfg(target_os, values("solana"))', 'cfg(tokio_unstable)'] +level = "allow" + +[lints.clippy] +uninlined_format_args = "allow" diff --git a/container/vendor/rise/rust/sdk/examples/cancel_order.rs b/container/vendor/rise/rust/sdk/examples/cancel_order.rs new file mode 100644 index 00000000000..5336edb3abb --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/cancel_order.rs @@ -0,0 +1,91 @@ +//! Example: Cancel an order using PhoenixTxBuilder. +//! +//! This example demonstrates how to use PhoenixTxBuilder with a raw Solana +//! RPC client for order cancellation. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! cargo run -p phoenix-sdk --example cancel_order -- SOL 50000 12345 + +use std::env; + +use phoenix_sdk::{CancelId, PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, TraderKey}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse command line args + let args: Vec = env::args().collect(); + if args.len() < 4 { + eprintln!("Usage: cancel_order "); + eprintln!("Example: cancel_order SOL 50000 12345"); + std::process::exit(1); + } + let symbol = &args[1]; + let price_in_ticks: u64 = args[2].parse()?; + let order_sequence_number: u64 = args[3].parse()?; + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let trader = TraderKey::new(keypair.pubkey()); + + println!("Trader authority: {}", trader.authority()); + println!("Trader PDA: {}", trader.pda()); + + // Fetch exchange metadata via HTTP + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + + // Show cached market info + if let Some(market) = metadata.get_market(symbol) { + println!("\n=== {} Market ===", market.symbol); + println!(" Market Key: {}", market.market_pubkey); + println!(" Spline Collection: {}", market.spline_pubkey); + } + + // Build cancel order instructions + let builder = PhoenixTxBuilder::new(&metadata); + let cancel_id = CancelId::new(price_in_ticks, order_sequence_number); + println!( + "\nCancelling order: {} price_in_ticks={} seq={}", + symbol, price_in_ticks, order_sequence_number + ); + + let instructions = + builder.build_cancel_orders(trader.authority(), trader.pda(), symbol, vec![cancel_id])?; + + // Send transaction via raw Solana RPC client + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/cancel_stop_loss.rs b/container/vendor/rise/rust/sdk/examples/cancel_stop_loss.rs new file mode 100644 index 00000000000..d79def97195 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/cancel_stop_loss.rs @@ -0,0 +1,86 @@ +//! Example: Cancel a stop loss (or take profit) order using PhoenixTxBuilder. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! cargo run -p phoenix-sdk --example cancel_stop_loss -- SOL less_than + +use std::env; + +use phoenix_sdk::{Direction, PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, TraderKey}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: cancel_stop_loss "); + eprintln!(" DIRECTION: less_than | greater_than"); + eprintln!("Example: cancel_stop_loss SOL less_than"); + std::process::exit(1); + } + let symbol = &args[1]; + let direction = match args[2].as_str() { + "less_than" => Direction::LessThan, + "greater_than" => Direction::GreaterThan, + other => { + eprintln!("Invalid direction '{}': use 'less_than' or 'greater_than'", other); + std::process::exit(1); + } + }; + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let trader = TraderKey::new(keypair.pubkey()); + + println!("Trader authority: {}", trader.authority()); + println!("Trader PDA: {}", trader.pda()); + + // Fetch exchange metadata via HTTP + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + + // Build cancel stop loss instruction + let builder = PhoenixTxBuilder::new(&metadata); + println!( + "\nCancelling stop loss: {} direction={:?}", + symbol, direction + ); + + let instructions = + builder.build_cancel_bracket_leg(trader.authority(), trader.pda(), symbol, direction)?; + + // Send transaction via raw Solana RPC client + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/compute_trader_margin.rs b/container/vendor/rise/rust/sdk/examples/compute_trader_margin.rs new file mode 100644 index 00000000000..505043dc588 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/compute_trader_margin.rs @@ -0,0 +1,329 @@ +//! Example: Real-time trader margin monitoring using live WebSocket data +//! +//! This example demonstrates a production-ready margin monitoring system: +//! 1. Bootstrap market configuration via HTTP API +//! 2. Subscribe to market-stats WebSocket for real-time prices +//! 3. Subscribe to trader-state WebSocket for position updates +//! 4. Recompute margin instantly (<1ms) on every update +//! 5. Monitor risk tier changes in real-time +//! +//! # Usage +//! +//! ```bash +//! # PHOENIX_API_KEY is optional +//! PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws \ +//! PHOENIX_API_KEY=your_api_key \ +//! cargo run --example compute_trader_margin -- +//! ``` +//! +//! # What this demonstrates +//! +//! - **Real-time margin monitoring**: Margin recalculated on every +//! price/position update +//! - **Zero-latency calculations**: <1ms margin computation vs 50-200ms API +//! latency +//! - **Risk monitoring**: Instant alerts when crossing risk tier thresholds +//! - **Production-ready**: Handles errors and state synchronization + +use std::env; + +use phoenix_math_utils::{TraderPortfolio, TraderPortfolioMargin, WrapperNum}; +use phoenix_sdk::{ + PhoenixHttpClient, PhoenixMetadata, PhoenixWSClient, SubaccountState, Trader, TraderKey, +}; +use solana_pubkey::Pubkey; +use tokio::select; + +/// Print detailed margin information for the portfolio +fn print_margin_summary(margin: &TraderPortfolioMargin, subaccount: &SubaccountState) { + println!("\n========== MARGIN SUMMARY =========="); + + println!("\nTrader State:"); + println!(" Positions: {}", subaccount.positions.len()); + println!(" Orders: {}", subaccount.orders.len()); + + // Aggregate portfolio margin + let portfolio_value = margin.portfolio_value().as_inner() as f64 / 1_000_000_000_000.0; + let effective_coll = margin.effective_collateral().as_inner() as f64 / 1_000_000_000_000.0; + let initial_margin = margin.margin.initial_margin.as_inner() as f64 / 1_000_000.0; + let maintenance_margin = margin.margin.maintenance_margin.as_inner() as f64 / 1_000_000.0; + let unrealized_pnl = margin.margin.unrealized_pnl.as_inner() as f64 / 1_000_000.0; + + println!("\nAggregate Portfolio:"); + println!( + " Collateral: ${:.2}", + margin.quote_lot_collateral.as_inner() as f64 / 1_000_000_000_000.0 + ); + println!(" Portfolio Value: ${:.2}", portfolio_value); + println!(" Effective Collateral:${:.2}", effective_coll); + println!(" Initial Margin: ${:.2}", initial_margin); + println!( + " Maintenance Margin: ${:.2} (liquidation threshold)", + maintenance_margin + ); + println!(" Unrealized PnL: ${:+.2}", unrealized_pnl); + println!(" Risk Tier: {:?}", margin.risk_tier().ok()); + + // SOL market margin (if position exists in computed margin) + if let Some(sol_margin) = margin.positions.get("SOL") { + let sol_initial = sol_margin.margin.initial_margin.as_inner() as f64 / 1_000_000.0; + let sol_maint = sol_margin.margin.maintenance_margin.as_inner() as f64 / 1_000_000.0; + let sol_pnl = sol_margin.margin.unrealized_pnl.as_inner() as f64 / 1_000_000.0; + let sol_limit_order = sol_margin.margin.limit_order_margin.as_inner() as f64 / 1_000_000.0; + + println!("\nSOL Market:"); + if let Some(pos) = sol_margin.position { + println!( + " Position: {} lots", + pos.base_lot_position.as_inner() + ); + } + println!(" Initial Margin: ${:.2}", sol_initial); + println!(" Maintenance Margin: ${:.2}", sol_maint); + println!(" Limit Order Margin: ${:.2}", sol_limit_order); + println!(" Unrealized PnL: ${:+.2}", sol_pnl); + } else { + println!("\nSOL Market: No position"); + } + + // BTC market margin (if position exists in computed margin) + if let Some(btc_margin) = margin.positions.get("BTC") { + let btc_initial = btc_margin.margin.initial_margin.as_inner() as f64 / 1_000_000.0; + let btc_maint = btc_margin.margin.maintenance_margin.as_inner() as f64 / 1_000_000.0; + let btc_pnl = btc_margin.margin.unrealized_pnl.as_inner() as f64 / 1_000_000.0; + let btc_limit_order = btc_margin.margin.limit_order_margin.as_inner() as f64 / 1_000_000.0; + + println!("\nBTC Market:"); + if let Some(pos) = btc_margin.position { + println!( + " Position: {} lots", + pos.base_lot_position.as_inner() + ); + } + println!(" Initial Margin: ${:.2}", btc_initial); + println!(" Maintenance Margin: ${:.2}", btc_maint); + println!(" Limit Order Margin: ${:.2}", btc_limit_order); + println!(" Unrealized PnL: ${:+.2}", btc_pnl); + } else { + println!("\nBTC Market: No position"); + } + + println!("====================================\n"); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse authority pubkey from command line + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample:"); + eprintln!(" {} 3z9vL1zjN6qyAFHhHQdWYRTFAcy69pJydkZmSFBKHg1R", args[0]); + std::process::exit(1); + } + + let authority_str = &args[1]; + let authority = authority_str + .parse::() + .map_err(|e| format!("Invalid pubkey: {}", e))?; + + println!("Phoenix Margin Monitor"); + println!("Authority: {}\n", authority_str); + + // ======================================================================== + // STEP 1: Bootstrap - Fetch static exchange config via HTTP + // ======================================================================== + + println!("[1/5] Bootstrapping exchange configuration..."); + let http_client = PhoenixHttpClient::new_from_env(); + let exchange_response = http_client.get_exchange().await?; + let exchange: phoenix_types::ExchangeView = exchange_response.into(); + println!(" Loaded {} markets\n", exchange.markets.len()); + + // ======================================================================== + // STEP 2: Prepare market symbols from exchange config + // ======================================================================== + + println!("[2/5] Preparing market metadata..."); + + // Build PhoenixMetadata with cached calculators for all markets + // Note: PerpAssetMetadata will be populated once we receive mark prices from + // WebSocket + let mut metadata = PhoenixMetadata::new(exchange); + + println!( + " Prepared {} markets with cached calculators\n", + metadata.exchange().markets.len() + ); + + // ======================================================================== + // STEP 3: Initialize trader state container + // ======================================================================== + + println!("[3/5] Initializing trader state..."); + let trader_key = TraderKey::from_authority(authority); + let mut trader = Trader::new(trader_key.clone()); + + // Optionally fetch initial state via HTTP + match http_client.get_traders(&authority).await { + Ok(traders) => { + if let Some(view) = traders.into_iter().find(|t| t.trader_subaccount_index == 0) { + println!(" Found trader (PDA index: {})", view.trader_pda_index); + println!(" Positions: {}", view.positions.len()); + println!(" Current Risk Tier: {:?}\n", view.risk_tier); + } else { + println!(" No primary subaccount found"); + println!(" Will populate from WebSocket\n"); + } + } + Err(e) => { + println!(" Could not fetch trader state: {}", e); + println!(" Will populate from WebSocket\n"); + } + }; + + // Portfolio will be built from trader state updates + let mut portfolio = TraderPortfolio::default(); + + // ======================================================================== + // STEP 4: Connect to WebSocket and subscribe to channels + // ======================================================================== + + println!("[4/5] Connecting to WebSocket..."); + let ws_url = env::var("PHOENIX_WS_URL") + .unwrap_or_else(|_| "wss://public-api.phoenix.trade/ws".to_string()); + + let ws_client = PhoenixWSClient::new_from_env()?; + println!(" Connected to {}\n", ws_url); + + // Subscribe to market updates for each market (to get mark prices) + // The public API requires subscribing to each market individually + println!("[5/5] Subscribing to data streams..."); + + // Get market symbols from exchange config and subscribe to each + let market_symbols: Vec = metadata.exchange().markets.keys().cloned().collect(); + + // Create a channel to merge all market updates + let (market_tx, mut market_stats_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut _market_handles = Vec::new(); + + for symbol in &market_symbols { + let (rx, handle) = ws_client.subscribe_to_market(symbol.clone())?; + _market_handles.push(handle); + + let tx = market_tx.clone(); + let symbol_clone = symbol.clone(); + tokio::spawn(async move { + let mut rx = rx; + while let Some(msg) = rx.recv().await { + if tx.send(msg).is_err() { + break; + } + } + tracing::debug!("Market subscription for {} ended", symbol_clone); + }); + } + drop(market_tx); // Drop the original sender so the channel closes when all spawned tasks end + + println!( + " Subscribed to market updates for {} markets", + market_symbols.len() + ); + + // Subscribe to trader-state for position updates + let (mut trader_state_rx, _trader_state_handle) = + ws_client.subscribe_to_trader_state(&trader_key.authority())?; + println!(" Subscribed to trader-state for {}\n", authority_str); + + // Track if we've initialized metadata (need at least one price update) + let mut initialized_markets = std::collections::HashSet::new(); + let mut last_risk_tier = None; + + println!("----------------------------------------------------"); + println!("Live Margin Monitor Started"); + println!("----------------------------------------------------\n"); + + // ======================================================================== + // STEP 5: Main event loop - Process WebSocket updates + // ======================================================================== + + loop { + select! { + // Handle market stats updates (mark price changes) + Some(stats) = market_stats_rx.recv() => { + // Initialize or update metadata for this market + let is_new = !initialized_markets.contains(&stats.symbol); + match metadata.apply_market_stats(&stats) { + Ok(()) => { + if is_new { + initialized_markets.insert(stats.symbol.clone()); + println!("Initialized {}: mark=${:.2}", stats.symbol, stats.mark_price); + } + } + Err(e) => { + eprintln!("Failed to apply stats for {}: {}", stats.symbol, e); + } + } + + // Recompute margin if we have initialized markets and positions + if let Some(subaccount) = trader.primary_subaccount() { + if metadata.initialized_market_count() > 0 && !portfolio.positions.is_empty() { + match portfolio.compute_margin(metadata.all_perp_asset_metadata()) { + Ok(margin) => { + let risk_tier = margin.risk_tier().ok(); + + if risk_tier != last_risk_tier { + println!("\nRISK TIER CHANGE: {:?} -> {:?}", last_risk_tier, risk_tier); + last_risk_tier = risk_tier; + } + + println!("Price update: {} @ ${:.2}", stats.symbol, stats.mark_price); + print_margin_summary(&margin, subaccount); + } + Err(e) => { + eprintln!("Margin calculation error: {}", e); + } + } + } + } + } + + // Handle trader state updates (position changes) + Some(msg) = trader_state_rx.recv() => { + // Apply update to Trader state container + trader.apply_update(&msg); + + // Rebuild portfolio from trader state using new conversion methods + if let Some(subaccount) = trader.primary_subaccount() { + portfolio = subaccount.to_trader_portfolio(); + + println!("\nTrader State Updated (slot {})", trader.last_slot); + println!(" Collateral: ${:.2}", subaccount.collateral); + + for (symbol, pos) in &subaccount.positions { + println!(" {} position: {} lots @ ${:.2}", + symbol, + pos.base_position_lots, + pos.entry_price_usd + ); + } + + // Recompute and print margin after trader state update + if metadata.initialized_market_count() > 0 && !portfolio.positions.is_empty() { + if let Ok(margin) = portfolio.compute_margin(metadata.all_perp_asset_metadata()) { + print_margin_summary(&margin, subaccount); + last_risk_tier = margin.risk_tier().ok(); + } + } + } + } + + else => { + println!("All channels closed, exiting..."); + break; + } + } + } + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/deposit_funds.rs b/container/vendor/rise/rust/sdk/examples/deposit_funds.rs new file mode 100644 index 00000000000..4744ab1af35 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/deposit_funds.rs @@ -0,0 +1,138 @@ +//! Example: Withdraw and Deposit USDC with Phoenix protocol. +//! +//! This example demonstrates both the withdraw and deposit flows: +//! +//! Withdraw flow (5 instructions): +//! 1. Create ATA for Phoenix tokens (if needed) +//! 2. Approve Ember to spend Phoenix tokens +//! 3. Create ATA for USDC (if needed) +//! 4. Withdraw Phoenix tokens from Phoenix protocol +//! 5. Convert Phoenix tokens to USDC via Ember +//! +//! Deposit flow (3 instructions): +//! 1. Create ATA for Phoenix tokens (if needed) +//! 2. Convert USDC to Phoenix tokens via Ember +//! 3. Deposit Phoenix tokens into Phoenix protocol +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! cargo run -p phoenix-sdk --example deposit_funds -- 100.0 + +use std::env; + +use phoenix_sdk::{PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, TraderKey}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse command line args + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: deposit_funds "); + eprintln!("Example: deposit_funds 100.0"); + std::process::exit(1); + } + let usdc_amount: f64 = args[1] + .parse() + .map_err(|_| "Invalid USDC amount - must be a number")?; + + if usdc_amount <= 0.0 { + eprintln!("Error: USDC amount must be greater than 0"); + std::process::exit(1); + } + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let trader = TraderKey::new(keypair.pubkey()); + + println!("Trader authority: {}", trader.authority()); + println!("Trader PDA: {}", trader.pda()); + + // Fetch exchange metadata via HTTP + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + + // Show exchange keys info + let keys = metadata.keys(); + println!("\n=== Exchange Keys ==="); + println!(" Canonical Mint: {}", keys.canonical_mint); + println!(" Global Vault: {}", keys.global_vault); + + // Create Solana RPC client + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let builder = PhoenixTxBuilder::new(&metadata); + + // Withdraw funds first + println!("\nWithdrawing ${:.2} USDC...", usdc_amount); + println!(" This will:"); + println!(" 1. Create Phoenix token ATA (if needed)"); + println!(" 2. Approve Ember to spend Phoenix tokens"); + println!(" 3. Create USDC ATA (if needed)"); + println!(" 4. Withdraw Phoenix tokens from the protocol"); + println!(" 5. Convert Phoenix tokens to USDC via Ember"); + + let withdraw_instructions = + builder.build_withdraw_funds(trader.authority(), trader.pda(), usdc_amount)?; + + let blockhash = rpc.get_latest_blockhash().await?; + let withdraw_tx = Transaction::new_signed_with_payer( + &withdraw_instructions, + Some(&trader.authority()), + &[&keypair], + blockhash, + ); + + let withdraw_signature = rpc.send_and_confirm_transaction(&withdraw_tx).await?; + + println!("\nWithdraw transaction confirmed!"); + println!("Signature: {}", withdraw_signature); + println!( + "Explorer: https://explorer.solana.com/tx/{}", + withdraw_signature + ); + + // Deposit funds + println!("\nDepositing ${:.2} USDC...", usdc_amount); + println!(" This will:"); + println!(" 1. Create Phoenix token ATA (if needed)"); + println!(" 2. Convert USDC to Phoenix tokens via Ember"); + println!(" 3. Deposit Phoenix tokens into the protocol"); + + let deposit_instructions = + builder.build_deposit_funds(trader.authority(), trader.pda(), usdc_amount)?; + + let blockhash = rpc.get_latest_blockhash().await?; + let deposit_tx = Transaction::new_signed_with_payer( + &deposit_instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let deposit_signature = rpc.send_and_confirm_transaction(&deposit_tx).await?; + + println!("\nDeposit transaction confirmed!"); + println!("Signature: {}", deposit_signature); + println!( + "Explorer: https://explorer.solana.com/tx/{}", + deposit_signature + ); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/http_client.rs b/container/vendor/rise/rust/sdk/examples/http_client.rs new file mode 100644 index 00000000000..ba4e6736303 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/http_client.rs @@ -0,0 +1,365 @@ +//! Example: Query exchange keys, SOL market, and trader info via HTTP API. +//! +//! Demonstrates the resource-based sub-client API: +//! client.markets().get_markets() +//! client.exchange().get_keys() +//! client.traders().get_trader(&authority) +//! etc. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! export TRADER_PUBKEY=your_trader_pubkey # optional +//! cargo run -p phoenix-sdk --example http_client + +use std::str::FromStr; + +use phoenix_sdk::{ + CandlesQueryParams, CollateralHistoryQueryParams, FundingHistoryQueryParams, + OrderHistoryQueryParams, PhoenixHttpClient, PnlQueryParams, PnlResolution, Timeframe, + TradeHistoryQueryParams, +}; +use solana_pubkey::Pubkey; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Connecting to Phoenix HTTP API...\n"); + + // Create HTTP client (uses optional PHOENIX_API_KEY env var) + let client = PhoenixHttpClient::new_from_env(); + + // Fetch exchange keys + println!("=== Exchange Keys ==="); + let exchange_keys = client.exchange().get_keys().await?; + println!(" Global Config: {}", exchange_keys.global_config); + println!(" Canonical Mint: {}", exchange_keys.canonical_mint); + println!(" Global Vault: {}", exchange_keys.global_vault); + println!(" Perp Asset Map: {}", exchange_keys.perp_asset_map); + println!(" Withdraw Queue: {}", exchange_keys.withdraw_queue); + println!( + " Trader Index Accs: {}", + exchange_keys.global_trader_index.len() + ); + println!( + " Active Trader Buf: {}", + exchange_keys.active_trader_buffer.len() + ); + println!("\n Current Authorities:"); + println!( + " Root: {}", + exchange_keys.current_authorities.root_authority + ); + println!( + " Risk: {}", + exchange_keys.current_authorities.risk_authority + ); + println!( + " Market: {}", + exchange_keys.current_authorities.market_authority + ); + println!( + " Oracle: {}", + exchange_keys.current_authorities.oracle_authority + ); + println!(); + + // Fetch SOL market config (static configuration, not live data) + println!("=== SOL Market Config ==="); + let market = client.markets().get_market("SOL").await?; + println!(" Symbol: {}", market.symbol); + println!(" Asset ID: {}", market.asset_id); + println!(" Status: {:?}", market.market_status); + println!(" Market Pubkey: {}", market.market_pubkey); + println!(" Spline Pubkey: {}", market.spline_pubkey); + println!(" Tick Size: {}", market.tick_size); + println!(" Base Lot Decimals: {}", market.base_lots_decimals); + println!(" Isolated Only: {}", market.isolated_only); + + println!("\n Fees:"); + println!(" Taker Fee: {:.4}%", market.taker_fee * 100.0); + println!(" Maker Fee: {:.4}%", market.maker_fee * 100.0); + + println!("\n Funding:"); + println!( + " Interval: {} seconds", + market.funding_interval_seconds + ); + println!( + " Period: {} seconds", + market.funding_period_seconds + ); + println!( + " Max Rate/Int: {:.4}%", + market.max_funding_rate_per_interval * 100.0 + ); + + println!("\n Leverage Tiers:"); + for (i, tier) in market.leverage_tiers.iter().enumerate() { + println!( + " Tier {}: max {:.1}x leverage, max {} base lots", + i + 1, + tier.max_leverage, + tier.max_size_base_lots + ); + } + + println!("\n Risk Factors:"); + println!( + " Maintenance: {:.2}%", + market.risk_factors.maintenance + ); + println!(" Backstop: {:.2}%", market.risk_factors.backstop); + println!(" High Risk: {:.2}%", market.risk_factors.high_risk); + + println!("\n Caps:"); + println!( + " OI Cap: {} base lots", + market.open_interest_cap_base_lots + ); + println!( + " Max Liq Size: {} base lots", + market.max_liquidation_size_base_lots + ); + println!(); + + // Fetch SOL candles + println!("=== SOL Candles (1m) ==="); + let params = CandlesQueryParams::new("SOL", Timeframe::Minute1).with_limit(5); + let candles = client.candles().get_candles(params).await?; + println!(" Latest {} candles:", candles.len()); + for candle in &candles { + println!( + " {} | O: ${:.2} H: ${:.2} L: ${:.2} C: ${:.2} V: {:.1}", + candle.time, + candle.open, + candle.high, + candle.low, + candle.close, + candle.volume.unwrap_or(0.0) + ); + } + println!(); + + // Fetch all markets (static configuration) + println!("=== All Markets ==="); + let markets = client.markets().get_markets().await?; + println!(" Markets ({} total):", markets.len()); + for m in &markets { + println!( + " {}: {:?} (max leverage: {}x, isolated: {})", + m.symbol, + m.market_status, + m.leverage_tiers + .first() + .map(|t| t.max_leverage) + .unwrap_or(0.0), + m.isolated_only + ); + } + println!(); + + // Fetch trader info (if TRADER_PUBKEY env var is set) + if let Ok(pubkey_str) = std::env::var("TRADER_PUBKEY") { + println!("=== Trader Subaccounts ==="); + let authority = Pubkey::from_str(&pubkey_str)?; + let traders = client.traders().get_trader(&authority).await?; + println!(" Found {} subaccount(s)\n", traders.len()); + + for trader in &traders { + println!( + " --- Subaccount {} (PDA index: {}) ---", + trader.trader_subaccount_index, trader.trader_pda_index + ); + println!(" Trader Key: {}", trader.trader_key); + println!(" State: {:?}", trader.state); + println!(" Collateral: {}", trader.collateral_balance.ui); + println!(" Portfolio Value: {}", trader.portfolio_value.ui); + println!(" Unrealized PnL: {}", trader.unrealized_pnl.ui); + println!(" Risk State: {:?}", trader.risk_state); + println!(" Risk Tier: {:?}", trader.risk_tier); + + if !trader.positions.is_empty() { + println!("\n Positions:"); + for pos in &trader.positions { + println!( + " {}: {} @ {} (uPnL: {})", + pos.symbol, pos.position_size.ui, pos.entry_price.ui, pos.unrealized_pnl.ui + ); + } + } + + let order_count: usize = trader.limit_orders.values().map(|v| v.len()).sum(); + if order_count > 0 { + println!("\n Limit Orders ({} total):", order_count); + for (symbol, orders) in &trader.limit_orders { + for order in orders { + println!( + " {}: {:?} {} @ {}", + symbol, order.side, order.trade_size_remaining.ui, order.price.ui + ); + } + } + } + println!(); + } + + // Fetch trade history (fills) for this trader + println!("=== Trade History ==="); + let params = TradeHistoryQueryParams::new().with_limit(10); + let trades = client.trades().get_trader_trade_history(&authority, params).await?; + println!(" Latest {} trades:", trades.data.len()); + for fill in &trades.data { + println!( + " {} | {} {} @ {} ({} quote)", + fill.timestamp, fill.market_symbol, fill.base_qty, fill.price, fill.quote_qty + ); + } + if trades.has_more { + println!(" (more trades available via pagination)"); + } + + // Fetch collateral history for this trader + println!("=== Collateral History ==="); + let params = CollateralHistoryQueryParams::new(10); + let history = client.collateral().get_user_collateral_history(&authority, params).await?; + println!(" Latest {} events:", history.data.len()); + for event in &history.data { + println!( + " {} | {} {} (balance after: {})", + event.timestamp, event.event_type, event.amount, event.collateral_after + ); + } + if history.has_more { + println!(" (more events available via pagination)"); + } + println!(); + + // Fetch funding history for this trader + println!("=== Funding History ==="); + let params = FundingHistoryQueryParams::new().with_limit(10); + let funding = client.funding().get_user_funding_history(&authority, params).await?; + println!(" Latest {} events:", funding.events.len()); + for event in &funding.events { + println!( + " {} | {} {} USDC", + event.timestamp, event.symbol, event.funding_payment + ); + } + if funding.has_more { + println!(" (more events available via pagination)"); + } + println!(); + + // Fetch PnL time-series for the last 6 months + println!("=== PnL (last 6 months, daily) ==="); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let six_months_ago_ms = now_ms - 180 * 24 * 60 * 60 * 1000; + let params = PnlQueryParams::new(PnlResolution::Day1) + .with_start_time(six_months_ago_ms) + .with_end_time(now_ms); + let pnl = client.traders().get_trader_pnl(&authority, params).await?; + println!(" {} data points:", pnl.len()); + for point in &pnl { + println!( + " {} | cumPnL: {:.2} | unrealized: {:.2} | funding: {:.2} | fees: {:.2}", + point.timestamp, + point.cumulative_pnl, + point.unrealized_pnl, + point.cumulative_funding_payment, + point.cumulative_taker_fee, + ); + } + println!(); + + // Fetch order history for this trader + println!("=== Order History ==="); + let params = OrderHistoryQueryParams::new(10); + let orders = client.orders().get_trader_order_history(&authority, params).await?; + println!(" Latest {} orders:", orders.data.len()); + for order in &orders.data { + println!( + " {} | {:?} {:?} {} @ {} ({:?})", + order.market_symbol, + order.side, + order.status, + order.base_qty, + order.price, + order + .placed_at + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + ); + } + + // Demonstrate pagination: fetch next page if available + if orders.has_more { + if let Some(cursor) = &orders.next_cursor { + println!("\n Fetching next page..."); + let next_params = OrderHistoryQueryParams::new(5).with_cursor(cursor); + let next_page = client.orders().get_trader_order_history(&authority, next_params).await?; + println!(" Next {} orders:", next_page.data.len()); + for order in &next_page.data { + println!( + " {} | {:?} {:?} {} @ {}", + order.market_symbol, order.side, order.status, order.base_qty, order.price + ); + } + } + } + + // Demonstrate market filter + println!("\n Filtering by SOL market:"); + let sol_params = OrderHistoryQueryParams::new(5).with_market_symbol("SOL"); + let sol_orders = client.orders().get_trader_order_history(&authority, sol_params).await?; + println!(" Found {} SOL orders", sol_orders.data.len()); + println!(); + + // Demonstrate market filter for trades + println!("\n Filtering trades by SOL market:"); + let sol_params = TradeHistoryQueryParams::new() + .with_market_symbol("SOL") + .with_limit(5); + let sol_trades = client.trades().get_trader_trade_history(&authority, sol_params).await?; + println!(" Found {} SOL trades", sol_trades.data.len()); + println!(); + } + + // Fetch exchange config (static market parameters) + println!("=== Exchange Config ==="); + let exchange = client.exchange().get_exchange().await?; + println!(" Markets ({} total):", exchange.markets.len()); + for market in &exchange.markets { + println!("\n {} Market Config:", market.symbol); + println!(" Spline Collection: {}", market.spline_pubkey); + println!( + " Fees: taker {:.4}%, maker {:.4}%", + market.taker_fee * 100.0, + market.maker_fee * 100.0 + ); + println!( + " Funding: {} sec interval, {} sec period", + market.funding_interval_seconds, market.funding_period_seconds + ); + println!( + " Max Funding Rate/Interval: {:.4}%", + market.max_funding_rate_per_interval * 100.0 + ); + println!( + " OI Cap: {} base lots", + market.open_interest_cap_base_lots + ); + println!( + " Max Liquidation Size: {} base lots", + market.max_liquidation_size_base_lots + ); + println!(" Isolated Only: {}", market.isolated_only); + println!(" Risk Factors:"); + println!(" Maintenance: {:.2}%", market.risk_factors.maintenance); + println!(" Backstop: {:.2}%", market.risk_factors.backstop); + println!(" High Risk: {:.2}%", market.risk_factors.high_risk); + } + println!(); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/isolated_limit_order.rs b/container/vendor/rise/rust/sdk/examples/isolated_limit_order.rs new file mode 100644 index 00000000000..84db4657030 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/isolated_limit_order.rs @@ -0,0 +1,196 @@ +//! Example: Isolated margin limit order. +//! +//! Two modes: +//! **Client-side** (default) — uses `PhoenixTxBuilder` with local state +//! from WebSocket to construct instructions. +//! **Server-side** (`--async`) — POSTs to the HTTP API and receives +//! pre-built instructions, no WebSocket connection needed. +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! export PHOENIX_API_KEY=your_api_key +//! cargo run -p phoenix-sdk --example isolated_limit_order -- SOL 10 150.50 +//! 5.0 [--async] + +use std::env; + +use phoenix_sdk::{ + IsolatedCollateralFlow, PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, PhoenixWSClient, + Side, Trader, TraderKey, +}; +use solana_commitment_config::CommitmentConfig; +use solana_instruction::Instruction; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +/// Build isolated limit order instructions via the server-side HTTP endpoint. +/// No WebSocket or local state required. +async fn build_via_server( + http: &PhoenixHttpClient, + authority: &solana_pubkey::Pubkey, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + collateral: u64, +) -> Result, Box> { + let ixs = http + .build_isolated_limit_order_tx( + authority, + symbol, + side, + price, + num_base_lots, + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }), + false, + ) + .await?; + + Ok(ixs) +} + +/// Build isolated limit order instructions locally using on-chain state +/// fetched via WebSocket. +async fn build_via_client( + http: &PhoenixHttpClient, + authority: &solana_pubkey::Pubkey, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + collateral: u64, +) -> Result, Box> { + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + let builder = PhoenixTxBuilder::new(&metadata); + let key = TraderKey::new(*authority); + + println!("Connecting to WebSocket for trader state..."); + let ws = PhoenixWSClient::new_from_env()?; + let (mut rx, _handle) = ws.subscribe_to_trader_state(&key.authority())?; + + let mut trader = Trader::new(key); + let msg = rx + .recv() + .await + .ok_or("WebSocket closed before receiving trader state")?; + trader.apply_update(&msg); + println!( + "Loaded trader state: {} subaccounts, collateral {}", + trader.subaccounts.len(), + trader.total_collateral() + ); + + let ixs = builder.build_isolated_limit_order( + &trader, + symbol, + side, + price, + num_base_lots, + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }), + false, + )?; + + Ok(ixs) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + let use_async = args.iter().any(|a| a == "--async"); + let args: Vec<&String> = args.iter().filter(|a| *a != "--async").collect(); + + if args.len() < 5 { + eprintln!( + "Usage: isolated_limit_order [--async]" + ); + eprintln!(" NUM_BASE_LOTS: positive for bid, negative for ask"); + eprintln!(" PRICE: limit price in USD (e.g. 150.50)"); + eprintln!(" --async: use server-side instruction building (no WS needed)"); + eprintln!("Example: isolated_limit_order SOL 10 150.50 5.0"); + std::process::exit(1); + } + let symbol = args[1].as_str(); + let num_base_lots_signed: i64 = args[2].parse().expect("NUM_BASE_LOTS must be an integer"); + let price: f64 = args[3].parse().expect("PRICE must be a number"); + let collateral: u64 = args[4] + .parse() + .expect("COLLATERAL must be quote lots (u64)"); + + let (side, num_base_lots) = if num_base_lots_signed >= 0 { + (Side::Bid, num_base_lots_signed as u64) + } else { + (Side::Ask, num_base_lots_signed.unsigned_abs()) + }; + + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + + println!("Trader authority: {}", keypair.pubkey()); + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + + let authority = keypair.pubkey(); + let instructions = if use_async { + println!("Building instructions via server (--async)..."); + build_via_server( + &http, + &authority, + symbol, + side, + price, + num_base_lots, + collateral, + ) + .await? + } else { + build_via_client( + &http, + &authority, + symbol, + side, + price, + num_base_lots, + collateral, + ) + .await? + }; + + println!( + "\nSending {} instruction(s): {} {} base lots @ ${} on {}", + instructions.len(), + if side == Side::Bid { "Bid" } else { "Ask" }, + num_base_lots, + price, + symbol, + ); + + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/isolated_market_order_client.rs b/container/vendor/rise/rust/sdk/examples/isolated_market_order_client.rs new file mode 100644 index 00000000000..75561ee4f15 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/isolated_market_order_client.rs @@ -0,0 +1,434 @@ +//! Example: Isolated margin market order via client-side construction. +//! +//! Demonstrates: +//! 1. Initialize a `PhoenixClient` +//! 2. Subscribe to trader state (explicit) and trader margin events +//! 3. Cache `Trader` state and `TraderPortfolioMargin` from their respective +//! receivers +//! 4. Wait 10 seconds for state to settle +//! 5. Compute transferable collateral via `calculate_transferable_collateral` +//! 6. Build isolated market order via `PhoenixTxBuilder` +//! +//! Run with: +//! export PHOENIX_API_URL=https://public-api.phoenix.trade +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! cargo run -p phoenix-sdk --example isolated_market_order_client -- SOL 10 +//! 5.0 [SL_PRICE|x] [TP_PRICE|x] +//! +//! Use "x" in place of a price to skip stop-loss or take-profit. + +use std::env; + +use phoenix_math_utils::{TraderPortfolioMargin, WrapperNum}; +use phoenix_sdk::{ + BracketLegOrders, IsolatedCollateralFlow, MarginTrigger, PhoenixClient, PhoenixClientEvent, + PhoenixMetadata, PhoenixSubscription, PhoenixTxBuilder, Side, SubscriptionKey, Trader, + TraderKey, +}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +fn print_margin(margin: &TraderPortfolioMargin) { + println!( + " collateral: {} signed_quote_lots", + margin.quote_lot_collateral.as_inner() + ); + println!( + " portfolio value: {} signed_quote_lots", + margin.portfolio_value().as_inner() + ); + println!( + " effective collateral: {} signed_quote_lots", + margin.effective_collateral().as_inner() + ); + println!( + " initial margin: {} quote_lots", + margin.margin.initial_margin.as_inner() + ); + println!( + " initial margin (wdraw): {} quote_lots", + margin.margin.initial_margin_for_withdrawals.as_inner() + ); + println!( + " maintenance margin: {} quote_lots", + margin.margin.maintenance_margin.as_inner() + ); + println!( + " limit order margin: {} quote_lots", + margin.margin.limit_order_margin.as_inner() + ); + println!( + " unrealized pnl: {} signed_quote_lots", + margin.margin.unrealized_pnl.as_inner() + ); + println!( + " discounted pnl: {} signed_quote_lots", + margin.margin.discounted_unrealized_pnl.as_inner() + ); + println!( + " unsettled funding: {} signed_quote_lots", + margin.margin.unsettled_funding.as_inner() + ); + println!( + " position value: {} signed_quote_lots", + margin.margin.position_value.as_inner() + ); + println!(" risk tier: {:?}", margin.risk_tier().ok()); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt().init(); + + let args: Vec = env::args().collect(); + + if args.len() < 4 { + eprintln!( + "Usage: isolated_market_order_client \ + [SL_PRICE|x] [TP_PRICE|x]" + ); + eprintln!(" NUM_BASE_LOTS: positive for bid, negative for ask"); + eprintln!("Example: isolated_market_order_client SOL 10 5.0 120.5 150.0"); + std::process::exit(1); + } + + let symbol = args[1].clone(); + let num_base_lots_signed: i64 = args[2].parse().expect("NUM_BASE_LOTS must be an integer"); + let collateral: u64 = args[3] + .parse() + .expect("COLLATERAL must be quote lots (u64)"); + + fn parse_price(arg: &str) -> Option { + if arg.eq_ignore_ascii_case("x") { + return None; + } + Some(arg.parse::().expect("invalid price")) + } + + let sl_price = args.get(4).and_then(|s| parse_price(s)); + let tp_price = args.get(5).and_then(|s| parse_price(s)); + let bracket = if sl_price.is_some() || tp_price.is_some() { + Some(BracketLegOrders { + stop_loss_price: sl_price, + take_profit_price: tp_price, + }) + } else { + None + }; + + let (side, num_base_lots) = if num_base_lots_signed >= 0 { + (Side::Bid, num_base_lots_signed as u64) + } else { + (Side::Ask, num_base_lots_signed.unsigned_abs()) + }; + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let authority = keypair.pubkey(); + println!("Trader authority: {}\n", authority); + + // Initialize PhoenixClient + println!("Creating PhoenixClient..."); + let client = PhoenixClient::new_from_env().await?; + + // Subscribe to trader state (explicit receiver for Trader updates) + let (mut trader_rx, _trader_handle) = client + .subscribe(PhoenixSubscription::Key(SubscriptionKey::trader( + &authority, 0, + ))) + .await?; + println!("Subscribed to trader state"); + + // Subscribe to trader margin events + let (mut margin_rx, _margin_handle) = client + .subscribe(PhoenixSubscription::trader_margin(authority, 0)) + .await?; + println!("Subscribed to trader margin events\n"); + + // Cached state from receivers + let mut cached_trader = Trader::new(TraderKey::new(authority)); + let mut trader_initialized = false; + let mut cached_margin: Option = None; + let mut cached_metadata: Option = None; + let mut trader_updates: u32 = 0; + let mut margin_updates: u32 = 0; + + // Collect updates for 10 seconds to let state settle + println!("Waiting 10s for state to settle..."); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); + + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => { + println!( + "\nWait complete ({} trader updates, {} margin updates).\n", + trader_updates, margin_updates + ); + break; + } + Some(event) = trader_rx.recv() => { + if let PhoenixClientEvent::TraderUpdate { update, .. } = event { + cached_trader.apply_update(&update); + trader_initialized = true; + trader_updates += 1; + + println!( + "[trader {:>3}] slot={} subaccounts={} collateral={}", + trader_updates, + update.slot, + cached_trader.subaccounts.len(), + cached_trader.total_collateral(), + ); + + if let Some(sub) = cached_trader.primary_subaccount() { + for (sym, pos) in &sub.positions { + println!( + " {} position: {} lots @ ${:.2}", + sym, pos.base_position_lots, pos.entry_price_usd + ); + } + } + } + } + Some(event) = margin_rx.recv() => { + if let PhoenixClientEvent::MarginUpdate { + trigger, + margin, + metadata, + .. + } = event + { + margin_updates += 1; + + match &trigger { + MarginTrigger::Trader(msg) => { + println!("[margin {:>3}] trader trigger (slot={})", margin_updates, msg.slot); + } + MarginTrigger::Market(msg) => { + println!( + "[margin {:>3}] market trigger ({} mark={:.4})", + margin_updates, msg.symbol, msg.mark_price + ); + } + } + + if let Some(ref m) = margin { + print_margin(m); + } else { + println!(" margin unavailable (waiting for initialization)"); + } + + cached_margin = margin; + cached_metadata = Some(metadata); + } + } + else => { + return Err("Subscription channels closed unexpectedly".into()); + } + } + } + + // Validate cached state + if !trader_initialized { + return Err("No trader state received during wait period".into()); + } + let margin = cached_margin + .as_ref() + .ok_or("No margin data received during wait period")?; + let metadata = cached_metadata + .as_ref() + .ok_or("No metadata received during wait period")?; + + // Dump cached Trader state + println!("========== CACHED TRADER =========="); + println!(" authority: {}", cached_trader.key.authority()); + println!(" pda_index: {}", cached_trader.key.pda_index); + println!(" last_slot: {}", cached_trader.last_slot); + println!(" subaccounts: {}", cached_trader.subaccounts.len()); + println!( + " total_collateral: {} (Decimal, USD)", + cached_trader.total_collateral() + ); + if let Some(sub) = cached_trader.primary_subaccount() { + println!(" [subaccount 0]"); + println!(" collateral: {} (Decimal, USD)", sub.collateral); + for (sym, pos) in &sub.positions { + println!( + " {} position: {} base_lots, entry={} ticks ({} USD), vquote={} quote_lots, \ + unsettled_funding={} quote_lots, accum_funding={} quote_lots", + sym, + pos.base_position_lots, + pos.entry_price_ticks, + pos.entry_price_usd, + pos.virtual_quote_position_lots, + pos.unsettled_funding_quote_lots, + pos.accumulated_funding_quote_lots, + ); + } + for ((sym, seq), order) in &sub.orders { + println!(" order: {}#{} {:?}", sym, seq, order); + } + } + + // Dump TraderPortfolioMargin + println!("\n========== TRADER PORTFOLIO MARGIN =========="); + println!( + " quote_lot_collateral: {} signed_quote_lots", + margin.quote_lot_collateral.as_inner() + ); + println!( + " portfolio value: {} signed_quote_lots", + margin.portfolio_value().as_inner() + ); + println!( + " effective collateral: {} signed_quote_lots", + margin.effective_collateral().as_inner() + ); + println!(" [aggregate margin]"); + println!( + " initial_margin: {} quote_lots", + margin.margin.initial_margin.as_inner() + ); + println!( + " initial_margin_for_wdraw: {} quote_lots", + margin.margin.initial_margin_for_withdrawals.as_inner() + ); + println!( + " maintenance_margin: {} quote_lots", + margin.margin.maintenance_margin.as_inner() + ); + println!( + " limit_order_margin: {} quote_lots", + margin.margin.limit_order_margin.as_inner() + ); + println!( + " unrealized_pnl: {} signed_quote_lots", + margin.margin.unrealized_pnl.as_inner() + ); + println!( + " discounted_unrealized_pnl: {} signed_quote_lots", + margin.margin.discounted_unrealized_pnl.as_inner() + ); + println!( + " discounted_pnl_for_wdraw: {} signed_quote_lots", + margin.margin.discounted_pnl_for_withdrawals.as_inner() + ); + println!( + " unsettled_funding: {} signed_quote_lots", + margin.margin.unsettled_funding.as_inner() + ); + println!( + " accumulated_funding: {} signed_quote_lots", + margin.margin.accumulated_funding.as_inner() + ); + println!( + " position_value: {} signed_quote_lots", + margin.margin.position_value.as_inner() + ); + println!( + " backstop_requirement: {} quote_lots", + margin.margin.backstop_requirement.as_inner() + ); + println!(" risk tier: {:?}", margin.risk_tier().ok()); + + for (sym, mm) in &margin.positions { + println!(" [{}]", sym); + if let Some(pos) = &mm.position { + println!( + " base_lot_position: {} signed_base_lots", + pos.base_lot_position.as_inner() + ); + println!( + " virtual_quote_position: {} signed_quote_lots", + pos.virtual_quote_lot_position.as_inner() + ); + } + println!( + " initial_margin: {} quote_lots", + mm.margin.initial_margin.as_inner() + ); + println!( + " maintenance_margin: {} quote_lots", + mm.margin.maintenance_margin.as_inner() + ); + println!( + " limit_order_margin: {} quote_lots", + mm.margin.limit_order_margin.as_inner() + ); + println!( + " unrealized_pnl: {} signed_quote_lots", + mm.margin.unrealized_pnl.as_inner() + ); + println!( + " position_value: {} signed_quote_lots", + mm.margin.position_value.as_inner() + ); + println!(" limit_orders: {}", mm.limit_orders.len()); + } + println!("=======================================\n"); + + // Compute transferable collateral + let transferable = margin.calculate_transferable_collateral()?; + println!("Transferable collateral: {} quote_lots", transferable); + + // Build isolated market order via PhoenixTxBuilder + let builder = PhoenixTxBuilder::new(metadata); + let instructions = builder.build_isolated_market_order( + &cached_trader, + &symbol, + side, + num_base_lots, + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }), + false, + bracket.as_ref(), + )?; + + println!( + "\nSending {} instruction(s): {} {} base lots on {}", + instructions.len(), + if side == Side::Bid { "Bid" } else { "Ask" }, + num_base_lots, + symbol, + ); + if let Some(ref b) = bracket { + if let Some(sl) = b.stop_loss_price { + println!(" Stop-loss: {}", sl); + } + if let Some(tp) = b.take_profit_price { + println!(" Take-profit: {}", tp); + } + } + + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + client.shutdown(); + client.run().await; + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/isolated_market_order_server.rs b/container/vendor/rise/rust/sdk/examples/isolated_market_order_server.rs new file mode 100644 index 00000000000..44219991c09 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/isolated_market_order_server.rs @@ -0,0 +1,133 @@ +//! Example: Isolated margin market order via server-side HTTP endpoint. +//! +//! Uses `PhoenixHttpClient` to POST to the HTTP API and receive +//! pre-built instructions. No WebSocket connection needed. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key +//! cargo run -p phoenix-sdk --example isolated_market_order_server -- SOL 10 +//! 5.0 [SL_PRICE|x] [TP_PRICE|x] +//! +//! Use "x" in place of a price to skip stop-loss or take-profit. +//! Examples: +//! isolated_market_order_server SOL 10 5.0 # no bracket legs +//! isolated_market_order_server SOL 10 5.0 120.5 150.0 # SL at 120.5, TP at +//! 150.0 isolated_market_order_server SOL 10 5.0 x 150.0 # no SL, TP at +//! 150.0 + +use std::env; + +use phoenix_sdk::{BracketLegOrders, IsolatedCollateralFlow, PhoenixHttpClient, Side}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + + if args.len() < 4 { + eprintln!( + "Usage: isolated_market_order_server \ + [SL_PRICE|x] [TP_PRICE|x]" + ); + eprintln!(" NUM_BASE_LOTS: positive for bid, negative for ask"); + eprintln!("Example: isolated_market_order_server SOL 10 5.0 120.5 150.0"); + std::process::exit(1); + } + + let symbol = args[1].as_str(); + let num_base_lots_signed: i64 = args[2].parse().expect("NUM_BASE_LOTS must be an integer"); + let collateral: u64 = args[3] + .parse() + .expect("COLLATERAL must be quote lots (u64)"); + + fn parse_price(arg: &str) -> Option { + if arg.eq_ignore_ascii_case("x") { + return None; + } + Some(arg.parse::().expect("invalid price")) + } + + let sl_price = args.get(4).and_then(|s| parse_price(s)); + let tp_price = args.get(5).and_then(|s| parse_price(s)); + let bracket = if sl_price.is_some() || tp_price.is_some() { + Some(BracketLegOrders { + stop_loss_price: sl_price, + take_profit_price: tp_price, + }) + } else { + None + }; + + let (side, num_base_lots) = if num_base_lots_signed >= 0 { + (Side::Bid, num_base_lots_signed as u64) + } else { + (Side::Ask, num_base_lots_signed.unsigned_abs()) + }; + + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let authority = keypair.pubkey(); + println!("Trader authority: {}", authority); + + let http = PhoenixHttpClient::new_from_env(); + + println!("Building instructions via server..."); + let instructions = http + .build_isolated_market_order_tx( + &authority, + symbol, + side, + num_base_lots, + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }), + false, + bracket.as_ref(), + ) + .await?; + + println!( + "\nSending {} instruction(s): {} {} base lots on {}", + instructions.len(), + if side == Side::Bid { "Bid" } else { "Ask" }, + num_base_lots, + symbol, + ); + if let Some(ref b) = bracket { + if let Some(sl) = b.stop_loss_price { + println!(" Stop-loss: {}", sl); + } + if let Some(tp) = b.take_profit_price { + println!(" Take-profit: {}", tp); + } + } + + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/market_maker.rs b/container/vendor/rise/rust/sdk/examples/market_maker.rs new file mode 100644 index 00000000000..9b74eb3347b --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/market_maker.rs @@ -0,0 +1,343 @@ +//! Example: Naive inventory-aware market maker on Phoenix Perps. +//! +//! Quotes one tick inside the current spread on each market/trader update, +//! cancels existing orders before requoting, and adjusts bid/ask volume +//! based on current inventory (linear skew). +//! +//! Run with: +//! export KEYPAIR_PATH=/path/to/keypair.json +//! export PHOENIX_API_URL=https://public-api.phoenix.trade +//! cargo run -p phoenix-sdk --example market_maker -- SOL 100 1000 + +use std::env; +use std::sync::Arc; +use std::time::Instant; + +use parking_lot::Mutex; +use phoenix_math_utils::{Ticks, WrapperNum}; +use phoenix_sdk::{ + CancelId, Market, PhoenixClient, PhoenixClientEvent, PhoenixHttpClient, PhoenixMetadata, + PhoenixSubscription, PhoenixTxBuilder, Side, SubscriptionKey, Trader, TraderKey, +}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::{Keypair, read_keypair_file}; +use solana_pubkey::Pubkey; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; +const REQUOTE_INTERVAL_MS: u64 = 5_000; + +fn apply_market_event(event: PhoenixClientEvent, market_state: &mut Option) { + match event { + PhoenixClientEvent::MarketUpdate { + symbol, + prev_market, + update, + } => { + let mut market = prev_market.unwrap_or_else(|| Market::from_symbol(symbol)); + market.apply_market_stats_update(&update); + *market_state = Some(market); + } + PhoenixClientEvent::OrderbookUpdate { + symbol, + prev_market, + update, + } => { + let mut market = prev_market.unwrap_or_else(|| Market::from_symbol(symbol)); + market.apply_l2_book_update(&update); + *market_state = Some(market); + } + _ => {} + } +} + +fn apply_trader_event( + event: PhoenixClientEvent, + authority: Pubkey, + trader_state: &mut Option, +) { + if let PhoenixClientEvent::TraderUpdate { + prev_trader, + update, + .. + } = event + { + let mut trader = prev_trader + .unwrap_or_else(|| Trader::new(TraderKey::from_authority_with_idx(authority, 0, 0))); + trader.apply_update(&update); + *trader_state = Some(trader); + } +} + +fn try_requote( + symbol: &str, + order_size_lots: u64, + max_position_lots: i64, + tick_size_usd: f64, + last_quote: &Mutex, + market_state: &Option, + trader_state: &Option, + builder: &PhoenixTxBuilder, + rpc: &Arc, + keypair: &Arc, + authority: Pubkey, +) { + { + let last = last_quote.lock(); + if last.elapsed().as_millis() < REQUOTE_INTERVAL_MS as u128 { + return; + } + } + + let Some(market) = market_state.as_ref() else { + return; + }; + let Some(best_bid) = market.best_bid() else { + return; + }; + let Some(best_ask) = market.best_ask() else { + return; + }; + + let mut cancel_ids = Vec::new(); + if let Some(ts) = trader_state.as_ref() { + for order in ts.all_orders() { + if order.symbol != symbol { + continue; + } + cancel_ids.push(CancelId::new( + order.price_ticks as u64, + order.order_sequence_number, + )); + } + } + + let mut instructions = Vec::new(); + + if !cancel_ids.is_empty() { + match builder.build_cancel_orders( + authority, + TraderKey::new(authority).pda(), + symbol, + cancel_ids, + ) { + Ok(ixs) => instructions.extend(ixs), + Err(e) => { + eprintln!("Failed to build cancel ix: {}", e); + return; + } + } + } + + let position_lots: i64 = trader_state + .as_ref() + .and_then(|ts| ts.subaccount(0)) + .and_then(|sub| sub.positions.get(symbol)) + .map(|pos| pos.base_position_lots) + .unwrap_or(0); + + let skew = (position_lots as f64 / max_position_lots as f64).clamp(-1.0, 1.0); + let bid_size = (order_size_lots as f64 * (1.0 - skew)) as u64; + let ask_size = (order_size_lots as f64 * (1.0 + skew)) as u64; + + let bid_price = best_bid + tick_size_usd; + let ask_price = best_ask - tick_size_usd; + + if bid_price >= ask_price { + if instructions.is_empty() { + return; + } + } else { + let trader_pda = TraderKey::new(authority).pda(); + + if bid_size > 0 { + match builder.build_limit_order( + authority, + trader_pda, + symbol, + Side::Bid, + bid_price, + bid_size, + ) { + Ok(ixs) => instructions.extend(ixs), + Err(e) => eprintln!("Failed to build bid ix: {}", e), + } + } + + if ask_size > 0 { + match builder.build_limit_order( + authority, + trader_pda, + symbol, + Side::Ask, + ask_price, + ask_size, + ) { + Ok(ixs) => instructions.extend(ixs), + Err(e) => eprintln!("Failed to build ask ix: {}", e), + } + } + } + + if instructions.is_empty() { + return; + } + + *last_quote.lock() = Instant::now(); + + let rpc = rpc.clone(); + let keypair = keypair.clone(); + let symbol = symbol.to_string(); + tokio::spawn(async move { + let blockhash = match rpc.get_latest_blockhash().await { + Ok(bh) => bh, + Err(e) => { + eprintln!("Failed to get blockhash: {}", e); + return; + } + }; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&authority), + &[&*keypair], + blockhash, + ); + + match rpc.send_and_confirm_transaction(&tx).await { + Ok(sig) => { + println!( + "Requoted {}: bid {:.4} x {} | ask {:.4} x {} | pos={} | sig={}", + symbol, bid_price, bid_size, ask_price, ask_size, position_lots, sig + ); + } + Err(e) => eprintln!("Transaction failed: {}", e), + } + }); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt().init(); + + let args: Vec = env::args().collect(); + if args.len() < 4 { + eprintln!("Usage: market_maker "); + eprintln!("Example: market_maker SOL 100 1000"); + std::process::exit(1); + } + + let symbol = args[1].to_ascii_uppercase(); + let order_size_lots: u64 = args[2].parse()?; + let max_position_lots: i64 = args[3].parse()?; + + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + println!("Loading keypair from: {}", keypair_path); + + let keypair = Arc::new( + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?, + ); + let trader = TraderKey::new(keypair.pubkey()); + let authority = trader.authority(); + let pda = trader.pda(); + + println!("Authority: {}", authority); + println!("PDA: {}", pda); + + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata: &'static PhoenixMetadata = Box::leak(Box::new(PhoenixMetadata::new(exchange))); + + let calc = metadata + .get_market_calculator(&symbol) + .ok_or_else(|| format!("Unknown symbol: {}", symbol))?; + let tick_size_usd = calc.ticks_to_price(Ticks::new(1)); + println!("Tick size for {}: ${:.6}", symbol, tick_size_usd); + + let builder = PhoenixTxBuilder::new(metadata); + let rpc = Arc::new(RpcClient::new_with_commitment( + RPC_ENDPOINT.to_string(), + CommitmentConfig::confirmed(), + )); + + println!("\nConnecting PhoenixClient..."); + let client = PhoenixClient::new_from_env().await?; + + let (mut market_rx, _market_handle) = client + .subscribe(PhoenixSubscription::market(symbol.clone())) + .await?; + let (mut trader_rx, _trader_handle) = client + .subscribe(PhoenixSubscription::Key(SubscriptionKey::trader( + &authority, 0, + ))) + .await?; + + let mut market_state: Option = None; + let mut trader_state: Option = None; + let last_quote = Mutex::new( + Instant::now() + .checked_sub(std::time::Duration::from_millis(REQUOTE_INTERVAL_MS)) + .unwrap_or_else(Instant::now), + ); + + println!( + "\nMarket maker running: {} | size={} lots | max_pos={} lots | requote={}ms", + symbol, order_size_lots, max_position_lots, REQUOTE_INTERVAL_MS + ); + println!("Press Ctrl+C to stop\n"); + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + break; + } + Some(event) = market_rx.recv() => { + apply_market_event(event, &mut market_state); + try_requote( + &symbol, + order_size_lots, + max_position_lots, + tick_size_usd, + &last_quote, + &market_state, + &trader_state, + &builder, + &rpc, + &keypair, + authority, + ); + } + Some(event) = trader_rx.recv() => { + apply_trader_event(event, authority, &mut trader_state); + try_requote( + &symbol, + order_size_lots, + max_position_lots, + tick_size_usd, + &last_quote, + &market_state, + &trader_state, + &builder, + &rpc, + &keypair, + authority, + ); + } + else => { + break; + } + } + } + + println!("\nShutting down..."); + client.shutdown(); + client.run().await; + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/phoenix_client.rs b/container/vendor/rise/rust/sdk/examples/phoenix_client.rs new file mode 100644 index 00000000000..3eb19071e78 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/phoenix_client.rs @@ -0,0 +1,131 @@ +//! Example: Using PhoenixClient with receiver-based subscriptions. +//! +//! Demonstrates: +//! - Unified `subscribe(...)` API +//! - Market bundle subscriptions +//! - Trader margin subscriptions with trigger messages +//! - No callbacks or shared-state getters +//! +//! Run with: +//! export PHOENIX_API_URL=https://public-api.phoenix.trade +//! cargo run -p phoenix-sdk --example phoenix_client -- + +use std::str::FromStr; + +use phoenix_math_utils::{TraderPortfolioMargin, WrapperNum}; +use phoenix_sdk::{ + MarginTrigger, PhoenixClient, PhoenixClientEvent, PhoenixSubscription, Timeframe, +}; +use solana_pubkey::Pubkey; + +fn print_margin_summary(margin: &TraderPortfolioMargin) { + let collateral = margin.quote_lot_collateral.as_inner() as f64 / 1_000_000.0; + let portfolio_value = margin.portfolio_value().as_inner() as f64 / 1_000_000.0; + let effective_coll = margin.effective_collateral().as_inner() as f64 / 1_000_000.0; + let initial_margin = margin.margin.initial_margin.as_inner() as f64 / 1_000_000.0; + let maintenance_margin = margin.margin.maintenance_margin.as_inner() as f64 / 1_000_000.0; + let unrealized_pnl = margin.margin.unrealized_pnl.as_inner() as f64 / 1_000_000.0; + + println!(" Collateral: ${:.2}", collateral); + println!(" Portfolio Value: ${:.2}", portfolio_value); + println!(" Effective Collateral: ${:.2}", effective_coll); + println!(" Initial Margin: ${:.2}", initial_margin); + println!(" Maintenance Margin: ${:.2}", maintenance_margin); + println!(" Unrealized PnL: ${:+.2}", unrealized_pnl); + println!(" Risk Tier: {:?}", margin.risk_tier().ok()); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + // .with_env_filter("phoenix_sdk=debug,info") + .init(); + + let authority_str = std::env::args() + .nth(1) + .expect("Usage: phoenix_client "); + let authority = Pubkey::from_str(&authority_str)?; + + println!("Creating PhoenixClient..."); + let client = PhoenixClient::new_from_env().await?; + + let (mut market_rx, _market_handle) = client + .subscribe(PhoenixSubscription::Market { + symbol: "SOL".to_string(), + candle_timeframes: vec![Timeframe::Minute1], + include_trades: false, + }) + .await?; + + let (mut margin_rx, _margin_handle) = client + .subscribe(PhoenixSubscription::trader_margin(authority, 0)) + .await?; + + println!("Running... Press Ctrl+C to stop\n"); + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + break; + } + Some(event) = market_rx.recv() => { + match event { + PhoenixClientEvent::MarketUpdate { symbol, update, .. } => { + println!("{} mark={:.4} funding={:.8}", symbol, update.mark_price, update.funding_rate); + } + PhoenixClientEvent::OrderbookUpdate { symbol, update, .. } => { + let best_bid = update.orderbook.bids.first().map(|(px, _)| *px); + let best_ask = update.orderbook.asks.first().map(|(px, _)| *px); + println!("{} book bid={:?} ask={:?}", symbol, best_bid, best_ask); + } + PhoenixClientEvent::FundingRateUpdate { symbol, update, .. } => { + println!( + "{} funding={:.8}", + symbol, + update.funding, + ); + } + PhoenixClientEvent::CandleUpdate { symbol, timeframe, update, .. } => { + println!( + "{} {} candle o={:.4} h={:.4} l={:.4} c={:.4}", + symbol, + timeframe, + update.candle.open, + update.candle.high, + update.candle.low, + update.candle.close, + ); + } + _ => {} + } + } + Some(event) = margin_rx.recv() => { + if let PhoenixClientEvent::MarginUpdate { trigger, margin, .. } = event { + match trigger { + MarginTrigger::Trader(msg) => { + println!("Margin trigger: trader slot={}", msg.slot); + } + MarginTrigger::Market(msg) => { + println!("Margin trigger: market {} mark={:.4}", msg.symbol, msg.mark_price); + } + } + + if let Some(margin) = margin { + print_margin_summary(&margin); + } else { + println!(" margin unavailable (waiting for trader/market initialization)"); + } + } + } + else => { + break; + } + } + } + + println!("\nShutting down..."); + client.shutdown(); + client.run().await; + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/register_trader.rs b/container/vendor/rise/rust/sdk/examples/register_trader.rs new file mode 100644 index 00000000000..16d85cfe40e --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/register_trader.rs @@ -0,0 +1,28 @@ +//! Example: Register a trader by activating an invite code. +//! +//! Run with: +//! cargo run -p phoenix-sdk --example register_trader -- +//! + +use std::str::FromStr; + +use phoenix_sdk::PhoenixHttpClient; +use solana_pubkey::Pubkey; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: register_trader "); + std::process::exit(1); + } + + let authority = Pubkey::from_str(&args[1])?; + let code = &args[2]; + + let client = PhoenixHttpClient::new_from_env(); + let response = client.register_trader(&authority, code).await?; + println!("{}", response); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/send_limit_order.rs b/container/vendor/rise/rust/sdk/examples/send_limit_order.rs new file mode 100644 index 00000000000..62a566679ec --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/send_limit_order.rs @@ -0,0 +1,98 @@ +//! Example: Send a limit order using PhoenixTxBuilder. +//! +//! This example demonstrates how to use PhoenixTxBuilder with a raw Solana +//! RPC client for order placement. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! cargo run -p phoenix-sdk --example send_limit_order -- SOL 150.50 50000 + +use std::env; + +use phoenix_sdk::{PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, Side, TraderKey}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse command line args + let args: Vec = env::args().collect(); + if args.len() < 4 { + eprintln!("Usage: send_limit_order "); + eprintln!("Example: send_limit_order SOL 150.50 50000"); + std::process::exit(1); + } + let symbol = &args[1]; + let price: f64 = args[2].parse()?; + let num_base_lots: u64 = args[3].parse()?; + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let trader = TraderKey::new(keypair.pubkey()); + + println!("Trader authority: {}", trader.authority()); + println!("Trader PDA: {}", trader.pda()); + + // Fetch exchange metadata via HTTP + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + + // Show cached market info + if let Some(market) = metadata.get_market(symbol) { + println!("\n=== {} Market ===", market.symbol); + println!(" Market Key: {}", market.market_pubkey); + println!(" Spline Collection: {}", market.spline_pubkey); + println!(" Taker Fee: {:.4}%", market.taker_fee * 100.0); + println!(" Maker Fee: {:.4}%", market.maker_fee * 100.0); + } + + // Build limit order instructions + let builder = PhoenixTxBuilder::new(&metadata); + println!( + "\nPlacing limit order: {} {} base lots @ ${:.2}", + symbol, num_base_lots, price + ); + + let instructions = builder.build_limit_order( + trader.authority(), + trader.pda(), + symbol, + Side::Bid, + price, + num_base_lots, + )?; + + // Send transaction via raw Solana RPC client + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/send_market_order.rs b/container/vendor/rise/rust/sdk/examples/send_market_order.rs new file mode 100644 index 00000000000..23f2591a7d1 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/send_market_order.rs @@ -0,0 +1,133 @@ +//! Example: Send a market order using PhoenixTxBuilder. +//! +//! This example demonstrates how to use PhoenixTxBuilder with a raw Solana +//! RPC client for order placement. +//! +//! Run with: +//! export PHOENIX_API_KEY=your_api_key # optional +//! cargo run -p phoenix-sdk --example send_market_order -- SOL [SL_PRICE|x] +//! [TP_PRICE|x] +//! +//! Use "x" in place of a price to skip stop-loss or take-profit. +//! Examples: +//! send_market_order SOL # no bracket legs +//! send_market_order SOL 120.5 150.0 # SL at 120.5, TP at 150.0 +//! send_market_order SOL x 150.0 # no SL, TP at 150.0 +//! send_market_order SOL 120.5 x # SL at 120.5, no TP + +use std::env; + +use phoenix_sdk::{ + BracketLegOrders, PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, Side, TraderKey, +}; +use solana_commitment_config::CommitmentConfig; +use solana_keypair::read_keypair_file; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; + +const RPC_ENDPOINT: &str = "https://api.mainnet-beta.solana.com"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Parse command line args + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: send_market_order [SL_PRICE|x] [TP_PRICE|x]"); + eprintln!("Example: send_market_order SOL 120.5 150.0"); + std::process::exit(1); + } + let symbol = &args[1]; + + fn parse_price(arg: &str) -> Option { + if arg.eq_ignore_ascii_case("x") { + return None; + } + Some(arg.parse::().expect("invalid price")) + } + + let sl_price = args.get(2).and_then(|s| parse_price(s)); + let tp_price = args.get(3).and_then(|s| parse_price(s)); + let bracket = if sl_price.is_some() || tp_price.is_some() { + Some(BracketLegOrders { + stop_loss_price: sl_price, + take_profit_price: tp_price, + }) + } else { + None + }; + + // Load keypair + let keypair_path = env::var("KEYPAIR_PATH").unwrap_or_else(|_| { + let home = env::var("HOME").expect("HOME environment variable not set"); + format!("{}/.config/solana/id.json", home) + }); + + println!("Loading keypair from: {}", keypair_path); + let keypair = + read_keypair_file(&keypair_path).map_err(|e| format!("Failed to read keypair: {}", e))?; + let trader = TraderKey::new(keypair.pubkey()); + + println!("Trader authority: {}", trader.authority()); + println!("Trader PDA: {}", trader.pda()); + + // Fetch exchange metadata via HTTP + println!("\nFetching exchange metadata..."); + let http = PhoenixHttpClient::new_from_env(); + let exchange = http.get_exchange().await?.into(); + let metadata = PhoenixMetadata::new(exchange); + + // Show cached market info + if let Some(market) = metadata.get_market(symbol) { + println!("\n=== {} Market ===", market.symbol); + println!(" Market Key: {}", market.market_pubkey); + println!(" Spline Collection: {}", market.spline_pubkey); + println!(" Taker Fee: {:.4}%", market.taker_fee * 100.0); + println!(" Maker Fee: {:.4}%", market.maker_fee * 100.0); + } + + // Build market order instructions + let builder = PhoenixTxBuilder::new(&metadata); + let num_base_lots = 67; + println!( + "\nPlacing market order: {} {} base lots", + symbol, num_base_lots + ); + if let Some(ref b) = bracket { + if let Some(sl) = b.stop_loss_price { + println!(" Stop-loss: {}", sl); + } + if let Some(tp) = b.take_profit_price { + println!(" Take-profit: {}", tp); + } + } + + let instructions = builder.build_market_order( + trader.authority(), + trader.pda(), + symbol, + Side::Bid, + num_base_lots, + bracket.as_ref(), + )?; + + // Send transaction via raw Solana RPC client + let rpc = + RpcClient::new_with_commitment(RPC_ENDPOINT.to_string(), CommitmentConfig::confirmed()); + let blockhash = rpc.get_latest_blockhash().await?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = rpc.send_and_confirm_transaction(&tx).await?; + + println!("Transaction confirmed!"); + println!("Signature: {}", signature); + println!("Explorer: https://explorer.solana.com/tx/{}", signature); + + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/subscribe_candles.rs b/container/vendor/rise/rust/sdk/examples/subscribe_candles.rs new file mode 100644 index 00000000000..1b951537380 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/subscribe_candles.rs @@ -0,0 +1,68 @@ +//! Example: Subscribe to candle updates via WebSocket. +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! export PHOENIX_API_KEY=your_api_key +//! cargo run -p phoenix-sdk --example subscribe_candles -- SOL 1m +//! +//! Arguments: +//! Market symbol (e.g., "SOL") +//! Candle timeframe (1s, 5s, 1m, 5m, 15m, 30m, 1h, 4h, 1d) + +use std::env; + +use phoenix_sdk::{PhoenixWSClient, Timeframe}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing for debug output + tracing_subscriber::fmt() + .with_env_filter("phoenix_sdk=debug,info") + .init(); + + // Get symbol and timeframe from command line + let args: Vec = env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} SOL 1m", args[0]); + eprintln!("Timeframes: 1s, 5s, 1m, 5m, 15m, 30m, 1h, 4h, 1d"); + std::process::exit(1); + } + + let symbol = args[1].clone(); + let timeframe: Timeframe = args[2] + .parse() + .map_err(|e| format!("Invalid timeframe: {}", e))?; + + println!("Connecting to Phoenix WebSocket..."); + + // Connect to the WebSocket server (uses PHOENIX_WS_URL and PHOENIX_API_KEY env + // vars) + let client = PhoenixWSClient::new_from_env()?; + + println!("Subscribing to {} candles for {}", timeframe, symbol); + + // Subscribe to candle updates + let (mut rx, _handle) = client.subscribe_to_candles(symbol.clone(), timeframe)?; + println!("Subscribed! Waiting for updates...\n"); + + // Process updates + while let Some(msg) = rx.recv().await { + println!("=== {} {} Candle ===", msg.symbol, msg.timeframe); + println!(" Time: {}", msg.candle.time); + println!(" Open: {:.4}", msg.candle.open); + println!(" High: {:.4}", msg.candle.high); + println!(" Low: {:.4}", msg.candle.low); + println!(" Close: {:.4}", msg.candle.close); + if let Some(volume) = msg.candle.volume { + println!(" Volume: {:.4}", volume); + } + if let Some(trade_count) = msg.candle.trade_count { + println!(" Trade Count: {}", trade_count); + } + println!(); + } + + println!("WebSocket connection closed"); + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/subscribe_l2_book.rs b/container/vendor/rise/rust/sdk/examples/subscribe_l2_book.rs new file mode 100644 index 00000000000..103f4aa2238 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/subscribe_l2_book.rs @@ -0,0 +1,104 @@ +//! Example: Subscribe to orderbook updates via WebSocket. +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! cargo run -p phoenix-sdk --example subscribe_l2_book -- SOL +//! +//! The symbol argument is required (e.g., "SOL", "BTC", "ETH"). + +use std::env; + +use phoenix_sdk::{L2Book, PhoenixWSClient}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing for debug output + tracing_subscriber::fmt() + .with_env_filter("phoenix_sdk=debug,info") + .init(); + + // Get symbol from command line (required) + let symbol = env::args() + .nth(1) + .expect("Usage: subscribe_l2_book (e.g., SOL, BTC)"); + + println!("Connecting to Phoenix WebSocket..."); + + // Connect to the WebSocket server (uses PHOENIX_WS_URL env var) + let client = PhoenixWSClient::new_from_env()?; + + println!("Subscribing to orderbook for: {}", symbol); + + // Subscribe to orderbook updates + let (mut rx, _handle) = client.subscribe_to_orderbook(symbol.clone())?; + println!("Subscribed! Waiting for updates...\n"); + + // Maintain an L2Book container + let mut book = L2Book::new(symbol); + + // Process updates + while let Some(msg) = rx.recv().await { + book.apply_update(&msg); + + println!("=== {} Orderbook Update ===", book.symbol()); + + if let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) { + println!( + " Best Bid: ${:.4} ({:.2})", + bid, + book.best_bid_quantity().unwrap_or(0.0) + ); + println!( + " Best Ask: ${:.4} ({:.2})", + ask, + book.best_ask_quantity().unwrap_or(0.0) + ); + } + + if let Some(spread) = book.spread() { + println!(" Spread: ${:.6}", spread); + } + + if let Some(mid) = book.mid_price() { + println!(" Mid Price: ${:.4}", mid); + } + + if let Some(spread_pct) = book.spread_percent() { + println!(" Spread %: {:.4}%", spread_pct); + } + + println!( + " Bid Depth: {} levels ({:.2} total qty)", + book.bid_depth(), + book.total_bid_liquidity() + ); + println!( + " Ask Depth: {} levels ({:.2} total qty)", + book.ask_depth(), + book.total_ask_liquidity() + ); + + // Show top 3 levels + let bids = book.bids(); + let asks = book.asks(); + + if !bids.is_empty() { + println!("\n Top Bids:"); + for level in bids.iter().take(3) { + println!(" ${:.4} x {:.2}", level.price, level.quantity); + } + } + + if !asks.is_empty() { + println!("\n Top Asks:"); + for level in asks.iter().take(3) { + println!(" ${:.4} x {:.2}", level.price, level.quantity); + } + } + + println!(); + } + + println!("WebSocket connection closed"); + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/subscribe_market_stats.rs b/container/vendor/rise/rust/sdk/examples/subscribe_market_stats.rs new file mode 100644 index 00000000000..067c5d318a3 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/subscribe_market_stats.rs @@ -0,0 +1,97 @@ +//! Example: Subscribe to market updates via WebSocket. +//! +//! This example demonstrates: +//! - Connection status tracking +//! - Multiple subscribers to the same channel +//! - Explicit handle drops for unsubscription +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! cargo run -p phoenix-sdk --example subscribe_market_stats -- SOL + +use std::env; + +use phoenix_sdk::{MarketStats, PhoenixWSClient, WsConnectionStatus}; +use tokio::select; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing for debug output + tracing_subscriber::fmt() + .with_env_filter("phoenix_sdk=debug,info") + .init(); + + // Get symbol from command line (required) + let symbol = env::args() + .nth(1) + .expect("Usage: subscribe_market_stats (e.g., SOL, BTC)"); + + println!("Connecting to Phoenix WebSocket..."); + + // Connect with connection status tracking + let mut client = PhoenixWSClient::new_from_env_with_connection_status()?; + + // Get connection status receiver + let mut status_rx = client.connection_status_receiver().unwrap(); + + println!("Subscribing to market updates for: {}", symbol); + + // Subscribe twice to demonstrate multiple subscribers + let (mut rx1, handle1) = client.subscribe_to_market(symbol.clone())?; + let (mut rx2, handle2) = client.subscribe_to_market(symbol.clone())?; + println!("Subscribed with 2 receivers! Waiting for updates...\n"); + + // Maintain a MarketStats container + let mut market = MarketStats::new(symbol.clone()); + + // Process updates (limit to 5 for demo purposes) + let mut update_count = 0; + loop { + select! { + Some(status) = status_rx.recv() => { + match status { + WsConnectionStatus::Connecting => println!("[status] Connecting..."), + WsConnectionStatus::Connected => println!("[status] Connected!"), + WsConnectionStatus::ConnectionFailed => println!("[status] Connection failed"), + WsConnectionStatus::Disconnected(reason) => println!("[status] Disconnected: {}", reason), + } + } + Some(msg) = rx1.recv() => { + println!("[rx1] === {} Stats Update ===", msg.symbol); + println!(" Mark Price: ${:.4}", msg.mark_price); + println!(" Mid Price: ${:.4}", msg.mid_price); + println!(" Oracle Price: ${:.4}", msg.oracle_price); + println!(" Funding Rate: {:.6}%", msg.funding_rate * 100.0); + + // Apply the update to our market stats container + market.apply_update(&msg); + if let Some(change) = market.price_change_24h_percent() { + println!(" 24h Change: {:.2}%", change); + } + println!(); + + update_count += 1; + if update_count >= 5 { + break; + } + } + Some(msg) = rx2.recv() => { + println!("[rx2] === {} Stats Update ===", msg.symbol); + println!(" Mark Price: ${:.4}", msg.mark_price); + println!(); + } + } + } + + // Explicitly drop handles to unsubscribe + println!("Dropping handle1..."); + drop(handle1); + println!("Dropped handle1 (one subscriber remains)"); + + println!("Dropping handle2..."); + drop(handle2); + println!("Dropped handle2 (server unsubscribe sent)\n"); + + println!("WebSocket connection closed"); + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/subscribe_trader_state.rs b/container/vendor/rise/rust/sdk/examples/subscribe_trader_state.rs new file mode 100644 index 00000000000..f73bc14a245 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/subscribe_trader_state.rs @@ -0,0 +1,90 @@ +//! Example: Subscribe to trader state updates via WebSocket. +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! export PHOENIX_API_KEY=your_api_key +//! export KEYPAIR_PATH=~/.config/solana/id.json +//! cargo run -p phoenix-sdk --example subscribe_trader_state + +use phoenix_sdk::{PhoenixWSClient, Trader, TraderKey}; +use solana_keypair::read_keypair_file; +use solana_signer::Signer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing for debug output + tracing_subscriber::fmt() + .with_env_filter("phoenix_sdk=debug,info") + .init(); + + println!("Connecting to Phoenix WebSocket..."); + + // Connect to the WebSocket server (uses PHOENIX_WS_URL and PHOENIX_API_KEY env + // vars) + let client = PhoenixWSClient::new_from_env()?; + + // Load keypair from file specified by KEYPAIR_PATH env var + let keypair_path = + std::env::var("KEYPAIR_PATH").map_err(|_| "KEYPAIR_PATH environment variable not set")?; + let keypair = read_keypair_file(&keypair_path) + .map_err(|e| format!("Failed to read keypair from {}: {}", keypair_path, e))?; + + // Create trader key from keypair + let key = TraderKey::new(keypair.pubkey()); + let authority = key.authority(); + println!("Subscribing to trader state for authority: {}", authority); + println!("Trader PDA: {}", key.pda()); + + // Create a trader state container + let mut trader = Trader::new(key.clone()); + + // Subscribe to trader state updates + let (mut rx, _handle) = client.subscribe_to_trader_state(&authority)?; + println!("Subscribed! Waiting for updates...\n"); + + // Process updates + while let Some(msg) = rx.recv().await { + println!("=== Received update at slot {} ===", msg.slot); + + // Apply the update to our local state + trader.apply_update(&msg); + + // Print summary + println!("Total Collateral: {}", trader.total_collateral()); + + let positions = trader.all_positions(); + if positions.is_empty() { + println!("Positions: (none)"); + } else { + println!("Positions:"); + for pos in positions { + println!( + " {} | Size: {} lots | Entry: {}", + pos.symbol, pos.base_position_lots, pos.entry_price_usd + ); + } + } + + let orders = trader.all_orders(); + if orders.is_empty() { + println!("Orders: (none)"); + } else { + println!("Orders:"); + for order in orders { + println!( + " {} | {} {} @ {} | Remaining: {} lots", + order.symbol, + order.side, + order.order_type, + order.price_usd, + order.size_remaining_lots + ); + } + } + + println!(); + } + + println!("WebSocket connection closed"); + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/subscribe_trades.rs b/container/vendor/rise/rust/sdk/examples/subscribe_trades.rs new file mode 100644 index 00000000000..f2e531f26b0 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/subscribe_trades.rs @@ -0,0 +1,55 @@ +//! Example: Subscribe to trades updates via WebSocket. +//! +//! Run with: +//! export PHOENIX_WS_URL=wss://public-api.phoenix.trade/ws +//! export PHOENIX_API_KEY=your_api_key +//! cargo run -p phoenix-sdk --example subscribe_trades -- SOL + +use std::env; + +use phoenix_sdk::PhoenixWSClient; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing for debug output + tracing_subscriber::fmt() + .with_env_filter("phoenix_sdk=debug,info") + .init(); + + // Get market symbol from command line + let Some(market_symbol) = env::args().nth(1) else { + eprintln!("Usage: cargo run -p phoenix-sdk --example subscribe_trades -- "); + eprintln!("Example: cargo run -p phoenix-sdk --example subscribe_trades -- SOL"); + return Ok(()); + }; + + println!("Connecting to Phoenix WebSocket..."); + + // Connect to the WebSocket server (uses PHOENIX_WS_URL and PHOENIX_API_KEY env + // vars) + let client = PhoenixWSClient::new_from_env()?; + + println!("Subscribing to trades for: {}", market_symbol); + + // Subscribe to trades updates + let (mut rx, _handle) = client.subscribe_to_trades(market_symbol)?; + println!("Subscribed! Waiting for trades...\n"); + + // Process trades + while let Some(msg) = rx.recv().await { + for trade in &msg.trades { + println!("=== {} Trade ===", msg.symbol); + println!(" Side: {:?}", trade.side); + println!(" Base Amount: {}", trade.base_amount); + println!(" Quote Amount: {}", trade.quote_amount); + println!(" Taker: {}", trade.taker); + println!(" Timestamp: {}", trade.timestamp); + println!(" Seq Number: {}", trade.trade_sequence_number); + println!(" Num Fills: {}", trade.num_fills); + println!(); + } + } + + println!("WebSocket connection closed"); + Ok(()) +} diff --git a/container/vendor/rise/rust/sdk/examples/ws_debug_cli.rs b/container/vendor/rise/rust/sdk/examples/ws_debug_cli.rs new file mode 100644 index 00000000000..35ff43b2102 --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/ws_debug_cli.rs @@ -0,0 +1,450 @@ +//! Tiny WebSocket debug CLI driven by a TOML config file. +//! +//! Usage: +//! cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml +//! cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml --stats + +use std::env; +use std::error::Error; +use std::fs; +use std::io::{self, Write}; +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use phoenix_sdk::{ + AllMidsData, CandleData, FundingRateMessage, L2BookUpdate, MarketStatsUpdate, PhoenixEnv, + PhoenixWSClient, ServerMessage, SubscriptionHandle, Timeframe, TraderStatePayload, + TraderStateServerMessage, TradesMessage, WsConnectionStatus, +}; +use serde::Deserialize; +use serde_json::json; +use solana_pubkey::Pubkey; +use tokio::sync::mpsc; +use tokio::time::{self, MissedTickBehavior}; + +#[derive(Debug)] +struct CliArgs { + config_path: String, + stats: bool, +} + +#[derive(Debug, Default, Deserialize)] +struct WsDebugConfig { + #[serde(default)] + connection: ConnectionConfig, + #[serde(default)] + subscriptions: SubscriptionsConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct ConnectionConfig { + ws_url: Option, + api_key: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct SubscriptionsConfig { + #[serde(default)] + all_mids: bool, + #[serde(default)] + funding_rate: Vec, + #[serde(default)] + orderbook: Vec, + #[serde(default)] + market: Vec, + #[serde(default)] + trades: Vec, + #[serde(default)] + candles: Vec, + #[serde(default)] + trader_state: Vec, +} + +#[derive(Debug, Deserialize)] +struct CandlesSubscriptionConfig { + symbol: String, + timeframe: String, +} + +#[derive(Debug, Deserialize)] +struct TraderStateSubscriptionConfig { + authority: String, + #[serde(default)] + trader_pda_index: u8, +} + +enum IncomingEvent { + Status(WsConnectionStatus), + AllMids(AllMidsData), + FundingRate(FundingRateMessage), + Orderbook(L2BookUpdate), + Market(MarketStatsUpdate), + Trades(TradesMessage), + Candles(CandleData), + TraderState(TraderStateServerMessage), +} + +#[derive(Default)] +struct Metrics { + total: u64, + status: u64, + all_mids: u64, + funding_rate: u64, + orderbook: u64, + market: u64, + trades: u64, + candles: u64, + trader_state: u64, + trader_snapshots: u64, + trader_deltas: u64, + connection_open_since: Option, + last_connection_open: Option, +} + +impl Metrics { + fn record(&mut self, event: &IncomingEvent, now: Instant) { + self.total += 1; + match event { + IncomingEvent::Status(status) => { + self.status += 1; + match status { + WsConnectionStatus::Connected => { + self.connection_open_since = Some(now); + } + WsConnectionStatus::Disconnected(_) | WsConnectionStatus::ConnectionFailed => { + if let Some(since) = self.connection_open_since.take() { + self.last_connection_open = Some(now.saturating_duration_since(since)); + } + } + WsConnectionStatus::Connecting => {} + } + } + IncomingEvent::AllMids(_) => self.all_mids += 1, + IncomingEvent::FundingRate(_) => self.funding_rate += 1, + IncomingEvent::Orderbook(_) => self.orderbook += 1, + IncomingEvent::Market(_) => self.market += 1, + IncomingEvent::Trades(_) => self.trades += 1, + IncomingEvent::Candles(_) => self.candles += 1, + IncomingEvent::TraderState(msg) => { + self.trader_state += 1; + match &msg.content { + TraderStatePayload::Snapshot(_) => self.trader_snapshots += 1, + TraderStatePayload::Delta(_) => self.trader_deltas += 1, + } + } + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = parse_args().map_err(|e| format!("{e}\n\n{}", usage()))?; + let config = load_config(&args.config_path)?; + + let mut env_cfg = PhoenixEnv::load(); + if let Some(ws_url) = config.connection.ws_url { + env_cfg.ws_url = ws_url; + } + if config.connection.api_key.is_some() { + env_cfg.api_key = config.connection.api_key; + } + + let mut client = PhoenixWSClient::from_env_with_connection_status(env_cfg)?; + let status_rx = client + .connection_status_receiver() + .ok_or("connection status receiver unavailable")?; + + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + spawn_forwarder(status_rx, event_tx.clone(), IncomingEvent::Status); + + let mut _handles: Vec = Vec::new(); + let mut configured_streams = 0usize; + + if config.subscriptions.all_mids { + let (rx, handle) = client.subscribe_to_all_mids()?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::AllMids); + } + + for symbol in config.subscriptions.funding_rate { + let symbol = symbol.to_ascii_uppercase(); + let (rx, handle) = client.subscribe_to_funding_rate(symbol)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::FundingRate); + } + + for symbol in config.subscriptions.orderbook { + let symbol = symbol.to_ascii_uppercase(); + let (rx, handle) = client.subscribe_to_orderbook(symbol)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::Orderbook); + } + + for symbol in config.subscriptions.market { + let symbol = symbol.to_ascii_uppercase(); + let (rx, handle) = client.subscribe_to_market(symbol)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::Market); + } + + for symbol in config.subscriptions.trades { + let symbol = symbol.to_ascii_uppercase(); + let (rx, handle) = client.subscribe_to_trades(symbol)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::Trades); + } + + for sub in config.subscriptions.candles { + let symbol = sub.symbol.to_ascii_uppercase(); + let timeframe: Timeframe = sub.timeframe.parse().map_err(|e| { + format!( + "invalid timeframe '{}' for {}: {}", + sub.timeframe, symbol, e + ) + })?; + let (rx, handle) = client.subscribe_to_candles(symbol, timeframe)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::Candles); + } + + for sub in config.subscriptions.trader_state { + let authority = Pubkey::from_str(&sub.authority) + .map_err(|e| format!("invalid trader authority '{}': {}", sub.authority, e))?; + let (rx, handle) = + client.subscribe_to_trader_state_with_pda(&authority, sub.trader_pda_index)?; + _handles.push(handle); + configured_streams += 1; + spawn_forwarder(rx, event_tx.clone(), IncomingEvent::TraderState); + } + + if configured_streams == 0 { + return Err("no subscriptions enabled in config file".into()); + } + + println!( + "Connected and subscribed to {} stream(s). Press Ctrl+C to exit.", + configured_streams + ); + + drop(event_tx); + + let started = Instant::now(); + let mut metrics = Metrics::default(); + let mut ticker = time::interval(Duration::from_secs(1)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + if args.stats { + print_stats(&metrics, started); + } + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("\nShutting down..."); + break; + } + _ = ticker.tick(), if args.stats => { + print_stats(&metrics, started); + } + maybe_event = event_rx.recv() => { + match maybe_event { + Some(event) => { + metrics.record(&event, Instant::now()); + if args.stats { + continue; + } + print_event_json(event)?; + } + None => { + println!("All subscription channels closed."); + break; + } + } + } + } + } + + if args.stats { + print_stats(&metrics, started); + println!(); + } + + Ok(()) +} + +fn spawn_forwarder( + mut rx: mpsc::UnboundedReceiver, + tx: mpsc::UnboundedSender, + map: F, +) where + T: Send + 'static, + F: Fn(T) -> IncomingEvent + Send + 'static, +{ + tokio::spawn(async move { + while let Some(message) = rx.recv().await { + if tx.send(map(message)).is_err() { + break; + } + } + }); +} + +fn print_event_json(event: IncomingEvent) -> Result<(), serde_json::Error> { + match event { + IncomingEvent::Status(status) => { + let payload = match status { + WsConnectionStatus::Connecting => { + json!({"type":"connectionStatus","status":"connecting"}) + } + WsConnectionStatus::Connected => { + json!({"type":"connectionStatus","status":"connected"}) + } + WsConnectionStatus::ConnectionFailed => { + json!({"type":"connectionStatus","status":"connectionFailed"}) + } + WsConnectionStatus::Disconnected(reason) => { + json!({"type":"connectionStatus","status":"disconnected","reason":reason}) + } + }; + println!("🔌 {}", serde_json::to_string(&payload)?); + } + IncomingEvent::AllMids(msg) => { + println!( + "📈 {}", + serde_json::to_string(&ServerMessage::AllMids(msg))? + ); + } + IncomingEvent::FundingRate(msg) => { + println!( + "💸 {}", + serde_json::to_string(&ServerMessage::FundingRate(msg))? + ); + } + IncomingEvent::Orderbook(msg) => { + println!( + "📚 {}", + serde_json::to_string(&ServerMessage::Orderbook(msg))? + ); + } + IncomingEvent::Market(msg) => { + println!("📊 {}", serde_json::to_string(&ServerMessage::Market(msg))?); + } + IncomingEvent::Trades(msg) => { + println!("💱 {}", serde_json::to_string(&ServerMessage::Trades(msg))?); + } + IncomingEvent::Candles(msg) => { + println!( + "🕯️ {}", + serde_json::to_string(&ServerMessage::Candles(msg))? + ); + } + IncomingEvent::TraderState(msg) => { + println!( + "👤 {}", + serde_json::to_string(&ServerMessage::TraderState(msg))? + ); + } + } + Ok(()) +} + +fn print_stats(metrics: &Metrics, started: Instant) { + let elapsed = started.elapsed().as_secs_f64(); + let rate = if elapsed > 0.0 { + metrics.total as f64 / elapsed + } else { + 0.0 + }; + + print!("\x1B[2J\x1B[H"); + println!("Phoenix WS Debug Stats"); + match metrics.connection_open_since { + Some(since) => { + println!("🔗 connected_for {:.1}s", since.elapsed().as_secs_f64()); + } + None => { + if let Some(last) = metrics.last_connection_open { + println!( + "🔗 connected_for disconnected (last {:.1}s)", + last.as_secs_f64() + ); + } else { + println!("🔗 connected_for disconnected"); + } + } + } + println!("Elapsed: {:.1}s", elapsed); + println!("Total messages: {} ({:.2}/s)", metrics.total, rate); + println!(); + + let print_metric = |emoji: &str, label: &str, count: u64| { + let per_sec = if elapsed > 0.0 { + count as f64 / elapsed + } else { + 0.0 + }; + println!("{} {:<12} {:>6} ({:>6.2}/s)", emoji, label, count, per_sec); + }; + + print_metric("🔌", "status", metrics.status); + print_metric("📈", "all_mids", metrics.all_mids); + print_metric("💸", "funding_rate", metrics.funding_rate); + print_metric("📚", "orderbook", metrics.orderbook); + print_metric("📊", "market", metrics.market); + print_metric("💱", "trades", metrics.trades); + print_metric("🕯️", "candles", metrics.candles); + print_metric("👤", "trader_state", metrics.trader_state); + print_metric("📸", "snapshots", metrics.trader_snapshots); + print_metric("🧩", "deltas", metrics.trader_deltas); + let _ = io::stdout().flush(); +} + +fn load_config(path: &str) -> Result> { + let raw = fs::read_to_string(path)?; + let config: WsDebugConfig = toml::from_str(&raw)?; + Ok(config) +} + +fn parse_args() -> Result { + let mut args = env::args().skip(1); + let mut config_path: Option = None; + let mut stats = false; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => { + let path = args + .next() + .ok_or("--config requires a path argument".to_string())?; + config_path = Some(path); + } + "--stats" => { + stats = true; + } + "-h" | "--help" => { + println!("{}", usage()); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {}", other)); + } + } + } + + let config_path = config_path.ok_or("--config is required".to_string())?; + Ok(CliArgs { config_path, stats }) +} + +fn usage() -> &'static str { + "Usage: ws_debug_cli --config [--stats] + +Examples: + cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml + cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml --stats" +} diff --git a/container/vendor/rise/rust/sdk/examples/ws_debug_config.toml b/container/vendor/rise/rust/sdk/examples/ws_debug_config.toml new file mode 100644 index 00000000000..5ea541e355c --- /dev/null +++ b/container/vendor/rise/rust/sdk/examples/ws_debug_config.toml @@ -0,0 +1,31 @@ +# Tiny websocket debug config for `ws_debug_cli`. +# +# Run: +# cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml +# cargo run -p phoenix-sdk --example ws_debug_cli -- --config sdk/examples/ws_debug_config.toml --stats + +[connection] +# Optional. If omitted, PHOENIX_WS_URL/PHOENIX_API_URL env defaults are used. +# ws_url = "wss://public-api.phoenix.trade/ws" +# Optional. If omitted, PHOENIX_API_KEY env var is used if present. +# api_key = "your_api_key" + +[subscriptions] +all_mids = true + +# Symbol lists. +funding_rate = ["SOL"] +orderbook = ["SOL"] +market = ["SOL"] +trades = ["SOL"] + +# Candles subscriptions. +[[subscriptions.candles]] +symbol = "SOL" +timeframe = "1m" + +# Trader-state subscriptions. +# trader_pda_index is optional (default: 0). +[[subscriptions.trader_state]] +authority = "1imt7zeK3mE17dvdfztuEDhfoCUwnK8RVcjRzxnXLba" +trader_pda_index = 0 diff --git a/container/vendor/rise/rust/sdk/src/api/candles.rs b/container/vendor/rise/rust/sdk/src/api/candles.rs new file mode 100644 index 00000000000..b8d5171b7fc --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/candles.rs @@ -0,0 +1,43 @@ +use phoenix_types::{ApiCandle, CandlesQueryParams, PhoenixHttpError}; + +use crate::http_client::HttpClientInner; + +pub struct CandlesClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl CandlesClient<'_> { + pub async fn get_candles( + &self, + params: CandlesQueryParams, + ) -> Result, PhoenixHttpError> { + let url = format!("{}/candles", self.http.api_url); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[("symbol", ¶ms.symbol), ("timeframe", ¶ms.timeframe)]); + + if let Some(start_time) = params.start_time { + request = request.query(&[("startTime", start_time)]); + } + if let Some(end_time) = params.end_time { + request = request.query(&[("endTime", end_time)]); + } + if let Some(limit) = params.limit { + request = request.query(&[("limit", limit)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse candles response: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/collateral.rs b/container/vendor/rise/rust/sdk/src/api/collateral.rs new file mode 100644 index 00000000000..d26cf590850 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/collateral.rs @@ -0,0 +1,74 @@ +use phoenix_types::{ + CollateralHistoryQueryParams, CollateralHistoryResponse, PhoenixHttpError, TraderKey, +}; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; + +pub struct CollateralClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl CollateralClient<'_> { + pub async fn get_user_collateral_history( + &self, + authority: &Pubkey, + params: CollateralHistoryQueryParams, + ) -> Result { + self.get_collateral_history_internal(authority, params).await + } + + pub async fn get_trader_collateral_history( + &self, + trader_key: &TraderKey, + params: CollateralHistoryQueryParams, + ) -> Result { + let params = params.with_pda_index(trader_key.pda_index); + self.get_collateral_history_internal(&trader_key.authority(), params) + .await + } + + async fn get_collateral_history_internal( + &self, + authority: &Pubkey, + params: CollateralHistoryQueryParams, + ) -> Result { + let url = format!( + "{}/trader/{}/collateral-history", + self.http.api_url, authority + ); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[ + ("pdaIndex", params.pda_index.to_string()), + ("limit", params.request.limit.to_string()), + ]); + + if let Some(next_cursor) = ¶ms.request.next_cursor { + request = request.query(&[("nextCursor", next_cursor)]); + } + if let Some(prev_cursor) = ¶ms.request.prev_cursor { + request = request.query(&[("prevCursor", prev_cursor)]); + } + if let Some(cursor) = ¶ms.request.cursor { + request = request.query(&[("cursor", cursor)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!( + "Failed to parse CollateralHistoryResponse: {}", + e + )) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/exchange.rs b/container/vendor/rise/rust/sdk/src/api/exchange.rs new file mode 100644 index 00000000000..78dd93ea81f --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/exchange.rs @@ -0,0 +1,51 @@ +use phoenix_types::{ExchangeKeysView, ExchangeResponse, PhoenixHttpError}; + +use crate::http_client::HttpClientInner; + +pub struct ExchangeClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl ExchangeClient<'_> { + pub async fn get_exchange(&self) -> Result { + let url = format!("{}/exchange", self.http.api_url); + + let response = self + .http + .send_with_rate_limit_retry(self.http.maybe_add_api_key(self.http.client.get(&url))) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + let body = response.text().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to read response body: {}", e)) + })?; + + serde_json::from_str(&body).map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse ExchangeResponse: {}", e)) + }) + } + + pub async fn get_keys(&self) -> Result { + let url = format!("{}/exchange/keys", self.http.api_url); + + let response = self + .http + .send_with_rate_limit_retry(self.http.maybe_add_api_key(self.http.client.get(&url))) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse ExchangeKeysView: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/funding.rs b/container/vendor/rise/rust/sdk/src/api/funding.rs new file mode 100644 index 00000000000..bb0eda78d70 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/funding.rs @@ -0,0 +1,77 @@ +use phoenix_types::{ + FundingHistoryQueryParams, FundingHistoryResponse, PhoenixHttpError, TraderKey, +}; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; + +pub struct FundingClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl FundingClient<'_> { + pub async fn get_user_funding_history( + &self, + authority: &Pubkey, + params: FundingHistoryQueryParams, + ) -> Result { + self.get_funding_history_internal(authority, params).await + } + + pub async fn get_trader_funding_history( + &self, + trader_key: &TraderKey, + params: FundingHistoryQueryParams, + ) -> Result { + let params = params.with_pda_index(trader_key.pda_index); + self.get_funding_history_internal(&trader_key.authority(), params) + .await + } + + async fn get_funding_history_internal( + &self, + authority: &Pubkey, + params: FundingHistoryQueryParams, + ) -> Result { + let url = format!( + "{}/trader/{}/funding-history", + self.http.api_url, authority + ); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)); + + if let Some(symbol) = ¶ms.symbol { + request = request.query(&[("symbol", symbol)]); + } + if let Some(start_time) = params.start_time { + request = request.query(&[("startTime", start_time)]); + } + if let Some(end_time) = params.end_time { + request = request.query(&[("endTime", end_time)]); + } + if let Some(limit) = params.limit { + request = request.query(&[("limit", limit)]); + } + if let Some(cursor) = ¶ms.cursor { + request = request.query(&[("cursor", cursor)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + let body = response.text().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to read response body: {}", e)) + })?; + + serde_json::from_str(&body).map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse FundingHistoryResponse: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/invite.rs b/container/vendor/rise/rust/sdk/src/api/invite.rs new file mode 100644 index 00000000000..351eae9b23d --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/invite.rs @@ -0,0 +1,74 @@ +use phoenix_types::PhoenixHttpError; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; + +pub struct InviteClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl InviteClient<'_> { + pub async fn activate_invite( + &self, + authority: &Pubkey, + code: &str, + ) -> Result { + let url = format!("{}/v1/invite/activate", self.http.api_url); + + let response = self + .http + .client + .post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "authority": authority.to_string(), + "code": code, + })) + .send() + .await + .map_err(PhoenixHttpError::RequestFailed)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response + .text() + .await + .map_err(|e| PhoenixHttpError::ParseFailed(format!("Failed to read response: {}", e))) + } + + pub async fn activate_referral( + &self, + authority: &Pubkey, + referral_code: &str, + ) -> Result { + let url = format!("{}/v1/invite/activate-with-referral", self.http.api_url); + + let response = self + .http + .client + .post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "authority": authority.to_string(), + "referral_code": referral_code, + })) + .send() + .await + .map_err(PhoenixHttpError::RequestFailed)?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response + .text() + .await + .map_err(|e| PhoenixHttpError::ParseFailed(format!("Failed to read response: {}", e))) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/markets.rs b/container/vendor/rise/rust/sdk/src/api/markets.rs new file mode 100644 index 00000000000..02741d6308c --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/markets.rs @@ -0,0 +1,52 @@ +use phoenix_types::{ExchangeMarketConfig, PhoenixHttpError}; + +use crate::http_client::HttpClientInner; + +pub struct MarketsClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl MarketsClient<'_> { + pub async fn get_markets(&self) -> Result, PhoenixHttpError> { + let url = format!("{}/exchange/markets", self.http.api_url); + + let response = self + .http + .send_with_rate_limit_retry(self.http.maybe_add_api_key(self.http.client.get(&url))) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response + .json() + .await + .map_err(|e| PhoenixHttpError::ParseFailed(format!("Failed to parse markets: {}", e))) + } + + pub async fn get_market( + &self, + symbol: &str, + ) -> Result { + let symbol_upper = symbol.to_ascii_uppercase(); + let url = format!("{}/exchange/market/{}", self.http.api_url, symbol_upper); + + let response = self + .http + .send_with_rate_limit_retry(self.http.maybe_add_api_key(self.http.client.get(&url))) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse ExchangeMarketConfig: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/mod.rs b/container/vendor/rise/rust/sdk/src/api/mod.rs new file mode 100644 index 00000000000..48e1bb6aa99 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/mod.rs @@ -0,0 +1,19 @@ +mod candles; +mod collateral; +mod exchange; +mod funding; +mod invite; +mod markets; +mod orders; +mod traders; +mod trades; + +pub use candles::CandlesClient; +pub use collateral::CollateralClient; +pub use exchange::ExchangeClient; +pub use funding::FundingClient; +pub use invite::InviteClient; +pub use markets::MarketsClient; +pub use orders::OrdersClient; +pub use traders::TradersClient; +pub use trades::TradesClient; diff --git a/container/vendor/rise/rust/sdk/src/api/orders.rs b/container/vendor/rise/rust/sdk/src/api/orders.rs new file mode 100644 index 00000000000..1ab7eb36823 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/orders.rs @@ -0,0 +1,226 @@ +use phoenix_ix::{IsolatedCollateralFlow, Side}; +use phoenix_types::{ + ApiInstructionResponse, OrderHistoryQueryParams, OrderHistoryResponse, PhoenixHttpError, + PlaceIsolatedLimitOrderRequest, PlaceIsolatedMarketOrderRequest, TraderKey, +}; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; +use crate::tx_builder::BracketLegOrders; + +pub struct OrdersClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl OrdersClient<'_> { + pub async fn get_trader_order_history( + &self, + authority: &Pubkey, + params: OrderHistoryQueryParams, + ) -> Result { + self.get_order_history_internal(authority, params).await + } + + pub async fn get_trader_order_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: OrderHistoryQueryParams, + ) -> Result { + let params = params.with_pda_index(trader_key.pda_index); + self.get_order_history_internal(&trader_key.authority(), params) + .await + } + + async fn get_order_history_internal( + &self, + authority: &Pubkey, + params: OrderHistoryQueryParams, + ) -> Result { + let url = format!("{}/trader/{}/order-history", self.http.api_url, authority); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[("limit", params.limit)]); + + if let Some(pda_index) = params.trader_pda_index { + request = request.query(&[("traderPdaIndex", pda_index)]); + } + if let Some(market_symbol) = ¶ms.market_symbol { + request = request.query(&[("marketSymbol", market_symbol)]); + } + if let Some(cursor) = ¶ms.cursor { + request = request.query(&[("cursor", cursor)]); + } + if let Some(privy_id) = ¶ms.privy_id { + request = request.query(&[("privyId", privy_id)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse OrderHistoryResponse: {}", e)) + }) + } + + pub async fn build_isolated_limit_order_tx( + &self, + authority: &Pubkey, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + ) -> Result, PhoenixHttpError> { + let transfer_amount = collateral_transfer_amount(&collateral)?; + + let request = PlaceIsolatedLimitOrderRequest { + authority: authority.to_string(), + symbol: symbol.to_string(), + side: side.to_api_string().to_string(), + price: Some(price), + num_base_lots: Some(num_base_lots), + transfer_amount, + allow_cross_and_isolated_for_asset: Some(allow_cross_and_isolated), + ..Default::default() + }; + + self.build_isolated_limit_order_tx_with_request(request) + .await + } + + pub async fn build_isolated_limit_order_tx_with_request( + &self, + request: PlaceIsolatedLimitOrderRequest, + ) -> Result, PhoenixHttpError> { + let url = format!("{}/ix/place-isolated-limit-order", self.http.api_url); + + let response = self + .http + .send_with_rate_limit_retry( + self.http + .maybe_add_api_key(self.http.client.post(&url)) + .json(&request), + ) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + let api_ixs: Vec = response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse instruction response: {}", e)) + })?; + + api_ixs.into_iter().map(try_into_instruction).collect() + } + + pub async fn build_isolated_market_order_tx( + &self, + authority: &Pubkey, + symbol: &str, + side: Side, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixHttpError> { + let transfer_amount = collateral_transfer_amount(&collateral)?; + let tp_sl = bracket.map(BracketLegOrders::to_tp_sl_config); + + let request = PlaceIsolatedMarketOrderRequest { + authority: authority.to_string(), + symbol: symbol.to_string(), + side: side.to_api_string().to_string(), + num_base_lots: Some(num_base_lots), + transfer_amount, + allow_cross_and_isolated_for_asset: Some(allow_cross_and_isolated), + tp_sl, + ..Default::default() + }; + + self.build_isolated_market_order_tx_with_request(request) + .await + } + + pub async fn build_isolated_market_order_tx_with_request( + &self, + request: PlaceIsolatedMarketOrderRequest, + ) -> Result, PhoenixHttpError> { + let url = format!("{}/ix/place-isolated-market-order", self.http.api_url); + + let response = self + .http + .send_with_rate_limit_retry( + self.http + .maybe_add_api_key(self.http.client.post(&url)) + .json(&request), + ) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + let api_ixs: Vec = response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse instruction response: {}", e)) + })?; + + api_ixs.into_iter().map(try_into_instruction).collect() + } +} + +fn collateral_transfer_amount( + collateral: &Option, +) -> Result { + match collateral { + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }) => Ok(*collateral), + Some(IsolatedCollateralFlow::Deposit { .. }) => Err(PhoenixHttpError::ApiError { + status: 0, + message: "IsolatedCollateralFlow::Deposit is not supported by the server-side \ + endpoint; use TransferFromCrossMargin instead" + .to_string(), + }), + None => Ok(0), + } +} + +fn try_into_instruction(api_ix: ApiInstructionResponse) -> Result { + let program_id: Pubkey = api_ix + .program_id + .parse() + .map_err(|e| PhoenixHttpError::ParseFailed(format!("Invalid program_id pubkey: {}", e)))?; + + let accounts = api_ix + .keys + .into_iter() + .map(|meta| { + let pubkey: Pubkey = meta.pubkey.parse().map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Invalid account pubkey: {}", e)) + })?; + Ok(if meta.is_writable { + AccountMeta::new(pubkey, meta.is_signer) + } else { + AccountMeta::new_readonly(pubkey, meta.is_signer) + }) + }) + .collect::, PhoenixHttpError>>()?; + + Ok(Instruction::new_with_bytes( + program_id, + &api_ix.data, + accounts, + )) +} diff --git a/container/vendor/rise/rust/sdk/src/api/traders.rs b/container/vendor/rise/rust/sdk/src/api/traders.rs new file mode 100644 index 00000000000..6ac3e56e6b3 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/traders.rs @@ -0,0 +1,81 @@ +use phoenix_types::{PhoenixHttpError, PnlPoint, PnlQueryParams, TraderStateResponse, TraderView}; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; + +pub struct TradersClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl TradersClient<'_> { + pub async fn get_trader( + &self, + authority: &Pubkey, + ) -> Result, PhoenixHttpError> { + self.get_trader_internal(authority, 0).await + } + + pub async fn get_trader_internal( + &self, + authority: &Pubkey, + pda_index: u8, + ) -> Result, PhoenixHttpError> { + let url = format!("{}/trader/{}/state", self.http.api_url, authority); + + let response = self + .http + .send_with_rate_limit_retry( + self.http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[("pdaIndex", pda_index)]), + ) + .await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + let resp: TraderStateResponse = response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse TraderStateResponse: {}", e)) + })?; + + Ok(resp.traders) + } + + pub async fn get_trader_pnl( + &self, + authority: &Pubkey, + params: PnlQueryParams, + ) -> Result, PhoenixHttpError> { + let url = format!("{}/trader/{}/pnl", self.http.api_url, authority); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[("resolution", params.resolution.to_string())]); + + if let Some(start_time) = params.start_time { + request = request.query(&[("startTime", start_time)]); + } + if let Some(end_time) = params.end_time { + request = request.query(&[("endTime", end_time)]); + } + if let Some(limit) = params.limit { + request = request.query(&[("limit", limit)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse PnL response: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/api/trades.rs b/container/vendor/rise/rust/sdk/src/api/trades.rs new file mode 100644 index 00000000000..46b73a32714 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/api/trades.rs @@ -0,0 +1,68 @@ +use phoenix_types::{ + PhoenixHttpError, TradeHistoryQueryParams, TradeHistoryResponse, TraderKey, +}; +use solana_pubkey::Pubkey; + +use crate::http_client::HttpClientInner; + +pub struct TradesClient<'a> { + pub(crate) http: &'a HttpClientInner, +} + +impl TradesClient<'_> { + pub async fn get_trader_trade_history( + &self, + authority: &Pubkey, + params: TradeHistoryQueryParams, + ) -> Result { + self.get_trade_history_internal(authority, params).await + } + + pub async fn get_trader_trade_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: TradeHistoryQueryParams, + ) -> Result { + let params = params.with_pda_index(trader_key.pda_index); + self.get_trade_history_internal(&trader_key.authority(), params) + .await + } + + async fn get_trade_history_internal( + &self, + authority: &Pubkey, + params: TradeHistoryQueryParams, + ) -> Result { + let url = format!( + "{}/trader/{}/trades-history", + self.http.api_url, authority + ); + + let mut request = self + .http + .maybe_add_api_key(self.http.client.get(&url)) + .query(&[("pdaIndex", params.pda_index)]); + + if let Some(market_symbol) = ¶ms.market_symbol { + request = request.query(&[("market_symbol", market_symbol)]); + } + if let Some(limit) = params.limit { + request = request.query(&[("limit", limit)]); + } + if let Some(cursor) = ¶ms.cursor { + request = request.query(&[("cursor", cursor)]); + } + + let response = self.http.send_with_rate_limit_retry(request).await?; + + if !response.status().is_success() { + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + return Err(PhoenixHttpError::ApiError { status, message }); + } + + response.json().await.map_err(|e| { + PhoenixHttpError::ParseFailed(format!("Failed to parse TradeHistoryResponse: {}", e)) + }) + } +} diff --git a/container/vendor/rise/rust/sdk/src/client.rs b/container/vendor/rise/rust/sdk/src/client.rs new file mode 100644 index 00000000000..a918f388c27 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/client.rs @@ -0,0 +1,990 @@ +//! Unified Phoenix client managing WebSocket subscriptions, reconnection, +//! and receiver-based events. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::stream::{BoxStream, StreamExt}; +use parking_lot::Mutex; +use phoenix_types::{ + ClientCommand, ClientSubscriptionId, LogicalSubscription, MarginTrigger, Market, + PhoenixClientError, PhoenixClientEvent, PhoenixClientSubscriptionHandle, PhoenixMetadata, + PhoenixSubscription, PhoenixWsError, RuntimeState, ServerMessage, SubscriptionKey, Trader, + TraderKey, +}; +use solana_pubkey::Pubkey; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_stream::StreamMap; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tracing::{debug, error, info, warn}; + +use crate::env::PhoenixEnv; +use crate::http_client::PhoenixHttpClient; +use crate::ws_client::{PhoenixWSClient, SubscriptionHandle, WsConnectionStatus}; + +/// Internal shared state for PhoenixClient handle clones. +struct PhoenixClientInner { + http_client: PhoenixHttpClient, + cmd_tx: mpsc::UnboundedSender, + task_handle: Mutex>>, +} + +/// Unified high-level client for the Phoenix perpetuals exchange. +/// +/// - Auto-reconnects WebSocket connections +/// - Keeps state lock-free (single-owner state inside background task) +/// - Emits subscription updates via receivers (no callbacks) +pub struct PhoenixClient { + inner: Arc, +} + +impl Clone for PhoenixClient { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl PhoenixClient { + /// Create a new PhoenixClient from environment variables. + pub async fn new_from_env() -> Result { + Self::from_env(PhoenixEnv::load()).await + } + + /// Create a new PhoenixClient from a [`PhoenixEnv`]. + pub async fn from_env(env: PhoenixEnv) -> Result { + let http_client = PhoenixHttpClient::from_env(env.clone()); + let exchange_response = http_client + .get_exchange() + .await + .map_err(PhoenixClientError::Http)?; + let metadata = PhoenixMetadata::new(exchange_response.into()); + + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let inner = Arc::new(PhoenixClientInner { + http_client, + cmd_tx, + task_handle: Mutex::new(None), + }); + + let client = Self { + inner: Arc::clone(&inner), + }; + + let task_handle = tokio::spawn(Self::connection_loop(env, cmd_rx, metadata)); + *client.inner.task_handle.lock() = Some(task_handle); + + Ok(client) + } + + /// Subscribe to a high-level subscription and receive events. + pub async fn subscribe( + &self, + subscription: PhoenixSubscription, + ) -> Result< + ( + mpsc::UnboundedReceiver, + PhoenixClientSubscriptionHandle, + ), + PhoenixClientError, + > { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + self.inner + .cmd_tx + .send(ClientCommand::Subscribe { + subscription, + response_tx, + }) + .map_err(|_| PhoenixClientError::SendFailed)?; + + let (subscription_id, rx) = response_rx + .await + .map_err(|_| PhoenixClientError::ResponseDropped)??; + + Ok(( + rx, + PhoenixClientSubscriptionHandle { + cmd_tx: self.inner.cmd_tx.clone(), + subscription_id, + }, + )) + } + + /// Access the HTTP client for REST API calls. + pub fn http(&self) -> &PhoenixHttpClient { + &self.inner.http_client + } + + /// Signal the background task to shut down. + pub fn shutdown(&self) { + let _ = self.inner.cmd_tx.send(ClientCommand::Shutdown); + } + + /// Block until the background task exits (e.g. after `shutdown()`). + pub async fn run(&self) { + let handle = self.inner.task_handle.lock().take(); + if let Some(handle) = handle { + let _ = handle.await; + } + } + + async fn connection_loop( + env: PhoenixEnv, + mut cmd_rx: mpsc::UnboundedReceiver, + metadata: PhoenixMetadata, + ) { + let mut runtime_state = RuntimeState::new(metadata); + let mut logical_subscriptions: HashMap = + HashMap::new(); + let mut subscribers_by_key: HashMap> = + HashMap::new(); + let mut dependency_refcounts: HashMap = HashMap::new(); + let mut next_subscription_id: ClientSubscriptionId = 1; + + let mut backoff_ms = 1_000u64; + const MAX_BACKOFF_MS: u64 = 30_000; + + 'reconnect: loop { + let mut ws_client = match PhoenixWSClient::from_env_with_connection_status(env.clone()) + { + Ok(client) => { + backoff_ms = 1_000; + client + } + Err(e) => { + error!("Failed to create WS client: {:?}", e); + if !Self::wait_with_command_processing( + Duration::from_millis(backoff_ms), + &mut cmd_rx, + &runtime_state, + &mut logical_subscriptions, + &mut subscribers_by_key, + &mut dependency_refcounts, + &mut next_subscription_id, + ) + .await + { + return; + } + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + continue 'reconnect; + } + }; + + let mut status_rx = match ws_client.connection_status_receiver() { + Some(status_rx) => status_rx, + None => { + error!( + "PhoenixWSClient missing status receiver; expected \ + from_env_with_connection_status to enable it" + ); + return; + } + }; + let mut ws_handles: HashMap = HashMap::new(); + let mut ws_streams: StreamMap> = + StreamMap::new(); + + for key in dependency_refcounts.keys() { + if let Err(e) = + Self::open_dependency(&ws_client, key, &mut ws_handles, &mut ws_streams) + { + warn!("Failed to restore subscription {:?}: {:?}", key, e); + } + } + + loop { + tokio::select! { + status = status_rx.recv() => { + match status { + Some(WsConnectionStatus::Connected) => { + debug!("PhoenixClient WebSocket connected"); + backoff_ms = 1_000; + } + Some(WsConnectionStatus::Connecting) => { + debug!("PhoenixClient WebSocket connecting"); + } + Some(WsConnectionStatus::Disconnected(reason)) => { + warn!("PhoenixClient WebSocket disconnected: {}", reason); + drop(ws_handles); + if !Self::wait_with_command_processing( + Duration::from_millis(backoff_ms), + &mut cmd_rx, + &runtime_state, + &mut logical_subscriptions, + &mut subscribers_by_key, + &mut dependency_refcounts, + &mut next_subscription_id, + ).await { + return; + } + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + continue 'reconnect; + } + Some(WsConnectionStatus::ConnectionFailed) => { + warn!("PhoenixClient WebSocket connection failed"); + drop(ws_handles); + if !Self::wait_with_command_processing( + Duration::from_millis(backoff_ms), + &mut cmd_rx, + &runtime_state, + &mut logical_subscriptions, + &mut subscribers_by_key, + &mut dependency_refcounts, + &mut next_subscription_id, + ).await { + return; + } + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + continue 'reconnect; + } + None => { + warn!("PhoenixClient connection status channel closed"); + drop(ws_handles); + continue 'reconnect; + } + } + } + + cmd = cmd_rx.recv() => { + if !Self::handle_command_connected( + cmd, + &runtime_state, + &ws_client, + &mut ws_handles, + &mut ws_streams, + &mut logical_subscriptions, + &mut subscribers_by_key, + &mut dependency_refcounts, + &mut next_subscription_id, + ) { + info!("PhoenixClient shutting down"); + return; + } + } + + ws_message = ws_streams.next(), if !ws_streams.is_empty() => { + if let Some((key, message)) = ws_message { + let stale = Self::handle_ws_message( + &key, + message, + &mut runtime_state, + &mut logical_subscriptions, + &subscribers_by_key, + ); + + if !stale.is_empty() { + let deactivated = Self::remove_subscriptions( + &stale, + &mut logical_subscriptions, + &mut subscribers_by_key, + &mut dependency_refcounts, + ); + + for key in deactivated { + Self::close_dependency(&key, &mut ws_handles, &mut ws_streams); + } + } + } + } + } + } + } + } + + async fn wait_with_command_processing( + delay: Duration, + cmd_rx: &mut mpsc::UnboundedReceiver, + runtime_state: &RuntimeState, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + next_subscription_id: &mut ClientSubscriptionId, + ) -> bool { + let sleep = tokio::time::sleep(delay); + tokio::pin!(sleep); + + loop { + tokio::select! { + _ = &mut sleep => { + return true; + } + cmd = cmd_rx.recv() => { + if !Self::handle_command_offline( + cmd, + runtime_state, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + next_subscription_id, + ) { + return false; + } + } + } + } + } + + fn handle_command_offline( + cmd: Option, + runtime_state: &RuntimeState, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + next_subscription_id: &mut ClientSubscriptionId, + ) -> bool { + match cmd { + Some(ClientCommand::Subscribe { + subscription, + response_tx, + }) => { + let result = Self::register_subscription( + subscription, + runtime_state, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + next_subscription_id, + ) + .map(|(id, rx, _)| (id, rx)); + let _ = response_tx.send(result); + true + } + Some(ClientCommand::Unsubscribe { subscription_id }) => { + let _ = Self::remove_subscription( + subscription_id, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + ); + true + } + Some(ClientCommand::Shutdown) | None => false, + } + } + + fn handle_command_connected( + cmd: Option, + runtime_state: &RuntimeState, + ws_client: &PhoenixWSClient, + ws_handles: &mut HashMap, + ws_streams: &mut StreamMap>, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + next_subscription_id: &mut ClientSubscriptionId, + ) -> bool { + match cmd { + Some(ClientCommand::Subscribe { + subscription, + response_tx, + }) => { + let result = match Self::register_subscription( + subscription, + runtime_state, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + next_subscription_id, + ) { + Ok((id, rx, activated_keys)) => { + for key in &activated_keys { + if let Err(e) = + Self::open_dependency(ws_client, key, ws_handles, ws_streams) + { + warn!("Failed to open subscription {:?}: {:?}", key, e); + } + } + Ok((id, rx)) + } + Err(e) => Err(e), + }; + + let _ = response_tx.send(result); + true + } + Some(ClientCommand::Unsubscribe { subscription_id }) => { + let deactivated = Self::remove_subscription( + subscription_id, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + ); + + for key in deactivated { + Self::close_dependency(&key, ws_handles, ws_streams); + } + + true + } + Some(ClientCommand::Shutdown) | None => false, + } + } + + fn register_subscription( + subscription: PhoenixSubscription, + runtime_state: &RuntimeState, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + next_subscription_id: &mut ClientSubscriptionId, + ) -> Result< + ( + ClientSubscriptionId, + mpsc::UnboundedReceiver, + Vec, + ), + PhoenixClientError, + > { + let dependencies = Self::resolve_dependencies(&subscription, &runtime_state.metadata); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + + let subscription_id = *next_subscription_id; + *next_subscription_id = next_subscription_id.saturating_add(1); + + let mut activated = Vec::new(); + + for key in &dependencies { + subscribers_by_key + .entry(key.clone()) + .or_default() + .insert(subscription_id); + + let count = dependency_refcounts.entry(key.clone()).or_insert(0); + *count += 1; + if *count == 1 { + activated.push(key.clone()); + } + } + + logical_subscriptions.insert( + subscription_id, + LogicalSubscription { + subscription, + dependencies, + event_tx, + }, + ); + + Ok((subscription_id, event_rx, activated)) + } + + fn remove_subscriptions( + subscription_ids: &[ClientSubscriptionId], + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + ) -> Vec { + let mut deactivated = HashSet::new(); + for subscription_id in subscription_ids { + for key in Self::remove_subscription( + *subscription_id, + logical_subscriptions, + subscribers_by_key, + dependency_refcounts, + ) { + deactivated.insert(key); + } + } + deactivated.into_iter().collect() + } + + fn remove_subscription( + subscription_id: ClientSubscriptionId, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &mut HashMap>, + dependency_refcounts: &mut HashMap, + ) -> Vec { + let mut deactivated = Vec::new(); + + let Some(logical) = logical_subscriptions.remove(&subscription_id) else { + return deactivated; + }; + + for key in logical.dependencies { + if let Some(ids) = subscribers_by_key.get_mut(&key) { + ids.remove(&subscription_id); + if ids.is_empty() { + subscribers_by_key.remove(&key); + } + } + + if let Some(count) = dependency_refcounts.get_mut(&key) { + if *count > 1 { + *count -= 1; + } else { + dependency_refcounts.remove(&key); + deactivated.push(key); + } + } + } + + deactivated + } + + fn resolve_dependencies( + subscription: &PhoenixSubscription, + metadata: &PhoenixMetadata, + ) -> HashSet { + let mut dependencies = HashSet::new(); + + match subscription { + PhoenixSubscription::Key(key) => { + dependencies.insert(key.clone()); + } + PhoenixSubscription::Market { + symbol, + candle_timeframes, + include_trades, + } => { + let symbol = symbol.to_ascii_uppercase(); + + dependencies.insert(SubscriptionKey::market(symbol.clone())); + dependencies.insert(SubscriptionKey::orderbook(symbol.clone())); + dependencies.insert(SubscriptionKey::funding_rate(symbol.clone())); + + for timeframe in candle_timeframes { + dependencies.insert(SubscriptionKey::candles(symbol.clone(), *timeframe)); + } + + if *include_trades { + dependencies.insert(SubscriptionKey::trades(symbol)); + } + } + PhoenixSubscription::TraderMargin { + authority, + trader_pda_index, + market_symbols, + .. + } => { + dependencies.insert(SubscriptionKey::trader(authority, *trader_pda_index)); + + if market_symbols.is_empty() { + for symbol in metadata.exchange().markets.keys() { + dependencies.insert(SubscriptionKey::market(symbol.clone())); + } + } else { + for symbol in market_symbols { + dependencies.insert(SubscriptionKey::market(symbol.to_ascii_uppercase())); + } + } + } + } + + dependencies + } + + fn open_dependency( + ws_client: &PhoenixWSClient, + key: &SubscriptionKey, + ws_handles: &mut HashMap, + ws_streams: &mut StreamMap>, + ) -> Result<(), PhoenixWsError> { + if ws_handles.contains_key(key) { + return Ok(()); + } + + match key { + SubscriptionKey::AllMids => { + let (rx, handle) = ws_client.subscribe_to_all_mids()?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::AllMids) + .boxed(), + ); + } + SubscriptionKey::FundingRate { symbol } => { + let (rx, handle) = ws_client.subscribe_to_funding_rate(symbol.clone())?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::FundingRate) + .boxed(), + ); + } + SubscriptionKey::Orderbook { symbol } => { + let (rx, handle) = ws_client.subscribe_to_orderbook(symbol.clone())?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::Orderbook) + .boxed(), + ); + } + SubscriptionKey::TraderState { + authority, + trader_pda_index, + } => { + let authority = match authority.parse::() { + Ok(authority) => authority, + Err(e) => { + warn!( + "Invalid trader authority in subscription key {}: {}", + authority, e + ); + return Ok(()); + } + }; + + let (rx, handle) = + ws_client.subscribe_to_trader_state_with_pda(&authority, *trader_pda_index)?; + + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::TraderState) + .boxed(), + ); + } + SubscriptionKey::Market { symbol } => { + let (rx, handle) = ws_client.subscribe_to_market(symbol.clone())?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::Market) + .boxed(), + ); + } + SubscriptionKey::Trades { symbol } => { + let (rx, handle) = ws_client.subscribe_to_trades(symbol.clone())?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::Trades) + .boxed(), + ); + } + SubscriptionKey::Candles { symbol, timeframe } => { + let (rx, handle) = ws_client.subscribe_to_candles(symbol.clone(), *timeframe)?; + ws_handles.insert(key.clone(), handle); + ws_streams.insert( + key.clone(), + UnboundedReceiverStream::new(rx) + .map(ServerMessage::Candles) + .boxed(), + ); + } + } + + Ok(()) + } + + fn close_dependency( + key: &SubscriptionKey, + ws_handles: &mut HashMap, + ws_streams: &mut StreamMap>, + ) { + ws_handles.remove(key); + ws_streams.remove(key); + } + + fn handle_ws_message( + key: &SubscriptionKey, + message: ServerMessage, + runtime_state: &mut RuntimeState, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &HashMap>, + ) -> Vec { + let mut stale = Vec::new(); + + match message { + ServerMessage::Market(update) => { + let symbol = update.symbol.clone(); + let prev_market = runtime_state.markets.get(&symbol).cloned(); + + let market = runtime_state + .markets + .entry(symbol.clone()) + .or_insert_with(|| Market::from_symbol(symbol.clone())); + market.apply_market_stats_update(&update); + + if let Err(e) = runtime_state.metadata.apply_market_stats(&update) { + warn!("Failed to apply market stats for {}: {}", symbol, e); + } + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::MarketUpdate { + symbol, + prev_market, + update: update.clone(), + }, + logical_subscriptions, + subscribers_by_key, + )); + + stale.extend(Self::dispatch_margin_events( + key, + MarginTrigger::Market(update), + None, + runtime_state, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::Orderbook(update) => { + let symbol = update.symbol.clone(); + let prev_market = runtime_state.markets.get(&symbol).cloned(); + + let market = runtime_state + .markets + .entry(symbol.clone()) + .or_insert_with(|| Market::from_symbol(symbol.clone())); + market.apply_l2_book_update(&update); + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::OrderbookUpdate { + symbol, + prev_market, + update, + }, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::TraderState(update) => { + let trader_key = match key { + SubscriptionKey::TraderState { + authority, + trader_pda_index, + } => { + let authority_pubkey = match authority.parse::() { + Ok(authority) => authority, + Err(e) => { + warn!("Invalid trader authority {}: {}", authority, e); + return stale; + } + }; + SubscriptionKey::trader(&authority_pubkey, *trader_pda_index) + } + _ => { + warn!("Received trader message for non-trader key: {:?}", key); + return stale; + } + }; + + let prev_trader = runtime_state.traders.get(&trader_key).cloned(); + + let (authority, pda_index) = match &trader_key { + SubscriptionKey::TraderState { + authority, + trader_pda_index, + } => (authority, *trader_pda_index), + _ => unreachable!(), + }; + + let authority_pubkey = match authority.parse::() { + Ok(authority) => authority, + Err(e) => { + warn!("Invalid trader authority {}: {}", authority, e); + return stale; + } + }; + + let trader = runtime_state + .traders + .entry(trader_key.clone()) + .or_insert_with(|| { + Trader::new(TraderKey::from_authority_with_idx( + authority_pubkey, + pda_index, + 0, + )) + }); + trader.apply_update(&update); + + stale.extend(Self::dispatch_raw_event( + &trader_key, + PhoenixClientEvent::TraderUpdate { + key: trader_key.clone(), + prev_trader: prev_trader.clone(), + update: update.clone(), + }, + logical_subscriptions, + subscribers_by_key, + )); + + stale.extend(Self::dispatch_margin_events( + &trader_key, + MarginTrigger::Trader(update), + prev_trader, + runtime_state, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::AllMids(update) => { + let prev_mids = runtime_state.mids.clone(); + runtime_state.mids = update.mids.clone(); + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::MidsUpdate { prev_mids, update }, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::FundingRate(update) => { + let symbol = update.symbol.clone(); + let prev_funding_rate = runtime_state + .funding_rates + .insert(symbol.clone(), update.clone()); + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::FundingRateUpdate { + symbol, + prev_funding_rate, + update, + }, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::Candles(update) => { + let (symbol, timeframe) = match key { + SubscriptionKey::Candles { symbol, timeframe } => (symbol.clone(), *timeframe), + _ => { + warn!("Received candle message for non-candle key: {:?}", key); + return stale; + } + }; + + let prev_candle = runtime_state + .candles + .insert((symbol.clone(), timeframe), update.clone()); + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::CandleUpdate { + symbol, + timeframe, + prev_candle, + update, + }, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::Trades(update) => { + let symbol = update.symbol.clone(); + let prev_trades = runtime_state.trades.insert(symbol.clone(), update.clone()); + + stale.extend(Self::dispatch_raw_event( + key, + PhoenixClientEvent::TradesUpdate { + symbol, + prev_trades, + update, + }, + logical_subscriptions, + subscribers_by_key, + )); + } + ServerMessage::Error(_) | ServerMessage::Other => {} + } + + stale + } + + fn dispatch_raw_event( + key: &SubscriptionKey, + event: PhoenixClientEvent, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &HashMap>, + ) -> Vec { + let mut stale = Vec::new(); + + let Some(subscription_ids) = subscribers_by_key.get(key) else { + return stale; + }; + + for subscription_id in subscription_ids { + let Some(logical) = logical_subscriptions.get(subscription_id) else { + continue; + }; + + if matches!( + logical.subscription, + PhoenixSubscription::TraderMargin { .. } + ) { + continue; + } + + if logical.event_tx.send(event.clone()).is_err() { + stale.push(*subscription_id); + } + } + + stale + } + + fn dispatch_margin_events( + trigger_key: &SubscriptionKey, + trigger: MarginTrigger, + prev_trader: Option, + runtime_state: &RuntimeState, + logical_subscriptions: &mut HashMap, + subscribers_by_key: &HashMap>, + ) -> Vec { + let mut stale = Vec::new(); + + let Some(subscription_ids) = subscribers_by_key.get(trigger_key) else { + return stale; + }; + + for subscription_id in subscription_ids { + let Some(logical) = logical_subscriptions.get(subscription_id) else { + continue; + }; + + let PhoenixSubscription::TraderMargin { + authority, + trader_pda_index, + subaccount_index, + .. + } = &logical.subscription + else { + continue; + }; + + let trader_key = SubscriptionKey::trader(authority, *trader_pda_index); + let margin = runtime_state + .traders + .get(&trader_key) + .and_then(|trader| trader.subaccount(*subaccount_index)) + .and_then(|subaccount| { + subaccount + .to_trader_portfolio() + .compute_margin(runtime_state.metadata.all_perp_asset_metadata()) + .ok() + }); + + let event = PhoenixClientEvent::MarginUpdate { + trader_key, + trigger: trigger.clone(), + margin, + metadata: runtime_state.metadata.clone(), + prev_trader: prev_trader.clone(), + }; + + if logical.event_tx.send(event).is_err() { + stale.push(*subscription_id); + } + } + + stale + } +} diff --git a/container/vendor/rise/rust/sdk/src/env.rs b/container/vendor/rise/rust/sdk/src/env.rs new file mode 100644 index 00000000000..8c9bec43a31 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/env.rs @@ -0,0 +1,166 @@ +//! Environment configuration for Phoenix SDK. +//! +//! This module provides a centralized way to load configuration from +//! environment variables with sane defaults. + +use phoenix_types::PhoenixWsError; +use url::Url; + +pub(crate) const PHOENIX_WS_URL_ENV: &str = "PHOENIX_WS_URL"; +pub(crate) const PHOENIX_API_URL_ENV: &str = "PHOENIX_API_URL"; +pub(crate) const PHOENIX_API_KEY_ENV: &str = "PHOENIX_API_KEY"; + +pub(crate) const DEFAULT_PHOENIX_API_URL: &str = "https://public-api.phoenix.trade"; +pub(crate) const DEFAULT_WS_URL: &str = "wss://public-api.phoenix.trade/ws"; + +/// Environment configuration for Phoenix SDK. +/// +/// Holds the API URL, WebSocket URL, and optional API key needed to connect +/// to the Phoenix API. +/// +/// # Example +/// +/// ```no_run +/// use phoenix_sdk::PhoenixEnv; +/// +/// // Load configuration from environment variables +/// let env = PhoenixEnv::load(); +/// +/// println!("API URL: {}", env.api_url); +/// println!("WS URL: {}", env.ws_url); +/// println!("API Key: {:?}", env.api_key.as_ref().map(|_| "***")); +/// ``` +#[derive(Debug, Clone)] +pub struct PhoenixEnv { + /// Base URL for the Phoenix HTTP API. + pub api_url: String, + /// WebSocket URL for real-time subscriptions. + pub ws_url: String, + /// Optional API key for authenticated endpoints. + pub api_key: Option, +} + +impl PhoenixEnv { + /// Load configuration from environment variables. + /// + /// # Environment Variables + /// + /// * `PHOENIX_API_URL` - Base URL for the Phoenix API. Defaults to `https://public-api.phoenix.trade`. + /// * `PHOENIX_WS_URL` - WebSocket URL. If not set, derived from the API URL + /// by converting the scheme (https→wss, http→ws) and appending `/ws`. + /// * `PHOENIX_API_KEY` - Optional API key for authenticated endpoints. + pub fn load() -> Self { + let api_url = std::env::var(PHOENIX_API_URL_ENV) + .unwrap_or_else(|_| DEFAULT_PHOENIX_API_URL.to_string()); + + let ws_url = std::env::var(PHOENIX_WS_URL_ENV).unwrap_or_else(|_| { + ws_url_from_api_url(&api_url).unwrap_or_else(|_| DEFAULT_WS_URL.to_string()) + }); + + let api_key = std::env::var(PHOENIX_API_KEY_ENV).ok(); + + Self { + api_url, + ws_url, + api_key, + } + } +} + +impl Default for PhoenixEnv { + /// Returns the default environment configuration. + /// + /// Uses `https://public-api.phoenix.trade` as the API URL and + /// `wss://public-api.phoenix.trade/ws` as the WebSocket URL. No API key is + /// set. + fn default() -> Self { + Self { + api_url: DEFAULT_PHOENIX_API_URL.to_string(), + ws_url: DEFAULT_WS_URL.to_string(), + api_key: None, + } + } +} + +// ============================================================================ +// URL utilities +// ============================================================================ + +pub(crate) fn ws_url_from_api_url(api_url: &str) -> Result { + let mut url = Url::parse(api_url)?; + + // Convert http(s) to ws(s) by replacing "http" prefix with "ws" + let scheme = url.scheme(); + if let Some(ws_scheme) = scheme.strip_prefix("http") { + let new_scheme = format!("ws{}", ws_scheme); + let _ = url.set_scheme(&new_scheme); + } else if scheme != "ws" && scheme != "wss" { + return Err(PhoenixWsError::UnsupportedUrlScheme(scheme.to_string())); + } + + // Append /ws path if not already present + let mut segments: Vec<&str> = url + .path_segments() + .map(|s| s.filter(|seg| !seg.is_empty()).collect()) + .unwrap_or_default(); + if segments.last().copied() != Some("ws") { + segments.push("ws"); + } + url.set_path(&format!("/{}", segments.join("/"))); + url.set_query(None); + url.set_fragment(None); + + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load() { + // load() always succeeds with defaults when env vars are not set + let env = PhoenixEnv::load(); + // Should have valid URLs (either from env or defaults) + assert!(!env.api_url.is_empty()); + assert!(!env.ws_url.is_empty()); + } + + #[test] + fn test_default_env() { + let env = PhoenixEnv::default(); + assert_eq!(env.api_url, "https://public-api.phoenix.trade"); + assert_eq!(env.ws_url, "wss://public-api.phoenix.trade/ws"); + assert!(env.api_key.is_none()); + } + + #[test] + fn test_ws_url_from_https_api_url_appends_ws() { + let ws_url = ws_url_from_api_url("https://public-api.phoenix.trade").unwrap(); + assert_eq!(ws_url, "wss://public-api.phoenix.trade/ws"); + } + + #[test] + fn test_ws_url_from_http_api_url_appends_ws() { + let ws_url = ws_url_from_api_url("http://localhost:8080").unwrap(); + assert_eq!(ws_url, "ws://localhost:8080/ws"); + } + + #[test] + fn test_ws_url_preserves_path() { + let ws_url = ws_url_from_api_url("https://api.phoenix.trade/v1").unwrap(); + assert_eq!(ws_url, "wss://api.phoenix.trade/v1/ws"); + } + + #[test] + fn test_ws_url_handles_trailing_slash() { + let ws_url = ws_url_from_api_url("https://public-api.phoenix.trade/").unwrap(); + assert_eq!(ws_url, "wss://public-api.phoenix.trade/ws"); + } + + #[test] + fn test_ws_url_does_not_double_append_ws() { + let ws_url = ws_url_from_api_url("https://public-api.phoenix.trade/ws").unwrap(); + assert_eq!(ws_url, "wss://public-api.phoenix.trade/ws"); + } +} diff --git a/container/vendor/rise/rust/sdk/src/http_client.rs b/container/vendor/rise/rust/sdk/src/http_client.rs new file mode 100644 index 00000000000..31bcac207a6 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/http_client.rs @@ -0,0 +1,535 @@ +//! HTTP client for Phoenix API. +//! +//! This module provides a client for making HTTP requests to the Phoenix API +//! to fetch exchange configuration and market data. + +use std::time::Duration; + +use phoenix_ix::{IsolatedCollateralFlow, Side}; +use phoenix_types::{ + ApiCandle, CandlesQueryParams, CollateralHistoryQueryParams, CollateralHistoryResponse, + ExchangeKeysView, ExchangeMarketConfig, ExchangeResponse, FundingHistoryQueryParams, + FundingHistoryResponse, OrderHistoryQueryParams, OrderHistoryResponse, PhoenixHttpError, + PlaceIsolatedLimitOrderRequest, PlaceIsolatedMarketOrderRequest, PnlPoint, PnlQueryParams, + TradeHistoryQueryParams, TradeHistoryResponse, TraderKey, TraderView, +}; +use reqwest::header::RETRY_AFTER; +use reqwest::{Client, RequestBuilder, Response}; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; +use tracing::debug; + +use crate::api::{ + CandlesClient, CollateralClient, ExchangeClient, FundingClient, InviteClient, MarketsClient, + OrdersClient, TradersClient, TradesClient, +}; +use crate::env::PhoenixEnv; +use crate::tx_builder::BracketLegOrders; + +const API_KEY_HEADER: &str = "x-api-key"; +const RATE_LIMIT_STATUS: u16 = 429; + +/// Automatic retry behavior for HTTP 429 (rate-limited) responses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateLimitRetryConfig { + /// Enable automatic retry on HTTP 429. + pub enabled: bool, + /// Maximum number of retries after the initial attempt. + pub max_retries: u32, + /// Maximum total time spent sleeping between retries. + pub max_total_wait: Duration, + /// Fallback delay if `Retry-After` is missing or invalid. + pub fallback_delay: Duration, + /// Maximum delay per retry attempt. + pub max_delay: Duration, +} + +impl Default for RateLimitRetryConfig { + fn default() -> Self { + Self { + enabled: true, + max_retries: 2, + max_total_wait: Duration::from_secs(15), + fallback_delay: Duration::from_secs(1), + max_delay: Duration::from_secs(10), + } + } +} + +/// Shared HTTP transport used by all resource sub-clients. +#[derive(Debug, Clone)] +pub(crate) struct HttpClientInner { + pub api_url: String, + pub api_key: Option, + pub client: Client, + pub rate_limit_retry: RateLimitRetryConfig, +} + +impl HttpClientInner { + pub fn maybe_add_api_key(&self, request: RequestBuilder) -> RequestBuilder { + match &self.api_key { + Some(key) => request.header(API_KEY_HEADER, key), + None => request, + } + } + + pub async fn send_with_rate_limit_retry( + &self, + mut request: RequestBuilder, + ) -> Result { + let mut retries: u32 = 0; + let mut total_wait = Duration::ZERO; + + loop { + let retry_request = request.try_clone(); + let response = request.send().await?; + + if response.status().as_u16() != RATE_LIMIT_STATUS { + return Ok(response); + } + + let retry_after_seconds = parse_retry_after_seconds(response.headers()); + let message = response.text().await.unwrap_or_default(); + let attempts = retries.saturating_add(1); + + let can_retry = + self.rate_limit_retry.enabled && retries < self.rate_limit_retry.max_retries; + if !can_retry { + return Err(PhoenixHttpError::RateLimited { + retry_after_seconds, + message, + attempts, + }); + } + + let Some(next_request) = retry_request else { + return Err(PhoenixHttpError::RateLimited { + retry_after_seconds, + message: if message.is_empty() { + "rate_limited (request could not be cloned for retry)".to_string() + } else { + message + }, + attempts, + }); + }; + + let wait = retry_after_seconds + .map(Duration::from_secs) + .unwrap_or(self.rate_limit_retry.fallback_delay) + .min(self.rate_limit_retry.max_delay); + let next_total_wait = total_wait.saturating_add(wait); + + if next_total_wait > self.rate_limit_retry.max_total_wait { + return Err(PhoenixHttpError::RateLimited { + retry_after_seconds, + message: if message.is_empty() { + "rate_limited (max_total_wait exceeded)".to_string() + } else { + message + }, + attempts, + }); + } + + debug!( + "HTTP rate limited, retrying attempt {} in {:?} (retry_after={:?})", + attempts + 1, + wait, + retry_after_seconds + ); + + tokio::time::sleep(wait).await; + total_wait = next_total_wait; + retries = retries.saturating_add(1); + request = next_request; + } + } +} + +/// HTTP client for Phoenix API. +/// +/// Provides resource sub-client accessors (e.g. `client.markets()`, +/// `client.traders()`) that mirror the TypeScript SDK's `V1ApiClients` +/// shape. Existing flat methods remain for backwards compatibility and +/// delegate to the sub-clients. +/// +/// # Example +/// +/// ```no_run +/// use phoenix_sdk::PhoenixHttpClient; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = PhoenixHttpClient::new_from_env(); +/// +/// // Resource-based access (new) +/// let markets = client.markets().get_markets().await?; +/// let sol = client.markets().get_market("SOL").await?; +/// +/// // Flat access (backwards-compatible) +/// let keys = client.get_exchange_keys().await?; +/// +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct PhoenixHttpClient { + inner: HttpClientInner, +} + +impl PhoenixHttpClient { + /// Creates a new HTTP client using environment variables. + pub fn new_from_env() -> Self { + Self::from_env(PhoenixEnv::load()) + } + + /// Creates a new HTTP client from a `PhoenixEnv`. + pub fn from_env(env: PhoenixEnv) -> Self { + Self { + inner: HttpClientInner { + api_url: env.api_url, + api_key: env.api_key, + client: Client::new(), + rate_limit_retry: RateLimitRetryConfig::default(), + }, + } + } + + /// Creates a new HTTP client with the given API URL and API key. + pub fn new(api_url: impl Into, api_key: impl Into) -> Self { + Self { + inner: HttpClientInner { + api_url: api_url.into(), + api_key: Some(api_key.into()), + client: Client::new(), + rate_limit_retry: RateLimitRetryConfig::default(), + }, + } + } + + /// Creates a new HTTP client without an API key. + pub fn new_public(api_url: impl Into) -> Self { + Self { + inner: HttpClientInner { + api_url: api_url.into(), + api_key: None, + client: Client::new(), + rate_limit_retry: RateLimitRetryConfig::default(), + }, + } + } + + /// Sets automatic rate-limit retry behavior for this client. + pub fn set_rate_limit_retry_config(&mut self, config: RateLimitRetryConfig) { + self.inner.rate_limit_retry = config; + } + + /// Builder-style variant of [`Self::set_rate_limit_retry_config`]. + pub fn with_rate_limit_retry_config(mut self, config: RateLimitRetryConfig) -> Self { + self.inner.rate_limit_retry = config; + self + } + + /// Returns the current automatic rate-limit retry configuration. + pub fn rate_limit_retry_config(&self) -> &RateLimitRetryConfig { + &self.inner.rate_limit_retry + } + + // --- Resource sub-client accessors --- + + pub fn markets(&self) -> MarketsClient<'_> { + MarketsClient { http: &self.inner } + } + + pub fn exchange(&self) -> ExchangeClient<'_> { + ExchangeClient { http: &self.inner } + } + + pub fn traders(&self) -> TradersClient<'_> { + TradersClient { http: &self.inner } + } + + pub fn collateral(&self) -> CollateralClient<'_> { + CollateralClient { http: &self.inner } + } + + pub fn funding(&self) -> FundingClient<'_> { + FundingClient { http: &self.inner } + } + + pub fn orders(&self) -> OrdersClient<'_> { + OrdersClient { http: &self.inner } + } + + pub fn trades(&self) -> TradesClient<'_> { + TradesClient { http: &self.inner } + } + + pub fn candles(&self) -> CandlesClient<'_> { + CandlesClient { http: &self.inner } + } + + pub fn invite(&self) -> InviteClient<'_> { + InviteClient { http: &self.inner } + } + + // --- Backwards-compatible flat methods (delegate to sub-clients) --- + + pub async fn get_exchange_keys(&self) -> Result { + self.exchange().get_keys().await + } + + pub async fn get_markets(&self) -> Result, PhoenixHttpError> { + self.markets().get_markets().await + } + + pub async fn get_market( + &self, + symbol: &str, + ) -> Result { + self.markets().get_market(symbol).await + } + + pub async fn get_exchange(&self) -> Result { + self.exchange().get_exchange().await + } + + pub async fn get_traders( + &self, + authority: &Pubkey, + ) -> Result, PhoenixHttpError> { + self.traders().get_trader(authority).await + } + + pub async fn get_collateral_history( + &self, + authority: &Pubkey, + params: CollateralHistoryQueryParams, + ) -> Result { + self.collateral() + .get_user_collateral_history(authority, params) + .await + } + + pub async fn get_collateral_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: CollateralHistoryQueryParams, + ) -> Result { + self.collateral() + .get_trader_collateral_history(trader_key, params) + .await + } + + pub async fn get_funding_history( + &self, + authority: &Pubkey, + params: FundingHistoryQueryParams, + ) -> Result { + self.funding() + .get_user_funding_history(authority, params) + .await + } + + pub async fn get_funding_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: FundingHistoryQueryParams, + ) -> Result { + self.funding() + .get_trader_funding_history(trader_key, params) + .await + } + + pub async fn get_order_history( + &self, + authority: &Pubkey, + params: OrderHistoryQueryParams, + ) -> Result { + self.orders() + .get_trader_order_history(authority, params) + .await + } + + pub async fn get_order_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: OrderHistoryQueryParams, + ) -> Result { + self.orders() + .get_trader_order_history_with_trader_key(trader_key, params) + .await + } + + pub async fn get_candles( + &self, + params: CandlesQueryParams, + ) -> Result, PhoenixHttpError> { + self.candles().get_candles(params).await + } + + pub async fn get_trade_history( + &self, + authority: &Pubkey, + params: TradeHistoryQueryParams, + ) -> Result { + self.trades() + .get_trader_trade_history(authority, params) + .await + } + + pub async fn get_trade_history_with_trader_key( + &self, + trader_key: &TraderKey, + params: TradeHistoryQueryParams, + ) -> Result { + self.trades() + .get_trader_trade_history_with_trader_key(trader_key, params) + .await + } + + pub async fn get_pnl( + &self, + authority: &Pubkey, + params: PnlQueryParams, + ) -> Result, PhoenixHttpError> { + self.traders().get_trader_pnl(authority, params).await + } + + pub async fn build_isolated_limit_order_tx( + &self, + authority: &Pubkey, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + ) -> Result, PhoenixHttpError> { + self.orders() + .build_isolated_limit_order_tx( + authority, + symbol, + side, + price, + num_base_lots, + collateral, + allow_cross_and_isolated, + ) + .await + } + + pub async fn build_isolated_limit_order_tx_with_request( + &self, + request: PlaceIsolatedLimitOrderRequest, + ) -> Result, PhoenixHttpError> { + self.orders() + .build_isolated_limit_order_tx_with_request(request) + .await + } + + pub async fn build_isolated_market_order_tx( + &self, + authority: &Pubkey, + symbol: &str, + side: Side, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixHttpError> { + self.orders() + .build_isolated_market_order_tx( + authority, + symbol, + side, + num_base_lots, + collateral, + allow_cross_and_isolated, + bracket, + ) + .await + } + + pub async fn build_isolated_market_order_tx_with_request( + &self, + request: PlaceIsolatedMarketOrderRequest, + ) -> Result, PhoenixHttpError> { + self.orders() + .build_isolated_market_order_tx_with_request(request) + .await + } + + pub async fn register_trader( + &self, + authority: &Pubkey, + code: &str, + ) -> Result { + self.invite().activate_invite(authority, code).await + } +} + +fn parse_retry_after_seconds(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .map(|seconds| seconds.max(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_creation() { + let client = PhoenixHttpClient::new("https://api.phoenix.trade/v1", "test-key"); + assert_eq!(client.inner.api_url, "https://api.phoenix.trade/v1"); + assert_eq!(client.inner.api_key.as_deref(), Some("test-key")); + assert_eq!( + client.inner.rate_limit_retry, + RateLimitRetryConfig::default() + ); + } + + #[test] + fn test_client_with_string() { + let url = String::from("https://api.example.com"); + let key = String::from("my-api-key"); + let client = PhoenixHttpClient::new(url, key); + assert_eq!(client.inner.api_url, "https://api.example.com"); + assert_eq!(client.inner.api_key.as_deref(), Some("my-api-key")); + assert_eq!( + client.inner.rate_limit_retry, + RateLimitRetryConfig::default() + ); + } + + #[test] + fn test_client_public() { + let client = PhoenixHttpClient::new_public("https://api.example.com"); + assert_eq!(client.inner.api_url, "https://api.example.com"); + assert!(client.inner.api_key.is_none()); + assert_eq!( + client.inner.rate_limit_retry, + RateLimitRetryConfig::default() + ); + } + + #[test] + fn test_parse_retry_after_seconds() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(RETRY_AFTER, reqwest::header::HeaderValue::from_static("3")); + assert_eq!(parse_retry_after_seconds(&headers), Some(3)); + + headers.insert(RETRY_AFTER, reqwest::header::HeaderValue::from_static("0")); + assert_eq!(parse_retry_after_seconds(&headers), Some(1)); + + headers.insert( + RETRY_AFTER, + reqwest::header::HeaderValue::from_static("not-a-number"), + ); + assert_eq!(parse_retry_after_seconds(&headers), None); + } +} diff --git a/container/vendor/rise/rust/sdk/src/lib.rs b/container/vendor/rise/rust/sdk/src/lib.rs new file mode 100644 index 00000000000..438f6f116d9 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/lib.rs @@ -0,0 +1,86 @@ +//! Phoenix WebSocket SDK for Rust. +//! +//! This crate provides a client for subscribing to real-time trader state +//! updates from the Phoenix exchange via WebSocket. +//! +//! # Example +//! +//! ```no_run +//! use phoenix_sdk::{PhoenixWSClient, Trader, TraderKey}; +//! use solana_pubkey::Pubkey; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Connect to the WebSocket server +//! let client = PhoenixWSClient::new("wss://api.phoenix.trade/v1/ws", None)?; +//! +//! // Create a trader key from your authority pubkey +//! let key = TraderKey::new(Pubkey::new_unique()); +//! +//! // Create a trader state container +//! let mut trader = Trader::new(key.clone()); +//! +//! // Subscribe to trader state updates using the authority pubkey +//! let (mut rx, _handle) = client.subscribe_to_trader_state(&key.authority())?; +//! +//! // Process updates +//! while let Some(msg) = rx.recv().await { +//! trader.apply_update(&msg); +//! println!( +//! "Collateral: {}, Positions: {}", +//! trader.total_collateral(), +//! trader.all_positions().len() +//! ); +//! } +//! +//! Ok(()) +//! } +//! ``` + +pub mod api; +mod client; +mod env; +mod http_client; +mod tx_builder; +mod ws_client; + +// Re-export main types +pub use api::{ + CandlesClient, CollateralClient, ExchangeClient, FundingClient, InviteClient, MarketsClient, + OrdersClient, TradersClient, TradesClient, +}; +pub use client::PhoenixClient; +pub use env::PhoenixEnv; +pub use http_client::{PhoenixHttpClient, RateLimitRetryConfig}; +// Re-export phoenix-ix types users will need for orders +pub use phoenix_ix::{ + CancelId, CondensedOrder, FifoOrderId, MultiLimitOrderParams, OrderFlags, + RegisterTraderParams, SelfTradeBehavior, Side, TransferCollateralParams, +}; +pub use phoenix_ix::{ + CancelStopLossParams, Direction, IsolatedCollateralFlow, IsolatedLimitOrderParams, + IsolatedMarketOrderParams, StopLossOrderKind, StopLossParams, +}; +/// Re-export the types crate for direct access if needed. +pub use phoenix_types as types; +pub use phoenix_types::conversions::*; +// Re-export useful types from the types crate +pub use phoenix_types::{ + AllMidsData, ApiCandle, CROSS_MARGIN_SUBACCOUNT_IDX, CandleData, CandlesQueryParams, + CandlesSubscriptionRequest, ClientCommand, ClientSubscriptionId, CollateralEvent, + CollateralHistoryQueryParams, CollateralHistoryResponse, ETERNAL_PROGRAM_ID, + ExchangeMarketConfig, ExchangeView, FundingHistoryEvent, FundingHistoryQueryParams, + FundingHistoryResponse, FundingRateMessage, L2Book, L2BookUpdate, LogicalSubscription, + MarginTrigger, Market, MarketStats, MarketStatsUpdate, OrderHistoryItem, + OrderHistoryQueryParams, OrderHistoryResponse, OrderStatus, PaginatedResponse, + PhoenixClientError, PhoenixClientEvent, PhoenixClientSubscriptionHandle, PhoenixHttpError, + PhoenixMetadata, PhoenixSubscription, PhoenixWsError, PlaceIsolatedLimitOrderRequest, + PlaceIsolatedMarketOrderRequest, PnlPoint, PnlQueryParams, PnlResolution, Position, PriceLevel, + RuntimeState, ServerMessage, Spline, SubaccountState, SubscriptionKey, Timeframe, + TpSlOrderConfig, TradeEvent, TradeHistoryItem, TradeHistoryQueryParams, TradeHistoryResponse, + Trader, TraderKey, TraderStateDelta, TraderStatePayload, TraderStateServerMessage, + TraderStateSnapshot, TradesMessage, TradesSubscriptionRequest, +}; +pub use rust_decimal::Decimal; +pub use tx_builder::{BracketLegOrders, PhoenixTxBuilder, PhoenixTxBuilderError}; +pub use ws_client::{PhoenixWSClient, SubscriptionHandle, WsConnectionStatus}; diff --git a/container/vendor/rise/rust/sdk/src/tx_builder.rs b/container/vendor/rise/rust/sdk/src/tx_builder.rs new file mode 100644 index 00000000000..142083300ac --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/tx_builder.rs @@ -0,0 +1,1237 @@ +//! Transaction builder for Phoenix perpetuals exchange. +//! +//! This module provides `PhoenixTxBuilder`, which builds Solana instructions +//! from exchange metadata without requiring network access or keypairs. + +use std::str::FromStr; + +use phoenix_ix::{ + CancelId, CancelOrdersByIdParams, CancelStopLossParams, CondensedOrder, DepositFundsParams, + Direction, EmberDepositParams, EmberWithdrawParams, IsolatedCollateralFlow, + IsolatedLimitOrderParams, IsolatedMarketOrderParams, LimitOrderParams, MarketOrderParams, + MultiLimitOrderParams, RegisterTraderParams, Side, SplApproveParams, StopLossOrderKind, + StopLossParams, SyncParentToChildParams, TransferCollateralChildToParentParams, + TransferCollateralParams, USDC_MINT, WithdrawFundsParams, + create_associated_token_account_idempotent_ix, create_cancel_orders_by_id_ix, + create_cancel_stop_loss_ix, create_deposit_funds_ix, create_ember_deposit_ix, + create_ember_withdraw_ix, create_place_limit_order_ix, create_place_market_order_ix, + create_place_multi_limit_order_ix, create_place_stop_loss_ix, create_register_trader_ix, + create_spl_approve_ix, create_sync_parent_to_child_ix, + create_transfer_collateral_child_to_parent_ix, create_transfer_collateral_ix, + create_withdraw_funds_ix, get_associated_token_address, get_ember_state_address, + get_ember_vault_address, +}; +use phoenix_math_utils::{MathError, WrapperNum}; +use phoenix_types::{CROSS_MARGIN_SUBACCOUNT_IDX, ExchangeMarketConfig, Trader, TraderKey}; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; +use thiserror::Error; + +use crate::PhoenixMetadata; + +const USDC_NATIVE_DECIMALS: f64 = 1_000_000.0; + +/// Errors that can occur when building Phoenix transactions. +#[derive(Debug, Error)] +pub enum PhoenixTxBuilderError { + /// Instruction construction error. + #[error("Instruction error: {0}")] + Instruction(#[from] phoenix_ix::PhoenixIxError), + + /// Failed to parse pubkey. + #[error("Invalid pubkey: {0}")] + InvalidPubkey(#[from] solana_pubkey::ParsePubkeyError), + + /// Unknown market symbol. + #[error("Unknown symbol: {0}")] + UnknownSymbol(String), + + /// Math conversion error (e.g., price to ticks). + #[error("Math error: {0}")] + Math(#[from] MathError), + + /// Insufficient collateral in parent (cross-margin) subaccount. + #[error("Insufficient parent collateral: need {need} but have {have} quote lots")] + InsufficientParentCollateral { need: u64, have: u64 }, + + /// All isolated subaccount slots are occupied. + #[error("No available isolated subaccount slot")] + NoAvailableSubaccount, + + /// Cross-margin subaccount already has a position in this market. + #[error("Cross-margin subaccount already has a position in {0}")] + CrossMarginPositionExists(String), + + /// Attempted to place an order on an isolated-only market using the + /// cross-margin subaccount. + #[error("{0} is isolated-only and cannot be traded on the cross-margin subaccount")] + IsolatedOnlyMarket(String), +} + +/// Parsed addresses from exchange metadata for instruction building. +struct ParsedAddresses { + perp_asset_map: Pubkey, + global_trader_index: Vec, + active_trader_buffer: Vec, + orderbook: Pubkey, + spline_collection: Pubkey, +} + +/// Optional bracket leg orders (stop-loss and/or take-profit) attached to a +/// market order. Both use the on-chain `PlaceStopLoss` instruction with +/// different `Direction` parameters. +#[derive(Debug, Clone)] +pub struct BracketLegOrders { + /// Stop-loss trigger price in USD. Triggers when price moves against the + /// position. + pub stop_loss_price: Option, + /// Take-profit trigger price in USD. Triggers when price moves in favor. + pub take_profit_price: Option, +} + +impl BracketLegOrders { + /// Convert to a [`TpSlOrderConfig`] for server-side order endpoints. + /// + /// Sets both trigger and execution price to the supplied price for each + /// leg. + pub fn to_tp_sl_config(&self) -> phoenix_types::TpSlOrderConfig { + phoenix_types::TpSlOrderConfig { + take_profit_trigger_price: self.take_profit_price, + take_profit_execution_price: self.take_profit_price, + stop_loss_trigger_price: self.stop_loss_price, + stop_loss_execution_price: self.stop_loss_price, + ..Default::default() + } + } +} + +/// Transaction builder for Phoenix perpetuals exchange. +/// +/// Builds Solana instructions from exchange metadata without requiring +/// network access. Use this when you need fine-grained control over +/// transaction construction or want to batch instructions. +/// +/// # Example +/// +/// ```no_run +/// use phoenix_sdk::{PhoenixHttpClient, PhoenixMetadata, PhoenixTxBuilder, Side}; +/// use solana_pubkey::Pubkey; +/// +/// # async fn example() -> Result<(), Box> { +/// let http = PhoenixHttpClient::new_from_env(); +/// let exchange = http.get_exchange().await?.into(); +/// let metadata = PhoenixMetadata::new(exchange); +/// let builder = PhoenixTxBuilder::new(&metadata); +/// +/// let authority = Pubkey::new_unique(); +/// let trader_pda = Pubkey::new_unique(); +/// +/// // Build instructions without sending +/// let ixs = builder.build_market_order(authority, trader_pda, "SOL", Side::Bid, 100)?; +/// # Ok(()) +/// # } +/// ``` +pub struct PhoenixTxBuilder<'a> { + metadata: &'a PhoenixMetadata, +} + +impl std::fmt::Debug for PhoenixTxBuilder<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PhoenixTxBuilder") + .field("metadata", &self.metadata) + .finish() + } +} + +impl<'a> PhoenixTxBuilder<'a> { + /// Creates a new transaction builder from exchange metadata. + pub fn new(metadata: &'a PhoenixMetadata) -> Self { + Self { metadata } + } + + /// Build a market order instruction with pre-built params, optionally + /// followed by bracket leg (stop-loss / take-profit) instructions. + pub fn build_market_order_with_params( + &self, + params: MarketOrderParams, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixTxBuilderError> { + if params.subaccount_index() == CROSS_MARGIN_SUBACCOUNT_IDX { + self.reject_isolated_only(params.symbol())?; + } + + let authority = params.trader(); + let trader_account = params.trader_account(); + let symbol = params.symbol().to_string(); + let side = params.side(); + + let ix = create_place_market_order_ix(params)?; + let mut ixs = vec![ix.into()]; + + if let Some(bracket) = bracket { + ixs.extend(self.build_bracket_leg_orders( + authority, + trader_account, + &symbol, + side, + bracket, + )?); + } + + Ok(ixs) + } + + /// Build a market order instruction. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `symbol` - Market symbol ("SOL", "BTC", "ETH") + /// * `side` - Order side (Bid or Ask) + /// * `num_base_lots` - Size in base lots + /// + /// # Returns + /// + /// A vector containing the market order instruction. + pub fn build_market_order( + &self, + authority: Pubkey, + trader_pda: Pubkey, + symbol: &str, + side: Side, + num_base_lots: u64, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let addrs = self.parse_addresses(market)?; + + let params = MarketOrderParams::builder() + .trader(authority) + .trader_account(trader_pda) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .side(side) + .num_base_lots(num_base_lots) + .symbol(symbol) + .build()?; + + self.build_market_order_with_params(params, bracket) + } + + /// Build a limit order instruction with pre-built params. + /// + /// # Arguments + /// + /// * `params` - Pre-built limit order params + /// + /// # Returns + /// + /// A vector containing the limit order instruction. + pub fn build_limit_order_with_params( + &self, + params: LimitOrderParams, + ) -> Result, PhoenixTxBuilderError> { + if params.subaccount_index() == CROSS_MARGIN_SUBACCOUNT_IDX { + self.reject_isolated_only(params.symbol())?; + } + let ix = create_place_limit_order_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build a limit order instruction. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `symbol` - Market symbol + /// * `side` - Order side + /// * `price` - Limit price in USD (e.g., 150.50 for $150.50) + /// * `num_base_lots` - Size in base lots + /// + /// # Returns + /// + /// A vector containing the limit order instruction. + pub fn build_limit_order( + &self, + authority: Pubkey, + trader_pda: Pubkey, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let calc = self + .metadata + .get_market_calculator(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let price_in_ticks = calc.price_to_ticks(price)?.as_inner(); + + let addrs = self.parse_addresses(market)?; + + let params = LimitOrderParams::builder() + .trader(authority) + .trader_account(trader_pda) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .side(side) + .price_in_ticks(price_in_ticks) + .num_base_lots(num_base_lots) + .symbol(symbol) + .build()?; + + self.build_limit_order_with_params(params) + } + + /// Build a multi-limit-order instruction with pre-built params. + pub fn build_multi_limit_order_with_params( + &self, + params: MultiLimitOrderParams, + ) -> Result, PhoenixTxBuilderError> { + let ix = create_place_multi_limit_order_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build a multi-limit-order instruction. + /// + /// Places multiple post-only limit orders (bids and asks) in a single + /// instruction. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `symbol` - Market symbol + /// * `bids` - Bid orders as (price_usd, num_base_lots) tuples + /// * `asks` - Ask orders as (price_usd, num_base_lots) tuples + /// * `slide` - Whether orders should slide to top of book if they would cross + pub fn build_multi_limit_order( + &self, + authority: Pubkey, + trader_pda: Pubkey, + symbol: &str, + bids: &[(f64, u64)], + asks: &[(f64, u64)], + slide: bool, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let calc = self + .metadata + .get_market_calculator(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let addrs = self.parse_addresses(market)?; + + let bid_orders: Vec = bids + .iter() + .map(|(price, size)| { + Ok(CondensedOrder { + price_in_ticks: calc.price_to_ticks(*price)?.as_inner(), + size_in_base_lots: *size, + last_valid_slot: None, + }) + }) + .collect::>()?; + + let ask_orders: Vec = asks + .iter() + .map(|(price, size)| { + Ok(CondensedOrder { + price_in_ticks: calc.price_to_ticks(*price)?.as_inner(), + size_in_base_lots: *size, + last_valid_slot: None, + }) + }) + .collect::>()?; + + let params = MultiLimitOrderParams::builder() + .trader(authority) + .trader_account(trader_pda) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .bids(bid_orders) + .asks(ask_orders) + .slide(slide) + .symbol(symbol) + .build()?; + + self.build_multi_limit_order_with_params(params) + } + + /// Build cancel orders instruction. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `symbol` - Market symbol + /// * `order_ids` - List of order IDs to cancel + /// + /// # Returns + /// + /// A vector containing the cancel orders instruction. + pub fn build_cancel_orders( + &self, + authority: Pubkey, + trader_pda: Pubkey, + symbol: &str, + order_ids: Vec, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let addrs = self.parse_addresses(market)?; + + let params = CancelOrdersByIdParams::builder() + .trader(authority) + .trader_account(trader_pda) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .order_ids(order_ids) + .build()?; + + let ix = create_cancel_orders_by_id_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build a cancel stop loss instruction. + /// + /// Cancels an active stop-loss or take-profit order for a given market + /// and execution direction. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer / funder) + /// * `trader_pda` - The trader's PDA account + /// * `symbol` - Market symbol ("SOL", "BTC", "ETH") + /// * `execution_direction` - Which leg to cancel (`LessThan` for SL on + /// longs, `GreaterThan` for TP on longs; reversed for shorts) + pub fn build_cancel_bracket_leg( + &self, + authority: Pubkey, + trader_pda: Pubkey, + symbol: &str, + execution_direction: Direction, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let asset_id = market.asset_id as u64; + + let params = CancelStopLossParams::builder() + .funder(authority) + .trader_account(trader_pda) + .position_authority(authority) + .asset_id(asset_id) + .execution_direction(execution_direction) + .build()?; + + let ix = create_cancel_stop_loss_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build deposit funds instructions. + /// + /// This method builds the full deposit flow: + /// 1. Creates ATA for Phoenix tokens if needed (idempotent) + /// 2. Deposits USDC via Ember to receive Phoenix tokens + /// 3. Deposits Phoenix tokens into the Phoenix protocol + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `usdc_amount` - Amount of USDC to deposit (e.g., 100.0 for $100) + /// + /// # Returns + /// + /// A vector containing 3 instructions that should be sent in a single + /// transaction. + pub fn build_deposit_funds( + &self, + authority: Pubkey, + trader_pda: Pubkey, + usdc_amount: f64, + ) -> Result, PhoenixTxBuilderError> { + // Convert USDC amount to base units (6 decimals) + let amount = (usdc_amount * 1_000_000.0) as u64; + + // Get exchange keys from metadata + let keys = self.metadata.keys(); + let canonical_mint = Pubkey::from_str(&keys.canonical_mint)?; + let global_vault = Pubkey::from_str(&keys.global_vault)?; + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + let active_trader_buffer = parse_pubkey_vec(&keys.active_trader_buffer)?; + + // Derive addresses + let trader_usdc_ata = get_associated_token_address(&authority, &USDC_MINT); + let trader_phoenix_ata = get_associated_token_address(&authority, &canonical_mint); + let ember_state = get_ember_state_address(); + let ember_vault = get_ember_vault_address(); + + // 1. Create ATA instruction (idempotent) + let create_ata_ix = + create_associated_token_account_idempotent_ix(authority, authority, canonical_mint); + + // 2. Ember deposit instruction (USDC -> Phoenix tokens) + let ember_params = EmberDepositParams::builder() + .trader(authority) + .ember_state(ember_state) + .ember_vault(ember_vault) + .usdc_mint(USDC_MINT) + .canonical_mint(canonical_mint) + .trader_usdc_account(trader_usdc_ata) + .trader_phoenix_account(trader_phoenix_ata) + .amount(amount) + .build()?; + let ember_ix = create_ember_deposit_ix(ember_params)?; + + // 3. Deposit funds instruction (Phoenix tokens -> protocol) + let deposit_params = DepositFundsParams::builder() + .trader(authority) + .trader_account(trader_pda) + .canonical_mint(canonical_mint) + .global_vault(global_vault) + .trader_token_account(trader_phoenix_ata) + .global_trader_index(global_trader_index) + .active_trader_buffer(active_trader_buffer) + .amount(amount) + .build()?; + let deposit_ix = create_deposit_funds_ix(deposit_params)?; + + Ok(vec![ + create_ata_ix.into(), + ember_ix.into(), + deposit_ix.into(), + ]) + } + + /// Build withdraw funds instructions. + /// + /// This method builds the full withdrawal flow: + /// 1. Creates ATA for Phoenix tokens if needed (idempotent) + /// 2. Approves Ember state to spend Phoenix tokens + /// 3. Creates ATA for USDC if needed (idempotent) + /// 4. Withdraws Phoenix tokens from Phoenix protocol + /// 5. Converts Phoenix tokens to USDC via Ember + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `trader_pda` - The trader's PDA account + /// * `usdc_amount` - Amount of USDC to withdraw (e.g., 100.0 for $100) + /// + /// # Returns + /// + /// A vector containing 5 instructions that should be sent in a single + /// transaction. + pub fn build_withdraw_funds( + &self, + authority: Pubkey, + trader_pda: Pubkey, + usdc_amount: f64, + ) -> Result, PhoenixTxBuilderError> { + // Convert USDC amount to base units (6 decimals) + let amount = (usdc_amount * 1_000_000.0) as u64; + + // Get exchange keys from metadata + let keys = self.metadata.keys(); + let canonical_mint = Pubkey::from_str(&keys.canonical_mint)?; + let global_vault = Pubkey::from_str(&keys.global_vault)?; + let perp_asset_map = Pubkey::from_str(&keys.perp_asset_map)?; + let withdraw_queue = Pubkey::from_str(&keys.withdraw_queue)?; + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + let active_trader_buffer = parse_pubkey_vec(&keys.active_trader_buffer)?; + + // Derive addresses + let trader_usdc_ata = get_associated_token_address(&authority, &USDC_MINT); + let trader_phoenix_ata = get_associated_token_address(&authority, &canonical_mint); + let ember_state = get_ember_state_address(); + let ember_vault = get_ember_vault_address(); + + // 1. Create Phoenix token ATA instruction (idempotent) + let create_phoenix_ata_ix = + create_associated_token_account_idempotent_ix(authority, authority, canonical_mint); + + // 2. SPL Token Approve instruction (delegate Ember state to spend Phoenix + // tokens) + let approve_params = SplApproveParams::builder() + .source(trader_phoenix_ata) + .delegate(ember_state) + .owner(authority) + .amount(amount) + .build()?; + let approve_ix = create_spl_approve_ix(approve_params)?; + + // 3. Create USDC ATA instruction (idempotent) + let create_usdc_ata_ix = + create_associated_token_account_idempotent_ix(authority, authority, USDC_MINT); + + // 4. Withdraw funds instruction (Phoenix protocol -> Phoenix token ATA) + let withdraw_params = WithdrawFundsParams::builder() + .trader(authority) + .trader_account(trader_pda) + .perp_asset_map(perp_asset_map) + .global_vault(global_vault) + .trader_token_account(trader_phoenix_ata) + .global_trader_index(global_trader_index) + .active_trader_buffer(active_trader_buffer) + .withdraw_queue(withdraw_queue) + .amount(amount) + .build()?; + let withdraw_ix = create_withdraw_funds_ix(withdraw_params)?; + + // 5. Ember withdraw instruction (Phoenix tokens -> USDC) + let ember_params = EmberWithdrawParams::builder() + .trader(authority) + .ember_state(ember_state) + .ember_vault(ember_vault) + .usdc_mint(USDC_MINT) + .canonical_mint(canonical_mint) + .trader_usdc_account(trader_usdc_ata) + .trader_phoenix_account(trader_phoenix_ata) + .amount(Some(amount)) + .build()?; + let ember_ix = create_ember_withdraw_ix(ember_params)?; + + Ok(vec![ + create_phoenix_ata_ix.into(), + approve_ix.into(), + create_usdc_ata_ix.into(), + withdraw_ix.into(), + ember_ix.into(), + ]) + } + + /// Build a register trader instruction. + /// + /// Registers a new trader account. The authority pays for account creation. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (also pays for account + /// creation) + /// * `pda_index` - The PDA index for trader derivation + /// * `subaccount_index` - 0 for cross-margin, 1-100 for isolated margin + /// + /// # Returns + /// + /// A vector containing the register trader instruction. + pub fn build_register_trader( + &self, + authority: Pubkey, + pda_index: u8, + subaccount_index: u8, + ) -> Result, PhoenixTxBuilderError> { + let max_positions: u64 = if subaccount_index == CROSS_MARGIN_SUBACCOUNT_IDX { + 128 + } else { + 1 + }; + let trader_pda = + phoenix_types::TraderKey::derive_pda(&authority, pda_index, subaccount_index); + + let params = RegisterTraderParams::builder() + .payer(authority) + .trader(authority) + .trader_account(trader_pda) + .max_positions(max_positions) + .trader_pda_index(pda_index) + .subaccount_index(subaccount_index) + .build()?; + let ix = create_register_trader_ix(params)?; + + Ok(vec![ix.into()]) + } + + /// Build a transfer collateral instruction. + /// + /// Transfers collateral between two subaccounts (e.g., from cross-margin + /// subaccount 0 to an isolated margin subaccount). + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `src_trader_pda` - The source trader PDA account + /// * `dst_trader_pda` - The destination trader PDA account + /// * `usdc_amount` - Amount of USDC to transfer (e.g., 100.0 for $100) + /// + /// # Returns + /// + /// A vector containing the transfer collateral instruction. + pub fn build_transfer_collateral( + &self, + authority: Pubkey, + src_trader_pda: Pubkey, + dst_trader_pda: Pubkey, + usdc_amount: f64, + ) -> Result, PhoenixTxBuilderError> { + let amount = (usdc_amount * 1_000_000.0) as u64; + + let keys = self.metadata.keys(); + let perp_asset_map = Pubkey::from_str(&keys.perp_asset_map)?; + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + let active_trader_buffer = parse_pubkey_vec(&keys.active_trader_buffer)?; + + let params = TransferCollateralParams::builder() + .trader(authority) + .src_trader_account(src_trader_pda) + .dst_trader_account(dst_trader_pda) + .perp_asset_map(perp_asset_map) + .global_trader_index(global_trader_index) + .active_trader_buffer(active_trader_buffer) + .amount(amount) + .build()?; + + let ix = create_transfer_collateral_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build a transfer collateral child-to-parent instruction. + /// + /// Transfers **all** collateral from a child subaccount back to the parent + /// (subaccount 0). No-ops on-chain if the child has open positions, open + /// orders, or zero collateral. + /// + /// # Arguments + /// + /// * `authority` - The trader's wallet address (signer) + /// * `child_trader_pda` - The child trader PDA account + /// * `parent_trader_pda` - The parent trader PDA account + pub fn build_transfer_collateral_child_to_parent( + &self, + authority: Pubkey, + child_trader_pda: Pubkey, + parent_trader_pda: Pubkey, + ) -> Result, PhoenixTxBuilderError> { + let keys = self.metadata.keys(); + let perp_asset_map = Pubkey::from_str(&keys.perp_asset_map)?; + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + let active_trader_buffer = parse_pubkey_vec(&keys.active_trader_buffer)?; + + let params = TransferCollateralChildToParentParams::builder() + .trader(authority) + .child_trader_account(child_trader_pda) + .parent_trader_account(parent_trader_pda) + .perp_asset_map(perp_asset_map) + .global_trader_index(global_trader_index) + .active_trader_buffer(active_trader_buffer) + .build()?; + + let ix = create_transfer_collateral_child_to_parent_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Build a sync parent-to-child instruction. + /// + /// Syncs a parent trader account's state to a child (isolated) subaccount, + /// including global trader index updates. + /// + /// # Arguments + /// + /// * `trader_wallet` - The trader wallet authority + /// * `parent_trader_pda` - The parent trader PDA (subaccount 0) + /// * `child_trader_pda` - The child trader PDA (subaccount > 0) + pub fn build_sync_parent_to_child( + &self, + trader_wallet: Pubkey, + parent_trader_pda: Pubkey, + child_trader_pda: Pubkey, + ) -> Result, PhoenixTxBuilderError> { + let keys = self.metadata.keys(); + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + + let params = SyncParentToChildParams::builder() + .trader_wallet(trader_wallet) + .parent_trader_account(parent_trader_pda) + .child_trader_account(child_trader_pda) + .global_trader_index(global_trader_index) + .build()?; + + let ix = create_sync_parent_to_child_ix(params)?; + Ok(vec![ix.into()]) + } + + /// Register a new isolated subaccount and sync parent capabilities to it. + fn register_and_sync_subaccount( + &self, + parent_key: &TraderKey, + child_key: &TraderKey, + ) -> Result, PhoenixTxBuilderError> { + let mut ixs = self.build_register_trader( + child_key.authority(), + child_key.pda_index, + child_key.subaccount_index, + )?; + ixs.extend(self.build_sync_parent_to_child( + child_key.authority(), + parent_key.pda(), + child_key.pda(), + )?); + Ok(ixs) + } + + /// Resolve the isolated subaccount key, optionally registering it, then + /// apply collateral flow instructions. Returns the subaccount key and + /// accumulated instructions. + fn prepare_isolated_subaccount( + &self, + trader: &Trader, + symbol: &str, + allow_cross_and_isolated: bool, + collateral: &Option, + ) -> Result<(TraderKey, Vec), PhoenixTxBuilderError> { + if !allow_cross_and_isolated { + if let Some(primary) = trader.primary_subaccount() { + if primary.positions.contains_key(symbol) { + return Err(PhoenixTxBuilderError::CrossMarginPositionExists( + symbol.to_string(), + )); + } + } + } + + let mut ixs = Vec::new(); + + let sub_key = trader + .get_or_create_isolated_subaccount_key(symbol) + .ok_or(PhoenixTxBuilderError::NoAvailableSubaccount)?; + + if !trader.subaccount_exists(sub_key.subaccount_index) { + ixs.extend(self.register_and_sync_subaccount(&trader.key, &sub_key)?); + } + + match collateral { + Some(IsolatedCollateralFlow::TransferFromCrossMargin { collateral }) => { + let existing = trader + .get_collateral_for_subaccount(sub_key.subaccount_index) + .as_inner() + .max(0) as u64; + if *collateral > existing { + let transfer_amount = *collateral - existing; + + let parent_collateral = trader + .primary_subaccount() + .map(|s| s.collateral.as_inner().max(0) as u64) + .unwrap_or(0); + + if parent_collateral < transfer_amount { + return Err(PhoenixTxBuilderError::InsufficientParentCollateral { + need: transfer_amount, + have: parent_collateral, + }); + } + + let usdc_amount = transfer_amount as f64 / USDC_NATIVE_DECIMALS; + ixs.extend(self.build_transfer_collateral( + sub_key.authority(), + trader.key.pda(), + sub_key.pda(), + usdc_amount, + )?); + } + } + Some(IsolatedCollateralFlow::Deposit { usdc_amount }) => { + let usdc = *usdc_amount as f64 / USDC_NATIVE_DECIMALS; + ixs.extend(self.build_deposit_funds(sub_key.authority(), sub_key.pda(), usdc)?); + } + None => {} + } + + Ok((sub_key, ixs)) + } + + /// Build an isolated margin market order (convenience method). + /// + /// Encapsulates the full isolated margin trading flow: + /// 1. Selects (or registers) an isolated subaccount for the asset + /// 2. Funds the subaccount based on `collateral` + /// 3. Places the market order + /// 4. Optionally places bracket leg (SL/TP) orders + /// 5. Sweeps remaining collateral back to parent if subaccount existed + /// + /// # Returns + /// + /// 1+ instructions depending on subaccount state. + pub fn build_isolated_market_order( + &self, + trader: &Trader, + symbol: &str, + side: Side, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixTxBuilderError> { + let params = IsolatedMarketOrderParams { + side, + price_in_ticks: None, + num_base_lots, + num_quote_lots: None, + min_base_lots_to_fill: 0, + min_quote_lots_to_fill: 0, + self_trade_behavior: phoenix_ix::SelfTradeBehavior::Abort, + match_limit: None, + client_order_id: 0, + last_valid_slot: None, + order_flags: phoenix_ix::OrderFlags::None, + cancel_existing: false, + allow_cross_and_isolated, + collateral, + }; + self.build_isolated_market_order_with_params(trader, symbol, params, bracket) + } + + /// Build an isolated margin market order with pre-built params. + /// + /// Same flow as `build_isolated_market_order` but accepts full + /// `IsolatedMarketOrderParams` for advanced configuration. + pub fn build_isolated_market_order_with_params( + &self, + trader: &Trader, + symbol: &str, + params: IsolatedMarketOrderParams, + bracket: Option<&BracketLegOrders>, + ) -> Result, PhoenixTxBuilderError> { + let (sub_key, mut ixs) = self.prepare_isolated_subaccount( + trader, + symbol, + params.allow_cross_and_isolated, + ¶ms.collateral, + )?; + + let side = params.side; + + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + let addrs = self.parse_addresses(market)?; + + let mut builder = MarketOrderParams::builder() + .trader(sub_key.authority()) + .trader_account(sub_key.pda()) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .side(params.side) + .num_base_lots(params.num_base_lots) + .symbol(symbol) + .subaccount_index(sub_key.subaccount_index) + .self_trade_behavior(params.self_trade_behavior) + .order_flags(params.order_flags) + .cancel_existing(params.cancel_existing) + .client_order_id(params.client_order_id) + .min_base_lots_to_fill(params.min_base_lots_to_fill) + .min_quote_lots_to_fill(params.min_quote_lots_to_fill); + + if let Some(v) = params.price_in_ticks { + builder = builder.price_in_ticks(v); + } + if let Some(v) = params.num_quote_lots { + builder = builder.num_quote_lots(v); + } + if let Some(v) = params.match_limit { + builder = builder.match_limit(v); + } + if let Some(v) = params.last_valid_slot { + builder = builder.last_valid_slot(v); + } + + // Place order (pass None — bracket ixs are inserted below, before sweep) + ixs.extend(self.build_market_order_with_params(builder.build()?, None)?); + + // Bracket legs before child-to-parent sweep + if let Some(bracket) = bracket { + ixs.extend(self.build_bracket_leg_orders( + sub_key.authority(), + sub_key.pda(), + symbol, + side, + bracket, + )?); + } + + if trader.subaccount_exists(sub_key.subaccount_index) { + ixs.extend(self.build_transfer_collateral_child_to_parent( + sub_key.authority(), + sub_key.pda(), + trader.key.pda(), + )?); + } + + Ok(ixs) + } + + /// Build an isolated margin limit order (convenience method). + /// + /// Same flow as `build_isolated_market_order` but places a limit order. + /// Takes `price` as a USD float and converts to ticks internally. + pub fn build_isolated_limit_order( + &self, + trader: &Trader, + symbol: &str, + side: Side, + price: f64, + num_base_lots: u64, + collateral: Option, + allow_cross_and_isolated: bool, + ) -> Result, PhoenixTxBuilderError> { + let calc = self + .metadata + .get_market_calculator(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + + let price_in_ticks = calc.price_to_ticks(price)?.as_inner(); + + let params = IsolatedLimitOrderParams { + side, + price_in_ticks, + num_base_lots, + self_trade_behavior: phoenix_ix::SelfTradeBehavior::Abort, + match_limit: None, + client_order_id: 0, + last_valid_slot: None, + order_flags: phoenix_ix::OrderFlags::None, + cancel_existing: false, + allow_cross_and_isolated, + collateral, + }; + self.build_isolated_limit_order_with_params(trader, symbol, params) + } + + /// Build an isolated margin limit order with pre-built params. + /// + /// Same flow as `build_isolated_limit_order` but accepts full + /// `IsolatedLimitOrderParams` for advanced configuration. + pub fn build_isolated_limit_order_with_params( + &self, + trader: &Trader, + symbol: &str, + params: IsolatedLimitOrderParams, + ) -> Result, PhoenixTxBuilderError> { + let (sub_key, mut ixs) = self.prepare_isolated_subaccount( + trader, + symbol, + params.allow_cross_and_isolated, + ¶ms.collateral, + )?; + + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + let addrs = self.parse_addresses(market)?; + + let mut builder = LimitOrderParams::builder() + .trader(sub_key.authority()) + .trader_account(sub_key.pda()) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index) + .active_trader_buffer(addrs.active_trader_buffer) + .side(params.side) + .price_in_ticks(params.price_in_ticks) + .num_base_lots(params.num_base_lots) + .symbol(symbol) + .subaccount_index(sub_key.subaccount_index) + .self_trade_behavior(params.self_trade_behavior) + .order_flags(params.order_flags) + .cancel_existing(params.cancel_existing) + .client_order_id(params.client_order_id); + + if let Some(v) = params.match_limit { + builder = builder.match_limit(v); + } + if let Some(v) = params.last_valid_slot { + builder = builder.last_valid_slot(v); + } + + ixs.extend(self.build_limit_order_with_params(builder.build()?)?); + + if trader.subaccount_exists(sub_key.subaccount_index) { + ixs.extend(self.build_transfer_collateral_child_to_parent( + sub_key.authority(), + sub_key.pda(), + trader.key.pda(), + )?); + } + + Ok(ixs) + } + + /// Build stop-loss and/or take-profit bracket leg instructions. + /// + /// Both use the on-chain `PlaceStopLoss` instruction. Direction logic: + /// - Primary Bid (long): SL triggers LessThan, TP triggers GreaterThan, + /// bracket trade side = Ask + /// - Primary Ask (short): SL triggers GreaterThan, TP triggers LessThan, + /// bracket trade side = Bid + pub fn build_bracket_leg_orders( + &self, + authority: Pubkey, + trader_account: Pubkey, + symbol: &str, + primary_side: Side, + bracket: &BracketLegOrders, + ) -> Result, PhoenixTxBuilderError> { + let market = self + .metadata + .get_market(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + let calc = self + .metadata + .get_market_calculator(symbol) + .ok_or_else(|| PhoenixTxBuilderError::UnknownSymbol(symbol.to_string()))?; + let addrs = self.parse_addresses(market)?; + let asset_id = market.asset_id as u64; + + let (bracket_trade_side, sl_direction, tp_direction) = match primary_side { + Side::Bid => (Side::Ask, Direction::LessThan, Direction::GreaterThan), + Side::Ask => (Side::Bid, Direction::GreaterThan, Direction::LessThan), + }; + + let mut ixs = Vec::new(); + + if let Some(sl_price) = bracket.stop_loss_price { + let price_in_ticks = calc.price_to_ticks(sl_price)?.as_inner(); + let params = StopLossParams::builder() + .funder(authority) + .trader_account(trader_account) + .position_authority(authority) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index.clone()) + .active_trader_buffer(addrs.active_trader_buffer.clone()) + .asset_id(asset_id) + .trigger_price(price_in_ticks) + .execution_price(price_in_ticks) + .trade_side(bracket_trade_side) + .execution_direction(sl_direction) + .order_kind(StopLossOrderKind::IOC) + .build()?; + ixs.push(create_place_stop_loss_ix(params)?.into()); + } + + if let Some(tp_price) = bracket.take_profit_price { + let price_in_ticks = calc.price_to_ticks(tp_price)?.as_inner(); + let params = StopLossParams::builder() + .funder(authority) + .trader_account(trader_account) + .position_authority(authority) + .perp_asset_map(addrs.perp_asset_map) + .orderbook(addrs.orderbook) + .spline_collection(addrs.spline_collection) + .global_trader_index(addrs.global_trader_index.clone()) + .active_trader_buffer(addrs.active_trader_buffer.clone()) + .asset_id(asset_id) + .trigger_price(price_in_ticks) + .execution_price(price_in_ticks) + .trade_side(bracket_trade_side) + .execution_direction(tp_direction) + .order_kind(StopLossOrderKind::IOC) + .build()?; + ixs.push(create_place_stop_loss_ix(params)?.into()); + } + + Ok(ixs) + } + + /// Return an error if `symbol` is an isolated-only market. + fn reject_isolated_only(&self, symbol: &str) -> Result<(), PhoenixTxBuilderError> { + if self.metadata.is_isolated_only(symbol) { + return Err(PhoenixTxBuilderError::IsolatedOnlyMarket( + symbol.to_ascii_uppercase(), + )); + } + Ok(()) + } + + /// Parse all required addresses from the exchange metadata for a given + /// market. + fn parse_addresses( + &self, + market: &ExchangeMarketConfig, + ) -> Result { + let keys = self.metadata.keys(); + let perp_asset_map = Pubkey::from_str(&keys.perp_asset_map)?; + let global_trader_index = parse_pubkey_vec(&keys.global_trader_index)?; + let active_trader_buffer = parse_pubkey_vec(&keys.active_trader_buffer)?; + let orderbook = Pubkey::from_str(&market.market_pubkey)?; + let spline_collection = Pubkey::from_str(&market.spline_pubkey)?; + + Ok(ParsedAddresses { + perp_asset_map, + global_trader_index, + active_trader_buffer, + orderbook, + spline_collection, + }) + } +} + +/// Parse a vector of base58-encoded pubkeys. +fn parse_pubkey_vec(strings: &[String]) -> Result, PhoenixTxBuilderError> { + strings + .iter() + .map(|s| Pubkey::from_str(s).map_err(PhoenixTxBuilderError::from)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pubkey_vec() { + // Valid Solana pubkeys (32 bytes, base58 encoded) + let pubkeys = vec![ + "11111111111111111111111111111112".to_string(), // System program + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), // SPL Token + ]; + let result = parse_pubkey_vec(&pubkeys).unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_parse_pubkey_vec_invalid() { + let pubkeys = vec!["invalid".to_string()]; + let result = parse_pubkey_vec(&pubkeys); + assert!(result.is_err()); + } +} diff --git a/container/vendor/rise/rust/sdk/src/ws_client.rs b/container/vendor/rise/rust/sdk/src/ws_client.rs new file mode 100644 index 00000000000..067e89eee61 --- /dev/null +++ b/container/vendor/rise/rust/sdk/src/ws_client.rs @@ -0,0 +1,804 @@ +//! WebSocket client for connecting to the Phoenix API. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; + +use futures_util::{SinkExt, StreamExt}; +use phoenix_types::{ + AllMidsData, CandleData, CandlesSubscriptionRequest, ClientMessage, FundingRateMessage, + FundingRateSubscriptionRequest, L2BookUpdate, MarketStatsUpdate, MarketSubscriptionRequest, + OrderbookSubscriptionRequest, PhoenixWsError, ServerMessage, SubscriptionConfirmedMessage, + SubscriptionErrorMessage, SubscriptionKey, SubscriptionRequest, Timeframe, + TraderStateServerMessage, TraderStateSubscriptionRequest, TradesMessage, + TradesSubscriptionRequest, +}; +use solana_pubkey::Pubkey; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::header::{HeaderName, HeaderValue}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tracing::{debug, error, info, warn}; +use url::Url; + +use crate::env::PhoenixEnv; + +/// WebSocket connection status events. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WsConnectionStatus { + /// Attempting to connect to the WebSocket server. + Connecting, + /// Successfully connected to the WebSocket server. + Connected, + /// Connection attempt failed. + ConnectionFailed, + /// Connection was closed or lost. + Disconnected(String), +} + +/// Handle for managing an active subscription. +/// +/// When dropped, automatically sends an unsubscribe message to the server +/// (if this is the last subscriber for this key). +/// +/// # Example +/// +/// ```ignore +/// let (mut rx, handle) = client.subscribe_to_orderbook("SOL".to_string())?; +/// +/// // Process messages... +/// while let Some(msg) = rx.recv().await { +/// // ... +/// } +/// +/// // Unsubscribe by dropping the handle (or let it go out of scope) +/// drop(handle); +/// ``` +pub struct SubscriptionHandle { + control_tx: mpsc::UnboundedSender, + key: SubscriptionKey, + subscriber_id: u64, +} + +impl Drop for SubscriptionHandle { + fn drop(&mut self) { + let _ = self.control_tx.send(ControlMessage::Unsubscribe { + key: self.key.clone(), + subscriber_id: self.subscriber_id, + }); + } +} + +/// Subscriber channel for different message types. +enum Subscriber { + AllMids(mpsc::UnboundedSender), + FundingRate(mpsc::UnboundedSender), + L2Book(mpsc::UnboundedSender), + TraderState(mpsc::UnboundedSender), + MarketStats(mpsc::UnboundedSender), + Trades(mpsc::UnboundedSender), + Candles(mpsc::UnboundedSender), +} + +/// Internal control messages for the connection manager. +enum ControlMessage { + Subscribe { + key: SubscriptionKey, + request: SubscriptionRequest, + subscriber: Subscriber, + subscriber_id: u64, + }, + Unsubscribe { + key: SubscriptionKey, + subscriber_id: u64, + }, + Shutdown, +} + +/// WebSocket client for Phoenix API. +/// +/// Handles connection management and message routing to subscribers. +pub struct PhoenixWSClient { + control_tx: mpsc::UnboundedSender, + ws_url: Url, + ws_connection_status_rx: Option>, + next_subscriber_id: AtomicU64, +} + +impl PhoenixWSClient { + /// Create a new WebSocket client using environment variables. + /// + /// Uses `PhoenixEnv::load()` to read configuration from environment. + pub fn new_from_env() -> Result { + Self::from_env(PhoenixEnv::load()) + } + + /// Create a new WebSocket client using environment variables with + /// connection status updates. + /// + /// Use `connection_status_receiver()` to get the receiver for status + /// updates. + pub fn new_from_env_with_connection_status() -> Result { + Self::from_env_with_connection_status(PhoenixEnv::load()) + } + + /// Create a new WebSocket client from a `PhoenixEnv`. + pub fn from_env(env: PhoenixEnv) -> Result { + Self::new_internal(&env.ws_url, env.api_key, false) + } + + /// Create a new WebSocket client from a `PhoenixEnv` with connection status + /// updates. + /// + /// Use `connection_status_receiver()` to get the receiver for status + /// updates. + pub fn from_env_with_connection_status(env: PhoenixEnv) -> Result { + Self::new_internal(&env.ws_url, env.api_key, true) + } + + /// Create a new WebSocket client and connect to the server. + /// + /// # Arguments + /// * `ws_url` - The WebSocket URL (e.g., "wss://api.phoenix.trade/v1/ws") + /// * `api_key` - Optional API key for authentication + pub fn new(ws_url: &str, api_key: Option) -> Result { + Self::new_internal(ws_url, api_key, false) + } + + /// Create a new WebSocket client with connection status updates enabled. + /// + /// Use `connection_status_receiver()` to get the receiver for status + /// updates. + /// + /// # Arguments + /// * `ws_url` - The WebSocket URL (e.g., "wss://api.phoenix.trade/v1/ws") + /// * `api_key` - Optional API key for authentication + pub fn new_with_connection_status( + ws_url: &str, + api_key: Option, + ) -> Result { + Self::new_internal(ws_url, api_key, true) + } + + /// Internal constructor. + fn new_internal( + ws_url: &str, + api_key: Option, + receiver_connection_status: bool, + ) -> Result { + let url = Url::parse(ws_url)?; + let (control_tx, control_rx) = mpsc::unbounded_channel(); + + let (ws_connection_status_tx, ws_connection_status_rx) = if receiver_connection_status { + let (tx, rx) = mpsc::unbounded_channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + + let client = Self { + control_tx, + ws_url: url.clone(), + ws_connection_status_rx, + next_subscriber_id: AtomicU64::new(0), + }; + + // Spawn the connection manager task + tokio::spawn(Self::connection_manager( + url, + api_key, + control_rx, + ws_connection_status_tx, + )); + + Ok(client) + } + + /// Subscribe to all mid prices. + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `AllMidsData` messages. Drop the handle to unsubscribe. + pub fn subscribe_to_all_mids( + &self, + ) -> Result<(mpsc::UnboundedReceiver, SubscriptionHandle), PhoenixWsError> { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::all_mids(); + let request = SubscriptionRequest::AllMids; + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::AllMids(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to funding rate updates for a given symbol. + /// + /// # Arguments + /// * `symbol` - Market symbol (e.g., "SOL" or "BTC") + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `FundingRateMessage` messages. Drop the handle to unsubscribe. + pub fn subscribe_to_funding_rate( + &self, + symbol: String, + ) -> Result< + ( + mpsc::UnboundedReceiver, + SubscriptionHandle, + ), + PhoenixWsError, + > { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::funding_rate(symbol.clone()); + let request = SubscriptionRequest::FundingRate(FundingRateSubscriptionRequest { symbol }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::FundingRate(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to orderbook updates for a given symbol. + /// + /// # Arguments + /// * `symbol` - Market symbol (e.g., "SOL" or "BTC") + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `L2BookUpdate` messages. Drop the handle to unsubscribe. + pub fn subscribe_to_orderbook( + &self, + symbol: String, + ) -> Result<(mpsc::UnboundedReceiver, SubscriptionHandle), PhoenixWsError> { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::orderbook(symbol.clone()); + let request = SubscriptionRequest::Orderbook(OrderbookSubscriptionRequest { symbol }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::L2Book(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to trader state updates for the given authority (uses PDA + /// index 0). + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `TraderStateServerMessage` updates. Drop the handle to unsubscribe. + pub fn subscribe_to_trader_state( + &self, + authority: &Pubkey, + ) -> Result< + ( + mpsc::UnboundedReceiver, + SubscriptionHandle, + ), + PhoenixWsError, + > { + self.subscribe_to_trader_state_with_pda(authority, 0) + } + + /// Subscribe to trader state updates for the given authority and PDA index. + /// + /// # Arguments + /// * `authority` - The trader's authority pubkey + /// * `trader_pda_index` - The trader PDA subaccount index + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `TraderStateServerMessage` updates. Drop the handle to unsubscribe. + pub fn subscribe_to_trader_state_with_pda( + &self, + authority: &Pubkey, + trader_pda_index: u8, + ) -> Result< + ( + mpsc::UnboundedReceiver, + SubscriptionHandle, + ), + PhoenixWsError, + > { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::trader(authority, trader_pda_index); + let request = SubscriptionRequest::TraderState(TraderStateSubscriptionRequest { + authority: authority.to_string(), + trader_pda_index, + }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::TraderState(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to market updates for a given symbol. + /// + /// # Arguments + /// * `symbol` - Market symbol (e.g., "SOL" or "BTC") + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `MarketStatsUpdate` messages. Drop the handle to unsubscribe. + pub fn subscribe_to_market( + &self, + symbol: String, + ) -> Result< + ( + mpsc::UnboundedReceiver, + SubscriptionHandle, + ), + PhoenixWsError, + > { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::market(symbol.clone()); + let request = SubscriptionRequest::Market(MarketSubscriptionRequest { symbol }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::MarketStats(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to trade updates. + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `TradesMessage` messages containing the symbol and array of trades. + /// Drop the handle to unsubscribe. + pub fn subscribe_to_trades( + &self, + symbol: String, + ) -> Result<(mpsc::UnboundedReceiver, SubscriptionHandle), PhoenixWsError> { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::trades(symbol.clone()); + let request = SubscriptionRequest::Trades(TradesSubscriptionRequest { symbol }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::Trades(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Subscribe to candle updates. + /// + /// # Arguments + /// * `symbol` - Market symbol (e.g., "SOL"). + /// * `timeframe` - Candle timeframe (e.g., Timeframe::Minute1). + /// + /// Returns a tuple of (receiver, handle). The receiver will receive + /// `CandleData` messages. Drop the handle to unsubscribe. + pub fn subscribe_to_candles( + &self, + symbol: String, + timeframe: Timeframe, + ) -> Result<(mpsc::UnboundedReceiver, SubscriptionHandle), PhoenixWsError> { + let subscriber_id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + let sub_key = SubscriptionKey::candles(symbol.clone(), timeframe); + let request = + SubscriptionRequest::Candles(CandlesSubscriptionRequest { symbol, timeframe }); + + self.control_tx + .send(ControlMessage::Subscribe { + key: sub_key.clone(), + request, + subscriber: Subscriber::Candles(tx), + subscriber_id, + }) + .map_err(|_| PhoenixWsError::SubscriptionClosed)?; + + let handle = SubscriptionHandle { + control_tx: self.control_tx.clone(), + key: sub_key, + subscriber_id, + }; + + Ok((rx, handle)) + } + + /// Returns the WebSocket URL. + pub fn url(&self) -> &Url { + &self.ws_url + } + + /// Returns the connection status receiver, if enabled during construction. + /// + /// This takes ownership of the receiver, so it can only be called once. + /// Returns `None` if `receiver_connection_status` was `false` during + /// construction, or if the receiver has already been taken. + pub fn connection_status_receiver( + &mut self, + ) -> Option> { + self.ws_connection_status_rx.take() + } + + /// Shutdown the client and close the connection. + pub fn shutdown(&self) { + let _ = self.control_tx.send(ControlMessage::Shutdown); + } + + /// Connection manager that handles WebSocket connection and message + /// routing. + async fn connection_manager( + url: Url, + api_key: Option, + mut control_rx: mpsc::UnboundedReceiver, + ws_connection_status_tx: Option>, + ) { + let mut subscribers: HashMap> = HashMap::new(); + let mut active_subscriptions: HashMap = + HashMap::new(); + + // Send connecting status + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::Connecting); + } + + // Connect to WebSocket + let ws_stream = match Self::connect(&url, api_key.as_deref()).await { + Ok(stream) => { + info!("Connected to WebSocket: {}", url); + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::Connected); + } + stream + } + Err(e) => { + error!("Failed to connect: {:?}", e); + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::ConnectionFailed); + } + return; + } + }; + + let (mut ws_sink, mut ws_stream) = ws_stream.split(); + + // Main message loop + loop { + tokio::select! { + // Handle incoming WebSocket messages + ws_msg = ws_stream.next() => { + match ws_msg { + Some(Ok(Message::Text(text))) => { + Self::process_message(text.as_bytes(), &subscribers); + } + Some(Ok(Message::Binary(data))) => { + Self::process_message(&data, &subscribers); + } + Some(Ok(Message::Ping(data))) => { + if let Err(e) = ws_sink.send(Message::Pong(data)).await { + debug!("Failed to respond to Ping: {e:?}"); + } + } + Some(Ok(Message::Pong(_))) => { + debug!("Received pong"); + } + Some(Ok(Message::Close(frame))) => { + let reason = if let Some(frame) = frame { + warn!("WebSocket closed: code={}, reason={}", frame.code, frame.reason); + format!("closed: code={}, reason={}", frame.code, frame.reason) + } else { + warn!("WebSocket closed without frame"); + "closed without frame".to_string() + }; + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::Disconnected(reason)); + } + return; + } + Some(Ok(_)) => {} // Ignore other message types + Some(Err(e)) => { + error!("WebSocket error: {:?}", e); + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::Disconnected(format!("error: {:?}", e))); + } + return; + } + None => { + warn!("WebSocket stream ended"); + if let Some(ref tx) = ws_connection_status_tx { + let _ = tx.send(WsConnectionStatus::Disconnected("stream ended".to_string())); + } + return; + } + } + } + + // Handle control messages + control_msg = control_rx.recv() => { + match control_msg { + Some(ControlMessage::Subscribe { key, request, subscriber, subscriber_id }) => { + // Get or create the inner HashMap for this key + let key_subscribers = subscribers.entry(key.clone()).or_default(); + + // Check if this is the first subscriber for this key + let is_first_subscriber = key_subscribers.is_empty(); + + // Insert the new subscriber + key_subscribers.insert(subscriber_id, subscriber); + + // Only send wire subscription on first subscriber + if is_first_subscriber { + active_subscriptions.insert(key.clone(), request.clone()); + + // Send subscription request + let msg = ClientMessage::Subscribe { subscription: request }; + if let Ok(bytes) = serde_json::to_vec(&msg) { + debug!("Sending subscription: {}", String::from_utf8_lossy(&bytes)); + if let Err(e) = ws_sink.send(Message::Binary(bytes.into())).await { + error!("Failed to send subscription: {:?}", e); + } + } + } + } + Some(ControlMessage::Unsubscribe { key, subscriber_id }) => { + // Remove this specific subscriber + let should_unsubscribe = if let Some(key_subscribers) = subscribers.get_mut(&key) { + key_subscribers.remove(&subscriber_id); + key_subscribers.is_empty() + } else { + false + }; + + // If no subscribers remain for this key, unsubscribe from server + if should_unsubscribe { + subscribers.remove(&key); + if let Some(request) = active_subscriptions.remove(&key) { + // Send unsubscription request + let msg = ClientMessage::Unsubscribe { subscription: request }; + if let Ok(bytes) = serde_json::to_vec(&msg) { + let _ = ws_sink.send(Message::Binary(bytes.into())).await; + } + } + } + } + Some(ControlMessage::Shutdown) | None => { + info!("Shutting down WebSocket client"); + let _ = ws_sink.close().await; + return; + } + } + } + } + } + } + + /// Connect to the WebSocket server. + async fn connect( + url: &Url, + api_key: Option<&str>, + ) -> Result>, PhoenixWsError> { + const API_KEY_HEADER: HeaderName = HeaderName::from_static("x-api-key"); + + let mut request = url.as_str().into_client_request()?; + if let Some(key) = api_key { + let value = HeaderValue::from_str(key) + .map_err(|e| PhoenixWsError::InvalidHeaderValue(e.to_string()))?; + request.headers_mut().insert(API_KEY_HEADER, value); + } + + let (ws_stream, _response) = connect_async(request).await?; + Ok(ws_stream) + } + + /// Broadcast a message to all subscribers for a given key. + /// + /// The `try_send` closure should attempt to send the message if the + /// subscriber matches the expected variant, returning `true` if the + /// send failed (channel closed). + fn broadcast_to_subscribers( + subscribers: &HashMap>, + key: &SubscriptionKey, + try_send: F, + ) where + F: Fn(&Subscriber) -> bool, + { + if let Some(key_subscribers) = subscribers.get(key) { + for (id, subscriber) in key_subscribers { + if try_send(subscriber) { + debug!("Subscriber {} channel closed for {:?}", id, key); + } + } + } + } + + /// Handle an incoming WebSocket message. + /// Process an incoming WebSocket data message (subscription confirmations, + /// errors, and channel payloads). + fn process_message( + data: &[u8], + subscribers: &HashMap>, + ) { + let text = match std::str::from_utf8(data) { + Ok(s) => s, + Err(e) => { + debug!("Received non-UTF8 binary message: {:?}", e); + return; + } + }; + debug!("Received message: {}", text); + + // Handle subscription confirmed messages + if let Ok(confirmed) = serde_json::from_slice::(data) { + debug!("Subscription confirmed: {:?}", confirmed.subscription); + return; + } + + // Handle subscription error messages + if let Ok(error) = serde_json::from_slice::(data) { + error!( + "Subscription error: code={}, message={}", + error.code, error.message + ); + return; + } + + Self::handle_message(data, text, subscribers); + } + + /// Handle a data message and route to subscribers. + fn handle_message( + data: &[u8], + text: &str, + subscribers: &HashMap>, + ) { + match serde_json::from_slice::(data) { + Ok(ServerMessage::AllMids(msg)) => { + let key = SubscriptionKey::all_mids(); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::AllMids(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::FundingRate(msg)) => { + let key = SubscriptionKey::funding_rate(msg.symbol.clone()); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::FundingRate(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::Orderbook(msg)) => { + let key = SubscriptionKey::orderbook(msg.symbol.clone()); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::L2Book(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::TraderState(msg)) => { + let key = SubscriptionKey::TraderState { + authority: msg.authority.clone(), + trader_pda_index: msg.trader_pda_index, + }; + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::TraderState(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::Market(msg)) => { + let key = SubscriptionKey::market(msg.symbol.clone()); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub: &Subscriber| matches!(sub, Subscriber::MarketStats(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::Trades(msg)) => { + let key = SubscriptionKey::trades(msg.symbol.clone()); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::Trades(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::Candles(msg)) => { + let Some(timeframe) = msg.timeframe.parse().ok() else { + debug!( + "Failed to parse timeframe from candle message: {}", + msg.timeframe + ); + return; + }; + let key = SubscriptionKey::candles(msg.symbol.clone(), timeframe); + Self::broadcast_to_subscribers( + subscribers, + &key, + |sub| matches!(sub, Subscriber::Candles(tx) if tx.send(msg.clone()).is_err()), + ); + } + Ok(ServerMessage::Error(err)) => { + error!("Server error: code={}, error={}", err.code, err.error); + } + Ok(_) => { + // Ignore other message types + } + Err(e) => { + debug!("Failed to parse message: {} - {}", e, text); + } + } + } +} + +impl Drop for PhoenixWSClient { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/container/vendor/rise/rust/sdk/tests/trader_state_tests.rs b/container/vendor/rise/rust/sdk/tests/trader_state_tests.rs new file mode 100644 index 00000000000..91b6e487b9a --- /dev/null +++ b/container/vendor/rise/rust/sdk/tests/trader_state_tests.rs @@ -0,0 +1,457 @@ +//! Tests for the Trader state container. + +use phoenix_sdk::types::{ + CapabilityAccess, CooldownStatus, TraderCapabilitiesView, TraderStateCapabilities, + TraderStateDelta, TraderStatePayload, TraderStatePositionDelta, TraderStatePositionRow, + TraderStatePositionSnapshot, TraderStateRowChangeKind, TraderStateServerMessage, + TraderStateSnapshot, TraderStateSubaccountDelta, TraderStateSubaccountSnapshot, +}; +use phoenix_sdk::{Trader, TraderKey}; +use rust_decimal::Decimal; +use solana_pubkey::Pubkey; + +fn make_capabilities() -> TraderStateCapabilities { + TraderStateCapabilities { + flags: 63, + state: "active".to_string(), + capabilities: TraderCapabilitiesView { + place_limit_order: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + place_market_order: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + risk_increasing_trade: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + risk_reducing_trade: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + deposit_collateral: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + withdraw_collateral: CapabilityAccess { + immediate: true, + via_cold_activation: true, + }, + }, + } +} + +fn make_position_row(base_lots: i64, entry_price: &str) -> TraderStatePositionRow { + TraderStatePositionRow { + position_sequence_number: "1".to_string(), + base_position_lots: base_lots.to_string(), + entry_price_ticks: "1000".to_string(), + entry_price_usd: entry_price.to_string(), + virtual_quote_position_lots: "0".to_string(), + unsettled_funding_quote_lots: "0".to_string(), + accumulated_funding_quote_lots: "0".to_string(), + take_profit_triggers: vec![], + stop_loss_triggers: vec![], + } +} + +#[test] +fn test_apply_snapshot() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + // Create a snapshot message + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 0.9, + taker_fee_override_multiplier: 1.1, + subaccounts: vec![TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![TraderStatePositionSnapshot { + symbol: "SOL".to_string(), + position: make_position_row(100, "150.25"), + }], + orders: vec![], + splines: vec![], + }], + }; + + let msg = TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }; + + // Apply the snapshot + trader.apply_update(&msg); + + // Verify state + assert_eq!(trader.last_slot, 12345); + assert_eq!(trader.maker_fee_override_multiplier, 0.9); + assert_eq!(trader.taker_fee_override_multiplier, 1.1); + + let collateral = trader.total_collateral(); + assert_eq!(collateral, 1000); + + let positions = trader.all_positions(); + assert_eq!(positions.len(), 1); + assert_eq!(positions[0].symbol, "SOL"); + assert_eq!(positions[0].base_position_lots, 100); + + let subaccount = trader.primary_subaccount().unwrap(); + assert_eq!(subaccount.sequence, 100); +} + +#[test] +fn test_apply_delta_updates_position() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + // First apply a snapshot + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + subaccounts: vec![TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![TraderStatePositionSnapshot { + symbol: "SOL".to_string(), + position: make_position_row(100, "150.00"), + }], + orders: vec![], + splines: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }); + + // Now apply a delta that updates the position + let delta = TraderStateDelta { + deltas: vec![TraderStateSubaccountDelta { + subaccount_index: 0, + sequence: 101, + collateral: "1050".to_string(), + capabilities: None, + cooldown_status: None, + positions: vec![TraderStatePositionDelta { + symbol: "SOL".to_string(), + change: TraderStateRowChangeKind::Updated, + position: Some(make_position_row(200, "155.00")), + }], + orders: vec![], + splines: vec![], + trade_history: vec![], + order_history: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12346, + content: TraderStatePayload::Delta(delta), + }); + + // Verify updates + let subaccount = trader.primary_subaccount().unwrap(); + assert_eq!(subaccount.sequence, 101); + assert_eq!(subaccount.collateral, 1050); + + let positions = trader.all_positions(); + assert_eq!(positions.len(), 1); + assert_eq!(positions[0].base_position_lots, 200); + assert_eq!(positions[0].entry_price_usd, Decimal::new(15500, 2)); // 155.00 +} + +#[test] +fn test_apply_delta_closes_position() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + // First apply a snapshot with a position + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + subaccounts: vec![TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![TraderStatePositionSnapshot { + symbol: "SOL".to_string(), + position: make_position_row(100, "150.00"), + }], + orders: vec![], + splines: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }); + + assert_eq!(trader.all_positions().len(), 1); + + // Apply delta that closes the position + let delta = TraderStateDelta { + deltas: vec![TraderStateSubaccountDelta { + subaccount_index: 0, + sequence: 101, + collateral: "1100".to_string(), + capabilities: None, + cooldown_status: None, + positions: vec![TraderStatePositionDelta { + symbol: "SOL".to_string(), + change: TraderStateRowChangeKind::Closed, + position: None, + }], + orders: vec![], + splines: vec![], + trade_history: vec![], + order_history: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12346, + content: TraderStatePayload::Delta(delta), + }); + + // Position should be removed + assert_eq!(trader.all_positions().len(), 0); + assert_eq!(trader.total_collateral(), 1100); +} + +#[test] +fn test_stale_delta_ignored() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + // Apply snapshot with sequence 100 + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + subaccounts: vec![TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![], + orders: vec![], + splines: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }); + + // Try to apply a stale delta with sequence 99 + let delta = TraderStateDelta { + deltas: vec![TraderStateSubaccountDelta { + subaccount_index: 0, + sequence: 99, // Stale! + collateral: "999".to_string(), + capabilities: None, + cooldown_status: None, + positions: vec![], + orders: vec![], + splines: vec![], + trade_history: vec![], + order_history: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12346, + content: TraderStatePayload::Delta(delta), + }); + + // Collateral should NOT be updated since delta was stale + let subaccount = trader.primary_subaccount().unwrap(); + assert_eq!(subaccount.sequence, 100); + assert_eq!(subaccount.collateral, 1000); +} + +#[test] +fn test_multiple_subaccounts() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + // Create snapshot with multiple subaccounts + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + subaccounts: vec![ + TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![], + orders: vec![], + splines: vec![], + }, + TraderStateSubaccountSnapshot { + subaccount_index: 1, + sequence: 50, + collateral: "500".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: None, + positions: vec![], + orders: vec![], + splines: vec![], + }, + ], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }); + + // Verify both subaccounts + assert_eq!(trader.subaccounts.len(), 2); + assert_eq!(trader.total_collateral(), 1500); + + let sub0 = trader.subaccount(0).unwrap(); + assert_eq!(sub0.collateral, 1000); + + let sub1 = trader.subaccount(1).unwrap(); + assert_eq!(sub1.collateral, 500); +} + +#[test] +fn test_cooldown_status_snapshot_and_delta() { + let key = TraderKey::new(Pubkey::new_unique()); + let mut trader = Trader::new(key.clone()); + + let snapshot = TraderStateSnapshot { + version: 1, + capabilities: make_capabilities(), + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + subaccounts: vec![TraderStateSubaccountSnapshot { + subaccount_index: 0, + sequence: 100, + collateral: "1000".to_string(), + capabilities: Some(make_capabilities()), + cooldown_status: Some(CooldownStatus { + last_deposit_slot: 1_000, + cooldown_period_in_slots: 200, + }), + positions: vec![], + orders: vec![], + splines: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12345, + content: TraderStatePayload::Snapshot(snapshot), + }); + + let sub = trader.subaccount(0).unwrap(); + let initial = sub.cooldown_status.as_ref().unwrap(); + assert_eq!(initial.last_deposit_slot, 1_000); + assert_eq!(initial.cooldown_period_in_slots, 200); + + // Missing cooldown_status in delta should preserve existing value. + let delta_preserve = TraderStateDelta { + deltas: vec![TraderStateSubaccountDelta { + subaccount_index: 0, + sequence: 101, + collateral: "1100".to_string(), + capabilities: None, + cooldown_status: None, + positions: vec![], + orders: vec![], + splines: vec![], + trade_history: vec![], + order_history: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12346, + content: TraderStatePayload::Delta(delta_preserve), + }); + + let sub = trader.subaccount(0).unwrap(); + let preserved = sub.cooldown_status.as_ref().unwrap(); + assert_eq!(preserved.last_deposit_slot, 1_000); + + // Present cooldown_status in delta should replace existing value. + let delta_update = TraderStateDelta { + deltas: vec![TraderStateSubaccountDelta { + subaccount_index: 0, + sequence: 102, + collateral: "1200".to_string(), + capabilities: None, + cooldown_status: Some(CooldownStatus { + last_deposit_slot: 1_500, + cooldown_period_in_slots: 300, + }), + positions: vec![], + orders: vec![], + splines: vec![], + trade_history: vec![], + order_history: vec![], + }], + }; + + trader.apply_update(&TraderStateServerMessage { + authority: key.authority_string(), + trader_pda_index: 0, + slot: 12347, + content: TraderStatePayload::Delta(delta_update), + }); + + let sub = trader.subaccount(0).unwrap(); + let updated = sub.cooldown_status.as_ref().unwrap(); + assert_eq!(updated.last_deposit_slot, 1_500); + assert_eq!(updated.cooldown_period_in_slots, 300); +} diff --git a/container/vendor/rise/rust/types/Cargo.toml b/container/vendor/rise/rust/types/Cargo.toml new file mode 100644 index 00000000000..83bef6eff7f --- /dev/null +++ b/container/vendor/rise/rust/types/Cargo.toml @@ -0,0 +1,22 @@ +[package] +edition = "2021" +name = "phoenix-types" +publish = false +rust-version = "1.86.0" +version = "0.1.0" + +[dependencies] +phoenix-math-utils = { path = "../math" } + +chrono = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_with = { workspace = true } +solana-pubkey = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } diff --git a/container/vendor/rise/rust/types/src/candles.rs b/container/vendor/rise/rust/types/src/candles.rs new file mode 100644 index 00000000000..1a40a8b28d7 --- /dev/null +++ b/container/vendor/rise/rust/types/src/candles.rs @@ -0,0 +1,196 @@ +//! Candle types for Phoenix API. +//! +//! These types represent candlestick (OHLCV) data streamed via WebSocket. + +use std::fmt::{self, Display}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// Timeframe enumeration for candlestick data aggregation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Timeframe { + #[serde(rename = "1s")] + Second1, + #[serde(rename = "5s")] + Second5, + #[serde(rename = "1m")] + Minute1, + #[serde(rename = "5m")] + Minute5, + #[serde(rename = "15m")] + Minute15, + #[serde(rename = "30m")] + Minute30, + #[serde(rename = "1h")] + Hour1, + #[serde(rename = "4h")] + Hour4, + #[serde(rename = "1d")] + Day1, +} + +impl Display for Timeframe { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Timeframe::Second1 => write!(f, "1s"), + Timeframe::Second5 => write!(f, "5s"), + Timeframe::Minute1 => write!(f, "1m"), + Timeframe::Minute5 => write!(f, "5m"), + Timeframe::Minute15 => write!(f, "15m"), + Timeframe::Minute30 => write!(f, "30m"), + Timeframe::Hour1 => write!(f, "1h"), + Timeframe::Hour4 => write!(f, "4h"), + Timeframe::Day1 => write!(f, "1d"), + } + } +} + +impl FromStr for Timeframe { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "1s" => Ok(Timeframe::Second1), + "5s" => Ok(Timeframe::Second5), + "1m" => Ok(Timeframe::Minute1), + "5m" => Ok(Timeframe::Minute5), + "15m" => Ok(Timeframe::Minute15), + "30m" => Ok(Timeframe::Minute30), + "1h" => Ok(Timeframe::Hour1), + "4h" => Ok(Timeframe::Hour4), + "1d" => Ok(Timeframe::Day1), + _ => Err(format!("Unknown timeframe: {s}")), + } + } +} + +/// API Candle structure (Trading View Bar interface). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiCandle { + /// Time in seconds since Unix epoch (UTC). + pub time: i64, + pub low: f64, + pub high: f64, + pub open: f64, + pub close: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trade_count: Option, +} + +/// Candle data with symbol and timeframe metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CandleData { + pub candle: ApiCandle, + pub symbol: String, + pub timeframe: String, +} + +/// Query parameters for the candles endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CandlesQueryParams { + /// Trading symbol (e.g., "SOL", "BTC", "ETH"). + pub symbol: String, + /// Candle timeframe (e.g., "1m", "5m", "1h", "1d"). + pub timeframe: String, + /// Start time in milliseconds since Unix epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// End time in milliseconds since Unix epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Maximum number of candles to return (default: 2500). + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl CandlesQueryParams { + /// Creates a new query with the symbol and timeframe. + pub fn new(symbol: impl Into, timeframe: Timeframe) -> Self { + Self { + symbol: symbol.into(), + timeframe: timeframe.to_string(), + ..Default::default() + } + } + + /// Sets the start time. + pub fn with_start_time(mut self, start_time_ms: i64) -> Self { + self.start_time = Some(start_time_ms); + self + } + + /// Sets the end time. + pub fn with_end_time(mut self, end_time_ms: i64) -> Self { + self.end_time = Some(end_time_ms); + self + } + + /// Sets the limit. + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_timeframe_display() { + assert_eq!(Timeframe::Minute1.to_string(), "1m"); + assert_eq!(Timeframe::Hour4.to_string(), "4h"); + assert_eq!(Timeframe::Day1.to_string(), "1d"); + } + + #[test] + fn test_timeframe_from_str() { + assert_eq!("1m".parse::().unwrap(), Timeframe::Minute1); + assert_eq!("4h".parse::().unwrap(), Timeframe::Hour4); + assert_eq!("1d".parse::().unwrap(), Timeframe::Day1); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_timeframe_serde() { + let tf = Timeframe::Minute5; + let json = serde_json::to_string(&tf).unwrap(); + assert_eq!(json, r#""5m""#); + + let parsed: Timeframe = serde_json::from_str(r#""5m""#).unwrap(); + assert_eq!(parsed, Timeframe::Minute5); + } + + #[test] + fn test_deserialize_candle_data() { + let json = r#"{ + "candle": { + "time": 1727181985, + "low": 149.80, + "high": 151.50, + "open": 150.25, + "close": 150.90, + "volume": 1234.56, + "tradeCount": 89 + }, + "symbol": "SOL", + "timeframe": "1m" + }"#; + + let data: CandleData = serde_json::from_str(json).unwrap(); + assert_eq!(data.symbol, "SOL"); + assert_eq!(data.timeframe, "1m"); + assert_eq!(data.candle.time, 1727181985); + assert_eq!(data.candle.open, 150.25); + assert_eq!(data.candle.close, 150.90); + assert_eq!(data.candle.volume, Some(1234.56)); + assert_eq!(data.candle.trade_count, Some(89)); + } +} diff --git a/container/vendor/rise/rust/types/src/client.rs b/container/vendor/rise/rust/types/src/client.rs new file mode 100644 index 00000000000..dd9a39e315f --- /dev/null +++ b/container/vendor/rise/rust/types/src/client.rs @@ -0,0 +1,224 @@ +//! Client-side types used by higher-level SDK clients. + +use std::collections::{HashMap, HashSet}; + +use phoenix_math_utils::TraderPortfolioMargin; +use solana_pubkey::Pubkey; +use thiserror::Error; +use tokio::sync::{mpsc, oneshot}; + +use crate::http_error::PhoenixHttpError; +use crate::market_state::Market; +use crate::metadata::PhoenixMetadata; +use crate::subscription_key::SubscriptionKey; +use crate::trader_state::Trader; +use crate::ws_error::PhoenixWsError; +use crate::{ + AllMidsData, CandleData, FundingRateMessage, L2BookUpdate, MarketStatsUpdate, Timeframe, + TraderStateServerMessage, TradesMessage, +}; + +/// Client-side logical subscription identifier. +pub type ClientSubscriptionId = u64; + +/// Errors that can occur when using higher-level Phoenix clients. +#[derive(Debug, Error)] +pub enum PhoenixClientError { + /// WebSocket error. + #[error("WebSocket error: {0}")] + WebSocket(PhoenixWsError), + /// HTTP error. + #[error("HTTP error: {0}")] + Http(PhoenixHttpError), + /// Client is shutting down. + #[error("Client is shutting down")] + Shutdown, + /// Failed to send command to background task. + #[error("Failed to send command")] + SendFailed, + /// Failed to receive response from background task. + #[error("Failed to receive response")] + ResponseDropped, +} + +/// High-level client subscription request. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PhoenixSubscription { + /// Subscribe directly to a single low-level key. + Key(SubscriptionKey), + /// Subscribe to a market bundle. + /// + /// Includes market stats, orderbook, funding rate, optional trades, + /// and optional candle streams. + Market { + symbol: String, + candle_timeframes: Vec, + include_trades: bool, + }, + /// Subscribe to trader margin updates. + /// + /// If `market_symbols` is empty, all markets from metadata are tracked. + TraderMargin { + authority: Pubkey, + trader_pda_index: u8, + subaccount_index: u8, + market_symbols: Vec, + }, +} + +impl PhoenixSubscription { + /// Create a market bundle subscription with default options. + pub fn market(symbol: impl Into) -> Self { + Self::Market { + symbol: symbol.into().to_ascii_uppercase(), + candle_timeframes: Vec::new(), + include_trades: false, + } + } + + /// Create a trader margin subscription for a trader. + pub fn trader_margin(authority: Pubkey, trader_pda_index: u8) -> Self { + Self::TraderMargin { + authority, + trader_pda_index, + subaccount_index: 0, + market_symbols: Vec::new(), + } + } +} + +/// Message that triggered a margin recomputation. +#[derive(Debug, Clone)] +pub enum MarginTrigger { + /// Trader state update triggered recomputation. + Trader(TraderStateServerMessage), + /// Market stats update triggered recomputation. + Market(MarketStatsUpdate), +} + +/// Event emitted by high-level client subscription receivers. +#[derive(Debug, Clone)] +pub enum PhoenixClientEvent { + /// Market stats update and previous market snapshot. + MarketUpdate { + symbol: String, + prev_market: Option, + update: MarketStatsUpdate, + }, + /// Orderbook update and previous market snapshot. + OrderbookUpdate { + symbol: String, + prev_market: Option, + update: L2BookUpdate, + }, + /// Trader state update and previous trader snapshot. + TraderUpdate { + key: SubscriptionKey, + prev_trader: Option, + update: TraderStateServerMessage, + }, + /// All mids update and previous mids snapshot. + MidsUpdate { + prev_mids: HashMap, + update: AllMidsData, + }, + /// Funding rate update and previous funding rate snapshot. + FundingRateUpdate { + symbol: String, + prev_funding_rate: Option, + update: FundingRateMessage, + }, + /// Candle update and previous candle snapshot. + CandleUpdate { + symbol: String, + timeframe: Timeframe, + prev_candle: Option, + update: CandleData, + }, + /// Trades update and previous trades snapshot. + TradesUpdate { + symbol: String, + prev_trades: Option, + update: TradesMessage, + }, + /// Margin update carrying trigger + computed margin + metadata snapshot. + MarginUpdate { + trader_key: SubscriptionKey, + trigger: MarginTrigger, + margin: Option, + metadata: PhoenixMetadata, + prev_trader: Option, + }, +} + +/// Internal command channel messages for higher-level clients. +pub enum ClientCommand { + /// Register a logical subscription. + Subscribe { + subscription: PhoenixSubscription, + response_tx: oneshot::Sender< + Result< + ( + ClientSubscriptionId, + mpsc::UnboundedReceiver, + ), + PhoenixClientError, + >, + >, + }, + /// Remove a logical subscription. + Unsubscribe { + subscription_id: ClientSubscriptionId, + }, + /// Shut down the client loop. + Shutdown, +} + +/// Handle for a high-level client subscription. +/// +/// Dropping the handle unsubscribes this logical subscription. +pub struct PhoenixClientSubscriptionHandle { + pub cmd_tx: mpsc::UnboundedSender, + pub subscription_id: ClientSubscriptionId, +} + +impl Drop for PhoenixClientSubscriptionHandle { + fn drop(&mut self) { + let _ = self.cmd_tx.send(ClientCommand::Unsubscribe { + subscription_id: self.subscription_id, + }); + } +} + +/// Logical subscription state tracked by high-level clients. +pub struct LogicalSubscription { + pub subscription: PhoenixSubscription, + pub dependencies: HashSet, + pub event_tx: mpsc::UnboundedSender, +} + +/// Mutable runtime state owned by high-level client loops. +pub struct RuntimeState { + pub metadata: PhoenixMetadata, + pub markets: HashMap, + pub traders: HashMap, + pub mids: HashMap, + pub funding_rates: HashMap, + pub candles: HashMap<(String, Timeframe), CandleData>, + pub trades: HashMap, +} + +impl RuntimeState { + /// Create a new runtime state with initialized metadata. + pub fn new(metadata: PhoenixMetadata) -> Self { + Self { + metadata, + markets: HashMap::new(), + traders: HashMap::new(), + mids: HashMap::new(), + funding_rates: HashMap::new(), + candles: HashMap::new(), + trades: HashMap::new(), + } + } +} diff --git a/container/vendor/rise/rust/types/src/conversions.rs b/container/vendor/rise/rust/types/src/conversions.rs new file mode 100644 index 00000000000..6631c04f32a --- /dev/null +++ b/container/vendor/rise/rust/types/src/conversions.rs @@ -0,0 +1,80 @@ +//! Conversion utilities for building margin calculation types from +//! HTTP/WebSocket data. + +use phoenix_math_utils::{ + MarketCalculator, PerpAssetMetadata, QuoteLots, SignedBaseLots, SignedQuoteLots, WrapperNum, +}; + +use crate::core::Decimal; + +const MAX_PRICE_DECIMALS: i8 = 18; + +impl From for Decimal { + fn from(val: QuoteLots) -> Self { + Decimal::from_i64_with_decimals(val.as_inner() as i64, 6) + } +} + +impl From for Decimal { + fn from(val: SignedQuoteLots) -> Self { + Decimal::from_i64_with_decimals(val.as_inner(), 6) + } +} + +fn decimal_from_raw_lots(value: i128, base_lot_decimals: i8) -> Decimal { + if base_lot_decimals >= 0 { + let i64_value = value as i64; + Decimal::from_i64_with_decimals(i64_value, base_lot_decimals) + } else { + let exponent = (-base_lot_decimals) as u32; + let multiplier = 10i128.pow(exponent); + let scaled = value.saturating_mul(multiplier); + let i64_scaled = scaled as i64; + Decimal::from_i64_with_decimals(i64_scaled, 0) + } +} + +pub fn decimal_from_signed_base_lots(base_lots: SignedBaseLots, base_lot_decimals: i8) -> Decimal { + decimal_from_raw_lots(base_lots.as_inner() as i128, base_lot_decimals) +} + +pub fn price_to_decimal(price: f64, calculator: &MarketCalculator) -> Decimal { + let decimals = ((calculator.quote_lot_decimals as i32) - (calculator.base_lot_decimals as i32)) + .clamp(0, MAX_PRICE_DECIMALS as i32) as i8; + let scale = 10f64.powi(decimals as i32); + let scaled = price * scale; + + if !scaled.is_finite() || scaled > i64::MAX as f64 || scaled < i64::MIN as f64 { + Decimal::from_i64_with_decimals(-1, 0) + } else { + Decimal::from_i64_with_decimals(scaled.round() as i64, decimals) + } +} + +pub fn calculator_for_metadata(metadata: &PerpAssetMetadata) -> MarketCalculator { + MarketCalculator::new(metadata.base_lot_decimals(), metadata.tick_size()) +} + +/// Convert a decimal string to QuoteLots (base 10^6). +pub fn decimal_str_to_quote_lots(decimal_str: &str) -> Result { + let value: f64 = decimal_str + .parse() + .map_err(|e| format!("Failed to parse decimal: {}", e))?; + + let quote_lots = (value * 1_000_000.0).round() as u64; + Ok(QuoteLots::new(quote_lots)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decimal_str_to_quote_lots() { + let lots = decimal_str_to_quote_lots("1000.5").expect("Should convert"); + assert_eq!(lots.as_inner(), 1_000_500_000); + + let zero = decimal_str_to_quote_lots("0").expect("Should convert"); + assert_eq!(zero.as_inner(), 0); + } +} diff --git a/container/vendor/rise/rust/types/src/core.rs b/container/vendor/rise/rust/types/src/core.rs new file mode 100644 index 00000000000..e4c3376ce34 --- /dev/null +++ b/container/vendor/rise/rust/types/src/core.rs @@ -0,0 +1,66 @@ +//! Core primitive types for Phoenix API. +//! +//! These types are fundamental building blocks used across the SDK. + +use serde::{Deserialize, Serialize}; + +/// A decimal type representing a fixed-precision number. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Decimal { + pub value: i64, + pub decimals: i8, + pub ui: String, +} + +impl Decimal { + pub const ZERO: Decimal = Decimal { + value: 0, + decimals: 0, + ui: String::new(), + }; + + pub fn from_i64_with_decimals(value: i64, decimals: i8) -> Self { + let scale = 10f64.powi(decimals as i32); + let ui = format!("{:.*}", decimals as usize, value as f64 / scale); + Self { + value, + decimals, + ui, + } + } +} + +/// A price for an asset. +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Price { + pub price: f64, + pub slot: u64, +} + +/// Order side (Bid or Ask). +pub use phoenix_math_utils::Side; + +/// Generic paginated response wrapper with bidirectional cursor support. +/// +/// The cursor system supports both forward (newer) and backward (older) +/// pagination: +/// - `prev_cursor`: Use this cursor to poll for new items (items newer than the +/// current result set) +/// - `next_cursor`: Use this cursor to load more items (items older than the +/// current result set) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaginatedResponse { + /// The data payload (array of items). + pub data: T, + /// Opaque cursor for fetching newer items (for polling). + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_cursor: Option, + /// Opaque cursor for fetching the next page of older results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Whether there are more results available after this page. + pub has_more: bool, +} diff --git a/container/vendor/rise/rust/types/src/exchange.rs b/container/vendor/rise/rust/types/src/exchange.rs new file mode 100644 index 00000000000..6e13a64f7e0 --- /dev/null +++ b/container/vendor/rise/rust/types/src/exchange.rs @@ -0,0 +1,271 @@ +//! Exchange configuration types for Phoenix API. +//! +//! These types represent exchange-level configuration including authority keys, +//! global config, and market configurations for order construction. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::js_safe_ints::JsSafeU64; +use crate::market::MarketStatus; + +// ============================================================================ +// Authority and Keys +// ============================================================================ + +/// Authority set containing all authority pubkeys. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthoritySetView { + pub root_authority: String, + pub risk_authority: String, + pub market_authority: String, + pub oracle_authority: String, +} + +/// Response for the `/view/exchange-keys` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeKeysView { + pub global_config: String, + pub current_authorities: AuthoritySetView, + pub pending_authorities: AuthoritySetView, + pub canonical_mint: String, + pub global_vault: String, + pub perp_asset_map: String, + pub global_trader_index: Vec, + pub active_trader_buffer: Vec, + pub withdraw_queue: String, +} + +// ============================================================================ +// Exchange-specific Types (f64 percentages) +// ============================================================================ + +/// Leverage tier with risk factors as f64 percentages. +/// Used by the `/v1/exchange` endpoint. +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeLeverageTier { + pub max_leverage: f64, + pub max_size_base_lots: u64, + /// The limit order risk factor as a percentage (e.g., 60.0 = 60%). + pub limit_order_risk_factor: f64, +} + +/// Risk factors as f64 percentages. +/// Used by the `/v1/exchange` endpoint. +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeRiskFactors { + /// Maintenance margin risk factor as a percentage (e.g., 50.0 = 50%). + pub maintenance: f64, + /// Backstop liquidation risk factor as a percentage. + pub backstop: f64, + /// High risk threshold as a percentage. + pub high_risk: f64, + /// Risk factor for positive unrealized PnL penalty as a percentage. + pub upnl: f64, + /// Risk factor for positive unrealized PnL penalty during withdrawals as a + /// percentage. + pub upnl_for_withdrawals: f64, + /// Cancel order risk factor as a percentage. + pub cancel_order: f64, +} + +// ============================================================================ +// Exchange Configuration +// ============================================================================ + +/// Static market configuration without live data like prices or open interest. +/// Used by the `/v1/exchange` endpoint to return market parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeMarketConfig { + pub symbol: String, + pub asset_id: u32, + pub market_status: MarketStatus, + /// The orderbook account pubkey (base58 encoded). + pub market_pubkey: String, + /// The spline collection PDA (derived from market_pubkey). + pub spline_pubkey: String, + pub tick_size: u64, + pub base_lots_decimals: i8, + /// Taker fee as a decimal (e.g., 0.0005 = 0.05%). + pub taker_fee: f64, + /// Maker fee as a decimal (can be negative for rebates). + pub maker_fee: f64, + pub leverage_tiers: Vec, + pub risk_factors: ExchangeRiskFactors, + pub funding_interval_seconds: u32, + pub funding_period_seconds: u32, + pub max_funding_rate_per_interval: f64, + pub open_interest_cap_base_lots: JsSafeU64, + pub max_liquidation_size_base_lots: JsSafeU64, + /// Whether this market only supports isolated margin positions. + pub isolated_only: bool, +} + +/// Raw response from the `/v1/exchange` endpoint. +/// Markets are returned as a Vec from the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeResponse { + pub keys: ExchangeKeysView, + pub markets: Vec, +} + +/// Exchange configuration containing keys and market configs. +/// +/// This struct is populated by querying the Phoenix API for exchange keys +/// and market info for supported symbols. Markets are stored in a HashMap +/// for efficient lookup by symbol. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeView { + pub keys: ExchangeKeysView, + pub markets: HashMap, +} + +impl ExchangeView { + /// Get market configuration by symbol (case-insensitive). + pub fn get_market(&self, symbol: &str) -> Option<&ExchangeMarketConfig> { + self.markets.get(&symbol.to_ascii_uppercase()) + } +} + +impl From for ExchangeView { + fn from(response: ExchangeResponse) -> Self { + let markets = response + .markets + .into_iter() + .map(|m| (m.symbol.clone(), m)) + .collect(); + Self { + keys: response.keys, + markets, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_exchange_keys_view() { + let json = r#"{ + "globalConfig": "11111111111111111111111111111111", + "currentAuthorities": { + "rootAuthority": "22222222222222222222222222222222", + "riskAuthority": "33333333333333333333333333333333", + "marketAuthority": "44444444444444444444444444444444", + "oracleAuthority": "55555555555555555555555555555555" + }, + "pendingAuthorities": { + "rootAuthority": "66666666666666666666666666666666", + "riskAuthority": "77777777777777777777777777777777", + "marketAuthority": "88888888888888888888888888888888", + "oracleAuthority": "99999999999999999999999999999999" + }, + "canonicalMint": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "globalVault": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "perpAssetMap": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + "globalTraderIndex": ["idx1", "idx2"], + "activeTraderBuffer": ["buf1", "buf2"], + "withdrawQueue": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + }"#; + + let view: ExchangeKeysView = serde_json::from_str(json).unwrap(); + assert_eq!(view.global_config, "11111111111111111111111111111111"); + assert_eq!( + view.current_authorities.root_authority, + "22222222222222222222222222222222" + ); + assert_eq!(view.global_trader_index.len(), 2); + } + + #[test] + fn test_fee_fields() { + let config = ExchangeMarketConfig { + symbol: "SOL".to_string(), + asset_id: 0, + market_status: MarketStatus::Active, + market_pubkey: "test".to_string(), + spline_pubkey: "test".to_string(), + tick_size: 1, + base_lots_decimals: 6, + taker_fee: 0.0005, // 0.05% + maker_fee: -0.0001, // -0.01% (rebate) + leverage_tiers: vec![], + risk_factors: ExchangeRiskFactors::default(), + funding_interval_seconds: 3600, + funding_period_seconds: 86400, + max_funding_rate_per_interval: 0.001, + open_interest_cap_base_lots: 1_000_000_u64.into(), + max_liquidation_size_base_lots: 10_000_u64.into(), + isolated_only: false, + }; + + assert!((config.taker_fee - 0.0005).abs() < 1e-10); + assert!((config.maker_fee - (-0.0001)).abs() < 1e-10); + } + + #[test] + fn test_get_market_case_insensitive() { + let mut markets = HashMap::new(); + markets.insert( + "SOL".to_string(), + ExchangeMarketConfig { + symbol: "SOL".to_string(), + asset_id: 0, + market_status: MarketStatus::Active, + market_pubkey: "test".to_string(), + spline_pubkey: "test".to_string(), + tick_size: 1, + base_lots_decimals: 6, + taker_fee: 0.0005, + maker_fee: 0.0, + leverage_tiers: vec![], + risk_factors: ExchangeRiskFactors::default(), + funding_interval_seconds: 3600, + funding_period_seconds: 86400, + max_funding_rate_per_interval: 0.001, + open_interest_cap_base_lots: 1_000_000_u64.into(), + max_liquidation_size_base_lots: 10_000_u64.into(), + isolated_only: false, + }, + ); + + let view = ExchangeView { + keys: ExchangeKeysView { + global_config: "test".to_string(), + current_authorities: AuthoritySetView { + root_authority: "test".to_string(), + risk_authority: "test".to_string(), + market_authority: "test".to_string(), + oracle_authority: "test".to_string(), + }, + pending_authorities: AuthoritySetView { + root_authority: "test".to_string(), + risk_authority: "test".to_string(), + market_authority: "test".to_string(), + oracle_authority: "test".to_string(), + }, + canonical_mint: "test".to_string(), + global_vault: "test".to_string(), + perp_asset_map: "test".to_string(), + global_trader_index: vec![], + active_trader_buffer: vec![], + withdraw_queue: "test".to_string(), + }, + markets, + }; + + assert!(view.get_market("SOL").is_some()); + assert!(view.get_market("sol").is_some()); + assert!(view.get_market("Sol").is_some()); + assert!(view.get_market("BTC").is_none()); + } +} diff --git a/container/vendor/rise/rust/types/src/http_error.rs b/container/vendor/rise/rust/types/src/http_error.rs new file mode 100644 index 00000000000..6340391bc39 --- /dev/null +++ b/container/vendor/rise/rust/types/src/http_error.rs @@ -0,0 +1,33 @@ +//! HTTP error types for the Phoenix SDK. + +use thiserror::Error; + +/// Errors that can occur when using the Phoenix HTTP client. +#[derive(Debug, Error)] +pub enum PhoenixHttpError { + /// HTTP request failed. + #[error("HTTP request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + + /// Failed to parse response. + #[error("Failed to parse response: {0}")] + ParseFailed(String), + + /// API returned an error. + #[error("API error: {status} - {message}")] + ApiError { status: u16, message: String }, + + /// API rate limit was hit and automatic retries were exhausted or disabled. + #[error( + "Rate limited after {attempts} attempt(s), retry_after_seconds={retry_after_seconds:?}: {message}" + )] + RateLimited { + retry_after_seconds: Option, + message: String, + attempts: u32, + }, + + /// Missing environment variable. + #[error("Missing environment variable: {0}")] + MissingEnvVar(String), +} diff --git a/container/vendor/rise/rust/types/src/ix.rs b/container/vendor/rise/rust/types/src/ix.rs new file mode 100644 index 00000000000..e354266f874 --- /dev/null +++ b/container/vendor/rise/rust/types/src/ix.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +/// Account metadata returned from instruction-building endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiAccountMeta { + pub pubkey: String, + pub is_signer: bool, + pub is_writable: bool, +} + +/// API representation of a Solana instruction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiInstructionResponse { + pub data: Vec, + pub keys: Vec, + pub program_id: String, +} + +/// TP/SL configuration shared across isolated order endpoints. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TpSlOrderConfig { + #[serde(default)] + pub take_profit_trigger_price: Option, + #[serde(default)] + pub take_profit_trigger_price_in_ticks: Option, + #[serde(default)] + pub take_profit_execution_price: Option, + #[serde(default)] + pub take_profit_execution_price_in_ticks: Option, + #[serde(default)] + pub stop_loss_trigger_price: Option, + #[serde(default)] + pub stop_loss_trigger_price_in_ticks: Option, + #[serde(default)] + pub stop_loss_execution_price: Option, + #[serde(default)] + pub stop_loss_execution_price_in_ticks: Option, + #[serde(default)] + pub order_kind: Option, + #[serde(default)] + pub num_base_lots: Option, + #[serde(default)] + pub quantity: Option, +} + +/// Request payload for /ix/place-isolated-limit-order. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PlaceIsolatedLimitOrderRequest { + pub authority: String, + #[serde(default)] + pub position_authority: Option, + pub symbol: String, + pub side: String, + #[serde(default)] + pub price_in_ticks: Option, + #[serde(default)] + pub price: Option, + #[serde(default)] + pub num_base_lots: Option, + #[serde(default)] + pub quantity: Option, + #[serde(default)] + pub transfer_amount: u64, + #[serde(default)] + pub pda_index: Option, + #[serde(default)] + pub allow_cross_and_isolated_for_asset: Option, + #[serde(default)] + pub fee_payer: Option, + #[serde(default)] + pub is_reduce_only: Option, + #[serde(default)] + pub is_post_only: Option, + #[serde(default)] + pub slide: Option, + #[serde(default)] + pub skip_transfer_to_parent: Option, + #[serde(default)] + pub tp_sl: Option, +} + +/// Request payload for /ix/place-isolated-market-order. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PlaceIsolatedMarketOrderRequest { + pub authority: String, + #[serde(default)] + pub position_authority: Option, + pub symbol: String, + pub side: String, + #[serde(default)] + pub num_base_lots: Option, + #[serde(default)] + pub quantity: Option, + #[serde(default)] + pub transfer_amount: u64, + #[serde(default)] + pub max_price_in_ticks: Option, + #[serde(default)] + pub pda_index: Option, + #[serde(default)] + pub allow_cross_and_isolated_for_asset: Option, + #[serde(default)] + pub fee_payer: Option, + #[serde(default)] + pub is_reduce_only: Option, + #[serde(default)] + pub skip_transfer_to_parent: Option, + #[serde(default)] + pub tp_sl: Option, +} diff --git a/container/vendor/rise/rust/types/src/js_safe_ints.rs b/container/vendor/rise/rust/types/src/js_safe_ints.rs new file mode 100644 index 00000000000..6abd6380e6c --- /dev/null +++ b/container/vendor/rise/rust/types/src/js_safe_ints.rs @@ -0,0 +1,124 @@ +//! Safe big integers for JSON serialization. +//! +//! JavaScript numbers are IEEE-754 doubles, so only integers in the range +//! `[-(2^53 - 1), 2^53 - 1]` round-trip without losing bits. Anything outside +//! this window is either rounded or rejected by browsers. +//! +//! This module provides wrapper types that serialize as strings and can +//! deserialize from either strings or numbers, ensuring safe round-tripping +//! with JavaScript clients. + +use std::cmp::Ordering; +use std::fmt; +use std::ops::{AddAssign, Deref, DerefMut}; + +use serde::{Deserialize, Serialize}; +use serde_with::{DisplayFromStr, PickFirst, serde_as}; + +/// Wrapper for unsigned 64-bit values that must be JSON-safe for consumers +/// written in JavaScript/TypeScript. +/// +/// Serializes as a string and can deserialize from either a string or number. +#[derive(Default, Clone, Copy, Debug, PartialEq)] +#[serde_as] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct JsSafeU64(#[serde_as(as = "PickFirst<(DisplayFromStr, _)>")] u64); + +impl From for JsSafeU64 { + fn from(value: u64) -> Self { + JsSafeU64(value) + } +} + +impl From for u64 { + fn from(value: JsSafeU64) -> Self { + value.0 + } +} + +impl JsSafeU64 { + pub fn into_inner(self) -> u64 { + self.0 + } + + pub fn as_inner(&self) -> u64 { + self.0 + } +} + +impl Deref for JsSafeU64 { + type Target = u64; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for JsSafeU64 { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl AddAssign for JsSafeU64 { + fn add_assign(&mut self, other: Self) { + self.0 += other.0; + } +} + +impl PartialEq for JsSafeU64 { + fn eq(&self, other: &u64) -> bool { + self.0 == *other + } +} + +impl PartialEq for u64 { + fn eq(&self, other: &JsSafeU64) -> bool { + *self == other.0 + } +} + +impl PartialOrd for JsSafeU64 { + fn partial_cmp(&self, other: &u64) -> Option { + self.0.partial_cmp(other) + } +} + +impl PartialOrd for u64 { + fn partial_cmp(&self, other: &JsSafeU64) -> Option { + self.partial_cmp(&other.0) + } +} + +impl fmt::Display for JsSafeU64 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_js_safe_u64_from_string() { + let json = r#""18446744073709551615""#; + let value: JsSafeU64 = serde_json::from_str(json).unwrap(); + assert_eq!(value.into_inner(), u64::MAX); + } + + #[test] + fn test_js_safe_u64_from_number() { + let json = "12345"; + let value: JsSafeU64 = serde_json::from_str(json).unwrap(); + assert_eq!(value.into_inner(), 12345); + } + + #[test] + fn test_js_safe_u64_serializes_as_string() { + let value = JsSafeU64::from(9007199254740993_u64); + let json = serde_json::to_string(&value).unwrap(); + assert_eq!(json, r#""9007199254740993""#); + } +} diff --git a/container/vendor/rise/rust/types/src/l2book.rs b/container/vendor/rise/rust/types/src/l2book.rs new file mode 100644 index 00000000000..760db9fc764 --- /dev/null +++ b/container/vendor/rise/rust/types/src/l2book.rs @@ -0,0 +1,241 @@ +//! L2 orderbook state container for Phoenix markets. + +use crate::L2BookUpdate; + +/// A single price level in the orderbook. +#[derive(Debug, Clone, Copy)] +pub struct PriceLevel { + /// Price at this level + pub price: f64, + /// Total quantity at this level + pub quantity: f64, +} + +/// Container for L2 orderbook data. +#[derive(Debug, Clone)] +pub struct L2Book { + symbol: String, + data: Option, +} + +impl L2Book { + pub fn new(symbol: String) -> Self { + Self { symbol, data: None } + } + + pub fn apply_update(&mut self, msg: &L2BookUpdate) { + if msg.symbol != self.symbol { + return; + } + self.data = Some(msg.clone()); + } + + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn raw(&self) -> Option<&L2BookUpdate> { + self.data.as_ref() + } + + pub fn bids(&self) -> Vec { + self.data + .as_ref() + .map(|d| { + d.orderbook + .bids + .iter() + .map(|(price, quantity)| PriceLevel { + price: *price, + quantity: *quantity, + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn asks(&self) -> Vec { + self.data + .as_ref() + .map(|d| { + d.orderbook + .asks + .iter() + .map(|(price, quantity)| PriceLevel { + price: *price, + quantity: *quantity, + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn best_bid(&self) -> Option { + self.data + .as_ref() + .and_then(|d| d.orderbook.bids.first().map(|(p, _)| *p)) + } + + pub fn best_ask(&self) -> Option { + self.data + .as_ref() + .and_then(|d| d.orderbook.asks.first().map(|(p, _)| *p)) + } + + pub fn best_bid_quantity(&self) -> Option { + self.data + .as_ref() + .and_then(|d| d.orderbook.bids.first().map(|(_, q)| *q)) + } + + pub fn best_ask_quantity(&self) -> Option { + self.data + .as_ref() + .and_then(|d| d.orderbook.asks.first().map(|(_, q)| *q)) + } + + pub fn spread(&self) -> Option { + match (self.best_bid(), self.best_ask()) { + (Some(bid), Some(ask)) => Some(ask - bid), + _ => None, + } + } + + pub fn mid_price(&self) -> Option { + if let Some(mid) = self.data.as_ref().and_then(|d| d.orderbook.mid) { + return Some(mid); + } + match (self.best_bid(), self.best_ask()) { + (Some(bid), Some(ask)) => Some((bid + ask) / 2.0), + _ => None, + } + } + + pub fn spread_percent(&self) -> Option { + match (self.spread(), self.mid_price()) { + (Some(spread), Some(mid)) if mid != 0.0 => Some(spread / mid * 100.0), + _ => None, + } + } + + pub fn total_bid_liquidity(&self) -> f64 { + self.data + .as_ref() + .map(|d| d.orderbook.bids.iter().map(|(_, q)| q).sum()) + .unwrap_or(0.0) + } + + pub fn total_ask_liquidity(&self) -> f64 { + self.data + .as_ref() + .map(|d| d.orderbook.asks.iter().map(|(_, q)| q).sum()) + .unwrap_or(0.0) + } + + pub fn bid_depth(&self) -> usize { + self.data + .as_ref() + .map(|d| d.orderbook.bids.len()) + .unwrap_or(0) + } + + pub fn ask_depth(&self) -> usize { + self.data + .as_ref() + .map(|d| d.orderbook.asks.len()) + .unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::L2Orderbook; + + fn make_l2_update(symbol: &str) -> L2BookUpdate { + L2BookUpdate { + symbol: symbol.to_string(), + orderbook: L2Orderbook { + bids: vec![(150.25, 100.0), (150.20, 200.0), (150.15, 300.0)], + asks: vec![(150.30, 150.0), (150.35, 250.0), (150.40, 400.0)], + mid: Some(150.275), + }, + } + } + + #[test] + fn test_new_book() { + let book = L2Book::new("SOL".to_string()); + assert_eq!(book.symbol(), "SOL"); + assert!(book.raw().is_none()); + assert!(book.best_bid().is_none()); + } + + #[test] + fn test_apply_update() { + let mut book = L2Book::new("SOL".to_string()); + let update = make_l2_update("SOL"); + + book.apply_update(&update); + + assert!(book.raw().is_some()); + assert_eq!(book.best_bid(), Some(150.25)); + assert_eq!(book.best_ask(), Some(150.30)); + } + + #[test] + fn test_ignore_wrong_symbol() { + let mut book = L2Book::new("SOL".to_string()); + let update = make_l2_update("BTC"); + + book.apply_update(&update); + + assert!(book.raw().is_none()); + assert!(book.best_bid().is_none()); + } + + #[test] + fn test_spread_and_mid_price() { + let mut book = L2Book::new("SOL".to_string()); + let update = make_l2_update("SOL"); + + book.apply_update(&update); + + let spread = book.spread().unwrap(); + assert!((spread - 0.05).abs() < 0.0001); + + let mid = book.mid_price().unwrap(); + assert!((mid - 150.275).abs() < 0.0001); + } + + #[test] + fn test_liquidity() { + let mut book = L2Book::new("SOL".to_string()); + let update = make_l2_update("SOL"); + + book.apply_update(&update); + + assert_eq!(book.total_bid_liquidity(), 600.0); + assert_eq!(book.total_ask_liquidity(), 800.0); + assert_eq!(book.bid_depth(), 3); + assert_eq!(book.ask_depth(), 3); + } + + #[test] + fn test_price_levels() { + let mut book = L2Book::new("SOL".to_string()); + let update = make_l2_update("SOL"); + + book.apply_update(&update); + + let bids = book.bids(); + assert_eq!(bids.len(), 3); + assert_eq!(bids[0].price, 150.25); + assert_eq!(bids[0].quantity, 100.0); + + let asks = book.asks(); + assert_eq!(asks.len(), 3); + assert_eq!(asks[0].price, 150.30); + assert_eq!(asks[0].quantity, 150.0); + } +} diff --git a/container/vendor/rise/rust/types/src/lib.rs b/container/vendor/rise/rust/types/src/lib.rs new file mode 100644 index 00000000000..ed7eebe0c4b --- /dev/null +++ b/container/vendor/rise/rust/types/src/lib.rs @@ -0,0 +1,69 @@ +//! Minimal API types for Phoenix WebSocket protocol. +//! +//! These types mirror the wire format used by the Phoenix API without +//! requiring the full phoenix-api-types crate and its dependencies. + +pub mod candles; +pub mod client; +pub mod conversions; +pub mod core; +pub mod exchange; +pub mod http_error; +pub mod ix; +pub mod js_safe_ints; +pub mod l2book; +pub mod market; +pub mod market_state; +pub mod market_stats; +pub mod metadata; +pub mod subscription_key; +pub mod trader; +pub mod trader_http; +pub mod trader_key; +pub mod trader_state; +pub mod trades; +pub mod ws; +pub mod ws_error; + +// Re-export all types at crate root for backwards compatibility +pub use core::*; + +pub use candles::*; +pub use client::*; +pub use conversions::*; +pub use exchange::*; +pub use http_error::*; +pub use ix::*; +pub use js_safe_ints::*; +pub use l2book::*; +pub use market::*; +pub use market_state::*; +pub use market_stats::*; +pub use metadata::*; +pub use subscription_key::*; +pub use trader::*; +pub use trader_http::*; +pub use trader_key::*; +pub use trader_state::{Position, Spline, SubaccountState, Trader}; +pub use trades::*; +pub use ws::*; +pub use ws_error::*; + +/// Deprecated module for backwards compatibility. +/// +/// Use the specific modules instead: +/// - [`core`] for `Decimal`, `Price` +/// - [`market`] for market config and status types +/// - [`exchange`] for `ExchangeKeysView`, `AuthoritySetView` +#[deprecated( + since = "0.2.0", + note = "Use specific modules instead: core, market, exchange" +)] +pub mod http { + pub use crate::core::{Decimal, Price}; + pub use crate::exchange::{AuthoritySetView, ExchangeKeysView}; + pub use crate::market::{ + L2Orderbook, LeverageTier, MarketFeeConfig, MarketInfo, MarketStatus, MarketSummary, + MarketUnitConfig, MarketView, MarketsView, RiskFactors, RiskState, RiskTier, + }; +} diff --git a/container/vendor/rise/rust/types/src/market.rs b/container/vendor/rise/rust/types/src/market.rs new file mode 100644 index 00000000000..71c869a7a50 --- /dev/null +++ b/container/vendor/rise/rust/types/src/market.rs @@ -0,0 +1,327 @@ +//! Market types for Phoenix API. +//! +//! These types represent market configuration, status, orderbook data, +//! statistics, and market views from both WebSocket and HTTP APIs. + +use serde::{Deserialize, Serialize}; + +use crate::core::{Decimal, Price}; + +// ============================================================================ +// Market Configuration +// ============================================================================ + +/// Market unit configuration. +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketUnitConfig { + pub tick_size_in_quote_lots_per_base_lot: u64, + pub base_lots_decimals: i8, +} + +/// Market fee configuration. +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketFeeConfig { + pub taker_fee_micro: u32, + pub maker_fee_micro: i32, +} + +/// Leverage tier for a market. +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LeverageTier { + pub max_leverage: f64, + pub max_size_base_lots: u64, + pub limit_order_risk_factor: u16, +} + +/// Risk factors for a market. +#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RiskFactors { + pub maintenance: u16, + pub backstop: u16, + pub high_risk: u16, + pub upnl: u16, + pub upnl_for_withdrawals: u16, + pub cancel_order: u16, +} + +// ============================================================================ +// Market Status Enums +// ============================================================================ + +/// Market status enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MarketStatus { + #[default] + Uninitialized, + Active, + PostOnly, + Paused, + Closed, + Tombstoned, +} + +/// Risk state enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RiskState { + Healthy, + Unhealthy, + Underwater, + ZeroCollateralNoPositions, +} + +/// Risk tier enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RiskTier { + Safe, + AtRisk, + Cancellable, + Liquidatable, + BackstopLiquidatable, + HighRisk, +} + +// ============================================================================ +// Orderbook Types +// ============================================================================ + +/// L2 orderbook (HTTP API response format). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct L2Orderbook { + pub bids: Vec<(f64, f64)>, + pub asks: Vec<(f64, f64)>, + pub mid: Option, +} + +/// L2 orderbook update from the WebSocket server. +/// +/// Contains bid and ask levels for a specific market. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct L2BookUpdate { + /// Market symbol (e.g., "SOL") + pub symbol: String, + /// The orderbook data + pub orderbook: L2Orderbook, +} + +// ============================================================================ +// Market Statistics +// ============================================================================ + +/// Market statistics update from the WebSocket server. +/// +/// Contains real-time pricing and market data for a specific perpetual market. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketStatsUpdate { + /// Market symbol (e.g., "SOL") + pub symbol: String, + /// Total open interest in the market + #[serde(rename = "openInterest")] + pub open_interest: f64, + /// Current mark price + #[serde(rename = "markPx")] + pub mark_price: f64, + /// Current mid price + #[serde(rename = "midPx")] + pub mid_price: f64, + /// Current oracle price + #[serde(rename = "oraclePx")] + pub oracle_price: f64, + /// Mark price from 24 hours ago + #[serde(rename = "prevDayPx")] + pub prev_day_mark_price: f64, + /// 24-hour notional trading volume in USD + #[serde(rename = "dayNtlVlm")] + pub day_volume_usd: f64, + /// Current funding rate + #[serde(rename = "funding")] + pub funding_rate: f64, +} + +// ============================================================================ +// Market Views (HTTP API) +// ============================================================================ + +/// Full market information. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketInfo { + pub symbol: String, + pub asset_id: u64, + pub market_status: MarketStatus, + pub market_key: String, + pub units: MarketUnitConfig, + pub fees: MarketFeeConfig, + pub risk_action_price_validity_rules: [[[u8; 8]; 4]; 8], + + pub open_interest: Decimal, + pub leverage_tiers: Vec, + + pub risk_factors: RiskFactors, + + pub spot_price: Option, + pub mark_price: Option, + + pub funding_interval_seconds: u64, + pub funding_period_seconds: u64, + pub funding_start_interval_timestamp: u64, + pub cumulative_funding_rate: i64, + pub max_funding_rate_per_interval: i64, + + pub current_funding_rate_percentage: f64, + pub annualized_funding_rate_percentage: f64, + + pub l2_orderbook: L2Orderbook, +} + +/// Response for the `/view/market/{symbol}` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketView { + pub slot: u64, + pub market: MarketInfo, +} + +/// Summary market information returned by the `/view/markets` endpoint. +/// This is a simpler structure than `MarketInfo` without orderbook/price data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketSummary { + pub symbol: String, + pub asset_id: u64, + pub market_status: MarketStatus, + pub units: MarketUnitConfig, + pub fees: MarketFeeConfig, + pub open_interest: Decimal, + pub open_interest_cap: Decimal, + pub leverage_tiers: Vec, + pub funding_interval_in_slots: u64, + pub funding_period_in_slots: u64, + pub funding_start_interval_slot: u64, + pub cumulative_funding_rate: i64, + pub max_liquidation_size: Decimal, + pub risk_factors: RiskFactors, + pub isolated_only: bool, +} + +/// Response for the `/view/markets` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketsView { + pub slot: u64, + pub markets: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_l2_book_update() { + let json = r#"{ + "symbol": "SOL", + "orderbook": { + "bids": [[150.25, 100.0], [150.20, 200.0], [150.15, 300.0]], + "asks": [[150.30, 150.0], [150.35, 250.0], [150.40, 400.0]], + "mid": 150.275 + } + }"#; + + let update: L2BookUpdate = serde_json::from_str(json).unwrap(); + assert_eq!(update.symbol, "SOL"); + assert_eq!(update.orderbook.bids.len(), 3); + assert_eq!(update.orderbook.asks.len(), 3); + assert_eq!(update.orderbook.bids[0], (150.25, 100.0)); + assert_eq!(update.orderbook.asks[0], (150.30, 150.0)); + assert_eq!(update.orderbook.mid, Some(150.275)); + } + + #[test] + fn test_serialize_l2_book_update() { + let update = L2BookUpdate { + symbol: "BTC".to_string(), + orderbook: L2Orderbook { + bids: vec![(65000.0, 1.5), (64990.0, 2.0)], + asks: vec![(65010.0, 1.0), (65020.0, 2.5)], + mid: Some(65005.0), + }, + }; + + let json = serde_json::to_string(&update).unwrap(); + assert!(json.contains("\"symbol\":\"BTC\"")); + assert!(json.contains("\"orderbook\"")); + assert!(json.contains("\"mid\":65005")); + } + + #[test] + fn test_deserialize_market_stats_update() { + let json = r#"{ + "symbol": "SOL", + "openInterest": 367.51, + "markPx": 97.35, + "midPx": 97.315, + "oraclePx": 97.4, + "prevDayPx": 104.14, + "dayNtlVlm": 243491.15, + "funding": -0.00014956533318115242 + }"#; + + let update: MarketStatsUpdate = serde_json::from_str(json).unwrap(); + assert_eq!(update.symbol, "SOL"); + assert_eq!(update.mark_price, 97.35); + assert_eq!(update.mid_price, 97.315); + assert_eq!(update.oracle_price, 97.4); + assert_eq!(update.prev_day_mark_price, 104.14); + } + + #[test] + fn test_serialize_market_stats_update() { + let update = MarketStatsUpdate { + symbol: "BTC".to_string(), + open_interest: 500000.0, + mark_price: 65000.0, + mid_price: 64995.0, + oracle_price: 64990.0, + prev_day_mark_price: 64000.0, + day_volume_usd: 1000000000.0, + funding_rate: 0.00005, + }; + + let json = serde_json::to_string(&update).unwrap(); + assert!(json.contains("\"symbol\":\"BTC\"")); + assert!(json.contains("\"markPx\":65000")); + assert!(json.contains("\"oraclePx\":64990")); + } + + #[test] + fn test_deserialize_market_status() { + let json = r#""active""#; + let status: MarketStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status, MarketStatus::Active); + + let json = r#""postOnly""#; + let status: MarketStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status, MarketStatus::PostOnly); + } + + #[test] + fn test_deserialize_markets_view() { + let json = r#"{"slot":396561188,"markets":[{"symbol":"ETH","assetId":2,"marketStatus":"active","units":{"tickSizeInQuoteLotsPerBaseLot":100,"baseLotsDecimals":3},"fees":{"takerFeeMicro":200,"makerFeeMicro":0},"openInterest":{"value":1392,"decimals":3,"ui":"1.392"},"openInterestCap":{"value":312000000,"decimals":3,"ui":"312000.000"},"leverageTiers":[{"maxLeverage":20.0,"maxSizeBaseLots":8000,"limitOrderRiskFactor":6000}],"fundingIntervalInSlots":3600,"fundingPeriodInSlots":86400,"fundingStartIntervalSlot":1769634000,"cumulativeFundingRate":901,"maxLiquidationSize":{"value":20000,"decimals":3,"ui":"20.000"},"riskFactors":{"maintenance":5000,"backstop":2000,"highRisk":1000,"upnl":10000,"upnlForWithdrawals":100,"cancelOrder":7000},"isolatedOnly":false}]}"#; + + let view: MarketsView = serde_json::from_str(json).unwrap(); + assert_eq!(view.slot, 396561188); + assert_eq!(view.markets.len(), 1); + assert_eq!(view.markets[0].symbol, "ETH"); + assert_eq!(view.markets[0].asset_id, 2); + assert_eq!(view.markets[0].market_status, MarketStatus::Active); + assert!(!view.markets[0].isolated_only); + } +} diff --git a/container/vendor/rise/rust/types/src/market_state.rs b/container/vendor/rise/rust/types/src/market_state.rs new file mode 100644 index 00000000000..2b8de59726e --- /dev/null +++ b/container/vendor/rise/rust/types/src/market_state.rs @@ -0,0 +1,212 @@ +//! Combined market state container for Phoenix markets. + +use crate::l2book::L2Book; +use crate::market_stats::MarketStats; +use crate::{L2BookUpdate, MarketStatsUpdate, ServerMessage}; + +/// Combined container for market state including statistics and orderbook. +#[derive(Debug, Clone)] +pub struct Market { + stats: MarketStats, + book: L2Book, +} + +impl Market { + pub fn new(stats_symbol: String, orderbook_symbol: String) -> Self { + Self { + stats: MarketStats::new(stats_symbol), + book: L2Book::new(orderbook_symbol), + } + } + + pub fn from_symbol(symbol: String) -> Self { + Self::new(symbol.clone(), symbol) + } + + pub fn apply_server_message(&mut self, msg: &ServerMessage) -> &mut Self { + match msg { + ServerMessage::Market(update) => self.apply_market_stats_update(update), + ServerMessage::Orderbook(update) => self.apply_l2_book_update(update), + _ => self, + } + } + + pub fn apply_market_stats_update(&mut self, msg: &MarketStatsUpdate) -> &mut Self { + self.stats.apply_update(msg); + self + } + + pub fn apply_l2_book_update(&mut self, msg: &L2BookUpdate) -> &mut Self { + self.book.apply_update(msg); + self + } + + pub fn stats(&self) -> &MarketStats { + &self.stats + } + + pub fn stats_mut(&mut self) -> &mut MarketStats { + &mut self.stats + } + + pub fn book(&self) -> &L2Book { + &self.book + } + + pub fn book_mut(&mut self) -> &mut L2Book { + &mut self.book + } + + pub fn symbol(&self) -> &str { + self.stats.symbol() + } + + pub fn orderbook_symbol(&self) -> &str { + self.book.symbol() + } + + pub fn mark_price(&self) -> Option { + self.stats.mark_price() + } + + pub fn oracle_price(&self) -> Option { + self.stats.oracle_price() + } + + pub fn open_interest(&self) -> Option { + self.stats.open_interest() + } + + pub fn funding_rate(&self) -> Option { + self.stats.funding_rate() + } + + pub fn best_bid(&self) -> Option { + self.book.best_bid() + } + + pub fn best_ask(&self) -> Option { + self.book.best_ask() + } + + pub fn spread(&self) -> Option { + self.book.spread() + } + + pub fn mid_price(&self) -> Option { + self.book.mid_price() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_stats_update(symbol: &str, mark_price: f64) -> MarketStatsUpdate { + MarketStatsUpdate { + symbol: symbol.to_string(), + open_interest: 1000000.0, + mark_price, + mid_price: mark_price - 0.025, + oracle_price: mark_price - 0.05, + prev_day_mark_price: mark_price * 0.98, + day_volume_usd: 50000000.0, + funding_rate: 0.0001, + } + } + + fn make_l2_update(symbol: &str) -> L2BookUpdate { + L2BookUpdate { + symbol: symbol.to_string(), + orderbook: crate::L2Orderbook { + bids: vec![(150.25, 100.0), (150.20, 200.0), (150.15, 300.0)], + asks: vec![(150.30, 150.0), (150.35, 250.0), (150.40, 400.0)], + mid: Some(150.275), + }, + } + } + + #[test] + fn test_new_market() { + let market = Market::new("SOL".to_string(), "SOL".to_string()); + assert_eq!(market.symbol(), "SOL"); + assert_eq!(market.orderbook_symbol(), "SOL"); + assert!(market.mark_price().is_none()); + assert!(market.best_bid().is_none()); + } + + #[test] + fn test_from_symbol() { + let market = Market::from_symbol("SOL".to_string()); + assert_eq!(market.symbol(), "SOL"); + assert_eq!(market.orderbook_symbol(), "SOL"); + } + + #[test] + fn test_apply_server_message_market_stats() { + let mut market = Market::new("SOL".to_string(), "SOL".to_string()); + let update = make_stats_update("SOL", 150.0); + let msg = ServerMessage::Market(update); + + market.apply_server_message(&msg); + + assert_eq!(market.mark_price(), Some(150.0)); + assert_eq!(market.oracle_price(), Some(149.95)); + } + + #[test] + fn test_apply_server_message_l2_book() { + let mut market = Market::new("SOL".to_string(), "SOL".to_string()); + let update = make_l2_update("SOL"); + let msg = ServerMessage::Orderbook(update); + + market.apply_server_message(&msg); + + assert_eq!(market.best_bid(), Some(150.25)); + assert_eq!(market.best_ask(), Some(150.30)); + } + + #[test] + fn test_apply_both_updates() { + let mut market = Market::new("SOL".to_string(), "SOL".to_string()); + + let stats_update = make_stats_update("SOL", 150.0); + let l2_update = make_l2_update("SOL"); + + market + .apply_server_message(&ServerMessage::Market(stats_update)) + .apply_server_message(&ServerMessage::Orderbook(l2_update)); + + assert_eq!(market.mark_price(), Some(150.0)); + assert!(market.open_interest().is_some()); + assert_eq!(market.best_bid(), Some(150.25)); + assert!(market.spread().is_some()); + } + + #[test] + fn test_ignore_wrong_symbol_and_coin() { + let mut market = Market::new("SOL".to_string(), "SOL".to_string()); + + let wrong_stats = make_stats_update("BTC", 65000.0); + let wrong_l2 = make_l2_update("BTC"); + + market + .apply_server_message(&ServerMessage::Market(wrong_stats)) + .apply_server_message(&ServerMessage::Orderbook(wrong_l2)); + + assert!(market.mark_price().is_none()); + assert!(market.best_bid().is_none()); + } + + #[test] + fn test_access_inner_containers() { + let mut market = Market::new("SOL".to_string(), "SOL".to_string()); + let l2_update = make_l2_update("SOL"); + + market.apply_l2_book_update(&l2_update); + + assert_eq!(market.book().bid_depth(), 3); + assert_eq!(market.book().ask_depth(), 3); + assert_eq!(market.stats().symbol(), "SOL"); + } +} diff --git a/container/vendor/rise/rust/types/src/market_stats.rs b/container/vendor/rise/rust/types/src/market_stats.rs new file mode 100644 index 00000000000..46db52b00d2 --- /dev/null +++ b/container/vendor/rise/rust/types/src/market_stats.rs @@ -0,0 +1,131 @@ +//! Market statistics state container for Phoenix markets. + +use crate::MarketStatsUpdate; + +/// Container for market statistics. +#[derive(Debug, Clone)] +pub struct MarketStats { + symbol: String, + stats: Option, +} + +impl MarketStats { + pub fn new(symbol: String) -> Self { + Self { + symbol, + stats: None, + } + } + + pub fn apply_update(&mut self, msg: &MarketStatsUpdate) { + if msg.symbol != self.symbol { + return; + } + self.stats = Some(msg.clone()); + } + + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn stats(&self) -> Option<&MarketStatsUpdate> { + self.stats.as_ref() + } + + pub fn mark_price(&self) -> Option { + self.stats.as_ref().map(|s| s.mark_price) + } + + pub fn mid_price(&self) -> Option { + self.stats.as_ref().map(|s| s.mid_price) + } + + pub fn oracle_price(&self) -> Option { + self.stats.as_ref().map(|s| s.oracle_price) + } + + pub fn open_interest(&self) -> Option { + self.stats.as_ref().map(|s| s.open_interest) + } + + pub fn day_volume_usd(&self) -> Option { + self.stats.as_ref().map(|s| s.day_volume_usd) + } + + pub fn funding_rate(&self) -> Option { + self.stats.as_ref().map(|s| s.funding_rate) + } + + pub fn prev_day_mark_price(&self) -> Option { + self.stats.as_ref().map(|s| s.prev_day_mark_price) + } + + pub fn price_change_24h_percent(&self) -> Option { + self.stats.as_ref().and_then(|s| { + if s.prev_day_mark_price == 0.0 { + return None; + } + Some((s.mark_price - s.prev_day_mark_price) / s.prev_day_mark_price * 100.0) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_stats_update(symbol: &str, mark_price: f64) -> MarketStatsUpdate { + MarketStatsUpdate { + symbol: symbol.to_string(), + open_interest: 1000000.0, + mark_price, + mid_price: mark_price - 0.025, + oracle_price: mark_price - 0.05, + prev_day_mark_price: mark_price * 0.98, + day_volume_usd: 50000000.0, + funding_rate: 0.0001, + } + } + + #[test] + fn test_new_market_stats() { + let stats = MarketStats::new("SOL".to_string()); + assert_eq!(stats.symbol(), "SOL"); + assert!(stats.stats().is_none()); + assert!(stats.mark_price().is_none()); + } + + #[test] + fn test_apply_update() { + let mut stats = MarketStats::new("SOL".to_string()); + let update = make_stats_update("SOL", 150.0); + + stats.apply_update(&update); + + assert!(stats.stats().is_some()); + assert_eq!(stats.mark_price(), Some(150.0)); + assert_eq!(stats.oracle_price(), Some(149.95)); + } + + #[test] + fn test_ignore_wrong_symbol() { + let mut stats = MarketStats::new("SOL".to_string()); + let update = make_stats_update("BTC", 65000.0); + + stats.apply_update(&update); + + assert!(stats.stats().is_none()); + assert!(stats.mark_price().is_none()); + } + + #[test] + fn test_price_change_24h_percent() { + let mut stats = MarketStats::new("SOL".to_string()); + let update = make_stats_update("SOL", 150.0); + + stats.apply_update(&update); + + let change = stats.price_change_24h_percent().unwrap(); + assert!((change - 2.04).abs() < 0.1); + } +} diff --git a/container/vendor/rise/rust/types/src/metadata.rs b/container/vendor/rise/rust/types/src/metadata.rs new file mode 100644 index 00000000000..87f9975d24d --- /dev/null +++ b/container/vendor/rise/rust/types/src/metadata.rs @@ -0,0 +1,190 @@ +//! Exchange metadata caching for Phoenix SDK. + +use std::collections::{HashMap, HashSet}; + +use phoenix_math_utils::{ + BaseLots, BasisPoints, Constant, LeverageTier, LeverageTiers, MarketCalculator, + PerpAssetMetadata, QuoteLotsPerBaseLotPerTick, WrapperNum, +}; + +use crate::{ + ExchangeKeysView, ExchangeLeverageTier, ExchangeMarketConfig, ExchangeView, MarketStatsUpdate, +}; + +/// Consolidated exchange metadata for Phoenix SDK. +#[derive(Debug, Clone)] +pub struct PhoenixMetadata { + exchange: ExchangeView, + market_calculators: HashMap, + perp_asset_metadata: HashMap, + isolated_only_markets: HashSet, +} + +impl PhoenixMetadata { + pub fn new(exchange: ExchangeView) -> Self { + let mut market_calculators = HashMap::new(); + let mut isolated_only_markets = HashSet::new(); + + for (symbol, config) in &exchange.markets { + let calc = MarketCalculator::new( + config.base_lots_decimals, + QuoteLotsPerBaseLotPerTick::new(config.tick_size), + ); + market_calculators.insert(symbol.clone(), calc); + if config.isolated_only { + isolated_only_markets.insert(symbol.clone()); + } + } + + Self { + exchange, + market_calculators, + perp_asset_metadata: HashMap::new(), + isolated_only_markets, + } + } + + pub fn exchange(&self) -> &ExchangeView { + &self.exchange + } + + pub fn keys(&self) -> &ExchangeKeysView { + &self.exchange.keys + } + + pub fn get_market(&self, symbol: &str) -> Option<&ExchangeMarketConfig> { + self.exchange.get_market(symbol) + } + + pub fn is_isolated_only(&self, symbol: &str) -> bool { + self.isolated_only_markets + .contains(&symbol.to_ascii_uppercase()) + } + + pub fn get_market_calculator(&self, symbol: &str) -> Option<&MarketCalculator> { + self.market_calculators.get(&symbol.to_ascii_uppercase()) + } + + pub fn get_perp_asset_metadata(&self, symbol: &str) -> Option<&PerpAssetMetadata> { + self.perp_asset_metadata.get(&symbol.to_ascii_uppercase()) + } + + pub fn get_perp_asset_metadata_mut(&mut self, symbol: &str) -> Option<&mut PerpAssetMetadata> { + self.perp_asset_metadata + .get_mut(&symbol.to_ascii_uppercase()) + } + + pub fn all_perp_asset_metadata(&self) -> &HashMap { + &self.perp_asset_metadata + } + + pub fn all_perp_asset_metadata_mut(&mut self) -> &mut HashMap { + &mut self.perp_asset_metadata + } + + pub fn symbols(&self) -> impl Iterator { + self.exchange.markets.keys() + } + + pub fn apply_market_stats(&mut self, stats: &MarketStatsUpdate) -> Result<(), String> { + let symbol = stats.symbol.to_ascii_uppercase(); + + let config = self + .exchange + .get_market(&symbol) + .ok_or_else(|| format!("Unknown symbol: {}", symbol))?; + let calc = self + .market_calculators + .get(&symbol) + .ok_or_else(|| format!("Missing calculator for: {}", symbol))?; + + if let Some(metadata) = self.perp_asset_metadata.get_mut(&symbol) { + let mark_price_ticks = calc + .price_to_ticks(stats.mark_price) + .map_err(|e| format!("Failed to convert mark price: {:?}", e))?; + metadata.set_mark_price(mark_price_ticks); + } else { + let metadata = perp_asset_metadata_from_exchange_config(config, stats, calc)?; + self.perp_asset_metadata.insert(symbol, metadata); + } + + Ok(()) + } + + pub fn has_perp_asset_metadata(&self, symbol: &str) -> bool { + self.perp_asset_metadata + .contains_key(&symbol.to_ascii_uppercase()) + } + + pub fn initialized_market_count(&self) -> usize { + self.perp_asset_metadata.len() + } +} + +/// Build PerpAssetMetadata from exchange config and market stats. +fn perp_asset_metadata_from_exchange_config( + config: &ExchangeMarketConfig, + stats: &MarketStatsUpdate, + calc: &MarketCalculator, +) -> Result { + let mark_price_ticks = calc + .price_to_ticks(stats.mark_price) + .map_err(|e| format!("Failed to convert mark price: {:?}", e))?; + + let leverage_tiers = convert_leverage_tiers(&config.leverage_tiers)?; + + let risk_factors = [ + (config.risk_factors.maintenance * 100.0) as u16, + (config.risk_factors.backstop * 100.0) as u16, + (config.risk_factors.high_risk * 100.0) as u16, + ]; + + let cancel_order_risk_factor = (config.risk_factors.cancel_order * 100.0) as u16; + let upnl_risk_factor = (config.risk_factors.upnl * 100.0) as u16; + let upnl_risk_factor_for_withdrawals = + (config.risk_factors.upnl_for_withdrawals * 100.0) as u16; + + let tick_size = QuoteLotsPerBaseLotPerTick::new(config.tick_size); + + Ok(PerpAssetMetadata::new( + config.symbol.clone(), + config.asset_id as u64, + config.base_lots_decimals as i8, + mark_price_ticks, + tick_size, + leverage_tiers, + risk_factors, + cancel_order_risk_factor, + upnl_risk_factor, + upnl_risk_factor_for_withdrawals, + )) +} + +/// Convert Exchange API leverage tiers to margin calculation leverage tiers. +fn convert_leverage_tiers(api_tiers: &[ExchangeLeverageTier]) -> Result { + if api_tiers.len() != 4 { + return Err(format!( + "Expected exactly 4 leverage tiers, got {}", + api_tiers.len() + )); + } + + let convert_tier = |tier: &ExchangeLeverageTier| -> LeverageTier { + LeverageTier { + upper_bound_size: BaseLots::new(tier.max_size_base_lots), + max_leverage: Constant::new(tier.max_leverage as u64), + limit_order_risk_factor: BasisPoints::new( + (tier.limit_order_risk_factor * 100.0) as u64, + ), + } + }; + + let tiers: [LeverageTier; 4] = [ + convert_tier(&api_tiers[0]), + convert_tier(&api_tiers[1]), + convert_tier(&api_tiers[2]), + convert_tier(&api_tiers[3]), + ]; + + LeverageTiers::new(tiers).map_err(|e| e.to_string()) +} diff --git a/container/vendor/rise/rust/types/src/subscription_key.rs b/container/vendor/rise/rust/types/src/subscription_key.rs new file mode 100644 index 00000000000..ec1be5a8ed8 --- /dev/null +++ b/container/vendor/rise/rust/types/src/subscription_key.rs @@ -0,0 +1,106 @@ +//! Subscription key for routing messages to the correct subscriber. + +use solana_pubkey::Pubkey; + +use crate::{ + CandleData, FundingRateMessage, L2BookUpdate, MarketStatsUpdate, Timeframe, + TraderStateServerMessage, TradesMessage, +}; + +/// Subscription key for routing messages to the correct subscriber. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SubscriptionKey { + AllMids, + FundingRate { + symbol: String, + }, + Orderbook { + symbol: String, + }, + TraderState { + authority: String, + trader_pda_index: u8, + }, + Market { + symbol: String, + }, + Trades { + symbol: String, + }, + Candles { + symbol: String, + timeframe: Timeframe, + }, +} + +impl SubscriptionKey { + pub fn all_mids() -> Self { + Self::AllMids + } + + pub fn funding_rate(symbol: String) -> Self { + Self::FundingRate { symbol } + } + + pub fn funding_rate_from_message(msg: &FundingRateMessage) -> Self { + Self::FundingRate { + symbol: msg.symbol.clone(), + } + } + + pub fn orderbook(symbol: String) -> Self { + Self::Orderbook { symbol } + } + + pub fn orderbook_from_message(msg: &L2BookUpdate) -> Self { + Self::Orderbook { + symbol: msg.symbol.clone(), + } + } + + pub fn trader(authority: &Pubkey, trader_pda_index: u8) -> Self { + Self::TraderState { + authority: authority.to_string(), + trader_pda_index, + } + } + + pub fn trader_state_from_message(msg: &TraderStateServerMessage) -> Self { + Self::TraderState { + authority: msg.authority.clone(), + trader_pda_index: msg.trader_pda_index, + } + } + + pub fn market(symbol: String) -> Self { + Self::Market { symbol } + } + + pub fn market_from_message(msg: &MarketStatsUpdate) -> Self { + Self::Market { + symbol: msg.symbol.clone(), + } + } + + pub fn trades(symbol: String) -> Self { + Self::Trades { symbol } + } + + pub fn trades_from_message(msg: &TradesMessage) -> Self { + Self::Trades { + symbol: msg.symbol.clone(), + } + } + + pub fn candles(symbol: String, timeframe: Timeframe) -> Self { + Self::Candles { symbol, timeframe } + } + + pub fn candles_from_message(msg: &CandleData) -> Option { + let timeframe = msg.timeframe.parse().ok()?; + Some(Self::Candles { + symbol: msg.symbol.clone(), + timeframe, + }) + } +} diff --git a/container/vendor/rise/rust/types/src/trader.rs b/container/vendor/rise/rust/types/src/trader.rs new file mode 100644 index 00000000000..9cbf9aa0365 --- /dev/null +++ b/container/vendor/rise/rust/types/src/trader.rs @@ -0,0 +1,377 @@ +//! WebSocket protocol types for trader state synchronization. +//! +//! These types represent snapshots and deltas for trader positions, +//! orders, splines, and capabilities received via WebSocket. +//! +//! For HTTP API types (views, history), see [`crate::trader_http`]. + +use serde::{Deserialize, Serialize}; + +use crate::core::Side; + +// ============================================================================ +// State Envelope Types +// ============================================================================ + +/// Complete server message envelope for trader-state updates. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateServerMessage { + pub authority: String, + pub trader_pda_index: u8, + pub slot: u64, + #[serde(flatten)] + pub content: TraderStatePayload, +} + +/// Trader-state payload variants. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "messageType", rename_all = "camelCase")] +pub enum TraderStatePayload { + #[serde(rename = "snapshot")] + Snapshot(TraderStateSnapshot), + #[serde(rename = "delta")] + Delta(TraderStateDelta), +} + +/// Snapshot payload covering every subaccount belonging to a trader PDA. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSnapshot { + pub version: u32, + pub capabilities: TraderStateCapabilities, + pub maker_fee_override_multiplier: f64, + pub taker_fee_override_multiplier: f64, + pub subaccounts: Vec, +} + +/// Batch of row-level deltas grouped by subaccount. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateDelta { + pub deltas: Vec, +} + +// ============================================================================ +// Subaccount Types +// ============================================================================ + +/// Complete subaccount view contained in a snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSubaccountSnapshot { + pub subaccount_index: u8, + pub sequence: u64, + pub collateral: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cooldown_status: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub positions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub orders: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub splines: Vec, +} + +/// Row-level delta set for a specific subaccount. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSubaccountDelta { + pub subaccount_index: u8, + pub sequence: u64, + pub collateral: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cooldown_status: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub positions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub orders: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub splines: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trade_history: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub order_history: Vec, +} + +// ============================================================================ +// Position Types +// ============================================================================ + +/// Snapshot entry keyed by market symbol. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStatePositionSnapshot { + pub symbol: String, + #[serde(flatten)] + pub position: TraderStatePositionRow, +} + +/// Position row used for snapshots and deltas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStatePositionRow { + pub position_sequence_number: String, + pub base_position_lots: String, + pub entry_price_ticks: String, + pub entry_price_usd: String, + pub virtual_quote_position_lots: String, + pub unsettled_funding_quote_lots: String, + pub accumulated_funding_quote_lots: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub take_profit_triggers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stop_loss_triggers: Vec, +} + +/// Trigger configuration for TP/SL orders. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateTrigger { + pub trigger_price_ticks: String, + pub execution_price_ticks: String, + pub side: Side, + pub kind: String, +} + +/// Position delta grouped by symbol. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStatePositionDelta { + pub symbol: String, + pub change: TraderStateRowChangeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +/// Change indicator used for row-level deltas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TraderStateRowChangeKind { + Updated, + Closed, +} + +/// Stop-loss trigger rows scoped to a position. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateStopLossTrigger { + pub stop_loss_id: String, + pub trigger: TraderStateTrigger, + pub status: String, +} + +/// Take-profit trigger rows scoped to a position. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateTakeProfitTrigger { + pub take_profit_id: String, + pub trigger: TraderStateTrigger, + pub status: String, +} + +// ============================================================================ +// Order Types +// ============================================================================ + +/// Order grouping used for snapshots/deltas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateLimitOrderEvent { + pub symbol: String, + pub orders: Vec, +} + +/// Detailed limit order representation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateMarketLimitOrderEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub change: Option, + pub order_sequence_number: String, + pub side: Side, + pub order_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditional_kind: Option, + pub price_ticks: String, + pub price_usd: String, + pub size_remaining_lots: String, + pub initial_size_lots: String, + pub reduce_only: bool, + #[serde(default)] + pub is_stop_loss: bool, + pub status: String, +} + +// ============================================================================ +// Spline Types +// ============================================================================ + +/// Spline snapshot grouped by market symbol. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSplineSnapshot { + pub symbol: String, + #[serde(flatten)] + pub spline: TraderStateSplineRow, +} + +/// Spline row containing the spline parameters and fill state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSplineRow { + pub mid_price_ticks: String, + pub bid_filled_amount_lots: String, + pub ask_filled_amount_lots: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bid_regions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ask_regions: Vec, +} + +/// Tick region for spline configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateTickRegion { + pub start_price_ticks: String, + pub end_price_ticks: String, + pub density_lots_per_tick: String, + pub total_size_lots: String, + pub filled_size_lots: String, +} + +/// Spline delta for row-level changes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSplineDelta { + pub symbol: String, + pub change: TraderStateRowChangeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub spline: Option, +} + +/// Trade history row generated from PnL events. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TradeHistoryDelta { + pub timestamp: u64, + pub slot: i64, + pub slot_index: i32, + pub instruction_index: i32, + pub event_index: i32, + pub market: String, + pub instruction_type: String, + pub trade_type: String, + pub base_qty_before: String, + pub base_qty_after: String, + pub size: String, + pub liquidity: String, + pub price: String, + pub fee: String, + pub realized_pnl: String, +} + +/// Order history row generated from market order packets. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OrderHistoryDelta { + pub timestamp: u64, + pub slot: u64, + pub slot_index: u32, + pub instruction_index: u32, + pub event_index: u32, + pub market: String, + pub instruction_type: String, + pub order_type: String, + pub status: String, + pub size: String, + pub price: String, + pub filled_size: String, +} + +// ============================================================================ +// Capabilities Types +// ============================================================================ + +/// Withdrawal cooldown status for a trader PDA. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CooldownStatus { + pub last_deposit_slot: u64, + pub cooldown_period_in_slots: u64, +} + +/// Trader capability flags and derived views. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateCapabilities { + /// Raw capability flags as a bitmask integer. + pub flags: u16, + /// Activity state as a string (e.g., "active", "reduceOnly", + /// "liquidatable"). + pub state: String, + /// Capability access levels for various actions. + pub capabilities: TraderCapabilitiesView, +} + +/// Human-readable capabilities derived from flags. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TraderCapabilitiesView { + pub place_limit_order: CapabilityAccess, + pub place_market_order: CapabilityAccess, + pub risk_increasing_trade: CapabilityAccess, + pub risk_reducing_trade: CapabilityAccess, + pub deposit_collateral: CapabilityAccess, + pub withdraw_collateral: CapabilityAccess, +} + +/// Capability access levels. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityAccess { + #[serde(default)] + pub immediate: bool, + #[serde(default)] + pub via_cold_activation: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_trader_state_snapshot() { + let json = r#"{ + "authority": "ABC123", + "traderPdaIndex": 0, + "slot": 12345, + "messageType": "snapshot", + "version": 1, + "capabilities": { + "flags": 63, + "state": "active", + "capabilities": { + "placeLimitOrder": {"immediate": true}, + "placeMarketOrder": {"immediate": true}, + "riskIncreasingTrade": {"immediate": true}, + "riskReducingTrade": {"immediate": true}, + "depositCollateral": {"immediate": true}, + "withdrawCollateral": {"immediate": true} + } + }, + "makerFeeOverrideMultiplier": 1.0, + "takerFeeOverrideMultiplier": 1.0, + "subaccounts": [] + }"#; + + let msg: TraderStateServerMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.authority, "ABC123"); + assert_eq!(msg.slot, 12345); + assert!(matches!(msg.content, TraderStatePayload::Snapshot(_))); + } +} diff --git a/container/vendor/rise/rust/types/src/trader_http.rs b/container/vendor/rise/rust/types/src/trader_http.rs new file mode 100644 index 00000000000..0a30ef6740e --- /dev/null +++ b/container/vendor/rise/rust/types/src/trader_http.rs @@ -0,0 +1,663 @@ +//! HTTP API types for trader state, views, and history. +//! +//! These types represent responses from Phoenix REST API endpoints +//! for trader views, order history, collateral history, and funding history. + +use std::fmt::{self, Display}; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use solana_pubkey::Pubkey; + +use crate::core::{Decimal, Side}; +use crate::market::{RiskState, RiskTier}; +use crate::trader::TraderCapabilitiesView; +use crate::trader_key::TraderKey; + +// ============================================================================ +// Order History Types +// ============================================================================ + +/// Order status in order history. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum OrderStatus { + /// Order is open and active on the book. + Open, + /// Order was filled completely. + Filled, + /// Order was cancelled. + Cancelled, + /// Order expired. + Expired, +} + +/// Individual order in order history. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderHistoryItem { + /// Order sequence number. + pub order_sequence_number: String, + /// Market symbol (e.g., "SOL"). + pub market_symbol: String, + /// Order status. + pub status: OrderStatus, + /// Order side ("buy" or "sell"). + pub side: Side, + /// Indicates whether the order was marked reduce-only at placement time. + pub is_reduce_only: bool, + /// Indicates whether the order originated from a stop loss trigger. + #[serde(default)] + pub is_stop_loss: bool, + /// Order price (human-readable, decimal format). + pub price: String, + /// Base quantity (human-readable, decimal format). + pub base_qty: String, + /// Remaining base quantity (human-readable, decimal format). + pub remaining_base_qty: String, + /// Total filled base quantity (human-readable, decimal format). + pub filled_base_qty: String, + /// Timestamp when the order was placed (ISO 8601). + pub placed_at: Option>, + /// Timestamp when the order was completed (ISO 8601). + pub completed_at: Option>, +} + +/// Response from the order history endpoint. +pub type OrderHistoryResponse = crate::core::PaginatedResponse>; + +/// Query parameters for fetching order history. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderHistoryQueryParams { + /// PDA index for the trader (default 0). + #[serde(skip_serializing_if = "Option::is_none")] + pub trader_pda_index: Option, + /// Optional market symbol filter (e.g., "SOL"). + #[serde(skip_serializing_if = "Option::is_none")] + pub market_symbol: Option, + /// Number of items to return (max 1000). + pub limit: i64, + /// Optional cursor for pagination (format: "slot,slot_index,event_index"). + /// Returns items older than (exclusive of) this cursor. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Optional Privy user ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub privy_id: Option, +} + +impl OrderHistoryQueryParams { + /// Creates new query params with the specified limit. + pub fn new(limit: i64) -> Self { + Self { + trader_pda_index: None, + market_symbol: None, + limit, + cursor: None, + privy_id: None, + } + } + + /// Sets the PDA index for the trader. + pub fn with_pda_index(mut self, pda_index: u8) -> Self { + self.trader_pda_index = Some(pda_index); + self + } + + /// Sets the market symbol filter. + pub fn with_market_symbol(mut self, symbol: impl Into) -> Self { + self.market_symbol = Some(symbol.into()); + self + } + + /// Sets the cursor for pagination (returns items older than cursor). + pub fn with_cursor(mut self, cursor: impl Into) -> Self { + self.cursor = Some(cursor.into()); + self + } + + /// Sets the Privy user ID. + pub fn with_privy_id(mut self, privy_id: impl Into) -> Self { + self.privy_id = Some(privy_id.into()); + self + } +} + +// ============================================================================ +// Collateral History Types +// ============================================================================ + +/// Pagination parameters for collateral history requests. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollateralHistoryRequest { + /// Number of items to return (max 1000). + pub limit: i64, + /// Cursor for older events (base64-encoded). + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Cursor for newer events (base64-encoded). + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_cursor: Option, + /// Deprecated cursor parameter (older events). + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl CollateralHistoryRequest { + /// Creates new request params with the specified limit. + pub fn new(limit: i64) -> Self { + Self { + limit, + next_cursor: None, + prev_cursor: None, + cursor: None, + } + } + + /// Sets the cursor for fetching older events. + pub fn with_next_cursor(mut self, cursor: impl Into) -> Self { + self.next_cursor = Some(cursor.into()); + self + } + + /// Sets the cursor for fetching newer events. + pub fn with_prev_cursor(mut self, cursor: impl Into) -> Self { + self.prev_cursor = Some(cursor.into()); + self + } +} + +/// Query parameters for fetching collateral history. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollateralHistoryQueryParams { + /// PDA index for the trader account. + #[serde(default, alias = "pdaIndex", alias = "pda_index")] + pub pda_index: u8, + /// Pagination and filter parameters. + pub request: CollateralHistoryRequest, +} + +impl CollateralHistoryQueryParams { + /// Creates new query params with the specified limit. + pub fn new(limit: i64) -> Self { + Self { + pda_index: 0, + request: CollateralHistoryRequest::new(limit), + } + } + + /// Sets the PDA index. + pub fn with_pda_index(mut self, pda_index: u8) -> Self { + self.pda_index = pda_index; + self + } + + /// Sets the cursor for fetching older events. + pub fn with_next_cursor(mut self, cursor: impl Into) -> Self { + self.request.next_cursor = Some(cursor.into()); + self + } + + /// Sets the cursor for fetching newer events. + pub fn with_prev_cursor(mut self, cursor: impl Into) -> Self { + self.request.prev_cursor = Some(cursor.into()); + self + } +} + +/// Response from the collateral history endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollateralHistoryResponse { + /// The data payload (array of collateral events). + pub data: Vec, + /// Cursor for fetching older results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Cursor for fetching newer results. + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_cursor: Option, + /// Whether there are more results in the requested direction. + pub has_more: bool, +} + +/// A single collateral event (deposit or withdrawal). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollateralEvent { + /// Solana slot when the event occurred. + pub slot: i64, + /// Index within the slot. + pub slot_index: i32, + /// Event index for ordering within the slot. + pub event_index: i32, + /// Trader PDA index (usually 0). + pub trader_pda_index: i32, + /// Trader subaccount index. + pub trader_subaccount_index: i32, + /// Event type: "deposit" or "withdrawal". + pub event_type: String, + /// Amount deposited or withdrawn (in quote lots, 6 decimals). + pub amount: i64, + /// Collateral balance after this event (in quote lots, 6 decimals). + pub collateral_after: i64, + /// Timestamp when the transaction was processed. + pub timestamp: chrono::DateTime, +} + +// ============================================================================ +// Funding History Types +// ============================================================================ + +/// Query parameters for fetching funding history. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FundingHistoryQueryParams { + /// PDA index for the trader (default: 0). + #[serde(default)] + pub pda_index: u8, + /// Optional market symbol to filter by (e.g., "SOL"). + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, + /// Start time in milliseconds since Unix epoch. + /// Mutually exclusive with cursor. Max range: 1 year. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// End time in milliseconds since Unix epoch. + /// Max range: 1 year. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Max number of events (default: 100, max: 1000). + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque cursor for pagination. + /// Mutually exclusive with start_time. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl FundingHistoryQueryParams { + /// Creates new empty query params with defaults. + pub fn new() -> Self { + Self::default() + } + + /// Sets the PDA index for the trader. + pub fn with_pda_index(mut self, pda_index: u8) -> Self { + self.pda_index = pda_index; + self + } + + /// Sets the market symbol filter. + pub fn with_symbol(mut self, symbol: impl Into) -> Self { + self.symbol = Some(symbol.into()); + self + } + + /// Sets the start time in milliseconds since Unix epoch. + pub fn with_start_time(mut self, start_time: i64) -> Self { + self.start_time = Some(start_time); + self + } + + /// Sets the end time in milliseconds since Unix epoch. + pub fn with_end_time(mut self, end_time: i64) -> Self { + self.end_time = Some(end_time); + self + } + + /// Sets the maximum number of events to return. + pub fn with_limit(mut self, limit: i64) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the pagination cursor. + pub fn with_cursor(mut self, cursor: impl Into) -> Self { + self.cursor = Some(cursor.into()); + self + } +} + +/// Response from the funding history endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FundingHistoryResponse { + /// List of funding payment events. + pub events: Vec, + /// Opaque cursor for fetching newer results. + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_cursor: Option, + /// Opaque cursor for fetching older results. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Whether more results exist beyond the current page. + pub has_more: bool, +} + +/// A single funding payment event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FundingHistoryEvent { + /// Timestamp when funding was settled. + pub timestamp: chrono::DateTime, + /// Market symbol (e.g., "SOL"). + pub symbol: String, + /// Funding payment amount in USDC (negative = paid, positive = received). + pub funding_payment: String, + /// Funding rate percentage at the time of payment. + pub funding_rate_percentage: String, + /// Position size at the time of payment. + pub position_size: String, + /// Position side ("Long" or "Short"). + pub position_side: String, +} + +#[cfg(test)] +mod tests { + use super::FundingHistoryEvent; + + #[test] + fn funding_history_timestamp_accepts_rfc3339() { + let raw = r#"{ + "timestamp":"2026-02-11T16:00:00Z", + "symbol":"SOL", + "fundingPayment":"-0.123", + "fundingRatePercentage":"0.001", + "positionSize":"10", + "positionSide":"Long" + }"#; + + let event: FundingHistoryEvent = + serde_json::from_str(raw).expect("RFC3339 timestamp should deserialize"); + assert_eq!(event.timestamp.to_rfc3339(), "2026-02-11T16:00:00+00:00"); + } + + #[test] + fn funding_history_timestamp_rejects_integer() { + let raw = r#"{ + "timestamp":1770825600, + "symbol":"SOL", + "fundingPayment":"-0.123", + "fundingRatePercentage":"0.001", + "positionSize":"10", + "positionSide":"Long" + }"#; + + let result = serde_json::from_str::(raw); + assert!( + result.is_err(), + "integer timestamp should not deserialize for FundingHistoryEvent" + ); + } +} + +// ============================================================================ +// PnL Types +// ============================================================================ + +/// Resolution for PnL time-series data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PnlResolution { + #[serde(rename = "1m")] + Minute1, + #[serde(rename = "5m")] + Minute5, + #[serde(rename = "15m")] + Minute15, + #[serde(rename = "1h")] + Hour1, + #[serde(rename = "4h")] + Hour4, + #[serde(rename = "1d")] + Day1, + #[serde(rename = "1w")] + Week1, + #[serde(rename = "1M")] + Month1, +} + +impl Display for PnlResolution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PnlResolution::Minute1 => write!(f, "1m"), + PnlResolution::Minute5 => write!(f, "5m"), + PnlResolution::Minute15 => write!(f, "15m"), + PnlResolution::Hour1 => write!(f, "1h"), + PnlResolution::Hour4 => write!(f, "4h"), + PnlResolution::Day1 => write!(f, "1d"), + PnlResolution::Week1 => write!(f, "1w"), + PnlResolution::Month1 => write!(f, "1M"), + } + } +} + +impl FromStr for PnlResolution { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "1m" => Ok(PnlResolution::Minute1), + "5m" => Ok(PnlResolution::Minute5), + "15m" => Ok(PnlResolution::Minute15), + "1h" => Ok(PnlResolution::Hour1), + "4h" => Ok(PnlResolution::Hour4), + "1d" => Ok(PnlResolution::Day1), + "1w" => Ok(PnlResolution::Week1), + "1M" => Ok(PnlResolution::Month1), + _ => Err(format!("Unknown PnL resolution: {s}")), + } + } +} + +/// Query parameters for fetching PnL time-series data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PnlQueryParams { + /// Resolution for the PnL buckets. + pub resolution: PnlResolution, + /// Start time in milliseconds since Unix epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + /// End time in milliseconds since Unix epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + /// Maximum number of data points to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl PnlQueryParams { + /// Creates new query params with the specified resolution. + pub fn new(resolution: PnlResolution) -> Self { + Self { + resolution, + start_time: None, + end_time: None, + limit: None, + } + } + + /// Sets the start time in milliseconds since Unix epoch. + pub fn with_start_time(mut self, start_time: i64) -> Self { + self.start_time = Some(start_time); + self + } + + /// Sets the end time in milliseconds since Unix epoch. + pub fn with_end_time(mut self, end_time: i64) -> Self { + self.end_time = Some(end_time); + self + } + + /// Sets the maximum number of data points to return. + pub fn with_limit(mut self, limit: i64) -> Self { + self.limit = Some(limit); + self + } +} + +/// A single PnL data point in the time-series. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PnlPoint { + /// Timestamp in milliseconds since Unix epoch. + pub timestamp: i64, + /// Start time of the bucket in milliseconds since Unix epoch. + pub start_time: i64, + /// End time of the bucket in milliseconds since Unix epoch. + pub end_time: i64, + /// Cumulative realized PnL. + pub cumulative_pnl: f64, + /// Current unrealized PnL. + pub unrealized_pnl: f64, + /// Cumulative funding payments. + pub cumulative_funding_payment: f64, + /// Cumulative taker fees paid. + pub cumulative_taker_fee: f64, +} + +/// Response from the PnL endpoint. +pub type PnlResponse = Vec; + +// ============================================================================ +// Trader View Types +// ============================================================================ + +/// Trader activity state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TraderActivityState { + Uninitialized, + Cold, + Active, + ReduceOnly, + Frozen, +} + +/// A trader's position view. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderPositionView { + pub symbol: String, + pub position_size: Decimal, + pub virtual_quote_position: Decimal, + pub entry_price: Decimal, + pub unrealized_pnl: Decimal, + pub discounted_unrealized_pnl: Decimal, + pub position_initial_margin: Decimal, + pub initial_margin: Decimal, + pub maintenance_margin: Decimal, + pub backstop_margin: Decimal, + pub limit_order_margin: Decimal, + pub position_value: Decimal, + pub unsettled_funding: Decimal, + pub accumulated_funding: Decimal, + pub liquidation_price: Decimal, + #[serde(default)] + pub take_profit_price: Option, + #[serde(default)] + pub stop_loss_price: Option, +} + +/// A trader's limit order. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LimitOrder { + pub price: Decimal, + pub side: Side, + pub order_sequence_number: String, + pub initial_trade_size: Decimal, + pub trade_size_remaining: Decimal, + pub margin_requirement: Decimal, + pub margin_factor: Decimal, + pub is_reduce_only: bool, + #[serde(default)] + pub is_stop_loss: bool, +} + +/// Trader view with all trading information (HTTP API response). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderView { + pub flags: u16, + pub state: TraderActivityState, + pub capabilities: TraderCapabilitiesView, + pub trader_key: String, + pub trader_pda_index: u8, + pub trader_subaccount_index: u8, + pub authority: String, + + pub collateral_balance: Decimal, + pub effective_collateral: Decimal, + pub effective_collateral_for_withdrawals: Decimal, + pub unrealized_pnl: Decimal, + pub discounted_unrealized_pnl: Decimal, + pub unsettled_funding_owed: Decimal, + pub accumulated_funding: Decimal, + pub portfolio_value: Decimal, + pub maintenance_margin: Decimal, + pub cancel_margin: Decimal, + pub initial_margin: Decimal, + pub initial_margin_for_withdrawals: Decimal, + pub risk_state: RiskState, + pub risk_tier: RiskTier, + + pub positions: Vec, + pub limit_orders: std::collections::HashMap>, + pub maker_fee_override_multiplier: f64, + pub taker_fee_override_multiplier: f64, + + pub max_positions: u64, + pub last_deposit_slot: u64, + + pub is_in_active_traders: bool, +} + +/// Response wrapper for the `/trader/{authority}/state` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateResponse { + pub slot: u64, + pub slot_index: u32, + pub authority: String, + pub pda_index: u8, + pub traders: Vec, +} + +impl TraderStateResponse { + /// Find an isolated subaccount for the given asset. + /// + /// Prefers a subaccount with an existing position in this asset. Falls back + /// to the first empty isolated subaccount if none match. + pub fn isolated_subaccount_for_asset(&self, symbol: &str) -> Option<&TraderView> { + if let Some(t) = self.traders.iter().find(|t| { + t.trader_subaccount_index > 0 && t.positions.iter().any(|p| p.symbol == symbol) + }) { + return Some(t); + } + self.traders + .iter() + .find(|t| t.trader_subaccount_index > 0 && t.positions.is_empty()) + } + + /// Find the next available isolated subaccount slot and return its + /// `TraderKey`. + /// + /// Collects all registered subaccount indexes and returns a `TraderKey` for + /// the first in 1..=255 that is unused. Returns `None` if all 255 slots are + /// occupied or the authority string fails to parse. + pub fn get_next_isolated_subaccount_key(&self) -> Option { + let authority: Pubkey = self.authority.parse().ok()?; + let registered: std::collections::HashSet = self + .traders + .iter() + .map(|t| t.trader_subaccount_index) + .collect(); + let idx = (1..=255u8).find(|idx| !registered.contains(idx))?; + Some(TraderKey::new_with_idx(authority, self.pda_index, idx)) + } +} diff --git a/container/vendor/rise/rust/types/src/trader_key.rs b/container/vendor/rise/rust/types/src/trader_key.rs new file mode 100644 index 00000000000..7eda297fa91 --- /dev/null +++ b/container/vendor/rise/rust/types/src/trader_key.rs @@ -0,0 +1,106 @@ +//! Trader key identification and PDA derivation. + +use solana_pubkey::Pubkey; + +/// The Phoenix Eternal program ID (mainnet). +pub const ETERNAL_PROGRAM_ID: Pubkey = + solana_pubkey::pubkey!("EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih"); + +/// Subaccount index for the cross-margin (primary) account. +pub const CROSS_MARGIN_SUBACCOUNT_IDX: u8 = 0; + +/// Identifies a trader on Phoenix by authority pubkey and PDA indices. +#[derive(Debug, Clone)] +pub struct TraderKey { + /// The authority pubkey (wallet address). + pub authority: Pubkey, + /// The PDA index for this trader (0-255). + pub pda_index: u8, + /// The subaccount index (0 for cross-margin main account, 1+ for isolated + /// subaccounts). + pub subaccount_index: u8, +} + +impl TraderKey { + pub fn derive_pda(authority: &Pubkey, pda_index: u8, subaccount_index: u8) -> Pubkey { + let pda_schema = [pda_index, subaccount_index]; + let (pda, _bump) = Pubkey::find_program_address( + &[b"trader", authority.as_ref(), pda_schema.as_ref()], + &ETERNAL_PROGRAM_ID, + ); + pda + } + + pub fn new(authority: Pubkey) -> Self { + Self { + authority, + pda_index: 0, + subaccount_index: 0, + } + } + + pub fn new_with_idx(authority: Pubkey, pda_index: u8, subaccount_index: u8) -> Self { + Self { + authority, + pda_index, + subaccount_index, + } + } + + pub fn from_authority(authority: Pubkey) -> Self { + Self { + authority, + pda_index: 0, + subaccount_index: 0, + } + } + + pub fn from_authority_with_idx(authority: Pubkey, pda_index: u8, subaccount_index: u8) -> Self { + Self { + authority, + pda_index, + subaccount_index, + } + } + + pub fn pda(&self) -> Pubkey { + Self::derive_pda(&self.authority, self.pda_index, self.subaccount_index) + } + + pub fn authority(&self) -> Pubkey { + self.authority + } + + pub fn authority_string(&self) -> String { + self.authority.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trader_key_pda_derivation() { + let authority = Pubkey::new_unique(); + let key = TraderKey::new(authority); + + let pda1 = key.pda(); + let pda2 = key.pda(); + assert_eq!(pda1, pda2); + + assert_eq!(key.authority(), authority); + + assert_eq!(key.pda_index, 0); + assert_eq!(key.subaccount_index, 0); + } + + #[test] + fn test_new_with_idx() { + let authority = Pubkey::new_unique(); + let key = TraderKey::new_with_idx(authority, 5, 3); + + assert_eq!(key.pda_index, 5); + assert_eq!(key.subaccount_index, 3); + } +} diff --git a/container/vendor/rise/rust/types/src/trader_state.rs b/container/vendor/rise/rust/types/src/trader_state.rs new file mode 100644 index 00000000000..58f133bf88e --- /dev/null +++ b/container/vendor/rise/rust/types/src/trader_state.rs @@ -0,0 +1,433 @@ +//! Trader state container with snapshot and delta handling. + +use std::collections::HashMap; + +use phoenix_math_utils::{ + BaseLots, LimitOrder as MarginLimitOrder, SequenceNumberU8, SignedBaseLots, SignedQuoteLots, + SignedQuoteLotsI56, SignedQuoteLotsPerBaseLot, Ticks, TraderPortfolio, TraderPosition, + WrapperNum, +}; +use rust_decimal::Decimal; +use tracing::{debug, warn}; + +use crate::core::Side; +use crate::trader_key::TraderKey; +use crate::{ + CooldownStatus, TraderStateCapabilities, TraderStateMarketLimitOrderEvent, TraderStatePayload, + TraderStatePositionRow, TraderStatePositionSnapshot, TraderStateRowChangeKind, + TraderStateServerMessage, TraderStateSplineRow, TraderStateSplineSnapshot, + TraderStateSubaccountDelta, TraderStateSubaccountSnapshot, +}; + +/// A position held by the trader in a specific market. +#[derive(Debug, Clone)] +pub struct Position { + pub symbol: String, + pub base_position_lots: i64, + pub entry_price_ticks: i64, + pub entry_price_usd: Decimal, + pub virtual_quote_position_lots: i64, + pub unsettled_funding_quote_lots: i64, + pub accumulated_funding_quote_lots: i64, +} + +impl Position { + fn from_snapshot(snapshot: &TraderStatePositionSnapshot) -> Self { + Self::from_row(&snapshot.symbol, &snapshot.position) + } + + fn from_row(symbol: &str, row: &TraderStatePositionRow) -> Self { + Self { + symbol: symbol.to_string(), + base_position_lots: row.base_position_lots.parse().unwrap_or(0), + entry_price_ticks: row.entry_price_ticks.parse().unwrap_or(0), + entry_price_usd: row.entry_price_usd.parse().unwrap_or(Decimal::ZERO), + virtual_quote_position_lots: row.virtual_quote_position_lots.parse().unwrap_or(0), + unsettled_funding_quote_lots: row.unsettled_funding_quote_lots.parse().unwrap_or(0), + accumulated_funding_quote_lots: row.accumulated_funding_quote_lots.parse().unwrap_or(0), + } + } + + /// Convert to TraderPosition for margin calculations. + pub fn to_trader_position(&self) -> TraderPosition { + TraderPosition { + base_lot_position: SignedBaseLots::new(self.base_position_lots), + virtual_quote_lot_position: SignedQuoteLots::new(self.virtual_quote_position_lots), + cumulative_funding_snapshot: SignedQuoteLotsPerBaseLot::ZERO, + position_sequence_number: SequenceNumberU8::default(), + accumulated_funding_for_active_position: SignedQuoteLotsI56::default(), + } + } +} + +/// A limit order on the orderbook. +#[derive(Debug, Clone)] +pub struct LimitOrder { + pub symbol: String, + pub order_sequence_number: u64, + pub side: String, + pub order_type: String, + pub price_ticks: i64, + pub price_usd: Decimal, + pub size_remaining_lots: u64, + pub initial_size_lots: u64, + pub reduce_only: bool, + pub is_stop_loss: bool, + pub status: String, +} + +impl LimitOrder { + fn from_event(symbol: &str, event: &TraderStateMarketLimitOrderEvent) -> Self { + Self { + symbol: symbol.to_string(), + order_sequence_number: event.order_sequence_number.parse().unwrap_or(0), + side: format!("{:?}", event.side), + order_type: event.order_type.clone(), + price_ticks: event.price_ticks.parse().unwrap_or(0), + price_usd: event.price_usd.parse().unwrap_or(Decimal::ZERO), + size_remaining_lots: event.size_remaining_lots.parse().unwrap_or(0), + initial_size_lots: event.initial_size_lots.parse().unwrap_or(0), + reduce_only: event.reduce_only, + is_stop_loss: event.is_stop_loss, + status: event.status.clone(), + } + } +} + +/// A spline (market making curve) for a specific market. +#[derive(Debug, Clone)] +pub struct Spline { + pub symbol: String, + pub mid_price_ticks: i64, + pub bid_filled_amount_lots: i64, + pub ask_filled_amount_lots: i64, +} + +impl Spline { + fn from_snapshot(snapshot: &TraderStateSplineSnapshot) -> Self { + Self::from_row(&snapshot.symbol, &snapshot.spline) + } + + fn from_row(symbol: &str, row: &TraderStateSplineRow) -> Self { + Self { + symbol: symbol.to_string(), + mid_price_ticks: row.mid_price_ticks.parse().unwrap_or(0), + bid_filled_amount_lots: row.bid_filled_amount_lots.parse().unwrap_or(0), + ask_filled_amount_lots: row.ask_filled_amount_lots.parse().unwrap_or(0), + } + } +} + +/// State for a single subaccount. +#[derive(Debug, Clone, Default)] +pub struct SubaccountState { + pub subaccount_index: u8, + pub sequence: u64, + pub collateral: SignedQuoteLots, + pub capabilities: Option, + pub cooldown_status: Option, + /// Positions keyed by market symbol. + pub positions: HashMap, + /// Orders keyed by (symbol, order_sequence_number). + pub orders: HashMap<(String, u64), LimitOrder>, + /// Splines keyed by market symbol. + pub splines: HashMap, +} + +impl SubaccountState { + fn new(subaccount_index: u8) -> Self { + Self { + subaccount_index, + ..Default::default() + } + } + + /// Build a TraderPortfolio from this subaccount's positions and orders. + pub fn to_trader_portfolio(&self) -> TraderPortfolio { + let mut builder = TraderPortfolio::builder().quote_lot_collateral(self.collateral); + + for (symbol, position) in &self.positions { + builder = builder.position(symbol, position.to_trader_position()); + } + + // Group orders by symbol and convert to margin LimitOrders + let mut orders_by_symbol: HashMap> = HashMap::new(); + for ((symbol, _), order) in &self.orders { + let side = match order.side.as_str() { + "Buy" => Side::Bid, + _ => Side::Ask, + }; + orders_by_symbol + .entry(symbol.clone()) + .or_default() + .push(MarginLimitOrder { + price: Ticks::new(order.price_ticks as u64), + side, + order_sequence_number: order.order_sequence_number, + base_lot_size: BaseLots::new(order.size_remaining_lots), + initial_trade_size: BaseLots::new(order.initial_size_lots), + reduce_only: order.reduce_only, + is_stop_loss: order.is_stop_loss, + }); + } + for (symbol, orders) in orders_by_symbol { + builder = builder.limit_orders(symbol, orders); + } + + builder.build() + } + + fn apply_snapshot(&mut self, snapshot: &TraderStateSubaccountSnapshot) { + self.sequence = snapshot.sequence; + self.collateral = snapshot + .collateral + .parse::() + .map(SignedQuoteLots::new) + .unwrap_or(SignedQuoteLots::ZERO); + self.capabilities = snapshot.capabilities.clone(); + self.cooldown_status = snapshot.cooldown_status.clone(); + + self.positions.clear(); + for pos in &snapshot.positions { + let position = Position::from_snapshot(pos); + self.positions.insert(pos.symbol.clone(), position); + } + + self.orders.clear(); + for order_group in &snapshot.orders { + for order in &order_group.orders { + let limit_order = LimitOrder::from_event(&order_group.symbol, order); + self.orders.insert( + ( + order_group.symbol.clone(), + limit_order.order_sequence_number, + ), + limit_order, + ); + } + } + + self.splines.clear(); + for spline in &snapshot.splines { + let s = Spline::from_snapshot(spline); + self.splines.insert(spline.symbol.clone(), s); + } + } + + fn apply_delta(&mut self, delta: &TraderStateSubaccountDelta) -> bool { + if delta.sequence <= self.sequence { + warn!( + "Ignoring stale delta: received sequence {} but current is {}", + delta.sequence, self.sequence + ); + return false; + } + + self.sequence = delta.sequence; + self.collateral = delta + .collateral + .parse::() + .map(SignedQuoteLots::new) + .unwrap_or(self.collateral); + if delta.capabilities.is_some() { + self.capabilities = delta.capabilities.clone(); + } + if delta.cooldown_status.is_some() { + self.cooldown_status = delta.cooldown_status.clone(); + } + + for pos_delta in &delta.positions { + match pos_delta.change { + TraderStateRowChangeKind::Closed => { + self.positions.remove(&pos_delta.symbol); + } + TraderStateRowChangeKind::Updated => { + if let Some(row) = &pos_delta.position { + let position = Position::from_row(&pos_delta.symbol, row); + self.positions.insert(pos_delta.symbol.clone(), position); + } + } + } + } + + for order_group in &delta.orders { + for order in &order_group.orders { + let osn: u64 = order.order_sequence_number.parse().unwrap_or(0); + let key = (order_group.symbol.clone(), osn); + + match order.change { + Some(TraderStateRowChangeKind::Closed) => { + self.orders.remove(&key); + } + Some(TraderStateRowChangeKind::Updated) | None => { + let limit_order = LimitOrder::from_event(&order_group.symbol, order); + self.orders.insert(key, limit_order); + } + } + } + } + + for spline_delta in &delta.splines { + match spline_delta.change { + TraderStateRowChangeKind::Closed => { + self.splines.remove(&spline_delta.symbol); + } + TraderStateRowChangeKind::Updated => { + if let Some(row) = &spline_delta.spline { + let spline = Spline::from_row(&spline_delta.symbol, row); + self.splines.insert(spline_delta.symbol.clone(), spline); + } + } + } + } + + true + } +} + +/// Complete trader state across all subaccounts. +#[derive(Debug, Clone)] +pub struct Trader { + pub key: TraderKey, + pub last_slot: u64, + pub maker_fee_override_multiplier: f64, + pub taker_fee_override_multiplier: f64, + pub capabilities: Option, + pub subaccounts: HashMap, +} + +impl Trader { + pub fn new(key: TraderKey) -> Self { + Self { + key, + last_slot: 0, + maker_fee_override_multiplier: 1.0, + taker_fee_override_multiplier: 1.0, + capabilities: None, + subaccounts: HashMap::new(), + } + } + + pub fn apply_update(&mut self, msg: &TraderStateServerMessage) { + self.last_slot = msg.slot; + + match &msg.content { + TraderStatePayload::Snapshot(snapshot) => { + debug!("Applying snapshot at slot {}", msg.slot); + self.maker_fee_override_multiplier = snapshot.maker_fee_override_multiplier; + self.taker_fee_override_multiplier = snapshot.taker_fee_override_multiplier; + self.capabilities = Some(snapshot.capabilities.clone()); + + self.subaccounts.clear(); + for sub_snapshot in &snapshot.subaccounts { + let mut subaccount = SubaccountState::new(sub_snapshot.subaccount_index); + subaccount.apply_snapshot(sub_snapshot); + self.subaccounts + .insert(sub_snapshot.subaccount_index, subaccount); + } + } + TraderStatePayload::Delta(delta) => { + debug!("Applying delta at slot {}", msg.slot); + for sub_delta in &delta.deltas { + let subaccount = self + .subaccounts + .entry(sub_delta.subaccount_index) + .or_insert_with(|| SubaccountState::new(sub_delta.subaccount_index)); + subaccount.apply_delta(sub_delta); + } + } + } + } + + pub fn total_collateral(&self) -> SignedQuoteLots { + self.subaccounts + .values() + .fold(SignedQuoteLots::ZERO, |acc, s| acc + s.collateral) + } + + pub fn all_positions(&self) -> Vec<&Position> { + self.subaccounts + .values() + .flat_map(|s| s.positions.values()) + .collect() + } + + pub fn all_orders(&self) -> Vec<&LimitOrder> { + self.subaccounts + .values() + .flat_map(|s| s.orders.values()) + .collect() + } + + pub fn subaccount(&self, index: u8) -> Option<&SubaccountState> { + self.subaccounts.get(&index) + } + + pub fn primary_subaccount(&self) -> Option<&SubaccountState> { + self.subaccount(0) + } + + /// Return a `TraderKey` for the given subaccount index, inheriting this + /// trader's authority and PDA index. + pub fn subaccount_key(&self, subaccount_index: u8) -> TraderKey { + TraderKey::new_with_idx(self.key.authority, self.key.pda_index, subaccount_index) + } + + /// Find an isolated subaccount for the given asset. + /// + /// Prefers a subaccount with an existing position in this asset. Falls back + /// to the empty isolated subaccount with the greatest collateral. + pub fn isolated_subaccount_for_asset(&self, symbol: &str) -> Option<&SubaccountState> { + if let Some(s) = self + .subaccounts + .values() + .find(|s| s.subaccount_index > 0 && s.positions.contains_key(symbol)) + { + return Some(s); + } + self.subaccounts + .values() + .filter(|s| s.subaccount_index > 0 && s.positions.is_empty() && s.orders.is_empty()) + .max_by_key(|s| s.collateral) + } + + /// Try to find an existing isolated subaccount for `symbol`, falling back + /// to the next available slot. Returns `None` if no suitable subaccount + /// exists and all slots are occupied. + pub fn get_or_create_isolated_subaccount_key(&self, symbol: &str) -> Option { + if let Some(sub) = self.isolated_subaccount_for_asset(symbol) { + return Some(self.subaccount_key(sub.subaccount_index)); + } + self.get_next_isolated_subaccount_key() + } + + /// Returns whether the given subaccount index is registered. + pub fn subaccount_exists(&self, subaccount_index: u8) -> bool { + self.subaccounts.contains_key(&subaccount_index) + } + + pub fn get_collateral_for_subaccount(&self, subaccount_index: u8) -> SignedQuoteLots { + self.subaccounts + .get(&subaccount_index) + .map(|s| s.collateral) + .unwrap_or(SignedQuoteLots::ZERO) + } + + /// Find the next available isolated subaccount slot and return its + /// `TraderKey`. + /// + /// Scans subaccount indexes 1..=255 and returns the first one not already + /// registered. Returns `None` if all 255 isolated slots are occupied. + pub fn get_next_isolated_subaccount_key(&self) -> Option { + for idx in 1..=255u8 { + if !self.subaccounts.contains_key(&idx) { + return Some(TraderKey::new_with_idx( + self.key.authority(), + self.key.pda_index, + idx, + )); + } + } + None + } +} diff --git a/container/vendor/rise/rust/types/src/trades.rs b/container/vendor/rise/rust/types/src/trades.rs new file mode 100644 index 00000000000..160958a54a2 --- /dev/null +++ b/container/vendor/rise/rust/types/src/trades.rs @@ -0,0 +1,206 @@ +//! Trade types for Phoenix WebSocket protocol. +//! +//! These types represent real-time trade events streamed via WebSocket. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::core::Side; +use crate::js_safe_ints::JsSafeU64; + +/// Trades message from the trades channel (wrapper with array of events). +/// +/// The trades channel sends messages containing the symbol and an array of +/// trade events. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TradesMessage { + /// Market symbol (e.g., "SOL"). + pub symbol: String, + /// Array of trade events. + pub trades: Vec, +} + +/// Individual trade event from the trades channel. +/// +/// Represents a single trade with price, quantity, and metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TradeEvent { + /// Slot when the trade occurred. + pub slot: JsSafeU64, + /// Index within the slot. + pub slot_index: u32, + /// Timestamp of the trade (RFC3339). + pub timestamp: DateTime, + /// Market symbol (e.g., "SOL"). + pub symbol: String, + /// Taker authority pubkey. + pub taker: String, + /// Monotonically increasing trade sequence number. + pub trade_sequence_number: JsSafeU64, + /// Side of the taker order. + pub side: Side, + /// Base lots filled. + pub base_lots_filled: JsSafeU64, + /// Quote lots filled. + pub quote_lots_filled: JsSafeU64, + /// Fee in quote lots. + pub fee_in_quote_lots: JsSafeU64, + /// Human-readable base amount. + pub base_amount: f64, + /// Human-readable quote amount. + pub quote_amount: f64, + /// Number of fills in this trade. + pub num_fills: u32, +} + +/// Subscription request for the trades channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct TradesSubscriptionRequest { + /// Market symbol to filter trades (e.g., "SOL"). + pub symbol: String, +} + +// ============================================================================ +// Trade History Types (HTTP API) +// ============================================================================ + +/// Individual trade record from the trade history endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TradeHistoryItem { + /// Market symbol associated with the fill (e.g., "SOL"). + pub market_symbol: String, + /// Human-readable base quantity. + pub base_qty: String, + /// Human-readable quote quantity. + pub quote_qty: String, + /// Human-readable price derived from the fill quantities. + pub price: String, + /// Timestamp of the fill (ISO 8601). + pub timestamp: String, + /// Transaction signature containing the fill. + pub transaction_signature: String, + /// Instruction type that emitted this fill (e.g., PlaceMarketOrder, + /// LiquidateViaMarketOrder). + pub instruction_type: String, +} + +/// Response from the trade history endpoint. +pub type TradeHistoryResponse = crate::core::PaginatedResponse>; + +/// Query parameters for fetching trader trade history. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TradeHistoryQueryParams { + /// PDA index for the trader account. + #[serde(default, alias = "pdaIndex", alias = "pda_index")] + pub pda_index: u8, + /// Optional market symbol filter (e.g., "SOL"). + #[serde(skip_serializing_if = "Option::is_none")] + pub market_symbol: Option, + /// Number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Cursor for pagination (base64-encoded). + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl TradeHistoryQueryParams { + /// Creates new query params with default values. + pub fn new() -> Self { + Self::default() + } + + /// Sets the PDA index. + pub fn with_pda_index(mut self, pda_index: u8) -> Self { + self.pda_index = pda_index; + self + } + + /// Sets the market symbol filter. + pub fn with_market_symbol(mut self, symbol: impl Into) -> Self { + self.market_symbol = Some(symbol.into()); + self + } + + /// Sets the limit. + pub fn with_limit(mut self, limit: i64) -> Self { + self.limit = Some(limit); + self + } + + /// Sets the cursor for pagination. + pub fn with_cursor(mut self, cursor: impl Into) -> Self { + self.cursor = Some(cursor.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_trade_event() { + let json = r#"{ + "slot": "123456789", + "slotIndex": 5, + "timestamp": "2026-02-03T16:12:03Z", + "symbol": "SOL", + "taker": "ABC123pubkey", + "tradeSequenceNumber": "100", + "side": "bid", + "baseLotsFilled": "1000", + "quoteLotsFilled": "150000", + "feeInQuoteLots": "30", + "baseAmount": 10.0, + "quoteAmount": 1500.0, + "numFills": 2 + }"#; + + let trade: TradeEvent = serde_json::from_str(json).unwrap(); + assert_eq!(trade.symbol, "SOL"); + assert_eq!(trade.base_amount, 10.0); + assert_eq!(trade.quote_amount, 1500.0); + assert_eq!(trade.num_fills, 2); + assert_eq!(trade.side, Side::Bid); + } + + #[test] + fn test_serialize_trade_event() { + let trade = TradeEvent { + slot: 123456789u64.into(), + slot_index: 5, + timestamp: "2026-02-03T16:12:03Z".parse().unwrap(), + symbol: "SOL".to_string(), + taker: "ABC123pubkey".to_string(), + trade_sequence_number: 100u64.into(), + side: Side::Ask, + base_lots_filled: 1000u64.into(), + quote_lots_filled: 150000u64.into(), + fee_in_quote_lots: 30u64.into(), + base_amount: 10.0, + quote_amount: 1500.0, + num_fills: 2, + }; + + let json = serde_json::to_string(&trade).unwrap(); + assert!(json.contains("\"symbol\":\"SOL\"")); + assert!(json.contains("\"baseAmount\":10")); + assert!(json.contains("\"side\":\"ask\"")); + } + + #[test] + fn test_trades_subscription_request() { + let req = TradesSubscriptionRequest { + symbol: "SOL".to_string(), + }; + + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"symbol\":\"SOL\"")); + } +} diff --git a/container/vendor/rise/rust/types/src/ws.rs b/container/vendor/rise/rust/types/src/ws.rs new file mode 100644 index 00000000000..52e1a623c13 --- /dev/null +++ b/container/vendor/rise/rust/types/src/ws.rs @@ -0,0 +1,300 @@ +//! WebSocket protocol types for Phoenix API. +//! +//! These types handle subscription management, client/server message +//! envelopes, and error responses. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::candles::{CandleData, Timeframe}; +use crate::market::{L2BookUpdate, MarketStatsUpdate}; +use crate::trader::TraderStateServerMessage; +use crate::trades::{TradesMessage, TradesSubscriptionRequest}; + +// ============================================================================ +// Subscription Types +// ============================================================================ + +/// Subscription request for the funding-rate channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct FundingRateSubscriptionRequest { + /// Market symbol (e.g., "SOL" or "BTC") + pub symbol: String, +} + +/// Subscription request for the orderbook channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct OrderbookSubscriptionRequest { + /// Market symbol (e.g., "SOL" or "BTC") + pub symbol: String, +} + +/// Subscription request for the trader-state channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct TraderStateSubscriptionRequest { + pub authority: String, + pub trader_pda_index: u8, +} + +/// Subscription request for the market channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct MarketSubscriptionRequest { + /// Market symbol (e.g., "SOL" or "BTC") + pub symbol: String, +} + +/// Subscription request for the candles channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct CandlesSubscriptionRequest { + pub symbol: String, + pub timeframe: Timeframe, +} + +/// Subscription request from client. +#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)] +#[serde(tag = "channel")] +pub enum SubscriptionRequest { + #[serde(rename = "allMids")] + AllMids, + #[serde(rename = "fundingRate")] + FundingRate(FundingRateSubscriptionRequest), + #[serde(rename = "orderbook")] + Orderbook(OrderbookSubscriptionRequest), + #[serde(rename = "traderState")] + TraderState(TraderStateSubscriptionRequest), + #[serde(rename = "market")] + Market(MarketSubscriptionRequest), + #[serde(rename = "trades")] + Trades(TradesSubscriptionRequest), + #[serde(rename = "candles")] + Candles(CandlesSubscriptionRequest), + /// Other subscription types exist but are not used by this SDK. + #[serde(other)] + Other, +} + +// ============================================================================ +// Client Messages +// ============================================================================ + +/// WebSocket message types from client to server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ClientMessage { + #[serde(rename = "subscribe")] + Subscribe { subscription: SubscriptionRequest }, + #[serde(rename = "unsubscribe")] + Unsubscribe { subscription: SubscriptionRequest }, +} + +// ============================================================================ +// Server Messages +// ============================================================================ + +/// Mid price snapshot for all markets. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AllMidsData { + pub mids: HashMap, + pub slot: u64, + pub slot_index: u32, +} + +/// Funding rate update for a market. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FundingRateMessage { + pub symbol: String, + pub funding: f64, +} + +/// WebSocket message types from server to client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "channel")] +#[serde(rename_all = "camelCase")] +pub enum ServerMessage { + #[serde(rename = "allMids")] + AllMids(AllMidsData), + #[serde(rename = "fundingRate")] + FundingRate(FundingRateMessage), + #[serde(rename = "orderbook")] + Orderbook(L2BookUpdate), + #[serde(rename = "traderState")] + TraderState(TraderStateServerMessage), + #[serde(rename = "market")] + Market(MarketStatsUpdate), + #[serde(rename = "trades")] + Trades(TradesMessage), + #[serde(rename = "candles")] + Candles(CandleData), + #[serde(rename = "error")] + Error(ErrorMessage), + /// Other message types exist but are not used by this SDK. + #[serde(other)] + Other, +} + +/// Subscription confirmed message from server. +/// Expected format: `{"type":"subscriptionConfirmed","subscription":{...}}` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename = "subscriptionConfirmed")] +pub struct SubscriptionConfirmedMessage { + pub subscription: SubscriptionRequest, +} + +/// Subscription error message from server. +/// Expected format: +/// `{"type":"subscriptionError","subscription":{...},"code":"...","message":".. +/// ."}` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename = "subscriptionError")] +pub struct SubscriptionErrorMessage { + pub subscription: SubscriptionRequest, + pub code: String, + pub message: String, +} + +/// Error message from server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorMessage { + pub error: String, + pub code: u16, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_client_message() { + let json = r#"{ + "type": "subscribe", + "subscription": { + "channel": "traderState", + "authority": "ABC123", + "traderPdaIndex": 0 + } + }"#; + + let msg: ClientMessage = serde_json::from_str(json).unwrap(); + assert!(matches!(msg, ClientMessage::Subscribe { .. })); + } + + #[test] + fn test_serialize_client_message() { + let msg = ClientMessage::Subscribe { + subscription: SubscriptionRequest::TraderState(TraderStateSubscriptionRequest { + authority: "ABC123".to_string(), + trader_pda_index: 0, + }), + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("subscribe")); + assert!(json.contains("traderState")); + } + + #[test] + fn test_orderbook_subscription_request() { + let msg = ClientMessage::Subscribe { + subscription: SubscriptionRequest::Orderbook(OrderbookSubscriptionRequest { + symbol: "SOL".to_string(), + }), + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("subscribe")); + assert!(json.contains("orderbook")); + assert!(json.contains("SOL")); + } + + #[test] + fn test_deserialize_orderbook_server_message() { + let json = r#"{ + "channel": "orderbook", + "symbol": "SOL", + "orderbook": { + "bids": [[150.25, 100.0], [150.20, 200.0]], + "asks": [[150.30, 150.0], [150.35, 250.0]], + "mid": 150.275 + } + }"#; + + let msg: ServerMessage = serde_json::from_str(json).unwrap(); + if let ServerMessage::Orderbook(update) = msg { + assert_eq!(update.symbol, "SOL"); + assert_eq!(update.orderbook.bids.len(), 2); + assert_eq!(update.orderbook.asks.len(), 2); + assert_eq!(update.orderbook.mid, Some(150.275)); + } else { + panic!("Expected Orderbook message"); + } + } + + #[test] + fn test_deserialize_funding_rate_server_message() { + let json = r#"{ + "channel": "fundingRate", + "symbol": "SOL", + "funding": 0.0125 + }"#; + + let msg: ServerMessage = serde_json::from_str(json).unwrap(); + if let ServerMessage::FundingRate(update) = msg { + assert_eq!(update.symbol, "SOL"); + assert_eq!(update.funding, 0.0125); + } else { + panic!("Expected FundingRate message"); + } + } + + #[test] + fn test_deserialize_trades_server_message() { + let json = r#"{ + "channel": "trades", + "symbol": "SOL", + "trades": [{ + "slot": "123456789", + "slotIndex": 5, + "timestamp": "2026-02-03T16:12:03Z", + "symbol": "SOL", + "taker": "ABC123pubkey", + "tradeSequenceNumber": "100", + "side": "bid", + "baseLotsFilled": "1000", + "quoteLotsFilled": "150000", + "feeInQuoteLots": "30", + "baseAmount": 10.0, + "quoteAmount": 1500.0, + "numFills": 2 + }] + }"#; + + let msg: ServerMessage = serde_json::from_str(json).unwrap(); + if let ServerMessage::Trades(update) = msg { + assert_eq!(update.symbol, "SOL"); + assert_eq!(update.trades.len(), 1); + } else { + panic!("Expected Trades message"); + } + } + + #[test] + fn test_serialize_candles_subscription_request() { + let req = CandlesSubscriptionRequest { + symbol: "SOL".to_string(), + timeframe: Timeframe::Minute1, + }; + + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"symbol\":\"SOL\"")); + assert!(json.contains("\"timeframe\":\"1m\"")); + } +} diff --git a/container/vendor/rise/rust/types/src/ws_error.rs b/container/vendor/rise/rust/types/src/ws_error.rs new file mode 100644 index 00000000000..8a95a1fe335 --- /dev/null +++ b/container/vendor/rise/rust/types/src/ws_error.rs @@ -0,0 +1,47 @@ +//! WebSocket error types for the Phoenix SDK. + +use thiserror::Error; + +/// Errors that can occur when using the Phoenix WebSocket SDK. +#[derive(Debug, Error)] +pub enum PhoenixWsError { + /// Failed to connect to the WebSocket server. + #[error("WebSocket connection failed: {0}")] + ConnectionFailed(#[from] tokio_tungstenite::tungstenite::Error), + + /// Failed to parse the WebSocket URL. + #[error("Invalid WebSocket URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// Unsupported URL scheme in configuration. + #[error("Unsupported URL scheme: {0}")] + UnsupportedUrlScheme(String), + + /// Invalid header value. + #[error("Invalid header value: {0}")] + InvalidHeaderValue(String), + + /// Failed to serialize a message. + #[error("Failed to serialize message: {0}")] + SerializationFailed(#[from] serde_json::Error), + + /// The subscription channel was closed unexpectedly. + #[error("Subscription channel closed")] + SubscriptionClosed, + + /// The WebSocket connection was closed. + #[error("WebSocket connection closed: code={code}, reason={reason}")] + ConnectionClosed { code: u16, reason: String }, + + /// Failed to send a message on the WebSocket. + #[error("Failed to send WebSocket message")] + SendFailed, + + /// Invalid trader key configuration. + #[error("Invalid trader key: {0}")] + InvalidTraderKey(String), + + /// Missing environment variable. + #[error("Missing environment variable: {0}")] + MissingEnvVar(String), +} diff --git a/container/vendor/rise/rustfmt.toml b/container/vendor/rise/rustfmt.toml new file mode 100644 index 00000000000..4b93e32f998 --- /dev/null +++ b/container/vendor/rise/rustfmt.toml @@ -0,0 +1,12 @@ +enum_discrim_align_threshold = 60 +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +imports_granularity = "Module" +normalize_comments = true +reorder_impl_items = true +style_edition = "2024" +unstable_features = true +use_field_init_shorthand = true +wrap_comments = true diff --git a/container/vendor/rise/scripts/publish_repo_snapshot.py b/container/vendor/rise/scripts/publish_repo_snapshot.py new file mode 100755 index 00000000000..15a6a157531 --- /dev/null +++ b/container/vendor/rise/scripts/publish_repo_snapshot.py @@ -0,0 +1,285 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// + +""" +Create and upload a git-history-free repository snapshot to S3. + +Workflow: +1) Ensure AWS credentials are valid (or trigger `aws sso login`) +2) Ensure the git worktree is clean +3) Ensure the repository is on the target branch (default: master) +4) Create a zip snapshot from git-tracked files only +5) Remove excluded paths (for example `scripts/`) from the archive +6) Upload the archive to S3 with commit hash in the object name +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import Sequence + +EXCLUDED_ARCHIVE_PREFIXES = ("scripts/",) + + +def run( + cmd: Sequence[str], + *, + cwd: str | None = None, + check: bool = True, + capture_output: bool = True, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + cmd, + cwd=cwd, + text=True, + capture_output=capture_output, + ) + if check and result.returncode != 0: + pretty_cmd = " ".join(shlex.quote(part) for part in cmd) + stdout = result.stdout.strip() if result.stdout else "" + stderr = result.stderr.strip() if result.stderr else "" + details = "\n".join(part for part in [stdout, stderr] if part) + raise RuntimeError(f"Command failed: {pretty_cmd}\n{details}".strip()) + return result + + +def aws_base_cmd(profile: str | None) -> list[str]: + cmd = ["aws"] + if profile: + cmd.extend(["--profile", profile]) + return cmd + + +def ensure_aws_auth(profile: str | None, skip_sso_login: bool) -> None: + identity_cmd = aws_base_cmd(profile) + ["sts", "get-caller-identity", "--output", "json"] + identity = run(identity_cmd, check=False) + if identity.returncode == 0: + parsed = json.loads(identity.stdout) + arn = parsed.get("Arn", "") + print(f"AWS auth OK: {arn}") + return + + if skip_sso_login: + raise RuntimeError( + "AWS credentials are not valid and --skip-sso-login was provided. " + "Run `aws sso login` (or configure credentials) and retry." + ) + + login_cmd = aws_base_cmd(profile) + ["sso", "login"] + pretty_login = " ".join(shlex.quote(part) for part in login_cmd) + print(f"No valid AWS session found. Running: {pretty_login}") + login = subprocess.run(login_cmd, text=True) + if login.returncode != 0: + raise RuntimeError("AWS SSO login failed.") + + identity = run(identity_cmd, check=True) + parsed = json.loads(identity.stdout) + arn = parsed.get("Arn", "") + print(f"AWS auth OK after login: {arn}") + + +def get_repo_root(script_path: Path) -> Path: + script_dir = script_path.resolve().parent + result = run(["git", "rev-parse", "--show-toplevel"], cwd=str(script_dir)) + return Path(result.stdout.strip()) + + +def ensure_clean_worktree(repo_root: Path) -> None: + status = run(["git", "status", "--porcelain"], cwd=str(repo_root)) + if not status.stdout.strip(): + return + + preview_lines = status.stdout.strip().splitlines()[:20] + preview = "\n".join(preview_lines) + raise RuntimeError( + "Git worktree is not clean. Commit, stash, or remove changes before running.\n" + f"{preview}" + ) + + +def current_branch(repo_root: Path) -> str: + branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(repo_root)) + return branch.stdout.strip() + + +def ensure_on_branch(repo_root: Path, target_branch: str) -> None: + branch = current_branch(repo_root) + if branch == target_branch: + print(f"Git branch OK: {branch}") + return + + print(f"Switching branch from {branch} to {target_branch}") + run(["git", "switch", target_branch], cwd=str(repo_root), capture_output=False) + switched = current_branch(repo_root) + if switched != target_branch: + raise RuntimeError(f"Failed to switch to {target_branch}; currently on {switched}") + print(f"Git branch OK: {switched}") + + +def head_commit(repo_root: Path) -> str: + commit = run(["git", "rev-parse", "HEAD"], cwd=str(repo_root)) + return commit.stdout.strip() + + +def should_exclude_path(path: str) -> bool: + normalized = path.lstrip("./") + for prefix in EXCLUDED_ARCHIVE_PREFIXES: + clean_prefix = prefix.strip("/") + if normalized == clean_prefix or normalized.startswith(f"{clean_prefix}/"): + return True + return False + + +def create_archive(repo_root: Path, commit_hash: str, output_dir: Path) -> Path: + repo_name = repo_root.name + archive_path = output_dir / f"{repo_name}-{commit_hash}.zip" + raw_archive_path = output_dir / f".{repo_name}-{commit_hash}.raw.zip" + output_dir.mkdir(parents=True, exist_ok=True) + + run( + [ + "git", + "archive", + "--format=zip", + "--output", + str(raw_archive_path), + commit_hash, + ], + cwd=str(repo_root), + ) + + with zipfile.ZipFile(raw_archive_path, "r") as source_zip, zipfile.ZipFile( + archive_path, + "w", + compression=zipfile.ZIP_DEFLATED, + ) as final_zip: + for item_name in source_zip.namelist(): + if should_exclude_path(item_name): + continue + final_zip.writestr(item_name, source_zip.read(item_name)) + + raw_archive_path.unlink(missing_ok=True) + + with zipfile.ZipFile(archive_path, "r") as zf: + names = zf.namelist() + bad_git_paths = [name for name in names if name == ".git" or name.startswith(".git/")] + bad_excluded_paths = [name for name in names if should_exclude_path(name)] + + if bad_git_paths: + raise RuntimeError(f"Archive unexpectedly includes git metadata: {bad_git_paths}") + if bad_excluded_paths: + raise RuntimeError(f"Archive unexpectedly includes excluded paths: {bad_excluded_paths}") + + print(f"Created archive: {archive_path}") + return archive_path + + +def upload_to_s3(archive_path: Path, bucket: str, key: str, profile: str | None) -> str: + destination = f"s3://{bucket}/{key}" + cmd = aws_base_cmd(profile) + ["s3", "cp", str(archive_path), destination] + pretty_cmd = " ".join(shlex.quote(part) for part in cmd) + print(f"Uploading archive with: {pretty_cmd}") + upload = subprocess.run(cmd, text=True) + if upload.returncode != 0: + raise RuntimeError("S3 upload failed.") + return destination + +def cp_to_current(rise_s3_location: str, bucket: str, profile: str | None): + destination = f"s3://{bucket}/rise-current.zip" + cmd = aws_base_cmd(profile) + ["s3", "cp", rise_s3_location, destination] + upload = subprocess.run(cmd, text=True) + if upload.returncode != 0: + raise RuntimeError("S3 cp failed.") + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create a git-history-free repository zip and upload to S3." + ) + parser.add_argument( + "--bucket", + default="phoenix-rise-public-sdk", + help="Target S3 bucket name (default: phoenix-rise-public-sdk).", + ) + parser.add_argument( + "--profile", + default="ellipsis", + help="AWS CLI profile name (default: ellipsis).", + ) + parser.add_argument( + "--branch", + default="master", + help="Git branch to enforce before archiving (default: master).", + ) + parser.add_argument( + "--output-dir", + default="./dist", + help="Directory for the generated zip before upload (default: ./dist).", + ) + parser.add_argument( + "--skip-clean-worktree-check", + action="store_true", + help="Skip git clean-worktree enforcement before archiving.", + ) + parser.add_argument( + "--skip-sso-login", + action="store_true", + help="Do not call `aws sso login` when credentials are invalid.", + ) + parser.add_argument( + "--skip-upload", + action="store_true", + help="Create the archive but do not upload to S3.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + repo_root = get_repo_root(Path(__file__)) + output_dir_arg = Path(args.output_dir).expanduser() + output_dir = ( + (repo_root / output_dir_arg).resolve() + if not output_dir_arg.is_absolute() + else output_dir_arg.resolve() + ) + + print(f"Repository root: {repo_root}") + ensure_aws_auth(args.profile, args.skip_sso_login) + if not args.skip_clean_worktree_check: + ensure_clean_worktree(repo_root) + ensure_on_branch(repo_root, args.branch) + if not args.skip_clean_worktree_check: + ensure_clean_worktree(repo_root) + + commit_hash = head_commit(repo_root) + archive_path = create_archive(repo_root, commit_hash, output_dir) + key = archive_path.name + + if args.skip_upload: + print(f"Skipping upload. Archive is available at: {archive_path}") + print(f"Suggested S3 key: {key}") + return 0 + + destination = upload_to_s3(archive_path, args.bucket, key, args.profile) + destination = cp_to_current(destination, args.bucket, args.profile) + print(f"Upload complete: {destination}") + print(f"Public URL (if bucket/object is public): https://{args.bucket}.s3.amazonaws.com/{key}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/container/vendor/vulcan/.config/nextest.toml b/container/vendor/vulcan/.config/nextest.toml new file mode 100644 index 00000000000..91f52716fd2 --- /dev/null +++ b/container/vendor/vulcan/.config/nextest.toml @@ -0,0 +1,6 @@ +[profile.default] +slow-timeout = { period = "30s", terminate-after = 3 } + +[profile.ci] +slow-timeout = { period = "30s", terminate-after = 3 } +fail-fast = false diff --git a/container/vendor/vulcan/.github/actions/cargo-binstall/action.yaml b/container/vendor/vulcan/.github/actions/cargo-binstall/action.yaml new file mode 100644 index 00000000000..f374106bafe --- /dev/null +++ b/container/vendor/vulcan/.github/actions/cargo-binstall/action.yaml @@ -0,0 +1,44 @@ +name: cargo-binstall +description: Use cargo-binstall to install cargo binaries +inputs: + binaries: + description: The binaries to install + required: false +runs: + using: composite + steps: + - name: Hash binaries + id: hash-binaries + shell: bash + run: | + if command -v sha256sum >/dev/null 2>&1; then + hash=$(echo "${{ inputs.binaries }}" | sha256sum | awk '{print $1}') + else + hash=$(echo "${{ inputs.binaries }}" | shasum -a 256 | awk '{print $1}') + fi + echo "binaries-hash=$hash" >> $GITHUB_OUTPUT + - uses: actions/cache@v4 + name: Cache Cargo binaries + id: cache-cargo-binaries + with: + key: cargo-binaries-${{ steps.hash-binaries.outputs.binaries-hash }}-${{ runner.os }}-${{ runner.arch }} + path: | + ~/.cargo/bin/ + - name: Find missing binaries + id: find-missing-binaries + shell: bash + run: | + missing_binaries=() + for binary in ${{ inputs.binaries }}; do + if ! command -v $binary &> /dev/null; then + missing_binaries+=($binary) + fi + done + echo "missing_binaries=${missing_binaries[*]}" >> $GITHUB_OUTPUT + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@main + if: steps.find-missing-binaries.outputs.missing_binaries != '' + - name: Install missing binaries + if: steps.find-missing-binaries.outputs.missing_binaries != '' + shell: bash + run: cargo binstall --no-confirm ${{ steps.find-missing-binaries.outputs.missing_binaries }} diff --git a/container/vendor/vulcan/.github/actions/rust-cache/action.yaml b/container/vendor/vulcan/.github/actions/rust-cache/action.yaml new file mode 100644 index 00000000000..fc9e3aa73ea --- /dev/null +++ b/container/vendor/vulcan/.github/actions/rust-cache/action.yaml @@ -0,0 +1,36 @@ +name: Cache Rust dependencies +description: Cache Rust dependencies to speed up builds +inputs: + cache-name: + description: The name of the cache to use + required: true +runs: + using: composite + steps: + - uses: actions/cache@v4 + name: Cache Cargo registry + id: cache-cargo-registry + with: + key: cargo-registry-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}-${{ github.ref_name }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + cargo-registry-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}-main- + cargo-registry-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}- + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + - uses: actions/cache@v4 + name: Cache Cargo target + id: cache-cargo-target + with: + key: cargo-target-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}-${{ github.ref_name }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + cargo-target-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}-main- + cargo-target-${{ inputs.cache-name }}-${{ runner.os }}-${{ runner.arch }}- + path: | + target/** + - name: Set cache hit output + shell: bash + run: | + echo "target-cache-hit=${{ steps.cache-cargo-target.outputs.cache-hit }}" >> $GITHUB_OUTPUT + echo "registry-cache-hit=${{ steps.cache-cargo-registry.outputs.cache-hit }}" >> $GITHUB_OUTPUT diff --git a/container/vendor/vulcan/.github/actions/setup-r2-credentials/action.yml b/container/vendor/vulcan/.github/actions/setup-r2-credentials/action.yml new file mode 100644 index 00000000000..fe6c0e0bd9c --- /dev/null +++ b/container/vendor/vulcan/.github/actions/setup-r2-credentials/action.yml @@ -0,0 +1,18 @@ +name: "Setup R2 Credentials" +description: "Configure R2 credentials for CI" +inputs: + r2-access-key-id: + description: "R2 access key ID" + required: true + r2-secret-access-key: + description: "R2 secret access key" + required: true + +runs: + using: "composite" + steps: + - name: Configure R2 credentials + shell: bash + run: | + echo "AWS_ACCESS_KEY_ID=${{ inputs.r2-access-key-id }}" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=${{ inputs.r2-secret-access-key }}" >> $GITHUB_ENV diff --git a/container/vendor/vulcan/.github/dependabot.yml b/container/vendor/vulcan/.github/dependabot.yml new file mode 100644 index 00000000000..76a27164d85 --- /dev/null +++ b/container/vendor/vulcan/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/container/vendor/vulcan/.github/release.yml b/container/vendor/vulcan/.github/release.yml new file mode 100644 index 00000000000..06bb3d6ee65 --- /dev/null +++ b/container/vendor/vulcan/.github/release.yml @@ -0,0 +1,11 @@ +changelog: + categories: + - title: Features + labels: + - "*" + exclude: + labels: + - dependencies + - title: Dependencies + labels: + - dependencies diff --git a/container/vendor/vulcan/.github/workflows/build.yaml b/container/vendor/vulcan/.github/workflows/build.yaml new file mode 100644 index 00000000000..6ce5d8699d5 --- /dev/null +++ b/container/vendor/vulcan/.github/workflows/build.yaml @@ -0,0 +1,107 @@ +name: Run checks and tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always + NIX_SIGNING_PUBLIC_KEY: "ellipsis-labs:eug33YU0s2/K/BgiOtEta1cwNIzERtIybNATLOBsrEA=" + NIX_CACHE_URI: "s3://atlas-nix-cache?compression=zstd¶llel-compression=true&endpoint=6a2b885167c20bd5dd1d3bcb4b09760f.r2.cloudflarestorage.com" + +jobs: + check: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + - uses: ./.github/actions/rust-cache + with: + cache-name: check + - run: cargo check --locked --all-targets + + test: + runs-on: ${{ matrix.os }} + needs: [check] + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + - uses: ./.github/actions/rust-cache + with: + cache-name: test + - uses: ./.github/actions/cargo-binstall + with: + binaries: cargo-nextest + - run: cargo nextest run --locked --profile ci + + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + - uses: ./.github/actions/rust-cache + with: + cache-name: clippy + - run: cargo clippy --all-targets -- -D warnings + + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + - run: rustup toolchain install nightly-2025-07-08 --component rustfmt + - run: cargo +nightly-2025-07-08 fmt --check + + cross-build: + needs: [test, clippy, fmt] + uses: ./.github/workflows/cross-build.yaml + secrets: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + NIX_SIGNING_KEY: ${{ secrets.NIX_SIGNING_KEY }} + RISE_CHECKOUT_TOKEN: ${{ secrets.RISE_CHECKOUT_TOKEN }} + + all-checks-passed: + needs: [check, test, clippy, fmt, cross-build] + runs-on: ubuntu-latest + steps: + - run: echo "All checks passed" diff --git a/container/vendor/vulcan/.github/workflows/cross-build.yaml b/container/vendor/vulcan/.github/workflows/cross-build.yaml new file mode 100644 index 00000000000..beb68e48dc0 --- /dev/null +++ b/container/vendor/vulcan/.github/workflows/cross-build.yaml @@ -0,0 +1,150 @@ +name: Cross Build + +on: + workflow_call: + secrets: + R2_ACCESS_KEY_ID: + required: true + R2_SECRET_ACCESS_KEY: + required: true + NIX_SIGNING_KEY: + required: true + RISE_CHECKOUT_TOKEN: + required: true + +env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always + NIX_SIGNING_PUBLIC_KEY: "ellipsis-labs:eug33YU0s2/K/BgiOtEta1cwNIzERtIybNATLOBsrEA=" + NIX_CACHE_URI: "s3://atlas-nix-cache?compression=zstd¶llel-compression=true&endpoint=6a2b885167c20bd5dd1d3bcb4b09760f.r2.cloudflarestorage.com" + +jobs: + cross-build: + runs-on: ${{ matrix.target.runner }} + strategy: + matrix: + target: + - { arch: x86_64-linux, runner: ubuntu-latest } + - { arch: aarch64-linux, runner: ubuntu-latest } + - { arch: aarch64-darwin, runner: macos-latest } + - { arch: x86_64-darwin, runner: macos-latest } + steps: + - uses: actions/checkout@v6 + + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + + - name: Configure R2 credentials + uses: ./.github/actions/setup-r2-credentials + with: + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + + - name: Install nix + uses: nixbuild/nix-quick-install-action@v34 + with: + nix_conf: | + substituters = ${{ env.NIX_CACHE_URI }} https://cache.nixos.org/ + extra-trusted-public-keys = ${{ env.NIX_SIGNING_PUBLIC_KEY }} + + - uses: ./.github/actions/rust-cache + with: + cache-name: cross-build-${{ matrix.target.arch }} + + - name: Determine rust target + id: rust-target + run: | + case "${{ matrix.target.arch }}" in + "x86_64-linux") + echo "rust_target=x86_64-unknown-linux-musl" >> $GITHUB_OUTPUT + ;; + "aarch64-linux") + echo "rust_target=aarch64-unknown-linux-musl" >> $GITHUB_OUTPUT + ;; + "x86_64-darwin") + echo "rust_target=x86_64-apple-darwin" >> $GITHUB_OUTPUT + ;; + "aarch64-darwin") + echo "rust_target=aarch64-apple-darwin" >> $GITHUB_OUTPUT + ;; + esac + + - name: Build binary + run: | + # Track what nix actually builds (not downloads) using the post-build-hook + mkdir -p /tmp/nix-hooks + cat > /tmp/nix-hooks/track-builds.sh << 'EOF' + #!/bin/bash + # The OUT_PATHS environment variable contains the paths that were just built + echo "$OUT_PATHS" >> /tmp/locally-built-paths.txt + EOF + chmod +x /tmp/nix-hooks/track-builds.sh + + # Configure nix to use our tracking hook + export NIX_CONFIG="${NIX_CONFIG} + post-build-hook = /tmp/nix-hooks/track-builds.sh + " + + # Clear any previous tracking + rm -f /tmp/locally-built-paths.txt + touch /tmp/locally-built-paths.txt + + # Run the build + nix develop .#crossBuildShell-${{ matrix.target.arch }} \ + --print-build-logs \ + -c cargo build --locked --release + + # Report what was built + if [ -s /tmp/locally-built-paths.txt ]; then + echo "Locally built paths:" + sort -u /tmp/locally-built-paths.txt + else + echo "No local builds detected (all dependencies from cache)" + fi + + - name: Upload artifacts from cross-build + uses: actions/upload-artifact@v6 + with: + name: vulcan-${{ matrix.target.arch }} + path: target/${{ steps.rust-target.outputs.rust_target }}/release/vulcan + retention-days: 5 + if-no-files-found: error + + - name: Upload cache misses to Nix cache + if: always() + run: | + # Check if we have any locally built paths to upload + if [ -f /tmp/locally-built-paths.txt ] && [ -s /tmp/locally-built-paths.txt ]; then + # Remove duplicates and empty lines + BUILT_PATHS=$(sort -u /tmp/locally-built-paths.txt | grep -v '^$' || true) + + if [ -n "$BUILT_PATHS" ]; then + MISS_COUNT=$(echo "$BUILT_PATHS" | wc -l) + echo "Uploading $MISS_COUNT locally built derivations to cache:" + echo "$BUILT_PATHS" | head -10 + if [ "$MISS_COUNT" -gt 10 ]; then + echo "... and $((MISS_COUNT - 10)) more" + fi + + # Create signing key file + echo "${{ secrets.NIX_SIGNING_KEY }}" > /tmp/nix-signing-key.txt + + # Upload only the paths that were actually built locally + echo "$BUILT_PATHS" | xargs nix copy --to "${{ env.NIX_CACHE_URI }}&secret-key=/tmp/nix-signing-key.txt" || { + echo "Warning: Some paths failed to upload, continuing..." + } + + rm -f /tmp/nix-signing-key.txt + echo "Cache upload complete" + else + echo "No built paths found to upload" + fi + else + echo "No cache misses detected - all dependencies were already in cache" + fi diff --git a/container/vendor/vulcan/.github/workflows/publish.yaml b/container/vendor/vulcan/.github/workflows/publish.yaml new file mode 100644 index 00000000000..2a78a6850d6 --- /dev/null +++ b/container/vendor/vulcan/.github/workflows/publish.yaml @@ -0,0 +1,118 @@ +name: Release + +on: + push: + tags: + - v* + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: write + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v6 + - uses: actions/checkout@v6 + with: + repository: Ellipsis-Labs/rise + path: _rise + token: ${{ secrets.RISE_CHECKOUT_TOKEN }} + - name: Symlink rise for path deps + run: ln -s "$GITHUB_WORKSPACE/_rise" "$GITHUB_WORKSPACE/../rise" + - uses: ./.github/actions/rust-cache + with: + cache-name: publish-test + - uses: ./.github/actions/cargo-binstall + with: + binaries: cargo-nextest + - run: cargo nextest run --locked --profile ci + + cross-build: + needs: [test] + uses: ./.github/workflows/cross-build.yaml + secrets: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + NIX_SIGNING_KEY: ${{ secrets.NIX_SIGNING_KEY }} + RISE_CHECKOUT_TOKEN: ${{ secrets.RISE_CHECKOUT_TOKEN }} + + release: + runs-on: ubuntu-latest + needs: [cross-build] + steps: + - uses: actions/checkout@v6 + + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Prepare release assets + run: | + cd artifacts + + # Extract version from the git tag (remove 'v' prefix) + VERSION="${GITHUB_REF#refs/tags/v}" + echo "Version: $VERSION" + + # Map artifact directory names to Rust target triples + declare -A target_map=( + ["x86_64-linux"]="x86_64-unknown-linux-musl" + ["aarch64-linux"]="aarch64-unknown-linux-musl" + ["x86_64-darwin"]="x86_64-apple-darwin" + ["aarch64-darwin"]="aarch64-apple-darwin" + ) + + # Create compressed archives for each platform + for arch_dir in vulcan-*; do + if [ -d "$arch_dir" ]; then + arch_name="${arch_dir#vulcan-}" + rust_target="${target_map[$arch_name]}" + + if [ -z "$rust_target" ]; then + echo "Warning: Unknown architecture mapping for $arch_name" + rust_target="$arch_name" + fi + + # Move into directory and get the binary + cd "$arch_dir" + + # The binary is named vulcan + if [ -f "vulcan" ]; then + # Create a tar.gz archive with the binary + tar -czf "../vulcan-${VERSION}-${rust_target}.tar.gz" vulcan + echo "Created vulcan-${VERSION}-${rust_target}.tar.gz" + else + echo "Warning: Binary not found in $arch_dir" + fi + + cd .. + fi + done + + # Generate checksums for all archives + echo "Generating checksums..." + sha256sum *.tar.gz > vulcan-checksums-sha256.txt + + # List all created files + echo "Release assets:" + ls -la *.tar.gz vulcan-checksums-sha256.txt + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2 + if: github.ref_type == 'tag' + with: + draft: false + prerelease: false + generate_release_notes: true + files: | + artifacts/*.tar.gz + artifacts/vulcan-checksums-sha256.txt diff --git a/container/vendor/vulcan/.gitignore b/container/vendor/vulcan/.gitignore new file mode 100644 index 00000000000..d8241b4e50d --- /dev/null +++ b/container/vendor/vulcan/.gitignore @@ -0,0 +1,25 @@ +# Build +target + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Vulcan user data +.vulcan/ + +# MCP config (contains user-specific paths) +.mcp.json diff --git a/container/vendor/vulcan/AGENTS.md b/container/vendor/vulcan/AGENTS.md new file mode 100644 index 00000000000..274192be6f4 --- /dev/null +++ b/container/vendor/vulcan/AGENTS.md @@ -0,0 +1,246 @@ +# Agent Integration Guide: vulcan + +> **This is experimental software. Commands execute real financial transactions on Solana mainnet. The user who deploys this tool is responsible for all outcomes.** + +Self-contained guide for integrating `vulcan` into AI agents, MCP clients, and automated pipelines. + +Fast entry points: +- Runtime agent context: `CONTEXT.md` +- Full command contract: `agents/tool-catalog.json` +- Error routing contract: `agents/error-catalog.json` +- Workflow skills: `skills/INDEX.md` + +## Installation + +From the repo: + +```bash +cargo install --path vulcan +``` + +Verify: `vulcan --version` + +## Authentication + +### Wallet password (required for dangerous operations) + +```bash +export VULCAN_WALLET_PASSWORD="your_password" +``` + +The MCP server reads this at startup and unlocks the wallet for the session. No per-call prompts. + +### Configuration + +```bash +vulcan setup # interactive setup wizard +``` + +Creates `~/.vulcan/config.toml`: + +```toml +[network] +rpc_url = "https://api.mainnet-beta.solana.com" +api_url = "https://perp-api.phoenix.trade" + +[wallet] +default = "my-wallet" +``` + +### Credential resolution order + +1. CLI flags (`--rpc-url`, `--api-url`, `--api-key`) +2. Config file (`~/.vulcan/config.toml`) + +## Invocation Pattern + +### MCP (preferred for agents) + +```json +{ + "mcpServers": { + "vulcan": { + "command": "vulcan", + "args": ["mcp", "--allow-dangerous"], + "env": { "VULCAN_WALLET_PASSWORD": "your_password" } + } + } +} +``` + +Tools are named `vulcan__`. Dangerous tools require `acknowledged: true`. + +Without `--allow-dangerous`, only read-only tools are exposed. + +Group filtering: `vulcan mcp --groups market,position` exposes only those groups. + +### CLI (fallback) + +```bash +vulcan [args...] -o json +``` + +- `stdout`: JSON data (envelope format). +- `stderr`: Diagnostics/logging only. +- Exit code `0` = success, non-zero = failure. + +### Agent context command + +For agents using CLI without filesystem access: + +```bash +vulcan agent-context # prints full runtime context to stdout +``` + +## All Tool Groups + +### Market Data (5 tools, read-only, no auth) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_market_list` | — | All markets with fees and leverage | +| `vulcan_market_ticker` | `symbol` | Price, funding rate, 24h volume | +| `vulcan_market_info` | `symbol` | Tick size, lot sizes, fees, leverage tiers | +| `vulcan_market_orderbook` | `symbol`, `depth?` | L2 orderbook snapshot | +| `vulcan_market_candles` | `symbol`, `interval?`, `limit?` | OHLCV history | + +### Trading (9 tools, auth required) + +| Tool | Dangerous | Args | Description | +|------|-----------|------|-------------| +| `vulcan_trade_market_buy` | Yes | `symbol`, `size`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Market buy | +| `vulcan_trade_market_sell` | Yes | `symbol`, `size`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Market sell | +| `vulcan_trade_limit_buy` | Yes | `symbol`, `size`, `price`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Limit buy | +| `vulcan_trade_limit_sell` | Yes | `symbol`, `size`, `price`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Limit sell | +| `vulcan_trade_orders` | No | `symbol?` | List open orders | +| `vulcan_trade_cancel` | Yes | `symbol`, `order_ids[]`, `acknowledged` | Cancel specific orders | +| `vulcan_trade_cancel_all` | Yes | `symbol`, `acknowledged` | Cancel all orders for market | +| `vulcan_trade_set_tpsl` | Yes | `symbol`, `tp?`, `sl?`, `acknowledged` | Set TP/SL on position | +| `vulcan_trade_cancel_tpsl` | Yes | `symbol`, `tp?`, `sl?`, `acknowledged` | Cancel TP/SL | + +### Position (5 tools, auth required) + +| Tool | Dangerous | Args | Description | +|------|-----------|------|-------------| +| `vulcan_position_list` | No | — | All open positions | +| `vulcan_position_show` | No | `symbol` | Detailed position info | +| `vulcan_position_close` | Yes | `symbol`, `acknowledged` | Close entire position | +| `vulcan_position_reduce` | Yes | `symbol`, `size`, `acknowledged` | Reduce by size | +| `vulcan_position_tp_sl` | Yes | `symbol`, `tp?`, `sl?`, `acknowledged` | Attach TP/SL bracket | + +### Margin (8 tools, auth required) + +| Tool | Dangerous | Args | Description | +|------|-----------|------|-------------| +| `vulcan_margin_status` | No | — | Collateral, PnL, risk state | +| `vulcan_margin_deposit` | Yes | `amount`, `acknowledged` | Deposit USDC | +| `vulcan_margin_withdraw` | Yes | `amount`, `acknowledged` | Withdraw USDC | +| `vulcan_margin_transfer` | Yes | `from_subaccount`, `to_subaccount`, `amount`, `acknowledged` | Transfer between subaccounts | +| `vulcan_margin_transfer_child_to_parent` | Yes | `child_subaccount`, `acknowledged` | Sweep child to cross-margin | +| `vulcan_margin_sync_parent_to_child` | Yes | `child_subaccount`, `acknowledged` | Sync parent to child | +| `vulcan_margin_leverage_tiers` | No | `symbol` | Leverage tier schedule | +| `vulcan_margin_add_collateral` | Yes | `symbol`, `amount`, `acknowledged` | Add collateral to isolated | + +### History (5 tools, read-only, NOT YET IMPLEMENTED) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_history_trades` | `symbol?`, `limit?` | Trade/fill history | +| `vulcan_history_orders` | `symbol?`, `limit?` | Order history | +| `vulcan_history_collateral` | `limit?` | Deposit/withdrawal history | +| `vulcan_history_funding` | `symbol?`, `limit?` | Funding payment history | +| `vulcan_history_pnl` | `resolution?`, `limit?` | PnL over time | + +### Status, Wallet, Account (5 tools) + +| Tool | Dangerous | Auth | Args | Description | +|------|-----------|------|------|-------------| +| `vulcan_status` | No | No | — | Health check | +| `vulcan_wallet_list` | No | No | — | List wallets | +| `vulcan_wallet_balance` | No | No | `name?` | SOL/USDC balance | +| `vulcan_account_info` | No | Yes | — | Account info | +| `vulcan_account_register` | Yes | Yes | `invite_code`, `acknowledged` | Register account | + +## Output Parsing + +### Success envelope + +```json +{ + "ok": true, + "data": { ... }, + "meta": { "timestamp": "...", "duration_ms": 123 } +} +``` + +### Error envelope + +```json +{ + "ok": false, + "error": { + "category": "validation", + "code": "UNKNOWN_MARKET", + "message": "Market not found", + "retryable": false + } +} +``` + +## Error Handling + +| Category | Exit | Retryable | Action | +|----------|------|-----------|--------| +| `validation` | 1 | No | Fix input | +| `auth` | 2 | No | Check wallet/password | +| `config` | 3 | No | Run `vulcan setup` | +| `api` | 4 | No | Check API connectivity | +| `network` | 5 | Yes | Retry with backoff | +| `rate_limit` | 6 | Yes | Wait and retry | +| `tx_failed` | 7 | No | Verify state, then retry | +| `io` | 8 | Yes | Check file permissions | +| `dangerous_gate` | 9 | No | Set `acknowledged: true` | +| `internal` | 10 | No | Report bug | + +Full error code reference: `agents/error-catalog.json` + +## Symbol Format + +Uppercase ticker: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. No `-PERP` suffix. + +## Size Units + +Size is in **base lots**. Call `vulcan_market_info` first to get `base_lots_decimals`. + +`base_lots = desired_tokens * 10^base_lots_decimals` + +## Dangerous Commands (20 total) + +All dangerous tools require `acknowledged: true` via MCP or `--yes` via CLI: + +- Trade: market_buy, market_sell, limit_buy, limit_sell, cancel, cancel_all, set_tpsl, cancel_tpsl +- Position: close, reduce, tp_sl +- Margin: deposit, withdraw, transfer, transfer_child_to_parent, sync_parent_to_child, add_collateral +- Account: register + +## Workflow Skills + +Goal-oriented workflow guides in `skills/`. Read `skills/INDEX.md` for the full list. + +Skills are also available as MCP resources: `vulcan://skills/` + +**Core**: vulcan-shared, vulcan-risk-management, vulcan-error-recovery +**Trading**: vulcan-trade-execution, vulcan-lot-size-calculator, vulcan-tpsl-management +**Market**: vulcan-market-intel +**Portfolio**: vulcan-portfolio-intel, vulcan-margin-operations, vulcan-onboarding +**Position**: vulcan-position-management +**Recipes**: recipe-emergency-flatten, recipe-open-hedged-position, recipe-morning-portfolio-check, recipe-scale-into-position, recipe-funding-rate-harvest, recipe-close-and-withdraw + +## Machine-Readable Resources + +| Resource | Path | Description | +|----------|------|-------------| +| Tool catalog | `agents/tool-catalog.json` | All 37 tools with parameters and schemas | +| Error catalog | `agents/error-catalog.json` | Error codes, categories, recovery hints | +| Skills index | `skills/INDEX.md` | All workflow skills | +| Runtime context | `CONTEXT.md` | Compact runtime contract | diff --git a/container/vendor/vulcan/CLAUDE.md b/container/vendor/vulcan/CLAUDE.md new file mode 100644 index 00000000000..12b30f47fe9 --- /dev/null +++ b/container/vendor/vulcan/CLAUDE.md @@ -0,0 +1,231 @@ +# CLAUDE.md + +> **This is experimental software. Commands execute real financial transactions on Solana mainnet. The user who deploys this tool is responsible for all outcomes.** + +Vulcan is an AI-native CLI for trading perpetual futures on Phoenix DEX (Solana). Every command returns structured JSON. Designed for AI agents and automated pipelines. + +Fast entry points: +- Runtime context: `CONTEXT.md` +- Full command contract: `agents/tool-catalog.json` +- Error routing: `agents/error-catalog.json` +- Workflow skills: `skills/INDEX.md` + +## Build & Run + +```bash +cargo build # Build all crates +cargo run -- --help # Show help +cargo run -- market ticker SOL # Get ticker +cargo test # Run all tests +``` + +## Architecture + +- **`vulcan/`** — Binary crate. Entry point, clap parse, dispatch to commands. +- **`vulcan-lib/`** — Library crate. All logic lives here. + - `cli/` — Clap derive structs only. No business logic. + - `commands/` — Command execution. Receives parsed args, calls SDK, returns typed results. + - `output/` — JSON envelope and table formatting. + - `mcp/` — MCP server, tool registry, session wallet. + - `wallet/` — Wallet struct + encrypted storage (AES-256-GCM + Argon2id). + - `config/` — `~/.vulcan/config.toml` parsing. + - `context.rs` — `AppContext` shared across commands. + - `error.rs` — `VulcanError` with categories and exit codes. + +## Agent Runtime Contract + +**This section is the primary context for AI agents. Read it before using any Vulcan tool.** + +### Invocation + +Prefer MCP tools when available. Fallback to CLI: + +```bash +vulcan [args...] -o json +``` + +- MCP tools are named `vulcan__` (e.g., `vulcan_market_ticker`). +- CLI: stdout is JSON, stderr is diagnostics. Exit 0 = success, non-zero = failure. +- For non-Claude-Code agents: run `vulcan agent-context` to load this runtime contract. + +### Authentication + +MCP server unlocks the wallet once at startup: + +```json +{ + "mcpServers": { + "vulcan": { + "command": "vulcan", + "args": ["mcp", "--allow-dangerous"], + "env": { "VULCAN_WALLET_PASSWORD": "your_password" } + } + } +} +``` + +For CLI: `export VULCAN_WALLET_PASSWORD=your_password` + +### Symbol Format + +Uppercase ticker only: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. No `-PERP` suffix. Use `vulcan_market_list` to discover active markets. + +### Size Units — Base Lots + +The `size` parameter is in **base lots**, not tokens or USD. You MUST call `vulcan_market_info` first to get `base_lots_decimals`. + +**Conversion**: `base_lots = desired_tokens * 10^base_lots_decimals` + +| Want | Decimals | Calculation | Base lots | +|------|----------|-------------|-----------| +| 0.5 SOL | 2 | 0.5 * 100 | 50 | +| 0.01 ETH | 3 | 0.01 * 1000 | 10 | +| 0.001 BTC | 4 | 0.001 * 10000 | 10 | + +### Safety Rules + +1. All dangerous operations require `acknowledged: true` (MCP) or `--yes` (CLI). +2. **Always call `vulcan_market_info` before trading** — never guess lot sizes. +3. **Always call `vulcan_margin_status` before opening positions** — ensure sufficient collateral. +4. **Always call `vulcan_position_list` before trading** — know what's already open. +5. Report all transaction signatures to the user. + +### Confirmation Modes + +Ask the user at session start: +- **Confirm each** (default): Present trade details, wait for explicit approval before every dangerous op. +- **Auto-execute**: User grants blanket permission. Still log actions, report signatures, respect risk guardrails. + +### Error Handling + +Errors return `{ "category", "code", "message", "retryable" }`. Route on category: + +| Category | Action | +|----------|--------| +| `validation` | Fix input, do not retry unchanged | +| `auth` | Check wallet, password, permissions | +| `config` | Run `vulcan setup` | +| `api` | Check API connectivity, inspect message | +| `network` | Retry with backoff | +| `rate_limit` | Wait and retry | +| `tx_failed` | **Check position state before retrying** — never blind-retry on-chain tx | +| `dangerous_gate` | Add `acknowledged: true` | + +## All 37 MCP Tools + +### Market Data (5 tools, read-only) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_market_list` | — | All markets with fees and leverage | +| `vulcan_market_ticker` | `symbol` | Price, funding rate, 24h volume | +| `vulcan_market_info` | `symbol` | Tick size, lot sizes, fees, leverage tiers | +| `vulcan_market_orderbook` | `symbol`, `depth?` | L2 orderbook snapshot | +| `vulcan_market_candles` | `symbol`, `interval?`, `limit?` | OHLCV history | + +### Trading (9 tools, dangerous except orders) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_trade_market_buy` | `symbol`, `size`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Market buy | +| `vulcan_trade_market_sell` | `symbol`, `size`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Market sell | +| `vulcan_trade_limit_buy` | `symbol`, `size`, `price`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Limit buy | +| `vulcan_trade_limit_sell` | `symbol`, `size`, `price`, `tp?`, `sl?`, `isolated?`, `collateral?`, `reduce_only?`, `acknowledged` | Limit sell | +| `vulcan_trade_orders` | `symbol?` | List open orders (read-only) | +| `vulcan_trade_cancel` | `symbol`, `order_ids[]`, `acknowledged` | Cancel specific orders | +| `vulcan_trade_cancel_all` | `symbol`, `acknowledged` | Cancel all orders for market | +| `vulcan_trade_set_tpsl` | `symbol`, `tp?`, `sl?`, `acknowledged` | Set TP/SL on existing position | +| `vulcan_trade_cancel_tpsl` | `symbol`, `tp?`, `sl?`, `acknowledged` | Cancel TP/SL on position | + +### Position (5 tools) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_position_list` | — | All open positions with PnL | +| `vulcan_position_show` | `symbol` | Detailed position: PnL, margin, liquidation, TP/SL | +| `vulcan_position_close` | `symbol`, `acknowledged` | Close entire position (dangerous) | +| `vulcan_position_reduce` | `symbol`, `size`, `acknowledged` | Reduce position by size (dangerous) | +| `vulcan_position_tp_sl` | `symbol`, `tp?`, `sl?`, `acknowledged` | Attach TP/SL bracket to position (dangerous) | + +### Margin (8 tools) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_margin_status` | — | Collateral, PnL, risk state (read-only) | +| `vulcan_margin_deposit` | `amount`, `acknowledged` | Deposit USDC (dangerous) | +| `vulcan_margin_withdraw` | `amount`, `acknowledged` | Withdraw USDC (dangerous) | +| `vulcan_margin_transfer` | `from_subaccount`, `to_subaccount`, `amount`, `acknowledged` | Transfer between subaccounts (dangerous) | +| `vulcan_margin_transfer_child_to_parent` | `child_subaccount`, `acknowledged` | Sweep child to cross-margin (dangerous) | +| `vulcan_margin_sync_parent_to_child` | `child_subaccount`, `acknowledged` | Sync parent state to child (dangerous) | +| `vulcan_margin_leverage_tiers` | `symbol` | Leverage tier schedule (read-only) | +| `vulcan_margin_add_collateral` | `symbol`, `amount`, `acknowledged` | Add collateral to isolated position (dangerous) | + +### History (5 tools, read-only, NOT YET IMPLEMENTED) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_history_trades` | `symbol?`, `limit?` | Past trade/fill history | +| `vulcan_history_orders` | `symbol?`, `limit?` | Past order history | +| `vulcan_history_collateral` | `limit?` | Deposit/withdrawal history | +| `vulcan_history_funding` | `symbol?`, `limit?` | Funding payment history | +| `vulcan_history_pnl` | `resolution?`, `limit?` | PnL over time | + +### Status, Wallet, Account (5 tools) + +| Tool | Args | Description | +|------|------|-------------| +| `vulcan_status` | — | Health check: config, wallet, RPC, API, registration | +| `vulcan_wallet_list` | — | All stored wallets | +| `vulcan_wallet_balance` | `name?` | SOL and USDC balance | +| `vulcan_account_info` | — | Trader account: collateral, positions, risk | +| `vulcan_account_register` | `invite_code`, `acknowledged` | Register with invite code (dangerous) | + +## Common Patterns + +### Check price + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +### Safe order flow + +``` +1. vulcan_market_info → { symbol: "SOL" } # get lot sizes, fees +2. vulcan_market_ticker → { symbol: "SOL" } # current price +3. vulcan_margin_status → {} # check collateral +4. vulcan_position_list → {} # existing positions +5. vulcan_trade_market_buy → { symbol: "SOL", size: 50, acknowledged: true } +6. vulcan_position_list → {} # verify position opened +``` + +### Portfolio snapshot + +``` +vulcan_margin_status → {} # collateral, PnL, risk state +vulcan_position_list → {} # all positions +vulcan_trade_orders → {} # all open orders +``` + +### Close position + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +## Workflow Skills + +For deeper, goal-oriented workflows, read skills from `skills/INDEX.md`: +- **Core**: vulcan-shared, vulcan-risk-management, vulcan-error-recovery +- **Trading**: vulcan-trade-execution, vulcan-lot-size-calculator, vulcan-tpsl-management +- **Portfolio**: vulcan-portfolio-intel, vulcan-margin-operations, vulcan-market-intel +- **Recipes**: recipe-emergency-flatten, recipe-open-hedged-position, recipe-morning-portfolio-check + +Skills are also available as MCP resources: `vulcan://skills/` + +## Conventions + +- All commands return `Result<(), VulcanError>`. Never use `anyhow` in command return types. +- JSON envelope: `{ "ok": true, "data": ..., "meta": ... }` or `{ "ok": false, "error": { "category", "code", "message", "retryable" } }`. +- Wallet private keys are never logged, printed, or exported. The `Wallet` struct uses `Zeroize`/`ZeroizeOnDrop`. +- Dependencies: Rise SDK (`phoenix-sdk`, `phoenix-types`, `phoenix-math-utils`), Solana SDK, clap 4, rmcp. diff --git a/container/vendor/vulcan/CONTEXT.md b/container/vendor/vulcan/CONTEXT.md new file mode 100644 index 00000000000..e358b21d501 --- /dev/null +++ b/container/vendor/vulcan/CONTEXT.md @@ -0,0 +1,143 @@ +# Vulcan Runtime Context for AI Agents + +**This is experimental software. Commands interact with the live Phoenix DEX on Solana and can result in real financial transactions. The user who deploys this tool is responsible for all outcomes.** + +This file is optimized for runtime agent use. It defines how to call `vulcan` safely and reliably. + +## Core Invocation Contract + +### MCP (preferred) + +Tools are named `vulcan__`. Dangerous tools require `acknowledged: true`. + +### CLI (fallback) + +```bash +vulcan [args...] -o json +``` + +- `stdout` is the only machine data channel (JSON). +- `stderr` is diagnostics only. +- Exit code `0` = success, non-zero = failure with JSON error envelope in stdout. + +## Authentication + +MCP server unlocks the wallet once at startup — no per-call prompts: + +```json +{ + "mcpServers": { + "vulcan": { + "command": "vulcan", + "args": ["mcp", "--allow-dangerous"], + "env": { "VULCAN_WALLET_PASSWORD": "your_password" } + } + } +} +``` + +For CLI: `export VULCAN_WALLET_PASSWORD=your_password` + +Without `--allow-dangerous`, only read-only market data tools are available. + +## Symbol Format + +Uppercase ticker: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. No `-PERP` suffix. Use `vulcan_market_list` to discover active markets. + +## Size Units — Base Lots + +The `size` parameter is in **base lots**, not tokens or USD. Call `vulcan_market_info` first to get `base_lots_decimals`. + +**Conversion**: `base_lots = desired_tokens * 10^base_lots_decimals` + +Examples: 0.5 SOL at decimals=2 = 50 base lots. 0.001 BTC at decimals=4 = 10 base lots. + +## Safety Rules + +1. All dangerous operations require `acknowledged: true` (MCP) or `--yes` (CLI). +2. Always call `vulcan_market_info` before trading — never guess lot sizes. +3. Always call `vulcan_margin_status` before opening positions — ensure sufficient collateral. +4. Always call `vulcan_position_list` before trading — know existing exposure. +5. Report all transaction signatures to the user. + +## Error Handling Contract + +On failure, parse the error envelope: + +```json +{ + "ok": false, + "error": { + "category": "validation", + "code": "UNKNOWN_MARKET", + "message": "Market not found", + "retryable": false + } +} +``` + +Route on `.error.category`: +- `validation` — Fix inputs, do not retry unchanged request. +- `auth` — Check wallet and password. +- `config` — Run `vulcan setup`. +- `api` — Phoenix API issue, inspect message. +- `network` — Transient, retry with exponential backoff. +- `rate_limit` — Wait and retry. +- `tx_failed` — **Verify position/account state before retrying.** Never blind-retry on-chain transactions. +- `dangerous_gate` — Set `acknowledged: true`. +- `io` — Check filesystem permissions. +- `internal` — Report a bug. + +## High-Value Patterns + +### Price check + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +### Safe order flow (5 steps) + +``` +1. vulcan_market_info → { symbol } # lot sizes, fees, leverage tiers +2. vulcan_market_ticker → { symbol } # current price, funding rate +3. vulcan_margin_status → {} # available collateral, risk state +4. vulcan_position_list → {} # existing positions +5. vulcan_trade_market_buy → { symbol, size, acknowledged: true } +``` + +### Portfolio snapshot + +``` +vulcan_margin_status → {} # collateral, PnL, risk state +vulcan_position_list → {} # all open positions +vulcan_trade_orders → {} # all resting orders +``` + +### Close a position + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +## Tool Groups + +37 tools across 8 groups: + +| Group | Tools | Auth | Dangerous | +|-------|-------|------|-----------| +| market (5) | list, ticker, info, orderbook, candles | No | No | +| trade (9) | market_buy, market_sell, limit_buy, limit_sell, orders, cancel, cancel_all, set_tpsl, cancel_tpsl | Yes | Yes (except orders) | +| position (5) | list, show, close, reduce, tp_sl | Yes | Yes (except list, show) | +| margin (8) | status, deposit, withdraw, transfer, transfer_child_to_parent, sync_parent_to_child, leverage_tiers, add_collateral | Yes | Yes (except status, leverage_tiers) | +| history (5) | trades, orders, collateral, funding, pnl | Yes | No | +| status (1) | status | No | No | +| wallet (2) | list, balance | No | No | +| account (2) | info, register | Yes | register only | + +## Tool Discovery + +- Full machine-readable contract: `agents/tool-catalog.json` +- Error codes and recovery hints: `agents/error-catalog.json` +- Workflow skills: `skills/INDEX.md` +- Skills are also available as MCP resources: `vulcan://skills/` diff --git a/container/vendor/vulcan/Cargo.lock b/container/vendor/vulcan/Cargo.lock new file mode 100644 index 00000000000..bcd124e663d --- /dev/null +++ b/container/vendor/vulcan/Cargo.lock @@ -0,0 +1,6410 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "agave-feature-set" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a2c365c0245cbb8959de725fc2b44c754b673fdf34c9a7f9d4a25c35a7bf1" +dependencies = [ + "ahash 0.8.12", + "solana-epoch-schedule", + "solana-hash", + "solana-pubkey", + "solana-sha256-hasher", + "solana-svm-feature-set", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115e54d64eb62cdebad391c19efc9dce4981c690c85a33a12199d99bb9546fee" +dependencies = [ + "borsh-derive 0.10.4", + "hashbrown 0.13.2", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive 1.6.1", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831213f80d9423998dd696e2c5345aba6be7a0bd8cd19e31c5243e13df1cef89" +dependencies = [ + "borsh-derive-internal", + "borsh-schema-derive-internal", + "proc-macro-crate 0.1.5", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "borsh-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "borsh-schema-derive-internal" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" +dependencies = [ + "feature-probe", + "serde", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89f72f65e8501878b8a004d5a1afb780987e2ce2b4532c562e367a72c57499f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivation-path" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek 3.2.0", + "ed25519", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-bip32" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2be62a4061b872c8c0873ee4fc6f101ce7b889d039f019c5fa2af471a59908" +dependencies = [ + "derivation-path", + "ed25519-dalek", + "hmac 0.12.1", + "sha2 0.10.9", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "five8" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75b8549488b4715defcb0d8a8a1c1c76a80661b5fa106b4ca0e7fce59d7d875" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26dec3da8bc3ef08f2c04f61eab298c3ab334523e55f076354d6d6f613799a7b" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2551bf44bc5f776c15044b9b94153a00198be06743e262afaaa61f11ac7523a5" + +[[package]] +name = "fixed" +version = "1.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af2cbf772fa6d1c11358f92ef554cb6b386201210bcf0e91fb7fba8a907fb40" +dependencies = [ + "az", + "bytemuck", + "half", + "typenum", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc-core" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f7f76aef2d054868398427f6c54943cf3d1caa9a7ec7d0c38d69df97a965eb" +dependencies = [ + "futures", + "futures-executor", + "futures-util", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "libsecp256k1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" +dependencies = [ + "arrayref", + "base64 0.12.3", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phoenix-ix" +version = "0.1.0" +dependencies = [ + "borsh 1.6.1", + "sha2 0.10.9", + "solana-instruction", + "solana-pubkey", + "thiserror 2.0.18", +] + +[[package]] +name = "phoenix-math-utils" +version = "0.1.0" +dependencies = [ + "borsh 1.6.1", + "bytemuck", + "fixed", + "pastey 0.1.1", + "serde", + "sha2-const-stable", + "solana-pubkey", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "phoenix-sdk" +version = "0.1.0" +dependencies = [ + "futures-util", + "parking_lot", + "phoenix-ix", + "phoenix-math-utils", + "phoenix-types", + "reqwest", + "rust_decimal", + "serde_json", + "solana-instruction", + "solana-pubkey", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "url", +] + +[[package]] +name = "phoenix-types" +version = "0.1.0" +dependencies = [ + "chrono", + "phoenix-math-utils", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "serde_with", + "solana-pubkey", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "url", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rmcp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey 0.2.1", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh 1.6.1", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "solana-account" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f949fe4edaeaea78c844023bfc1c898e0b1f5a100f8a8d2d0f85d0a7b090258" +dependencies = [ + "bincode", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-sysvar", +] + +[[package]] +name = "solana-account-decoder-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5519e8343325b707f17fbed54fcefb325131b692506d0af9e08a539d15e4f8cf" +dependencies = [ + "base64 0.22.1", + "bs58", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-pubkey", + "zstd", +] + +[[package]] +name = "solana-account-info" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" +dependencies = [ + "bincode", + "serde", + "solana-program-error", + "solana-program-memory", + "solana-pubkey", +] + +[[package]] +name = "solana-address-lookup-table-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673f67efe870b64a65cb39e6194be5b26527691ce5922909939961a6e6b395" +dependencies = [ + "bincode", + "bytemuck", + "serde", + "serde_derive", + "solana-clock", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-slot-hashes", +] + +[[package]] +name = "solana-atomic-u64" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52e52720efe60465b052b9e7445a01c17550666beec855cce66f44766697bc2" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "solana-big-mod-exp" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75db7f2bbac3e62cfd139065d15bcda9e2428883ba61fc8d27ccb251081e7567" +dependencies = [ + "num-bigint", + "num-traits", + "solana-define-syscall", +] + +[[package]] +name = "solana-bincode" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" +dependencies = [ + "bincode", + "serde", + "solana-instruction", +] + +[[package]] +name = "solana-blake3-hasher" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0801e25a1b31a14494fc80882a036be0ffd290efc4c2d640bfcca120a4672" +dependencies = [ + "blake3", + "solana-define-syscall", + "solana-hash", + "solana-sanitize", +] + +[[package]] +name = "solana-bn254" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4420f125118732833f36facf96a27e7b78314b2d642ba07fa9ffdacd8d79e243" +dependencies = [ + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "bytemuck", + "solana-define-syscall", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-borsh" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718333bcd0a1a7aed6655aa66bef8d7fb047944922b2d3a18f49cbc13e73d004" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", +] + +[[package]] +name = "solana-client-traits" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83f0071874e629f29e0eb3dab8a863e98502ac7aba55b7e0df1803fc5cac72a7" +dependencies = [ + "solana-account", + "solana-commitment-config", + "solana-epoch-info", + "solana-hash", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-pubkey", + "solana-signature", + "solana-signer", + "solana-system-interface", + "solana-transaction", + "solana-transaction-error", +] + +[[package]] +name = "solana-clock" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8584296123df8fe229b95e2ebfd37ae637fe9db9b7d4dd677ac5a78e80dbfce" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-cluster-type" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ace9fea2daa28354d107ea879cff107181d85cd4e0f78a2bedb10e1a428c97e" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", +] + +[[package]] +name = "solana-commitment-config" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac49c4dde3edfa832de1697e9bcdb7c3b3f7cb7a1981b7c62526c8bb6700fb73" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-compute-budget-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8432d2c4c22d0499aa06d62e4f7e333f81777b3d7c96050ae9e5cb71a8c3aee4" +dependencies = [ + "borsh 1.6.1", + "serde", + "serde_derive", + "solana-instruction", + "solana-sdk-ids", +] + +[[package]] +name = "solana-cpi" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc71126edddc2ba014622fc32d0f5e2e78ec6c5a1e0eb511b85618c09e9ea11" +dependencies = [ + "solana-account-info", + "solana-define-syscall", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-stable-layout", +] + +[[package]] +name = "solana-decode-error" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c781686a18db2f942e70913f7ca15dc120ec38dcab42ff7557db2c70c625a35" +dependencies = [ + "num-traits", +] + +[[package]] +name = "solana-define-syscall" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae3e2abcf541c8122eafe9a625d4d194b4023c20adde1e251f94e056bb1aee2" + +[[package]] +name = "solana-derivation-path" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939756d798b25c5ec3cca10e06212bdca3b1443cb9bb740a38124f58b258737b" +dependencies = [ + "derivation-path", + "qstring", + "uriparse", +] + +[[package]] +name = "solana-ed25519-program" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1feafa1691ea3ae588f99056f4bdd1293212c7ece28243d7da257c443e84753" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "ed25519-dalek", + "solana-feature-set", + "solana-instruction", + "solana-precompile-error", + "solana-sdk-ids", +] + +[[package]] +name = "solana-epoch-info" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ef6f0b449290b0b9f32973eefd95af35b01c5c0c34c569f936c34c5b20d77b" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-epoch-rewards" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b575d3dd323b9ea10bb6fe89bf6bf93e249b215ba8ed7f68f1a3633f384db7" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-epoch-rewards-hasher" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c5fd2662ae7574810904585fd443545ed2b568dbd304b25a31e79ccc76e81b" +dependencies = [ + "siphasher", + "solana-hash", + "solana-pubkey", +] + +[[package]] +name = "solana-epoch-schedule" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fce071fbddecc55d727b1d7ed16a629afe4f6e4c217bc8d00af3b785f6f67ed" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-example-mocks" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84461d56cbb8bb8d539347151e0525b53910102e4bced875d49d5139708e39d3" +dependencies = [ + "serde", + "serde_derive", + "solana-address-lookup-table-interface", + "solana-clock", + "solana-hash", + "solana-instruction", + "solana-keccak-hasher", + "solana-message", + "solana-nonce", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-feature-gate-interface" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-feature-set" +version = "2.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93b93971e289d6425f88e6e3cb6668c4b05df78b3c518c249be55ced8efd6b6d" +dependencies = [ + "ahash 0.8.12", + "lazy_static", + "solana-epoch-schedule", + "solana-hash", + "solana-pubkey", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-fee-calculator" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89bc408da0fb3812bc3008189d148b4d3e08252c79ad810b245482a3f70cd8d" +dependencies = [ + "log", + "serde", + "serde_derive", +] + +[[package]] +name = "solana-fee-structure" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33adf673581c38e810bf618f745bf31b683a0a4a4377682e6aaac5d9a058dd4e" +dependencies = [ + "serde", + "serde_derive", + "solana-message", + "solana-native-token", +] + +[[package]] +name = "solana-genesis-config" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3725085d47b96d37fef07a29d78d2787fc89a0b9004c66eed7753d1e554989f" +dependencies = [ + "bincode", + "chrono", + "memmap2", + "serde", + "serde_derive", + "solana-account", + "solana-clock", + "solana-cluster-type", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash", + "solana-inflation", + "solana-keypair", + "solana-logger", + "solana-poh-config", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-sha256-hasher", + "solana-shred-version", + "solana-signer", + "solana-time-utils", +] + +[[package]] +name = "solana-hard-forks" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c28371f878e2ead55611d8ba1b5fb879847156d04edea13693700ad1a28baf" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-hash" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b96e9f0300fa287b545613f007dfe20043d7812bee255f418c1eb649c93b63" +dependencies = [ + "borsh 1.6.1", + "bytemuck", + "bytemuck_derive", + "five8", + "js-sys", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-sanitize", + "wasm-bindgen", +] + +[[package]] +name = "solana-inflation" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23eef6a09eb8e568ce6839573e4966850e85e9ce71e6ae1a6c930c1c43947de3" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-instruction" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" +dependencies = [ + "bincode", + "borsh 1.6.1", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "serde", + "serde_derive", + "serde_json", + "solana-define-syscall", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0e85a6fad5c2d0c4f5b91d34b8ca47118fc593af706e523cdbedf846a954f57" +dependencies = [ + "bitflags", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-serialize-utils", + "solana-sysvar-id", +] + +[[package]] +name = "solana-keccak-hasher" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7aeb957fbd42a451b99235df4942d96db7ef678e8d5061ef34c9b34cae12f79" +dependencies = [ + "sha3", + "solana-define-syscall", + "solana-hash", + "solana-sanitize", +] + +[[package]] +name = "solana-keypair" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd3f04aa1a05c535e93e121a95f66e7dcccf57e007282e8255535d24bf1e98bb" +dependencies = [ + "ed25519-dalek", + "ed25519-dalek-bip32", + "five8", + "rand 0.7.3", + "solana-derivation-path", + "solana-pubkey", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "wasm-bindgen", +] + +[[package]] +name = "solana-last-restart-slot" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6360ac2fdc72e7463565cd256eedcf10d7ef0c28a1249d261ec168c1b55cdd" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-loader-v2-interface" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ab08006dad78ae7cd30df8eea0539e207d08d91eaefb3e1d49a446e1c49654" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-loader-v3-interface" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f7162a05b8b0773156b443bccd674ea78bb9aa406325b467ea78c06c99a63a2" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-loader-v4-interface" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706a777242f1f39a83e2a96a2a6cb034cb41169c6ecbee2cf09cb873d9659e7e" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey", + "solana-sdk-ids", + "solana-system-interface", +] + +[[package]] +name = "solana-logger" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8e777ec1afd733939b532a42492d888ec7c88d8b4127a5d867eb45c6eb5cd5" +dependencies = [ + "env_logger", + "lazy_static", + "libc", + "log", + "signal-hook", +] + +[[package]] +name = "solana-message" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b" +dependencies = [ + "bincode", + "blake3", + "lazy_static", + "serde", + "serde_derive", + "solana-bincode", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-system-interface", + "solana-transaction-error", + "wasm-bindgen", +] + +[[package]] +name = "solana-msg" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36a1a14399afaabc2781a1db09cb14ee4cc4ee5c7a5a3cfcc601811379a8092" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-native-token" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61515b880c36974053dd499c0510066783f0cc6ac17def0c7ef2a244874cf4a9" + +[[package]] +name = "solana-nonce" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703e22eb185537e06204a5bd9d509b948f0066f2d1d814a6f475dafb3ddf1325" +dependencies = [ + "serde", + "serde_derive", + "solana-fee-calculator", + "solana-hash", + "solana-pubkey", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-nonce-account" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde971a20b8dbf60144d6a84439dda86b5466e00e2843091fe731083cda614da" +dependencies = [ + "solana-account", + "solana-hash", + "solana-nonce", + "solana-sdk-ids", +] + +[[package]] +name = "solana-offchain-message" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b526398ade5dea37f1f147ce55dae49aa017a5d7326606359b0445ca8d946581" +dependencies = [ + "num_enum", + "solana-hash", + "solana-packet", + "solana-pubkey", + "solana-sanitize", + "solana-sha256-hasher", + "solana-signature", + "solana-signer", +] + +[[package]] +name = "solana-packet" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004f2d2daf407b3ec1a1ca5ec34b3ccdfd6866dd2d3c7d0715004a96e4b6d127" +dependencies = [ + "bincode", + "bitflags", + "cfg_eval", + "serde", + "serde_derive", + "serde_with", +] + +[[package]] +name = "solana-poh-config" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d650c3b4b9060082ac6b0efbbb66865089c58405bfb45de449f3f2b91eccee75" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-precompile-error" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d87b2c1f5de77dfe2b175ee8dd318d196aaca4d0f66f02842f80c852811f9f8" +dependencies = [ + "num-traits", + "solana-decode-error", +] + +[[package]] +name = "solana-precompiles" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e92768a57c652edb0f5d1b30a7d0bc64192139c517967c18600debe9ae3832" +dependencies = [ + "lazy_static", + "solana-ed25519-program", + "solana-feature-set", + "solana-message", + "solana-precompile-error", + "solana-pubkey", + "solana-sdk-ids", + "solana-secp256k1-program", + "solana-secp256r1-program", +] + +[[package]] +name = "solana-presigner" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a57a24e6a4125fc69510b6774cd93402b943191b6cddad05de7281491c90fe" +dependencies = [ + "solana-pubkey", + "solana-signature", + "solana-signer", +] + +[[package]] +name = "solana-program" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98eca145bd3545e2fbb07166e895370576e47a00a7d824e325390d33bf467210" +dependencies = [ + "bincode", + "blake3", + "borsh 0.10.4", + "borsh 1.6.1", + "bs58", + "bytemuck", + "console_error_panic_hook", + "console_log", + "getrandom 0.2.17", + "lazy_static", + "log", + "memoffset", + "num-bigint", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-address-lookup-table-interface", + "solana-atomic-u64", + "solana-big-mod-exp", + "solana-bincode", + "solana-blake3-hasher", + "solana-borsh", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-define-syscall", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-example-mocks", + "solana-feature-gate-interface", + "solana-fee-calculator", + "solana-hash", + "solana-instruction", + "solana-instructions-sysvar", + "solana-keccak-hasher", + "solana-last-restart-slot", + "solana-loader-v2-interface", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-message", + "solana-msg", + "solana-native-token", + "solana-nonce", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-secp256k1-recover", + "solana-serde-varint", + "solana-serialize-utils", + "solana-sha256-hasher", + "solana-short-vec", + "solana-slot-hashes", + "solana-slot-history", + "solana-stable-layout", + "solana-stake-interface", + "solana-system-interface", + "solana-sysvar", + "solana-sysvar-id", + "solana-vote-interface", + "thiserror 2.0.18", + "wasm-bindgen", +] + +[[package]] +name = "solana-program-entrypoint" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ce041b1a0ed275290a5008ee1a4a6c48f5054c8a3d78d313c08958a06aedbd" +dependencies = [ + "solana-account-info", + "solana-msg", + "solana-program-error", + "solana-pubkey", +] + +[[package]] +name = "solana-program-error" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee2e0217d642e2ea4bee237f37bd61bb02aec60da3647c48ff88f6556ade775" +dependencies = [ + "borsh 1.6.1", + "num-traits", + "serde", + "serde_derive", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-pubkey", +] + +[[package]] +name = "solana-program-memory" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5426090c6f3fd6cfdc10685322fede9ca8e5af43cd6a59e98bfe4e91671712" +dependencies = [ + "solana-define-syscall", +] + +[[package]] +name = "solana-program-option" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" + +[[package]] +name = "solana-program-pack" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b" +dependencies = [ + "solana-program-error", +] + +[[package]] +name = "solana-pubkey" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b62adb9c3261a052ca1f999398c388f1daf558a1b492f60a6d9e64857db4ff1" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek 4.1.3", + "five8", + "five8_const", + "getrandom 0.2.17", + "js-sys", + "num-traits", + "rand 0.8.5", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-decode-error", + "solana-define-syscall", + "solana-sanitize", + "solana-sha256-hasher", + "wasm-bindgen", +] + +[[package]] +name = "solana-quic-definitions" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf0d4d5b049eb1d0c35f7b18f305a27c8986fc5c0c9b383e97adaa35334379e" +dependencies = [ + "solana-keypair", +] + +[[package]] +name = "solana-rent" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1aea8fdea9de98ca6e8c2da5827707fb3842833521b528a713810ca685d2480" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-rent-collector" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127e6dfa51e8c8ae3aa646d8b2672bc4ac901972a338a9e1cd249e030564fb9d" +dependencies = [ + "serde", + "serde_derive", + "solana-account", + "solana-clock", + "solana-epoch-schedule", + "solana-genesis-config", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", +] + +[[package]] +name = "solana-rent-debits" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f6f9113c6003492e74438d1288e30cffa8ccfdc2ef7b49b9e816d8034da18cd" +dependencies = [ + "solana-pubkey", + "solana-reward-info", +] + +[[package]] +name = "solana-reserved-account-keys" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4b22ea19ca2a3f28af7cd047c914abf833486bf7a7c4a10fc652fff09b385b1" +dependencies = [ + "lazy_static", + "solana-feature-set", + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-reward-info" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18205b69139b1ae0ab8f6e11cdcb627328c0814422ad2482000fa2ca54ae4a2f" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "solana-rpc-client" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d3161ac0918178e674c1f7f1bfac40de3e7ed0383bd65747d63113c156eaeb" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bincode", + "bs58", + "futures", + "indicatif", + "log", + "reqwest", + "reqwest-middleware", + "semver", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-account-decoder-client-types", + "solana-clock", + "solana-commitment-config", + "solana-epoch-info", + "solana-epoch-schedule", + "solana-feature-gate-interface", + "solana-hash", + "solana-instruction", + "solana-message", + "solana-pubkey", + "solana-rpc-client-api", + "solana-signature", + "solana-transaction", + "solana-transaction-error", + "solana-transaction-status-client-types", + "solana-version", + "solana-vote-interface", + "tokio", +] + +[[package]] +name = "solana-rpc-client-api" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dbc138685c79d88a766a8fd825057a74ea7a21e1dd7f8de275ada899540fff7" +dependencies = [ + "anyhow", + "jsonrpc-core", + "reqwest", + "reqwest-middleware", + "serde", + "serde_derive", + "serde_json", + "solana-account-decoder-client-types", + "solana-clock", + "solana-rpc-client-types", + "solana-signer", + "solana-transaction-error", + "solana-transaction-status-client-types", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-rpc-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea428a81729255d895ea47fba9b30fd4dacbfe571a080448121bd0592751676" +dependencies = [ + "base64 0.22.1", + "bs58", + "semver", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-account-decoder-client-types", + "solana-clock", + "solana-commitment-config", + "solana-fee-calculator", + "solana-inflation", + "solana-pubkey", + "solana-transaction-error", + "solana-transaction-status-client-types", + "solana-version", + "spl-generic-token", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-sanitize" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f1bc1357b8188d9c4a3af3fc55276e56987265eb7ad073ae6f8180ee54cecf" + +[[package]] +name = "solana-sdk" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc0e4a7635b902791c44b6581bfb82f3ada32c5bc0929a64f39fe4bb384c86a" +dependencies = [ + "bincode", + "bs58", + "getrandom 0.1.16", + "js-sys", + "serde", + "serde_json", + "solana-account", + "solana-bn254", + "solana-client-traits", + "solana-cluster-type", + "solana-commitment-config", + "solana-compute-budget-interface", + "solana-decode-error", + "solana-derivation-path", + "solana-ed25519-program", + "solana-epoch-info", + "solana-epoch-rewards-hasher", + "solana-feature-set", + "solana-fee-structure", + "solana-genesis-config", + "solana-hard-forks", + "solana-inflation", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-native-token", + "solana-nonce-account", + "solana-offchain-message", + "solana-packet", + "solana-poh-config", + "solana-precompile-error", + "solana-precompiles", + "solana-presigner", + "solana-program", + "solana-program-memory", + "solana-pubkey", + "solana-quic-definitions", + "solana-rent-collector", + "solana-rent-debits", + "solana-reserved-account-keys", + "solana-reward-info", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-secp256k1-program", + "solana-secp256k1-recover", + "solana-secp256r1-program", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-serde", + "solana-serde-varint", + "solana-short-vec", + "solana-shred-version", + "solana-signature", + "solana-signer", + "solana-system-transaction", + "solana-time-utils", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "solana-validator-exit", + "thiserror 2.0.18", + "wasm-bindgen", +] + +[[package]] +name = "solana-sdk-ids" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5d8b9cc68d5c88b062a33e23a6466722467dde0035152d8fb1afbcdf350a5f" +dependencies = [ + "solana-pubkey", +] + +[[package]] +name = "solana-sdk-macro" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df" +dependencies = [ + "bs58", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "solana-secp256k1-program" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f19833e4bc21558fe9ec61f239553abe7d05224347b57d65c2218aeeb82d6149" +dependencies = [ + "bincode", + "digest 0.10.7", + "libsecp256k1", + "serde", + "serde_derive", + "sha3", + "solana-feature-set", + "solana-instruction", + "solana-precompile-error", + "solana-sdk-ids", + "solana-signature", +] + +[[package]] +name = "solana-secp256k1-recover" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa3120b6cdaa270f39444f5093a90a7b03d296d362878f7a6991d6de3bbe496" +dependencies = [ + "borsh 1.6.1", + "libsecp256k1", + "solana-define-syscall", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-secp256r1-program" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce0ae46da3071a900f02d367d99b2f3058fe2e90c5062ac50c4f20cfedad8f0f" +dependencies = [ + "bytemuck", + "openssl", + "solana-feature-set", + "solana-instruction", + "solana-precompile-error", + "solana-sdk-ids", +] + +[[package]] +name = "solana-seed-derivable" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beb82b5adb266c6ea90e5cf3967235644848eac476c5a1f2f9283a143b7c97f" +dependencies = [ + "solana-derivation-path", +] + +[[package]] +name = "solana-seed-phrase" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36187af2324f079f65a675ec22b31c24919cb4ac22c79472e85d819db9bbbc15" +dependencies = [ + "hmac 0.12.1", + "pbkdf2", + "sha2 0.10.9", +] + +[[package]] +name = "solana-serde" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1931484a408af466e14171556a47adaa215953c7f48b24e5f6b0282763818b04" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-serde-varint" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a7e155eba458ecfb0107b98236088c3764a09ddf0201ec29e52a0be40857113" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-serialize-utils" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "817a284b63197d2b27afdba829c5ab34231da4a9b4e763466a003c40ca4f535e" +dependencies = [ + "solana-instruction", + "solana-pubkey", + "solana-sanitize", +] + +[[package]] +name = "solana-sha256-hasher" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa3feb32c28765f6aa1ce8f3feac30936f16c5c3f7eb73d63a5b8f6f8ecdc44" +dependencies = [ + "sha2 0.10.9", + "solana-define-syscall", + "solana-hash", +] + +[[package]] +name = "solana-short-vec" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c54c66f19b9766a56fa0057d060de8378676cb64987533fa088861858fc5a69" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-shred-version" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd3db0461089d1ad1a78d9ba3f15b563899ca2386351d38428faa5350c60a98" +dependencies = [ + "solana-hard-forks", + "solana-hash", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-signature" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c8ec8e657aecfc187522fc67495142c12f35e55ddeca8698edbb738b8dbd8c" +dependencies = [ + "ed25519-dalek", + "five8", + "rand 0.8.5", + "serde", + "serde-big-array", + "serde_derive", + "solana-sanitize", +] + +[[package]] +name = "solana-signer" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c41991508a4b02f021c1342ba00bcfa098630b213726ceadc7cb032e051975b" +dependencies = [ + "solana-pubkey", + "solana-signature", + "solana-transaction-error", +] + +[[package]] +name = "solana-slot-hashes" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8691982114513763e88d04094c9caa0376b867a29577939011331134c301ce" +dependencies = [ + "serde", + "serde_derive", + "solana-hash", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-slot-history" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccc1b2067ca22754d5283afb2b0126d61eae734fc616d23871b0943b0d935e" +dependencies = [ + "bv", + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sysvar-id", +] + +[[package]] +name = "solana-stable-layout" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f14f7d02af8f2bc1b5efeeae71bc1c2b7f0f65cd75bcc7d8180f2c762a57f54" +dependencies = [ + "solana-instruction", + "solana-pubkey", +] + +[[package]] +name = "solana-stake-interface" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5269e89fde216b4d7e1d1739cf5303f8398a1ff372a81232abbee80e554a838c" +dependencies = [ + "borsh 0.10.4", + "borsh 1.6.1", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-program-error", + "solana-pubkey", + "solana-system-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-svm-feature-set" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f24b836eb4d74ec255217bdbe0f24f64a07adeac31aca61f334f91cd4a3b1d5" + +[[package]] +name = "solana-system-interface" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d7c18cb1a91c6be5f5a8ac9276a1d7c737e39a21beba9ea710ab4b9c63bc90" +dependencies = [ + "js-sys", + "num-traits", + "serde", + "serde_derive", + "solana-decode-error", + "solana-instruction", + "solana-pubkey", + "wasm-bindgen", +] + +[[package]] +name = "solana-system-transaction" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd98a25e5bcba8b6be8bcbb7b84b24c2a6a8178d7fb0e3077a916855ceba91a" +dependencies = [ + "solana-hash", + "solana-keypair", + "solana-message", + "solana-pubkey", + "solana-signer", + "solana-system-interface", + "solana-transaction", +] + +[[package]] +name = "solana-sysvar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db" +dependencies = [ + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "lazy_static", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash", + "solana-instruction", + "solana-instructions-sysvar", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey", + "solana-rent", + "solana-sanitize", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-sysvar-id", +] + +[[package]] +name = "solana-sysvar-id" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5762b273d3325b047cfda250787f8d796d781746860d5d0a746ee29f3e8812c1" +dependencies = [ + "solana-pubkey", + "solana-sdk-ids", +] + +[[package]] +name = "solana-time-utils" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af261afb0e8c39252a04d026e3ea9c405342b08c871a2ad8aa5448e068c784c" + +[[package]] +name = "solana-transaction" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80657d6088f721148f5d889c828ca60c7daeedac9a8679f9ec215e0c42bcbf41" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-bincode", + "solana-feature-set", + "solana-hash", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-precompiles", + "solana-pubkey", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-signer", + "solana-system-interface", + "solana-transaction-error", + "wasm-bindgen", +] + +[[package]] +name = "solana-transaction-context" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a312304361987a85b2ef2293920558e6612876a639dd1309daf6d0d59ef2fe" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-instruction", + "solana-instructions-sysvar", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", +] + +[[package]] +name = "solana-transaction-error" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a9dc8fdb61c6088baab34fc3a8b8473a03a7a5fd404ed8dd502fa79b67cb1" +dependencies = [ + "serde", + "serde_derive", + "solana-instruction", + "solana-sanitize", +] + +[[package]] +name = "solana-transaction-status-client-types" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f1d7c2387c35850848212244d2b225847666cb52d3bd59a5c409d2c300303d" +dependencies = [ + "base64 0.22.1", + "bincode", + "bs58", + "serde", + "serde_derive", + "serde_json", + "solana-account-decoder-client-types", + "solana-commitment-config", + "solana-message", + "solana-reward-info", + "solana-signature", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-validator-exit" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbf6d7a3c0b28dd5335c52c0e9eae49d0ae489a8f324917faf0ded65a812c1d" + +[[package]] +name = "solana-version" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3324d46c7f7b7f5d34bf7dc71a2883bdc072c7b28ca81d0b2167ecec4cf8da9f" +dependencies = [ + "agave-feature-set", + "rand 0.8.5", + "semver", + "serde", + "serde_derive", + "solana-sanitize", + "solana-serde-varint", +] + +[[package]] +name = "solana-vote-interface" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" +dependencies = [ + "bincode", + "num-derive", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-decode-error", + "solana-hash", + "solana-instruction", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-serde-varint", + "solana-serialize-utils", + "solana-short-vec", + "solana-system-interface", +] + +[[package]] +name = "spl-generic-token" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741a62a566d97c58d33f9ed32337ceedd4e35109a686e31b1866c5dfa56abddc" +dependencies = [ + "bytemuck", + "solana-pubkey", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vulcan" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "rmcp", + "rpassword", + "tokio", + "tracing", + "tracing-subscriber", + "vulcan-lib", +] + +[[package]] +name = "vulcan-lib" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "base64 0.22.1", + "bs58", + "chrono", + "clap", + "colored", + "comfy-table", + "dirs", + "phoenix-math-utils", + "phoenix-sdk", + "phoenix-types", + "rand 0.8.5", + "reqwest", + "rmcp", + "rpassword", + "schemars 1.2.1", + "serde", + "serde_json", + "solana-pubkey", + "solana-rpc-client", + "solana-sdk", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", + "zeroize", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/container/vendor/vulcan/Cargo.toml b/container/vendor/vulcan/Cargo.toml new file mode 100644 index 00000000000..dd352dc8006 --- /dev/null +++ b/container/vendor/vulcan/Cargo.toml @@ -0,0 +1,67 @@ +[workspace] +members = ["vulcan", "vulcan-lib"] +resolver = "2" + +[workspace.dependencies] +vulcan-lib = { path = "vulcan-lib" } + +# Rise SDK +phoenix-sdk = { path = "../rise/rust/sdk" } +phoenix-types = { path = "../rise/rust/types" } +phoenix-ix = { path = "../rise/rust/ix" } +phoenix-math-utils = { path = "../rise/rust/math" } + +# CLI +clap = { version = "4", features = ["derive"] } + +# Async +tokio = { version = "1.44", features = ["rt-multi-thread", "macros", "sync", "time"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +# HTTP +reqwest = { version = "0.12", features = ["json"] } + +# Solana +solana-sdk = "~2.3" +solana-pubkey = { version = "~2.4", features = ["curve25519"] } +solana-instruction = { version = "~2.3", default-features = false, features = ["std"] } +solana-rpc-client = "~2.3" + +# Encryption +aes-gcm = "0.10" +argon2 = "0.5" +rand = "0.8" +zeroize = { version = "1", features = ["derive"] } + +# Encoding +bs58 = "0.5" +base64 = "0.22" + +# Output +comfy-table = "7" +colored = "2" + +# Error handling +anyhow = "1" +thiserror = "2" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Password input +rpassword = "5" + +# MCP +rmcp = { version = "1.2", features = ["server", "transport-io"] } +schemars = "1.0" + +# Directories +dirs = "5" diff --git a/container/vendor/vulcan/README.md b/container/vendor/vulcan/README.md new file mode 100644 index 00000000000..0ed8fedbd90 --- /dev/null +++ b/container/vendor/vulcan/README.md @@ -0,0 +1,178 @@ +# Vulcan + +AI-native CLI for the [Phoenix Perpetuals DEX](https://phoenix.trade) on Solana. Provides both human and agent interfaces. + +## Prerequisites + +- **Rust** 1.75+ — [install via rustup](https://rustup.rs) +- **Rise SDK** — clone [`rise`](https://github.com/ellipsis-labs/rise) alongside this repo so it lives at `../rise/` + +Your directory layout should look like: + +``` +phoenix-fullstack/ +├── rise/ # Rise SDK (phoenix-sdk, phoenix-types, etc.) +└── vulcan/ # This repo +``` + +## Quick Start + +```bash +# install +cargo install --path vulcan + +# Verify +vulcan version + +# Run the setup wizard (creates wallet + config) +vulcan setup + +# Check markets +vulcan market list +vulcan market ticker SOL +``` + +## CLI Usage + +### Commands + +| Command | Description | +| ---------- | ------------------------------------------------- | +| `setup` | Interactive wizard — wallet, config, connectivity | +| `wallet` | Create, import, list, and manage encrypted wallets | +| `market` | Prices, orderbooks, candles, funding rates | +| `trade` | Place, cancel, and manage orders | +| `position` | View and manage open positions | +| `margin` | Deposit, withdraw, and monitor collateral | +| `account` | Registration, info, subaccounts | +| `mcp` | Start MCP server over stdio (for AI agents) | +| `version` | Print version info | + +### Global Flags + +| Flag | Description | +| ----------------- | ---------------------------------- | +| `-o, --output` | Output format: `table` (default) or `json` | +| `--dry-run` | Simulate without submitting a tx | +| `-y, --yes` | Skip confirmation prompts | +| `-w, --wallet` | Wallet name or path override | +| `--rpc-url` | Solana RPC endpoint override | +| `--api-url` | Phoenix API endpoint override | +| `--api-key` | Phoenix API key override | +| `-v, --verbose` | Debug logging to stderr | + + +### JSON Envelope + +All commands support `-o json`. Responses follow a consistent envelope: + +```json +{ "ok": true, "data": { ... }, "meta": { ... } } +{ "ok": false, "error": { "category": "...", "code": "...", "message": "...", "retryable": false } } +``` + +## MCP Server + +Vulcan exposes an MCP server over stdio so AI agents (Claude, etc.) can call trading tools directly via the [Model Context Protocol](https://modelcontextprotocol.io). + +### Starting the server + +```bash +# Read-only mode (market data + positions only) +vulcan mcp + +# Full access (includes trading, deposits, withdrawals) +vulcan mcp --allow-dangerous + +# Filter to specific tool groups +vulcan mcp --allow-dangerous --groups market,trade +``` + +### Wallet authentication + +The MCP server unlocks the wallet once at startup (stdin is reserved for JSON-RPC): + +```bash +# Option 1: Environment variable +export VULCAN_WALLET_PASSWORD=your-password +vulcan mcp --allow-dangerous + +# Option 2: Stderr prompt (reads from /dev/tty, not stdin) +vulcan mcp --allow-dangerous +# → "Wallet password (for MCP session): " appears on stderr +``` + +### Claude Code configuration + +Add to your Claude Code MCP settings (`~/.claude/settings.json` or project `.mcp.json`): + +```json +{ + "mcpServers": { + "vulcan": { + "command": "/path/to/vulcan", + "args": ["mcp", "--allow-dangerous"], + "env": { + "VULCAN_WALLET_PASSWORD": "your-password" + } + } + } +} +``` + +Dangerous tools require `--allow-dangerous` on the server **and** `"acknowledged": true` in every call. + +### Tool groups + +Filter exposed tools with `--groups` (comma-separated): + +- **market** — Price data and market info (4 tools) +- **trade** — Order placement and cancellation (7 tools) +- **position** — Position monitoring and closing (3 tools) +- **margin** — Collateral management (3 tools) + +## Project Structure + +``` +vulcan/ +├── vulcan/ # Binary crate — CLI entry point +└── vulcan-lib/ # Library crate — all logic + ├── cli/ # Clap derive structs + ├── commands/ # Command execution (market, trade, position, margin, account) + ├── mcp/ # MCP server, tool registry, session wallet + │ ├── server.rs # rmcp ServerHandler implementation + │ ├── registry.rs # Static tool definitions with JSON schemas + │ └── session_wallet.rs # Pre-decrypted wallet for MCP session + ├── output/ # JSON envelopes and table formatting + ├── wallet/ # Encrypted wallet storage (AES-256-GCM + Argon2id) + ├── crypto/ # Encryption primitives + ├── config/ # ~/.vulcan/config.toml + ├── context.rs # Shared AppContext + └── error.rs # Categorized errors with exit codes +``` + +## Configuration + +Config lives at `~/.vulcan/config.toml`: + +```toml +[network] +rpc_url = "https://api.mainnet-beta.solana.com" +api_url = "https://public-api.phoenix.trade" +# api_key = "your-api-key" + +[wallet] +default = "my-wallet" + +[trading] +default_slippage_bps = 50 +confirm_trades = true +``` + +## Development + +```bash +cargo build # Build all crates +cargo test # Run all tests +cargo run -- --help # Show CLI help +``` diff --git a/container/vendor/vulcan/agents/README.md b/container/vendor/vulcan/agents/README.md new file mode 100644 index 00000000000..e0daab17f05 --- /dev/null +++ b/container/vendor/vulcan/agents/README.md @@ -0,0 +1,20 @@ +# Vulcan Agent Prompts + +Reusable prompt files for AI agents using the Vulcan MCP server to trade on Phoenix Perpetuals DEX. + +## Usage + +These prompts are agent-agnostic. Include them in your agent's system prompt or reference them as needed: + +- **`system.md`** — Core system prompt. Include this in every agent that uses Vulcan MCP tools. +- **`workflows/trade.md`** — Pre-trade checklist and order placement workflow. +- **`workflows/portfolio.md`** — Portfolio overview and position monitoring workflow. +- **`workflows/risk.md`** — Risk management rules and guardrails. + +## With Claude Code + +Add to your MCP config and reference these files in your system prompt or CLAUDE.md. + +## With Other Agents + +Include `system.md` content in your agent's system prompt. Reference workflow files as needed for specific tasks. diff --git a/container/vendor/vulcan/agents/error-catalog.json b/container/vendor/vulcan/agents/error-catalog.json new file mode 100644 index 00000000000..356f14ffbd9 --- /dev/null +++ b/container/vendor/vulcan/agents/error-catalog.json @@ -0,0 +1,316 @@ +{ + "categories": { + "validation": { "exit_code": 1, "retryable": false, "description": "Invalid input — fix the argument and retry" }, + "auth": { "exit_code": 2, "retryable": false, "description": "Wallet or permission issue" }, + "config": { "exit_code": 3, "retryable": false, "description": "Missing or invalid configuration" }, + "api": { "exit_code": 4, "retryable": false, "description": "Phoenix API server error" }, + "network": { "exit_code": 5, "retryable": true, "description": "Transient network error — safe to retry" }, + "rate_limit": { "exit_code": 6, "retryable": true, "description": "Rate limited — wait and retry" }, + "tx_failed": { "exit_code": 7, "retryable": false, "description": "On-chain transaction failed" }, + "io": { "exit_code": 8, "retryable": true, "description": "File I/O error — check permissions" }, + "dangerous_gate": { "exit_code": 9, "retryable": false, "description": "Dangerous operation not acknowledged" }, + "internal": { "exit_code": 10, "retryable": false, "description": "Internal error — report a bug" } + }, + "codes": { + "CONFIRMATION_REQUIRED": { + "category": "validation", + "recovery": "Add --yes flag to confirm, or --dry-run to simulate without executing" + }, + "NOT_IMPLEMENTED": { + "category": "validation", + "recovery": "This feature is not yet available. Check for updates." + }, + "INVALID_PUBKEY": { + "category": "validation", + "recovery": "Provide a valid Solana public key (base58 encoded)" + }, + "INVALID_BYTES": { + "category": "validation", + "recovery": "Provide a valid byte array (JSON format, 64 bytes)" + }, + "IMPORT_FAILED": { + "category": "validation", + "recovery": "Check the key format. Supported: base58, byte array, Solana CLI JSON file" + }, + "INVALID_KEY": { + "category": "validation", + "recovery": "The private key is malformed. Check the format and try again" + }, + "INVALID_FILE": { + "category": "validation", + "recovery": "File could not be read or parsed. Check the path and format" + }, + "INVALID_INTERVAL": { + "category": "validation", + "recovery": "Use a valid candle interval: 1m, 5m, 15m, 1h, 4h, 1d" + }, + "INVALID_SLIPPAGE": { + "category": "validation", + "recovery": "Slippage must be a positive number in basis points" + }, + "INVALID_SUBACCOUNT": { + "category": "validation", + "recovery": "Subaccount index 0 is reserved for cross-margin. Use 1+ for isolated" + }, + "UNKNOWN_MARKET": { + "category": "validation", + "recovery": "Run 'vulcan market list' to see available markets" + }, + "UNKNOWN_TOOL": { + "category": "validation", + "recovery": "Run MCP tools/list to see available tools" + }, + "MISSING_ARG": { + "category": "validation", + "recovery": "Provide the required argument. Check tool schema for required fields" + }, + "EMPTY_NAME": { + "category": "validation", + "recovery": "Wallet name cannot be empty" + }, + "EMPTY_PASSWORD": { + "category": "validation", + "recovery": "Password cannot be empty" + }, + "PASSWORD_MISMATCH": { + "category": "validation", + "recovery": "Passwords do not match. Try again" + }, + "INVALID_CHOICE": { + "category": "validation", + "recovery": "Enter a valid menu option number" + }, + "WALLET_EXISTS": { + "category": "validation", + "recovery": "A wallet with this name already exists. Choose a different name or remove the existing one" + }, + "WALLET_NOT_FOUND": { + "category": "auth", + "recovery": "Run 'vulcan wallet list' to see available wallets, or create one with 'vulcan wallet create'" + }, + "DECRYPT_FAILED": { + "category": "auth", + "recovery": "Wrong password. Check VULCAN_WALLET_PASSWORD env var or try again" + }, + "KEYPAIR_ERROR": { + "category": "auth", + "recovery": "Wallet keypair is corrupted. Try re-importing the wallet" + }, + "NO_DEFAULT_WALLET": { + "category": "config", + "recovery": "Run 'vulcan wallet set-default ' to set a default wallet" + }, + "CONFIG_ERROR": { + "category": "config", + "recovery": "Check ~/.vulcan/config.toml or run 'vulcan setup'" + }, + "CONFIG_LOAD_FAILED": { + "category": "config", + "recovery": "Config file is invalid. Run 'vulcan setup' to recreate it" + }, + "CONFIG_SAVE_FAILED": { + "category": "io", + "recovery": "Cannot write to ~/.vulcan/. Check directory permissions" + }, + "INIT_FAILED": { + "category": "config", + "recovery": "Run 'vulcan setup' to initialize configuration" + }, + "MCP_INIT_FAILED": { + "category": "config", + "recovery": "MCP server failed to initialize. Check config and try again" + }, + "REGISTER_API_FAILED": { + "category": "api", + "recovery": "Check your invite code and API URL. Run 'vulcan status' to verify connectivity" + }, + "TRADERS_FETCH_FAILED": { + "category": "api", + "recovery": "Phoenix API unreachable. Run 'vulcan status' to check connectivity" + }, + "NO_TRADER_ACCOUNT": { + "category": "api", + "recovery": "Register first with 'vulcan account register --invite-code '" + }, + "EXCHANGE_FETCH_FAILED": { + "category": "api", + "recovery": "Cannot reach Phoenix API. Check api_url in config and network connectivity" + }, + "MARKETS_FETCH_FAILED": { + "category": "api", + "recovery": "Cannot fetch market list. Run 'vulcan status' to check API connectivity" + }, + "MARKET_FETCH_FAILED": { + "category": "api", + "recovery": "Market not found or API error. Run 'vulcan market list' to see available markets" + }, + "CANDLES_FETCH_FAILED": { + "category": "api", + "recovery": "Cannot fetch candle data. Check symbol and interval parameters" + }, + "BUILD_ORDER_FAILED": { + "category": "api", + "recovery": "Failed to build order transaction. Check market info and lot sizes" + }, + "BUILD_CANCEL_FAILED": { + "category": "api", + "recovery": "Failed to build cancel transaction. Verify order IDs exist" + }, + "BUILD_REGISTER_FAILED": { + "category": "api", + "recovery": "Failed to build registration transaction. Account may already be registered" + }, + "BUILD_DEPOSIT_FAILED": { + "category": "api", + "recovery": "Failed to build deposit transaction. Check USDC balance with 'vulcan wallet balance'" + }, + "BUILD_WITHDRAW_FAILED": { + "category": "api", + "recovery": "Failed to build withdrawal. Check available collateral with 'vulcan margin status'" + }, + "BUILD_CLOSE_FAILED": { + "category": "api", + "recovery": "Failed to build close transaction. Check position exists with 'vulcan position list'" + }, + "BUILD_REDUCE_FAILED": { + "category": "api", + "recovery": "Failed to build reduce transaction. Check position size" + }, + "BUILD_TPSL_FAILED": { + "category": "api", + "recovery": "Failed to build TP/SL. TP/SL can only be set when opening or extending a position" + }, + "BUILD_BRACKET_FAILED": { + "category": "api", + "recovery": "Failed to build bracket orders. Check TP/SL prices relative to entry" + }, + "BUILD_TRANSFER_FAILED": { + "category": "api", + "recovery": "Failed to build transfer. Check subaccount indices and collateral balance" + }, + "BUILD_SWEEP_FAILED": { + "category": "api", + "recovery": "Failed to build sweep transaction. Check child subaccount exists" + }, + "BUILD_SYNC_FAILED": { + "category": "api", + "recovery": "Failed to build sync transaction. Check child subaccount exists" + }, + "WS_CONNECT_FAILED": { + "category": "network", + "recovery": "WebSocket connection failed. Check network and try again" + }, + "WS_SUBSCRIBE_FAILED": { + "category": "network", + "recovery": "WebSocket subscription failed. Check symbol and try again" + }, + "NO_MARKET_DATA": { + "category": "network", + "recovery": "No market data received. Market may be inactive — try again" + }, + "NO_ORDERBOOK_DATA": { + "category": "network", + "recovery": "No orderbook data received. Try again" + }, + "NO_MID_DATA": { + "category": "network", + "recovery": "No mid price data received. Try again" + }, + "TIMEOUT": { + "category": "network", + "recovery": "Request timed out. Check network connectivity and try again" + }, + "BLOCKHASH_FAILED": { + "category": "network", + "recovery": "Cannot get recent blockhash from RPC. Check rpc_url in config" + }, + "RPC_BALANCE_FAILED": { + "category": "network", + "recovery": "Cannot fetch balance from RPC. Check rpc_url in config" + }, + "TX_SEND_FAILED": { + "category": "tx_failed", + "recovery": "Transaction rejected on-chain. Check wallet SOL balance, account state, and error logs" + }, + "PASSWORD_READ_FAILED": { + "category": "io", + "recovery": "Cannot read password. Set VULCAN_WALLET_PASSWORD env var for non-interactive use" + }, + "WALLET_LIST_FAILED": { + "category": "io", + "recovery": "Cannot list wallets. Check ~/.vulcan/wallets/ directory permissions" + }, + "WALLET_SAVE_FAILED": { + "category": "io", + "recovery": "Cannot save wallet file. Check ~/.vulcan/wallets/ directory permissions" + }, + "FLUSH_FAILED": { + "category": "io", + "recovery": "I/O error writing to stdout" + }, + "READ_FAILED": { + "category": "io", + "recovery": "I/O error reading from stdin" + }, + "KEYGEN_FAILED": { + "category": "internal", + "recovery": "Key generation failed unexpectedly. Try again" + }, + "ENCRYPT_FAILED": { + "category": "internal", + "recovery": "Encryption failed unexpectedly. Try again" + }, + "SESSION_WALLET_ERROR": { + "category": "internal", + "recovery": "Session wallet state is invalid. Restart the MCP server" + }, + "MCP_SERVE_FAILED": { + "category": "internal", + "recovery": "MCP server failed to start. Check stderr for details" + }, + "MCP_WAIT_FAILED": { + "category": "internal", + "recovery": "MCP server exited unexpectedly. Check stderr for details" + }, + "ISOLATED_ONLY_MARKET": { + "category": "validation", + "recovery": "This market only supports isolated margin. Re-run with --isolated --collateral " + }, + "NO_POSITION": { + "category": "validation", + "recovery": "No open position for this market. Check 'vulcan position list'" + }, + "NO_TP_SL": { + "category": "validation", + "recovery": "Specify at least one of --tp or --sl" + }, + "NO_ISOLATED_POSITION": { + "category": "validation", + "recovery": "No isolated position for this market. Open one first" + }, + "INVALID_ORDER_IDS": { + "category": "validation", + "recovery": "Check order IDs with 'vulcan trade orders'" + }, + "FILE_NOT_FOUND": { + "category": "validation", + "recovery": "Check the file path exists" + }, + "SAVE_FAILED": { + "category": "io", + "recovery": "Check ~/.vulcan/wallets/ directory permissions" + }, + "LIST_FAILED": { + "category": "io", + "recovery": "Check ~/.vulcan/wallets/ directory exists and is readable" + }, + "SET_DEFAULT_FAILED": { + "category": "io", + "recovery": "Check filesystem permissions on ~/.vulcan/" + }, + "WALLET_DEFAULT_FAILED": { + "category": "io", + "recovery": "Check filesystem permissions on ~/.vulcan/" + } + } +} diff --git a/container/vendor/vulcan/agents/system.md b/container/vendor/vulcan/agents/system.md new file mode 100644 index 00000000000..9bcf34ce793 --- /dev/null +++ b/container/vendor/vulcan/agents/system.md @@ -0,0 +1,139 @@ +# Vulcan Trading Agent — System Prompt + +You have access to the Vulcan MCP server for trading perpetual futures on Phoenix DEX (Solana). + +## Setup + +### Install the CLI + +```bash +cargo install --path vulcan +``` + +Run from the repo root. The `vulcan/` directory is the binary crate within the workspace. + +This installs the `vulcan` binary globally. You can then run commands directly: + +```bash +vulcan market list # list all markets +vulcan market ticker SOL # price data +vulcan position list # open positions +vulcan trade market-buy SOL 1 --yes # place a trade +``` + +### Configure wallet and network + +```bash +vulcan setup # interactive setup wizard +``` + +This creates `~/.vulcan/config.toml` with API endpoint and wallet configuration. You need a registered trader account on Phoenix DEX. + +### MCP server + +For agent use via MCP (recommended), create `.mcp.json` in the project root: + +```json +{ + "mcpServers": { + "vulcan": { + "command": "vulcan", + "args": ["mcp", "--allow-dangerous"], + "env": { + "VULCAN_WALLET_PASSWORD": "your_password" + } + } + } +} +``` + +The MCP server unlocks the wallet once at startup and holds it in memory for the session — no password prompts per tool call. The `--allow-dangerous` flag enables trade/deposit/withdraw tools. Without it, only read-only market data tools are available. + +For CLI use, set `VULCAN_WALLET_PASSWORD` env var to avoid interactive password prompts: + +```bash +export VULCAN_WALLET_PASSWORD=your_password +``` + +## Available Tools + +### Market Data (read-only, safe) +- `vulcan_market_list` — List all available markets with fees and leverage. +- `vulcan_market_ticker` — Real-time price, funding rate, 24h volume. Args: `symbol`. +- `vulcan_market_info` — Full market config: tick size, fees, funding params, leverage tiers. Args: `symbol`. +- `vulcan_market_orderbook` — L2 orderbook snapshot. Args: `symbol`, `depth?` (default 10). +- `vulcan_market_candles` — OHLCV history. Args: `symbol`, `interval?` (1m/5m/15m/1h/4h/1d), `limit?` (default 50). + +### Trading (dangerous — requires `acknowledged: true`) +- `vulcan_trade_market_buy` — Market buy. Args: `symbol`, `size`, `tp?` (take-profit price, must be above entry), `sl?` (stop-loss price, must be below entry), `acknowledged`. +- `vulcan_trade_market_sell` — Market sell. Args: `symbol`, `size`, `tp?` (take-profit price, must be below entry), `sl?` (stop-loss price, must be above entry), `acknowledged`. +- `vulcan_trade_limit_buy` — Limit buy. Args: `symbol`, `size`, `price`, `acknowledged`. +- `vulcan_trade_limit_sell` — Limit sell. Args: `symbol`, `size`, `price`, `acknowledged`. +- `vulcan_trade_orders` — List open orders. Args: `symbol?` (omit for all markets). +- `vulcan_trade_cancel` — Cancel specific orders. Args: `symbol`, `order_ids[]`, `acknowledged`. +- `vulcan_trade_cancel_all` — Cancel all orders for a market. Args: `symbol`, `acknowledged`. + +### Positions (read-only except close) +- `vulcan_position_list` — All open positions with mark price and PnL. +- `vulcan_position_show` — Detailed position info. Args: `symbol`. +- `vulcan_position_close` — Close entire position via market order. Args: `symbol`, `acknowledged`. + +### Margin +- `vulcan_margin_status` — Collateral, PnL, risk state, available to withdraw. +- `vulcan_margin_deposit` — Deposit USDC. Args: `amount`, `acknowledged`. +- `vulcan_margin_withdraw` — Withdraw USDC. Args: `amount`, `acknowledged`. + +## Symbol Format + +Symbols are the asset ticker in uppercase: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. + +When the user says "sol" or "solana", use `SOL`. No `-PERP` suffix — just the ticker. + +Use `vulcan_market_list` to get the current list of active markets. + +## Size Units + +The `size` parameter is in **base lots**, not USD and not whole tokens. The relationship between base lots and token quantity depends on the market's `base_lots_decimals` configuration. + +**Before placing any trade**, call `vulcan_market_info` to check: +- `base_lots_decimals` — tells you the conversion (e.g., decimals=2 means 1 base lot = 0.01 tokens) +- `leverage_tiers` — max leverage at different position sizes (first tier is the relevant one for typical sizes) +- `taker_fee` / `maker_fee` — fee rates (e.g., 0.0002 = 0.02%) + +**Important**: The `size` parameter you pass to trade tools is in base lots. The trade response echoes back the base lot value, but orders and positions show the converted token amount (e.g., 1 base lot at `base_lots_decimals=2` shows as `0.01` in positions). + +## Dangerous Operations + +All trade, cancel, close, deposit, and withdraw operations require `acknowledged: true`. This is a safety mechanism — the agent must explicitly confirm it intends to execute a real financial transaction. + +### Confirmation Mode + +At the start of a trading session, ask the user which confirmation mode they prefer: + +- **Confirm each** (default) — Present trade details and get explicit user approval before every dangerous operation. This is the safe default. +- **Auto-execute** — User grants blanket permission for the rest of the session. The agent still shows what it's about to do, but does not wait for confirmation before executing. The user can revoke this at any time by saying "stop" or "confirm each". + +If the user says things like "just do it", "skip confirmations", "yolo mode", or "auto-execute", treat that as opting into auto-execute mode. If the user says "slow down", "wait", "confirm", or "stop", revert to confirm-each mode. + +In auto-execute mode, the agent **must still**: +1. Log every action taken (symbol, side, size, price). +2. Report transaction signatures. +3. Respect all risk guardrails (margin checks, leverage limits). +4. Never exceed position sizes that would move the account to an unhealthy risk state. + +## Error Handling + +Errors return structured JSON with `category`, `code`, `message`, and `retryable` fields. Categories: +- `validation` — Bad input, fix and retry. +- `auth` — Wallet/permission issue. +- `network` — Transient, safe to retry. +- `api` — Server-side issue, check message. +- `config` — Missing configuration. + +## Key Rules + +1. **Always check market info before trading.** Understand lot sizes and fees first. +2. **Always check margin status before opening positions.** Ensure sufficient collateral. +3. **Always check existing positions before trading.** Avoid unintended position changes. +4. **Never guess lot sizes.** Fetch market info and calculate precisely. +5. **Report all transaction signatures** back to the user for on-chain verification. diff --git a/container/vendor/vulcan/agents/tool-catalog.json b/container/vendor/vulcan/agents/tool-catalog.json new file mode 100644 index 00000000000..3c3ef515dd1 --- /dev/null +++ b/container/vendor/vulcan/agents/tool-catalog.json @@ -0,0 +1,511 @@ +{ + "schema_version": "1.0.0", + "cli_version": "0.1.0", + "description": "Machine-readable command catalog for vulcan. 37 tools across 8 groups.", + "groups": { + "market": "Market data: prices, orderbooks, candles, funding rates", + "trade": "Order management: place, cancel, TP/SL", + "position": "Position management: view, close, reduce, bracket orders", + "margin": "Collateral management: deposit, withdraw, transfer, leverage tiers", + "history": "Trade and account history (not yet implemented)", + "status": "Health check and connectivity", + "wallet": "Wallet listing and balance", + "account": "Account info and registration" + }, + "commands": [ + { + "name": "vulcan_market_list", + "command": "vulcan market list", + "group": "market", + "description": "List all available perpetual markets on Phoenix DEX with fees and leverage info.", + "auth_required": false, + "dangerous": false, + "parameters": [], + "example": "vulcan market list -o json" + }, + { + "name": "vulcan_market_ticker", + "command": "vulcan market ticker", + "group": "market", + "description": "Get real-time ticker data for a market: mark price, funding rate, 24h volume and change.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" } + ], + "example": "vulcan market ticker SOL -o json" + }, + { + "name": "vulcan_market_info", + "command": "vulcan market info", + "group": "market", + "description": "Get detailed market configuration: tick size, fees, funding params, leverage tiers.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" } + ], + "example": "vulcan market info SOL -o json" + }, + { + "name": "vulcan_market_orderbook", + "command": "vulcan market orderbook", + "group": "market", + "description": "Get L2 orderbook snapshot with bids, asks, mid price, and spread.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "depth", "type": "integer", "required": false, "default": 10, "description": "Number of price levels per side" } + ], + "example": "vulcan market orderbook SOL --depth 10 -o json" + }, + { + "name": "vulcan_market_candles", + "command": "vulcan market candles", + "group": "market", + "description": "Get historical candlestick (OHLCV) data for a market.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "interval", "type": "string", "required": false, "default": "1h", "description": "Candle interval: 1m, 5m, 15m, 1h, 4h, 1d" }, + { "name": "limit", "type": "integer", "required": false, "default": 50, "description": "Max candles to return" } + ], + "example": "vulcan market candles SOL --interval 1h --limit 24 -o json" + }, + { + "name": "vulcan_trade_market_buy", + "command": "vulcan trade market-buy", + "group": "trade", + "description": "Place a market buy order. Executes immediately at best available price.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "size", "type": "number", "required": true, "description": "Order size in base lots" }, + { "name": "tp", "type": "number", "required": false, "description": "Optional take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Optional stop-loss price" }, + { "name": "isolated", "type": "boolean", "required": false, "description": "Use isolated margin" }, + { "name": "collateral", "type": "number", "required": false, "description": "USDC collateral for isolated subaccount" }, + { "name": "reduce_only", "type": "boolean", "required": false, "description": "Order can only reduce existing position" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade market-buy SOL 50 --yes -o json" + }, + { + "name": "vulcan_trade_market_sell", + "command": "vulcan trade market-sell", + "group": "trade", + "description": "Place a market sell order. Executes immediately at best available price.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "size", "type": "number", "required": true, "description": "Order size in base lots" }, + { "name": "tp", "type": "number", "required": false, "description": "Optional take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Optional stop-loss price" }, + { "name": "isolated", "type": "boolean", "required": false, "description": "Use isolated margin" }, + { "name": "collateral", "type": "number", "required": false, "description": "USDC collateral for isolated subaccount" }, + { "name": "reduce_only", "type": "boolean", "required": false, "description": "Order can only reduce existing position" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade market-sell SOL 50 --yes -o json" + }, + { + "name": "vulcan_trade_limit_buy", + "command": "vulcan trade limit-buy", + "group": "trade", + "description": "Place a limit buy order at a specific price. Rests on the book until filled or cancelled.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "size", "type": "number", "required": true, "description": "Order size in base lots" }, + { "name": "price", "type": "number", "required": true, "description": "Limit price in USD" }, + { "name": "tp", "type": "number", "required": false, "description": "Optional take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Optional stop-loss price" }, + { "name": "isolated", "type": "boolean", "required": false, "description": "Use isolated margin" }, + { "name": "collateral", "type": "number", "required": false, "description": "USDC collateral for isolated subaccount" }, + { "name": "reduce_only", "type": "boolean", "required": false, "description": "Order can only reduce existing position" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade limit-buy SOL 50 --price 145.00 --yes -o json" + }, + { + "name": "vulcan_trade_limit_sell", + "command": "vulcan trade limit-sell", + "group": "trade", + "description": "Place a limit sell order at a specific price. Rests on the book until filled or cancelled.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "size", "type": "number", "required": true, "description": "Order size in base lots" }, + { "name": "price", "type": "number", "required": true, "description": "Limit price in USD" }, + { "name": "tp", "type": "number", "required": false, "description": "Optional take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Optional stop-loss price" }, + { "name": "isolated", "type": "boolean", "required": false, "description": "Use isolated margin" }, + { "name": "collateral", "type": "number", "required": false, "description": "USDC collateral for isolated subaccount" }, + { "name": "reduce_only", "type": "boolean", "required": false, "description": "Order can only reduce existing position" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade limit-sell SOL 50 --price 155.00 --yes -o json" + }, + { + "name": "vulcan_trade_orders", + "command": "vulcan trade orders", + "group": "trade", + "description": "List open orders. Omit symbol to list across all markets.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": false, "description": "Market symbol. Omit to list all markets." } + ], + "example": "vulcan trade orders -o json" + }, + { + "name": "vulcan_trade_cancel", + "command": "vulcan trade cancel", + "group": "trade", + "description": "Cancel specific orders by their IDs.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "order_ids", "type": "array", "required": true, "description": "Order IDs to cancel" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade cancel SOL --order-ids id1 id2 --yes -o json" + }, + { + "name": "vulcan_trade_cancel_all", + "command": "vulcan trade cancel-all", + "group": "trade", + "description": "Cancel all open orders for a market.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade cancel-all SOL --yes -o json" + }, + { + "name": "vulcan_trade_set_tpsl", + "command": "vulcan trade set-tpsl", + "group": "trade", + "description": "Set take-profit and/or stop-loss on an existing position. Auto-detects position side.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "tp", "type": "number", "required": false, "description": "Take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Stop-loss price" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade set-tpsl SOL --tp 160 --sl 140 --yes -o json" + }, + { + "name": "vulcan_trade_cancel_tpsl", + "command": "vulcan trade cancel-tpsl", + "group": "trade", + "description": "Cancel take-profit and/or stop-loss on an existing position.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "tp", "type": "boolean", "required": false, "description": "Cancel take-profit" }, + { "name": "sl", "type": "boolean", "required": false, "description": "Cancel stop-loss" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan trade cancel-tpsl SOL --tp --sl --yes -o json" + }, + { + "name": "vulcan_position_list", + "command": "vulcan position list", + "group": "position", + "description": "List all open positions across all markets.", + "auth_required": true, + "dangerous": false, + "parameters": [], + "example": "vulcan position list -o json" + }, + { + "name": "vulcan_position_show", + "command": "vulcan position show", + "group": "position", + "description": "Show detailed info for a specific position: PnL, margin, liquidation price, TP/SL.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" } + ], + "example": "vulcan position show SOL -o json" + }, + { + "name": "vulcan_position_close", + "command": "vulcan position close", + "group": "position", + "description": "Close an entire position via market order on the opposite side.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan position close SOL --yes -o json" + }, + { + "name": "vulcan_position_reduce", + "command": "vulcan position reduce", + "group": "position", + "description": "Reduce a position by a specified size via market order on the opposite side.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "size", "type": "number", "required": true, "description": "Size to reduce by in base lots" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan position reduce SOL 25 --yes -o json" + }, + { + "name": "vulcan_position_tp_sl", + "command": "vulcan position tp-sl", + "group": "position", + "description": "Attach take-profit and/or stop-loss bracket orders to an existing position.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" }, + { "name": "tp", "type": "number", "required": false, "description": "Take-profit price" }, + { "name": "sl", "type": "number", "required": false, "description": "Stop-loss price" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan position tp-sl SOL --tp 160 --sl 140 --yes -o json" + }, + { + "name": "vulcan_margin_status", + "command": "vulcan margin status", + "group": "margin", + "description": "Show current margin status: collateral, PnL, risk state, available to withdraw.", + "auth_required": true, + "dangerous": false, + "parameters": [], + "example": "vulcan margin status -o json" + }, + { + "name": "vulcan_margin_deposit", + "command": "vulcan margin deposit", + "group": "margin", + "description": "Deposit USDC collateral into the trading account.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "amount", "type": "number", "required": true, "description": "USDC amount to deposit" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin deposit 100 --yes -o json" + }, + { + "name": "vulcan_margin_withdraw", + "command": "vulcan margin withdraw", + "group": "margin", + "description": "Withdraw USDC collateral from the trading account.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "amount", "type": "number", "required": true, "description": "USDC amount to withdraw" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin withdraw 50 --yes -o json" + }, + { + "name": "vulcan_margin_transfer", + "command": "vulcan margin transfer", + "group": "margin", + "description": "Transfer collateral between subaccounts (e.g., cross-margin to isolated).", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "from_subaccount", "type": "integer", "required": true, "description": "Source subaccount index (0 = cross-margin)" }, + { "name": "to_subaccount", "type": "integer", "required": true, "description": "Destination subaccount index" }, + { "name": "amount", "type": "number", "required": true, "description": "USDC amount to transfer" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin transfer --from 0 --to 1 --amount 50 --yes -o json" + }, + { + "name": "vulcan_margin_transfer_child_to_parent", + "command": "vulcan margin sweep", + "group": "margin", + "description": "Sweep all collateral from a child (isolated) subaccount back to cross-margin.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "child_subaccount", "type": "integer", "required": true, "description": "Child subaccount index to sweep" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin sweep --child 1 --yes -o json" + }, + { + "name": "vulcan_margin_sync_parent_to_child", + "command": "vulcan margin sync", + "group": "margin", + "description": "Sync parent (cross-margin) state to a child (isolated) subaccount.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "child_subaccount", "type": "integer", "required": true, "description": "Child subaccount index to sync to" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin sync --child 1 --yes -o json" + }, + { + "name": "vulcan_margin_leverage_tiers", + "command": "vulcan margin leverage-tiers", + "group": "margin", + "description": "Show leverage tier schedule for a market: max leverage and max size per tier.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol, e.g. SOL" } + ], + "example": "vulcan margin leverage-tiers SOL -o json" + }, + { + "name": "vulcan_margin_add_collateral", + "command": "vulcan margin add-collateral", + "group": "margin", + "description": "Add USDC collateral to an isolated position by symbol.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "symbol", "type": "string", "required": true, "description": "Market symbol of the isolated position, e.g. SOL" }, + { "name": "amount", "type": "number", "required": true, "description": "USDC amount to add" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to confirm dangerous operation" } + ], + "example": "vulcan margin add-collateral SOL --amount 25 --yes -o json" + }, + { + "name": "vulcan_history_trades", + "command": "vulcan history trades", + "group": "history", + "description": "Get past trade/fill history. NOT YET IMPLEMENTED.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": false, "description": "Filter by market symbol" }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "description": "Max results to return" } + ], + "example": "vulcan history trades -o json" + }, + { + "name": "vulcan_history_orders", + "command": "vulcan history orders", + "group": "history", + "description": "Get past order history. NOT YET IMPLEMENTED.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": false, "description": "Filter by market symbol" }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "description": "Max results to return" } + ], + "example": "vulcan history orders -o json" + }, + { + "name": "vulcan_history_collateral", + "command": "vulcan history collateral", + "group": "history", + "description": "Get collateral deposit/withdrawal history. NOT YET IMPLEMENTED.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "limit", "type": "integer", "required": false, "default": 20, "description": "Max results to return" } + ], + "example": "vulcan history collateral -o json" + }, + { + "name": "vulcan_history_funding", + "command": "vulcan history funding", + "group": "history", + "description": "Get funding payment history. NOT YET IMPLEMENTED.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "symbol", "type": "string", "required": false, "description": "Filter by market symbol" }, + { "name": "limit", "type": "integer", "required": false, "default": 20, "description": "Max results to return" } + ], + "example": "vulcan history funding -o json" + }, + { + "name": "vulcan_history_pnl", + "command": "vulcan history pnl", + "group": "history", + "description": "Get PnL history over time. NOT YET IMPLEMENTED.", + "auth_required": true, + "dangerous": false, + "parameters": [ + { "name": "resolution", "type": "string", "required": false, "default": "hourly", "description": "Resolution: hourly or daily" }, + { "name": "limit", "type": "integer", "required": false, "default": 24, "description": "Max results to return" } + ], + "example": "vulcan history pnl --resolution daily -o json" + }, + { + "name": "vulcan_status", + "command": "vulcan status", + "group": "status", + "description": "Health check: verify config, wallet, RPC, API, and trader registration status.", + "auth_required": false, + "dangerous": false, + "parameters": [], + "example": "vulcan status -o json" + }, + { + "name": "vulcan_wallet_list", + "command": "vulcan wallet list", + "group": "wallet", + "description": "List all stored wallets with names, public keys, and default status.", + "auth_required": false, + "dangerous": false, + "parameters": [], + "example": "vulcan wallet list -o json" + }, + { + "name": "vulcan_wallet_balance", + "command": "vulcan wallet balance", + "group": "wallet", + "description": "Check SOL and USDC balance for a wallet.", + "auth_required": false, + "dangerous": false, + "parameters": [ + { "name": "name", "type": "string", "required": false, "description": "Wallet name (omit for default wallet)" } + ], + "example": "vulcan wallet balance -o json" + }, + { + "name": "vulcan_account_info", + "command": "vulcan account info", + "group": "account", + "description": "Get trader account info: collateral, positions, risk state.", + "auth_required": true, + "dangerous": false, + "parameters": [], + "example": "vulcan account info -o json" + }, + { + "name": "vulcan_account_register", + "command": "vulcan account register", + "group": "account", + "description": "Register a new trader account with an invite code.", + "auth_required": true, + "dangerous": true, + "parameters": [ + { "name": "invite_code", "type": "string", "required": true, "description": "Invite code for registration" }, + { "name": "acknowledged", "type": "boolean", "required": true, "description": "Must be true to execute" } + ], + "example": "vulcan account register --invite-code ABC123 --yes -o json" + } + ] +} diff --git a/container/vendor/vulcan/agents/workflows/onboarding.md b/container/vendor/vulcan/agents/workflows/onboarding.md new file mode 100644 index 00000000000..a0c91903231 --- /dev/null +++ b/container/vendor/vulcan/agents/workflows/onboarding.md @@ -0,0 +1,95 @@ +# Onboarding Workflow + +> **Note:** This workflow is superseded by the richer skill at `skills/vulcan-onboarding/SKILL.md`. This file is kept for backward compatibility with existing MCP resource URIs. + +Follow this checklist to set up a new user on Phoenix Perpetuals DEX. + +## Prerequisites + +The user needs: +- **SOL** in their Solana wallet (for transaction fees, ~0.01 SOL minimum) +- **USDC** in their Solana wallet (for trading collateral) +- **Invite code** from an existing Phoenix user + +## Step 1: Create or Import a Wallet + +```bash +vulcan wallet create --name # generate a new keypair +vulcan wallet import --name # or import existing key +vulcan wallet set-default # set as active wallet +``` + +The wallet is encrypted with a password on creation. For agent/MCP use, set `VULCAN_WALLET_PASSWORD` to avoid interactive prompts. + +After creation, give the user their public key so they can fund it with SOL and USDC before proceeding. + +## Step 2: Fund the Wallet + +The wallet needs: +1. **SOL** — at least 0.01 SOL for transaction fees (registration + deposit = 2 transactions) +2. **USDC** — the amount the user wants as trading collateral + +Funding happens outside Vulcan (wallet transfer, exchange withdrawal, etc). Confirm the user has funded before proceeding. + +## Step 3: Register with Invite Code + +``` +vulcan account register --invite-code +``` + +This does two things: +1. Activates the invite code via the Phoenix API +2. Creates the on-chain trader account (PDA at subaccount index 0, cross-margin) + +If the trader is already registered, the command skips the on-chain transaction and reports the existing account. + +After registration, verify with: +``` +vulcan account info +``` + +You should see the trader PDA, state, and zero collateral. + +## Step 4: Deposit Collateral + +``` +vulcan margin deposit --yes +``` + +`AMOUNT` is in USDC (e.g., `100` for $100). The `--yes` flag skips the confirmation prompt. Use `--dry-run` first to simulate without submitting. + +Verify the deposit: +``` +vulcan margin status +``` + +Collateral should reflect the deposited amount and risk state should be `Healthy`. + +## Step 5: Verify Setup + +Run these checks to confirm everything is working: + +``` +vulcan account info # trader registered, collateral > 0 +vulcan market list # markets load successfully +vulcan market ticker SOL # price data flows +``` + +## After Onboarding + +The user is ready to trade. Point them to: +- `vulcan trade market-buy SOL --yes` — place a market order +- `vulcan position list` — view positions +- `vulcan trade orders` — view open orders + +For agent workflows, read `vulcan://agents/workflows/trade` before placing any orders. + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `REGISTER_API_FAILED: 404` | Wrong API URL | Check `~/.vulcan/config.toml` — `api_url` should point to the active Phoenix API | +| `REGISTER_API_FAILED: 400/403` | Invalid or used invite code | Get a new invite code | +| `TX_SEND_FAILED: no record of a prior credit` | Wallet has no SOL | Fund the wallet with SOL for tx fees | +| `TX_SEND_FAILED: invalid account data` | Trader already registered | Run `vulcan account info` to confirm — this is safe to ignore | +| `CONFIRMATION_REQUIRED` | Missing `--yes` flag on deposit | Add `--yes` or `--dry-run` | diff --git a/container/vendor/vulcan/agents/workflows/portfolio.md b/container/vendor/vulcan/agents/workflows/portfolio.md new file mode 100644 index 00000000000..95af2a1e635 --- /dev/null +++ b/container/vendor/vulcan/agents/workflows/portfolio.md @@ -0,0 +1,74 @@ +# Portfolio Overview Workflow + +> **Note:** This workflow is superseded by the richer skill at `skills/vulcan-portfolio-intel/SKILL.md`. This file is kept for backward compatibility with existing MCP resource URIs. + +Use this workflow to get a complete picture of the trading account state. + +## Full Portfolio Snapshot + +Run these in parallel for a complete view: + +``` +vulcan_margin_status → {} # collateral, PnL, risk state +vulcan_position_list → {} # all open positions +vulcan_trade_orders → {} # all open orders across markets +``` + +## Interpreting Margin Status + +Key fields: +- **collateral_balance**: Total USDC deposited +- **unrealized_pnl**: Sum of PnL across all open positions +- **portfolio_value**: effective collateral + unrealized PnL +- **effective_collateral**: Collateral adjusted for risk +- **available_to_withdraw**: How much can be withdrawn without liquidation risk +- **risk_state**: Current risk level (`Healthy`, `HighRisk`, `Liquidatable`) +- **risk_tier**: Additional classification (`Safe`, etc.) +- **initial_margin**: Total margin currently used by open positions +- **maintenance_margin**: Minimum margin to avoid liquidation +- **num_positions** / **num_open_orders**: Quick counts + +## Interpreting Positions + +Key fields per position: +- **side**: Long or Short +- **size**: Position size (negative = short) +- **entry_price**: Average entry price +- **mark_price**: Current market price +- **unrealized_pnl**: Current profit/loss +- **liquidation_price**: Price at which position gets liquidated ("N/A" if unreachable) +- **maintenance_margin**: Margin required to keep position open + +## Position Detail + +For deeper analysis on a specific position: + +``` +vulcan_position_show → { symbol } +``` + +Additional fields: +- **take_profit_price / stop_loss_price**: Attached trigger orders (set via `tp`/`sl` on market orders). These do NOT appear in `vulcan_trade_orders` — only visible here. +- **unsettled_funding / accumulated_funding**: Funding payments +- **initial_margin**: Margin required to open at current size +- **position_value**: Notional value of position +- **discounted_unrealized_pnl**: PnL after risk discount (used for margin calculations) + +Note: `collateral_balance` decreases with each trade due to taker/maker fees, even if positions are profitable. A round-trip trade costs 2× taker fee on the notional value. + +## Presenting to the User + +When asked for portfolio status, summarize: + +1. **Account health**: Risk state, margin utilization (initial_margin / collateral_balance) +2. **Position summary**: For each position — symbol, side, size, entry vs mark, PnL +3. **Open orders**: Any resting limit orders +4. **Available capital**: Collateral available for new positions or withdrawal + +## Closing Positions + +``` +vulcan_position_close → { symbol, acknowledged: true } +``` + +This places a market order to fully close the position. Always confirm with the user first and check the orderbook for slippage on larger positions. diff --git a/container/vendor/vulcan/agents/workflows/risk.md b/container/vendor/vulcan/agents/workflows/risk.md new file mode 100644 index 00000000000..c99a91bd789 --- /dev/null +++ b/container/vendor/vulcan/agents/workflows/risk.md @@ -0,0 +1,54 @@ +# Risk Management Rules + +> **Note:** This workflow is superseded by the richer skill at `skills/vulcan-risk-management/SKILL.md`. This file is kept for backward compatibility with existing MCP resource URIs. + +Guardrails for AI agents trading on Phoenix DEX via Vulcan. + +## Hard Rules + +1. **Never trade without user confirmation.** Present the full trade details and wait for explicit approval. +2. **Never deposit or withdraw without user confirmation.** +3. **Always check margin before opening new positions.** If margin status shows HighRisk or worse, warn the user before any new trades. +4. **Never exceed available margin.** Calculate required margin from leverage tiers before proposing a trade size. +5. **Always report transaction signatures.** Every on-chain action returns a tx signature — share it with the user. + +## Pre-Trade Risk Checks + +Before every trade, verify: + +1. **Margin sufficiency**: `vulcan_margin_status` → ensure risk_state is Healthy +2. **Position awareness**: `vulcan_position_list` → know what's already open +3. **Order awareness**: `vulcan_trade_orders` → know what's resting on the book +4. **Slippage check**: For market orders, check `vulcan_market_orderbook` — if the order size is large relative to available liquidity at the best levels, warn the user about potential slippage + +## Leverage Tiers + +Markets have tiered leverage limits — larger positions get lower max leverage. Always check `vulcan_market_info` to find the applicable tier for the proposed position size. + +The first tier in `leverage_tiers` gives you the max leverage for normal-sized positions. Subsequent tiers apply to progressively larger positions. Tiers are configured by the exchange and subject to change — always fetch fresh values rather than caching them. + +## Funding Rate Awareness + +Perpetual futures charge/pay funding periodically. Check `vulcan_market_ticker` for the current funding rate: +- **Positive rate**: Longs pay shorts +- **Negative rate**: Shorts pay longs + +For longer-duration positions, factor funding costs into the trade thesis. + +## Position Sizing Guidelines + +When the user doesn't specify an exact size: +1. Ask them how much risk they want to take (in USD terms or as % of collateral) +2. Fetch market info for lot size conversion +3. Calculate position size based on their risk tolerance +4. Present the calculation before executing + +## When to Warn + +Alert the user when: +- Risk state is anything other than Healthy +- A trade would use >50% of available margin +- Liquidation price is within 10% of current mark price +- Funding rate is elevated (>0.01% per interval) +- Orderbook spread is wide (>10bps) +- They're about to increase an already-large position diff --git a/container/vendor/vulcan/agents/workflows/trade.md b/container/vendor/vulcan/agents/workflows/trade.md new file mode 100644 index 00000000000..afcaf395a66 --- /dev/null +++ b/container/vendor/vulcan/agents/workflows/trade.md @@ -0,0 +1,106 @@ +# Trade Workflow + +> **Note:** This workflow is superseded by the richer skill at `skills/vulcan-trade-execution/SKILL.md`. This file is kept for backward compatibility with existing MCP resource URIs. + +Follow this checklist before placing any order on Phoenix DEX. + +## Pre-Trade Checklist + +### 1. Gather Market Context + +``` +vulcan_market_info → { symbol } # lot sizes, fees, leverage tiers +vulcan_market_ticker → { symbol } # current price, funding rate, 24h volume +vulcan_margin_status → {} # available collateral +vulcan_position_list → {} # existing positions +vulcan_trade_orders → { symbol } # existing open orders +``` + +### 2. Calculate Size + +From `vulcan_market_info`, extract: +- `base_lots_decimals` (e.g., 2 means 1 base lot = 0.01 tokens) +- `taker_fee` for market orders, `maker_fee` for limit orders +- `tick_size` — minimum price increment for limit orders (price must be a multiple of this in the on-chain representation) + +To convert a desired token amount to base lots: +``` +base_lots = desired_tokens × 10^base_lots_decimals +``` + +Examples: +- Want 0.5 SOL, base_lots_decimals = 2 → 0.5 × 100 = 50 base lots +- Want 0.01 ETH, base_lots_decimals = 3 → 0.01 × 1000 = 10 base lots +- Want 0.001 BTC, base_lots_decimals = 4 → 0.001 × 10000 = 10 base lots + +### 3. Validate Against Constraints + +- **Margin**: Ensure collateral covers initial margin for the position size at your leverage tier. +- **Leverage tier**: Check `leverage_tiers` — larger positions have lower max leverage. +- **Existing exposure**: Account for open positions. Same-side orders increase exposure; opposite-side orders reduce it. +- **Limit order margin**: Resting limit orders consume margin even before they fill. Factor this into available margin calculations when placing multiple orders. + +### 4. Confirm With User + +Before executing, present: +- Symbol and direction (buy/sell) +- Size in both base lots AND approximate token amount +- Order type (market/limit) and price if limit +- Estimated fees +- Current mark price for reference +- Any existing positions in this market + +**In confirm-each mode** (default): Wait for explicit user approval before executing. + +**In auto-execute mode**: Log the trade details, then execute immediately without waiting. The user has already granted session-wide permission. Still report results and signatures after execution. + +## Market Orders + +``` +vulcan_trade_market_buy → { symbol, size, acknowledged: true } +vulcan_trade_market_sell → { symbol, size, acknowledged: true } +``` + +Market orders fill immediately at best available price. Check the orderbook first to estimate slippage for larger sizes. + +Note: Taker fees are deducted from collateral on each fill. A round-trip (open + close) costs 2× taker fee on the notional value. + +### Take-Profit / Stop-Loss (TP/SL) + +Attach TP and/or SL to market orders using optional `tp` and `sl` parameters: + +``` +vulcan_trade_market_buy → { symbol, size, tp: 100.0, sl: 90.0, acknowledged: true } +vulcan_trade_market_sell → { symbol, size, tp: 650.0, sl: 690.0, acknowledged: true } +``` + +**Rules:** +- **Long positions** (market buy): TP must be above entry, SL must be below entry. +- **Short positions** (market sell): TP must be below entry, SL must be above entry. +- You can set just TP, just SL, or both. +- TP/SL are **only available on market orders**, not limit orders. +- TP/SL can only be set when **opening or extending** a position. They will fail if the market order *reduces* an existing position (the entire transaction rolls back — the market order won't execute either). +- TP/SL are trigger orders — they show in `vulcan_position_show` as `take_profit_price` / `stop_loss_price`, **not** in `vulcan_trade_orders`. + +## Limit Orders + +``` +vulcan_trade_limit_buy → { symbol, size, price, acknowledged: true } +vulcan_trade_limit_sell → { symbol, size, price, acknowledged: true } +``` + +Limit orders rest on the book until filled or cancelled. They pay maker fees (often lower or negative/rebate). TP/SL is not available on limit orders. + +## After Placing an Order + +1. Report the transaction signature to the user. +2. For limit orders, check `vulcan_trade_orders` to confirm the order is on the book. +3. For market orders, check `vulcan_position_list` to confirm the position was opened/modified. + +## Cancelling Orders + +``` +vulcan_trade_orders → { symbol } # get order IDs +vulcan_trade_cancel → { symbol, order_ids: [...], acknowledged: true } # cancel specific +vulcan_trade_cancel_all → { symbol, acknowledged: true } # cancel all for market +``` diff --git a/container/vendor/vulcan/deny.toml b/container/vendor/vulcan/deny.toml new file mode 100644 index 00000000000..e962a7fa7ca --- /dev/null +++ b/container/vendor/vulcan/deny.toml @@ -0,0 +1,23 @@ +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", +] + +[advisories] +version = 2 + +[bans] +multiple-versions = "warn" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-git = [] diff --git a/container/vendor/vulcan/flake.nix b/container/vendor/vulcan/flake.nix new file mode 100644 index 00000000000..ac6fa00c107 --- /dev/null +++ b/container/vendor/vulcan/flake.nix @@ -0,0 +1,159 @@ +{ + description = "A dev shell Nix flake for vulcan"; + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + crane.url = "github:ipetkov/crane"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + rust-overlay, + flake-utils, + nixpkgs, + ... + }: let + in + flake-utils.lib.eachDefaultSystem + ( + system: let + pkgs = import nixpkgs { + inherit system; + overlays = [rust-overlay.overlays.default]; + }; + in + with pkgs; rec + { + # Function to create a cross-build shell for a specific target + mkCrossBuildShell = targetSystem: let + # Define rust targets for each platform + rustTarget = + if targetSystem == "x86_64-linux" + then "x86_64-unknown-linux-musl" + else if targetSystem == "aarch64-linux" + then "aarch64-unknown-linux-musl" + else if targetSystem == "x86_64-darwin" + then "x86_64-apple-darwin" + else if targetSystem == "aarch64-darwin" + then "aarch64-apple-darwin" + else throw "Unsupported target system: ${targetSystem}"; + + isLinux = lib.hasSuffix "linux" targetSystem; + isDarwin = lib.hasSuffix "darwin" targetSystem; + + # Import cross packages + # If target equals build system, don't set up cross-compilation + pkgsCross = + if targetSystem == system + then pkgs + else + import nixpkgs { + inherit system; + crossSystem = { + config = + if targetSystem == "x86_64-linux" + then "x86_64-unknown-linux-musl" + else if targetSystem == "aarch64-linux" + then "aarch64-unknown-linux-musl" + else if targetSystem == "x86_64-darwin" + then "x86_64-apple-darwin" + else if targetSystem == "aarch64-darwin" + then "aarch64-apple-darwin" + else throw "Unsupported target system: ${targetSystem}"; + }; + }; + + inherit (pkgs) lib; + # Use pkgsStatic for static libraries on all platforms + pkgsStatic = pkgsCross.pkgsStatic; + stdenv = pkgsStatic.libcxxStdenv; + + # Rust toolchain + toolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [rustTarget]; + }; + + # Convert rust target to env var name (e.g., x86_64-unknown-linux-musl -> x86_64_unknown_linux_musl) + envVarName = builtins.replaceStrings ["-"] ["_"] rustTarget; + in + mkShell { + buildInputs = + [ + ] + ++ lib.optionals stdenv.isDarwin [ + pkgsStatic.libiconv + ]; + + nativeBuildInputs = [ + toolchain + ]; + + CARGO_INCREMENTAL = 0; # disable incremental compilation + RUSTFLAGS = + # https://github.com/rust-lang/cargo/issues/4133 + "-C linker=${stdenv.cc}/bin/${stdenv.cc.targetPrefix}ld" + + ( + if stdenv.isDarwin + then " -L ${pkgsStatic.libiconv.dev}/lib" + else " -C link-arg=-static -C target-feature=+crt-static" + ); + + # Static library configuration + LIBICONV_STATIC = lib.optionalString stdenv.isDarwin "1"; + OPENSSL_STATIC = "1"; + OPENSSL_LIB_DIR = "${pkgsStatic.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgsStatic.openssl.dev}/include"; + + shellHook = '' + echo "Cross-compilation shell for ${targetSystem}" + echo "Target: ${rustTarget}" + echo "" + ${ + if isLinux + then '' + echo "Building statically linked binaries with musl" + '' + else if isDarwin + then '' + echo "Building for macOS (system libraries dynamic linking, everything else static)" + '' + else "" + } + echo "" + echo "Key environment variables:" + echo " CARGO_BUILD_TARGET: $CARGO_BUILD_TARGET" + echo " RUSTFLAGS: $RUSTFLAGS" + echo " CARGO_INCREMENTAL: $CARGO_INCREMENTAL" + echo " RUSTC_WRAPPER: $RUSTC_WRAPPER" + echo " CC: $CC" + echo " CXX: $CXX" + echo " CC_${envVarName}: ''${CC_${envVarName}}" + echo " CXX_${envVarName}: ''${CXX_${envVarName}}" + echo "" + echo "To build binaries:" + echo " cargo build --release" + echo "" + echo "The resulting binaries will be in:" + echo " target/${rustTarget}/release/" + ''; + # Cross-compilation environment variables + CARGO_BUILD_TARGET = rustTarget; + "CC_${envVarName}" = "${stdenv.cc}/bin/${stdenv.cc.targetPrefix}cc"; + "CXX_${envVarName}" = "${stdenv.cc}/bin/${stdenv.cc.targetPrefix}c++"; + "AR_${envVarName}" = "${stdenv.cc.bintools.bintools}/bin/${stdenv.cc.targetPrefix}ar"; + }; + + # Create dev shells for each target + devShells = { + "crossBuildShell-x86_64-linux" = mkCrossBuildShell "x86_64-linux"; + "crossBuildShell-aarch64-linux" = mkCrossBuildShell "aarch64-linux"; + "crossBuildShell-x86_64-darwin" = mkCrossBuildShell "x86_64-darwin"; + "crossBuildShell-aarch64-darwin" = mkCrossBuildShell "aarch64-darwin"; + }; + } + ); +} diff --git a/container/vendor/vulcan/rust-toolchain.toml b/container/vendor/vulcan/rust-toolchain.toml new file mode 100644 index 00000000000..292fe499e3b --- /dev/null +++ b/container/vendor/vulcan/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/container/vendor/vulcan/skills/INDEX.md b/container/vendor/vulcan/skills/INDEX.md new file mode 100644 index 00000000000..8ba59862810 --- /dev/null +++ b/container/vendor/vulcan/skills/INDEX.md @@ -0,0 +1,64 @@ +# Skills Index + +20 agent skills for `vulcan`, organized by category. + +## Core + +Shared runtime contract, safety rules, risk management, and error recovery. + +| Skill | Description | +|-------|-------------| +| [vulcan-shared](./vulcan-shared/SKILL.md) | Auth, invocation contract, symbol format, size units, and safety rules. | +| [vulcan-risk-management](./vulcan-risk-management/SKILL.md) | Pre-trade risk checks, leverage tiers, margin health, and when to warn. | +| [vulcan-error-recovery](./vulcan-error-recovery/SKILL.md) | Error category routing, tx_failed recovery, and network error handling. | + +## Trading + +Order execution, lot size calculation, TP/SL management, and execution strategies. + +| Skill | Description | +|-------|-------------| +| [vulcan-trade-execution](./vulcan-trade-execution/SKILL.md) | Safe order execution with pre-trade checks and post-trade verification. | +| [vulcan-lot-size-calculator](./vulcan-lot-size-calculator/SKILL.md) | Convert desired token amounts to base lots with worked examples. | +| [vulcan-tpsl-management](./vulcan-tpsl-management/SKILL.md) | Take-profit and stop-loss: direction rules, constraints, set/cancel flows. | +| [vulcan-twap-execution](./vulcan-twap-execution/SKILL.md) | Execute large orders as time-weighted slices to reduce market impact. | +| [vulcan-grid-trading](./vulcan-grid-trading/SKILL.md) | Grid trading with layered limit orders across a price range. | + +## Market Data + +Price reads, orderbook analysis, and pre-trade research. + +| Skill | Description | +|-------|-------------| +| [vulcan-market-intel](./vulcan-market-intel/SKILL.md) | Ticker, orderbook, candles, market info, and pre-trade analysis patterns. | + +## Portfolio & Account + +Margin operations, portfolio monitoring, and onboarding. + +| Skill | Description | +|-------|-------------| +| [vulcan-portfolio-intel](./vulcan-portfolio-intel/SKILL.md) | Portfolio snapshot: margin status, positions, orders, and funding rates. | +| [vulcan-margin-operations](./vulcan-margin-operations/SKILL.md) | Deposit, withdraw, transfer, isolated margin, and collateral management. | +| [vulcan-onboarding](./vulcan-onboarding/SKILL.md) | New user setup: wallet creation, registration, first deposit. | + +## Position + +Position monitoring and management. + +| Skill | Description | +|-------|-------------| +| [vulcan-position-management](./vulcan-position-management/SKILL.md) | List, show, close, reduce positions and attach TP/SL post-hoc. | + +## Recipes + +Multi-step workflows combining multiple skills. + +| Skill | Description | +|-------|-------------| +| [recipe-emergency-flatten](./recipe-emergency-flatten/SKILL.md) | Cancel all orders and close all positions across all markets. | +| [recipe-open-hedged-position](./recipe-open-hedged-position/SKILL.md) | Open a position with TP/SL protection in one complete flow. | +| [recipe-morning-portfolio-check](./recipe-morning-portfolio-check/SKILL.md) | Daily portfolio review with margin, positions, and funding rates. | +| [recipe-scale-into-position](./recipe-scale-into-position/SKILL.md) | Add to an existing position in calculated increments. | +| [recipe-funding-rate-harvest](./recipe-funding-rate-harvest/SKILL.md) | Scan markets for favorable funding rates and open positions. | +| [recipe-close-and-withdraw](./recipe-close-and-withdraw/SKILL.md) | Close all positions and withdraw collateral to wallet. | diff --git a/container/vendor/vulcan/skills/recipe-close-and-withdraw/SKILL.md b/container/vendor/vulcan/skills/recipe-close-and-withdraw/SKILL.md new file mode 100644 index 00000000000..b31efc855b7 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-close-and-withdraw/SKILL.md @@ -0,0 +1,66 @@ +--- +name: recipe-close-and-withdraw +version: 1.0.0 +description: "Close all positions and withdraw collateral to wallet." +metadata: + openclaw: + category: "recipe" + domain: "portfolio" + requires: + bins: ["vulcan"] + skills: ["vulcan-position-management", "vulcan-margin-operations"] +--- + +# Close and Withdraw + +> **PREREQUISITE:** Load `vulcan-position-management` and `vulcan-margin-operations` skills. + +Close all positions, cancel all orders, and withdraw collateral to the wallet. + +> **CAUTION:** This exits all positions at market price and withdraws funds. Irreversible. + +## Steps + +1. Check current state: + ``` + vulcan_position_list → {} + vulcan_trade_orders → {} + vulcan_margin_status → {} + ``` + +2. Cancel all resting orders for each market: + ``` + vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } + # ... repeat for each market with open orders + ``` + +3. Close each position: + ``` + vulcan_position_close → { symbol: "SOL", acknowledged: true } + # ... repeat for each open position + ``` + +4. Verify flat: + ``` + vulcan_position_list → {} # should be empty + vulcan_trade_orders → {} # should be empty + ``` + +5. Check available to withdraw: + ``` + vulcan_margin_status → {} + ``` + Note the `available_to_withdraw` amount. + +6. Withdraw collateral: + ``` + vulcan_margin_withdraw → { amount: , acknowledged: true } + ``` + +7. Verify withdrawal: + ``` + vulcan_wallet_balance → {} # USDC should have increased + vulcan_margin_status → {} # collateral should be ~0 + ``` + +8. Report all transaction signatures. diff --git a/container/vendor/vulcan/skills/recipe-emergency-flatten/SKILL.md b/container/vendor/vulcan/skills/recipe-emergency-flatten/SKILL.md new file mode 100644 index 00000000000..98b077912c2 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-emergency-flatten/SKILL.md @@ -0,0 +1,52 @@ +--- +name: recipe-emergency-flatten +version: 1.0.0 +description: "Cancel all orders and close all positions across all markets." +metadata: + openclaw: + category: "recipe" + domain: "risk" + requires: + bins: ["vulcan"] + skills: ["vulcan-risk-management", "vulcan-position-management"] +--- + +# Emergency Flatten + +> **PREREQUISITE:** Load `vulcan-risk-management` and `vulcan-position-management` skills. + +Cancel all resting orders and close all open positions. Use when margin health is critical or the user wants to exit everything immediately. + +> **CAUTION:** This executes multiple real transactions. Each step is irreversible. + +## Steps + +1. Check current state: + ``` + vulcan_margin_status → {} + vulcan_position_list → {} + vulcan_trade_orders → {} + ``` + +2. Cancel all orders for each market with open orders: + ``` + vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } + vulcan_trade_cancel_all → { symbol: "BTC", acknowledged: true } + # ... repeat for each market + ``` + +3. Close each open position: + ``` + vulcan_position_close → { symbol: "SOL", acknowledged: true } + vulcan_position_close → { symbol: "BTC", acknowledged: true } + # ... repeat for each position + ``` + +4. Verify everything is flat: + ``` + vulcan_position_list → {} # should be empty + vulcan_trade_orders → {} # should be empty + vulcan_margin_status → {} # all collateral should be available + ``` + +5. Report all transaction signatures to the user. diff --git a/container/vendor/vulcan/skills/recipe-funding-rate-harvest/SKILL.md b/container/vendor/vulcan/skills/recipe-funding-rate-harvest/SKILL.md new file mode 100644 index 00000000000..8abe09fcb14 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-funding-rate-harvest/SKILL.md @@ -0,0 +1,69 @@ +--- +name: recipe-funding-rate-harvest +version: 1.0.0 +description: "Scan markets for favorable funding rates and open positions to capture funding." +metadata: + openclaw: + category: "recipe" + domain: "strategy" + requires: + bins: ["vulcan"] + skills: ["vulcan-market-intel", "vulcan-trade-execution"] +--- + +# Funding Rate Harvest + +> **PREREQUISITE:** Load `vulcan-market-intel` and `vulcan-trade-execution` skills. + +Scan perpetual markets for attractive funding rates and open positions to earn funding payments. + +> **CAUTION:** Funding rate strategies carry directional risk. The position PnL may exceed funding income. + +## Steps + +1. List all markets: + ``` + vulcan_market_list → {} + ``` + +2. Get ticker for each market to check funding rates: + ``` + vulcan_market_ticker → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "BTC" } + vulcan_market_ticker → { symbol: "ETH" } + # ... for each active market + ``` + +3. Identify favorable rates: + - **Positive funding rate** → shorts receive payment → consider short. + - **Negative funding rate** → longs receive payment → consider long. + - Look for rates > 0.01% per interval for meaningful income. + +4. For the best opportunity, check market conditions: + ``` + vulcan_market_info → { symbol } # fees, leverage tiers + vulcan_market_orderbook → { symbol } # spread, depth + ``` + +5. Check margin: + ``` + vulcan_margin_status → {} + ``` + +6. Calculate position size accounting for: + - Expected funding income vs taker fees (round-trip cost). + - Leverage tier limits. + - Acceptable directional risk. + +7. Present analysis to user: funding rate, estimated daily income, entry cost (fees + spread), break-even time. + +8. Execute with user approval: + ``` + vulcan_trade_market_sell → { symbol: "SOL", size: , acknowledged: true } + ``` + (Short for positive funding rate, long for negative.) + +9. Verify position: + ``` + vulcan_position_show → { symbol } + ``` diff --git a/container/vendor/vulcan/skills/recipe-morning-portfolio-check/SKILL.md b/container/vendor/vulcan/skills/recipe-morning-portfolio-check/SKILL.md new file mode 100644 index 00000000000..3056014dbd0 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-morning-portfolio-check/SKILL.md @@ -0,0 +1,55 @@ +--- +name: recipe-morning-portfolio-check +version: 1.0.0 +description: "Daily portfolio review with margin, positions, orders, and funding rates." +metadata: + openclaw: + category: "recipe" + domain: "portfolio" + requires: + bins: ["vulcan"] + skills: ["vulcan-portfolio-intel", "vulcan-market-intel"] +--- + +# Morning Portfolio Check + +> **PREREQUISITE:** Load `vulcan-portfolio-intel` and `vulcan-market-intel` skills. + +Daily portfolio review — read-only, no trades. + +## Steps + +1. Get account health: + ``` + vulcan_margin_status → {} + ``` + +2. Get all positions: + ``` + vulcan_position_list → {} + ``` + +3. Get all resting orders: + ``` + vulcan_trade_orders → {} + ``` + +4. For each position, check funding rate: + ``` + vulcan_market_ticker → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "BTC" } + # ... for each held position + ``` + +5. For each position, check TP/SL status: + ``` + vulcan_position_show → { symbol: "SOL" } + # Check take_profit_price and stop_loss_price + ``` + +6. Present summary to user: + - Account: risk state, total collateral, total PnL, available to withdraw. + - Each position: symbol, side, size, entry, mark, PnL, liquidation price, TP/SL. + - Funding exposure: which positions are paying/receiving funding. + - Resting orders: any limit orders on the book. + - Warnings: positions near liquidation, elevated funding rates, wide spreads. diff --git a/container/vendor/vulcan/skills/recipe-open-hedged-position/SKILL.md b/container/vendor/vulcan/skills/recipe-open-hedged-position/SKILL.md new file mode 100644 index 00000000000..249eff9afe8 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-open-hedged-position/SKILL.md @@ -0,0 +1,72 @@ +--- +name: recipe-open-hedged-position +version: 1.0.0 +description: "Open a position with TP/SL protection in one complete flow." +metadata: + openclaw: + category: "recipe" + domain: "trading" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-tpsl-management"] +--- + +# Open Hedged Position + +> **PREREQUISITE:** Load `vulcan-trade-execution`, `vulcan-lot-size-calculator`, and `vulcan-tpsl-management` skills. + +Open a position with take-profit and stop-loss protection in one complete flow. + +> **CAUTION:** Live orders spend real money. Confirm with the user before executing. + +## Steps + +1. Get market configuration: + ``` + vulcan_market_info → { symbol: "SOL" } + ``` + Extract `base_lots_decimals` for size calculation. + +2. Get current price: + ``` + vulcan_market_ticker → { symbol: "SOL" } + ``` + +3. Check margin: + ``` + vulcan_margin_status → {} + ``` + Ensure risk_state is Healthy and sufficient collateral is available. + +4. Check orderbook for slippage: + ``` + vulcan_market_orderbook → { symbol: "SOL", depth: 10 } + ``` + +5. Calculate lot size: + ``` + base_lots = desired_tokens * 10^base_lots_decimals + ``` + +6. Calculate TP/SL levels based on user's risk/reward ratio. + +7. Confirm with user: symbol, direction, size, TP, SL, estimated fees. + +8. Execute with TP/SL attached: + ``` + vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + tp: 160.0, + sl: 140.0, + acknowledged: true + } + ``` + +9. Verify position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Confirm: position opened, TP/SL attached (check take_profit_price, stop_loss_price). + +10. Report transaction signature. diff --git a/container/vendor/vulcan/skills/recipe-scale-into-position/SKILL.md b/container/vendor/vulcan/skills/recipe-scale-into-position/SKILL.md new file mode 100644 index 00000000000..e05996ffc87 --- /dev/null +++ b/container/vendor/vulcan/skills/recipe-scale-into-position/SKILL.md @@ -0,0 +1,64 @@ +--- +name: recipe-scale-into-position +version: 1.0.0 +description: "Add to an existing position in calculated increments." +metadata: + openclaw: + category: "recipe" + domain: "trading" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-risk-management"] +--- + +# Scale Into Position + +> **PREREQUISITE:** Load `vulcan-trade-execution`, `vulcan-lot-size-calculator`, and `vulcan-risk-management` skills. + +Add to an existing position in calculated increments. + +> **CAUTION:** Each increment is a real transaction. Confirm with user before each step. + +## Steps + +1. Check existing position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Note current size, entry price, and margin usage. + +2. Check margin availability: + ``` + vulcan_margin_status → {} + ``` + Ensure sufficient collateral for the additional size. + +3. Get market info for lot calculation: + ``` + vulcan_market_info → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "SOL" } + ``` + +4. Calculate increment size: + ``` + increment_lots = desired_increment_tokens * 10^base_lots_decimals + ``` + +5. Check leverage tier — ensure total position (existing + increment) doesn't exceed max leverage. + +6. Confirm with user: current position, proposed addition, new total, margin impact. + +7. Execute: + ``` + vulcan_trade_market_buy → { symbol: "SOL", size: , acknowledged: true } + ``` + +8. Verify updated position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Confirm new size = old size + increment. + +9. Report transaction signature. + +Repeat steps 2-9 for each additional increment. diff --git a/container/vendor/vulcan/skills/vulcan-error-recovery/SKILL.md b/container/vendor/vulcan/skills/vulcan-error-recovery/SKILL.md new file mode 100644 index 00000000000..9a714b2e913 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-error-recovery/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-error-recovery +version: 1.0.0 +description: "Error category routing, tx_failed recovery, and network error handling for Vulcan." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-error-recovery + +Use this skill for: +- Routing errors by category +- Recovering from failed on-chain transactions +- Handling network and rate limit errors + +## Error Envelope Format + +```json +{ + "ok": false, + "error": { + "category": "validation", + "code": "UNKNOWN_MARKET", + "message": "Market not found", + "retryable": false + } +} +``` + +## Category Routing Table + +| Category | Exit | Retryable | Action | +|----------|------|-----------|--------| +| `validation` | 1 | No | Fix the input. Common: UNKNOWN_MARKET, MISSING_ARG, INVALID_INTERVAL | +| `auth` | 2 | No | Check wallet exists and password is correct. Run `vulcan wallet list` | +| `config` | 3 | No | Run `vulcan setup` to recreate config | +| `api` | 4 | No | Phoenix API issue. Check `vulcan status` for connectivity | +| `network` | 5 | Yes | Transient. Retry with exponential backoff (1s, 2s, 4s) | +| `rate_limit` | 6 | Yes | Wait 5s and retry. Reduce request frequency | +| `tx_failed` | 7 | No | **Critical: verify state before retrying.** See below | +| `io` | 8 | Yes | File permission issue. Check `~/.vulcan/` permissions | +| `dangerous_gate` | 9 | No | Add `acknowledged: true` to the tool call | +| `internal` | 10 | No | Bug in vulcan. Report it | + +## tx_failed Recovery (Critical) + +On-chain transactions can fail in complex ways. **Never blind-retry.** + +1. **Check position state first:** + ``` + vulcan_position_list → {} + vulcan_margin_status → {} + ``` + +2. **Common causes:** + - Blockhash expired — transaction took too long. Safe to retry with fresh state. + - Insufficient SOL for fees — check `vulcan_wallet_balance`. + - Account state changed — another transaction modified the account between build and send. + - Slippage exceeded — market moved. Re-check price and retry. + +3. **Recovery pattern:** + ``` + 1. vulcan_position_list → {} # did the original tx partially succeed? + 2. vulcan_margin_status → {} # is collateral state as expected? + 3. vulcan_wallet_balance → {} # enough SOL for fees? + 4. vulcan_market_ticker → { symbol } # has price moved significantly? + 5. Re-attempt the operation if state is clean + ``` + +## Network Error Recovery + +``` +1. Wait 1 second +2. Retry the same call +3. If still failing, wait 2 seconds and retry +4. After 3 failures, check connectivity: vulcan_status → {} +5. Report to user if API is down +``` + +## Common Error Codes and Fixes + +| Code | Fix | +|------|-----| +| `UNKNOWN_MARKET` | Run `vulcan_market_list` to see available symbols | +| `MISSING_ARG` | Check tool schema for required fields | +| `NO_POSITION` | No open position. Check `vulcan_position_list` | +| `ISOLATED_ONLY_MARKET` | Re-run with `isolated: true, collateral: ` | +| `NO_DEFAULT_WALLET` | Run `vulcan wallet set-default ` | +| `DECRYPT_FAILED` | Wrong password. Check `VULCAN_WALLET_PASSWORD` | +| `NO_TRADER_ACCOUNT` | Register with `vulcan_account_register` | +| `BUILD_TPSL_FAILED` | TP/SL only works when opening/extending a position | diff --git a/container/vendor/vulcan/skills/vulcan-grid-trading/SKILL.md b/container/vendor/vulcan/skills/vulcan-grid-trading/SKILL.md new file mode 100644 index 00000000000..707afeb45a7 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-grid-trading/SKILL.md @@ -0,0 +1,196 @@ +--- +name: vulcan-grid-trading +version: 1.0.0 +description: "Grid trading with layered limit orders across a price range on Phoenix DEX perpetuals." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-risk-management"] +--- + +# vulcan-grid-trading + +Use this skill for: +- Placing a grid of limit orders across a price range +- Profiting from sideways/ranging markets on perpetual futures +- Managing grid state (filled orders, replacements) +- Running a market-making-like strategy + +## Core Concept + +Grid trading places buy limit orders below the current price and sell limit orders above it at fixed intervals. When a buy fills, a corresponding sell is placed one grid level higher. When a sell fills, a corresponding buy is placed one grid level lower. Profit comes from capturing the spread at each level. + +On perpetual futures (Phoenix DEX), this means opening and closing positions at grid levels. Funding rate costs/income also factor into profitability. + +## Grid Parameters + +Define with the user before starting: +- **Symbol**: e.g., SOL +- **Price range**: lower bound to upper bound (e.g., 140–160) +- **Grid levels**: number of orders per side (e.g., 5 buy + 5 sell = 10 total) +- **Size per level**: in tokens (agent converts to base lots) + +Grid spacing = (upper - lower) / total_levels + +## Pre-Grid Checks + +``` +1. vulcan_market_info → { symbol: "SOL" } # base_lots_decimals, tick_size, fees +2. vulcan_market_ticker → { symbol: "SOL" } # current price (center the grid) +3. vulcan_market_orderbook → { symbol: "SOL" } # spread, depth +4. vulcan_margin_status → {} # enough collateral for worst case? +5. vulcan_position_list → {} # existing positions in this market +``` + +## Calculate Grid Levels + +``` +spacing = (upper_bound - lower_bound) / total_levels +``` + +Example: Range 140–160, 10 levels, current price 150: +``` +spacing = (160 - 140) / 10 = 2.0 +Buy levels: 148, 146, 144, 142, 140 +Sell levels: 152, 154, 156, 158, 160 +``` + +Ensure all prices are valid multiples of `tick_size` from `vulcan_market_info`. + +## Calculate Size Per Level + +``` +size_per_level_lots = desired_tokens_per_level * 10^base_lots_decimals +``` + +## Margin Estimation + +Worst case: all buy orders fill (max long position) or all sell orders fill (max short position). Calculate margin required: + +``` +max_position_lots = size_per_level_lots * levels_per_side +``` + +Check this against leverage tiers and available collateral. + +## Confirm with User + +Present the full grid before placing: +- Price range, grid levels, spacing +- Size per level (base lots + token equivalent) +- Total margin required (worst case) +- Estimated fees per round-trip +- Funding rate exposure +- Get explicit approval for the entire grid. + +## Place the Grid + +Use `vulcan_trade_multi_limit` to place all grid orders in a single transaction. This is much faster than placing orders individually. + +``` +vulcan_trade_multi_limit → { + symbol: "SOL", + bids: [ + { price: 148.00, size: 50 }, + { price: 146.00, size: 50 }, + { price: 144.00, size: 50 }, + { price: 142.00, size: 50 }, + { price: 140.00, size: 50 } + ], + asks: [ + { price: 152.00, size: 50 }, + { price: 154.00, size: 50 }, + { price: 156.00, size: 50 }, + { price: 158.00, size: 50 }, + { price: 160.00, size: 50 } + ], + slide: false, + acknowledged: true +} +``` + +### Verify all orders placed + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +## Grid Maintenance Loop + +Periodically check for fills and replace completed orders: + +### 1. Check open orders + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +Compare against the expected grid. Missing orders = filled. + +### 2. Check position + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +### 3. Replace filled orders + +- For each filled **buy** at price P: queue a **sell** at P + spacing. +- For each filled **sell** at price P: queue a **buy** at P - spacing. + +Batch all replacement orders into a single `vulcan_trade_multi_limit` call: + +``` +vulcan_trade_multi_limit → { + symbol: "SOL", + bids: [{ price:

, size: 50 }, ...], + asks: [{ price:

, size: 50 }, ...], + slide: false, + acknowledged: true +} +``` + +### 4. Check margin health + +``` +vulcan_margin_status → {} +``` + +If risk_state is not Healthy, pause grid maintenance and alert user. + +### 5. Repeat at regular intervals + +Suggested check interval: 30-60 seconds. + +## Grid Shutdown + +Cancel all grid orders: + +``` +vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } +``` + +Then optionally close any remaining position: + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +## Risk Considerations + +- **Trending markets**: Grid trading profits in ranging markets but loses in strong trends. If price drops below the entire grid, you accumulate a large long position at a loss. If price rises above, you're fully short. +- **Funding rates**: On perpetuals, holding a position incurs funding payments. Check `vulcan_market_ticker` for the funding rate — a high funding rate can erode grid profits. +- **Margin**: All resting limit orders consume margin. A wide grid with many levels can lock up significant collateral. +- **Slippage on replacement**: Replacement orders may not fill at exactly the grid level if the market moves fast. + +## Hard Rules + +1. Never place a live grid without explicit user approval for the full grid plan. +2. Always dry-run the grid math and present to user before placing. +3. Check margin status before placing and during maintenance. +4. Cancel the entire grid before adjusting parameters — never leave orphaned orders. +5. Track total grid P&L (sum of all fill spreads minus fees and funding). +6. Set price boundaries — if price moves outside the grid range, pause and alert. +7. Report all transaction signatures. diff --git a/container/vendor/vulcan/skills/vulcan-lot-size-calculator/SKILL.md b/container/vendor/vulcan/skills/vulcan-lot-size-calculator/SKILL.md new file mode 100644 index 00000000000..b41790cf581 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-lot-size-calculator/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-lot-size-calculator +version: 1.0.0 +description: "Convert desired token amounts to base lots — the most common agent mistake." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-lot-size-calculator + +Use this skill for: +- Converting a desired token amount to base lots before placing an order +- Converting a USD notional to base lots +- Understanding why lot sizes differ per market + +## Why This Matters + +Vulcan trade tools accept `size` in **base lots**, not tokens or USD. Getting this wrong means trading 100x more or less than intended. Always calculate before every trade. + +## Step-by-Step Calculation + +### Step 1: Fetch market info + +``` +vulcan_market_info → { symbol: "SOL" } +``` + +Extract `base_lots_decimals` from the response. + +### Step 2: Convert tokens to base lots + +``` +base_lots = desired_tokens * 10^base_lots_decimals +``` + +### Step 3: Pass base lots to trade tool + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: , acknowledged: true } +``` + +## Worked Examples + +### SOL (base_lots_decimals = 2) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.1 SOL | 0.1 * 10^2 = 0.1 * 100 | 10 | +| 0.5 SOL | 0.5 * 100 | 50 | +| 1 SOL | 1 * 100 | 100 | +| 5 SOL | 5 * 100 | 500 | + +### BTC (base_lots_decimals = 4) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.001 BTC | 0.001 * 10^4 = 0.001 * 10000 | 10 | +| 0.01 BTC | 0.01 * 10000 | 100 | +| 0.1 BTC | 0.1 * 10000 | 1000 | + +### ETH (base_lots_decimals = 3) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.01 ETH | 0.01 * 10^3 = 0.01 * 1000 | 10 | +| 0.1 ETH | 0.1 * 1000 | 100 | +| 1 ETH | 1 * 1000 | 1000 | + +## Converting from USD Notional + +To trade a specific USD amount: + +1. Get current price: `vulcan_market_ticker → { symbol }` +2. Calculate tokens: `desired_tokens = usd_amount / mark_price` +3. Calculate base lots: `base_lots = desired_tokens * 10^base_lots_decimals` + +Example: $100 worth of SOL at $150/SOL, decimals=2: +``` +tokens = 100 / 150 = 0.6667 +base_lots = 0.6667 * 100 = 66.67 → round to 67 +``` + +## Common Mistakes + +1. **Passing token amount as size** — If you want 0.5 SOL and pass `size: 0.5`, you'll get 0.005 SOL (0.5 base lots at decimals=2). Always multiply. + +2. **Using the wrong decimals** — Each market has different `base_lots_decimals`. SOL=2, BTC=4, ETH=3. Always fetch fresh from `vulcan_market_info`. + +3. **Not rounding** — Base lots must be whole numbers. Round to nearest integer after calculation. + +4. **Caching decimals across markets** — Different markets have different decimals. Fetch per-market. diff --git a/container/vendor/vulcan/skills/vulcan-margin-operations/SKILL.md b/container/vendor/vulcan/skills/vulcan-margin-operations/SKILL.md new file mode 100644 index 00000000000..a550a12b473 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-margin-operations/SKILL.md @@ -0,0 +1,90 @@ +--- +name: vulcan-margin-operations +version: 1.0.0 +description: "Deposit, withdraw, transfer collateral, isolated margin, and leverage tier management." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-margin-operations + +Use this skill for: +- Depositing and withdrawing USDC collateral +- Transferring between cross-margin and isolated subaccounts +- Adding collateral to isolated positions +- Checking leverage tiers + +## Check Margin Status + +``` +vulcan_margin_status → {} +``` + +Key fields: collateral, total_unrealized_pnl, risk_state, available_to_withdraw. + +## Deposit USDC + +``` +vulcan_margin_deposit → { amount: 100.0, acknowledged: true } +``` + +Prerequisite: wallet must have USDC. Check with `vulcan_wallet_balance`. + +## Withdraw USDC + +``` +vulcan_margin_withdraw → { amount: 50.0, acknowledged: true } +``` + +Check `available_to_withdraw` from `vulcan_margin_status` first. Cannot withdraw if it would put account into HighRisk state. + +## Transfer Between Subaccounts + +Transfer from cross-margin (subaccount 0) to isolated (subaccount 1+): + +``` +vulcan_margin_transfer → { + from_subaccount: 0, + to_subaccount: 1, + amount: 50.0, + acknowledged: true +} +``` + +## Add Collateral to Isolated Position + +Shorthand for transferring from cross-margin to the isolated subaccount holding a position: + +``` +vulcan_margin_add_collateral → { symbol: "SOL", amount: 25.0, acknowledged: true } +``` + +## Sweep Child to Cross-Margin + +Move all collateral from an isolated subaccount back to cross-margin: + +``` +vulcan_margin_transfer_child_to_parent → { child_subaccount: 1, acknowledged: true } +``` + +## Sync Parent to Child + +Sync parent (cross-margin) state to a child subaccount: + +``` +vulcan_margin_sync_parent_to_child → { child_subaccount: 1, acknowledged: true } +``` + +## Leverage Tiers + +Check max leverage for different position sizes: + +``` +vulcan_margin_leverage_tiers → { symbol: "SOL" } +``` + +Returns a tiered schedule — larger positions get lower max leverage. diff --git a/container/vendor/vulcan/skills/vulcan-market-intel/SKILL.md b/container/vendor/vulcan/skills/vulcan-market-intel/SKILL.md new file mode 100644 index 00000000000..1667553fa3b --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-market-intel/SKILL.md @@ -0,0 +1,76 @@ +--- +name: vulcan-market-intel +version: 1.0.0 +description: "Ticker, orderbook, candles, market info, and pre-trade analysis patterns." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-market-intel + +Use this skill for: +- Getting current price and funding rate +- Analyzing orderbook depth and spread +- Fetching historical candles +- Pre-trade market research + +## List All Markets + +``` +vulcan_market_list → {} +``` + +Returns all active perpetual markets with fees, leverage info, and trading status. + +## Get Price and Funding Rate + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +Key fields: mark_price, index_price, funding_rate, volume_24h, change_24h. + +## Get Market Configuration + +``` +vulcan_market_info → { symbol: "SOL" } +``` + +Key fields: base_lots_decimals, tick_size, taker_fee, maker_fee, leverage_tiers, funding_params. + +## Orderbook Analysis + +``` +vulcan_market_orderbook → { symbol: "SOL", depth: 10 } +``` + +Key fields: bids, asks, mid_price, spread. + +Use this for: +- **Spread check**: Wide spread (>10bps) means higher implicit cost. +- **Slippage estimation**: If order size exceeds liquidity at top levels, expect slippage. +- **Market depth**: How much liquidity is available at each price level. + +## Historical Candles + +``` +vulcan_market_candles → { symbol: "SOL", interval: "1h", limit: 24 } +``` + +Intervals: `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. Default: `1h`, limit: 50. + +## Pre-Trade Analysis Pattern + +Before placing a trade, gather comprehensive market context: + +``` +1. vulcan_market_info → { symbol } # lot sizes, fees, leverage +2. vulcan_market_ticker → { symbol } # current price, funding rate +3. vulcan_market_orderbook → { symbol } # spread, depth, slippage +4. vulcan_market_candles → { symbol, interval: "1h", limit: 24 } # recent price action +``` + +Summarize for the user: current price, 24h change, funding rate, spread, liquidity depth. diff --git a/container/vendor/vulcan/skills/vulcan-onboarding/SKILL.md b/container/vendor/vulcan/skills/vulcan-onboarding/SKILL.md new file mode 100644 index 00000000000..a528914f386 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-onboarding/SKILL.md @@ -0,0 +1,98 @@ +--- +name: vulcan-onboarding +version: 1.0.0 +description: "New user setup: wallet creation, invite registration, first deposit, and verification." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-onboarding + +Use this skill for: +- First-time setup of vulcan +- Creating and configuring a wallet +- Registering a trader account +- Making the first deposit + +## Prerequisites + +- Solana wallet with SOL (for transaction fees) and USDC (for collateral). +- An invite code for Phoenix DEX registration. + +## Step 1: Install and Configure + +```bash +cargo install --path vulcan # from repo +vulcan setup # interactive setup wizard +``` + +Setup creates `~/.vulcan/config.toml` with network endpoints. + +## Step 2: Create a Wallet + +Wallet operations are CLI-only (not available via MCP): + +```bash +vulcan wallet create # interactive: name, password +vulcan wallet import # import existing Solana keypair +vulcan wallet list # verify wallet created +vulcan wallet set-default +``` + +## Step 3: Fund the Wallet + +The wallet needs: +- **SOL** — for Solana transaction fees (~0.01 SOL per transaction). +- **USDC** — for trading collateral. + +Check balances: + +``` +vulcan_wallet_balance → {} +``` + +## Step 4: Register Trader Account + +``` +vulcan_account_register → { invite_code: "YOUR_CODE", acknowledged: true } +``` + +## Step 5: Deposit Collateral + +``` +vulcan_margin_deposit → { amount: 100.0, acknowledged: true } +``` + +## Step 6: Verify Everything + +``` +vulcan_status → {} # checks config, wallet, RPC, API, registration +``` + +All checks should pass. If any fail, the status output includes recovery hints. + +## Step 7: First Trade (Optional) + +Follow the safe order flow from the `vulcan-trade-execution` skill: + +``` +vulcan_market_info → { symbol: "SOL" } +vulcan_market_ticker → { symbol: "SOL" } +vulcan_margin_status → {} +``` + +Then place a small test trade. + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `NO_DEFAULT_WALLET` | `vulcan wallet set-default ` | +| `DECRYPT_FAILED` | Wrong password. Set `VULCAN_WALLET_PASSWORD` | +| `NO_TRADER_ACCOUNT` | Register with invite code | +| `CONFIG_ERROR` | Run `vulcan setup` | +| Insufficient SOL | Fund wallet with SOL for tx fees | +| Insufficient USDC | Transfer USDC to wallet address | diff --git a/container/vendor/vulcan/skills/vulcan-portfolio-intel/SKILL.md b/container/vendor/vulcan/skills/vulcan-portfolio-intel/SKILL.md new file mode 100644 index 00000000000..ff4079d64ea --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-portfolio-intel/SKILL.md @@ -0,0 +1,75 @@ +--- +name: vulcan-portfolio-intel +version: 1.0.0 +description: "Full portfolio snapshot: margin status, positions, orders, and funding rate awareness." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-portfolio-intel + +Use this skill for: +- Daily portfolio reviews +- Presenting account status to the user +- Monitoring margin health and PnL +- Understanding funding exposure + +## Portfolio Snapshot + +Run these calls (they can be called in parallel): + +``` +vulcan_margin_status → {} # collateral, total PnL, risk state, available to withdraw +vulcan_position_list → {} # all open positions with unrealized PnL +vulcan_trade_orders → {} # all resting limit orders +``` + +## Interpreting Margin Status + +Key fields: +- `collateral` — Total USDC deposited. +- `total_unrealized_pnl` — Combined PnL across all positions. +- `risk_state` — Healthy, HighRisk, or Liquidatable. +- `available_to_withdraw` — USDC that can be withdrawn without affecting positions. +- `initial_margin_used` — Margin locked by open positions and orders. + +## Interpreting Positions + +Key fields per position: +- `symbol`, `side` (Long/Short), `size` — What you hold. +- `entry_price`, `mark_price` — Where you entered vs current price. +- `unrealized_pnl` — Current profit/loss. +- `liquidation_price` — Price at which position gets liquidated. + +## Interpreting Orders + +Key fields per order: +- `symbol`, `side`, `order_type` — What's resting. +- `size`, `price` — Order parameters. +- `filled` — How much has filled so far. + +Note: Resting limit orders consume margin even before filling. + +## Funding Rate Check + +For each open position, check funding exposure: + +``` +vulcan_market_ticker → { symbol } # funding_rate field +``` + +- Positive rate: Longs pay shorts (costs you money if long). +- Negative rate: Shorts pay longs (costs you money if short). + +## Presenting to User + +Summarize: +1. Account health (risk state, collateral, total PnL). +2. Each position: symbol, side, size, entry, mark, PnL, liquidation price. +3. Resting orders: symbol, side, type, size, price. +4. Funding rate exposure for held positions. +5. Available to withdraw. diff --git a/container/vendor/vulcan/skills/vulcan-position-management/SKILL.md b/container/vendor/vulcan/skills/vulcan-position-management/SKILL.md new file mode 100644 index 00000000000..88158ca3b8e --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-position-management/SKILL.md @@ -0,0 +1,102 @@ +--- +name: vulcan-position-management +version: 1.0.0 +description: "List, show, close, reduce positions and manage TP/SL on existing positions." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-position-management + +Use this skill for: +- Viewing open positions +- Closing or reducing positions +- Attaching TP/SL to existing positions +- Monitoring position PnL and liquidation price + +## List All Positions + +``` +vulcan_position_list → {} +``` + +Returns all open positions with: symbol, side, size, entry price, mark price, unrealized PnL. + +## Show Position Detail + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +Returns detailed info: PnL, margin, liquidation price, TP/SL prices, subaccount info. + +## Close Entire Position + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +Closes via market order on the opposite side. Verify with: + +``` +vulcan_position_list → {} # confirm position is gone +``` + +## Reduce Position + +Partially reduce a position by a specified size (in base lots): + +``` +vulcan_position_reduce → { symbol: "SOL", size: 25, acknowledged: true } +``` + +## Attach TP/SL to Existing Position + +Two tools can set TP/SL on an existing position: + +### Using position tool (bracket orders) + +``` +vulcan_position_tp_sl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +### Using trade tool (set/modify) + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +Both auto-detect position side. Direction rules: +- Long: TP > current price, SL < current price. +- Short: TP < current price, SL > current price. + +You can set just TP, just SL, or both. + +## Cancel TP/SL + +``` +vulcan_trade_cancel_tpsl → { symbol: "SOL", tp: true, sl: true, acknowledged: true } +``` + +Set `tp: true` to cancel take-profit, `sl: true` to cancel stop-loss, or both. + +## View TP/SL + +TP/SL prices are in `vulcan_position_show` response, NOT in `vulcan_trade_orders`. + +``` +vulcan_position_show → { symbol: "SOL" } +# Look for take_profit_price and stop_loss_price fields +``` + +## Position Management Flow + +1. Review positions: `vulcan_position_list` +2. Get details on specific position: `vulcan_position_show → { symbol }` +3. If needed, adjust TP/SL: `vulcan_trade_set_tpsl → { symbol, tp?, sl?, acknowledged: true }` +4. If needed, reduce: `vulcan_position_reduce → { symbol, size, acknowledged: true }` +5. If needed, close: `vulcan_position_close → { symbol, acknowledged: true }` diff --git a/container/vendor/vulcan/skills/vulcan-risk-management/SKILL.md b/container/vendor/vulcan/skills/vulcan-risk-management/SKILL.md new file mode 100644 index 00000000000..beb44e0e289 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-risk-management/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-risk-management +version: 1.0.0 +description: "Pre-trade risk checks, leverage tiers, margin health thresholds, and when-to-warn rules." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-risk-management + +Use this skill for: +- Pre-trade risk assessment +- Monitoring margin health +- Understanding leverage tiers +- Deciding when to warn the user + +## Pre-Trade Risk Checklist + +Before every trade, call these tools: + +``` +1. vulcan_margin_status → {} # risk_state, collateral, PnL +2. vulcan_position_list → {} # existing positions +3. vulcan_trade_orders → { symbol } # resting orders consuming margin +4. vulcan_market_orderbook → { symbol } # slippage check for market orders +``` + +## Margin Health States + +| State | Meaning | Action | +|-------|---------|--------| +| `Healthy` | Sufficient collateral | Safe to trade | +| `HighRisk` | Margin getting thin | Warn user before any new trades | +| `Liquidatable` | At risk of liquidation | Do NOT open new positions. Suggest reducing exposure or adding collateral | + +## Leverage Tiers + +Markets have tiered leverage limits. Larger positions get lower max leverage. + +``` +vulcan_margin_leverage_tiers → { symbol: "SOL" } +``` + +The first tier gives max leverage for typical sizes. Always check before proposing a trade. + +## Funding Rate Awareness + +``` +vulcan_market_ticker → { symbol: "SOL" } # check funding_rate field +``` + +- Positive rate: Longs pay shorts. +- Negative rate: Shorts pay longs. +- For longer-duration positions, factor funding costs into the trade thesis. + +## Position Sizing + +When the user doesn't specify exact size: +1. Ask their risk tolerance (USD or % of collateral). +2. Fetch `vulcan_market_info` for lot size conversion. +3. Calculate position size. +4. Present the calculation before executing. + +## When to Warn + +Alert the user when: +- Risk state is anything other than Healthy. +- A trade would use >50% of available margin. +- Liquidation price is within 10% of mark price. +- Funding rate is elevated (>0.01% per interval). +- Orderbook spread is wide (>10bps). +- They're about to increase an already-large position. + +## Slippage Check + +For market orders, check the orderbook: + +``` +vulcan_market_orderbook → { symbol: "SOL", depth: 10 } +``` + +If order size is large relative to available liquidity at the best levels, warn about potential slippage. + +## Hard Rules + +1. Never trade without user confirmation. +2. Never deposit or withdraw without user confirmation. +3. Always check margin before opening new positions. +4. Never exceed available margin. +5. Always report transaction signatures. diff --git a/container/vendor/vulcan/skills/vulcan-shared/SKILL.md b/container/vendor/vulcan/skills/vulcan-shared/SKILL.md new file mode 100644 index 00000000000..9e3d01b4f43 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-shared/SKILL.md @@ -0,0 +1,75 @@ +--- +name: vulcan-shared +version: 1.0.0 +description: "Shared runtime contract for vulcan: auth, invocation, symbol format, size units, and safety." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-shared + +**This tool is experimental. Commands execute real financial transactions on Solana mainnet. Test with `--dry-run` before using real funds.** + +## Invocation Contract + +### MCP (preferred) + +Tools are named `vulcan__`. Call them directly via MCP tool calls. Dangerous tools require `acknowledged: true`. + +### CLI (fallback) + +```bash +vulcan [args...] -o json +``` + +- Parse `stdout` only (JSON). +- Treat `stderr` as diagnostics. +- Exit code `0` = success. +- Non-zero = failure with JSON error envelope in stdout. + +## Authentication + +MCP server unlocks wallet at startup via `VULCAN_WALLET_PASSWORD` env var. No per-call prompts. + +For CLI: `export VULCAN_WALLET_PASSWORD=your_password` + +## Symbol Format + +Uppercase ticker only: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. + +No `-PERP` suffix. Run `vulcan_market_list` to discover active markets. + +## Size Units — Base Lots + +The `size` parameter is in **base lots**, not tokens or USD. Always call `vulcan_market_info` first. + +**Conversion**: `base_lots = desired_tokens * 10^base_lots_decimals` + +## Error Routing + +Route on `.error.category`: +- `validation` — Fix inputs, do not retry. +- `auth` — Check wallet/password. +- `network` — Retry with exponential backoff. +- `tx_failed` — **Verify state before retrying.** Never blind-retry on-chain tx. +- `dangerous_gate` — Set `acknowledged: true`. + +## Safety + +Require explicit human approval before: +- Buy or sell orders (market and limit) +- Order cancellations +- Position close or reduce +- Deposits, withdrawals, and transfers +- TP/SL changes +- Account registration + +Hard rules: +1. Always call `vulcan_market_info` before trading. +2. Always call `vulcan_margin_status` before opening positions. +3. Always call `vulcan_position_list` before trading. +4. Never guess lot sizes. +5. Report all transaction signatures. diff --git a/container/vendor/vulcan/skills/vulcan-tpsl-management/SKILL.md b/container/vendor/vulcan/skills/vulcan-tpsl-management/SKILL.md new file mode 100644 index 00000000000..c3dc4f44df7 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-tpsl-management/SKILL.md @@ -0,0 +1,92 @@ +--- +name: vulcan-tpsl-management +version: 1.0.0 +description: "Take-profit and stop-loss: direction rules, constraints, and set/cancel flows." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-tpsl-management + +Use this skill for: +- Setting TP/SL when opening a position +- Attaching TP/SL to an existing position +- Modifying or cancelling TP/SL +- Understanding TP/SL constraints and gotchas + +## Direction Rules + +### Long positions (buy) + +- Take-profit price MUST be **above** entry price. +- Stop-loss price MUST be **below** entry price. + +### Short positions (sell) + +- Take-profit price MUST be **below** entry price. +- Stop-loss price MUST be **above** entry price. + +## Setting TP/SL at Order Time + +Attach to market orders: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", size: 50, + tp: 160.0, sl: 140.0, + acknowledged: true +} +``` + +**Critical constraint**: TP/SL at order time only works when **opening or extending** a position. If the market order **reduces** an existing position, the entire transaction rolls back (market order does not execute either). + +## Setting TP/SL on Existing Position + +### Method 1: Trade tool + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +### Method 2: Position tool + +``` +vulcan_position_tp_sl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +Both auto-detect position side. You can set just TP, just SL, or both. + +## Modifying TP/SL + +To change existing TP/SL, call set again with new values. The new values replace the old ones: + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 165.0, acknowledged: true } +``` + +## Cancelling TP/SL + +``` +vulcan_trade_cancel_tpsl → { symbol: "SOL", tp: true, sl: true, acknowledged: true } +``` + +Set `tp: true` to cancel take-profit, `sl: true` to cancel stop-loss, or both. + +## Viewing TP/SL + +TP/SL are **trigger orders** — they appear in `vulcan_position_show`, NOT in `vulcan_trade_orders`. + +``` +vulcan_position_show → { symbol: "SOL" } +# Look for: take_profit_price, stop_loss_price +``` + +## Common Mistakes + +1. **Wrong direction**: TP must be on the profitable side. For longs, TP > entry. For shorts, TP < entry. +2. **Setting on a reduce order**: TP/SL fails if the market order reduces a position. Use `vulcan_trade_set_tpsl` on the existing position instead. +3. **Looking for TP/SL in orders**: They won't appear in `vulcan_trade_orders`. Check `vulcan_position_show`. diff --git a/container/vendor/vulcan/skills/vulcan-trade-execution/SKILL.md b/container/vendor/vulcan/skills/vulcan-trade-execution/SKILL.md new file mode 100644 index 00000000000..a678ae618f3 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-trade-execution/SKILL.md @@ -0,0 +1,143 @@ +--- +name: vulcan-trade-execution +version: 1.0.0 +description: "Execute perpetual futures orders with pre-trade checks and post-trade verification." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared", "vulcan-lot-size-calculator"] +--- + +# vulcan-trade-execution + +Use this skill for: +- Placing market or limit orders on Phoenix DEX +- Attaching TP/SL to new orders +- Cancelling orders +- The complete safe order flow + +## Safe Market Order Flow + +### 1. Gather market context + +``` +vulcan_market_info → { symbol: "SOL" } # lot sizes, fees, leverage tiers +vulcan_market_ticker → { symbol: "SOL" } # current price, funding rate +vulcan_margin_status → {} # available collateral, risk state +vulcan_position_list → {} # existing positions +vulcan_trade_orders → { symbol: "SOL" } # existing resting orders +``` + +### 2. Calculate size + +From `vulcan_market_info`, extract `base_lots_decimals`: +``` +base_lots = desired_tokens * 10^base_lots_decimals +``` +Example: Want 0.5 SOL, decimals=2 → 0.5 * 100 = 50 base lots. + +### 3. Validate against constraints + +- Ensure `vulcan_margin_status` shows risk_state = Healthy. +- Check leverage tiers — larger positions have lower max leverage. +- Factor in existing positions (same-side increases exposure, opposite-side reduces). + +### 4. Confirm with user + +Present: symbol, direction, size (base lots + token equivalent), order type, estimated fees, mark price, existing positions. + +### 5. Execute + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: 50, acknowledged: true } +``` + +### 6. Verify + +``` +vulcan_position_list → {} # confirm position opened +``` + +Report the transaction signature to the user. + +## Market Order with TP/SL + +Attach take-profit and/or stop-loss at order time: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + tp: 160.0, + sl: 140.0, + acknowledged: true +} +``` + +**Direction rules:** +- Long (buy): TP must be above entry, SL must be below entry. +- Short (sell): TP must be below entry, SL must be above entry. + +**Constraints:** +- TP/SL only works when opening or extending a position. Fails if the order reduces a position (entire tx rolls back). +- TP/SL shows in `vulcan_position_show`, NOT in `vulcan_trade_orders`. + +## Limit Orders + +``` +vulcan_trade_limit_buy → { + symbol: "SOL", + size: 50, + price: 145.00, + acknowledged: true +} +``` + +Limit orders rest on the book. They pay maker fees (typically lower). After placing, verify with: + +``` +vulcan_trade_orders → { symbol: "SOL" } # confirm order on book +``` + +## Isolated Margin Orders + +For markets requiring isolated margin, or when you want dedicated collateral: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + isolated: true, + collateral: 100.0, + acknowledged: true +} +``` + +## Reduce-Only Orders + +To ensure an order only reduces (never increases) a position: + +``` +vulcan_trade_market_sell → { + symbol: "SOL", + size: 25, + reduce_only: true, + acknowledged: true +} +``` + +## Cancel Orders + +``` +vulcan_trade_orders → { symbol: "SOL" } # get order IDs +vulcan_trade_cancel → { symbol: "SOL", order_ids: ["id1"], acknowledged: true } +vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } # cancel all +``` + +## Hard Rules + +- Never execute orders without explicit user approval (unless in auto-execute mode). +- Route failures by `.error.category`. +- On `tx_failed`, check position state before retrying. diff --git a/container/vendor/vulcan/skills/vulcan-twap-execution/SKILL.md b/container/vendor/vulcan/skills/vulcan-twap-execution/SKILL.md new file mode 100644 index 00000000000..3d6b2bb06f7 --- /dev/null +++ b/container/vendor/vulcan/skills/vulcan-twap-execution/SKILL.md @@ -0,0 +1,155 @@ +--- +name: vulcan-twap-execution +version: 1.0.0 +description: "Execute large orders as time-weighted slices to reduce market impact on Phoenix DEX." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator"] +--- + +# vulcan-twap-execution + +Use this skill for: +- Breaking a large order into smaller time-spaced slices +- Reducing market impact and slippage on size +- Executing over minutes or hours +- Tracking average fill price across slices + +## Core Concept + +Time-Weighted Average Price (TWAP) splits a large order into N equal slices executed at regular intervals. The goal is an average fill price close to the time-weighted market average, reducing the impact a single large order would have on the book. + +Vulcan does not have a built-in scheduler — the agent manages the loop externally. + +## Parameters + +Agree on these with the user before starting: +- **Symbol**: e.g., SOL +- **Side**: buy or sell +- **Total size**: in tokens (agent converts to base lots) +- **Slices**: number of child orders (e.g., 5-10) +- **Interval**: time between slices (e.g., 60s, 300s) + +## Pre-TWAP Checks + +``` +1. vulcan_market_info → { symbol: "SOL" } # base_lots_decimals, fees +2. vulcan_market_ticker → { symbol: "SOL" } # current price, volume +3. vulcan_market_orderbook → { symbol: "SOL" } # depth — is there enough liquidity per slice? +4. vulcan_margin_status → {} # enough collateral for total position? +5. vulcan_position_list → {} # existing exposure +``` + +## Calculate Slice Size + +``` +total_base_lots = total_tokens * 10^base_lots_decimals +slice_lots = total_base_lots / slices # round to integer +``` + +Example: 5 SOL over 5 slices, decimals=2: +``` +total_base_lots = 5 * 100 = 500 +slice_lots = 500 / 5 = 100 base lots per slice +``` + +Verify: `slice_lots * slices` should equal `total_base_lots`. Adjust last slice for remainder. + +## Confirm with User + +Present before starting: +- Total: 5 SOL (500 base lots) across 5 slices +- Per slice: 1 SOL (100 base lots) +- Interval: 60 seconds +- Estimated total fees: `total_base_lots * price * taker_fee * 2` (if round-trip) +- Get explicit approval to begin the TWAP. + +## Market Order TWAP Loop + +For each slice: + +### 1. Check price hasn't moved beyond tolerance + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +If price has moved >X% from the start price, pause and alert the user. + +### 2. Execute slice + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: 100, acknowledged: true } +``` + +### 3. Record fill details + +Save: slice number, timestamp, fill price, tx signature. + +### 4. Wait for interval + +Wait the agreed interval before the next slice. + +### 5. Repeat until all slices complete + +## Limit-Order TWAP Variant + +Use limit orders at the current best bid/ask for potentially better fills (maker fees): + +### 1. Read current price + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +### 2. Place limit order at or near the ask (for buys) + +``` +vulcan_trade_limit_buy → { symbol: "SOL", size: 100, price: , acknowledged: true } +``` + +### 3. Wait, then check fill status + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +### 4. If unfilled after interval, cancel and place next slice + +``` +vulcan_trade_cancel → { symbol: "SOL", order_ids: [""], acknowledged: true } +``` + +Then adjust price and place next slice. Track unfilled volume to add to remaining slices. + +## Tracking Average Fill + +After all slices, compute the volume-weighted average price: + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +The `entry_price` field reflects the average across all fills for the position. + +For detailed per-slice tracking, the agent should maintain its own log of each slice's fill price and size. + +## Handling Errors Mid-Loop + +- **On `network` error**: Pause, retry the current slice after backoff. +- **On `tx_failed`**: Check position state before retrying. See `vulcan-error-recovery` skill. +- **On `rate_limit`**: Wait and retry. A 60s interval between slices should avoid rate limits. +- **Never skip a slice on error** — pause the loop, diagnose, then resume. + +## Hard Rules + +1. Each TWAP session requires human approval before the first slice. +2. In confirm-each mode, confirm each individual slice. In auto-execute mode, log every slice. +3. Track cumulative fill volume — stop if total exceeds target (handle partial fills from limit orders). +4. On any error, pause the loop rather than skipping the slice. +5. If price moves beyond user-defined tolerance, pause and alert. +6. Report all transaction signatures. +7. Present final summary: total filled, average price, total fees, time elapsed. diff --git a/container/vendor/vulcan/vulcan-lib/Cargo.toml b/container/vendor/vulcan/vulcan-lib/Cargo.toml new file mode 100644 index 00000000000..e03b0433c65 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "vulcan-lib" +version = "0.1.0" +edition = "2021" +description = "Core library for the Vulcan CLI" + +[dependencies] +# Rise SDK +phoenix-sdk = { workspace = true } +phoenix-types = { workspace = true } +phoenix-math-utils = { workspace = true } + +# CLI +clap = { workspace = true } + +# Async +tokio = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } + +# HTTP +reqwest = { workspace = true } + +# Solana +solana-sdk = { workspace = true } +solana-pubkey = { workspace = true } +solana-rpc-client = { workspace = true } + +# Encryption +aes-gcm = { workspace = true } +argon2 = { workspace = true } +rand = { workspace = true } +zeroize = { workspace = true } + +# Encoding +bs58 = { workspace = true } +base64 = { workspace = true } + +# Output +comfy-table = { workspace = true } +colored = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } + +# Time +chrono = { workspace = true } + +# Password input +rpassword = { workspace = true } + +# Directories +dirs = { workspace = true } + +# MCP +rmcp = { workspace = true } +schemars = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/account.rs b/container/vendor/vulcan/vulcan-lib/src/cli/account.rs new file mode 100644 index 00000000000..e61144a2a20 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/account.rs @@ -0,0 +1,30 @@ +//! Account subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum AccountCommand { + /// Register a trader account on Phoenix + Register { + /// Invite code for registration + #[arg(long)] + invite_code: String, + }, + + /// Show trader account details (PDA, subaccounts, margin mode) + Info, + + /// List all subaccounts + Subaccounts, + + /// Create a new subaccount + CreateSubaccount { + /// PDA index + #[arg(long, default_value = "0")] + pda_index: u8, + + /// Subaccount index + #[arg(long)] + subaccount_index: u8, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/history.rs b/container/vendor/vulcan/vulcan-lib/src/cli/history.rs new file mode 100644 index 00000000000..b0b83ad3df0 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/history.rs @@ -0,0 +1,53 @@ +//! History subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum HistoryCommand { + /// Past trade/fill history + Trades { + /// Filter by market symbol + #[arg(long)] + symbol: Option, + /// Max results to return + #[arg(long, default_value = "20")] + limit: i64, + }, + + /// Past order history + Orders { + /// Filter by market symbol + #[arg(long)] + symbol: Option, + /// Max results to return + #[arg(long, default_value = "20")] + limit: i64, + }, + + /// Deposit/withdrawal history + Collateral { + /// Max results to return + #[arg(long, default_value = "20")] + limit: i64, + }, + + /// Funding payment history + Funding { + /// Filter by market symbol + #[arg(long)] + symbol: Option, + /// Max results to return + #[arg(long, default_value = "20")] + limit: i64, + }, + + /// PnL over time + Pnl { + /// Resolution: hourly or daily + #[arg(long, default_value = "hourly")] + resolution: String, + /// Max results to return + #[arg(long, default_value = "24")] + limit: i64, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/margin.rs b/container/vendor/vulcan/vulcan-lib/src/cli/margin.rs new file mode 100644 index 00000000000..4783ad4153e --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/margin.rs @@ -0,0 +1,61 @@ +//! Margin subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum MarginCommand { + /// Show cross-margin health, equity, maintenance margin, available balance + Status, + + /// Deposit USDC collateral + Deposit { + /// Amount in USDC + amount: f64, + }, + + /// Withdraw USDC collateral + Withdraw { + /// Amount in USDC + amount: f64, + }, + + /// Transfer collateral between subaccounts + Transfer { + /// Amount in USDC + amount: f64, + /// Source subaccount index (0 = cross-margin) + #[arg(long)] + from: u8, + /// Destination subaccount index + #[arg(long)] + to: u8, + }, + + /// Sweep all collateral from child subaccount back to cross-margin + TransferChildToParent { + /// Child subaccount index to sweep + #[arg(long)] + child: u8, + }, + + /// Sync parent state to child subaccount + SyncParentToChild { + /// Child subaccount index + #[arg(long)] + child: u8, + }, + + /// Show leverage tier schedule for a market + LeverageTiers { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// Add collateral to an isolated position by symbol + AddCollateral { + /// Market symbol (e.g., SOL) + symbol: String, + /// Amount of USDC to add + amount: f64, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/market.rs b/container/vendor/vulcan/vulcan-lib/src/cli/market.rs new file mode 100644 index 00000000000..2f890c3ea79 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/market.rs @@ -0,0 +1,65 @@ +//! Market subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum MarketCommand { + /// List all available perpetual markets + List, + + /// Detailed market configuration (tick size, lot size, fees, leverage tiers) + Info { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// Current price, 24h volume, open interest, funding rate + Ticker { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// L2 orderbook snapshot + Orderbook { + /// Market symbol (e.g., SOL) + symbol: String, + + /// Number of price levels to display + #[arg(long, default_value = "10")] + depth: usize, + }, + + /// OHLCV candle data + Candles { + /// Market symbol (e.g., SOL) + symbol: String, + + /// Candle interval + #[arg(long, default_value = "1h")] + interval: String, + + /// Number of candles to fetch + #[arg(long, default_value = "20")] + limit: usize, + }, + + /// Recent trades for a market + Trades { + /// Market symbol (e.g., SOL) + symbol: String, + + /// Number of trades to fetch + #[arg(long, default_value = "20")] + limit: usize, + }, + + /// Historical funding rate data + FundingRates { + /// Market symbol (e.g., SOL) + symbol: String, + + /// Number of entries to fetch + #[arg(long, default_value = "20")] + limit: usize, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/mod.rs b/container/vendor/vulcan/vulcan-lib/src/cli/mod.rs new file mode 100644 index 00000000000..7cec238c052 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/mod.rs @@ -0,0 +1,114 @@ +//! CLI definition — clap derive structs for all command groups. + +pub mod account; +pub mod history; +pub mod margin; +pub mod market; +pub mod position; +pub mod trade; +pub mod wallet; + +use crate::output::OutputFormat; +use clap::{Parser, Subcommand}; + +/// Vulcan — AI-native CLI for Phoenix Perpetuals DEX on Solana. +#[derive(Debug, Parser)] +#[command( + name = "vulcan", + version, + about = "Vulcan — AI-native CLI for Phoenix Perpetuals DEX on Solana" +)] +pub struct Cli { + /// Output format + #[arg(short, long, value_enum, default_value = "table", global = true)] + pub output: OutputFormat, + + /// Simulate the operation without submitting a transaction + #[arg(long, default_value = "false", global = true)] + pub dry_run: bool, + + /// Skip confirmation prompts + #[arg(short, long, default_value = "false", global = true)] + pub yes: bool, + + /// Solana RPC endpoint override + #[arg(long, global = true)] + pub rpc_url: Option, + + /// Phoenix API endpoint override + #[arg(long, global = true)] + pub api_url: Option, + + /// Phoenix API key override + #[arg(long, global = true)] + pub api_key: Option, + + /// Wallet name or path override + #[arg(short, long, global = true)] + pub wallet: Option, + + /// Enable verbose/debug logging to stderr + #[arg(short, long, default_value = "false", global = true)] + pub verbose: bool, + + /// Watch for live updates via WebSocket + #[arg(long, default_value = "false", global = true)] + pub watch: bool, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Wallet management — create, import, list, and manage encrypted wallets + #[command(subcommand)] + Wallet(wallet::WalletCommand), + + /// Market data — prices, orderbooks, candles, funding rates + #[command(subcommand)] + Market(market::MarketCommand), + + /// Order management — place, cancel, and manage orders + #[command(subcommand)] + Trade(trade::TradeCommand), + + /// Position management — view and manage open positions + #[command(subcommand)] + Position(position::PositionCommand), + + /// Collateral management — deposit, withdraw, and monitor margin + #[command(subcommand)] + Margin(margin::MarginCommand), + + /// Account management — registration, info, subaccounts + #[command(subcommand)] + Account(account::AccountCommand), + + /// Trade and account history + #[command(subcommand)] + History(history::HistoryCommand), + + /// Check configuration, connectivity, wallet, and registration status + Status, + + /// Interactive setup wizard — wallet, config, and connectivity + Setup, + + /// Print version and build information + Version, + + /// Print agent runtime context (CONTEXT.md) to stdout + AgentContext, + + /// Start MCP server over stdio + Mcp { + /// Allow dangerous commands without explicit acknowledgment + #[arg(long)] + allow_dangerous: bool, + + /// Command groups to expose (comma-separated) + #[arg(long, value_delimiter = ',')] + groups: Option>, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/position.rs b/container/vendor/vulcan/vulcan-lib/src/cli/position.rs new file mode 100644 index 00000000000..2906619e243 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/position.rs @@ -0,0 +1,41 @@ +//! Position subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum PositionCommand { + /// List all open positions + List, + + /// Detailed view of a specific position + Show { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// Close an entire position + Close { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// Reduce a position by a specified size + Reduce { + /// Market symbol (e.g., SOL) + symbol: String, + /// Size to reduce by (in base lots) + size: f64, + }, + + /// Attach take-profit and/or stop-loss to an existing position + TpSl { + /// Market symbol (e.g., SOL) + symbol: String, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/trade.rs b/container/vendor/vulcan/vulcan-lib/src/cli/trade.rs new file mode 100644 index 00000000000..4121f514c4e --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/trade.rs @@ -0,0 +1,147 @@ +//! Trade subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum TradeCommand { + /// Place a market buy order + MarketBuy { + /// Market symbol (e.g., SOL) + symbol: String, + /// Order size in base lots + size: f64, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + /// Use isolated margin + #[arg(long)] + isolated: bool, + /// USDC collateral for isolated subaccount + #[arg(long, requires = "isolated")] + collateral: Option, + /// Reduce-only order + #[arg(long)] + reduce_only: bool, + }, + + /// Place a market sell order + MarketSell { + /// Market symbol (e.g., SOL) + symbol: String, + /// Order size in base lots + size: f64, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + /// Use isolated margin + #[arg(long)] + isolated: bool, + /// USDC collateral for isolated subaccount + #[arg(long, requires = "isolated")] + collateral: Option, + /// Reduce-only order + #[arg(long)] + reduce_only: bool, + }, + + /// Place a limit buy order + LimitBuy { + /// Market symbol (e.g., SOL) + symbol: String, + /// Order size in base lots + size: f64, + /// Limit price + price: f64, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + /// Use isolated margin + #[arg(long)] + isolated: bool, + /// USDC collateral for isolated subaccount + #[arg(long, requires = "isolated")] + collateral: Option, + /// Reduce-only order + #[arg(long)] + reduce_only: bool, + }, + + /// Place a limit sell order + LimitSell { + /// Market symbol (e.g., SOL) + symbol: String, + /// Order size in base lots + size: f64, + /// Limit price + price: f64, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + /// Use isolated margin + #[arg(long)] + isolated: bool, + /// USDC collateral for isolated subaccount + #[arg(long, requires = "isolated")] + collateral: Option, + /// Reduce-only order + #[arg(long)] + reduce_only: bool, + }, + + /// Cancel specific orders by ID + Cancel { + /// Market symbol (e.g., SOL) + symbol: String, + /// Order IDs to cancel + #[arg(required = true)] + order_ids: Vec, + }, + + /// Cancel all open orders for a market + CancelAll { + /// Market symbol (e.g., SOL) + symbol: String, + }, + + /// List open orders (optionally filter by market) + Orders { + /// Market symbol (e.g., SOL). Omit to list all. + symbol: Option, + }, + + /// Set take-profit and/or stop-loss on an existing position + SetTpsl { + /// Market symbol (e.g., SOL) + symbol: String, + /// Take-profit price + #[arg(long)] + tp: Option, + /// Stop-loss price + #[arg(long)] + sl: Option, + }, + + /// Cancel take-profit and/or stop-loss on an existing position + CancelTpsl { + /// Market symbol (e.g., SOL) + symbol: String, + /// Cancel take-profit + #[arg(long)] + tp: bool, + /// Cancel stop-loss + #[arg(long)] + sl: bool, + }, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/cli/wallet.rs b/container/vendor/vulcan/vulcan-lib/src/cli/wallet.rs new file mode 100644 index 00000000000..11acd5cfbb5 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/cli/wallet.rs @@ -0,0 +1,70 @@ +//! Wallet subcommand definitions. + +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum WalletCommand { + /// Generate a new Solana keypair, encrypt, and store + Create { + /// Name for the new wallet + #[arg(long)] + name: String, + }, + + /// Import a wallet from base58 private key, byte array, or Solana CLI JSON file + Import { + /// Name for the imported wallet + #[arg(long)] + name: String, + + /// Import format + #[arg(long, value_enum, default_value = "base58")] + format: ImportFormat, + + /// Source: base58 string, byte array, or file path (depending on --format) + source: String, + }, + + /// List all stored wallets + List, + + /// Show wallet details (pubkey, default status) + Show { + /// Wallet name + name: String, + }, + + /// Set a wallet as the default for all commands + SetDefault { + /// Wallet name + name: String, + }, + + /// Remove a wallet from local storage + Remove { + /// Wallet name + name: String, + }, + + /// Export wallet public key (never exports private key) + Export { + /// Wallet name + name: String, + }, + + /// Show SOL and USDC balances for a wallet + Balance { + /// Wallet name (defaults to default wallet) + name: Option, + }, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +pub enum ImportFormat { + /// Base58 encoded private key + Base58, + /// Byte array (JSON) + Bytes, + /// Solana CLI JSON file + File, +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/account.rs b/container/vendor/vulcan/vulcan-lib/src/commands/account.rs new file mode 100644 index 00000000000..438b5df1fb8 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/account.rs @@ -0,0 +1,403 @@ +//! Account command execution. + +use crate::cli::account::AccountCommand; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use serde::Serialize; +use solana_pubkey::Pubkey; +use std::str::FromStr; + +// ── Result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct RegisterResult { + pub authority: String, + pub trader_pda: String, + pub dry_run: bool, + pub tx_signature: Option, +} + +impl TableRenderable for RegisterResult { + fn render_table(&self) { + if self.dry_run { + println!("[DRY RUN] Would register trader account:"); + } else { + println!("Trader account registered:"); + } + println!(" Authority: {}", self.authority); + println!(" Trader PDA: {}", self.trader_pda); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct AccountInfoResult { + pub authority: String, + pub trader_key: String, + pub pda_index: u8, + pub subaccount_index: u8, + pub state: String, + pub collateral_balance: String, + pub portfolio_value: String, + pub risk_state: String, + pub risk_tier: String, + pub num_positions: usize, + pub num_open_orders: usize, + pub max_positions: u64, +} + +impl TableRenderable for AccountInfoResult { + fn render_table(&self) { + println!("Account Info:"); + println!(" Authority: {}", self.authority); + println!(" Trader key: {}", self.trader_key); + println!(" PDA index: {}", self.pda_index); + println!(" Subaccount index: {}", self.subaccount_index); + println!(" State: {}", self.state); + println!(" Collateral: {}", self.collateral_balance); + println!(" Portfolio value: {}", self.portfolio_value); + println!(" Risk state: {}", self.risk_state); + println!(" Risk tier: {}", self.risk_tier); + println!(" Positions: {}/{}", self.num_positions, self.max_positions); + println!(" Open orders: {}", self.num_open_orders); + } +} + +#[derive(Debug, Serialize)] +pub struct SubaccountListResult { + pub authority: String, + pub subaccounts: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SubaccountInfo { + pub trader_key: String, + pub pda_index: u8, + pub subaccount_index: u8, + pub state: String, + pub collateral_balance: String, + pub num_positions: usize, + pub margin_type: String, +} + +impl TableRenderable for SubaccountListResult { + fn render_table(&self) { + if self.subaccounts.is_empty() { + println!("No subaccounts found."); + return; + } + let rows: Vec> = self + .subaccounts + .iter() + .map(|s| { + vec![ + format!("{}", s.subaccount_index), + s.margin_type.clone(), + s.state.clone(), + s.collateral_balance.clone(), + s.num_positions.to_string(), + ] + }) + .collect(); + crate::output::table::render_table( + &["Subaccount", "Type", "State", "Collateral", "Positions"], + rows, + ); + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn resolve_authority(ctx: &AppContext) -> Result<(String, Pubkey), VulcanError> { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let authority = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + Ok((wallet_name, authority)) +} + +// ── Execution ─────────────────────────────────────────────────────────── + +pub async fn execute(ctx: &AppContext, cmd: AccountCommand) -> Result<(), VulcanError> { + match cmd { + AccountCommand::Register { invite_code } => { + let (wallet_name, authority) = resolve_authority(ctx)?; + + // Step 1: Register via HTTP API (activate invite code) + let _reg_result = ctx + .http_client + .register_trader(&authority, &invite_code) + .await + .map_err(|e| VulcanError::api("REGISTER_API_FAILED", e.to_string()))?; + + // Step 2: Check if trader already exists on-chain + let already_registered = ctx + .http_client + .get_traders(&authority) + .await + .map(|traders| traders.iter().any(|t| t.trader_subaccount_index == 0)) + .unwrap_or(false); + + let sig = if already_registered { + eprintln!("Trader account already registered, skipping on-chain transaction."); + None + } else { + // Step 3: Build and submit on-chain registration transaction + let builder = ctx.tx_builder().await?; + let ixs = builder + .build_register_trader(authority, 0, 0) + .map_err(|e| VulcanError::api("BUILD_REGISTER_FAILED", e.to_string()))?; + + let wallet = if let Some(sw) = &ctx.session_wallet { + sw.to_wallet()? + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))? + }; + + crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await? + }; + + let trader_key = phoenix_sdk::types::TraderKey::new(authority); + let result = RegisterResult { + authority: authority.to_string(), + trader_pda: trader_key.pda().to_string(), + dry_run: ctx.dry_run, + tx_signature: sig, + }; + + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + AccountCommand::Info => { + let (_, authority) = resolve_authority(ctx)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api( + "NO_TRADER_ACCOUNT", + "No registered trader account found. Use 'vulcan account register' first.", + ) + })?; + + let total_orders: usize = trader.limit_orders.values().map(|v| v.len()).sum(); + + let result = AccountInfoResult { + authority: trader.authority.clone(), + trader_key: trader.trader_key.clone(), + pda_index: trader.trader_pda_index, + subaccount_index: trader.trader_subaccount_index, + state: format!("{:?}", trader.state), + collateral_balance: trader.collateral_balance.ui.clone(), + portfolio_value: trader.portfolio_value.ui.clone(), + risk_state: format!("{:?}", trader.risk_state), + risk_tier: format!("{:?}", trader.risk_tier), + num_positions: trader.positions.len(), + num_open_orders: total_orders, + max_positions: trader.max_positions, + }; + + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + AccountCommand::Subaccounts => { + let (_, authority) = resolve_authority(ctx)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let subaccounts: Vec = traders + .iter() + .map(|t| SubaccountInfo { + trader_key: t.trader_key.clone(), + pda_index: t.trader_pda_index, + subaccount_index: t.trader_subaccount_index, + state: format!("{:?}", t.state), + collateral_balance: t.collateral_balance.ui.clone(), + num_positions: t.positions.len(), + margin_type: if t.trader_subaccount_index == 0 { + "Cross".to_string() + } else { + "Isolated".to_string() + }, + }) + .collect(); + + let result = SubaccountListResult { + authority: authority.to_string(), + subaccounts, + }; + + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + AccountCommand::CreateSubaccount { + pda_index, + subaccount_index, + } => { + if subaccount_index == 0 { + return Err(VulcanError::validation( + "INVALID_SUBACCOUNT", + "Subaccount index 0 is reserved for cross-margin. Use 1+ for isolated.", + )); + } + + let (wallet_name, authority) = resolve_authority(ctx)?; + let builder = ctx.tx_builder().await?; + + let ixs = builder + .build_register_trader(authority, pda_index, subaccount_index) + .map_err(|e| VulcanError::api("BUILD_REGISTER_FAILED", e.to_string()))?; + + let wallet = if let Some(sw) = &ctx.session_wallet { + sw.to_wallet()? + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))? + }; + + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + let trader_key = + phoenix_sdk::types::TraderKey::new_with_idx(authority, pda_index, subaccount_index); + let result = RegisterResult { + authority: authority.to_string(), + trader_pda: trader_key.pda().to_string(), + dry_run: ctx.dry_run, + tx_signature: sig, + }; + + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + } +} + +// ── Inner functions for MCP ──────────────────────────────────────────── + +pub async fn execute_info_inner(ctx: &AppContext) -> Result { + let (_, authority) = resolve_authority(ctx)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api( + "NO_TRADER_ACCOUNT", + "No registered trader account found. Use 'vulcan account register' first.", + ) + })?; + + let total_orders: usize = trader.limit_orders.values().map(|v| v.len()).sum(); + + Ok(AccountInfoResult { + authority: trader.authority.clone(), + trader_key: trader.trader_key.clone(), + pda_index: trader.trader_pda_index, + subaccount_index: trader.trader_subaccount_index, + state: format!("{:?}", trader.state), + collateral_balance: trader.collateral_balance.ui.clone(), + portfolio_value: trader.portfolio_value.ui.clone(), + risk_state: format!("{:?}", trader.risk_state), + risk_tier: format!("{:?}", trader.risk_tier), + num_positions: trader.positions.len(), + num_open_orders: total_orders, + max_positions: trader.max_positions, + }) +} + +pub async fn execute_register_inner( + ctx: &AppContext, + invite_code: &str, +) -> Result { + let (wallet_name, authority) = resolve_authority(ctx)?; + + // Step 1: Register via HTTP API + let _reg_result = ctx + .http_client + .register_trader(&authority, invite_code) + .await + .map_err(|e| VulcanError::api("REGISTER_API_FAILED", e.to_string()))?; + + // Step 2: Check if already registered + let already_registered = ctx + .http_client + .get_traders(&authority) + .await + .map(|traders| traders.iter().any(|t| t.trader_subaccount_index == 0)) + .unwrap_or(false); + + let sig = if already_registered { + None + } else { + let builder = ctx.tx_builder().await?; + let ixs = builder + .build_register_trader(authority, 0, 0) + .map_err(|e| VulcanError::api("BUILD_REGISTER_FAILED", e.to_string()))?; + + let wallet = if let Some(sw) = &ctx.session_wallet { + sw.to_wallet()? + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))? + }; + + crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await? + }; + + let trader_key = phoenix_sdk::types::TraderKey::new(authority); + Ok(RegisterResult { + authority: authority.to_string(), + trader_pda: trader_key.pda().to_string(), + dry_run: ctx.dry_run, + tx_signature: sig, + }) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/margin.rs b/container/vendor/vulcan/vulcan-lib/src/commands/margin.rs new file mode 100644 index 00000000000..ea8e698ebbf --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/margin.rs @@ -0,0 +1,559 @@ +//! Margin command execution. + +use crate::cli::margin::MarginCommand; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use serde::Serialize; +use solana_pubkey::Pubkey; +use std::str::FromStr; + +// ── Result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct MarginStatusResult { + pub collateral_balance: String, + pub effective_collateral: String, + pub portfolio_value: String, + pub unrealized_pnl: String, + pub initial_margin: String, + pub maintenance_margin: String, + pub risk_state: String, + pub risk_tier: String, + pub available_to_withdraw: String, + pub num_positions: usize, + pub num_open_orders: usize, +} + +impl TableRenderable for MarginStatusResult { + fn render_table(&self) { + println!("Margin Status:"); + println!(" Collateral balance: {}", self.collateral_balance); + println!(" Effective collateral: {}", self.effective_collateral); + println!(" Portfolio value: {}", self.portfolio_value); + println!(" Unrealized PnL: {}", self.unrealized_pnl); + println!(" Initial margin: {}", self.initial_margin); + println!(" Maintenance margin: {}", self.maintenance_margin); + println!(" Risk state: {}", self.risk_state); + println!(" Risk tier: {}", self.risk_tier); + println!(" Available to withdraw: {}", self.available_to_withdraw); + println!(" Open positions: {}", self.num_positions); + println!(" Open orders: {}", self.num_open_orders); + } +} + +#[derive(Debug, Serialize)] +pub struct DepositWithdrawResult { + pub action: String, + pub amount: f64, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for DepositWithdrawResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would {} ${:.2} USDC ({} instructions)", + self.action, self.amount, self.num_instructions + ); + } else { + println!("{}ed ${:.2} USDC", self.action, self.amount); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } + } +} + +#[derive(Debug, Serialize)] +pub struct TransferResult { + pub from_subaccount: u8, + pub to_subaccount: u8, + pub amount: f64, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for TransferResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would transfer ${:.2} USDC from subaccount {} to subaccount {}", + self.amount, self.from_subaccount, self.to_subaccount + ); + } else { + println!( + "Transferred ${:.2} USDC from subaccount {} to subaccount {}", + self.amount, self.from_subaccount, self.to_subaccount + ); + } + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct SweepResult { + pub child_subaccount: u8, + pub action: String, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for SweepResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would {} subaccount {}", + self.action, self.child_subaccount + ); + } else { + println!("{}d subaccount {}", self.action, self.child_subaccount); + } + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct LeverageTiersResult { + pub symbol: String, + pub tiers: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LeverageTierInfo { + pub max_leverage: String, + pub max_size: String, +} + +impl TableRenderable for LeverageTiersResult { + fn render_table(&self) { + println!("Leverage tiers for {}:", self.symbol); + let rows: Vec> = self + .tiers + .iter() + .map(|t| vec![t.max_leverage.clone(), t.max_size.clone()]) + .collect(); + crate::output::table::render_table(&["Max Leverage", "Max Size"], rows); + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn resolve_authority(ctx: &AppContext) -> Result { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string())) +} + +// ── Execution ─────────────────────────────────────────────────────────── + +pub async fn execute_status_inner(ctx: &AppContext) -> Result { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let authority = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api( + "NO_TRADER_ACCOUNT", + "No registered trader account found. Use 'vulcan account register' first.", + ) + })?; + + let total_orders: usize = trader.limit_orders.values().map(|v| v.len()).sum(); + + Ok(MarginStatusResult { + collateral_balance: trader.collateral_balance.ui.clone(), + effective_collateral: trader.effective_collateral.ui.clone(), + portfolio_value: trader.portfolio_value.ui.clone(), + unrealized_pnl: trader.unrealized_pnl.ui.clone(), + initial_margin: trader.initial_margin.ui.clone(), + maintenance_margin: trader.maintenance_margin.ui.clone(), + risk_state: format!("{:?}", trader.risk_state), + risk_tier: format!("{:?}", trader.risk_tier), + available_to_withdraw: trader.effective_collateral_for_withdrawals.ui.clone(), + num_positions: trader.positions.len(), + num_open_orders: total_orders, + }) +} + +pub async fn execute_deposit_withdraw_inner( + ctx: &AppContext, + amount: f64, + is_deposit: bool, +) -> Result { + let (wallet, authority, trader_pda) = if let Some(sw) = &ctx.session_wallet { + (sw.to_wallet()?, sw.authority, sw.trader_pda) + } else { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + let w = crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + let auth = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + let tk = phoenix_sdk::types::TraderKey::new(auth); + (w, auth, tk.pda()) + }; + + let builder = ctx.tx_builder().await?; + + let ixs = if is_deposit { + builder + .build_deposit_funds(authority, trader_pda, amount) + .map_err(|e| VulcanError::api("BUILD_DEPOSIT_FAILED", e.to_string()))? + } else { + builder + .build_withdraw_funds(authority, trader_pda, amount) + .map_err(|e| VulcanError::api("BUILD_WITHDRAW_FAILED", e.to_string()))? + }; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + let action = if is_deposit { "deposit" } else { "withdraw" }; + Ok(DepositWithdrawResult { + action: action.to_string(), + amount, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_transfer_inner( + ctx: &AppContext, + amount: f64, + from: u8, + to: u8, +) -> Result { + let (wallet, authority, _) = crate::commands::trade::resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + + let src_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, from); + let dst_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, to); + + let ixs = builder + .build_transfer_collateral(authority, src_pda, dst_pda, amount) + .map_err(|e| VulcanError::api("BUILD_TRANSFER_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(TransferResult { + from_subaccount: from, + to_subaccount: to, + amount, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_transfer_child_to_parent_inner( + ctx: &AppContext, + child: u8, +) -> Result { + let (wallet, authority, _) = crate::commands::trade::resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + + let child_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, child); + let parent_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + + let ixs = builder + .build_transfer_collateral_child_to_parent(authority, child_pda, parent_pda) + .map_err(|e| VulcanError::api("BUILD_SWEEP_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(SweepResult { + child_subaccount: child, + action: "sweep".to_string(), + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_sync_parent_to_child_inner( + ctx: &AppContext, + child: u8, +) -> Result { + let (wallet, authority, _) = crate::commands::trade::resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + + let parent_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + let child_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, child); + + let ixs = builder + .build_sync_parent_to_child(authority, parent_pda, child_pda) + .map_err(|e| VulcanError::api("BUILD_SYNC_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(SweepResult { + child_subaccount: child, + action: "sync parent-to-child".to_string(), + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_add_collateral_inner( + ctx: &AppContext, + symbol: &str, + amount: f64, +) -> Result { + let symbol_upper = symbol.to_ascii_uppercase(); + + // Fetch all trader views to find the isolated subaccount for this symbol + let (wallet, authority, _) = crate::commands::trade::resolve_wallet_and_pda(ctx, None)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + // Find the isolated subaccount that holds a position in this symbol + let iso_view = traders + .iter() + .find(|t| { + t.trader_subaccount_index > 0 + && t.positions + .iter() + .any(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + }) + .ok_or_else(|| { + VulcanError::validation( + "NO_ISOLATED_POSITION", + format!("No isolated position found for '{}'", symbol), + ) + })?; + + let sub_idx = iso_view.trader_subaccount_index; + let builder = ctx.tx_builder().await?; + + let src_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + let dst_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, sub_idx); + + let ixs = builder + .build_transfer_collateral(authority, src_pda, dst_pda, amount) + .map_err(|e| VulcanError::api("BUILD_TRANSFER_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(TransferResult { + from_subaccount: 0, + to_subaccount: sub_idx, + amount, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_leverage_tiers_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let metadata = ctx.metadata().await?; + let symbol_upper = symbol.to_ascii_uppercase(); + let market = metadata.get_market(&symbol_upper).ok_or_else(|| { + VulcanError::validation("UNKNOWN_MARKET", format!("Unknown market: {}", symbol)) + })?; + + let tiers: Vec = market + .leverage_tiers + .iter() + .map(|t| { + let max_size_str = { + let size = t.max_size_base_lots as f64 + / 10f64.powi(market.base_lots_decimals.max(0) as i32); + format!("{:.4}", size) + }; + LeverageTierInfo { + max_leverage: format!("{:.1}x", t.max_leverage), + max_size: max_size_str, + } + }) + .collect(); + + Ok(LeverageTiersResult { + symbol: symbol_upper, + tiers, + }) +} + +pub async fn execute(ctx: &AppContext, cmd: MarginCommand) -> Result<(), VulcanError> { + match cmd { + MarginCommand::Status => { + let result = execute_status_inner(ctx).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + + if ctx.watch { + let authority = resolve_authority(ctx)?; + crate::watch::watch_loop( + ctx, + crate::watch::WatchKind::TraderState(authority), + || async { + let result = execute_status_inner(ctx).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + }, + ) + .await?; + } + Ok(()) + } + + MarginCommand::Deposit { amount } => execute_deposit_withdraw(ctx, amount, true).await, + + MarginCommand::Withdraw { amount } => execute_deposit_withdraw(ctx, amount, false).await, + + MarginCommand::Transfer { amount, from, to } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm transfer, or --dry-run to simulate", + )); + } + let result = execute_transfer_inner(ctx, amount, from, to).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "margin transfer", "amount": amount, "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + MarginCommand::TransferChildToParent { child } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm sweep, or --dry-run to simulate", + )); + } + let result = execute_transfer_child_to_parent_inner(ctx, child).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "margin transfer-child-to-parent", "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + MarginCommand::SyncParentToChild { child } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm sync, or --dry-run to simulate", + )); + } + let result = execute_sync_parent_to_child_inner(ctx, child).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "margin sync-parent-to-child", "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + MarginCommand::AddCollateral { symbol, amount } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm collateral addition, or --dry-run to simulate", + )); + } + let result = execute_add_collateral_inner(ctx, &symbol, amount).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "margin add-collateral", "symbol": symbol, "amount": amount, "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + MarginCommand::LeverageTiers { symbol } => { + let result = execute_leverage_tiers_inner(ctx, &symbol).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "margin leverage-tiers", "symbol": symbol }), + ); + Ok(()) + } + } +} + +async fn execute_deposit_withdraw( + ctx: &AppContext, + amount: f64, + is_deposit: bool, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + let action = if is_deposit { "deposit" } else { "withdrawal" }; + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + format!("Pass --yes to confirm {}, or --dry-run to simulate", action), + )); + } + + let result = execute_deposit_withdraw_inner(ctx, amount, is_deposit).await?; + let action = &result.action; + + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": format!("margin {}", action), "amount": amount, "dry_run": ctx.dry_run }), + ); + Ok(()) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/market.rs b/container/vendor/vulcan/vulcan-lib/src/commands/market.rs new file mode 100644 index 00000000000..c5cb43d396a --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/market.rs @@ -0,0 +1,597 @@ +//! Market command execution. + +use crate::cli::market::MarketCommand; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use serde::Serialize; + +// ── Result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct MarketListResult { + pub markets: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MarketSummary { + pub symbol: String, + pub status: String, + pub taker_fee: f64, + pub maker_fee: f64, + pub max_leverage: f64, + pub isolated_only: bool, +} + +impl TableRenderable for MarketListResult { + fn render_table(&self) { + if self.markets.is_empty() { + println!("No markets found."); + return; + } + let rows: Vec> = self + .markets + .iter() + .map(|m| { + vec![ + m.symbol.clone(), + m.status.clone(), + format!("{:.2}%", m.taker_fee * 100.0), + format!("{:.2}%", m.maker_fee * 100.0), + format!("{:.0}x", m.max_leverage), + if m.isolated_only { + "yes".into() + } else { + "no".into() + }, + ] + }) + .collect(); + crate::output::table::render_table( + &[ + "Symbol", + "Status", + "Taker Fee", + "Maker Fee", + "Max Leverage", + "Isolated Only", + ], + rows, + ); + } +} + +#[derive(Debug, Serialize)] +pub struct MarketInfoResult { + pub symbol: String, + pub status: String, + pub market_pubkey: String, + pub tick_size: u64, + pub base_lots_decimals: i8, + pub taker_fee: f64, + pub maker_fee: f64, + pub funding_interval_seconds: u32, + pub funding_period_seconds: u32, + pub max_funding_rate_per_interval: f64, + pub isolated_only: bool, + pub leverage_tiers: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LeverageTierInfo { + pub max_leverage: f64, + pub max_size_base_lots: u64, +} + +impl TableRenderable for MarketInfoResult { + fn render_table(&self) { + println!("Market: {}", self.symbol); + println!("Status: {}", self.status); + println!("Pubkey: {}", self.market_pubkey); + println!("Tick size: {}", self.tick_size); + println!("Base lots decimals: {}", self.base_lots_decimals); + println!("Taker fee: {:.4}%", self.taker_fee * 100.0); + println!("Maker fee: {:.4}%", self.maker_fee * 100.0); + println!( + "Funding: every {}s / period {}s", + self.funding_interval_seconds, self.funding_period_seconds + ); + println!( + "Max funding rate/interval: {:.6}%", + self.max_funding_rate_per_interval * 100.0 + ); + println!("Isolated only: {}", self.isolated_only); + if !self.leverage_tiers.is_empty() { + println!("\nLeverage tiers:"); + let rows: Vec> = self + .leverage_tiers + .iter() + .map(|t| { + vec![ + format!("{:.0}x", t.max_leverage), + t.max_size_base_lots.to_string(), + ] + }) + .collect(); + crate::output::table::render_table(&["Max Leverage", "Max Size (base lots)"], rows); + } + } +} + +#[derive(Debug, Serialize)] +pub struct TickerResult { + pub symbol: String, + pub mark_price: f64, + pub mid_price: f64, + pub oracle_price: f64, + pub prev_day_price: f64, + pub change_24h_pct: f64, + pub volume_24h_usd: f64, + pub open_interest: f64, + pub funding_rate: f64, +} + +impl TableRenderable for TickerResult { + fn render_table(&self) { + let rows = vec![vec![ + self.symbol.clone(), + format!("${:.2}", self.mark_price), + format!("{:+.2}%", self.change_24h_pct), + format!("${:.0}", self.volume_24h_usd), + format!("{:.0}", self.open_interest), + format!("{:+.4}%", self.funding_rate * 100.0), + ]]; + crate::output::table::render_table( + &[ + "Symbol", + "Mark Price", + "24h Change", + "24h Volume", + "Open Interest", + "Funding Rate", + ], + rows, + ); + } +} + +#[derive(Debug, Serialize)] +pub struct CandlesResult { + pub symbol: String, + pub interval: String, + pub candles: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CandleRow { + pub time: String, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: Option, +} + +impl TableRenderable for CandlesResult { + fn render_table(&self) { + let rows: Vec> = self + .candles + .iter() + .map(|c| { + vec![ + c.time.clone(), + format!("{:.2}", c.open), + format!("{:.2}", c.high), + format!("{:.2}", c.low), + format!("{:.2}", c.close), + c.volume.map_or("-".into(), |v| format!("{:.0}", v)), + ] + }) + .collect(); + crate::output::table::render_table( + &["Time", "Open", "High", "Low", "Close", "Volume"], + rows, + ); + } +} + +#[derive(Debug, Serialize)] +pub struct OrderbookResult { + pub symbol: String, + pub mid_price: Option, + pub spread: Option, + pub bids: Vec, + pub asks: Vec, +} + +#[derive(Debug, Serialize)] +pub struct OrderbookLevel { + pub price: f64, + pub quantity: f64, +} + +impl TableRenderable for OrderbookResult { + fn render_table(&self) { + if self.bids.is_empty() && self.asks.is_empty() { + println!("Orderbook is empty for {}.", self.symbol); + return; + } + + if let (Some(mid), Some(spread)) = (self.mid_price, self.spread) { + let spread_bps = if mid > 0.0 { + (spread / mid) * 10000.0 + } else { + 0.0 + }; + println!( + "{} — mid: {:.4} spread: {:.1}bps\n", + self.symbol, mid, spread_bps + ); + } + + println!(" Asks:"); + let ask_rows: Vec> = self + .asks + .iter() + .rev() + .map(|l| vec![format!("{:.4}", l.price), format!("{:.4}", l.quantity)]) + .collect(); + crate::output::table::render_table(&["Price", "Quantity"], ask_rows); + + println!("\n Bids:"); + let bid_rows: Vec> = self + .bids + .iter() + .map(|l| vec![format!("{:.4}", l.price), format!("{:.4}", l.quantity)]) + .collect(); + crate::output::table::render_table(&["Price", "Quantity"], bid_rows); + } +} + +// ── Execution ─────────────────────────────────────────────────────────── + +pub async fn execute_list_inner(ctx: &AppContext) -> Result { + let markets = ctx + .http_client + .get_markets() + .await + .map_err(|e| VulcanError::api("MARKETS_FETCH_FAILED", e.to_string()))?; + + Ok(MarketListResult { + markets: markets + .iter() + .map(|m| MarketSummary { + symbol: m.symbol.clone(), + status: format!("{:?}", m.market_status), + taker_fee: m.taker_fee, + maker_fee: m.maker_fee, + max_leverage: m + .leverage_tiers + .first() + .map(|t| t.max_leverage) + .unwrap_or(1.0), + isolated_only: m.isolated_only, + }) + .collect(), + }) +} + +pub async fn execute_info_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let market = ctx + .http_client + .get_market(symbol) + .await + .map_err(|e| VulcanError::api("MARKET_FETCH_FAILED", e.to_string()))?; + + Ok(MarketInfoResult { + symbol: market.symbol.clone(), + status: format!("{:?}", market.market_status), + market_pubkey: market.market_pubkey.clone(), + tick_size: market.tick_size, + base_lots_decimals: market.base_lots_decimals, + taker_fee: market.taker_fee, + maker_fee: market.maker_fee, + funding_interval_seconds: market.funding_interval_seconds, + funding_period_seconds: market.funding_period_seconds, + max_funding_rate_per_interval: market.max_funding_rate_per_interval, + isolated_only: market.isolated_only, + leverage_tiers: market + .leverage_tiers + .iter() + .map(|t| LeverageTierInfo { + max_leverage: t.max_leverage, + max_size_base_lots: t.max_size_base_lots, + }) + .collect(), + }) +} + +pub async fn execute_ticker_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let env = phoenix_sdk::PhoenixEnv { + api_url: ctx.config.network.api_url.clone(), + ws_url: { + let api = &ctx.config.network.api_url; + let ws = api + .replace("https://", "wss://") + .replace("http://", "ws://"); + if ws.ends_with("/ws") { + ws + } else { + format!("{}/ws", ws.trim_end_matches('/')) + } + }, + api_key: ctx.config.network.api_key.clone(), + }; + + let client = phoenix_sdk::PhoenixClient::from_env(env) + .await + .map_err(|e| VulcanError::network("WS_CONNECT_FAILED", e.to_string()))?; + + let (mut rx, _handle) = client + .subscribe(phoenix_sdk::PhoenixSubscription::Market { + symbol: symbol.to_string(), + candle_timeframes: vec![], + include_trades: false, + }) + .await + .map_err(|e| VulcanError::network("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + let stats = tokio::time::timeout(std::time::Duration::from_secs(10), async { + while let Some(event) = rx.recv().await { + if let phoenix_sdk::PhoenixClientEvent::MarketUpdate { update, .. } = event { + return Ok(update); + } + } + Err(VulcanError::network( + "NO_MARKET_DATA", + "No market stats received", + )) + }) + .await + .map_err(|_| VulcanError::network("TIMEOUT", "Timed out waiting for market data"))??; + + let change_pct = if stats.prev_day_mark_price > 0.0 { + ((stats.mark_price - stats.prev_day_mark_price) / stats.prev_day_mark_price) * 100.0 + } else { + 0.0 + }; + + let result = TickerResult { + symbol: stats.symbol.clone(), + mark_price: stats.mark_price, + mid_price: stats.mid_price, + oracle_price: stats.oracle_price, + prev_day_price: stats.prev_day_mark_price, + change_24h_pct: change_pct, + volume_24h_usd: stats.day_volume_usd, + open_interest: stats.open_interest, + funding_rate: stats.funding_rate, + }; + + client.shutdown(); + Ok(result) +} + +pub async fn execute_orderbook_inner( + ctx: &AppContext, + symbol: &str, + depth: usize, +) -> Result { + let api = &ctx.config.network.api_url; + let ws_url = { + let ws = api + .replace("https://", "wss://") + .replace("http://", "ws://"); + if ws.ends_with("/ws") { + ws + } else { + format!("{}/ws", ws.trim_end_matches('/')) + } + }; + + let client = phoenix_sdk::PhoenixWSClient::new(&ws_url, ctx.config.network.api_key.clone()) + .map_err(|e| VulcanError::network("WS_CONNECT_FAILED", e.to_string()))?; + + let (mut rx, _handle) = client + .subscribe_to_orderbook(symbol.to_string()) + .map_err(|e| VulcanError::network("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + let update = tokio::time::timeout(std::time::Duration::from_secs(10), async { + match rx.recv().await { + Some(data) => Ok(data), + None => Err(VulcanError::network( + "NO_ORDERBOOK_DATA", + "No orderbook data received", + )), + } + }) + .await + .map_err(|_| VulcanError::network("TIMEOUT", "Timed out waiting for orderbook data"))??; + + let book = &update.orderbook; + + let bids: Vec = book + .bids + .iter() + .take(depth) + .map(|&(price, qty)| OrderbookLevel { + price, + quantity: qty, + }) + .collect(); + + let asks: Vec = book + .asks + .iter() + .take(depth) + .map(|&(price, qty)| OrderbookLevel { + price, + quantity: qty, + }) + .collect(); + + let spread = match (bids.first(), asks.first()) { + (Some(b), Some(a)) => Some(a.price - b.price), + _ => None, + }; + + Ok(OrderbookResult { + symbol: symbol.to_string(), + mid_price: book.mid, + spread, + bids, + asks, + }) +} + +pub async fn execute_candles_inner( + ctx: &AppContext, + symbol: &str, + interval: &str, + limit: usize, +) -> Result { + let timeframe: phoenix_sdk::Timeframe = interval + .parse() + .map_err(|e: String| VulcanError::validation("INVALID_INTERVAL", e))?; + let params = phoenix_sdk::CandlesQueryParams::new(symbol, timeframe); + let candles = ctx + .http_client + .get_candles(params) + .await + .map_err(|e| VulcanError::api("CANDLES_FETCH_FAILED", e.to_string()))?; + + let candles_to_show: Vec<_> = candles.into_iter().rev().take(limit).rev().collect(); + + Ok(CandlesResult { + symbol: symbol.to_string(), + interval: interval.to_string(), + candles: candles_to_show + .iter() + .map(|c| { + let secs = if c.time > 1_000_000_000_000 { + c.time / 1000 + } else { + c.time + }; + let dt = chrono::DateTime::from_timestamp(secs, 0) + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| c.time.to_string()); + CandleRow { + time: dt, + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + } + }) + .collect(), + }) +} + +pub async fn execute(ctx: &AppContext, cmd: MarketCommand) -> Result<(), VulcanError> { + match cmd { + MarketCommand::List => { + let result = execute_list_inner(ctx).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + MarketCommand::Info { symbol } => { + let result = execute_info_inner(ctx, &symbol).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + MarketCommand::Ticker { symbol } => { + let result = execute_ticker_inner(ctx, &symbol).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "market ticker", "symbol": symbol }), + ); + + if ctx.watch { + crate::watch::watch_loop( + ctx, + crate::watch::WatchKind::Market(symbol.clone()), + || { + let symbol = symbol.clone(); + async move { + let result = execute_ticker_inner(ctx, &symbol).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "market ticker", "symbol": symbol }), + ); + Ok(()) + } + }, + ) + .await?; + } + Ok(()) + } + + MarketCommand::Orderbook { symbol, depth } => { + let result = execute_orderbook_inner(ctx, &symbol, depth).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "market orderbook", "symbol": symbol, "depth": depth }), + ); + + if ctx.watch { + crate::watch::watch_loop(ctx, crate::watch::WatchKind::Orderbook(symbol.clone()), || { + let symbol = symbol.clone(); + async move { + let result = execute_orderbook_inner(ctx, &symbol, depth).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "market orderbook", "symbol": symbol, "depth": depth }), + ); + Ok(()) + } + }).await?; + } + Ok(()) + } + + MarketCommand::Candles { + symbol, + interval, + limit, + } => { + let result = execute_candles_inner(ctx, &symbol, &interval, limit).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "market candles", "symbol": symbol, "interval": interval }), + ); + Ok(()) + } + + MarketCommand::Trades { symbol, limit } => { + let _ = (symbol, limit); + Err(VulcanError::internal( + "NOT_IMPLEMENTED", + "market trades not yet implemented (needs WebSocket trades stream)", + )) + } + + MarketCommand::FundingRates { symbol, limit } => { + let _ = (symbol, limit); + Err(VulcanError::internal( + "NOT_IMPLEMENTED", + "market funding-rates not yet implemented (needs history endpoint with no auth)", + )) + } + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/mod.rs b/container/vendor/vulcan/vulcan-lib/src/commands/mod.rs new file mode 100644 index 00000000000..6a18f2111b2 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/mod.rs @@ -0,0 +1,13 @@ +//! Command execution logic. +//! +//! Each module receives parsed CLI args, calls the SDK, and returns typed results. +//! The output layer handles formatting. + +pub mod account; +pub mod margin; +pub mod market; +pub mod position; +pub mod setup; +pub mod status; +pub mod trade; +pub mod wallet; diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/position.rs b/container/vendor/vulcan/vulcan-lib/src/commands/position.rs new file mode 100644 index 00000000000..8ce8395f17b --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/position.rs @@ -0,0 +1,746 @@ +//! Position command execution. + +use crate::cli::position::PositionCommand; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use phoenix_sdk::Side; +use serde::Serialize; +use solana_pubkey::Pubkey; +use std::str::FromStr; + +// ── Result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct PositionListResult { + pub positions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PositionInfo { + pub symbol: String, + pub side: String, + pub size: String, + pub entry_price: String, + pub mark_price: String, + pub unrealized_pnl: String, + pub liquidation_price: String, + pub maintenance_margin: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subaccount_index: Option, +} + +impl TableRenderable for PositionListResult { + fn render_table(&self) { + if self.positions.is_empty() { + println!("No open positions."); + return; + } + let rows: Vec> = self + .positions + .iter() + .map(|p| { + vec![ + p.symbol.clone(), + p.side.clone(), + p.size.clone(), + p.entry_price.clone(), + p.mark_price.clone(), + p.unrealized_pnl.clone(), + p.liquidation_price.clone(), + ] + }) + .collect(); + crate::output::table::render_table( + &[ + "Symbol", + "Side", + "Size", + "Entry", + "Mark", + "PnL", + "Liq Price", + ], + rows, + ); + } +} + +#[derive(Debug, Serialize)] +pub struct PositionDetailResult { + pub symbol: String, + pub side: String, + pub size: String, + pub entry_price: String, + pub unrealized_pnl: String, + pub discounted_unrealized_pnl: String, + pub position_value: String, + pub initial_margin: String, + pub maintenance_margin: String, + pub liquidation_price: String, + pub take_profit_price: Option, + pub stop_loss_price: Option, + pub unsettled_funding: String, + pub accumulated_funding: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub subaccount_index: Option, +} + +impl TableRenderable for PositionDetailResult { + fn render_table(&self) { + println!("Position: {} {}", self.symbol, self.side); + println!(" Size: {}", self.size); + println!(" Entry price: {}", self.entry_price); + println!(" Unrealized PnL: {}", self.unrealized_pnl); + println!(" Position value: {}", self.position_value); + println!(" Initial margin: {}", self.initial_margin); + println!(" Maintenance margin: {}", self.maintenance_margin); + println!(" Liquidation price: {}", self.liquidation_price); + if let Some(tp) = &self.take_profit_price { + println!(" Take profit: {}", tp); + } + if let Some(sl) = &self.stop_loss_price { + println!(" Stop loss: {}", sl); + } + println!(" Unsettled funding: {}", self.unsettled_funding); + println!(" Accumulated funding: {}", self.accumulated_funding); + } +} + +#[derive(Debug, Serialize)] +pub struct CloseResult { + pub symbol: String, + pub side_closed: String, + pub size_closed: String, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub swept_subaccount: Option, +} + +impl TableRenderable for CloseResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would close {} {} position (size: {})", + self.symbol, self.side_closed, self.size_closed + ); + } else { + println!( + "Closed {} {} position (size: {})", + self.symbol, self.side_closed, self.size_closed + ); + } + if let Some(sub) = self.swept_subaccount { + if self.dry_run { + println!( + " [DRY RUN] Would sweep collateral from subaccount {} back to cross-margin", + sub + ); + } else { + println!( + " Swept collateral from subaccount {} back to cross-margin", + sub + ); + } + } + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct TpSlResult { + pub symbol: String, + pub tp: Option, + pub sl: Option, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for TpSlResult { + fn render_table(&self) { + if self.dry_run { + print!("[DRY RUN] Would attach"); + } else { + print!("Attached"); + } + if let Some(tp) = self.tp { + print!(" TP=${:.2}", tp); + } + if let Some(sl) = self.sl { + print!(" SL=${:.2}", sl); + } + println!(" to {} position", self.symbol); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +/// Fetch all TraderViews for the default wallet. +async fn get_all_trader_views( + ctx: &AppContext, +) -> Result<(Vec, String), VulcanError> { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let authority = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + if traders.is_empty() { + return Err(VulcanError::api( + "NO_TRADER_ACCOUNT", + "No registered trader account found. Use 'vulcan account register' first.", + )); + } + + Ok((traders, wallet_name)) +} + +/// Fetch the cross-margin TraderView for the default wallet. +#[allow(dead_code)] +async fn get_trader_view( + ctx: &AppContext, +) -> Result<(phoenix_sdk::types::TraderView, String), VulcanError> { + let (traders, wallet_name) = get_all_trader_views(ctx).await?; + + let trader = traders + .into_iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api( + "NO_TRADER_ACCOUNT", + "No cross-margin trader account found. Use 'vulcan account register' first.", + ) + })?; + + Ok((trader, wallet_name)) +} + +fn resolve_authority(ctx: &AppContext) -> Result { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string())) +} + +fn position_side(size_ui: &str) -> &str { + if size_ui.starts_with('-') { + "Short" + } else { + "Long" + } +} + +fn format_liq_price(ui: &str) -> String { + // If the liquidation price is negative, it's effectively unreachable + if ui.starts_with('-') { + "N/A".to_string() + } else { + ui.to_string() + } +} + +/// Fetch mid prices for all markets via a single WS round-trip. +async fn fetch_mid_prices( + ctx: &AppContext, +) -> Result, VulcanError> { + let api = &ctx.config.network.api_url; + let ws_url = { + let ws = api + .replace("https://", "wss://") + .replace("http://", "ws://"); + if ws.ends_with("/ws") { + ws + } else { + format!("{}/ws", ws.trim_end_matches('/')) + } + }; + + let client = phoenix_sdk::PhoenixWSClient::new(&ws_url, ctx.config.network.api_key.clone()) + .map_err(|e| VulcanError::network("WS_CONNECT_FAILED", e.to_string()))?; + + let (mut rx, _handle) = client + .subscribe_to_all_mids() + .map_err(|e| VulcanError::network("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + let mids = tokio::time::timeout(std::time::Duration::from_secs(5), async { + match rx.recv().await { + Some(data) => Ok(data.mids), + None => Err(VulcanError::network( + "NO_MID_DATA", + "No mid price data received", + )), + } + }) + .await + .map_err(|_| VulcanError::network("TIMEOUT", "Timed out waiting for mid prices"))??; + + Ok(mids) +} + +// ── Execution ─────────────────────────────────────────────────────────── + +pub async fn execute_list_inner(ctx: &AppContext) -> Result { + let (traders, _) = get_all_trader_views(ctx).await?; + + // Fetch mark prices in parallel — best-effort, fall back to "—" if unavailable + let mids = fetch_mid_prices(ctx).await.unwrap_or_default(); + + let mut positions: Vec = Vec::new(); + for trader in &traders { + let margin_label = if trader.trader_subaccount_index == 0 { + "" + } else { + " [iso]" + }; + for p in &trader.positions { + let mark = mids + .get(&p.symbol) + .map(|m| format!("{:.4}", m)) + .unwrap_or_else(|| "—".to_string()); + let sub_idx = if trader.trader_subaccount_index == 0 { + None + } else { + Some(trader.trader_subaccount_index) + }; + positions.push(PositionInfo { + symbol: format!("{}{}", p.symbol, margin_label), + side: position_side(&p.position_size.ui).to_string(), + size: p.position_size.ui.clone(), + entry_price: p.entry_price.ui.clone(), + mark_price: mark, + unrealized_pnl: p.unrealized_pnl.ui.clone(), + liquidation_price: format_liq_price(&p.liquidation_price.ui), + maintenance_margin: p.maintenance_margin.ui.clone(), + subaccount_index: sub_idx, + }); + } + } + + Ok(PositionListResult { positions }) +} + +pub async fn execute_show_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let (traders, _) = get_all_trader_views(ctx).await?; + + let symbol_upper = symbol.to_ascii_uppercase(); + let (trader_view, pos) = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + .map(|p| (t, p)) + }) + .ok_or_else(|| { + VulcanError::validation("NO_POSITION", format!("No open position for '{}'", symbol)) + })?; + + let sub_idx = if trader_view.trader_subaccount_index == 0 { + None + } else { + Some(trader_view.trader_subaccount_index) + }; + + Ok(PositionDetailResult { + symbol: pos.symbol.clone(), + side: position_side(&pos.position_size.ui).to_string(), + size: pos.position_size.ui.clone(), + entry_price: pos.entry_price.ui.clone(), + unrealized_pnl: pos.unrealized_pnl.ui.clone(), + discounted_unrealized_pnl: pos.discounted_unrealized_pnl.ui.clone(), + position_value: pos.position_value.ui.clone(), + initial_margin: pos.initial_margin.ui.clone(), + maintenance_margin: pos.maintenance_margin.ui.clone(), + liquidation_price: format_liq_price(&pos.liquidation_price.ui), + take_profit_price: pos.take_profit_price.as_ref().map(|d| d.ui.clone()), + stop_loss_price: pos.stop_loss_price.as_ref().map(|d| d.ui.clone()), + unsettled_funding: pos.unsettled_funding.ui.clone(), + accumulated_funding: pos.accumulated_funding.ui.clone(), + subaccount_index: sub_idx, + }) +} + +pub async fn execute_close_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let (traders, wallet_name) = get_all_trader_views(ctx).await?; + + let symbol_upper = symbol.to_ascii_uppercase(); + + // Find the position across all subaccounts + let (trader_view, pos) = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + .map(|p| (t, p)) + }) + .ok_or_else(|| { + VulcanError::validation("NO_POSITION", format!("No open position for '{}'", symbol)) + })?; + + let subaccount_index = trader_view.trader_subaccount_index; + let is_long = !pos.position_size.ui.starts_with('-'); + let close_side = if is_long { Side::Ask } else { Side::Bid }; + let abs_size = pos.position_size.value.unsigned_abs(); + let size_str = pos.position_size.ui.clone(); + let side_closed_str = if is_long { "Long" } else { "Short" }.to_string(); + + let (wallet, authority, _) = if let Some(sw) = &ctx.session_wallet { + (sw.to_wallet()?, sw.authority, sw.trader_pda) + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + let w = crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + let auth = solana_pubkey::Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + (w, auth, phoenix_sdk::types::TraderKey::new(auth).pda()) + }; + + let builder = ctx.tx_builder().await?; + + let mut ixs = if subaccount_index == 0 { + // Cross-margin: use standard market order + let trader_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + builder + .build_market_order(authority, trader_pda, symbol, close_side, abs_size, None) + .map_err(|e| VulcanError::api("BUILD_CLOSE_FAILED", e.to_string()))? + } else { + // Isolated: use isolated market order path + let trader = crate::commands::trade::trader_from_views(authority, 0, &traders); + builder + .build_isolated_market_order( + &trader, symbol, close_side, abs_size, + None, // no additional collateral needed for closing + true, // allow_cross_and_isolated + None, // no bracket + ) + .map_err(|e| VulcanError::api("BUILD_CLOSE_FAILED", e.to_string()))? + }; + + // Auto-sweep: if closing an isolated position, append sweep instruction + let swept_subaccount = if subaccount_index > 0 { + let child_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, subaccount_index); + let parent_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + let sweep_ixs = builder + .build_transfer_collateral_child_to_parent(authority, child_pda, parent_pda) + .map_err(|e| VulcanError::api("BUILD_SWEEP_FAILED", e.to_string()))?; + ixs.extend(sweep_ixs); + Some(subaccount_index) + } else { + None + }; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(CloseResult { + symbol: symbol.to_string(), + side_closed: side_closed_str, + size_closed: size_str, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + swept_subaccount, + }) +} + +pub async fn execute_reduce_inner( + ctx: &AppContext, + symbol: &str, + size: f64, +) -> Result { + let (traders, wallet_name) = get_all_trader_views(ctx).await?; + + let symbol_upper = symbol.to_ascii_uppercase(); + let (trader_view, pos) = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + .map(|p| (t, p)) + }) + .ok_or_else(|| { + VulcanError::validation("NO_POSITION", format!("No open position for '{}'", symbol)) + })?; + + let subaccount_index = trader_view.trader_subaccount_index; + let is_long = !pos.position_size.ui.starts_with('-'); + let reduce_side = if is_long { Side::Ask } else { Side::Bid }; + let side_str = if is_long { "Long" } else { "Short" }.to_string(); + let num_base_lots = size as u64; + + let (wallet, authority, _) = if let Some(sw) = &ctx.session_wallet { + (sw.to_wallet()?, sw.authority, sw.trader_pda) + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + let w = crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + let auth = solana_pubkey::Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + (w, auth, phoenix_sdk::types::TraderKey::new(auth).pda()) + }; + + let builder = ctx.tx_builder().await?; + let ixs = if subaccount_index == 0 { + let trader_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, 0); + builder + .build_market_order( + authority, + trader_pda, + symbol, + reduce_side, + num_base_lots, + None, + ) + .map_err(|e| VulcanError::api("BUILD_REDUCE_FAILED", e.to_string()))? + } else { + let trader = crate::commands::trade::trader_from_views(authority, 0, &traders); + builder + .build_isolated_market_order( + &trader, + symbol, + reduce_side, + num_base_lots, + None, + true, + None, + ) + .map_err(|e| VulcanError::api("BUILD_REDUCE_FAILED", e.to_string()))? + }; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(CloseResult { + symbol: symbol.to_string(), + side_closed: side_str, + size_closed: format!("{}", size), + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + swept_subaccount: None, + }) +} + +pub async fn execute_tp_sl_inner( + ctx: &AppContext, + symbol: &str, + tp: Option, + sl: Option, +) -> Result { + if tp.is_none() && sl.is_none() { + return Err(VulcanError::validation( + "NO_TP_SL", + "At least one of --tp or --sl must be specified", + )); + } + + let (traders, wallet_name) = get_all_trader_views(ctx).await?; + + let symbol_upper = symbol.to_ascii_uppercase(); + let (trader_view, pos) = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + .map(|p| (t, p)) + }) + .ok_or_else(|| { + VulcanError::validation("NO_POSITION", format!("No open position for '{}'", symbol)) + })?; + + let subaccount_index = trader_view.trader_subaccount_index; + let is_long = !pos.position_size.ui.starts_with('-'); + let primary_side = if is_long { Side::Bid } else { Side::Ask }; + + let (wallet, authority, _) = if let Some(sw) = &ctx.session_wallet { + (sw.to_wallet()?, sw.authority, sw.trader_pda) + } else { + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let password = crate::commands::trade::prompt_password()?; + let w = crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + let auth = solana_pubkey::Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + (w, auth, phoenix_sdk::types::TraderKey::new(auth).pda()) + }; + + let trader_pda = phoenix_sdk::types::TraderKey::derive_pda(&authority, 0, subaccount_index); + + let builder = ctx.tx_builder().await?; + let bracket = phoenix_sdk::BracketLegOrders { + take_profit_price: tp, + stop_loss_price: sl, + }; + + let ixs = builder + .build_bracket_leg_orders(authority, trader_pda, symbol, primary_side, &bracket) + .map_err(|e| VulcanError::api("BUILD_TPSL_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(TpSlResult { + symbol: symbol.to_string(), + tp, + sl, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute(ctx: &AppContext, cmd: PositionCommand) -> Result<(), VulcanError> { + match cmd { + PositionCommand::List => { + let result = execute_list_inner(ctx).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + + if ctx.watch { + let authority = resolve_authority(ctx)?; + crate::watch::watch_loop( + ctx, + crate::watch::WatchKind::TraderState(authority), + || async { + let result = execute_list_inner(ctx).await?; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + }, + ) + .await?; + } + Ok(()) + } + + PositionCommand::Show { symbol } => { + let result = execute_show_inner(ctx, &symbol).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "position show", "symbol": symbol }), + ); + + if ctx.watch { + let authority = resolve_authority(ctx)?; + crate::watch::watch_loop( + ctx, + crate::watch::WatchKind::TraderState(authority), + || { + let symbol = symbol.clone(); + async move { + let result = execute_show_inner(ctx, &symbol).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "position show", "symbol": symbol }), + ); + Ok(()) + } + }, + ) + .await?; + } + Ok(()) + } + + PositionCommand::Close { symbol } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm position close, or --dry-run to simulate", + )); + } + + let result = execute_close_inner(ctx, &symbol).await?; + + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "position close", "symbol": symbol, "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + PositionCommand::Reduce { symbol, size } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm position reduce, or --dry-run to simulate", + )); + } + let result = execute_reduce_inner(ctx, &symbol, size).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "position reduce", "symbol": symbol, "dry_run": ctx.dry_run }), + ); + Ok(()) + } + + PositionCommand::TpSl { symbol, tp, sl } => { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm TP/SL, or --dry-run to simulate", + )); + } + let result = execute_tp_sl_inner(ctx, &symbol, tp, sl).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "position tp-sl", "symbol": symbol, "dry_run": ctx.dry_run }), + ); + Ok(()) + } + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/setup.rs b/container/vendor/vulcan/vulcan-lib/src/commands/setup.rs new file mode 100644 index 00000000000..858316270d0 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/setup.rs @@ -0,0 +1,377 @@ +//! Interactive setup wizard — wallet creation, config, and connectivity check. + +use crate::config::VulcanConfig; +use crate::error::VulcanError; +use crate::wallet::{Wallet, WalletFile, WalletStore}; +use std::io::{self, BufRead, Write}; +use std::path::Path; + +// ── Prompt helpers ───────────────────────────────────────────────────── + +fn prompt(msg: &str) -> Result { + print!("{msg}"); + io::stdout() + .flush() + .map_err(|e| VulcanError::io("FLUSH_FAILED", e.to_string()))?; + let mut input = String::new(); + io::stdin() + .lock() + .read_line(&mut input) + .map_err(|e| VulcanError::io("READ_FAILED", e.to_string()))?; + Ok(input.trim().to_string()) +} + +fn prompt_yn(msg: &str, default: bool) -> Result { + let hint = if default { "Y/n" } else { "y/N" }; + let input = prompt(&format!("{msg} [{hint}] "))?; + Ok(match input.to_lowercase().as_str() { + "y" | "yes" => true, + "n" | "no" => false, + _ => default, + }) +} + +fn prompt_password(msg: &str) -> Result { + // Use rpassword if available, otherwise fall back to regular prompt + print!("{msg}"); + io::stdout() + .flush() + .map_err(|e| VulcanError::io("FLUSH_FAILED", e.to_string()))?; + rpassword::read_password().map_err(|e| VulcanError::io("PASSWORD_READ_FAILED", e.to_string())) +} + +fn step_header(n: u8, total: u8, label: &str) { + println!(" [{n}/{total}] {label}"); + println!(" {}", "─".repeat(label.len() + 6)); +} + +// ── Banner ───────────────────────────────────────────────────────────── + +fn print_banner() { + let b = "\x1b[38;2;255;100;0m"; // Phoenix orange + let bold = "\x1b[1m"; + let dim = "\x1b[2m"; + let r = "\x1b[0m"; + + println!(); + println!(" {b}{bold}██╗ ██╗██╗ ██╗██╗ ██████╗ █████╗ ███╗ ██╗{r}"); + println!(" {b}{bold}██║ ██║██║ ██║██║ ██╔════╝██╔══██╗████╗ ██║{r}"); + println!(" {b}{bold}██║ ██║██║ ██║██║ ██║ ███████║██╔██╗ ██║{r}"); + println!(" {b}{bold}╚██╗ ██╔╝██║ ██║██║ ██║ ██╔══██║██║╚██╗██║{r}"); + println!(" {b}{bold} ╚████╔╝ ╚██████╔╝███████╗╚██████╗██║ ██║██║ ╚████║{r}"); + println!(" {b}{bold} ╚═══╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝{r}"); + println!(); + println!(" {b}╭──────────────────────────────────────────────────────╮{r}"); + println!( + " {b}│{r} {bold}Phoenix Perpetuals DEX{r} {dim}— AI-native CLI for Solana{r} {b}│{r}" + ); + println!(" {b}╰──────────────────────────────────────────────────────╯{r}"); + println!(); +} + +// ── Execution ────────────────────────────────────────────────────────── + +pub fn execute(wallet_store: &WalletStore) -> Result<(), VulcanError> { + print_banner(); + + let total = 4; + + // ── Step 1: Wallet ───────────────────────────────────────────────── + step_header(1, total, "Wallet"); + + let wallets = wallet_store + .list() + .map_err(|e| VulcanError::io("WALLET_LIST_FAILED", e.to_string()))?; + + if !wallets.is_empty() { + let default = wallet_store + .default_wallet() + .map_err(|e| VulcanError::io("WALLET_DEFAULT_FAILED", e.to_string()))?; + let default_label = default.as_deref().unwrap_or("none"); + println!( + " ✓ {} wallet(s) found (default: {})", + wallets.len(), + default_label + ); + for name in &wallets { + let marker = if Some(name.as_str()) == default.as_deref() { + "→" + } else { + " " + }; + println!(" {marker} {name}"); + } + println!(); + + if !prompt_yn(" Add another wallet?", false)? { + println!(); + return finish_setup(wallet_store); + } + println!(); + } + + let (wallet_name, address) = setup_wallet(wallet_store)?; + + // Set as default if it's the only wallet or user wants it + let wallets_after = wallet_store + .list() + .map_err(|e| VulcanError::io("WALLET_LIST_FAILED", e.to_string()))?; + if wallets_after.len() == 1 { + wallet_store + .set_default(&wallet_name) + .map_err(|e| VulcanError::io("SET_DEFAULT_FAILED", e.to_string()))?; + println!(" ✓ Set as default wallet"); + } else if prompt_yn(" Set as default wallet?", true)? { + wallet_store + .set_default(&wallet_name) + .map_err(|e| VulcanError::io("SET_DEFAULT_FAILED", e.to_string()))?; + println!(" ✓ Set as default wallet"); + } + + println!(" Address: {address}"); + println!(); + + finish_setup(wallet_store) +} + +fn setup_wallet(wallet_store: &WalletStore) -> Result<(String, String), VulcanError> { + let name = prompt(" Wallet name: ")?; + if name.is_empty() { + return Err(VulcanError::validation( + "EMPTY_NAME", + "Wallet name cannot be empty", + )); + } + if wallet_store.exists(&name) { + return Err(VulcanError::validation( + "WALLET_EXISTS", + format!("Wallet '{}' already exists", name), + )); + } + + println!(); + println!(" How would you like to set up your wallet?"); + println!(" 1) Generate a new keypair"); + println!(" 2) Import from base58 private key"); + println!(" 3) Import from Solana keypair file (JSON)"); + println!(); + + let choice = prompt(" Choice [1]: ")?; + let choice = if choice.is_empty() { + "1".to_string() + } else { + choice + }; + + let wallet = match choice.as_str() { + "1" => { + println!(); + println!(" Generating new keypair..."); + Wallet::generate().map_err(|e| VulcanError::internal("KEYGEN_FAILED", e.to_string()))? + } + "2" => { + let key = prompt(" Enter base58 private key: ")?; + Wallet::from_base58(&key) + .map_err(|e| VulcanError::validation("INVALID_KEY", e.to_string()))? + } + "3" => { + let path_str = prompt(" Path to keypair file: ")?; + let path = Path::new(&path_str); + if !path.exists() { + return Err(VulcanError::validation( + "FILE_NOT_FOUND", + format!("File not found: {}", path_str), + )); + } + Wallet::from_file(path) + .map_err(|e| VulcanError::validation("INVALID_FILE", e.to_string()))? + } + _ => { + return Err(VulcanError::validation( + "INVALID_CHOICE", + "Please enter 1, 2, or 3", + )); + } + }; + + let address = wallet.public_key.clone(); + + // Encrypt with password + println!(); + let password = prompt_password(" Set encryption password: ")?; + if password.is_empty() { + return Err(VulcanError::validation( + "EMPTY_PASSWORD", + "Password cannot be empty", + )); + } + let password_confirm = prompt_password(" Confirm password: ")?; + if password != password_confirm { + return Err(VulcanError::validation( + "PASSWORD_MISMATCH", + "Passwords do not match", + )); + } + + let encrypted = wallet + .encrypt(&password) + .map_err(|e| VulcanError::internal("ENCRYPT_FAILED", e.to_string()))?; + + let wallet_file = WalletFile { + name: name.clone(), + public_key: address.clone(), + encrypted, + created_at: chrono::Utc::now().to_rfc3339(), + }; + + wallet_store + .save(&wallet_file) + .map_err(|e| VulcanError::io("WALLET_SAVE_FAILED", e.to_string()))?; + + println!(); + if choice == "1" { + println!(" ✓ Wallet created"); + println!(); + println!(" ⚠ Back up your wallet file. If lost, your funds cannot be recovered."); + println!(" File: {}", wallet_store.wallet_path(&name).display()); + } else { + println!(" ✓ Wallet imported"); + } + + Ok((name, address)) +} + +fn finish_setup(wallet_store: &WalletStore) -> Result<(), VulcanError> { + let total = 4; + + // ── Step 2: Config ───────────────────────────────────────────────── + step_header(2, total, "Configuration"); + + let config_path = VulcanConfig::path(); + if config_path.exists() { + println!(" ✓ Config file found"); + println!(" Path: {}", config_path.display()); + println!(); + + if prompt_yn(" Reconfigure?", false)? { + println!(); + configure()?; + } + } else { + println!(" ○ No config file found — using defaults"); + println!(); + + if prompt_yn(" Customize configuration?", false)? { + println!(); + configure()?; + } else { + // Save defaults + let config = VulcanConfig::default(); + config + .save() + .map_err(|e| VulcanError::io("CONFIG_SAVE_FAILED", e.to_string()))?; + println!(" ✓ Default config saved to {}", config_path.display()); + } + } + + println!(); + + // ── Step 3: Verify Connection ────────────────────────────────────── + step_header(3, total, "Verify Connection"); + + let config = VulcanConfig::load() + .map_err(|e| VulcanError::config("CONFIG_LOAD_FAILED", e.to_string()))?; + println!(" ○ API: {}", config.network.api_url); + println!(" ○ RPC: {}", config.network.rpc_url); + println!( + " ○ API Key: {}", + if config.network.api_key.is_some() { + "configured" + } else { + "none (public access)" + } + ); + println!(); + println!(" Run `vulcan market list` to verify API connectivity."); + + println!(); + + // ── Step 4: Next Steps ───────────────────────────────────────────── + step_header(4, total, "Next Steps"); + + let default_wallet = wallet_store.default_wallet().ok().flatten(); + + if default_wallet.is_some() { + println!(" ○ Register your trader account:"); + println!(" vulcan account register"); + println!(); + println!(" ○ Deposit collateral:"); + println!(" vulcan margin deposit "); + } else { + println!(" ○ Create or import a wallet:"); + println!(" vulcan wallet create "); + println!(" vulcan wallet import --base58 "); + } + + println!(); + println!(" ────────────────────────────────────────"); + println!(" ✓ Setup complete! You're ready to go."); + println!(); + println!(" Quick start:"); + println!(" vulcan market list Browse markets"); + println!(" vulcan market ticker SOL Live price"); + println!(" vulcan trade market-buy SOL 1 --dry-run"); + println!(); + + Ok(()) +} + +fn configure() -> Result<(), VulcanError> { + let mut config = VulcanConfig::load() + .map_err(|e| VulcanError::config("CONFIG_LOAD_FAILED", e.to_string()))?; + + let rpc = prompt(&format!(" Solana RPC URL [{}]: ", config.network.rpc_url))?; + if !rpc.is_empty() { + config.network.rpc_url = rpc; + } + + let api = prompt(&format!(" Phoenix API URL [{}]: ", config.network.api_url))?; + if !api.is_empty() { + config.network.api_url = api; + } + + let key_hint = config + .network + .api_key + .as_deref() + .map(|k| { + if k.len() > 8 { + format!("{}...", &k[..8]) + } else { + k.to_string() + } + }) + .unwrap_or_else(|| "none".to_string()); + let api_key = prompt(&format!(" API Key [{}]: ", key_hint))?; + if !api_key.is_empty() { + config.network.api_key = Some(api_key); + } + + let slippage = prompt(&format!( + " Default slippage (bps) [{}]: ", + config.trading.default_slippage_bps + ))?; + if !slippage.is_empty() { + config.trading.default_slippage_bps = slippage.parse().map_err(|_| { + VulcanError::validation("INVALID_SLIPPAGE", "Slippage must be a number") + })?; + } + + config + .save() + .map_err(|e| VulcanError::io("CONFIG_SAVE_FAILED", e.to_string()))?; + + println!(" ✓ Config saved to {}", VulcanConfig::path().display()); + + Ok(()) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/status.rs b/container/vendor/vulcan/vulcan-lib/src/commands/status.rs new file mode 100644 index 00000000000..b2b3aad64e1 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/status.rs @@ -0,0 +1,245 @@ +//! Status command — diagnostic health check for agents and users. + +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct StatusReport { + pub config: ConfigStatus, + pub wallet: WalletStatus, + pub rpc: ConnectivityStatus, + pub api: ApiStatus, + pub trader: TraderStatus, +} + +#[derive(Debug, Serialize)] +pub struct ConfigStatus { + pub ok: bool, + pub path: String, + pub rpc_url: String, + pub api_url: String, +} + +#[derive(Debug, Serialize)] +pub struct WalletStatus { + pub ok: bool, + pub name: Option, + pub public_key: Option, +} + +#[derive(Debug, Serialize)] +pub struct ConnectivityStatus { + pub ok: bool, + pub version: Option, + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct ApiStatus { + pub ok: bool, + pub markets: Option, + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct TraderStatus { + pub ok: bool, + pub registered: bool, + pub trader_key: Option, + pub collateral: Option, + pub error: Option, +} + +impl TableRenderable for StatusReport { + fn render_table(&self) { + let check = |ok: bool| if ok { "OK" } else { "FAIL" }; + + println!("Vulcan Status"); + println!("─────────────────────────────────────────"); + + println!( + " Config: [{}] {}", + check(self.config.ok), + self.config.path + ); + println!(" RPC: {}", self.config.rpc_url); + println!(" API: {}", self.config.api_url); + + print!(" Wallet: [{}]", check(self.wallet.ok)); + if let Some(name) = &self.wallet.name { + print!(" {}", name); + } + if let Some(pk) = &self.wallet.public_key { + print!(" ({})", pk); + } + if !self.wallet.ok { + print!(" No default wallet configured"); + } + println!(); + + print!(" RPC: [{}]", check(self.rpc.ok)); + if let Some(v) = &self.rpc.version { + print!(" Solana {}", v); + } + if let Some(e) = &self.rpc.error { + print!(" {}", e); + } + println!(); + + print!(" API: [{}]", check(self.api.ok)); + if let Some(m) = self.api.markets { + print!(" {} markets available", m); + } + if let Some(e) = &self.api.error { + print!(" {}", e); + } + println!(); + + print!(" Trader: [{}]", check(self.trader.ok)); + if self.trader.registered { + if let Some(k) = &self.trader.trader_key { + print!(" {}", k); + } + if let Some(c) = &self.trader.collateral { + print!(" (${} USDC)", c); + } + } else if self.trader.ok { + print!(" Not registered"); + } + if let Some(e) = &self.trader.error { + print!(" {}", e); + } + println!(); + } +} + +pub async fn execute(ctx: &AppContext) -> Result<(), VulcanError> { + let report = execute_inner(ctx).await?; + render_success(ctx.output_format, &report, serde_json::Value::Null); + Ok(()) +} + +pub async fn execute_inner(ctx: &AppContext) -> Result { + // Config check — always passes if we got this far + let config = ConfigStatus { + ok: true, + path: crate::config::VulcanConfig::dir() + .join("config.toml") + .to_string_lossy() + .to_string(), + rpc_url: ctx.config.network.rpc_url.clone(), + api_url: ctx.config.network.api_url.clone(), + }; + + // Wallet check + let wallet = match ctx.wallet_store.default_wallet() { + Ok(Some(name)) => match ctx.wallet_store.load(&name) { + Ok(wf) => WalletStatus { + ok: true, + name: Some(name), + public_key: Some(wf.public_key.clone()), + }, + Err(_) => WalletStatus { + ok: false, + name: Some(name), + public_key: None, + }, + }, + _ => WalletStatus { + ok: false, + name: None, + public_key: None, + }, + }; + + // RPC connectivity check + let rpc = { + let rpc_client = + solana_rpc_client::rpc_client::RpcClient::new(ctx.config.network.rpc_url.clone()); + match rpc_client.get_version() { + Ok(v) => ConnectivityStatus { + ok: true, + version: Some(v.solana_core), + error: None, + }, + Err(e) => ConnectivityStatus { + ok: false, + version: None, + error: Some(e.to_string()), + }, + } + }; + + // API connectivity check + let api = match ctx.http_client.get_markets().await { + Ok(markets) => ApiStatus { + ok: true, + markets: Some(markets.len()), + error: None, + }, + Err(e) => ApiStatus { + ok: false, + markets: None, + error: Some(e.to_string()), + }, + }; + + // Trader registration check + let trader = if let Some(pk) = &wallet.public_key { + match solana_pubkey::Pubkey::try_from(pk.as_str()) { + Ok(authority) => match ctx.http_client.get_traders(&authority).await { + Ok(traders) => { + let cross = traders.iter().find(|t| t.trader_subaccount_index == 0); + match cross { + Some(t) => TraderStatus { + ok: true, + registered: true, + trader_key: Some(t.trader_key.clone()), + collateral: Some(t.collateral_balance.ui.clone()), + error: None, + }, + None => TraderStatus { + ok: true, + registered: false, + trader_key: None, + collateral: None, + error: None, + }, + } + } + Err(e) => TraderStatus { + ok: false, + registered: false, + trader_key: None, + collateral: None, + error: Some(e.to_string()), + }, + }, + Err(_) => TraderStatus { + ok: false, + registered: false, + trader_key: None, + collateral: None, + error: Some("Invalid public key".to_string()), + }, + } + } else { + TraderStatus { + ok: false, + registered: false, + trader_key: None, + collateral: None, + error: Some("No wallet configured".to_string()), + } + }; + + Ok(StatusReport { + config, + wallet, + rpc, + api, + trader, + }) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/trade.rs b/container/vendor/vulcan/vulcan-lib/src/commands/trade.rs new file mode 100644 index 00000000000..a308a2465bd --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/trade.rs @@ -0,0 +1,1395 @@ +//! Trade command execution. + +use crate::cli::trade::TradeCommand; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use phoenix_math_utils::{SignedQuoteLots, WrapperNum}; +use phoenix_sdk::types::trader_state::LimitOrder as SdkLimitOrder; +use phoenix_sdk::types::{Position as SdkPosition, SubaccountState, Trader, TraderKey, TraderView}; +use phoenix_sdk::IsolatedCollateralFlow; +use phoenix_sdk::Side; +use serde::Serialize; +use solana_pubkey::Pubkey; +use solana_sdk::signer::Signer; +use std::str::FromStr; + +// ── Result types ──────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct OrderResult { + pub action: String, + pub symbol: String, + pub side: String, + pub size: f64, + pub price: Option, + pub tp: Option, + pub sl: Option, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for OrderResult { + fn render_table(&self) { + if self.dry_run { + println!("[DRY RUN] Would place {} order:", self.action); + } else { + println!("Order placed:"); + } + println!(" Symbol: {}", self.symbol); + println!(" Side: {}", self.side); + println!(" Size: {} base lots", self.size); + if let Some(p) = self.price { + println!(" Price: ${:.2}", p); + } + if let Some(tp) = self.tp { + println!(" Take profit: ${:.2}", tp); + } + if let Some(sl) = self.sl { + println!(" Stop loss: ${:.2}", sl); + } + println!(" Instructions: {}", self.num_instructions); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct MultiLimitOrderEntry { + pub side: String, + pub price: f64, + pub size: u64, +} + +#[derive(Debug, Serialize)] +pub struct MultiLimitOrderResult { + pub action: String, + pub symbol: String, + pub bids: Vec, + pub asks: Vec, + pub slide: bool, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for MultiLimitOrderResult { + fn render_table(&self) { + if self.dry_run { + println!("[DRY RUN] Would place multi-limit order:"); + } else { + println!("Multi-limit order placed:"); + } + println!(" Symbol: {}", self.symbol); + println!(" Bids: {}", self.bids.len()); + for b in &self.bids { + println!(" ${:.4} × {} lots", b.price, b.size); + } + println!(" Asks: {}", self.asks.len()); + for a in &self.asks { + println!(" ${:.4} × {} lots", a.price, a.size); + } + println!(" Slide: {}", self.slide); + println!(" Instructions: {}", self.num_instructions); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct CancelResult { + pub symbol: String, + pub cancelled_ids: Vec, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for CancelResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would cancel {} orders on {}", + self.cancelled_ids.len(), + self.symbol + ); + } else { + println!( + "Cancelled {} orders on {}", + self.cancelled_ids.len(), + self.symbol + ); + } + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct OrderInfo { + pub symbol: String, + pub side: String, + pub order_id: String, + pub price: String, + pub size_remaining: String, + pub initial_size: String, + pub reduce_only: bool, + pub is_stop_loss: bool, +} + +#[derive(Debug, Serialize)] +pub struct OrdersResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub symbol: Option, + pub orders: Vec, +} + +impl TableRenderable for OrdersResult { + fn render_table(&self) { + if self.orders.is_empty() { + match &self.symbol { + Some(s) => println!("No open orders for {}.", s), + None => println!("No open orders."), + } + return; + } + let show_symbol = self.symbol.is_none(); + let mut headers = vec!["Order ID", "Side", "Price", "Remaining", "Initial", "Flags"]; + if show_symbol { + headers.insert(0, "Symbol"); + } + let rows: Vec> = self + .orders + .iter() + .map(|o| { + let mut flags = Vec::new(); + if o.reduce_only { + flags.push("RO"); + } + if o.is_stop_loss { + flags.push("SL"); + } + let mut row = Vec::new(); + if show_symbol { + row.push(o.symbol.clone()); + } + row.extend([ + o.order_id.clone(), + o.side.clone(), + o.price.clone(), + o.size_remaining.clone(), + o.initial_size.clone(), + flags.join(","), + ]); + row + }) + .collect(); + crate::output::table::render_table(&headers, rows); + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +/// Get wallet password from VULCAN_WALLET_PASSWORD env var, or prompt via stderr. +pub fn prompt_password() -> Result { + if let Ok(pw) = std::env::var("VULCAN_WALLET_PASSWORD") { + return Ok(pw); + } + eprint!("Wallet password: "); + rpassword::read_password().map_err(|e| VulcanError::io("PASSWORD_READ_FAILED", e.to_string())) +} + +/// Resolve the wallet and trader PDA for trading commands. +/// If a session wallet is available (MCP mode), use it directly. +pub fn resolve_wallet_and_pda( + ctx: &AppContext, + wallet_override: Option<&str>, +) -> Result<(crate::wallet::Wallet, Pubkey, Pubkey), VulcanError> { + // MCP session wallet path — no password prompt needed + if let Some(sw) = &ctx.session_wallet { + let wallet = sw.to_wallet()?; + return Ok((wallet, sw.authority, sw.trader_pda)); + } + + let wallet_name = match wallet_override { + Some(name) => name.to_string(), + None => ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| { + VulcanError::config( + "NO_DEFAULT_WALLET", + "No default wallet set. Use 'vulcan wallet set-default '", + ) + })?, + }; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let password = prompt_password()?; + let wallet = crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + + let authority = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + // Default trader PDA: pda_index=0, subaccount_index=0 (cross-margin) + let trader_key = phoenix_sdk::types::TraderKey::new(authority); + let trader_pda = trader_key.pda(); + + Ok((wallet, authority, trader_pda)) +} + +/// Resolve the default wallet's authority pubkey (no decryption needed). +fn resolve_authority(ctx: &AppContext) -> Result { + let wallet_name = ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| VulcanError::config("NO_DEFAULT_WALLET", "No default wallet set"))?; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string())) +} + +/// Convert HTTP API TraderViews into the SDK Trader struct needed for isolated orders. +pub fn trader_from_views(authority: Pubkey, pda_index: u8, views: &[TraderView]) -> Trader { + let key = TraderKey::new_with_idx(authority, pda_index, 0); + let mut trader = Trader::new(key); + + for view in views { + let collateral_f64: f64 = view.collateral_balance.value as f64 + / 10f64.powi(view.collateral_balance.decimals as i32); + let collateral_quote_lots = (collateral_f64 * 1_000_000.0) as i64; + + let mut subaccount = SubaccountState { + subaccount_index: view.trader_subaccount_index, + collateral: SignedQuoteLots::new(collateral_quote_lots), + ..Default::default() + }; + + // Convert positions + for pos_view in &view.positions { + let base_lots: i64 = pos_view.position_size.value; + let entry_ticks: i64 = pos_view.entry_price.value; + let entry_usd = pos_view + .entry_price + .ui + .parse() + .unwrap_or(phoenix_sdk::Decimal::ZERO); + + let position = SdkPosition { + symbol: pos_view.symbol.clone(), + base_position_lots: base_lots, + entry_price_ticks: entry_ticks, + entry_price_usd: entry_usd, + virtual_quote_position_lots: 0, + unsettled_funding_quote_lots: 0, + accumulated_funding_quote_lots: 0, + }; + subaccount + .positions + .insert(pos_view.symbol.clone(), position); + } + + // Convert limit orders + for (symbol, orders) in &view.limit_orders { + for order in orders { + let osn: u64 = order.order_sequence_number.parse().unwrap_or(0); + let sdk_order = SdkLimitOrder { + symbol: symbol.clone(), + order_sequence_number: osn, + side: format!("{:?}", order.side), + order_type: String::new(), + price_ticks: order.price.value, + price_usd: order.price.ui.parse().unwrap_or(phoenix_sdk::Decimal::ZERO), + size_remaining_lots: order.trade_size_remaining.value.unsigned_abs(), + initial_size_lots: order.initial_trade_size.value.unsigned_abs(), + reduce_only: order.is_reduce_only, + is_stop_loss: order.is_stop_loss, + status: "Open".to_string(), + }; + subaccount.orders.insert((symbol.clone(), osn), sdk_order); + } + } + + trader + .subaccounts + .insert(view.trader_subaccount_index, subaccount); + } + + trader +} + +/// Build, optionally sign, and submit a transaction. +pub async fn send_or_dry_run( + ctx: &AppContext, + ixs: Vec, + wallet: &crate::wallet::Wallet, +) -> Result, VulcanError> { + if ctx.dry_run { + return Ok(None); + } + + let keypair = wallet + .to_solana_keypair() + .map_err(|e| VulcanError::auth("KEYPAIR_ERROR", e.to_string()))?; + + let rpc_client = + solana_rpc_client::rpc_client::RpcClient::new(ctx.config.network.rpc_url.clone()); + + let recent_blockhash = rpc_client + .get_latest_blockhash() + .map_err(|e| VulcanError::network("BLOCKHASH_FAILED", e.to_string()))?; + + // Prepend a compute budget instruction to avoid CU exhaustion on complex + // transactions (e.g. opening a new position with many existing positions). + let mut all_ixs = Vec::with_capacity(ixs.len() + 1); + all_ixs.push( + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit(400_000), + ); + all_ixs.extend(ixs); + + let tx = solana_sdk::transaction::Transaction::new_signed_with_payer( + &all_ixs, + Some(&keypair.pubkey()), + &[&keypair], + recent_blockhash, + ); + + let sig = rpc_client + .send_and_confirm_transaction(&tx) + .map_err(|e| VulcanError::tx_failed("TX_SEND_FAILED", e.to_string()))?; + + Ok(Some(sig.to_string())) +} + +// ── Execution ─────────────────────────────────────────────────────────── + +pub async fn execute(ctx: &AppContext, cmd: TradeCommand) -> Result<(), VulcanError> { + match cmd { + TradeCommand::MarketBuy { + symbol, + size, + tp, + sl, + isolated, + collateral, + reduce_only, + } => { + execute_market_order( + ctx, + &symbol, + size, + Side::Bid, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await + } + TradeCommand::MarketSell { + symbol, + size, + tp, + sl, + isolated, + collateral, + reduce_only, + } => { + execute_market_order( + ctx, + &symbol, + size, + Side::Ask, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await + } + TradeCommand::LimitBuy { + symbol, + size, + price, + tp, + sl, + isolated, + collateral, + reduce_only, + } => { + execute_limit_order( + ctx, + &symbol, + size, + price, + Side::Bid, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await + } + TradeCommand::LimitSell { + symbol, + size, + price, + tp, + sl, + isolated, + collateral, + reduce_only, + } => { + execute_limit_order( + ctx, + &symbol, + size, + price, + Side::Ask, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await + } + TradeCommand::Cancel { symbol, order_ids } => execute_cancel(ctx, &symbol, order_ids).await, + TradeCommand::CancelAll { symbol } => execute_cancel_all(ctx, &symbol).await, + TradeCommand::Orders { symbol } => execute_orders(ctx, symbol.as_deref()).await, + TradeCommand::SetTpsl { symbol, tp, sl } => execute_set_tpsl(ctx, &symbol, tp, sl).await, + TradeCommand::CancelTpsl { symbol, tp, sl } => { + execute_cancel_tpsl(ctx, &symbol, tp, sl).await + } + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn execute_market_order_inner( + ctx: &AppContext, + symbol: &str, + size: f64, + side: Side, + tp: Option, + sl: Option, + isolated: bool, + collateral: Option, + _reduce_only: bool, +) -> Result { + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + let num_base_lots = size as u64; + + let bracket = if tp.is_some() || sl.is_some() { + Some(phoenix_sdk::BracketLegOrders { + take_profit_price: tp, + stop_loss_price: sl, + }) + } else { + None + }; + + let ixs = if isolated { + // Fetch full trader state for isolated order building + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = trader_from_views(authority, 0, &traders); + + let collateral_flow = collateral.map(|c| IsolatedCollateralFlow::TransferFromCrossMargin { + collateral: (c * 1_000_000.0) as u64, + }); + + builder + .build_isolated_market_order( + &trader, + symbol, + side, + num_base_lots, + collateral_flow, + true, // allow_cross_and_isolated + bracket.as_ref(), + ) + .map_err(|e| VulcanError::api("BUILD_ORDER_FAILED", e.to_string()))? + } else { + // Check for isolated-only markets + let metadata = ctx.metadata().await?; + if metadata.is_isolated_only(symbol) { + return Err(VulcanError::validation( + "ISOLATED_ONLY_MARKET", + format!( + "{} is isolated-only. Use --isolated --collateral .", + symbol + ), + )); + } + + builder + .build_market_order( + authority, + trader_pda, + symbol, + side, + num_base_lots, + bracket.as_ref(), + ) + .map_err(|e| VulcanError::api("BUILD_ORDER_FAILED", e.to_string()))? + }; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + let side_str = match side { + Side::Bid => "buy", + Side::Ask => "sell", + }; + + Ok(OrderResult { + action: format!("market-{}", side_str), + symbol: symbol.to_string(), + side: side_str.to_string(), + size, + price: None, + tp, + sl, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_market_order( + ctx: &AppContext, + symbol: &str, + size: f64, + side: Side, + tp: Option, + sl: Option, + isolated: bool, + collateral: Option, + reduce_only: bool, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm trade, or --dry-run to simulate", + )); + } + + let result = execute_market_order_inner( + ctx, + symbol, + size, + side, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + + let side_str = &result.side; + render_success( + ctx.output_format, + &result, + serde_json::json!({ + "command": format!("trade market-{}", side_str), + "symbol": symbol, + "dry_run": ctx.dry_run, + }), + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn execute_limit_order_inner( + ctx: &AppContext, + symbol: &str, + size: f64, + price: f64, + side: Side, + tp: Option, + sl: Option, + isolated: bool, + collateral: Option, + _reduce_only: bool, +) -> Result { + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + let num_base_lots = size as u64; + + let bracket = if tp.is_some() || sl.is_some() { + Some(phoenix_sdk::BracketLegOrders { + take_profit_price: tp, + stop_loss_price: sl, + }) + } else { + None + }; + + let ixs = if isolated { + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = trader_from_views(authority, 0, &traders); + + let collateral_flow = collateral.map(|c| IsolatedCollateralFlow::TransferFromCrossMargin { + collateral: (c * 1_000_000.0) as u64, + }); + + builder + .build_isolated_limit_order( + &trader, + symbol, + side, + price, + num_base_lots, + collateral_flow, + true, // allow_cross_and_isolated + ) + .map_err(|e| VulcanError::api("BUILD_ORDER_FAILED", e.to_string()))? + } else { + let metadata = ctx.metadata().await?; + if metadata.is_isolated_only(symbol) { + return Err(VulcanError::validation( + "ISOLATED_ONLY_MARKET", + format!( + "{} is isolated-only. Use --isolated --collateral .", + symbol + ), + )); + } + + let mut limit_ixs = builder + .build_limit_order(authority, trader_pda, symbol, side, price, num_base_lots) + .map_err(|e| VulcanError::api("BUILD_ORDER_FAILED", e.to_string()))?; + + // Append bracket legs if TP/SL specified + if let Some(ref bracket) = bracket { + let bracket_ixs = builder + .build_bracket_leg_orders(authority, trader_pda, symbol, side, bracket) + .map_err(|e| VulcanError::api("BUILD_BRACKET_FAILED", e.to_string()))?; + limit_ixs.extend(bracket_ixs); + } + + limit_ixs + }; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + let side_str = match side { + Side::Bid => "buy", + Side::Ask => "sell", + }; + + Ok(OrderResult { + action: format!("limit-{}", side_str), + symbol: symbol.to_string(), + side: side_str.to_string(), + size, + price: Some(price), + tp, + sl, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +pub async fn execute_multi_limit_order_inner( + ctx: &AppContext, + symbol: &str, + bids: Vec<(f64, u64)>, + asks: Vec<(f64, u64)>, + slide: bool, +) -> Result { + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + + let metadata = ctx.metadata().await?; + if metadata.is_isolated_only(symbol) { + return Err(VulcanError::validation( + "ISOLATED_ONLY_MARKET", + format!( + "{} is isolated-only. Multi-limit orders are not supported for isolated markets.", + symbol + ), + )); + } + + let ixs = builder + .build_multi_limit_order(authority, trader_pda, symbol, &bids, &asks, slide) + .map_err(|e| VulcanError::api("BUILD_MULTI_ORDER_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + let bid_entries: Vec = bids + .iter() + .map(|(price, size)| MultiLimitOrderEntry { + side: "buy".to_string(), + price: *price, + size: *size, + }) + .collect(); + + let ask_entries: Vec = asks + .iter() + .map(|(price, size)| MultiLimitOrderEntry { + side: "sell".to_string(), + price: *price, + size: *size, + }) + .collect(); + + Ok(MultiLimitOrderResult { + action: "multi-limit".to_string(), + symbol: symbol.to_string(), + bids: bid_entries, + asks: ask_entries, + slide, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_limit_order( + ctx: &AppContext, + symbol: &str, + size: f64, + price: f64, + side: Side, + tp: Option, + sl: Option, + isolated: bool, + collateral: Option, + reduce_only: bool, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm trade, or --dry-run to simulate", + )); + } + + let result = execute_limit_order_inner( + ctx, + symbol, + size, + price, + side, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + + let side_str = &result.side; + render_success( + ctx.output_format, + &result, + serde_json::json!({ + "command": format!("trade limit-{}", side_str), + "symbol": symbol, + "dry_run": ctx.dry_run, + }), + ); + Ok(()) +} + +pub async fn execute_cancel_inner( + ctx: &AppContext, + symbol: &str, + order_ids: Vec, +) -> Result { + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let builder = ctx.tx_builder().await?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api("NO_TRADER_ACCOUNT", "No registered trader account found") + })?; + + let symbol_upper = symbol.to_ascii_uppercase(); + let all_orders = trader + .limit_orders + .get(&symbol_upper) + .cloned() + .unwrap_or_default(); + + let metadata = ctx.metadata().await?; + let calc = metadata + .get_market_calculator(&symbol_upper) + .ok_or_else(|| { + VulcanError::validation("UNKNOWN_MARKET", format!("Unknown market: {}", symbol)) + })?; + + let cancel_ids: Vec = all_orders + .iter() + .filter(|o| order_ids.contains(&o.order_sequence_number)) + .map(|o| { + let price_f64 = o.price.value as f64 / 10f64.powi(o.price.decimals as i32); + let ticks = calc.price_to_ticks(price_f64).unwrap_or_default(); + phoenix_sdk::CancelId::new( + ticks.into(), + o.order_sequence_number.parse::().unwrap_or(0), + ) + }) + .collect(); + + if cancel_ids.is_empty() { + return Err(VulcanError::validation( + "INVALID_ORDER_IDS", + "No matching open orders found for the provided IDs", + )); + } + + let ixs = builder + .build_cancel_orders(authority, trader_pda, symbol, cancel_ids) + .map_err(|e| VulcanError::api("BUILD_CANCEL_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(CancelResult { + symbol: symbol.to_string(), + cancelled_ids: order_ids, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +async fn execute_cancel( + ctx: &AppContext, + symbol: &str, + order_ids: Vec, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm cancellation, or --dry-run to simulate", + )); + } + + let result = execute_cancel_inner(ctx, symbol, order_ids).await?; + + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "trade cancel", "symbol": symbol, "dry_run": ctx.dry_run }), + ); + Ok(()) +} + +pub async fn execute_cancel_all_inner( + ctx: &AppContext, + symbol: &str, +) -> Result { + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api("NO_TRADER_ACCOUNT", "No registered trader account found") + })?; + + let symbol_upper = symbol.to_ascii_uppercase(); + let orders = trader + .limit_orders + .get(&symbol_upper) + .cloned() + .unwrap_or_default(); + + if orders.is_empty() { + return Ok(CancelResult { + symbol: symbol.to_string(), + cancelled_ids: vec![], + dry_run: ctx.dry_run, + tx_signature: None, + num_instructions: 0, + }); + } + + let order_ids: Vec = orders + .iter() + .map(|o| o.order_sequence_number.clone()) + .collect(); + + let metadata = ctx.metadata().await?; + let calc = metadata + .get_market_calculator(&symbol_upper) + .ok_or_else(|| { + VulcanError::validation("UNKNOWN_MARKET", format!("Unknown market: {}", symbol)) + })?; + + let cancel_ids: Vec = orders + .iter() + .map(|o| { + let price_f64 = o.price.value as f64 / 10f64.powi(o.price.decimals as i32); + let ticks = calc.price_to_ticks(price_f64).unwrap_or_default(); + phoenix_sdk::CancelId::new( + ticks.into(), + o.order_sequence_number.parse::().unwrap_or(0), + ) + }) + .collect(); + + let builder = ctx.tx_builder().await?; + + let ixs = builder + .build_cancel_orders(authority, trader_pda, symbol, cancel_ids) + .map_err(|e| VulcanError::api("BUILD_CANCEL_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(CancelResult { + symbol: symbol.to_string(), + cancelled_ids: order_ids, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +async fn execute_cancel_all(ctx: &AppContext, symbol: &str) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm cancellation, or --dry-run to simulate", + )); + } + + let result = execute_cancel_all_inner(ctx, symbol).await?; + + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "trade cancel-all", "symbol": symbol, "dry_run": ctx.dry_run }), + ); + Ok(()) +} + +pub async fn execute_orders_inner( + ctx: &AppContext, + symbol: Option<&str>, +) -> Result { + let authority = resolve_authority(ctx)?; + + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let trader = traders + .iter() + .find(|t| t.trader_subaccount_index == 0) + .ok_or_else(|| { + VulcanError::api( + "NO_TRADER_ACCOUNT", + "No registered trader account found. Use 'vulcan account register' first.", + ) + })?; + + let order_infos = match symbol { + Some(sym) => { + let symbol_upper = sym.to_ascii_uppercase(); + let orders = trader + .limit_orders + .get(&symbol_upper) + .cloned() + .unwrap_or_default(); + orders_to_infos(&symbol_upper, &orders) + } + None => { + let mut all = Vec::new(); + for (sym, orders) in &trader.limit_orders { + all.extend(orders_to_infos(sym, orders)); + } + all + } + }; + + Ok(OrdersResult { + symbol: symbol.map(|s| s.to_ascii_uppercase()), + orders: order_infos, + }) +} + +fn orders_to_infos(symbol: &str, orders: &[phoenix_types::LimitOrder]) -> Vec { + orders + .iter() + .map(|o| { + let side = match o.side { + phoenix_types::Side::Bid => "Buy", + phoenix_types::Side::Ask => "Sell", + }; + OrderInfo { + symbol: symbol.to_string(), + side: side.to_string(), + order_id: o.order_sequence_number.clone(), + price: o.price.ui.clone(), + size_remaining: o.trade_size_remaining.ui.clone(), + initial_size: o.initial_trade_size.ui.clone(), + reduce_only: o.is_reduce_only, + is_stop_loss: o.is_stop_loss, + } + }) + .collect() +} + +async fn execute_orders(ctx: &AppContext, symbol: Option<&str>) -> Result<(), VulcanError> { + let result = execute_orders_inner(ctx, symbol).await?; + + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "trade orders", "symbol": symbol }), + ); + + if ctx.watch { + let authority = resolve_authority(ctx)?; + let sym = symbol.map(|s| s.to_string()); + crate::watch::watch_loop(ctx, crate::watch::WatchKind::TraderState(authority), || { + let sym = sym.clone(); + async move { + let result = execute_orders_inner(ctx, sym.as_deref()).await?; + render_success( + ctx.output_format, + &result, + serde_json::json!({ "command": "trade orders", "symbol": sym }), + ); + Ok(()) + } + }) + .await?; + } + + Ok(()) +} + +// ── TP/SL result types ───────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct SetTpSlResult { + pub symbol: String, + pub side: String, + pub tp: Option, + pub sl: Option, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for SetTpSlResult { + fn render_table(&self) { + if self.dry_run { + println!( + "[DRY RUN] Would set TP/SL on {} {} position:", + self.symbol, self.side + ); + } else { + println!("TP/SL set on {} {} position:", self.symbol, self.side); + } + if let Some(tp) = self.tp { + println!(" Take profit: ${:.2}", tp); + } + if let Some(sl) = self.sl { + println!(" Stop loss: ${:.2}", sl); + } + println!(" Instructions: {}", self.num_instructions); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +#[derive(Debug, Serialize)] +pub struct CancelTpSlResult { + pub symbol: String, + pub cancelled_tp: bool, + pub cancelled_sl: bool, + pub dry_run: bool, + pub tx_signature: Option, + pub num_instructions: usize, +} + +impl TableRenderable for CancelTpSlResult { + fn render_table(&self) { + let mut legs = Vec::new(); + if self.cancelled_tp { + legs.push("TP"); + } + if self.cancelled_sl { + legs.push("SL"); + } + if self.dry_run { + println!( + "[DRY RUN] Would cancel {} on {}:", + legs.join("/"), + self.symbol + ); + } else { + println!("Cancelled {} on {}:", legs.join("/"), self.symbol); + } + println!(" Instructions: {}", self.num_instructions); + if let Some(sig) = &self.tx_signature { + println!(" Tx: {}", sig); + } + } +} + +// ── set-tpsl ─────────────────────────────────────────────────────────── + +pub async fn execute_set_tpsl_inner( + ctx: &AppContext, + symbol: &str, + tp: Option, + sl: Option, +) -> Result { + if tp.is_none() && sl.is_none() { + return Err(VulcanError::validation( + "NO_TP_SL", + "Specify at least one of --tp or --sl", + )); + } + + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let symbol_upper = symbol.to_ascii_uppercase(); + + // Fetch trader state to detect position side + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let pos = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + }) + .ok_or_else(|| { + VulcanError::validation( + "NO_POSITION", + format!( + "No open position for '{}'. TP/SL requires an existing position.", + symbol + ), + ) + })?; + + let is_long = !pos.position_size.ui.starts_with('-'); + let primary_side = if is_long { Side::Bid } else { Side::Ask }; + let side_str = if is_long { "Long" } else { "Short" }; + + let bracket = phoenix_sdk::BracketLegOrders { + take_profit_price: tp, + stop_loss_price: sl, + }; + + let builder = ctx.tx_builder().await?; + let ixs = builder + .build_bracket_leg_orders(authority, trader_pda, &symbol_upper, primary_side, &bracket) + .map_err(|e| VulcanError::api("BUILD_TPSL_FAILED", e.to_string()))?; + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(SetTpSlResult { + symbol: symbol_upper, + side: side_str.to_string(), + tp, + sl, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +async fn execute_set_tpsl( + ctx: &AppContext, + symbol: &str, + tp: Option, + sl: Option, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm, or --dry-run to simulate", + )); + } + + let result = execute_set_tpsl_inner(ctx, symbol, tp, sl).await?; + let symbol_upper = result.symbol.clone(); + + render_success( + ctx.output_format, + &result, + serde_json::json!({ + "command": "trade set-tpsl", + "symbol": symbol_upper, + "dry_run": ctx.dry_run, + }), + ); + Ok(()) +} + +// ── cancel-tpsl ──────────────────────────────────────────────────────── + +pub async fn execute_cancel_tpsl_inner( + ctx: &AppContext, + symbol: &str, + cancel_tp: bool, + cancel_sl: bool, +) -> Result { + if !cancel_tp && !cancel_sl { + return Err(VulcanError::validation( + "NO_TP_SL", + "Specify at least one of --tp or --sl to cancel", + )); + } + + let (wallet, authority, trader_pda) = resolve_wallet_and_pda(ctx, None)?; + let symbol_upper = symbol.to_ascii_uppercase(); + let builder = ctx.tx_builder().await?; + + // Detect position side to determine correct directions + let traders = ctx + .http_client + .get_traders(&authority) + .await + .map_err(|e| VulcanError::api("TRADERS_FETCH_FAILED", e.to_string()))?; + + let pos = traders + .iter() + .find_map(|t| { + t.positions + .iter() + .find(|p| p.symbol.to_ascii_uppercase() == symbol_upper) + }) + .ok_or_else(|| { + VulcanError::validation("NO_POSITION", format!("No open position for '{}'", symbol)) + })?; + + let is_long = !pos.position_size.ui.starts_with('-'); + + // For longs: TP triggers GreaterThan, SL triggers LessThan + // For shorts: TP triggers LessThan, SL triggers GreaterThan + let mut ixs = Vec::new(); + + if cancel_tp { + let tp_direction = if is_long { + phoenix_sdk::Direction::GreaterThan + } else { + phoenix_sdk::Direction::LessThan + }; + let tp_ixs = builder + .build_cancel_bracket_leg(authority, trader_pda, &symbol_upper, tp_direction) + .map_err(|e| VulcanError::api("BUILD_CANCEL_TP_FAILED", e.to_string()))?; + ixs.extend(tp_ixs); + } + + if cancel_sl { + let sl_direction = if is_long { + phoenix_sdk::Direction::LessThan + } else { + phoenix_sdk::Direction::GreaterThan + }; + let sl_ixs = builder + .build_cancel_bracket_leg(authority, trader_pda, &symbol_upper, sl_direction) + .map_err(|e| VulcanError::api("BUILD_CANCEL_SL_FAILED", e.to_string()))?; + ixs.extend(sl_ixs); + } + + let num_ixs = ixs.len(); + let sig = send_or_dry_run(ctx, ixs, &wallet).await?; + + Ok(CancelTpSlResult { + symbol: symbol_upper, + cancelled_tp: cancel_tp, + cancelled_sl: cancel_sl, + dry_run: ctx.dry_run, + tx_signature: sig, + num_instructions: num_ixs, + }) +} + +async fn execute_cancel_tpsl( + ctx: &AppContext, + symbol: &str, + cancel_tp: bool, + cancel_sl: bool, +) -> Result<(), VulcanError> { + if !ctx.yes && !ctx.dry_run { + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm, or --dry-run to simulate", + )); + } + + let result = execute_cancel_tpsl_inner(ctx, symbol, cancel_tp, cancel_sl).await?; + let symbol_upper = result.symbol.clone(); + + render_success( + ctx.output_format, + &result, + serde_json::json!({ + "command": "trade cancel-tpsl", + "symbol": symbol_upper, + "dry_run": ctx.dry_run, + }), + ); + Ok(()) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/commands/wallet.rs b/container/vendor/vulcan/vulcan-lib/src/commands/wallet.rs new file mode 100644 index 00000000000..3eddb66d99b --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/commands/wallet.rs @@ -0,0 +1,466 @@ +//! Wallet command execution. + +use crate::cli::wallet::{ImportFormat, WalletCommand}; +use crate::context::AppContext; +use crate::error::VulcanError; +use crate::output::{render_success, TableRenderable}; +use crate::wallet::Wallet; +use crate::wallet::WalletFile; +use serde::Serialize; +use solana_pubkey::Pubkey; + +/// Derive the associated token account address (no external dependency needed). +fn spl_associated_token_address(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + let spl_token_program = + Pubkey::try_from("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").unwrap(); + let ata_program = Pubkey::try_from("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").unwrap(); + let seeds = &[wallet.as_ref(), spl_token_program.as_ref(), mint.as_ref()]; + Pubkey::find_program_address(seeds, &ata_program).0 +} + +#[derive(Debug, Serialize)] +pub struct WalletInfo { + pub name: String, + pub public_key: String, + pub is_default: bool, +} + +impl TableRenderable for WalletInfo { + fn render_table(&self) { + crate::output::table::render_table( + &["Name", "Public Key", "Default"], + vec![vec![ + self.name.clone(), + self.public_key.clone(), + if self.is_default { + "yes".into() + } else { + "no".into() + }, + ]], + ); + } +} + +#[derive(Debug, Serialize)] +pub struct WalletList { + pub wallets: Vec, +} + +impl TableRenderable for WalletList { + fn render_table(&self) { + if self.wallets.is_empty() { + println!("No wallets found. Create one with: vulcan wallet create --name "); + return; + } + let rows: Vec> = self + .wallets + .iter() + .map(|w| { + vec![ + w.name.clone(), + w.public_key.clone(), + if w.is_default { + "yes".into() + } else { + "no".into() + }, + ] + }) + .collect(); + crate::output::table::render_table(&["Name", "Public Key", "Default"], rows); + } +} + +#[derive(Debug, Serialize)] +pub struct WalletCreated { + pub name: String, + pub public_key: String, +} + +impl TableRenderable for WalletCreated { + fn render_table(&self) { + println!("Wallet '{}' created successfully.", self.name); + println!("Public key: {}", self.public_key); + } +} + +#[derive(Debug, Serialize)] +pub struct WalletRemoved { + pub name: String, +} + +impl TableRenderable for WalletRemoved { + fn render_table(&self) { + println!("Wallet '{}' removed.", self.name); + } +} + +#[derive(Debug, Serialize)] +pub struct WalletExport { + pub name: String, + pub public_key: String, +} + +impl TableRenderable for WalletExport { + fn render_table(&self) { + println!("{}", self.public_key); + } +} + +#[derive(Debug, Serialize)] +pub struct WalletBalance { + pub name: String, + pub address: String, + pub sol: f64, + pub usdc: f64, +} + +impl TableRenderable for WalletBalance { + fn render_table(&self) { + println!("Wallet '{}'", self.name); + println!(" Address: {}", self.address); + println!(" SOL: {:.9} SOL", self.sol); + println!(" USDC: {:.6} USDC", self.usdc); + } +} + +#[derive(Debug, Serialize)] +pub struct DefaultSet { + pub name: String, +} + +impl TableRenderable for DefaultSet { + fn render_table(&self) { + println!("Default wallet set to '{}'.", self.name); + } +} + +pub async fn execute(ctx: &AppContext, cmd: WalletCommand) -> Result<(), VulcanError> { + match cmd { + WalletCommand::Create { name } => { + if ctx.wallet_store.exists(&name) { + return Err(VulcanError::validation( + "WALLET_EXISTS", + format!("Wallet '{}' already exists", name), + )); + } + + let password = crate::commands::trade::prompt_password()?; + + let wallet = Wallet::generate() + .map_err(|e| VulcanError::internal("KEYGEN_FAILED", e.to_string()))?; + + let encrypted = wallet + .encrypt(&password) + .map_err(|e| VulcanError::internal("ENCRYPT_FAILED", e.to_string()))?; + + let wallet_file = WalletFile { + name: name.clone(), + public_key: wallet.public_key.clone(), + encrypted, + created_at: chrono::Utc::now().to_rfc3339(), + }; + + ctx.wallet_store.save(&wallet_file).map_err(|e| { + VulcanError::new( + crate::error::ErrorCategory::Io, + "SAVE_FAILED", + e.to_string(), + ) + })?; + + let result = WalletCreated { + name, + public_key: wallet.public_key.clone(), + }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::Import { + name, + format, + source, + } => { + if ctx.wallet_store.exists(&name) { + return Err(VulcanError::validation( + "WALLET_EXISTS", + format!("Wallet '{}' already exists", name), + )); + } + + let wallet = match format { + ImportFormat::Base58 => Wallet::from_base58(&source), + ImportFormat::Bytes => { + let bytes: Vec = serde_json::from_str(&source).map_err(|e| { + VulcanError::validation( + "INVALID_BYTES", + format!("Invalid byte array: {}", e), + ) + })?; + Wallet::from_bytes(&bytes) + } + ImportFormat::File => Wallet::from_file(std::path::Path::new(&source)), + } + .map_err(|e| VulcanError::validation("IMPORT_FAILED", e.to_string()))?; + + let password = crate::commands::trade::prompt_password()?; + + let encrypted = wallet + .encrypt(&password) + .map_err(|e| VulcanError::internal("ENCRYPT_FAILED", e.to_string()))?; + + let wallet_file = WalletFile { + name: name.clone(), + public_key: wallet.public_key.clone(), + encrypted, + created_at: chrono::Utc::now().to_rfc3339(), + }; + + ctx.wallet_store.save(&wallet_file).map_err(|e| { + VulcanError::new( + crate::error::ErrorCategory::Io, + "SAVE_FAILED", + e.to_string(), + ) + })?; + + let result = WalletCreated { + name, + public_key: wallet.public_key.clone(), + }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::List => { + let names = ctx.wallet_store.list().map_err(|e| { + VulcanError::new( + crate::error::ErrorCategory::Io, + "LIST_FAILED", + e.to_string(), + ) + })?; + + let default_name = ctx.wallet_store.default_wallet().ok().flatten(); + + let wallets: Vec = names + .into_iter() + .map(|name| { + let public_key = ctx + .wallet_store + .load(&name) + .map(|f| f.public_key) + .unwrap_or_else(|_| "???".into()); + let is_default = default_name.as_deref() == Some(&name); + WalletInfo { + name, + public_key, + is_default, + } + }) + .collect(); + + let result = WalletList { wallets }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::Show { name } => { + let wallet_file = ctx + .wallet_store + .load(&name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + let default_name = ctx.wallet_store.default_wallet().ok().flatten(); + let is_default = default_name.as_deref() == Some(name.as_str()); + + let result = WalletInfo { + name, + public_key: wallet_file.public_key, + is_default, + }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::SetDefault { name } => { + ctx.wallet_store + .set_default(&name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let result = DefaultSet { name }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::Remove { name } => { + if !ctx.yes { + // TODO: interactive confirmation prompt + eprintln!("Use --yes to confirm removal of wallet '{}'", name); + return Err(VulcanError::validation( + "CONFIRMATION_REQUIRED", + "Pass --yes to confirm wallet removal", + )); + } + + ctx.wallet_store + .remove(&name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let result = WalletRemoved { name }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::Export { name } => { + let wallet_file = ctx + .wallet_store + .load(&name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let result = WalletExport { + name, + public_key: wallet_file.public_key, + }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + + WalletCommand::Balance { name } => { + let wallet_name = match name { + Some(n) => n, + None => ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| { + VulcanError::config( + "NO_DEFAULT_WALLET", + "No default wallet set. Use 'vulcan wallet set-default '", + ) + })?, + }; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let pubkey = solana_pubkey::Pubkey::try_from(wallet_file.public_key.as_str()) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + let rpc_client = + solana_rpc_client::rpc_client::RpcClient::new(ctx.config.network.rpc_url.clone()); + + // SOL balance + let sol_lamports = rpc_client + .get_balance(&pubkey) + .map_err(|e| VulcanError::network("RPC_BALANCE_FAILED", e.to_string()))?; + let sol = sol_lamports as f64 / 1_000_000_000.0; + + // USDC balance — derive the associated token account + let usdc_mint = + solana_pubkey::Pubkey::try_from("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + .unwrap(); + let ata = spl_associated_token_address(&pubkey, &usdc_mint); + + let usdc = match rpc_client.get_token_account_balance(&ata) { + Ok(balance) => balance.ui_amount.unwrap_or(0.0), + Err(_) => 0.0, // No token account = 0 USDC + }; + + let result = WalletBalance { + name: wallet_name, + address: wallet_file.public_key, + sol, + usdc, + }; + render_success(ctx.output_format, &result, serde_json::Value::Null); + Ok(()) + } + } +} + +// ── Inner functions for MCP ──────────────────────────────────────────── + +pub fn execute_list_inner(ctx: &AppContext) -> Result { + let names = ctx.wallet_store.list().map_err(|e| { + VulcanError::new( + crate::error::ErrorCategory::Io, + "LIST_FAILED", + e.to_string(), + ) + })?; + + let default_name = ctx.wallet_store.default_wallet().ok().flatten(); + + let wallets: Vec = names + .into_iter() + .map(|name| { + let public_key = ctx + .wallet_store + .load(&name) + .map(|f| f.public_key) + .unwrap_or_else(|_| "???".into()); + let is_default = default_name.as_deref() == Some(&name); + WalletInfo { + name, + public_key, + is_default, + } + }) + .collect(); + + Ok(WalletList { wallets }) +} + +pub fn execute_balance_inner( + ctx: &AppContext, + name: Option<&str>, +) -> Result { + let wallet_name = match name { + Some(n) => n.to_string(), + None => ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| { + VulcanError::config( + "NO_DEFAULT_WALLET", + "No default wallet set. Use 'vulcan wallet set-default '", + ) + })?, + }; + + let wallet_file = ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let pubkey = Pubkey::try_from(wallet_file.public_key.as_str()) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + let rpc_client = + solana_rpc_client::rpc_client::RpcClient::new(ctx.config.network.rpc_url.clone()); + + let sol_lamports = rpc_client + .get_balance(&pubkey) + .map_err(|e| VulcanError::network("RPC_BALANCE_FAILED", e.to_string()))?; + let sol = sol_lamports as f64 / 1_000_000_000.0; + + let usdc_mint = Pubkey::try_from("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap(); + let ata = spl_associated_token_address(&pubkey, &usdc_mint); + + let usdc = match rpc_client.get_token_account_balance(&ata) { + Ok(balance) => balance.ui_amount.unwrap_or(0.0), + Err(_) => 0.0, + }; + + Ok(WalletBalance { + name: wallet_name, + address: wallet_file.public_key, + sol, + usdc, + }) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/config/config.rs b/container/vendor/vulcan/vulcan-lib/src/config/config.rs new file mode 100644 index 00000000000..8f5f553814f --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/config/config.rs @@ -0,0 +1,136 @@ +//! Config file parsing and defaults. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VulcanConfig { + #[serde(default)] + pub network: NetworkConfig, + #[serde(default)] + pub wallet: WalletConfig, + #[serde(default)] + pub output: OutputConfig, + #[serde(default)] + pub trading: TradingConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + #[serde(default = "default_rpc_url")] + pub rpc_url: String, + #[serde(default = "default_api_url")] + pub api_url: String, + pub api_key: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WalletConfig { + pub default: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputConfig { + #[serde(default = "default_format")] + pub format: String, + #[serde(default = "default_true")] + pub color: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TradingConfig { + #[serde(default = "default_slippage")] + pub default_slippage_bps: u32, + #[serde(default = "default_true")] + pub confirm_trades: bool, +} + +fn default_rpc_url() -> String { + "https://api.mainnet-beta.solana.com".to_string() +} + +fn default_api_url() -> String { + "https://perp-api.phoenix.trade".to_string() +} + +fn default_format() -> String { + "table".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_slippage() -> u32 { + 50 +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + rpc_url: default_rpc_url(), + api_url: default_api_url(), + api_key: None, + } + } +} + +impl Default for OutputConfig { + fn default() -> Self { + Self { + format: default_format(), + color: default_true(), + } + } +} + +impl Default for TradingConfig { + fn default() -> Self { + Self { + default_slippage_bps: default_slippage(), + confirm_trades: default_true(), + } + } +} + +impl VulcanConfig { + /// Path to the Vulcan config directory. + pub fn dir() -> PathBuf { + dirs::home_dir() + .expect("Could not find home directory") + .join(".vulcan") + } + + /// Path to the config file. + pub fn path() -> PathBuf { + Self::dir().join("config.toml") + } + + /// Load config from disk, or return defaults if not found. + pub fn load() -> Result { + let path = Self::path(); + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path)?; + let config: Self = toml::from_str(&content)?; + Ok(config) + } + + /// Save config to disk. + pub fn save(&self) -> Result<()> { + let dir = Self::dir(); + std::fs::create_dir_all(&dir)?; + let content = toml::to_string_pretty(self)?; + std::fs::write(Self::path(), content)?; + Ok(()) + } + + /// Load config from a specific path. + pub fn load_from(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&content)?; + Ok(config) + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/config/mod.rs b/container/vendor/vulcan/vulcan-lib/src/config/mod.rs new file mode 100644 index 00000000000..1366cbb16ce --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/config/mod.rs @@ -0,0 +1,6 @@ +//! Configuration management — `~/.vulcan/config.toml` + +#[allow(clippy::module_inception)] +mod config; + +pub use config::VulcanConfig; diff --git a/container/vendor/vulcan/vulcan-lib/src/context.rs b/container/vendor/vulcan/vulcan-lib/src/context.rs new file mode 100644 index 00000000000..4b3322726aa --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/context.rs @@ -0,0 +1,101 @@ +//! Application context — shared state across commands. + +use crate::config::VulcanConfig; +use crate::mcp::session_wallet::SessionWallet; +use crate::output::OutputFormat; +use crate::wallet::WalletStore; +use anyhow::Result; +use phoenix_sdk::{PhoenixHttpClient, PhoenixTxBuilder}; +use phoenix_types::PhoenixMetadata; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::OnceCell; + +/// Shared application context available to all commands. +pub struct AppContext { + pub config: VulcanConfig, + pub wallet_store: WalletStore, + pub output_format: OutputFormat, + pub dry_run: bool, + pub yes: bool, + pub verbose: bool, + pub watch: bool, + pub vulcan_dir: PathBuf, + pub http_client: PhoenixHttpClient, + /// Pre-decrypted session wallet for MCP mode (None in CLI mode). + pub session_wallet: Option>, + /// Lazily-initialized metadata (fetched on first use). + metadata: OnceCell, +} + +impl AppContext { + /// Build an AppContext from global CLI flags and config. + #[allow(clippy::too_many_arguments)] + pub fn new( + output_format: OutputFormat, + dry_run: bool, + yes: bool, + verbose: bool, + watch: bool, + rpc_url: Option, + api_url: Option, + api_key: Option, + ) -> Result { + let mut config = VulcanConfig::load()?; + + // CLI flags override config + if let Some(rpc) = rpc_url { + config.network.rpc_url = rpc; + } + if let Some(api) = api_url { + config.network.api_url = api; + } + if let Some(key) = api_key { + config.network.api_key = Some(key); + } + + let vulcan_dir = VulcanConfig::dir(); + std::fs::create_dir_all(&vulcan_dir)?; + + let wallet_store = WalletStore::new(&vulcan_dir)?; + + // Build HTTP client from config (not env vars — config takes precedence) + let http_client = match &config.network.api_key { + Some(key) => PhoenixHttpClient::new(&config.network.api_url, key), + None => PhoenixHttpClient::new_public(&config.network.api_url), + }; + + Ok(Self { + config, + wallet_store, + output_format, + dry_run, + yes, + verbose, + watch, + vulcan_dir, + http_client, + session_wallet: None, + metadata: OnceCell::new(), + }) + } + + /// Get exchange metadata, fetching it lazily on first call. + pub async fn metadata(&self) -> Result<&PhoenixMetadata, crate::error::VulcanError> { + self.metadata + .get_or_try_init(|| async { + let exchange = self.http_client.get_exchange().await.map_err(|e| { + crate::error::VulcanError::api("EXCHANGE_FETCH_FAILED", e.to_string()) + })?; + let view: phoenix_types::ExchangeView = exchange.into(); + Ok(PhoenixMetadata::new(view)) + }) + .await + } + + /// Create a transaction builder from cached metadata. + pub async fn tx_builder(&self) -> Result, crate::error::VulcanError> { + let metadata = self.metadata().await?; + Ok(PhoenixTxBuilder::new(metadata)) + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/crypto/encryption.rs b/container/vendor/vulcan/vulcan-lib/src/crypto/encryption.rs new file mode 100644 index 00000000000..11693994152 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/crypto/encryption.rs @@ -0,0 +1,175 @@ +//! AES-256-GCM encryption with Argon2id key derivation +//! +//! Extracted from quant/src/crypto/encryption.rs + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use anyhow::{anyhow, Result}; +use argon2::{password_hash::SaltString, Algorithm, Argon2, Params, PasswordHasher, Version}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; + +/// Encrypted data with metadata needed for decryption +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedData { + /// Salt used for key derivation (base64 encoded) + pub salt: String, + /// Nonce/IV for AES-GCM (base64 encoded) + pub nonce: String, + /// Encrypted ciphertext (base64 encoded) + pub ciphertext: String, +} + +impl EncryptedData { + /// Serialize to a single string for storage + #[allow(clippy::inherent_to_string)] + pub fn to_string(&self) -> String { + format!("encrypted:{}:{}:{}", self.salt, self.nonce, self.ciphertext) + } + + /// Parse from a serialized string + pub fn from_string(s: &str) -> Result { + let s = s.strip_prefix("encrypted:").unwrap_or(s); + let parts: Vec<&str> = s.split(':').collect(); + + if parts.len() != 3 { + return Err(anyhow!("Invalid encrypted data format")); + } + + Ok(Self { + salt: parts[0].to_string(), + nonce: parts[1].to_string(), + ciphertext: parts[2].to_string(), + }) + } +} + +/// Derive a 256-bit key from a password using Argon2id +/// +/// Uses OWASP-recommended parameters: +/// - Memory: 19456 KiB (~19 MiB) +/// - Iterations: 3 +/// - Parallelism: 1 +fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> { + let salt_string = + SaltString::encode_b64(salt).map_err(|e| anyhow!("Failed to encode salt: {}", e))?; + + let params = Params::new(19456, 3, 1, Some(32)) + .map_err(|e| anyhow!("Failed to create Argon2 params: {}", e))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt_string) + .map_err(|e| anyhow!("Failed to hash password: {}", e))?; + + let hash = password_hash + .hash + .ok_or_else(|| anyhow!("No hash output"))?; + let hash_bytes = hash.as_bytes(); + + let mut key = [0u8; 32]; + key.copy_from_slice(&hash_bytes[..32]); + + Ok(key) +} + +/// Encrypt data using AES-256-GCM with Argon2id key derivation +pub fn encrypt(plaintext: &[u8], password: &str) -> Result { + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut salt); + + let key = derive_key(password, &salt)?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?; + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow!("Encryption failed: {}", e))?; + + Ok(EncryptedData { + salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, salt), + nonce: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes), + ciphertext: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &ciphertext), + }) +} + +/// Decrypt data using AES-256-GCM with Argon2id key derivation +pub fn decrypt(encrypted: &EncryptedData, password: &str) -> Result> { + use base64::Engine; + + let salt = base64::engine::general_purpose::STANDARD + .decode(&encrypted.salt) + .map_err(|e| anyhow!("Failed to decode salt: {}", e))?; + + let nonce_bytes = base64::engine::general_purpose::STANDARD + .decode(&encrypted.nonce) + .map_err(|e| anyhow!("Failed to decode nonce: {}", e))?; + + let ciphertext = base64::engine::general_purpose::STANDARD + .decode(&encrypted.ciphertext) + .map_err(|e| anyhow!("Failed to decode ciphertext: {}", e))?; + + let key = derive_key(password, &salt)?; + + let cipher = + Aes256Gcm::new_from_slice(&key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?; + + let nonce = Nonce::from_slice(&nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, ciphertext.as_ref()) + .map_err(|_| anyhow!("Decryption failed - invalid password or corrupted data"))?; + + Ok(plaintext) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let plaintext = b"Hello, World!"; + let password = "test_password_123"; + + let encrypted = encrypt(plaintext, password).unwrap(); + let decrypted = decrypt(&encrypted, password).unwrap(); + + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[test] + fn test_wrong_password_fails() { + let plaintext = b"Secret data"; + let password = "correct_password"; + let wrong_password = "wrong_password"; + + let encrypted = encrypt(plaintext, password).unwrap(); + let result = decrypt(&encrypted, wrong_password); + + assert!(result.is_err()); + } + + #[test] + fn test_encrypted_data_serialization() { + let data = EncryptedData { + salt: "salt123".to_string(), + nonce: "nonce456".to_string(), + ciphertext: "cipher789".to_string(), + }; + + let serialized = data.to_string(); + let deserialized = EncryptedData::from_string(&serialized).unwrap(); + + assert_eq!(data.salt, deserialized.salt); + assert_eq!(data.nonce, deserialized.nonce); + assert_eq!(data.ciphertext, deserialized.ciphertext); + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/crypto/mod.rs b/container/vendor/vulcan/vulcan-lib/src/crypto/mod.rs new file mode 100644 index 00000000000..29e902603b3 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/crypto/mod.rs @@ -0,0 +1,8 @@ +//! Cryptographic utilities for Vulcan CLI +//! +//! Provides AES-256-GCM encryption with Argon2id key derivation. +//! Extracted from the Quant project. + +mod encryption; + +pub use encryption::{decrypt, encrypt, EncryptedData}; diff --git a/container/vendor/vulcan/vulcan-lib/src/error.rs b/container/vendor/vulcan/vulcan-lib/src/error.rs new file mode 100644 index 00000000000..ac06622fecc --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/error.rs @@ -0,0 +1,170 @@ +//! Error types and categories for Vulcan CLI +//! +//! Every error carries a category that maps to a deterministic exit code +//! and tells agents whether the error is retryable. + +use serde::Serialize; +use std::fmt; + +/// Error categories with deterministic exit codes. +/// +/// These map 1:1 to the categories in `error-catalog.json`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCategory { + Validation, + Auth, + Config, + Api, + Network, + RateLimit, + TxFailed, + Io, + DangerousGate, + Internal, +} + +impl ErrorCategory { + pub fn exit_code(self) -> i32 { + match self { + Self::Validation => 1, + Self::Auth => 2, + Self::Config => 3, + Self::Api => 4, + Self::Network => 5, + Self::RateLimit => 6, + Self::TxFailed => 7, + Self::Io => 8, + Self::DangerousGate => 9, + Self::Internal => 10, + } + } + + pub fn is_retryable(self) -> bool { + matches!(self, Self::Network | Self::RateLimit | Self::Io) + } +} + +impl fmt::Display for ErrorCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Validation => "validation", + Self::Auth => "auth", + Self::Config => "config", + Self::Api => "api", + Self::Network => "network", + Self::RateLimit => "rate_limit", + Self::TxFailed => "tx_failed", + Self::Io => "io", + Self::DangerousGate => "dangerous_gate", + Self::Internal => "internal", + }; + f.write_str(s) + } +} + +/// The main error type for Vulcan. +#[derive(Debug)] +pub struct VulcanError { + pub category: ErrorCategory, + pub code: String, + pub message: String, + pub source: Option>, +} + +impl VulcanError { + pub fn new( + category: ErrorCategory, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + category, + code: code.into(), + message: message.into(), + source: None, + } + } + + pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self { + self.source = Some(Box::new(source)); + self + } + + pub fn validation(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Validation, code, message) + } + + pub fn auth(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Auth, code, message) + } + + pub fn config(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Config, code, message) + } + + pub fn api(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Api, code, message) + } + + pub fn network(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Network, code, message) + } + + pub fn tx_failed(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::TxFailed, code, message) + } + + pub fn io(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Io, code, message) + } + + pub fn internal(code: impl Into, message: impl Into) -> Self { + Self::new(ErrorCategory::Internal, code, message) + } + + pub fn exit_code(&self) -> i32 { + self.category.exit_code() + } + + /// Return a short recovery hint for agents based on the error code. + pub fn recovery_hint(&self) -> &'static str { + match self.code.as_str() { + "CONFIRMATION_REQUIRED" => "Add --yes flag to confirm, or --dry-run to simulate", + "NOT_IMPLEMENTED" => "This feature is not yet available", + "NO_DEFAULT_WALLET" => "Run: vulcan wallet set-default ", + "WALLET_NOT_FOUND" => "Run 'vulcan wallet list' to see available wallets", + "DECRYPT_FAILED" => "Wrong password. Check VULCAN_WALLET_PASSWORD env var", + "NO_TRADER_ACCOUNT" => "Register first: vulcan account register --invite-code ", + "REGISTER_API_FAILED" => "Check invite code and API URL. Run 'vulcan status' to verify", + "TX_SEND_FAILED" => "Check wallet SOL balance and account state", + "CONFIG_ERROR" | "CONFIG_LOAD_FAILED" | "INIT_FAILED" => { + "Run 'vulcan setup' to configure" + } + "PASSWORD_READ_FAILED" => "Set VULCAN_WALLET_PASSWORD env var for non-interactive use", + "UNKNOWN_MARKET" => "Run 'vulcan market list' to see available markets", + "MISSING_ARG" => "Check tool schema for required fields", + "UNKNOWN_TOOL" => "Run MCP tools/list to see available tools", + "BLOCKHASH_FAILED" | "RPC_BALANCE_FAILED" => "Check rpc_url in config", + "EXCHANGE_FETCH_FAILED" | "TRADERS_FETCH_FAILED" | "MARKETS_FETCH_FAILED" => { + "Run 'vulcan status' to check API connectivity" + } + _ if self.category.is_retryable() => "Transient error — safe to retry", + _ => "", + } + } +} + +impl fmt::Display for VulcanError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}: {}", self.category, self.code, self.message) + } +} + +impl std::error::Error for VulcanError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/lib.rs b/container/vendor/vulcan/vulcan-lib/src/lib.rs new file mode 100644 index 00000000000..bde7d48ba66 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/lib.rs @@ -0,0 +1,15 @@ +//! Vulcan — AI-native CLI for Phoenix Perpetuals DEX on Solana. +//! +//! This is the core library crate. The binary crate (`vulcan`) handles +//! argument parsing and dispatches to command handlers here. + +pub mod cli; +pub mod commands; +pub mod config; +pub mod context; +pub mod crypto; +pub mod error; +pub mod mcp; +pub mod output; +pub mod wallet; +pub mod watch; diff --git a/container/vendor/vulcan/vulcan-lib/src/mcp/mod.rs b/container/vendor/vulcan/vulcan-lib/src/mcp/mod.rs new file mode 100644 index 00000000000..2ba1356ec48 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/mcp/mod.rs @@ -0,0 +1,5 @@ +//! MCP server — Model Context Protocol over stdio. + +pub mod registry; +pub mod server; +pub mod session_wallet; diff --git a/container/vendor/vulcan/vulcan-lib/src/mcp/registry.rs b/container/vendor/vulcan/vulcan-lib/src/mcp/registry.rs new file mode 100644 index 00000000000..419e0ef26ab --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/mcp/registry.rs @@ -0,0 +1,641 @@ +//! Tool registry — static tool definitions with JSON schemas for MCP. + +use serde_json::{json, Value}; + +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + pub group: &'static str, + pub dangerous: bool, + pub schema: fn() -> Value, +} + +/// All tools exposed by the Vulcan MCP server. +pub static TOOLS: &[ToolDef] = &[ + // ── Market (read-only) ────────────────────────────────────────────── + ToolDef { + name: "vulcan_market_list", + description: "List all available perpetual markets on Phoenix DEX with fees and leverage info.", + group: "market", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_market_ticker", + description: "Get real-time ticker data for a market: mark price, funding rate, 24h volume and change.", + group: "market", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_market_info", + description: "Get detailed market configuration: tick size, fees, funding params, leverage tiers.", + group: "market", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_market_orderbook", + description: "Get L2 orderbook snapshot with bids, asks, mid price, and spread.", + group: "market", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "depth": { "type": "integer", "description": "Number of price levels per side", "default": 10 } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_market_candles", + description: "Get historical candlestick (OHLCV) data for a market.", + group: "market", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "interval": { "type": "string", "description": "Candle interval: 1m, 5m, 15m, 1h, 4h, 1d", "default": "1h" }, + "limit": { "type": "integer", "description": "Max candles to return", "default": 50 } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + + // ── Trade (dangerous) ─────────────────────────────────────────────── + ToolDef { + name: "vulcan_trade_market_buy", + description: "Place a market buy order. Executes immediately at best available price.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "size": { "type": "number", "description": "Order size in base lots" }, + "tp": { "type": "number", "description": "Optional take-profit price" }, + "sl": { "type": "number", "description": "Optional stop-loss price" }, + "isolated": { "type": "boolean", "description": "Use isolated margin (dedicated collateral per position)" }, + "collateral": { "type": "number", "description": "USDC collateral for isolated subaccount (requires isolated=true)" }, + "reduce_only": { "type": "boolean", "description": "Order can only reduce existing position" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "size", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_market_sell", + description: "Place a market sell order. Executes immediately at best available price.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "size": { "type": "number", "description": "Order size in base lots" }, + "tp": { "type": "number", "description": "Optional take-profit price" }, + "sl": { "type": "number", "description": "Optional stop-loss price" }, + "isolated": { "type": "boolean", "description": "Use isolated margin (dedicated collateral per position)" }, + "collateral": { "type": "number", "description": "USDC collateral for isolated subaccount (requires isolated=true)" }, + "reduce_only": { "type": "boolean", "description": "Order can only reduce existing position" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "size", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_limit_buy", + description: "Place a limit buy order at a specific price. Rests on the book until filled or cancelled.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "size": { "type": "number", "description": "Order size in base lots" }, + "price": { "type": "number", "description": "Limit price in USD" }, + "tp": { "type": "number", "description": "Optional take-profit price" }, + "sl": { "type": "number", "description": "Optional stop-loss price" }, + "isolated": { "type": "boolean", "description": "Use isolated margin (dedicated collateral per position)" }, + "collateral": { "type": "number", "description": "USDC collateral for isolated subaccount (requires isolated=true)" }, + "reduce_only": { "type": "boolean", "description": "Order can only reduce existing position" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "size", "price", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_limit_sell", + description: "Place a limit sell order at a specific price. Rests on the book until filled or cancelled.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "size": { "type": "number", "description": "Order size in base lots" }, + "price": { "type": "number", "description": "Limit price in USD" }, + "tp": { "type": "number", "description": "Optional take-profit price" }, + "sl": { "type": "number", "description": "Optional stop-loss price" }, + "isolated": { "type": "boolean", "description": "Use isolated margin (dedicated collateral per position)" }, + "collateral": { "type": "number", "description": "USDC collateral for isolated subaccount (requires isolated=true)" }, + "reduce_only": { "type": "boolean", "description": "Order can only reduce existing position" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "size", "price", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_multi_limit", + description: "Place multiple limit orders (bids and asks) in a single transaction. Much faster than placing orders individually. Orders are post-only.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "bids": { + "type": "array", + "description": "Array of bid orders, each with price (USD) and size (base lots)", + "items": { + "type": "object", + "properties": { + "price": { "type": "number", "description": "Limit price in USD" }, + "size": { "type": "integer", "description": "Order size in base lots" } + }, + "required": ["price", "size"] + } + }, + "asks": { + "type": "array", + "description": "Array of ask orders, each with price (USD) and size (base lots)", + "items": { + "type": "object", + "properties": { + "price": { "type": "number", "description": "Limit price in USD" }, + "size": { "type": "integer", "description": "Order size in base lots" } + }, + "required": ["price", "size"] + } + }, + "slide": { "type": "boolean", "description": "Whether orders should slide to top of book if they would cross. Default false.", "default": false }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "bids", "asks", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_orders", + description: "List open orders. Omit symbol to list across all markets.", + group: "trade", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL. Omit to list all markets." } + }, + "required": [], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_cancel", + description: "Cancel specific orders by their IDs.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "order_ids": { "type": "array", "items": { "type": "string" }, "description": "Order IDs to cancel" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "order_ids", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_cancel_all", + description: "Cancel all open orders for a market.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "acknowledged"], + "additionalProperties": false + }), + }, + + ToolDef { + name: "vulcan_trade_set_tpsl", + description: "Set take-profit and/or stop-loss on an existing position. Auto-detects position side.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "tp": { "type": "number", "description": "Take-profit price (optional)" }, + "sl": { "type": "number", "description": "Stop-loss price (optional)" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_trade_cancel_tpsl", + description: "Cancel take-profit and/or stop-loss on an existing position.", + group: "trade", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "tp": { "type": "boolean", "description": "Cancel take-profit (default false)" }, + "sl": { "type": "boolean", "description": "Cancel stop-loss (default false)" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "acknowledged"], + "additionalProperties": false + }), + }, + + // ── Position ──────────────────────────────────────────────────────── + ToolDef { + name: "vulcan_position_list", + description: "List all open positions across all markets.", + group: "position", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_position_show", + description: "Show detailed info for a specific position: PnL, margin, liquidation price, TP/SL.", + group: "position", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_position_close", + description: "Close an entire position via market order on the opposite side.", + group: "position", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "acknowledged"], + "additionalProperties": false + }), + }, + + // ── Margin ────────────────────────────────────────────────────────── + ToolDef { + name: "vulcan_margin_status", + description: "Show current margin status: collateral, PnL, risk state, available to withdraw.", + group: "margin", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_deposit", + description: "Deposit USDC collateral into the trading account.", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "amount": { "type": "number", "description": "USDC amount to deposit" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["amount", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_withdraw", + description: "Withdraw USDC collateral from the trading account.", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "amount": { "type": "number", "description": "USDC amount to withdraw" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["amount", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_transfer", + description: "Transfer collateral between subaccounts (e.g., cross-margin to isolated).", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "from_subaccount": { "type": "integer", "description": "Source subaccount index (0 = cross-margin)" }, + "to_subaccount": { "type": "integer", "description": "Destination subaccount index" }, + "amount": { "type": "number", "description": "USDC amount to transfer" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["from_subaccount", "to_subaccount", "amount", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_transfer_child_to_parent", + description: "Sweep all collateral from a child (isolated) subaccount back to cross-margin.", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "child_subaccount": { "type": "integer", "description": "Child subaccount index to sweep" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["child_subaccount", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_sync_parent_to_child", + description: "Sync parent (cross-margin) state to a child (isolated) subaccount.", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "child_subaccount": { "type": "integer", "description": "Child subaccount index to sync to" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["child_subaccount", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_margin_leverage_tiers", + description: "Show leverage tier schedule for a market: max leverage and max size per tier.", + group: "margin", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" } + }, + "required": ["symbol"], + "additionalProperties": false + }), + }, + + ToolDef { + name: "vulcan_margin_add_collateral", + description: "Add USDC collateral to an isolated position by symbol. Transfers from cross-margin to the isolated subaccount.", + group: "margin", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol of the isolated position, e.g. SOL" }, + "amount": { "type": "number", "description": "USDC amount to add" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "amount", "acknowledged"], + "additionalProperties": false + }), + }, + + // ── Position (new) ───────────────────────────────────────────────── + ToolDef { + name: "vulcan_position_reduce", + description: "Reduce a position by a specified size via market order on the opposite side.", + group: "position", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "size": { "type": "number", "description": "Size to reduce by in base lots" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "size", "acknowledged"], + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_position_tp_sl", + description: "Attach take-profit and/or stop-loss bracket orders to an existing position.", + group: "position", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Market symbol, e.g. SOL" }, + "tp": { "type": "number", "description": "Take-profit price" }, + "sl": { "type": "number", "description": "Stop-loss price" }, + "acknowledged": { "type": "boolean", "description": "Must be true to confirm this dangerous operation" } + }, + "required": ["symbol", "acknowledged"], + "additionalProperties": false + }), + }, + + // ── History (read-only) ──────────────────────────────────────────── + ToolDef { + name: "vulcan_history_trades", + description: "Get past trade/fill history.", + group: "history", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Filter by market symbol" }, + "limit": { "type": "integer", "description": "Max results to return", "default": 20 } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_history_orders", + description: "Get past order history (filled, cancelled, expired).", + group: "history", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Filter by market symbol" }, + "limit": { "type": "integer", "description": "Max results to return", "default": 20 } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_history_collateral", + description: "Get collateral deposit/withdrawal history.", + group: "history", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "limit": { "type": "integer", "description": "Max results to return", "default": 20 } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_history_funding", + description: "Get funding payment history.", + group: "history", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "symbol": { "type": "string", "description": "Filter by market symbol" }, + "limit": { "type": "integer", "description": "Max results to return", "default": 20 } + }, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_history_pnl", + description: "Get PnL history over time.", + group: "history", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "resolution": { "type": "string", "description": "Resolution: hourly or daily", "default": "hourly" }, + "limit": { "type": "integer", "description": "Max results to return", "default": 24 } + }, + "additionalProperties": false + }), + }, + + // ── Status ──────────────────────────────────────────────────────── + ToolDef { + name: "vulcan_status", + description: "Health check: verify config, wallet, RPC, API, and trader registration status.", + group: "status", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + + // ── Wallet ──────────────────────────────────────────────────────── + ToolDef { + name: "vulcan_wallet_list", + description: "List all stored wallets with names, public keys, and default status.", + group: "wallet", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_wallet_balance", + description: "Check SOL and USDC balance for a wallet.", + group: "wallet", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Wallet name (omit for default wallet)" } + }, + "additionalProperties": false + }), + }, + + // ── Account ─────────────────────────────────────────────────────── + ToolDef { + name: "vulcan_account_info", + description: "Get trader account info: collateral, positions, risk state.", + group: "account", + dangerous: false, + schema: || json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + }, + ToolDef { + name: "vulcan_account_register", + description: "Register a new trader account with an invite code.", + group: "account", + dangerous: true, + schema: || json!({ + "type": "object", + "properties": { + "invite_code": { "type": "string", "description": "Invite code for registration" }, + "acknowledged": { "type": "boolean", "description": "Must be true to execute" } + }, + "required": ["invite_code", "acknowledged"], + "additionalProperties": false + }), + }, +]; + +/// Filter tools by group. If groups is None, return all tools. +pub fn tools_for_groups(groups: &Option>) -> Vec<&'static ToolDef> { + match groups { + None => TOOLS.iter().collect(), + Some(gs) => TOOLS + .iter() + .filter(|t| gs.iter().any(|g| g == t.group)) + .collect(), + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/mcp/server.rs b/container/vendor/vulcan/vulcan-lib/src/mcp/server.rs new file mode 100644 index 00000000000..fb8fd510b3f --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/mcp/server.rs @@ -0,0 +1,763 @@ +//! MCP server handler — implements rmcp ServerHandler. + +use crate::commands; +use crate::context::AppContext; +use crate::mcp::registry::{self, ToolDef}; +use phoenix_sdk::Side; +use rmcp::model::*; +use rmcp::ServerHandler; +use serde_json::Value; +use std::sync::Arc; + +/// Vulcan MCP server — exposes trading tools over stdio. +pub struct VulcanMcpServer { + ctx: Arc, + tools: Vec<&'static ToolDef>, + allow_dangerous: bool, +} + +impl VulcanMcpServer { + pub fn new(ctx: Arc, allow_dangerous: bool, groups: Option>) -> Self { + let tools = registry::tools_for_groups(&groups); + Self { + ctx, + tools, + allow_dangerous, + } + } + + /// Convert a ToolDef into an rmcp Tool model. + fn to_rmcp_tool(def: &ToolDef) -> Tool { + let schema = (def.schema)(); + let schema_obj: serde_json::Map = match schema { + Value::Object(m) => m, + _ => serde_json::Map::new(), + }; + Tool::new(def.name, def.description, Arc::new(schema_obj)) + } + + /// Dispatch a tool call to the appropriate command inner function. + async fn dispatch(&self, name: &str, args: &Value) -> Result { + match name { + // ── Market ────────────────────────────────────────────────── + "vulcan_market_list" => { + let result = commands::market::execute_list_inner(&self.ctx).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_market_ticker" => { + let symbol = arg_str(args, "symbol")?; + let result = commands::market::execute_ticker_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_market_info" => { + let symbol = arg_str(args, "symbol")?; + let result = commands::market::execute_info_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_market_orderbook" => { + let symbol = arg_str(args, "symbol")?; + let depth = arg_usize_or(args, "depth", 10); + let result = + commands::market::execute_orderbook_inner(&self.ctx, &symbol, depth).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_market_candles" => { + let symbol = arg_str(args, "symbol")?; + let interval = arg_str_or(args, "interval", "1h"); + let limit = arg_usize_or(args, "limit", 50); + let result = + commands::market::execute_candles_inner(&self.ctx, &symbol, &interval, limit) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Trade ─────────────────────────────────────────────────── + "vulcan_trade_market_buy" => { + let symbol = arg_str(args, "symbol")?; + let size = arg_f64(args, "size")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let isolated = arg_bool_or(args, "isolated", false); + let collateral = arg_f64_opt(args, "collateral"); + let reduce_only = arg_bool_or(args, "reduce_only", false); + let result = commands::trade::execute_market_order_inner( + &self.ctx, + &symbol, + size, + Side::Bid, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_market_sell" => { + let symbol = arg_str(args, "symbol")?; + let size = arg_f64(args, "size")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let isolated = arg_bool_or(args, "isolated", false); + let collateral = arg_f64_opt(args, "collateral"); + let reduce_only = arg_bool_or(args, "reduce_only", false); + let result = commands::trade::execute_market_order_inner( + &self.ctx, + &symbol, + size, + Side::Ask, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_limit_buy" => { + let symbol = arg_str(args, "symbol")?; + let size = arg_f64(args, "size")?; + let price = arg_f64(args, "price")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let isolated = arg_bool_or(args, "isolated", false); + let collateral = arg_f64_opt(args, "collateral"); + let reduce_only = arg_bool_or(args, "reduce_only", false); + let result = commands::trade::execute_limit_order_inner( + &self.ctx, + &symbol, + size, + price, + Side::Bid, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_limit_sell" => { + let symbol = arg_str(args, "symbol")?; + let size = arg_f64(args, "size")?; + let price = arg_f64(args, "price")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let isolated = arg_bool_or(args, "isolated", false); + let collateral = arg_f64_opt(args, "collateral"); + let reduce_only = arg_bool_or(args, "reduce_only", false); + let result = commands::trade::execute_limit_order_inner( + &self.ctx, + &symbol, + size, + price, + Side::Ask, + tp, + sl, + isolated, + collateral, + reduce_only, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_multi_limit" => { + let symbol = arg_str(args, "symbol")?; + let slide = arg_bool_or(args, "slide", false); + let bids = arg_order_array(args, "bids")?; + let asks = arg_order_array(args, "asks")?; + let result = commands::trade::execute_multi_limit_order_inner( + &self.ctx, &symbol, bids, asks, slide, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_orders" => { + let symbol = arg_str_opt(args, "symbol"); + let result = + commands::trade::execute_orders_inner(&self.ctx, symbol.as_deref()).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_cancel" => { + let symbol = arg_str(args, "symbol")?; + let order_ids = arg_str_array(args, "order_ids")?; + let result = + commands::trade::execute_cancel_inner(&self.ctx, &symbol, order_ids).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_cancel_all" => { + let symbol = arg_str(args, "symbol")?; + let result = commands::trade::execute_cancel_all_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + "vulcan_trade_set_tpsl" => { + let symbol = arg_str(args, "symbol")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let result = + commands::trade::execute_set_tpsl_inner(&self.ctx, &symbol, tp, sl).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_trade_cancel_tpsl" => { + let symbol = arg_str(args, "symbol")?; + let cancel_tp = arg_bool_or(args, "tp", false); + let cancel_sl = arg_bool_or(args, "sl", false); + let result = commands::trade::execute_cancel_tpsl_inner( + &self.ctx, &symbol, cancel_tp, cancel_sl, + ) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Position ──────────────────────────────────────────────── + "vulcan_position_list" => { + let result = commands::position::execute_list_inner(&self.ctx).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_position_show" => { + let symbol = arg_str(args, "symbol")?; + let result = commands::position::execute_show_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_position_close" => { + let symbol = arg_str(args, "symbol")?; + let result = commands::position::execute_close_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Margin ────────────────────────────────────────────────── + "vulcan_margin_status" => { + let result = commands::margin::execute_status_inner(&self.ctx).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_deposit" => { + let amount = arg_f64(args, "amount")?; + let result = + commands::margin::execute_deposit_withdraw_inner(&self.ctx, amount, true) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_withdraw" => { + let amount = arg_f64(args, "amount")?; + let result = + commands::margin::execute_deposit_withdraw_inner(&self.ctx, amount, false) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_transfer" => { + let from = arg_u8(args, "from_subaccount")?; + let to = arg_u8(args, "to_subaccount")?; + let amount = arg_f64(args, "amount")?; + let result = + commands::margin::execute_transfer_inner(&self.ctx, amount, from, to).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_add_collateral" => { + let symbol = arg_str(args, "symbol")?; + let amount = arg_f64(args, "amount")?; + let result = + commands::margin::execute_add_collateral_inner(&self.ctx, &symbol, amount) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_transfer_child_to_parent" => { + let child = arg_u8(args, "child_subaccount")?; + let result = + commands::margin::execute_transfer_child_to_parent_inner(&self.ctx, child) + .await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_sync_parent_to_child" => { + let child = arg_u8(args, "child_subaccount")?; + let result = + commands::margin::execute_sync_parent_to_child_inner(&self.ctx, child).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_margin_leverage_tiers" => { + let symbol = arg_str(args, "symbol")?; + let result = + commands::margin::execute_leverage_tiers_inner(&self.ctx, &symbol).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Position (new) ────────────────────────────────────────── + "vulcan_position_reduce" => { + let symbol = arg_str(args, "symbol")?; + let size = arg_f64(args, "size")?; + let result = + commands::position::execute_reduce_inner(&self.ctx, &symbol, size).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_position_tp_sl" => { + let symbol = arg_str(args, "symbol")?; + let tp = arg_f64_opt(args, "tp"); + let sl = arg_f64_opt(args, "sl"); + let result = + commands::position::execute_tp_sl_inner(&self.ctx, &symbol, tp, sl).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── History ───────────────────────────────────────────────── + "vulcan_history_trades" + | "vulcan_history_orders" + | "vulcan_history_collateral" + | "vulcan_history_funding" + | "vulcan_history_pnl" => Err(crate::error::VulcanError::validation( + "NOT_IMPLEMENTED", + format!("{} is not yet implemented", name), + )), + + // ── Status ──────────────────────────────────────────────────── + "vulcan_status" => { + let result = commands::status::execute_inner(&self.ctx).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Wallet ──────────────────────────────────────────────────── + "vulcan_wallet_list" => { + let result = commands::wallet::execute_list_inner(&self.ctx)?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_wallet_balance" => { + let name = arg_str_opt(args, "name"); + let result = commands::wallet::execute_balance_inner(&self.ctx, name.as_deref())?; + Ok(serde_json::to_value(result).unwrap()) + } + + // ── Account ─────────────────────────────────────────────────── + "vulcan_account_info" => { + let result = commands::account::execute_info_inner(&self.ctx).await?; + Ok(serde_json::to_value(result).unwrap()) + } + "vulcan_account_register" => { + let invite_code = arg_str(args, "invite_code")?; + let result = + commands::account::execute_register_inner(&self.ctx, &invite_code).await?; + Ok(serde_json::to_value(result).unwrap()) + } + + _ => Err(crate::error::VulcanError::validation( + "UNKNOWN_TOOL", + format!("Unknown tool: {}", name), + )), + } + } +} + +// ── Embedded agent resources ──────────────────────────────────────────── +static RESOURCES: &[(&str, &str, &str)] = &[ + // ── Context ──────────────────────────────────────────────────────── + ( + "vulcan://context", + "Vulcan Runtime Context for AI Agents", + include_str!("../../../CONTEXT.md"), + ), + // ── Legacy agent resources ───────────────────────────────────────── + ( + "vulcan://agents/system", + "Vulcan System Prompt", + include_str!("../../../agents/system.md"), + ), + ( + "vulcan://agents/workflows/trade", + "Trade Workflow", + include_str!("../../../agents/workflows/trade.md"), + ), + ( + "vulcan://agents/workflows/portfolio", + "Portfolio Overview Workflow", + include_str!("../../../agents/workflows/portfolio.md"), + ), + ( + "vulcan://agents/workflows/risk", + "Risk Management Rules", + include_str!("../../../agents/workflows/risk.md"), + ), + ( + "vulcan://agents/workflows/onboarding", + "Onboarding & Registration Workflow", + include_str!("../../../agents/workflows/onboarding.md"), + ), + ( + "vulcan://agents/error-catalog", + "Error Catalog — codes, categories, and recovery hints", + include_str!("../../../agents/error-catalog.json"), + ), + // ── Skills ───────────────────────────────────────────────────────── + ( + "vulcan://skills/index", + "Skills Index — all available workflow skills", + include_str!("../../../skills/INDEX.md"), + ), + ( + "vulcan://skills/vulcan-shared", + "Shared runtime contract: auth, invocation, symbol format, safety", + include_str!("../../../skills/vulcan-shared/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-risk-management", + "Pre-trade risk checks, leverage tiers, margin health, when to warn", + include_str!("../../../skills/vulcan-risk-management/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-error-recovery", + "Error category routing, tx_failed recovery, network error handling", + include_str!("../../../skills/vulcan-error-recovery/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-trade-execution", + "Safe order execution with pre-trade checks and post-trade verification", + include_str!("../../../skills/vulcan-trade-execution/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-lot-size-calculator", + "Convert desired token amounts to base lots with worked examples", + include_str!("../../../skills/vulcan-lot-size-calculator/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-tpsl-management", + "Take-profit and stop-loss: direction rules, constraints, set/cancel flows", + include_str!("../../../skills/vulcan-tpsl-management/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-market-intel", + "Ticker, orderbook, candles, market info, and pre-trade analysis", + include_str!("../../../skills/vulcan-market-intel/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-portfolio-intel", + "Portfolio snapshot: margin status, positions, orders, funding rates", + include_str!("../../../skills/vulcan-portfolio-intel/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-margin-operations", + "Deposit, withdraw, transfer, isolated margin, collateral management", + include_str!("../../../skills/vulcan-margin-operations/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-position-management", + "List, show, close, reduce positions and manage TP/SL", + include_str!("../../../skills/vulcan-position-management/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-onboarding", + "New user setup: wallet creation, registration, first deposit", + include_str!("../../../skills/vulcan-onboarding/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-twap-execution", + "Execute large orders as time-weighted slices to reduce market impact", + include_str!("../../../skills/vulcan-twap-execution/SKILL.md"), + ), + ( + "vulcan://skills/vulcan-grid-trading", + "Grid trading with layered limit orders across a price range", + include_str!("../../../skills/vulcan-grid-trading/SKILL.md"), + ), + ( + "vulcan://skills/recipe-emergency-flatten", + "Cancel all orders and close all positions across all markets", + include_str!("../../../skills/recipe-emergency-flatten/SKILL.md"), + ), + ( + "vulcan://skills/recipe-open-hedged-position", + "Open a position with TP/SL protection in one flow", + include_str!("../../../skills/recipe-open-hedged-position/SKILL.md"), + ), + ( + "vulcan://skills/recipe-morning-portfolio-check", + "Daily portfolio review with margin, positions, and funding rates", + include_str!("../../../skills/recipe-morning-portfolio-check/SKILL.md"), + ), + ( + "vulcan://skills/recipe-scale-into-position", + "Add to an existing position in calculated increments", + include_str!("../../../skills/recipe-scale-into-position/SKILL.md"), + ), + ( + "vulcan://skills/recipe-funding-rate-harvest", + "Scan markets for favorable funding rates and open positions", + include_str!("../../../skills/recipe-funding-rate-harvest/SKILL.md"), + ), + ( + "vulcan://skills/recipe-close-and-withdraw", + "Close all positions and withdraw collateral to wallet", + include_str!("../../../skills/recipe-close-and-withdraw/SKILL.md"), + ), +]; + +impl ServerHandler for VulcanMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::from_build_env()) + .with_instructions( + "Vulcan MCP server for Phoenix Perpetuals DEX on Solana. \ + Use market tools for price data, trade tools for order management, \ + position tools to monitor open positions, and margin tools for collateral. \ + Dangerous tools (trades, deposits, withdrawals, cancellations) require \ + acknowledged=true. Size is in base lots — call vulcan_market_info first. \ + Read vulcan://context for the full runtime contract. \ + Read vulcan://skills/index for goal-oriented workflow skills.", + ) + } + + #[allow(deprecated)] + #[allow(clippy::manual_async_fn)] + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async { + let resources: Vec = RESOURCES + .iter() + .map(|(uri, name, _)| { + Annotated::new( + RawResource::new(*uri, *name).with_mime_type("text/markdown"), + None, + ) + }) + .collect(); + Ok(ListResourcesResult::with_all_items(resources)) + } + } + + #[allow(deprecated)] + #[allow(clippy::manual_async_fn)] + fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async move { + let uri = request.uri.as_str(); + let content = RESOURCES.iter().find(|(u, _, _)| *u == uri); + match content { + Some((uri, _, text)) => Ok(ReadResourceResult::new(vec![ResourceContents::text( + *text, *uri, + ) + .with_mime_type("text/markdown")])), + None => Err(ErrorData::resource_not_found( + format!("Unknown resource: {}", uri), + None, + )), + } + } + } + + #[allow(deprecated)] + #[allow(clippy::manual_async_fn)] + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async { + let tools: Vec = self.tools.iter().map(|t| Self::to_rmcp_tool(t)).collect(); + Ok(ListToolsResult::with_all_items(tools)) + } + } + + #[allow(deprecated)] + #[allow(clippy::manual_async_fn)] + fn call_tool( + &self, + request: CallToolRequestParams, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + async move { + let name = request.name.as_ref(); + let args = match &request.arguments { + Some(map) => Value::Object(map.clone()), + None => Value::Object(serde_json::Map::new()), + }; + + // Audit log to stderr (tool name + arg keys only, never values) + let arg_keys: Vec<&String> = match &args { + Value::Object(m) => m.keys().collect(), + _ => vec![], + }; + eprintln!("[mcp] call_tool: {} args={:?}", name, arg_keys); + + // Find tool definition + let tool_def = self.tools.iter().find(|t| t.name == name); + let tool_def = match tool_def { + Some(t) => t, + None => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Unknown tool: {}", + name + ))])); + } + }; + + // Dangerous command gating + if tool_def.dangerous { + if !self.allow_dangerous { + return Ok(CallToolResult::error(vec![Content::text( + "This tool is dangerous and --allow-dangerous was not set on the server.", + )])); + } + let acknowledged = args + .get("acknowledged") + .map(|v| { + v.as_bool().unwrap_or_else(|| { + // Some MCP clients send "true" as a string instead of a JSON boolean + v.as_str() + .map(|s| s.eq_ignore_ascii_case("true")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !acknowledged { + return Ok(CallToolResult::error(vec![Content::text( + "Dangerous operation requires acknowledged=true. \ + This is a real financial transaction. Set acknowledged=true to proceed.", + )])); + } + } + + // Dispatch + match self.dispatch(name, &args).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).unwrap_or_default(); + Ok(CallToolResult::success(vec![Content::text(text)])) + } + Err(e) => { + eprintln!("[mcp] error: {}", e); + let error_json = serde_json::json!({ + "category": e.category.to_string(), + "code": e.code, + "message": e.message, + "retryable": e.category.is_retryable(), + }); + Ok(CallToolResult::error(vec![Content::text( + serde_json::to_string_pretty(&error_json).unwrap_or(e.message.clone()), + )])) + } + } + } + } +} + +// ── Arg extraction helpers ────────────────────────────────────────────── + +fn arg_str(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + crate::error::VulcanError::validation( + "MISSING_ARG", + format!("Required argument '{}' is missing or not a string", key), + ) + }) +} + +fn arg_str_opt(args: &Value, key: &str) -> Option { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn arg_str_or(args: &Value, key: &str, default: &str) -> String { + args.get(key) + .and_then(|v| v.as_str()) + .unwrap_or(default) + .to_string() +} + +fn arg_f64(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse::().ok()))) + .ok_or_else(|| { + crate::error::VulcanError::validation( + "MISSING_ARG", + format!("Required argument '{}' is missing or not a number", key), + ) + }) +} + +fn arg_f64_opt(args: &Value, key: &str) -> Option { + args.get(key).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse::().ok()))) +} + +fn arg_usize_or(args: &Value, key: &str, default: usize) -> usize { + args.get(key) + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse::().ok()))) + .map(|v| v as usize) + .unwrap_or(default) +} + +fn arg_bool_or(args: &Value, key: &str, default: bool) -> bool { + args.get(key) + .and_then(|v| { + v.as_bool() + .or_else(|| v.as_str().map(|s| s.eq_ignore_ascii_case("true"))) + }) + .unwrap_or(default) +} + +fn arg_u8(args: &Value, key: &str) -> Result { + args.get(key) + .and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse::().ok()))) + .map(|v| v as u8) + .ok_or_else(|| { + crate::error::VulcanError::validation( + "MISSING_ARG", + format!("Required argument '{}' is missing or not a number", key), + ) + }) +} + +fn arg_order_array(args: &Value, key: &str) -> Result, crate::error::VulcanError> { + args.get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|entry| { + let price = entry + .get("price") + .and_then(|v| v.as_f64())?; + let size = entry + .get("size") + .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))?; + Some((price, size)) + }) + .collect() + }) + .ok_or_else(|| { + crate::error::VulcanError::validation( + "MISSING_ARG", + format!( + "Required argument '{}' is missing or not an array of {{price, size}} objects", + key + ), + ) + }) +} + +fn arg_str_array(args: &Value, key: &str) -> Result, crate::error::VulcanError> { + args.get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| { + crate::error::VulcanError::validation( + "MISSING_ARG", + format!("Required argument '{}' is missing or not an array", key), + ) + }) +} diff --git a/container/vendor/vulcan/vulcan-lib/src/mcp/session_wallet.rs b/container/vendor/vulcan/vulcan-lib/src/mcp/session_wallet.rs new file mode 100644 index 00000000000..178eb5f14e5 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/mcp/session_wallet.rs @@ -0,0 +1,59 @@ +//! Session wallet — holds decrypted wallet bytes for the MCP session lifetime. + +use crate::error::VulcanError; +use crate::wallet::{Wallet, WalletFile}; +use solana_pubkey::Pubkey; +use std::str::FromStr; +use zeroize::Zeroize; + +/// Holds decrypted wallet bytes in memory for the duration of an MCP session. +/// Bytes are zeroized on drop. +pub struct SessionWallet { + bytes: Vec, + pub public_key: String, + pub authority: Pubkey, + pub trader_pda: Pubkey, +} + +impl SessionWallet { + /// Create a new session wallet from a decrypted wallet and its file metadata. + pub fn new(wallet: &Wallet, wallet_file: &WalletFile) -> Result { + let bytes = wallet.to_bytes().to_vec(); + + let authority = Pubkey::from_str(&wallet_file.public_key) + .map_err(|e| VulcanError::validation("INVALID_PUBKEY", e.to_string()))?; + + let trader_key = phoenix_sdk::types::TraderKey::new(authority); + let trader_pda = trader_key.pda(); + + Ok(Self { + bytes, + public_key: wallet_file.public_key.clone(), + authority, + trader_pda, + }) + } + + /// Reconstruct a Wallet from the stored bytes. + pub fn to_wallet(&self) -> Result { + Wallet::from_bytes(&self.bytes) + .map_err(|e| VulcanError::internal("SESSION_WALLET_ERROR", e.to_string())) + } +} + +impl Drop for SessionWallet { + fn drop(&mut self) { + self.bytes.zeroize(); + } +} + +impl std::fmt::Debug for SessionWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionWallet") + .field("public_key", &self.public_key) + .field("authority", &self.authority) + .field("trader_pda", &self.trader_pda) + .field("bytes", &"[REDACTED]") + .finish() + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/output/json.rs b/container/vendor/vulcan/vulcan-lib/src/output/json.rs new file mode 100644 index 00000000000..fafd8c77540 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/output/json.rs @@ -0,0 +1,56 @@ +//! JSON envelope types for structured output. + +use crate::error::VulcanError; +use serde::Serialize; + +/// Successful response envelope. +#[derive(Debug, Serialize)] +pub struct SuccessEnvelope { + pub ok: bool, + pub data: T, + #[serde(skip_serializing_if = "serde_json::Value::is_null")] + pub meta: serde_json::Value, +} + +impl SuccessEnvelope { + pub fn new(data: T, meta: serde_json::Value) -> Self { + Self { + ok: true, + data, + meta, + } + } +} + +/// Error detail inside the error envelope. +#[derive(Debug, Serialize)] +pub struct ErrorDetail { + pub category: String, + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(skip_serializing_if = "String::is_empty")] + pub hint: String, +} + +/// Error response envelope. +#[derive(Debug, Serialize)] +pub struct ErrorEnvelope { + pub ok: bool, + pub error: ErrorDetail, +} + +impl ErrorEnvelope { + pub fn from_error(err: &VulcanError) -> Self { + Self { + ok: false, + error: ErrorDetail { + category: err.category.to_string(), + code: err.code.clone(), + message: err.message.clone(), + retryable: err.category.is_retryable(), + hint: err.recovery_hint().to_string(), + }, + } + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/output/mod.rs b/container/vendor/vulcan/vulcan-lib/src/output/mod.rs new file mode 100644 index 00000000000..b71836b73fb --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/output/mod.rs @@ -0,0 +1,54 @@ +//! Output formatting — JSON envelopes and human-readable tables. + +mod json; +pub mod table; + +pub use json::{ErrorEnvelope, SuccessEnvelope}; +pub use table::render_table; + +use crate::error::VulcanError; +use serde::Serialize; + +/// Output format selection. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormat { + Json, + #[default] + Table, +} + +/// Render a successful result to stdout in the requested format. +pub fn render_success( + format: OutputFormat, + data: &T, + meta: serde_json::Value, +) { + match format { + OutputFormat::Json => { + let envelope = SuccessEnvelope::new(data, meta); + // unwrap is safe: we control the types and they are Serialize + println!("{}", serde_json::to_string_pretty(&envelope).unwrap()); + } + OutputFormat::Table => { + data.render_table(); + } + } +} + +/// Render an error to stdout in the requested format. +pub fn render_error(format: OutputFormat, error: &VulcanError) { + match format { + OutputFormat::Json => { + let envelope = ErrorEnvelope::from_error(error); + println!("{}", serde_json::to_string_pretty(&envelope).unwrap()); + } + OutputFormat::Table => { + eprintln!("Error: {}", error); + } + } +} + +/// Trait for types that can render themselves as a table. +pub trait TableRenderable { + fn render_table(&self); +} diff --git a/container/vendor/vulcan/vulcan-lib/src/output/table.rs b/container/vendor/vulcan/vulcan-lib/src/output/table.rs new file mode 100644 index 00000000000..b273d76b091 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/output/table.rs @@ -0,0 +1,14 @@ +//! Table rendering helpers using comfy-table. + +use comfy_table::{presets::UTF8_FULL, Table}; + +/// Build a table with standard Vulcan styling. +pub fn render_table(headers: &[&str], rows: Vec>) { + let mut table = Table::new(); + table.load_preset(UTF8_FULL); + table.set_header(headers); + for row in rows { + table.add_row(row); + } + println!("{table}"); +} diff --git a/container/vendor/vulcan/vulcan-lib/src/wallet/keypair.rs b/container/vendor/vulcan/vulcan-lib/src/wallet/keypair.rs new file mode 100644 index 00000000000..f2116d801ee --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/wallet/keypair.rs @@ -0,0 +1,203 @@ +//! Solana keypair handling +//! +//! Implements secure key management with memory zeroization on drop. +//! Extracted from quant/src/wallet/keypair.rs + +use anyhow::{anyhow, Result}; +use solana_sdk::signer::keypair::Keypair; +use solana_sdk::signer::Signer; +use std::path::Path; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::crypto::{decrypt, encrypt, EncryptedData}; + +/// How the private key was provided +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletSource { + /// Base58 encoded string + Base58, + /// Byte array [u8; 64] + ByteArray, + /// Loaded from file + File, +} + +/// A Solana wallet (keypair) +/// +/// Private key material is automatically zeroed from memory when dropped. +/// This struct intentionally does not implement Clone to prevent +/// accidental duplication of sensitive key material in memory. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct Wallet { + /// The raw keypair bytes (64 bytes: 32 private + 32 public) + /// Automatically zeroed on drop via ZeroizeOnDrop + keypair_bytes: Vec, + /// The public key (base58 encoded) + #[zeroize(skip)] + pub public_key: String, +} + +impl Wallet { + /// Create a wallet from a base58-encoded private key + pub fn from_base58(base58_key: &str) -> Result { + let mut bytes = bs58::decode(base58_key) + .into_vec() + .map_err(|e| anyhow!("Invalid base58 encoding: {}", e))?; + + let result = Self::from_bytes(&bytes); + bytes.zeroize(); + result + } + + /// Create a wallet from a byte array + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 64 { + return Err(anyhow!( + "Invalid keypair length: expected 64 bytes, got {}", + bytes.len() + )); + } + + let public_key_bytes = &bytes[32..64]; + let public_key = bs58::encode(public_key_bytes).into_string(); + + Ok(Self { + keypair_bytes: bytes.to_vec(), + public_key, + }) + } + + /// Load a wallet from a JSON file (Solana CLI format) + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| anyhow!("Failed to read keypair file: {}", e))?; + + let mut bytes: Vec = serde_json::from_str(&content) + .map_err(|e| anyhow!("Failed to parse keypair file: {}", e))?; + + let result = Self::from_bytes(&bytes); + bytes.zeroize(); + result + } + + /// Generate a new random wallet + pub fn generate() -> Result { + let keypair = Keypair::new(); + let bytes = keypair.to_bytes(); + Self::from_bytes(&bytes) + } + + /// Get the wallet's public key (address) + pub fn address(&self) -> &str { + &self.public_key + } + + /// Get the raw keypair bytes + pub fn to_bytes(&self) -> &[u8] { + &self.keypair_bytes + } + + /// Get a Solana SDK Keypair from this wallet + #[allow(deprecated)] + pub fn to_solana_keypair(&self) -> Result { + solana_sdk::signature::Keypair::from_bytes(&self.keypair_bytes) + .map_err(|e| anyhow!("Failed to create Solana keypair: {}", e)) + } + + /// Encrypt the wallet with a password + pub fn encrypt(&self, password: &str) -> Result { + encrypt(&self.keypair_bytes, password) + } + + /// Decrypt a wallet from encrypted data + pub fn decrypt(encrypted: &EncryptedData, password: &str) -> Result { + let mut bytes = decrypt(encrypted, password)?; + let result = Self::from_bytes(&bytes); + bytes.zeroize(); + result + } + + /// Save encrypted wallet to a file + pub fn save_encrypted(&self, path: &Path, password: &str) -> Result<()> { + let encrypted = self.encrypt(password)?; + let json = serde_json::to_string_pretty(&encrypted)?; + std::fs::write(path, json)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } + + /// Load encrypted wallet from a file + pub fn load_encrypted(path: &Path, password: &str) -> Result { + let content = std::fs::read_to_string(path)?; + let encrypted: EncryptedData = serde_json::from_str(&content)?; + Self::decrypt(&encrypted, password) + } + + /// Sign a message with the wallet's private key + pub fn sign(&self, message: &[u8]) -> Result> { + let keypair = self.to_keypair()?; + let signature = keypair.sign_message(message); + Ok(signature.as_ref().to_vec()) + } + + /// Get a Keypair for use with solana_sdk Transaction signing + pub fn to_keypair(&self) -> Result { + Keypair::try_from(self.keypair_bytes.as_slice()) + .map_err(|e| anyhow!("Invalid keypair bytes: {}", e)) + } +} + +impl std::fmt::Debug for Wallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Wallet") + .field("public_key", &self.public_key) + .field("keypair_bytes", &"[REDACTED]") + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wallet_from_bytes() { + let mut bytes = vec![0u8; 64]; + bytes[32..64].copy_from_slice(&[1u8; 32]); + + let wallet = Wallet::from_bytes(&bytes).unwrap(); + assert!(!wallet.public_key.is_empty()); + } + + #[test] + fn test_wallet_encrypt_decrypt() { + let bytes = vec![42u8; 64]; + let wallet = Wallet::from_bytes(&bytes).unwrap(); + let password = "test_password"; + + let encrypted = wallet.encrypt(password).unwrap(); + let decrypted = Wallet::decrypt(&encrypted, password).unwrap(); + + assert_eq!(wallet.keypair_bytes, decrypted.keypair_bytes); + } + + #[test] + fn test_invalid_keypair_length() { + let bytes = vec![0u8; 32]; + let result = Wallet::from_bytes(&bytes); + assert!(result.is_err()); + } + + #[test] + fn test_wallet_generate() { + let wallet = Wallet::generate().unwrap(); + assert!(!wallet.public_key.is_empty()); + assert_eq!(wallet.to_bytes().len(), 64); + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/wallet/mod.rs b/container/vendor/vulcan/vulcan-lib/src/wallet/mod.rs new file mode 100644 index 00000000000..486f7d02222 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/wallet/mod.rs @@ -0,0 +1,10 @@ +//! Wallet management for Solana keypairs +//! +//! Handles loading, storing, and using Solana keypairs with encrypted storage. +//! Extracted from the Quant project. + +mod keypair; +pub mod store; + +pub use keypair::{Wallet, WalletSource}; +pub use store::{WalletFile, WalletStore}; diff --git a/container/vendor/vulcan/vulcan-lib/src/wallet/store.rs b/container/vendor/vulcan/vulcan-lib/src/wallet/store.rs new file mode 100644 index 00000000000..700f04ade9c --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/wallet/store.rs @@ -0,0 +1,114 @@ +//! Wallet file storage — manages encrypted wallets in `~/.vulcan/wallets/`. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::crypto::EncryptedData; + +/// Metadata stored alongside encrypted wallet data. +#[derive(Debug, Serialize, Deserialize)] +pub struct WalletFile { + pub name: String, + pub public_key: String, + pub encrypted: EncryptedData, + pub created_at: String, +} + +/// Manages the wallet directory (`~/.vulcan/wallets/`). +pub struct WalletStore { + wallets_dir: PathBuf, +} + +impl WalletStore { + /// Create a new WalletStore, ensuring the wallets directory exists. + pub fn new(vulcan_dir: &Path) -> Result { + let wallets_dir = vulcan_dir.join("wallets"); + std::fs::create_dir_all(&wallets_dir)?; + Ok(Self { wallets_dir }) + } + + /// Path to a wallet file by name. + pub fn wallet_path(&self, name: &str) -> PathBuf { + self.wallets_dir.join(format!("{}.json", name)) + } + + /// Check if a wallet exists. + pub fn exists(&self, name: &str) -> bool { + self.wallet_path(name).exists() + } + + /// Save a wallet file. + pub fn save(&self, wallet_file: &WalletFile) -> Result<()> { + let path = self.wallet_path(&wallet_file.name); + let json = serde_json::to_string_pretty(wallet_file)?; + std::fs::write(&path, json)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } + + /// Load a wallet file by name. + pub fn load(&self, name: &str) -> Result { + let path = self.wallet_path(name); + if !path.exists() { + return Err(anyhow!("Wallet '{}' not found", name)); + } + let content = std::fs::read_to_string(&path)?; + let wallet_file: WalletFile = serde_json::from_str(&content)?; + Ok(wallet_file) + } + + /// List all wallet names. + pub fn list(&self) -> Result> { + let mut names = Vec::new(); + for entry in std::fs::read_dir(&self.wallets_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Some(stem) = path.file_stem() { + names.push(stem.to_string_lossy().to_string()); + } + } + } + names.sort(); + Ok(names) + } + + /// Remove a wallet by name. + pub fn remove(&self, name: &str) -> Result<()> { + let path = self.wallet_path(name); + if !path.exists() { + return Err(anyhow!("Wallet '{}' not found", name)); + } + std::fs::remove_file(&path)?; + Ok(()) + } + + /// Get or set the default wallet name. + pub fn default_wallet(&self) -> Result> { + let default_path = self.wallets_dir.join("default"); + if default_path.exists() { + let name = std::fs::read_to_string(&default_path)?.trim().to_string(); + if self.exists(&name) { + return Ok(Some(name)); + } + } + Ok(None) + } + + /// Set the default wallet. + pub fn set_default(&self, name: &str) -> Result<()> { + if !self.exists(name) { + return Err(anyhow!("Wallet '{}' not found", name)); + } + let default_path = self.wallets_dir.join("default"); + std::fs::write(default_path, name)?; + Ok(()) + } +} diff --git a/container/vendor/vulcan/vulcan-lib/src/watch.rs b/container/vendor/vulcan/vulcan-lib/src/watch.rs new file mode 100644 index 00000000000..13581ba0783 --- /dev/null +++ b/container/vendor/vulcan/vulcan-lib/src/watch.rs @@ -0,0 +1,98 @@ +//! Watch mode — live updates via WebSocket. +//! +//! When `--watch` is active, commands render once then subscribe to a WS channel. +//! On each update, the screen is cleared and the command re-renders. + +use crate::context::AppContext; +use crate::error::VulcanError; +use phoenix_sdk::PhoenixWSClient; +use solana_pubkey::Pubkey; + +/// What WS channel to subscribe to for live updates. +pub enum WatchKind { + /// Trader state (orders, positions, margin) — requires authority pubkey. + TraderState(Pubkey), + /// Market stats (ticker) — requires symbol. + Market(String), + /// L2 orderbook — requires symbol. + Orderbook(String), +} + +/// Connect to WS and run a watch loop. +/// +/// `render` is called on each update — it should clear and re-render. +/// The initial render should happen *before* calling this function. +pub async fn watch_loop( + ctx: &AppContext, + kind: WatchKind, + render: F, +) -> Result<(), VulcanError> +where + F: Fn() -> Fut, + Fut: std::future::Future>, +{ + let ws_url = ws_url_from_api(&ctx.config.network.api_url)?; + let api_key = ctx.config.network.api_key.clone(); + + let client = PhoenixWSClient::new(&ws_url, api_key) + .map_err(|e| VulcanError::api("WS_CONNECT_FAILED", e.to_string()))?; + + match kind { + WatchKind::TraderState(authority) => { + let (mut rx, _handle) = client + .subscribe_to_trader_state(&authority) + .map_err(|e| VulcanError::api("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + while let Some(_msg) = rx.recv().await { + clear_screen(); + render().await?; + } + } + WatchKind::Market(symbol) => { + let (mut rx, _handle) = client + .subscribe_to_market(symbol) + .map_err(|e| VulcanError::api("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + while let Some(_msg) = rx.recv().await { + clear_screen(); + render().await?; + } + } + WatchKind::Orderbook(symbol) => { + let (mut rx, _handle) = client + .subscribe_to_orderbook(symbol) + .map_err(|e| VulcanError::api("WS_SUBSCRIBE_FAILED", e.to_string()))?; + + while let Some(_msg) = rx.recv().await { + clear_screen(); + render().await?; + } + } + } + + Ok(()) +} + +fn clear_screen() { + // ANSI escape: move cursor to top-left and clear screen + print!("\x1b[2J\x1b[H"); +} + +/// Derive WebSocket URL from the HTTP API URL. +fn ws_url_from_api(api_url: &str) -> Result { + let mut url = api_url.to_string(); + + // Replace http(s) scheme with ws(s) + if url.starts_with("https://") { + url = format!("wss://{}", &url[8..]); + } else if url.starts_with("http://") { + url = format!("ws://{}", &url[7..]); + } + + // Append /ws if not present + if !url.ends_with("/ws") && !url.ends_with("/ws/") { + url = format!("{}/ws", url.trim_end_matches('/')); + } + + Ok(url) +} diff --git a/container/vendor/vulcan/vulcan/Cargo.toml b/container/vendor/vulcan/vulcan/Cargo.toml new file mode 100644 index 00000000000..2de8ad6fa1c --- /dev/null +++ b/container/vendor/vulcan/vulcan/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "vulcan" +version = "0.1.0" +edition = "2021" +description = "Vulcan — AI-native CLI for Phoenix Perpetuals DEX on Solana" + +[[bin]] +name = "vulcan" +path = "src/main.rs" + +[dependencies] +vulcan-lib = { workspace = true } + +clap = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +rpassword = { workspace = true } +rmcp = { workspace = true } diff --git a/container/vendor/vulcan/vulcan/src/main.rs b/container/vendor/vulcan/vulcan/src/main.rs new file mode 100644 index 00000000000..ca796dc414b --- /dev/null +++ b/container/vendor/vulcan/vulcan/src/main.rs @@ -0,0 +1,150 @@ +//! Vulcan CLI entry point. + +use clap::Parser; +use vulcan_lib::cli::{Cli, Command}; +use vulcan_lib::context::AppContext; +use vulcan_lib::error::VulcanError; +use vulcan_lib::output::render_error; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // Initialize logging + if cli.verbose { + tracing_subscriber::fmt() + .with_env_filter("vulcan=debug") + .with_writer(std::io::stderr) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter("vulcan=warn") + .with_writer(std::io::stderr) + .init(); + } + + let ctx = match AppContext::new( + cli.output, + cli.dry_run, + cli.yes, + cli.verbose, + cli.watch, + cli.rpc_url, + cli.api_url, + cli.api_key, + ) { + Ok(ctx) => ctx, + Err(e) => { + let err = VulcanError::config("INIT_FAILED", e.to_string()); + render_error(cli.output, &err); + std::process::exit(err.exit_code()); + } + }; + + let result = match cli.command { + Command::Wallet(cmd) => vulcan_lib::commands::wallet::execute(&ctx, cmd).await, + Command::Market(cmd) => vulcan_lib::commands::market::execute(&ctx, cmd).await, + Command::Trade(cmd) => vulcan_lib::commands::trade::execute(&ctx, cmd).await, + Command::Position(cmd) => vulcan_lib::commands::position::execute(&ctx, cmd).await, + Command::Margin(cmd) => vulcan_lib::commands::margin::execute(&ctx, cmd).await, + Command::Account(cmd) => vulcan_lib::commands::account::execute(&ctx, cmd).await, + Command::History(_cmd) => Err(vulcan_lib::error::VulcanError::validation( + "NOT_IMPLEMENTED", + "history commands are not yet implemented", + )), + Command::Status => vulcan_lib::commands::status::execute(&ctx).await, + Command::Setup => vulcan_lib::commands::setup::execute(&ctx.wallet_store), + Command::Version => { + println!("vulcan {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Command::AgentContext => { + print!("{}", include_str!("../../CONTEXT.md")); + Ok(()) + } + Command::Mcp { + allow_dangerous, + groups, + } => run_mcp(allow_dangerous, groups, cli.verbose).await, + }; + + if let Err(err) = result { + render_error(ctx.output_format, &err); + std::process::exit(err.exit_code()); + } +} + +async fn run_mcp( + allow_dangerous: bool, + groups: Option>, + verbose: bool, +) -> Result<(), VulcanError> { + use rmcp::ServiceExt; + use std::sync::Arc; + + let mut mcp_ctx = AppContext::new( + vulcan_lib::output::OutputFormat::Json, + false, // dry_run + true, // yes (auto-confirm for MCP) + verbose, + false, // watch + None, + None, + None, + ) + .map_err(|e| VulcanError::config("MCP_INIT_FAILED", e.to_string()))?; + + // Unlock session wallet if dangerous tools are enabled + if allow_dangerous { + let password = match std::env::var("VULCAN_WALLET_PASSWORD") { + Ok(pw) => pw, + Err(_) => { + eprint!("Wallet password (for MCP session): "); + rpassword::read_password() + .map_err(|e| VulcanError::io("PASSWORD_READ_FAILED", e.to_string()))? + } + }; + + let wallet_name = mcp_ctx + .wallet_store + .default_wallet() + .map_err(|e| VulcanError::config("CONFIG_ERROR", e.to_string()))? + .ok_or_else(|| { + VulcanError::config( + "NO_DEFAULT_WALLET", + "No default wallet set. Use 'vulcan wallet set-default '", + ) + })?; + + let wallet_file = mcp_ctx + .wallet_store + .load(&wallet_name) + .map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?; + + let wallet = vulcan_lib::wallet::Wallet::decrypt(&wallet_file.encrypted, &password) + .map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?; + + let session_wallet = + vulcan_lib::mcp::session_wallet::SessionWallet::new(&wallet, &wallet_file)?; + + eprintln!("[mcp] Session wallet unlocked: {}", wallet_file.public_key); + mcp_ctx.session_wallet = Some(Arc::new(session_wallet)); + } + + let ctx = Arc::new(mcp_ctx); + let server = vulcan_lib::mcp::server::VulcanMcpServer::new(ctx, allow_dangerous, groups); + + eprintln!("[mcp] Starting Vulcan MCP server over stdio..."); + + let service = server + .serve(rmcp::transport::stdio()) + .await + .map_err(|e| VulcanError::internal("MCP_SERVE_FAILED", e.to_string()))?; + + service + .waiting() + .await + .map_err(|e| VulcanError::internal("MCP_WAIT_FAILED", e.to_string()))?; + + Ok(()) +} From 0158183ad4eb32e64966ce4953d38cc2d69e4dec Mon Sep 17 00:00:00 2001 From: NickNut <68846529+Se76@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:36:46 +0200 Subject: [PATCH 2/3] update strucutre --- container/skills/phoenix-perps/SKILL.md | 48 +++++ .../SKILL.md | 66 ++++++ .../phoenix-recipe-emergency-flatten/SKILL.md | 52 +++++ .../SKILL.md | 69 ++++++ .../SKILL.md | 55 +++++ .../SKILL.md | 72 +++++++ .../SKILL.md | 64 ++++++ .../phoenix-vulcan-error-recovery/SKILL.md | 94 +++++++++ .../phoenix-vulcan-grid-trading/SKILL.md | 196 ++++++++++++++++++ .../SKILL.md | 94 +++++++++ .../phoenix-vulcan-margin-operations/SKILL.md | 90 ++++++++ .../phoenix-vulcan-market-intel/SKILL.md | 76 +++++++ .../phoenix-vulcan-onboarding/SKILL.md | 98 +++++++++ .../phoenix-vulcan-portfolio-intel/SKILL.md | 75 +++++++ .../SKILL.md | 102 +++++++++ .../phoenix-vulcan-risk-management/SKILL.md | 94 +++++++++ .../phoenix-vulcan-shared/SKILL.md | 75 +++++++ .../phoenix-vulcan-tpsl-management/SKILL.md | 92 ++++++++ .../phoenix-vulcan-trade-execution/SKILL.md | 143 +++++++++++++ .../phoenix-vulcan-twap-execution/SKILL.md | 155 ++++++++++++++ 20 files changed, 1810 insertions(+) create mode 100644 container/skills/phoenix-perps/phoenix-recipe-close-and-withdraw/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-recipe-emergency-flatten/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-recipe-funding-rate-harvest/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-recipe-morning-portfolio-check/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-recipe-open-hedged-position/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-recipe-scale-into-position/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-error-recovery/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-grid-trading/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-lot-size-calculator/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-margin-operations/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-market-intel/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-onboarding/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-portfolio-intel/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-position-management/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-risk-management/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-shared/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-tpsl-management/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-trade-execution/SKILL.md create mode 100644 container/skills/phoenix-perps/phoenix-vulcan-twap-execution/SKILL.md diff --git a/container/skills/phoenix-perps/SKILL.md b/container/skills/phoenix-perps/SKILL.md index 249fbad871a..409ac8e33fd 100644 --- a/container/skills/phoenix-perps/SKILL.md +++ b/container/skills/phoenix-perps/SKILL.md @@ -255,3 +255,51 @@ Route on `.error.category`: 4. **Never execute trades without user confirmation** unless they explicitly opted into auto-execute mode. 5. **Report all transaction signatures** to the user for on-chain verification. 6. **On `tx_failed`, verify state before retrying** — the tx may have partially succeeded. + +## Detailed Skills + +Load these sub-skills for deeper guidance on specific topics. + +### Core + +| Skill | Path | Description | +|-------|------|-------------| +| Shared Contract | `phoenix-vulcan-shared/` | Auth, invocation contract, symbol format, size units, safety rules | +| Risk Management | `phoenix-vulcan-risk-management/` | Pre-trade risk checks, leverage tiers, margin health, when to warn | +| Error Recovery | `phoenix-vulcan-error-recovery/` | Error category routing, tx_failed recovery, network error handling | + +### Trading + +| Skill | Path | Description | +|-------|------|-------------| +| Trade Execution | `phoenix-vulcan-trade-execution/` | Safe order execution with pre/post-trade checks | +| Lot Size Calculator | `phoenix-vulcan-lot-size-calculator/` | Convert token amounts to base lots with worked examples | +| TP/SL Management | `phoenix-vulcan-tpsl-management/` | Take-profit and stop-loss: direction rules, set/cancel flows | +| TWAP Execution | `phoenix-vulcan-twap-execution/` | Time-weighted average price execution for large orders | +| Grid Trading | `phoenix-vulcan-grid-trading/` | Layered limit orders across a price range | + +### Market Data + +| Skill | Path | Description | +|-------|------|-------------| +| Market Intel | `phoenix-vulcan-market-intel/` | Ticker, orderbook, candles, pre-trade analysis | + +### Portfolio & Account + +| Skill | Path | Description | +|-------|------|-------------| +| Portfolio Intel | `phoenix-vulcan-portfolio-intel/` | Portfolio snapshot: margin, positions, orders, funding | +| Margin Operations | `phoenix-vulcan-margin-operations/` | Deposit, withdraw, transfer, isolated margin | +| Position Management | `phoenix-vulcan-position-management/` | List, show, close, reduce, attach TP/SL | +| Onboarding | `phoenix-vulcan-onboarding/` | New user setup: wallet creation, registration, first deposit | + +### Recipes (Multi-Step Workflows) + +| Recipe | Path | Description | +|--------|------|-------------| +| Emergency Flatten | `phoenix-recipe-emergency-flatten/` | Cancel all orders and close all positions | +| Open Hedged Position | `phoenix-recipe-open-hedged-position/` | Open position with TP/SL in one flow | +| Morning Portfolio Check | `phoenix-recipe-morning-portfolio-check/` | Daily portfolio review | +| Scale Into Position | `phoenix-recipe-scale-into-position/` | Add to position in calculated increments | +| Funding Rate Harvest | `phoenix-recipe-funding-rate-harvest/` | Scan for favorable funding and open positions | +| Close and Withdraw | `phoenix-recipe-close-and-withdraw/` | Close all and withdraw collateral to wallet | diff --git a/container/skills/phoenix-perps/phoenix-recipe-close-and-withdraw/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-close-and-withdraw/SKILL.md new file mode 100644 index 00000000000..b31efc855b7 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-close-and-withdraw/SKILL.md @@ -0,0 +1,66 @@ +--- +name: recipe-close-and-withdraw +version: 1.0.0 +description: "Close all positions and withdraw collateral to wallet." +metadata: + openclaw: + category: "recipe" + domain: "portfolio" + requires: + bins: ["vulcan"] + skills: ["vulcan-position-management", "vulcan-margin-operations"] +--- + +# Close and Withdraw + +> **PREREQUISITE:** Load `vulcan-position-management` and `vulcan-margin-operations` skills. + +Close all positions, cancel all orders, and withdraw collateral to the wallet. + +> **CAUTION:** This exits all positions at market price and withdraws funds. Irreversible. + +## Steps + +1. Check current state: + ``` + vulcan_position_list → {} + vulcan_trade_orders → {} + vulcan_margin_status → {} + ``` + +2. Cancel all resting orders for each market: + ``` + vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } + # ... repeat for each market with open orders + ``` + +3. Close each position: + ``` + vulcan_position_close → { symbol: "SOL", acknowledged: true } + # ... repeat for each open position + ``` + +4. Verify flat: + ``` + vulcan_position_list → {} # should be empty + vulcan_trade_orders → {} # should be empty + ``` + +5. Check available to withdraw: + ``` + vulcan_margin_status → {} + ``` + Note the `available_to_withdraw` amount. + +6. Withdraw collateral: + ``` + vulcan_margin_withdraw → { amount: , acknowledged: true } + ``` + +7. Verify withdrawal: + ``` + vulcan_wallet_balance → {} # USDC should have increased + vulcan_margin_status → {} # collateral should be ~0 + ``` + +8. Report all transaction signatures. diff --git a/container/skills/phoenix-perps/phoenix-recipe-emergency-flatten/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-emergency-flatten/SKILL.md new file mode 100644 index 00000000000..98b077912c2 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-emergency-flatten/SKILL.md @@ -0,0 +1,52 @@ +--- +name: recipe-emergency-flatten +version: 1.0.0 +description: "Cancel all orders and close all positions across all markets." +metadata: + openclaw: + category: "recipe" + domain: "risk" + requires: + bins: ["vulcan"] + skills: ["vulcan-risk-management", "vulcan-position-management"] +--- + +# Emergency Flatten + +> **PREREQUISITE:** Load `vulcan-risk-management` and `vulcan-position-management` skills. + +Cancel all resting orders and close all open positions. Use when margin health is critical or the user wants to exit everything immediately. + +> **CAUTION:** This executes multiple real transactions. Each step is irreversible. + +## Steps + +1. Check current state: + ``` + vulcan_margin_status → {} + vulcan_position_list → {} + vulcan_trade_orders → {} + ``` + +2. Cancel all orders for each market with open orders: + ``` + vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } + vulcan_trade_cancel_all → { symbol: "BTC", acknowledged: true } + # ... repeat for each market + ``` + +3. Close each open position: + ``` + vulcan_position_close → { symbol: "SOL", acknowledged: true } + vulcan_position_close → { symbol: "BTC", acknowledged: true } + # ... repeat for each position + ``` + +4. Verify everything is flat: + ``` + vulcan_position_list → {} # should be empty + vulcan_trade_orders → {} # should be empty + vulcan_margin_status → {} # all collateral should be available + ``` + +5. Report all transaction signatures to the user. diff --git a/container/skills/phoenix-perps/phoenix-recipe-funding-rate-harvest/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-funding-rate-harvest/SKILL.md new file mode 100644 index 00000000000..8abe09fcb14 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-funding-rate-harvest/SKILL.md @@ -0,0 +1,69 @@ +--- +name: recipe-funding-rate-harvest +version: 1.0.0 +description: "Scan markets for favorable funding rates and open positions to capture funding." +metadata: + openclaw: + category: "recipe" + domain: "strategy" + requires: + bins: ["vulcan"] + skills: ["vulcan-market-intel", "vulcan-trade-execution"] +--- + +# Funding Rate Harvest + +> **PREREQUISITE:** Load `vulcan-market-intel` and `vulcan-trade-execution` skills. + +Scan perpetual markets for attractive funding rates and open positions to earn funding payments. + +> **CAUTION:** Funding rate strategies carry directional risk. The position PnL may exceed funding income. + +## Steps + +1. List all markets: + ``` + vulcan_market_list → {} + ``` + +2. Get ticker for each market to check funding rates: + ``` + vulcan_market_ticker → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "BTC" } + vulcan_market_ticker → { symbol: "ETH" } + # ... for each active market + ``` + +3. Identify favorable rates: + - **Positive funding rate** → shorts receive payment → consider short. + - **Negative funding rate** → longs receive payment → consider long. + - Look for rates > 0.01% per interval for meaningful income. + +4. For the best opportunity, check market conditions: + ``` + vulcan_market_info → { symbol } # fees, leverage tiers + vulcan_market_orderbook → { symbol } # spread, depth + ``` + +5. Check margin: + ``` + vulcan_margin_status → {} + ``` + +6. Calculate position size accounting for: + - Expected funding income vs taker fees (round-trip cost). + - Leverage tier limits. + - Acceptable directional risk. + +7. Present analysis to user: funding rate, estimated daily income, entry cost (fees + spread), break-even time. + +8. Execute with user approval: + ``` + vulcan_trade_market_sell → { symbol: "SOL", size: , acknowledged: true } + ``` + (Short for positive funding rate, long for negative.) + +9. Verify position: + ``` + vulcan_position_show → { symbol } + ``` diff --git a/container/skills/phoenix-perps/phoenix-recipe-morning-portfolio-check/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-morning-portfolio-check/SKILL.md new file mode 100644 index 00000000000..3056014dbd0 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-morning-portfolio-check/SKILL.md @@ -0,0 +1,55 @@ +--- +name: recipe-morning-portfolio-check +version: 1.0.0 +description: "Daily portfolio review with margin, positions, orders, and funding rates." +metadata: + openclaw: + category: "recipe" + domain: "portfolio" + requires: + bins: ["vulcan"] + skills: ["vulcan-portfolio-intel", "vulcan-market-intel"] +--- + +# Morning Portfolio Check + +> **PREREQUISITE:** Load `vulcan-portfolio-intel` and `vulcan-market-intel` skills. + +Daily portfolio review — read-only, no trades. + +## Steps + +1. Get account health: + ``` + vulcan_margin_status → {} + ``` + +2. Get all positions: + ``` + vulcan_position_list → {} + ``` + +3. Get all resting orders: + ``` + vulcan_trade_orders → {} + ``` + +4. For each position, check funding rate: + ``` + vulcan_market_ticker → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "BTC" } + # ... for each held position + ``` + +5. For each position, check TP/SL status: + ``` + vulcan_position_show → { symbol: "SOL" } + # Check take_profit_price and stop_loss_price + ``` + +6. Present summary to user: + - Account: risk state, total collateral, total PnL, available to withdraw. + - Each position: symbol, side, size, entry, mark, PnL, liquidation price, TP/SL. + - Funding exposure: which positions are paying/receiving funding. + - Resting orders: any limit orders on the book. + - Warnings: positions near liquidation, elevated funding rates, wide spreads. diff --git a/container/skills/phoenix-perps/phoenix-recipe-open-hedged-position/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-open-hedged-position/SKILL.md new file mode 100644 index 00000000000..249eff9afe8 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-open-hedged-position/SKILL.md @@ -0,0 +1,72 @@ +--- +name: recipe-open-hedged-position +version: 1.0.0 +description: "Open a position with TP/SL protection in one complete flow." +metadata: + openclaw: + category: "recipe" + domain: "trading" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-tpsl-management"] +--- + +# Open Hedged Position + +> **PREREQUISITE:** Load `vulcan-trade-execution`, `vulcan-lot-size-calculator`, and `vulcan-tpsl-management` skills. + +Open a position with take-profit and stop-loss protection in one complete flow. + +> **CAUTION:** Live orders spend real money. Confirm with the user before executing. + +## Steps + +1. Get market configuration: + ``` + vulcan_market_info → { symbol: "SOL" } + ``` + Extract `base_lots_decimals` for size calculation. + +2. Get current price: + ``` + vulcan_market_ticker → { symbol: "SOL" } + ``` + +3. Check margin: + ``` + vulcan_margin_status → {} + ``` + Ensure risk_state is Healthy and sufficient collateral is available. + +4. Check orderbook for slippage: + ``` + vulcan_market_orderbook → { symbol: "SOL", depth: 10 } + ``` + +5. Calculate lot size: + ``` + base_lots = desired_tokens * 10^base_lots_decimals + ``` + +6. Calculate TP/SL levels based on user's risk/reward ratio. + +7. Confirm with user: symbol, direction, size, TP, SL, estimated fees. + +8. Execute with TP/SL attached: + ``` + vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + tp: 160.0, + sl: 140.0, + acknowledged: true + } + ``` + +9. Verify position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Confirm: position opened, TP/SL attached (check take_profit_price, stop_loss_price). + +10. Report transaction signature. diff --git a/container/skills/phoenix-perps/phoenix-recipe-scale-into-position/SKILL.md b/container/skills/phoenix-perps/phoenix-recipe-scale-into-position/SKILL.md new file mode 100644 index 00000000000..e05996ffc87 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-recipe-scale-into-position/SKILL.md @@ -0,0 +1,64 @@ +--- +name: recipe-scale-into-position +version: 1.0.0 +description: "Add to an existing position in calculated increments." +metadata: + openclaw: + category: "recipe" + domain: "trading" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-risk-management"] +--- + +# Scale Into Position + +> **PREREQUISITE:** Load `vulcan-trade-execution`, `vulcan-lot-size-calculator`, and `vulcan-risk-management` skills. + +Add to an existing position in calculated increments. + +> **CAUTION:** Each increment is a real transaction. Confirm with user before each step. + +## Steps + +1. Check existing position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Note current size, entry price, and margin usage. + +2. Check margin availability: + ``` + vulcan_margin_status → {} + ``` + Ensure sufficient collateral for the additional size. + +3. Get market info for lot calculation: + ``` + vulcan_market_info → { symbol: "SOL" } + vulcan_market_ticker → { symbol: "SOL" } + ``` + +4. Calculate increment size: + ``` + increment_lots = desired_increment_tokens * 10^base_lots_decimals + ``` + +5. Check leverage tier — ensure total position (existing + increment) doesn't exceed max leverage. + +6. Confirm with user: current position, proposed addition, new total, margin impact. + +7. Execute: + ``` + vulcan_trade_market_buy → { symbol: "SOL", size: , acknowledged: true } + ``` + +8. Verify updated position: + ``` + vulcan_position_show → { symbol: "SOL" } + ``` + Confirm new size = old size + increment. + +9. Report transaction signature. + +Repeat steps 2-9 for each additional increment. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-error-recovery/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-error-recovery/SKILL.md new file mode 100644 index 00000000000..9a714b2e913 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-error-recovery/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-error-recovery +version: 1.0.0 +description: "Error category routing, tx_failed recovery, and network error handling for Vulcan." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-error-recovery + +Use this skill for: +- Routing errors by category +- Recovering from failed on-chain transactions +- Handling network and rate limit errors + +## Error Envelope Format + +```json +{ + "ok": false, + "error": { + "category": "validation", + "code": "UNKNOWN_MARKET", + "message": "Market not found", + "retryable": false + } +} +``` + +## Category Routing Table + +| Category | Exit | Retryable | Action | +|----------|------|-----------|--------| +| `validation` | 1 | No | Fix the input. Common: UNKNOWN_MARKET, MISSING_ARG, INVALID_INTERVAL | +| `auth` | 2 | No | Check wallet exists and password is correct. Run `vulcan wallet list` | +| `config` | 3 | No | Run `vulcan setup` to recreate config | +| `api` | 4 | No | Phoenix API issue. Check `vulcan status` for connectivity | +| `network` | 5 | Yes | Transient. Retry with exponential backoff (1s, 2s, 4s) | +| `rate_limit` | 6 | Yes | Wait 5s and retry. Reduce request frequency | +| `tx_failed` | 7 | No | **Critical: verify state before retrying.** See below | +| `io` | 8 | Yes | File permission issue. Check `~/.vulcan/` permissions | +| `dangerous_gate` | 9 | No | Add `acknowledged: true` to the tool call | +| `internal` | 10 | No | Bug in vulcan. Report it | + +## tx_failed Recovery (Critical) + +On-chain transactions can fail in complex ways. **Never blind-retry.** + +1. **Check position state first:** + ``` + vulcan_position_list → {} + vulcan_margin_status → {} + ``` + +2. **Common causes:** + - Blockhash expired — transaction took too long. Safe to retry with fresh state. + - Insufficient SOL for fees — check `vulcan_wallet_balance`. + - Account state changed — another transaction modified the account between build and send. + - Slippage exceeded — market moved. Re-check price and retry. + +3. **Recovery pattern:** + ``` + 1. vulcan_position_list → {} # did the original tx partially succeed? + 2. vulcan_margin_status → {} # is collateral state as expected? + 3. vulcan_wallet_balance → {} # enough SOL for fees? + 4. vulcan_market_ticker → { symbol } # has price moved significantly? + 5. Re-attempt the operation if state is clean + ``` + +## Network Error Recovery + +``` +1. Wait 1 second +2. Retry the same call +3. If still failing, wait 2 seconds and retry +4. After 3 failures, check connectivity: vulcan_status → {} +5. Report to user if API is down +``` + +## Common Error Codes and Fixes + +| Code | Fix | +|------|-----| +| `UNKNOWN_MARKET` | Run `vulcan_market_list` to see available symbols | +| `MISSING_ARG` | Check tool schema for required fields | +| `NO_POSITION` | No open position. Check `vulcan_position_list` | +| `ISOLATED_ONLY_MARKET` | Re-run with `isolated: true, collateral: ` | +| `NO_DEFAULT_WALLET` | Run `vulcan wallet set-default ` | +| `DECRYPT_FAILED` | Wrong password. Check `VULCAN_WALLET_PASSWORD` | +| `NO_TRADER_ACCOUNT` | Register with `vulcan_account_register` | +| `BUILD_TPSL_FAILED` | TP/SL only works when opening/extending a position | diff --git a/container/skills/phoenix-perps/phoenix-vulcan-grid-trading/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-grid-trading/SKILL.md new file mode 100644 index 00000000000..707afeb45a7 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-grid-trading/SKILL.md @@ -0,0 +1,196 @@ +--- +name: vulcan-grid-trading +version: 1.0.0 +description: "Grid trading with layered limit orders across a price range on Phoenix DEX perpetuals." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator", "vulcan-risk-management"] +--- + +# vulcan-grid-trading + +Use this skill for: +- Placing a grid of limit orders across a price range +- Profiting from sideways/ranging markets on perpetual futures +- Managing grid state (filled orders, replacements) +- Running a market-making-like strategy + +## Core Concept + +Grid trading places buy limit orders below the current price and sell limit orders above it at fixed intervals. When a buy fills, a corresponding sell is placed one grid level higher. When a sell fills, a corresponding buy is placed one grid level lower. Profit comes from capturing the spread at each level. + +On perpetual futures (Phoenix DEX), this means opening and closing positions at grid levels. Funding rate costs/income also factor into profitability. + +## Grid Parameters + +Define with the user before starting: +- **Symbol**: e.g., SOL +- **Price range**: lower bound to upper bound (e.g., 140–160) +- **Grid levels**: number of orders per side (e.g., 5 buy + 5 sell = 10 total) +- **Size per level**: in tokens (agent converts to base lots) + +Grid spacing = (upper - lower) / total_levels + +## Pre-Grid Checks + +``` +1. vulcan_market_info → { symbol: "SOL" } # base_lots_decimals, tick_size, fees +2. vulcan_market_ticker → { symbol: "SOL" } # current price (center the grid) +3. vulcan_market_orderbook → { symbol: "SOL" } # spread, depth +4. vulcan_margin_status → {} # enough collateral for worst case? +5. vulcan_position_list → {} # existing positions in this market +``` + +## Calculate Grid Levels + +``` +spacing = (upper_bound - lower_bound) / total_levels +``` + +Example: Range 140–160, 10 levels, current price 150: +``` +spacing = (160 - 140) / 10 = 2.0 +Buy levels: 148, 146, 144, 142, 140 +Sell levels: 152, 154, 156, 158, 160 +``` + +Ensure all prices are valid multiples of `tick_size` from `vulcan_market_info`. + +## Calculate Size Per Level + +``` +size_per_level_lots = desired_tokens_per_level * 10^base_lots_decimals +``` + +## Margin Estimation + +Worst case: all buy orders fill (max long position) or all sell orders fill (max short position). Calculate margin required: + +``` +max_position_lots = size_per_level_lots * levels_per_side +``` + +Check this against leverage tiers and available collateral. + +## Confirm with User + +Present the full grid before placing: +- Price range, grid levels, spacing +- Size per level (base lots + token equivalent) +- Total margin required (worst case) +- Estimated fees per round-trip +- Funding rate exposure +- Get explicit approval for the entire grid. + +## Place the Grid + +Use `vulcan_trade_multi_limit` to place all grid orders in a single transaction. This is much faster than placing orders individually. + +``` +vulcan_trade_multi_limit → { + symbol: "SOL", + bids: [ + { price: 148.00, size: 50 }, + { price: 146.00, size: 50 }, + { price: 144.00, size: 50 }, + { price: 142.00, size: 50 }, + { price: 140.00, size: 50 } + ], + asks: [ + { price: 152.00, size: 50 }, + { price: 154.00, size: 50 }, + { price: 156.00, size: 50 }, + { price: 158.00, size: 50 }, + { price: 160.00, size: 50 } + ], + slide: false, + acknowledged: true +} +``` + +### Verify all orders placed + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +## Grid Maintenance Loop + +Periodically check for fills and replace completed orders: + +### 1. Check open orders + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +Compare against the expected grid. Missing orders = filled. + +### 2. Check position + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +### 3. Replace filled orders + +- For each filled **buy** at price P: queue a **sell** at P + spacing. +- For each filled **sell** at price P: queue a **buy** at P - spacing. + +Batch all replacement orders into a single `vulcan_trade_multi_limit` call: + +``` +vulcan_trade_multi_limit → { + symbol: "SOL", + bids: [{ price:

, size: 50 }, ...], + asks: [{ price:

, size: 50 }, ...], + slide: false, + acknowledged: true +} +``` + +### 4. Check margin health + +``` +vulcan_margin_status → {} +``` + +If risk_state is not Healthy, pause grid maintenance and alert user. + +### 5. Repeat at regular intervals + +Suggested check interval: 30-60 seconds. + +## Grid Shutdown + +Cancel all grid orders: + +``` +vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } +``` + +Then optionally close any remaining position: + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +## Risk Considerations + +- **Trending markets**: Grid trading profits in ranging markets but loses in strong trends. If price drops below the entire grid, you accumulate a large long position at a loss. If price rises above, you're fully short. +- **Funding rates**: On perpetuals, holding a position incurs funding payments. Check `vulcan_market_ticker` for the funding rate — a high funding rate can erode grid profits. +- **Margin**: All resting limit orders consume margin. A wide grid with many levels can lock up significant collateral. +- **Slippage on replacement**: Replacement orders may not fill at exactly the grid level if the market moves fast. + +## Hard Rules + +1. Never place a live grid without explicit user approval for the full grid plan. +2. Always dry-run the grid math and present to user before placing. +3. Check margin status before placing and during maintenance. +4. Cancel the entire grid before adjusting parameters — never leave orphaned orders. +5. Track total grid P&L (sum of all fill spreads minus fees and funding). +6. Set price boundaries — if price moves outside the grid range, pause and alert. +7. Report all transaction signatures. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-lot-size-calculator/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-lot-size-calculator/SKILL.md new file mode 100644 index 00000000000..b41790cf581 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-lot-size-calculator/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-lot-size-calculator +version: 1.0.0 +description: "Convert desired token amounts to base lots — the most common agent mistake." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-lot-size-calculator + +Use this skill for: +- Converting a desired token amount to base lots before placing an order +- Converting a USD notional to base lots +- Understanding why lot sizes differ per market + +## Why This Matters + +Vulcan trade tools accept `size` in **base lots**, not tokens or USD. Getting this wrong means trading 100x more or less than intended. Always calculate before every trade. + +## Step-by-Step Calculation + +### Step 1: Fetch market info + +``` +vulcan_market_info → { symbol: "SOL" } +``` + +Extract `base_lots_decimals` from the response. + +### Step 2: Convert tokens to base lots + +``` +base_lots = desired_tokens * 10^base_lots_decimals +``` + +### Step 3: Pass base lots to trade tool + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: , acknowledged: true } +``` + +## Worked Examples + +### SOL (base_lots_decimals = 2) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.1 SOL | 0.1 * 10^2 = 0.1 * 100 | 10 | +| 0.5 SOL | 0.5 * 100 | 50 | +| 1 SOL | 1 * 100 | 100 | +| 5 SOL | 5 * 100 | 500 | + +### BTC (base_lots_decimals = 4) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.001 BTC | 0.001 * 10^4 = 0.001 * 10000 | 10 | +| 0.01 BTC | 0.01 * 10000 | 100 | +| 0.1 BTC | 0.1 * 10000 | 1000 | + +### ETH (base_lots_decimals = 3) + +| Want | Calculation | Base lots | +|------|-------------|-----------| +| 0.01 ETH | 0.01 * 10^3 = 0.01 * 1000 | 10 | +| 0.1 ETH | 0.1 * 1000 | 100 | +| 1 ETH | 1 * 1000 | 1000 | + +## Converting from USD Notional + +To trade a specific USD amount: + +1. Get current price: `vulcan_market_ticker → { symbol }` +2. Calculate tokens: `desired_tokens = usd_amount / mark_price` +3. Calculate base lots: `base_lots = desired_tokens * 10^base_lots_decimals` + +Example: $100 worth of SOL at $150/SOL, decimals=2: +``` +tokens = 100 / 150 = 0.6667 +base_lots = 0.6667 * 100 = 66.67 → round to 67 +``` + +## Common Mistakes + +1. **Passing token amount as size** — If you want 0.5 SOL and pass `size: 0.5`, you'll get 0.005 SOL (0.5 base lots at decimals=2). Always multiply. + +2. **Using the wrong decimals** — Each market has different `base_lots_decimals`. SOL=2, BTC=4, ETH=3. Always fetch fresh from `vulcan_market_info`. + +3. **Not rounding** — Base lots must be whole numbers. Round to nearest integer after calculation. + +4. **Caching decimals across markets** — Different markets have different decimals. Fetch per-market. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-margin-operations/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-margin-operations/SKILL.md new file mode 100644 index 00000000000..a550a12b473 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-margin-operations/SKILL.md @@ -0,0 +1,90 @@ +--- +name: vulcan-margin-operations +version: 1.0.0 +description: "Deposit, withdraw, transfer collateral, isolated margin, and leverage tier management." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-margin-operations + +Use this skill for: +- Depositing and withdrawing USDC collateral +- Transferring between cross-margin and isolated subaccounts +- Adding collateral to isolated positions +- Checking leverage tiers + +## Check Margin Status + +``` +vulcan_margin_status → {} +``` + +Key fields: collateral, total_unrealized_pnl, risk_state, available_to_withdraw. + +## Deposit USDC + +``` +vulcan_margin_deposit → { amount: 100.0, acknowledged: true } +``` + +Prerequisite: wallet must have USDC. Check with `vulcan_wallet_balance`. + +## Withdraw USDC + +``` +vulcan_margin_withdraw → { amount: 50.0, acknowledged: true } +``` + +Check `available_to_withdraw` from `vulcan_margin_status` first. Cannot withdraw if it would put account into HighRisk state. + +## Transfer Between Subaccounts + +Transfer from cross-margin (subaccount 0) to isolated (subaccount 1+): + +``` +vulcan_margin_transfer → { + from_subaccount: 0, + to_subaccount: 1, + amount: 50.0, + acknowledged: true +} +``` + +## Add Collateral to Isolated Position + +Shorthand for transferring from cross-margin to the isolated subaccount holding a position: + +``` +vulcan_margin_add_collateral → { symbol: "SOL", amount: 25.0, acknowledged: true } +``` + +## Sweep Child to Cross-Margin + +Move all collateral from an isolated subaccount back to cross-margin: + +``` +vulcan_margin_transfer_child_to_parent → { child_subaccount: 1, acknowledged: true } +``` + +## Sync Parent to Child + +Sync parent (cross-margin) state to a child subaccount: + +``` +vulcan_margin_sync_parent_to_child → { child_subaccount: 1, acknowledged: true } +``` + +## Leverage Tiers + +Check max leverage for different position sizes: + +``` +vulcan_margin_leverage_tiers → { symbol: "SOL" } +``` + +Returns a tiered schedule — larger positions get lower max leverage. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-market-intel/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-market-intel/SKILL.md new file mode 100644 index 00000000000..1667553fa3b --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-market-intel/SKILL.md @@ -0,0 +1,76 @@ +--- +name: vulcan-market-intel +version: 1.0.0 +description: "Ticker, orderbook, candles, market info, and pre-trade analysis patterns." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-market-intel + +Use this skill for: +- Getting current price and funding rate +- Analyzing orderbook depth and spread +- Fetching historical candles +- Pre-trade market research + +## List All Markets + +``` +vulcan_market_list → {} +``` + +Returns all active perpetual markets with fees, leverage info, and trading status. + +## Get Price and Funding Rate + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +Key fields: mark_price, index_price, funding_rate, volume_24h, change_24h. + +## Get Market Configuration + +``` +vulcan_market_info → { symbol: "SOL" } +``` + +Key fields: base_lots_decimals, tick_size, taker_fee, maker_fee, leverage_tiers, funding_params. + +## Orderbook Analysis + +``` +vulcan_market_orderbook → { symbol: "SOL", depth: 10 } +``` + +Key fields: bids, asks, mid_price, spread. + +Use this for: +- **Spread check**: Wide spread (>10bps) means higher implicit cost. +- **Slippage estimation**: If order size exceeds liquidity at top levels, expect slippage. +- **Market depth**: How much liquidity is available at each price level. + +## Historical Candles + +``` +vulcan_market_candles → { symbol: "SOL", interval: "1h", limit: 24 } +``` + +Intervals: `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. Default: `1h`, limit: 50. + +## Pre-Trade Analysis Pattern + +Before placing a trade, gather comprehensive market context: + +``` +1. vulcan_market_info → { symbol } # lot sizes, fees, leverage +2. vulcan_market_ticker → { symbol } # current price, funding rate +3. vulcan_market_orderbook → { symbol } # spread, depth, slippage +4. vulcan_market_candles → { symbol, interval: "1h", limit: 24 } # recent price action +``` + +Summarize for the user: current price, 24h change, funding rate, spread, liquidity depth. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-onboarding/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-onboarding/SKILL.md new file mode 100644 index 00000000000..a528914f386 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-onboarding/SKILL.md @@ -0,0 +1,98 @@ +--- +name: vulcan-onboarding +version: 1.0.0 +description: "New user setup: wallet creation, invite registration, first deposit, and verification." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-onboarding + +Use this skill for: +- First-time setup of vulcan +- Creating and configuring a wallet +- Registering a trader account +- Making the first deposit + +## Prerequisites + +- Solana wallet with SOL (for transaction fees) and USDC (for collateral). +- An invite code for Phoenix DEX registration. + +## Step 1: Install and Configure + +```bash +cargo install --path vulcan # from repo +vulcan setup # interactive setup wizard +``` + +Setup creates `~/.vulcan/config.toml` with network endpoints. + +## Step 2: Create a Wallet + +Wallet operations are CLI-only (not available via MCP): + +```bash +vulcan wallet create # interactive: name, password +vulcan wallet import # import existing Solana keypair +vulcan wallet list # verify wallet created +vulcan wallet set-default +``` + +## Step 3: Fund the Wallet + +The wallet needs: +- **SOL** — for Solana transaction fees (~0.01 SOL per transaction). +- **USDC** — for trading collateral. + +Check balances: + +``` +vulcan_wallet_balance → {} +``` + +## Step 4: Register Trader Account + +``` +vulcan_account_register → { invite_code: "YOUR_CODE", acknowledged: true } +``` + +## Step 5: Deposit Collateral + +``` +vulcan_margin_deposit → { amount: 100.0, acknowledged: true } +``` + +## Step 6: Verify Everything + +``` +vulcan_status → {} # checks config, wallet, RPC, API, registration +``` + +All checks should pass. If any fail, the status output includes recovery hints. + +## Step 7: First Trade (Optional) + +Follow the safe order flow from the `vulcan-trade-execution` skill: + +``` +vulcan_market_info → { symbol: "SOL" } +vulcan_market_ticker → { symbol: "SOL" } +vulcan_margin_status → {} +``` + +Then place a small test trade. + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| `NO_DEFAULT_WALLET` | `vulcan wallet set-default ` | +| `DECRYPT_FAILED` | Wrong password. Set `VULCAN_WALLET_PASSWORD` | +| `NO_TRADER_ACCOUNT` | Register with invite code | +| `CONFIG_ERROR` | Run `vulcan setup` | +| Insufficient SOL | Fund wallet with SOL for tx fees | +| Insufficient USDC | Transfer USDC to wallet address | diff --git a/container/skills/phoenix-perps/phoenix-vulcan-portfolio-intel/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-portfolio-intel/SKILL.md new file mode 100644 index 00000000000..ff4079d64ea --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-portfolio-intel/SKILL.md @@ -0,0 +1,75 @@ +--- +name: vulcan-portfolio-intel +version: 1.0.0 +description: "Full portfolio snapshot: margin status, positions, orders, and funding rate awareness." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-portfolio-intel + +Use this skill for: +- Daily portfolio reviews +- Presenting account status to the user +- Monitoring margin health and PnL +- Understanding funding exposure + +## Portfolio Snapshot + +Run these calls (they can be called in parallel): + +``` +vulcan_margin_status → {} # collateral, total PnL, risk state, available to withdraw +vulcan_position_list → {} # all open positions with unrealized PnL +vulcan_trade_orders → {} # all resting limit orders +``` + +## Interpreting Margin Status + +Key fields: +- `collateral` — Total USDC deposited. +- `total_unrealized_pnl` — Combined PnL across all positions. +- `risk_state` — Healthy, HighRisk, or Liquidatable. +- `available_to_withdraw` — USDC that can be withdrawn without affecting positions. +- `initial_margin_used` — Margin locked by open positions and orders. + +## Interpreting Positions + +Key fields per position: +- `symbol`, `side` (Long/Short), `size` — What you hold. +- `entry_price`, `mark_price` — Where you entered vs current price. +- `unrealized_pnl` — Current profit/loss. +- `liquidation_price` — Price at which position gets liquidated. + +## Interpreting Orders + +Key fields per order: +- `symbol`, `side`, `order_type` — What's resting. +- `size`, `price` — Order parameters. +- `filled` — How much has filled so far. + +Note: Resting limit orders consume margin even before filling. + +## Funding Rate Check + +For each open position, check funding exposure: + +``` +vulcan_market_ticker → { symbol } # funding_rate field +``` + +- Positive rate: Longs pay shorts (costs you money if long). +- Negative rate: Shorts pay longs (costs you money if short). + +## Presenting to User + +Summarize: +1. Account health (risk state, collateral, total PnL). +2. Each position: symbol, side, size, entry, mark, PnL, liquidation price. +3. Resting orders: symbol, side, type, size, price. +4. Funding rate exposure for held positions. +5. Available to withdraw. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-position-management/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-position-management/SKILL.md new file mode 100644 index 00000000000..88158ca3b8e --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-position-management/SKILL.md @@ -0,0 +1,102 @@ +--- +name: vulcan-position-management +version: 1.0.0 +description: "List, show, close, reduce positions and manage TP/SL on existing positions." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-position-management + +Use this skill for: +- Viewing open positions +- Closing or reducing positions +- Attaching TP/SL to existing positions +- Monitoring position PnL and liquidation price + +## List All Positions + +``` +vulcan_position_list → {} +``` + +Returns all open positions with: symbol, side, size, entry price, mark price, unrealized PnL. + +## Show Position Detail + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +Returns detailed info: PnL, margin, liquidation price, TP/SL prices, subaccount info. + +## Close Entire Position + +``` +vulcan_position_close → { symbol: "SOL", acknowledged: true } +``` + +Closes via market order on the opposite side. Verify with: + +``` +vulcan_position_list → {} # confirm position is gone +``` + +## Reduce Position + +Partially reduce a position by a specified size (in base lots): + +``` +vulcan_position_reduce → { symbol: "SOL", size: 25, acknowledged: true } +``` + +## Attach TP/SL to Existing Position + +Two tools can set TP/SL on an existing position: + +### Using position tool (bracket orders) + +``` +vulcan_position_tp_sl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +### Using trade tool (set/modify) + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +Both auto-detect position side. Direction rules: +- Long: TP > current price, SL < current price. +- Short: TP < current price, SL > current price. + +You can set just TP, just SL, or both. + +## Cancel TP/SL + +``` +vulcan_trade_cancel_tpsl → { symbol: "SOL", tp: true, sl: true, acknowledged: true } +``` + +Set `tp: true` to cancel take-profit, `sl: true` to cancel stop-loss, or both. + +## View TP/SL + +TP/SL prices are in `vulcan_position_show` response, NOT in `vulcan_trade_orders`. + +``` +vulcan_position_show → { symbol: "SOL" } +# Look for take_profit_price and stop_loss_price fields +``` + +## Position Management Flow + +1. Review positions: `vulcan_position_list` +2. Get details on specific position: `vulcan_position_show → { symbol }` +3. If needed, adjust TP/SL: `vulcan_trade_set_tpsl → { symbol, tp?, sl?, acknowledged: true }` +4. If needed, reduce: `vulcan_position_reduce → { symbol, size, acknowledged: true }` +5. If needed, close: `vulcan_position_close → { symbol, acknowledged: true }` diff --git a/container/skills/phoenix-perps/phoenix-vulcan-risk-management/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-risk-management/SKILL.md new file mode 100644 index 00000000000..beb44e0e289 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-risk-management/SKILL.md @@ -0,0 +1,94 @@ +--- +name: vulcan-risk-management +version: 1.0.0 +description: "Pre-trade risk checks, leverage tiers, margin health thresholds, and when-to-warn rules." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-risk-management + +Use this skill for: +- Pre-trade risk assessment +- Monitoring margin health +- Understanding leverage tiers +- Deciding when to warn the user + +## Pre-Trade Risk Checklist + +Before every trade, call these tools: + +``` +1. vulcan_margin_status → {} # risk_state, collateral, PnL +2. vulcan_position_list → {} # existing positions +3. vulcan_trade_orders → { symbol } # resting orders consuming margin +4. vulcan_market_orderbook → { symbol } # slippage check for market orders +``` + +## Margin Health States + +| State | Meaning | Action | +|-------|---------|--------| +| `Healthy` | Sufficient collateral | Safe to trade | +| `HighRisk` | Margin getting thin | Warn user before any new trades | +| `Liquidatable` | At risk of liquidation | Do NOT open new positions. Suggest reducing exposure or adding collateral | + +## Leverage Tiers + +Markets have tiered leverage limits. Larger positions get lower max leverage. + +``` +vulcan_margin_leverage_tiers → { symbol: "SOL" } +``` + +The first tier gives max leverage for typical sizes. Always check before proposing a trade. + +## Funding Rate Awareness + +``` +vulcan_market_ticker → { symbol: "SOL" } # check funding_rate field +``` + +- Positive rate: Longs pay shorts. +- Negative rate: Shorts pay longs. +- For longer-duration positions, factor funding costs into the trade thesis. + +## Position Sizing + +When the user doesn't specify exact size: +1. Ask their risk tolerance (USD or % of collateral). +2. Fetch `vulcan_market_info` for lot size conversion. +3. Calculate position size. +4. Present the calculation before executing. + +## When to Warn + +Alert the user when: +- Risk state is anything other than Healthy. +- A trade would use >50% of available margin. +- Liquidation price is within 10% of mark price. +- Funding rate is elevated (>0.01% per interval). +- Orderbook spread is wide (>10bps). +- They're about to increase an already-large position. + +## Slippage Check + +For market orders, check the orderbook: + +``` +vulcan_market_orderbook → { symbol: "SOL", depth: 10 } +``` + +If order size is large relative to available liquidity at the best levels, warn about potential slippage. + +## Hard Rules + +1. Never trade without user confirmation. +2. Never deposit or withdraw without user confirmation. +3. Always check margin before opening new positions. +4. Never exceed available margin. +5. Always report transaction signatures. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-shared/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-shared/SKILL.md new file mode 100644 index 00000000000..9e3d01b4f43 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-shared/SKILL.md @@ -0,0 +1,75 @@ +--- +name: vulcan-shared +version: 1.0.0 +description: "Shared runtime contract for vulcan: auth, invocation, symbol format, size units, and safety." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] +--- + +# vulcan-shared + +**This tool is experimental. Commands execute real financial transactions on Solana mainnet. Test with `--dry-run` before using real funds.** + +## Invocation Contract + +### MCP (preferred) + +Tools are named `vulcan__`. Call them directly via MCP tool calls. Dangerous tools require `acknowledged: true`. + +### CLI (fallback) + +```bash +vulcan [args...] -o json +``` + +- Parse `stdout` only (JSON). +- Treat `stderr` as diagnostics. +- Exit code `0` = success. +- Non-zero = failure with JSON error envelope in stdout. + +## Authentication + +MCP server unlocks wallet at startup via `VULCAN_WALLET_PASSWORD` env var. No per-call prompts. + +For CLI: `export VULCAN_WALLET_PASSWORD=your_password` + +## Symbol Format + +Uppercase ticker only: `SOL`, `BTC`, `ETH`, `DOGE`, `SUI`, `XRP`, `BNB`, `AAVE`, `ZEC`, `HYPE`, `SKR`. + +No `-PERP` suffix. Run `vulcan_market_list` to discover active markets. + +## Size Units — Base Lots + +The `size` parameter is in **base lots**, not tokens or USD. Always call `vulcan_market_info` first. + +**Conversion**: `base_lots = desired_tokens * 10^base_lots_decimals` + +## Error Routing + +Route on `.error.category`: +- `validation` — Fix inputs, do not retry. +- `auth` — Check wallet/password. +- `network` — Retry with exponential backoff. +- `tx_failed` — **Verify state before retrying.** Never blind-retry on-chain tx. +- `dangerous_gate` — Set `acknowledged: true`. + +## Safety + +Require explicit human approval before: +- Buy or sell orders (market and limit) +- Order cancellations +- Position close or reduce +- Deposits, withdrawals, and transfers +- TP/SL changes +- Account registration + +Hard rules: +1. Always call `vulcan_market_info` before trading. +2. Always call `vulcan_margin_status` before opening positions. +3. Always call `vulcan_position_list` before trading. +4. Never guess lot sizes. +5. Report all transaction signatures. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-tpsl-management/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-tpsl-management/SKILL.md new file mode 100644 index 00000000000..c3dc4f44df7 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-tpsl-management/SKILL.md @@ -0,0 +1,92 @@ +--- +name: vulcan-tpsl-management +version: 1.0.0 +description: "Take-profit and stop-loss: direction rules, constraints, and set/cancel flows." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared"] +--- + +# vulcan-tpsl-management + +Use this skill for: +- Setting TP/SL when opening a position +- Attaching TP/SL to an existing position +- Modifying or cancelling TP/SL +- Understanding TP/SL constraints and gotchas + +## Direction Rules + +### Long positions (buy) + +- Take-profit price MUST be **above** entry price. +- Stop-loss price MUST be **below** entry price. + +### Short positions (sell) + +- Take-profit price MUST be **below** entry price. +- Stop-loss price MUST be **above** entry price. + +## Setting TP/SL at Order Time + +Attach to market orders: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", size: 50, + tp: 160.0, sl: 140.0, + acknowledged: true +} +``` + +**Critical constraint**: TP/SL at order time only works when **opening or extending** a position. If the market order **reduces** an existing position, the entire transaction rolls back (market order does not execute either). + +## Setting TP/SL on Existing Position + +### Method 1: Trade tool + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +### Method 2: Position tool + +``` +vulcan_position_tp_sl → { symbol: "SOL", tp: 160.0, sl: 140.0, acknowledged: true } +``` + +Both auto-detect position side. You can set just TP, just SL, or both. + +## Modifying TP/SL + +To change existing TP/SL, call set again with new values. The new values replace the old ones: + +``` +vulcan_trade_set_tpsl → { symbol: "SOL", tp: 165.0, acknowledged: true } +``` + +## Cancelling TP/SL + +``` +vulcan_trade_cancel_tpsl → { symbol: "SOL", tp: true, sl: true, acknowledged: true } +``` + +Set `tp: true` to cancel take-profit, `sl: true` to cancel stop-loss, or both. + +## Viewing TP/SL + +TP/SL are **trigger orders** — they appear in `vulcan_position_show`, NOT in `vulcan_trade_orders`. + +``` +vulcan_position_show → { symbol: "SOL" } +# Look for: take_profit_price, stop_loss_price +``` + +## Common Mistakes + +1. **Wrong direction**: TP must be on the profitable side. For longs, TP > entry. For shorts, TP < entry. +2. **Setting on a reduce order**: TP/SL fails if the market order reduces a position. Use `vulcan_trade_set_tpsl` on the existing position instead. +3. **Looking for TP/SL in orders**: They won't appear in `vulcan_trade_orders`. Check `vulcan_position_show`. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-trade-execution/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-trade-execution/SKILL.md new file mode 100644 index 00000000000..a678ae618f3 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-trade-execution/SKILL.md @@ -0,0 +1,143 @@ +--- +name: vulcan-trade-execution +version: 1.0.0 +description: "Execute perpetual futures orders with pre-trade checks and post-trade verification." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-shared", "vulcan-lot-size-calculator"] +--- + +# vulcan-trade-execution + +Use this skill for: +- Placing market or limit orders on Phoenix DEX +- Attaching TP/SL to new orders +- Cancelling orders +- The complete safe order flow + +## Safe Market Order Flow + +### 1. Gather market context + +``` +vulcan_market_info → { symbol: "SOL" } # lot sizes, fees, leverage tiers +vulcan_market_ticker → { symbol: "SOL" } # current price, funding rate +vulcan_margin_status → {} # available collateral, risk state +vulcan_position_list → {} # existing positions +vulcan_trade_orders → { symbol: "SOL" } # existing resting orders +``` + +### 2. Calculate size + +From `vulcan_market_info`, extract `base_lots_decimals`: +``` +base_lots = desired_tokens * 10^base_lots_decimals +``` +Example: Want 0.5 SOL, decimals=2 → 0.5 * 100 = 50 base lots. + +### 3. Validate against constraints + +- Ensure `vulcan_margin_status` shows risk_state = Healthy. +- Check leverage tiers — larger positions have lower max leverage. +- Factor in existing positions (same-side increases exposure, opposite-side reduces). + +### 4. Confirm with user + +Present: symbol, direction, size (base lots + token equivalent), order type, estimated fees, mark price, existing positions. + +### 5. Execute + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: 50, acknowledged: true } +``` + +### 6. Verify + +``` +vulcan_position_list → {} # confirm position opened +``` + +Report the transaction signature to the user. + +## Market Order with TP/SL + +Attach take-profit and/or stop-loss at order time: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + tp: 160.0, + sl: 140.0, + acknowledged: true +} +``` + +**Direction rules:** +- Long (buy): TP must be above entry, SL must be below entry. +- Short (sell): TP must be below entry, SL must be above entry. + +**Constraints:** +- TP/SL only works when opening or extending a position. Fails if the order reduces a position (entire tx rolls back). +- TP/SL shows in `vulcan_position_show`, NOT in `vulcan_trade_orders`. + +## Limit Orders + +``` +vulcan_trade_limit_buy → { + symbol: "SOL", + size: 50, + price: 145.00, + acknowledged: true +} +``` + +Limit orders rest on the book. They pay maker fees (typically lower). After placing, verify with: + +``` +vulcan_trade_orders → { symbol: "SOL" } # confirm order on book +``` + +## Isolated Margin Orders + +For markets requiring isolated margin, or when you want dedicated collateral: + +``` +vulcan_trade_market_buy → { + symbol: "SOL", + size: 50, + isolated: true, + collateral: 100.0, + acknowledged: true +} +``` + +## Reduce-Only Orders + +To ensure an order only reduces (never increases) a position: + +``` +vulcan_trade_market_sell → { + symbol: "SOL", + size: 25, + reduce_only: true, + acknowledged: true +} +``` + +## Cancel Orders + +``` +vulcan_trade_orders → { symbol: "SOL" } # get order IDs +vulcan_trade_cancel → { symbol: "SOL", order_ids: ["id1"], acknowledged: true } +vulcan_trade_cancel_all → { symbol: "SOL", acknowledged: true } # cancel all +``` + +## Hard Rules + +- Never execute orders without explicit user approval (unless in auto-execute mode). +- Route failures by `.error.category`. +- On `tx_failed`, check position state before retrying. diff --git a/container/skills/phoenix-perps/phoenix-vulcan-twap-execution/SKILL.md b/container/skills/phoenix-perps/phoenix-vulcan-twap-execution/SKILL.md new file mode 100644 index 00000000000..3d6b2bb06f7 --- /dev/null +++ b/container/skills/phoenix-perps/phoenix-vulcan-twap-execution/SKILL.md @@ -0,0 +1,155 @@ +--- +name: vulcan-twap-execution +version: 1.0.0 +description: "Execute large orders as time-weighted slices to reduce market impact on Phoenix DEX." +metadata: + openclaw: + category: "finance" + requires: + bins: ["vulcan"] + skills: ["vulcan-trade-execution", "vulcan-lot-size-calculator"] +--- + +# vulcan-twap-execution + +Use this skill for: +- Breaking a large order into smaller time-spaced slices +- Reducing market impact and slippage on size +- Executing over minutes or hours +- Tracking average fill price across slices + +## Core Concept + +Time-Weighted Average Price (TWAP) splits a large order into N equal slices executed at regular intervals. The goal is an average fill price close to the time-weighted market average, reducing the impact a single large order would have on the book. + +Vulcan does not have a built-in scheduler — the agent manages the loop externally. + +## Parameters + +Agree on these with the user before starting: +- **Symbol**: e.g., SOL +- **Side**: buy or sell +- **Total size**: in tokens (agent converts to base lots) +- **Slices**: number of child orders (e.g., 5-10) +- **Interval**: time between slices (e.g., 60s, 300s) + +## Pre-TWAP Checks + +``` +1. vulcan_market_info → { symbol: "SOL" } # base_lots_decimals, fees +2. vulcan_market_ticker → { symbol: "SOL" } # current price, volume +3. vulcan_market_orderbook → { symbol: "SOL" } # depth — is there enough liquidity per slice? +4. vulcan_margin_status → {} # enough collateral for total position? +5. vulcan_position_list → {} # existing exposure +``` + +## Calculate Slice Size + +``` +total_base_lots = total_tokens * 10^base_lots_decimals +slice_lots = total_base_lots / slices # round to integer +``` + +Example: 5 SOL over 5 slices, decimals=2: +``` +total_base_lots = 5 * 100 = 500 +slice_lots = 500 / 5 = 100 base lots per slice +``` + +Verify: `slice_lots * slices` should equal `total_base_lots`. Adjust last slice for remainder. + +## Confirm with User + +Present before starting: +- Total: 5 SOL (500 base lots) across 5 slices +- Per slice: 1 SOL (100 base lots) +- Interval: 60 seconds +- Estimated total fees: `total_base_lots * price * taker_fee * 2` (if round-trip) +- Get explicit approval to begin the TWAP. + +## Market Order TWAP Loop + +For each slice: + +### 1. Check price hasn't moved beyond tolerance + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +If price has moved >X% from the start price, pause and alert the user. + +### 2. Execute slice + +``` +vulcan_trade_market_buy → { symbol: "SOL", size: 100, acknowledged: true } +``` + +### 3. Record fill details + +Save: slice number, timestamp, fill price, tx signature. + +### 4. Wait for interval + +Wait the agreed interval before the next slice. + +### 5. Repeat until all slices complete + +## Limit-Order TWAP Variant + +Use limit orders at the current best bid/ask for potentially better fills (maker fees): + +### 1. Read current price + +``` +vulcan_market_ticker → { symbol: "SOL" } +``` + +### 2. Place limit order at or near the ask (for buys) + +``` +vulcan_trade_limit_buy → { symbol: "SOL", size: 100, price: , acknowledged: true } +``` + +### 3. Wait, then check fill status + +``` +vulcan_trade_orders → { symbol: "SOL" } +``` + +### 4. If unfilled after interval, cancel and place next slice + +``` +vulcan_trade_cancel → { symbol: "SOL", order_ids: [""], acknowledged: true } +``` + +Then adjust price and place next slice. Track unfilled volume to add to remaining slices. + +## Tracking Average Fill + +After all slices, compute the volume-weighted average price: + +``` +vulcan_position_show → { symbol: "SOL" } +``` + +The `entry_price` field reflects the average across all fills for the position. + +For detailed per-slice tracking, the agent should maintain its own log of each slice's fill price and size. + +## Handling Errors Mid-Loop + +- **On `network` error**: Pause, retry the current slice after backoff. +- **On `tx_failed`**: Check position state before retrying. See `vulcan-error-recovery` skill. +- **On `rate_limit`**: Wait and retry. A 60s interval between slices should avoid rate limits. +- **Never skip a slice on error** — pause the loop, diagnose, then resume. + +## Hard Rules + +1. Each TWAP session requires human approval before the first slice. +2. In confirm-each mode, confirm each individual slice. In auto-execute mode, log every slice. +3. Track cumulative fill volume — stop if total exceeds target (handle partial fills from limit orders). +4. On any error, pause the loop rather than skipping the slice. +5. If price moves beyond user-defined tolerance, pause and alert. +6. Report all transaction signatures. +7. Present final summary: total filled, average price, total fees, time elapsed. From faa411c003c8dcb525c939ff1fb6f80650599ce9 Mon Sep 17 00:00:00 2001 From: NickNut <68846529+Se76@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:44:30 +0200 Subject: [PATCH 3/3] fix program ids --- container/agent-runner/src/known-protocols.ts | 1 + container/solana-tx-preload.cjs | 5 ++++- src/known-protocols.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/known-protocols.ts b/container/agent-runner/src/known-protocols.ts index 06bce2d3f53..812e8577fe9 100644 --- a/container/agent-runner/src/known-protocols.ts +++ b/container/agent-runner/src/known-protocols.ts @@ -18,6 +18,7 @@ export const KNOWN_PROTOCOLS = new Set([ 'metaplex', 'meteora', 'orca', + 'phoenix', 'pumpfun', 'raydium', 'swig', diff --git a/container/solana-tx-preload.cjs b/container/solana-tx-preload.cjs index 553d00c80c2..ab9dd2dd24b 100644 --- a/container/solana-tx-preload.cjs +++ b/container/solana-tx-preload.cjs @@ -166,13 +166,16 @@ const KNOWN_PROGRAMS = { // Manifest 'MNFSTqtC93rEfYHB6hF82sKdZpUDFWkViLByLd1k1Ms': 'manifest', 'wMNFSTkir3HgyZTsB7uqu3i7FA73grFCptPXgrZjksL': 'manifest', + // Phoenix (perpetual futures) + 'EtrnLzgbS7nMMy5fbD42kXiUzGg8XQzJ972Xtk1cjWih': 'phoenix', + 'EMBERpYNE6ehWmXymZZS2skiFmCa9V5dp14e1iduM5qy': 'phoenix', }; // Canonical protocol names — keep in sync with src/known-protocols.ts const KNOWN_PROTOCOLS_SET = new Set([ 'breeze', 'coingecko', 'crossmint', 'dflow', 'drift', 'glam', 'helius', 'jupiter', 'kamino', 'manifest', 'marginfi', 'metaplex', - 'meteora', 'orca', 'pumpfun', 'raydium', 'swig', + 'meteora', 'orca', 'phoenix', 'pumpfun', 'raydium', 'swig', 'system-program', 'token-program', ]); diff --git a/src/known-protocols.ts b/src/known-protocols.ts index d9768cf239f..7f467b5ca7b 100644 --- a/src/known-protocols.ts +++ b/src/known-protocols.ts @@ -21,6 +21,7 @@ export const KNOWN_PROTOCOLS = new Set([ 'metaplex', 'meteora', 'orca', + 'phoenix', 'pumpfun', 'raydium', 'swig',