commit 97006e46a7ab5728d25777d4e806be90f90baadf Author: gavindiaz Date: Fri Jul 31 20:27:15 2026 +0800 修复对冲 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9db6c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,218 @@ +# ============================================================================= +# poly_bot 环境配置 +# Environment Configuration +# +# 复制为 .env 后填写必填项: +# Copy to .env and fill required fields: +# cp .env.example .env +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# [1] 账户认证 +# Account & Authentication REQUIRED +# ----------------------------------------------------------------------------- + +# Polymarket 私钥(64 位 hex,可带或不带 0x 前缀) +# Private key (64-char hex, with or without 0x prefix) +# 邮箱/Magic 账号: https://reveal.magic.link/polymarket +# Email/Magic: https://reveal.magic.link/polymarket +# 浏览器钱包: 导出对应 EOA 私钥 +# Browser wallet: export your EOA private key + +POLYMARKET_PRIVATE_KEY= + +# 资金托管地址(Settings 里显示的 Address,非 EOA 签名地址) +# Funder address from Polymarket Settings (NOT your EOA signer address) +# https://polymarket.com/settings?tab=builder → Address +# 邮箱与浏览器钱包在 V2 下通常均为 deposit wallet,填 Settings 地址即可 +# For V2, both email and browser accounts usually use the deposit wallet from Settings + +POLYMARKET_PROXY_ADDRESS= + + +# ----------------------------------------------------------------------------- +# [2] Builder API REQUIRED for Merge +# Merge / Redeem 前必须填写 +# ----------------------------------------------------------------------------- + +# 在 Polymarket → Settings → Builders → Create New 获取 +# Get from Polymarket → Settings → Builders → Create New +POLY_BUILDER_API_KEY= +POLY_BUILDER_SECRET= +POLY_BUILDER_PASSPHRASE= + + +# ----------------------------------------------------------------------------- +# [3] CLOB 接口 +# CLOB API optional +# ----------------------------------------------------------------------------- + +# API 地址 — 使用 https://clob.polymarket.com(不要用 clob-v2.polymarket.com) +# API endpoint — use https://clob.polymarket.com (NOT clob-v2.polymarket.com) +CLOB_API_URL=https://clob.polymarket.com + +# 签名类型 — 按「资金托管钱包类型」选择,与登录方式(邮箱/钱包)无必然对应 +# Signature type — based on funder wallet type, NOT login method (email vs browser) +# +# Poly1271 | deposit | 3 — V2 deposit wallet(默认;邮箱/Magic 与浏览器钱包均适用) +# Poly1271 | deposit | 3 — V2 deposit wallet (default; email/Magic and browser wallet) +# +# Proxy | magic | email — 仅 V1 旧 Magic 代理(Settings 地址 = ProxyFactory 推导地址) +# Proxy | magic | email — legacy Magic proxy only (Settings addr = ProxyFactory derive) +# +# GnosisSafe | safe — Gnosis Safe +# Eoa | 0 — 纯 EOA 直连(无需 POLYMARKET_PROXY_ADDRESS) +# Eoa | 0 — direct EOA (no POLYMARKET_PROXY_ADDRESS) +# +# 若 Proxy 下单报 "please use the deposit wallet flow" → 改用 Poly1271 +# If Proxy orders fail with "please use the deposit wallet flow" → use Poly1271 +SIGNATURE_TYPE=Poly1271 + +# ----------------------------------------------------------------------------- +# [4] 链上 & Relayer +# On-chain & Relayer optional +# ----------------------------------------------------------------------------- + +RELAYER_URL=https://relayer-v2.polymarket.com + +# Merge / Redeem 输出代币(默认 pUSD) +# Output token after Merge or Redeem (default: pUSD) +MERGE_OUTPUT_TOKEN=pUSD + +# Merge 后自动 wrap 为 pUSD +# Auto-wrap merge proceeds to pUSD via Collateral Onramp +MERGE_WRAP_TO_PUSD=true +REDEEM_OUTPUT_TOKEN=pUSD + +# Polygon RPC(不填则使用内置公共节点) +# Polygon RPC (uses built-in public node if unset) +# RPC_URL=https://polygon-bor-rpc.publicnode.com + + +# ----------------------------------------------------------------------------- +# [5] 市场发现 +# Market Discovery optional +# ----------------------------------------------------------------------------- + +# 监控的加密货币符号,逗号分隔 +# Comma-separated crypto symbols to monitor +CRYPTO_SYMBOLS=btc,eth + +# 距下一窗口结束前多少秒刷新市场 +# Seconds before next window ends to refresh markets +MARKET_REFRESH_ADVANCE_SECS=5 + + +# ----------------------------------------------------------------------------- +# [6] 套利 & 下单 +# Arbitrage & Orders optional +# ----------------------------------------------------------------------------- + +# 最小利润阈值(0.001 = 0.1%) +# Min profit threshold (0.001 = 0.1%) +MIN_PROFIT_THRESHOLD=0.001 + +# 单笔最大下单量(USDC / pUSD) +# Max order size per trade (USDC / pUSD) +MAX_ORDER_SIZE_USDC=10.0 + +# 执行条件: yes + no <= 1 - spread(如 spread=0.01 时在 0.99 执行) +# Execute when yes + no <= 1 - spread (e.g. spread=0.01 triggers at 0.99) +ARBITRAGE_EXECUTION_SPREAD=0.03 + +# YES / NO 价格下限,0 = 不限制 +# Min YES/NO price; 0 = no filter +MIN_YES_PRICE_THRESHOLD=0.2 +MIN_NO_PRICE_THRESHOLD=0.2 + +# 滑点 [first, second]: 下降侧用 second,上涨/持平用 first +# Slippage [first, second]: use second for declining side, first for rising/flat +SLIPPAGE=0.01,0.01 + +# 订单类型: GTC | GTD | FOK | FAK +# Order type: GTC | GTD | FOK | FAK +ARBITRAGE_ORDER_TYPE=GTD + +# GTD 订单过期时间(秒),仅 ARBITRAGE_ORDER_TYPE=GTD 时生效 +# GTD order expiry in seconds (only when ARBITRAGE_ORDER_TYPE=GTD) +GTD_EXPIRATION_SECS=300 + + +# ----------------------------------------------------------------------------- +# [7] 风控 & 收尾 +# Risk & Wind-down optional +# ----------------------------------------------------------------------------- + +# 每轮最大风险敞口(USDC) +# Max exposure per round (USDC) +RISK_MAX_EXPOSURE_USDC=1000.0 + +# 持仓不平衡阈值(0.1 = 10%) +# Position imbalance threshold (0.1 = 10%) +RISK_IMBALANCE_THRESHOLD=0.1 + +# 对冲止盈 / 止损比例 +# Hedge take-profit / stop-loss ratio +HEDGE_TAKE_PROFIT_PCT=0.05 +HEDGE_STOP_LOSS_PCT=0.05 + +# 市场结束前 N 分钟停止套利,0 = 不限制 +# Stop arbitrage N minutes before market end; 0 = no limit +STOP_ARBITRAGE_BEFORE_END_MINUTES=2 + +# 窗口结束前收尾(取消挂单 → Merge → 市价卖剩余),0 = 关闭 +# Wind-down before window end (cancel orders → Merge → market sell remainder); 0 = disabled +WIND_DOWN_BEFORE_WINDOW_END_MINUTES=1 + +# 收尾时单腿卖出限价 +# Limit price for single-leg sell during wind-down +WIND_DOWN_SELL_PRICE=0.01 + +# 定时 Merge 间隔(分钟),0 = 关闭 +# Scheduled Merge interval in minutes; 0 = disabled +MERGE_INTERVAL_MINUTES=1 + + +# ----------------------------------------------------------------------------- +# [8] 持仓同步 +# Position Sync optional +# ----------------------------------------------------------------------------- + +# 持仓同步间隔(秒) +# Position sync interval in seconds +POSITION_SYNC_INTERVAL_SECS=10 + +# 仓位平衡检查间隔(秒) +# Position balance check interval in seconds +POSITION_BALANCE_INTERVAL_SECS=60 + +# 不平衡阈值 +# Imbalance threshold to trigger balance action +POSITION_BALANCE_THRESHOLD=2.0 + +# 最小总持仓要求 +# Min total position required to run balance check +POSITION_BALANCE_MIN_TOTAL=5.0 + + +# ----------------------------------------------------------------------------- +# [9] 日志 & 界面 +# Logging & UI optional +# ----------------------------------------------------------------------------- + +# 日志级别: trace | debug | info | warn | error +# Log level: trace | debug | info | warn | error +RUST_LOG=info + +# 日志文件路径 +# Log file path +# LOG_FILE=bot.log + +# 启用终端 UI +# Enable terminal dashboard UI +# TUI_ENABLED=true + +# 纯文本日志(关闭 TUI 着色) +# Plain-text logs (disable TUI styling) +# PLAIN_LOGS=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea7e4c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +.env +bot.log +.idea +_vendor \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..49f47eb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5395 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c36ddb69f5e41407e7a93aead3480f7c8ff076e73d01a951ae8f7ea4542c1ca0" +dependencies = [ + "alloy-primitives", + "num_enum", + "phf 0.14.0", +] + +[[package]] +name = "alloy-consensus" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.7", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac9e0c34dc6bce643b182049cdfcca1b8ce7d9c260cbdd561f511873b7e26cd" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-core" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8421a5ee9019b6d89f92935d0f8facd9f6ca088b41d7cc47244a02ccde4545f" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a04eb4abc2b5074a18e687ee63918f407cc7990083cba9b999445f839796060" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eips" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ef28c9fdad22d4eec52d894f5f2673a0895f1e5ef196734568e68c0f6caca8" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", +] + +[[package]] +name = "alloy-json-abi" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cee30dd4c2f4b23f434fdf675e7bf9681b86768141277266c6f548ef25cba0a" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.5", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-provider" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6b18b929ef1d078b834c3631e9c925177f3b23ddc6fa08a722d13047205876" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru 0.16.4", + "parking_lot", + "pin-project", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fcc9604042ca80bd37aa5e232ea1cd851f337e31e2babbbb345bc0b1c30de3" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest 0.13.4", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-serde" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f447aefab0f1c0649f71edc33f590992d4e122bc35fb9cdbbf67d4421ace85" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror", +] + +[[package]] +name = "alloy-signer-local" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.7", + "thiserror", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5655c38d5f84955bf727b2eeb62fddd91ebb98fd1d7ae6eb77f73ea88f9b9cf" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6277c780e07b76951e09a59788dde230d1582612324177d11a43a61e21a6bb83" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.14.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9762b2ad3e5a0c09886de54fe549ab0056681df843cb082e2df7e1c0eb270d30" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da4c7130f0f01f4719678bda3db3bc7267fc2f7f9d0565e3bd964cd2bb45050d" +dependencies = [ + "serde", + "winnow", +] + +[[package]] +name = "alloy-sol-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96e74d6213180f78dbdccddce8af02a639c160c94b0a543fa35c77c58b8a7fc" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest 0.13.4", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "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 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[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-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "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-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[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.7", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.17", + "instant", + "rand 0.8.7", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[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 = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[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 = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[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 = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "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-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[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", + "serde", + "strsim", + "syn 2.0.119", +] + +[[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.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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 = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.119", + "unicode-xid", +] + +[[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", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[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 = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.7", + "rustc-hex", + "static_assertions", +] + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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 = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[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-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polymarket-client-sdk" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0852dbe6df78c4964452d6071abdc7ec0f649042c76733a075cb79e322745eaa" +dependencies = [ + "alloy", + "async-stream", + "async-trait", + "backoff", + "base64", + "bitflags", + "bon", + "chrono", + "dashmap", + "futures", + "hmac", + "phf 0.13.1", + "rand 0.10.2", + "reqwest 0.13.4", + "rust_decimal", + "rust_decimal_macros", + "secrecy", + "serde", + "serde_html_form", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "serde_repr", + "serde_with", + "sha2", + "strum_macros 0.28.0", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "polymarket_client_sdk_v2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ed9ca91088808232ee7e6b48f8f4eaa846882f799c40f8190b0a0e3d28ed5e" +dependencies = [ + "alloy", + "async-stream", + "async-trait", + "base64", + "bon", + "chrono", + "dashmap", + "futures", + "hmac", + "phf 0.13.1", + "rand 0.10.2", + "reqwest 0.13.4", + "rust_decimal", + "rust_decimal_macros", + "secrecy", + "serde", + "serde_html_form", + "serde_json", + "serde_repr", + "serde_with", + "sha1", + "sha2", + "strum_macros 0.28.0", + "url", + "uuid", +] + +[[package]] +name = "polypulse" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "base64", + "chrono", + "crossterm", + "dashmap", + "dotenvy", + "futures", + "hmac", + "polymarket-client-sdk", + "polymarket_client_sdk_v2", + "rand 0.8.7", + "ratatui", + "reqwest 0.12.28", + "rust_decimal", + "rust_decimal_macros", + "rustls", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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.119", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "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 = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.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.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", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.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 = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[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 = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.7", + "rand 0.9.5", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +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 = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[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 = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_html_form" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "itoa", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "083be3061e64d362cbe6ef12cfe1307ba3884326d8856448fe8a120fa2c44ebf" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", +] + +[[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 = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "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.119", +] + +[[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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "serde_derive", +] + +[[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 = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +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.119", +] + +[[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.119", +] + +[[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", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9c27a39 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "polypulse" +version = "0.1.0" +edition = "2021" +default-run = "polypulse" + +[dependencies] +polymarket-client-sdk = { version = "0.4.1", features = ["clob", "ctf", "data", "gamma", "ws", "tracing"] } +polymarket-client-sdk-v2 = { package = "polymarket_client_sdk_v2", version = "0.6.0-canary.1", features = ["clob"] } +tokio = { version = "1.49", features = ["full"] } +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = { version = "0.23", features = ["ring"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +rust_decimal = "1.39" +rust_decimal_macros = "1.39" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" +alloy = { version = "1.3", default-features = false, features = [ + "signer-local", + "signers", + "reqwest", + "reqwest-rustls-tls", + "providers", + "contract", +] } +chrono = { version = "0.4", features = ["serde"] } +dashmap = "6.1" +futures = "0.3" +uuid = { version = "1.0", features = ["v4"] } +ratatui = "0.29" +crossterm = "0.28" +rand = "0.8" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa47742 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Polymarket-5min-bot + +**English** | [中文](./README.zh-CN.md) + +**Official Website**: [polypulse.wiki](https://polypulse.wiki/) + +> Rust arbitrage bot for [Polymarket](https://polymarket.com) crypto “Up or Down” 5-minute prediction markets. + +![account](image/account.png) + +![Dashboard](image/main.png) + +## How It Works + +Polymarket Up/Down markets open a new 5-minute window (UTC) every cycle. Each market has YES and NO outcome tokens. + +In theory, holding equal amounts of YES + NO redeems for 1 USDC at settlement, so: + +``` +YES best ask + NO best ask < 1 → arbitrage opportunity +``` + +The bot roughly follows these steps: + +1. **Market discovery** — Finds current 5-minute Up/Down markets for configured symbols (e.g. btc, eth). +2. **Order book monitoring** — Subscribes to CLOB order books and tracks YES + NO combined price in real time. +3. **Arbitrage execution** — Buys YES and NO when the combined price falls below your threshold; slippage, size limits, and execution spread are configurable. +4. **Merge** — When holding both YES and NO, merges on-chain into USDC/pUSD to reduce position risk. +5. **Wind-down** — Near window end, can auto-cancel orders, merge, and market-sell remaining single-leg positions. + +> This bot connects to live markets and real funds. Understand the risks before use. + +## Quick Start + +### Pre-built binary + +If you don't want to compile from source, use the pre-built executable from **[Releases](https://github.com/crazygirl437/Polymarket-5min-bot/releases/tag/V.10)**: + +1. Download the package for your OS (Linux / Windows) +2. Copy `.env.example` to `.env` and fill in required fields +3. Run: + - Linux / macOS: `./polypulse` + - Windows: `polypulse.exe` + +### Build from source + +Requires [Rust](https://rustup.rs). + +```bash +cp .env.example .env # fill required fields in .env, then run +cargo run +``` + +See `.env.example` for full options, grouped as `[1]`–`[9]`: earlier sections are more important. + +## Configuration + +### Required + +| Variable | Description | +|----------|-------------| +| `POLYMARKET_PRIVATE_KEY` | Signer private key. Email/Magic: [reveal.magic.link/polymarket](https://reveal.magic.link/polymarket); browser wallet: export your EOA key | +| `POLYMARKET_PROXY_ADDRESS` | Funder address from Settings (not your EOA) — [polymarket.com/settings](https://polymarket.com/settings) | + +### Signature type `SIGNATURE_TYPE` + +Choose based on **funder wallet type in Settings**, not whether you registered with email or a browser wallet: + +| Value | When to use | +|-------|-------------| +| `Poly1271` (**default**) | V2 deposit wallet — email/Magic and browser-wallet accounts | +| `Proxy` | Legacy V1 Magic proxy only (Settings address must match ProxyFactory CREATE2 derive from your EOA) | +| `GnosisSafe` | Gnosis Safe multisig | +| `Eoa` | Direct EOA trading — no `POLYMARKET_PROXY_ADDRESS` needed | + +**Rule of thumb:** keep the default `Poly1271`. If `Proxy` orders fail with `please use the deposit wallet flow`, your account uses the V2 deposit wallet — switch back to `Poly1271` (no need to change private key or proxy address). + +### Required for Merge (when scheduled Merge or wind-down is enabled) + +| Variable | Description | +|----------|-------------| +| `POLY_BUILDER_API_KEY` | Builder API key | +| `POLY_BUILDER_SECRET` | Builder API secret | +| `POLY_BUILDER_PASSPHRASE` | Builder API passphrase | + +Get these from Polymarket → Settings → Builder. + +### Common options + +| Variable | Default | Description | +|----------|---------|-------------| +| `CRYPTO_SYMBOLS` | `btc,eth,sol,xrp` | Symbols to monitor, comma-separated | +| `ARBITRAGE_EXECUTION_SPREAD` | `0.01` | Execute when `yes + no <= 1 - spread` | +| `MAX_ORDER_SIZE_USDC` | `100.0` | Max order size per trade | +| `RISK_MAX_EXPOSURE_USDC` | `1000.0` | Max exposure per round | +| `MERGE_INTERVAL_MINUTES` | `0` | Scheduled Merge interval (minutes); `0` = disabled | +| `WIND_DOWN_BEFORE_WINDOW_END_MINUTES` | `0` | Wind-down before window end (minutes); `0` = disabled | +| `RUST_LOG` | `info` | Log level | + +Other settings (CLOB URL, signature type, slippage, order type, position sync, etc.) have sensible defaults. See `.env.example` for the full list with bilingual comments. + +## Disclaimer + +This software is for learning and research only — not investment advice. Crypto and prediction markets carry significant risk, including loss of funds. Assess your own risk and comply with Polymarket’s terms and applicable laws. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..9689b61 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,104 @@ +# Polymarket-5min-bot + +[English](./README.md) | **中文** + +**官方网站**:[polypulse.wiki](https://polypulse.wiki/) + +> 面向 [Polymarket](https://polymarket.com) 加密货币「Up or Down」5 分钟预测市场的 Rust 套利机器人。 + +![account](image/account.png) + +![运行界面](image/main.png) + +## 基本原理 + +Polymarket 的 Up/Down 市场每 5 分钟(UTC)开一个新窗口,每个市场有 YES 和 NO 两个结果代币。 + +理论上,持有等量 YES + NO 可在结算时兑换 1 USDC,因此: + +``` +YES 卖一价 + NO 卖一价 < 1 → 存在套利空间 +``` + +机器人大致流程如下: + +1. **发现市场** — 按配置的币种(如 btc、eth)自动查找当前 5 分钟窗口对应的 Up/Down 市场。 +2. **监控订单簿** — 订阅 CLOB 订单簿,实时计算 YES + NO 的合计价格。 +3. **执行套利** — 当合计价格低于阈值时,同时买入 YES 和 NO;可通过滑点、单笔上限、执行价差等参数控制下单行为。 +4. **Merge 回收** — 若同时持有 YES 和 NO,可链上 Merge 合并为 USDC/pUSD,减少持仓风险。 +5. **窗口收尾** — 接近窗口结束时,可自动取消挂单、Merge、并卖出剩余单腿仓位。 + +> 本程序连接真实市场与真实资金,使用前请充分理解风险。 + +## 快速开始 + +### 预编译可执行文件 + +如果你不会编译代码,请直接使用 **[Releases](https://github.com/crazygirl437/Polymarket-5min-bot/releases/tag/V.10)** 中提供的预编译可执行文件: + +1. 下载对应系统(Linux / Windows)的安装包 +2. 复制 `.env.example` 为 `.env`,并填写必填项 +3. 运行: + - Linux / macOS:`./polypulse` + - Windows:`polypulse.exe` + +### 从源码编译 + +需要先安装 [Rust](https://rustup.rs)。 + +```bash +cp .env.example .env # 填写 .env 配置文件的必填项后启动 +cargo run # 运行程序 +``` + +详细参数说明见 `.env.example`,按 `[1]` ~ `[9]` 分区排列:越靠前越重要。 + +## 基础配置 + +### 必填 + +| 变量 | 说明 | +|------|------| +| `POLYMARKET_PRIVATE_KEY` | 签名私钥。邮箱/Magic 从 [reveal.magic.link/polymarket](https://reveal.magic.link/polymarket) 导出;浏览器钱包导出对应 EOA 私钥 | +| `POLYMARKET_PROXY_ADDRESS` | 资金托管地址(Settings 里的 Address,非 EOA),见 [polymarket.com/settings](https://polymarket.com/settings) | + +### 签名类型 `SIGNATURE_TYPE` + +按 **Settings 里资金钱包的类型** 选择,与「邮箱还是浏览器钱包注册」无必然对应: + +| 值 | 适用场景 | +|----|----------| +| `Poly1271`(**默认**) | V2 deposit wallet;邮箱/Magic 与浏览器钱包授权账号均适用 | +| `Proxy` | 仅 V1 旧 Magic 代理(Settings 地址须等于 ProxyFactory 从 EOA 推导的地址) | +| `GnosisSafe` | Gnosis Safe 多签 | +| `Eoa` | 纯 EOA 直连,无需 `POLYMARKET_PROXY_ADDRESS` | + +**判断方法:** 保持默认 `Poly1271` 即可。若误设 `Proxy` 且下单报 `please use the deposit wallet flow`,说明账号已走 V2 deposit wallet,应改回 `Poly1271`(私钥与 `POLYMARKET_PROXY_ADDRESS` 无需改动)。 + +### Merge 所需(启用定时 Merge 或收尾时必填) + +| 变量 | 说明 | +|------|------| +| `POLY_BUILDER_API_KEY` | Builder API Key | +| `POLY_BUILDER_SECRET` | Builder API Secret | +| `POLY_BUILDER_PASSPHRASE` | Builder API Passphrase | + +以上三项在 Polymarket → Settings → Builder 获取。 + +### 常用可选项 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `CRYPTO_SYMBOLS` | `btc,eth,sol,xrp` | 监控的币种,逗号分隔 | +| `ARBITRAGE_EXECUTION_SPREAD` | `0.01` | 执行阈值:`yes + no <= 1 - spread` 时下单 | +| `MAX_ORDER_SIZE_USDC` | `100.0` | 单笔最大下单量 | +| `RISK_MAX_EXPOSURE_USDC` | `1000.0` | 每轮最大风险敞口 | +| `MERGE_INTERVAL_MINUTES` | `0` | 定时 Merge 间隔(分钟),`0` 为关闭 | +| `WIND_DOWN_BEFORE_WINDOW_END_MINUTES` | `0` | 窗口结束前收尾(分钟),`0` 为关闭 | +| `RUST_LOG` | `info` | 日志级别 | + +其余参数(CLOB 地址、签名类型、滑点、订单类型、持仓同步等)均有合理默认值,一般无需修改。完整列表与注释见 `.env.example`。 + +## 免责声明 + +本软件仅供学习与研究,不构成任何投资建议。加密货币与预测市场存在较高风险,可能导致资金损失。使用前请自行评估风险,并遵守 Polymarket 服务条款及当地法律法规。 diff --git a/arbitrage_opportunities.jsonl b/arbitrage_opportunities.jsonl new file mode 100644 index 0000000..6824c61 --- /dev/null +++ b/arbitrage_opportunities.jsonl @@ -0,0 +1,11 @@ +{"timestamp":"2026-07-31T11:52:17.962618300+00:00","market_id":"0x251226efd34dfb1e70bfa4fdffcabf6f0ccaf114d38debe2881b861d5fbeffd7","market_name":"sol prediction market","yes_token_id":"87671177098490840447267975709321257731489318834625312488018185664274964770925","no_token_id":"80625219662495722035314259632233306129491459203226995546332365550197651303600","yes_ask_price":"0.65","no_ask_price":"0.3","total_cost":"6.650","profit_percentage":"5.000","yes_size":"7.0","no_size":"7.0"} +{"timestamp":"2026-07-31T11:55:06.418986300+00:00","market_id":"0x1fc302abf53eed766299fdf371e401181391fe83d651b802199b539caa7cda35","market_name":"xrp prediction market","yes_token_id":"103011732465316163990562466191950705455492548163488329997311206669478678372678","no_token_id":"65032737727084115678802951859102358188554061792240485480628523613007795296957","yes_ask_price":"0.42","no_ask_price":"0.51","total_cost":"4.2222","profit_percentage":"7.000","yes_size":"4.54","no_size":"4.54"} +{"timestamp":"2026-07-31T11:55:12.204090400+00:00","market_id":"0x10fc130b4ec2c3843c12fca26603275e35235a7b12cc1748c601bfc24b28bb51","market_name":"doge prediction market","yes_token_id":"24168352865313107577883991999490092466080407689550221899327027471828981030322","no_token_id":"109669927598398093436703789776968664813182641445516511600372546509277280653299","yes_ask_price":"0.44","no_ask_price":"0.54","total_cost":"5.880","profit_percentage":"2.000","yes_size":"6.0","no_size":"6.0"} +{"timestamp":"2026-07-31T11:55:16.504422500+00:00","market_id":"0x832cdff1d090bdfaf649b7989bb1fbf204364b6812bc1ce98a7e663365897186","market_name":"hype prediction market","yes_token_id":"86645564241450216690617643235901688830062237942993436985963815979413910294402","no_token_id":"31138692739478710808178576321126767777962893395909778313178814417331041767542","yes_ask_price":"0.36","no_ask_price":"0.54","total_cost":"5.400","profit_percentage":"10.000","yes_size":"6.0","no_size":"6.0"} +{"timestamp":"2026-07-31T11:55:42.884280500+00:00","market_id":"0x832cdff1d090bdfaf649b7989bb1fbf204364b6812bc1ce98a7e663365897186","market_name":"hype prediction market","yes_token_id":"86645564241450216690617643235901688830062237942993436985963815979413910294402","no_token_id":"31138692739478710808178576321126767777962893395909778313178814417331041767542","yes_ask_price":"0.56","no_ask_price":"0.39","total_cost":"5.700","profit_percentage":"5.000","yes_size":"6.0","no_size":"6.0"} +{"timestamp":"2026-07-31T11:56:49.362739100+00:00","market_id":"0x832cdff1d090bdfaf649b7989bb1fbf204364b6812bc1ce98a7e663365897186","market_name":"hype prediction market","yes_token_id":"86645564241450216690617643235901688830062237942993436985963815979413910294402","no_token_id":"31138692739478710808178576321126767777962893395909778313178814417331041767542","yes_ask_price":"0.71","no_ask_price":"0.26","total_cost":"4.8306","profit_percentage":"3.000","yes_size":"4.98","no_size":"4.98"} +{"timestamp":"2026-07-31T11:57:16.031684700+00:00","market_id":"0x94bd1b8012e96f156368c7738490b057284fec6144f8fed61b5661437cba1843","market_name":"sol prediction market","yes_token_id":"5749762498657106037101107744020394844584909818261079637715596253784363438854","no_token_id":"10815617916906492654108340408261561336400777496427541804462267117163816581712","yes_ask_price":"0.22","no_ask_price":"0.73","total_cost":"6.6310","profit_percentage":"5.000","yes_size":"6.98","no_size":"6.98"} +{"timestamp":"2026-07-31T12:02:23.917235600+00:00","market_id":"0x8a81a1a933dd9b03a80e9f8b62824dfcd5f6bbbd643857dc2827f41be24e26da","market_name":"sol prediction market","yes_token_id":"7485340670896420594360684839116488544731800210061430440764957819593939995751","no_token_id":"97963498755829768914530612187539142870223073367744597468250664082623832623962","yes_ask_price":"0.31","no_ask_price":"0.66","total_cost":"5.820","profit_percentage":"3.000","yes_size":"6.0","no_size":"6.0"} +{"timestamp":"2026-07-31T12:02:31.080548600+00:00","market_id":"0x8a81a1a933dd9b03a80e9f8b62824dfcd5f6bbbd643857dc2827f41be24e26da","market_name":"sol prediction market","yes_token_id":"7485340670896420594360684839116488544731800210061430440764957819593939995751","no_token_id":"97963498755829768914530612187539142870223073367744597468250664082623832623962","yes_ask_price":"0.21","no_ask_price":"0.7","total_cost":"9.5186","profit_percentage":"9.000","yes_size":"10.46","no_size":"10.46"} +{"timestamp":"2026-07-31T12:15:59.591574300+00:00","market_id":"0x5863fc97351c29a5335929ac7eda660b399701501b645d732d1a7b8f08ce6b0b","market_name":"doge prediction market","yes_token_id":"28094949449082456048168671076578695522711593507598708298357715882610577188712","no_token_id":"77699348515810730313333415489395804045262894996313590837880484403208593576375","yes_ask_price":"0.49","no_ask_price":"0.44","total_cost":"5.580","profit_percentage":"7.000","yes_size":"6.0","no_size":"6.0"} +{"timestamp":"2026-07-31T12:22:55.865699300+00:00","market_id":"0xedb5eb9eaa32cc0b915012833942decaf6e8492b63d9cd13c1925f1843b0d3f7","market_name":"xrp prediction market","yes_token_id":"8498117842794714133949405079373670965866532662668838096422614325742583629510","no_token_id":"42988919682058948209313046606745199743181865472418754587018610480788220984816","yes_ask_price":"0.65","no_ask_price":"0.22","total_cost":"4.3326","profit_percentage":"13.000","yes_size":"4.98","no_size":"4.98"} diff --git a/image/account.png b/image/account.png new file mode 100644 index 0000000..d5d2ce4 Binary files /dev/null and b/image/account.png differ diff --git a/image/main.png b/image/main.png new file mode 100644 index 0000000..fd02e91 Binary files /dev/null and b/image/main.png differ diff --git a/src/adapter_auth.rs b/src/adapter_auth.rs new file mode 100644 index 0000000..0becd15 --- /dev/null +++ b/src/adapter_auth.rs @@ -0,0 +1,135 @@ +//! Shared CTF adapter approval helpers for pUSD on-chain ops (merge, redeem). + +use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::providers::Provider; +use anyhow::Result; +use tracing::info; + +use crate::deposit_wallet_relay::{ + relayer_execute_deposit_wallet_calldata, use_deposit_wallet_relayer, +}; +use crate::proxy_relay::{relayer_execute_proxy_calldata, CTF_POLYGON}; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IERC1155Approval { + function isApprovedForAll(address account, address operator) external view returns (bool); + } +} + +pub fn encode_set_approval_for_all(operator: Address, approved: bool) -> Vec { + let sel = &keccak256(b"setApprovalForAll(address,bool)")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(operator.as_slice()); + out.extend_from_slice(&U256::from(approved as u8).to_be_bytes::<32>()); + out +} + +pub async fn is_adapter_approved( + provider: &P, + ctf: Address, + owner: Address, + adapter: Address, +) -> Result { + let ctf_contract = IERC1155Approval::new(ctf, provider); + ctf_contract + .isApprovedForAll(owner, adapter) + .call() + .await + .map_err(|e| anyhow::anyhow!("isApprovedForAll failed: {}", e)) +} + +pub async fn wait_relayer_tx(provider: &P, tx_hash: &str) -> Result<()> { + let hash: B256 = tx_hash + .parse() + .map_err(|e| anyhow::anyhow!("invalid tx hash {}: {}", tx_hash, e))?; + for _ in 0..60 { + if let Some(receipt) = provider.get_transaction_receipt(hash).await? { + if !receipt.status() { + anyhow::bail!("Relayer tx reverted on-chain: {}", tx_hash); + } + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + anyhow::bail!("Timed out waiting for relayer tx: {}", tx_hash); +} + +async fn submit_adapter_approval( + _provider: &P, + wallet: Address, + adapter: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result { + let approve_calldata = encode_set_approval_for_all(adapter, true); + if use_deposit_wallet_relayer() { + relayer_execute_deposit_wallet_calldata( + &approve_calldata, + CTF_POLYGON, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Approve CTF adapter", + ) + .await + } else { + relayer_execute_proxy_calldata( + &approve_calldata, + CTF_POLYGON, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Approve CTF adapter", + None, + ) + .await + } +} + +pub async fn ensure_adapter_approved( + provider: &P, + wallet: Address, + adapter: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result<()> { + if is_adapter_approved(provider, CTF_POLYGON, wallet, adapter).await? { + return Ok(()); + } + info!("pUSD adapter {:?} 未授权,提交 setApprovalForAll …", adapter); + let tx = submit_adapter_approval( + provider, + wallet, + adapter, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + ) + .await?; + wait_relayer_tx(provider, &tx).await?; + if !is_adapter_approved(provider, CTF_POLYGON, wallet, adapter).await? { + anyhow::bail!( + "setApprovalForAll 已提交 ({}) 但链上仍未授权,请稍后重试", + tx + ); + } + info!("✅ CTF adapter 已授权"); + Ok(()) +} diff --git a/src/clob_v2.rs b/src/clob_v2.rs new file mode 100644 index 0000000..9343cd5 --- /dev/null +++ b/src/clob_v2.rs @@ -0,0 +1,71 @@ +//! Polymarket V2 CLOB client factory (shared by main bot and test binaries). + +use anyhow::Result; +use alloy::signers::local::LocalSigner; +use alloy::signers::Signer as _; +use polymarket_client_sdk_v2::clob::types::SignatureType; +use polymarket_client_sdk_v2::clob::{Client, Config as ClobConfig}; +use polymarket_client_sdk_v2::types::Address; +use polymarket_client_sdk_v2::POLYGON; +use std::str::FromStr; + +pub const CLOB_API_URL_DEFAULT: &str = "https://clob.polymarket.com"; + +pub type AuthenticatedClobClient = Client< + polymarket_client_sdk_v2::auth::state::Authenticated< + polymarket_client_sdk_v2::auth::Normal, + >, +>; + +/// Parse V2 CLOB signature type from env string. +/// +/// Most V2 accounts (email/Magic and browser wallet) use `Poly1271` (deposit wallet). +/// Legacy `Proxy` applies only when Settings funder equals ProxyFactory CREATE2 derive. +pub fn parse_signature_type(s: &str) -> SignatureType { + match s.trim().to_lowercase().as_str() { + "proxy" | "magic" | "email" => SignatureType::Proxy, + "gnosissafe" | "safe" => SignatureType::GnosisSafe, + "poly1271" | "deposit" | "deposit_wallet" | "3" => SignatureType::Poly1271, + "eoa" | "0" => SignatureType::Eoa, + _ => SignatureType::Poly1271, + } +} + +/// Build an authenticated V2 CLOB client (EIP-712 domain v2 / pUSD). +pub async fn create_authenticated_clob_client( + private_key: &str, + clob_api_url: &str, + funder_address: Option
, + signature_type: SignatureType, +) -> Result { + if !matches!(signature_type, SignatureType::Eoa) && funder_address.is_none() { + anyhow::bail!( + "POLYMARKET_PROXY_ADDRESS (deposit wallet / proxy) is required for {:?} orders", + signature_type + ); + } + + let signer = LocalSigner::from_str(private_key) + .map_err(|e| anyhow::anyhow!("Invalid private key: {}", e))? + .with_chain_id(Some(POLYGON)); + + let clob_config = ClobConfig::builder().use_server_time(true).build(); + let mut auth_builder = Client::new(clob_api_url, clob_config)? + .authentication_builder(&signer); + + if let Some(funder) = funder_address { + auth_builder = auth_builder + .funder(funder) + .signature_type(signature_type); + } + + auth_builder + .authenticate() + .await + .map_err(|e| anyhow::anyhow!("CLOB V2 auth failed: {}", e)) +} + +/// Parse proxy/deposit wallet address from v1 SDK Address string representation. +pub fn v1_address_to_v2(addr: polymarket_client_sdk::types::Address) -> Address { + addr.to_string().parse().expect("valid address") +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..04e287f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,182 @@ +use anyhow::Result; +use polymarket_client_sdk_v2::clob::types::OrderType; +use std::env; + +use polymarket_client_sdk::types::Address; + +use crate::trading::CLOB_API_URL_DEFAULT; + +/// Parse arbitrage order type: GTC, GTD, FOK, FAK; case-insensitive; invalid/unknown defaults to GTD. +fn parse_arbitrage_order_type(s: &str) -> OrderType { + match s.trim().to_uppercase().as_str() { + "GTC" => OrderType::GTC, + "GTD" => OrderType::GTD, + "FOK" => OrderType::FOK, + "FAK" => OrderType::FAK, + _ => OrderType::GTD, + } +} + +/// Parse slippage array: comma-separated, e.g. "-0.02,0.0". +/// Index 0=up/flat side, 1=down-only side. Single value used for both. Default "0,0.01". +fn parse_slippage(s: &str) -> [f64; 2] { + let parts: Vec = s + .split(',') + .map(|x| x.trim().parse().unwrap_or(0.0)) + .collect(); + match parts.len() { + 0 => [0.0, 0.01], + 1 => [parts[0], parts[0]], + _ => [parts[0], parts[1]], + } +} + +#[derive(Debug, Clone)] +pub struct Config { + pub private_key: String, + pub proxy_address: Option
, // Funder from Settings (deposit wallet or legacy proxy) + /// V2 CLOB API base URL (default https://clob.polymarket.com — do not use clob-v2 host) + pub clob_api_url: String, + /// CLOB signature type: Poly1271 | Proxy | GnosisSafe | Eoa (see SIGNATURE_TYPE) + pub signature_type: String, + pub min_profit_threshold: f64, + pub max_order_size_usdc: f64, + pub crypto_symbols: Vec, + pub market_refresh_advance_secs: u64, + pub risk_max_exposure_usdc: f64, + pub risk_imbalance_threshold: f64, + pub hedge_take_profit_pct: f64, // Hedge take-profit % (e.g. 0.05 = 5%) + pub hedge_stop_loss_pct: f64, // Hedge stop-loss % (e.g. 0.05 = 5%) + pub arbitrage_execution_spread: f64, // Execute arbitrage when yes+no <= 1 - this spread + /// Slippage [first, second]: down-only uses second, up/flat uses first. e.g. "-0.02,0.0" + pub slippage: [f64; 2], + pub gtd_expiration_secs: u64, // GTD order expiry (seconds), default 300 (5 min); only when arbitrage_order_type=GTD + /// Order type for arbitrage: GTC (good till cancel), GTD (with gtd_expiration_secs), FOK (fill or kill), FAK (fill and kill remainder) + pub arbitrage_order_type: OrderType, + pub stop_arbitrage_before_end_minutes: u64, // Stop arbitrage N minutes before market end, default 0 (no stop) + /// Scheduled Merge interval (minutes); 0 = disabled. CONDITION_ID from current window markets like orderbook. + pub merge_interval_minutes: u64, + /// YES price threshold: only execute arbitrage when YES >= this, default 0.0 (no limit) + pub min_yes_price_threshold: f64, + /// NO price threshold: only execute arbitrage when NO >= this, default 0.0 (no limit) + pub min_no_price_threshold: f64, + /// Position sync interval (seconds), default 10 (fetch from API, overwrite local cache) + pub position_sync_interval_secs: u64, + /// Position balance check interval (seconds), default 60 + pub position_balance_interval_secs: u64, + /// Imbalance threshold: cancel orders only when position diff >= this, default 2.0 + pub position_balance_threshold: f64, + /// Min total position: run balance only when total >= this, default 5.0 + pub position_balance_min_total: f64, + /// Wind-down before window end: minutes before 5min window end to trigger (cancel→Merge→market sell rest). 0=disabled. + pub wind_down_before_window_end_minutes: u64, + /// Limit price for one-sided leg sells during wind-down (aim for fast fill), default 0.01 + pub wind_down_sell_price: f64, +} + +impl Config { + pub fn from_env() -> Result { + dotenvy::dotenv().ok(); + + // Parse proxy_address (optional) + let proxy_address: Option
= env::var("POLYMARKET_PROXY_ADDRESS") + .ok() + .and_then(|addr| addr.trim().parse().ok()); + + Ok(Config { + private_key: env::var("POLYMARKET_PRIVATE_KEY") + .expect("POLYMARKET_PRIVATE_KEY must be set") + .trim() + .to_string(), + proxy_address, + clob_api_url: env::var("CLOB_API_URL") + .unwrap_or_else(|_| CLOB_API_URL_DEFAULT.to_string()), + signature_type: env::var("SIGNATURE_TYPE").unwrap_or_else(|_| "Poly1271".to_string()), + min_profit_threshold: env::var("MIN_PROFIT_THRESHOLD") + .unwrap_or_else(|_| "0.001".to_string()) + .parse() + .unwrap_or(0.001), + max_order_size_usdc: env::var("MAX_ORDER_SIZE_USDC") + .unwrap_or_else(|_| "100.0".to_string()) + .parse() + .unwrap_or(100.0), + crypto_symbols: env::var("CRYPTO_SYMBOLS") + .unwrap_or_else(|_| "btc,eth,xrp,sol".to_string()) + .split(',') + .map(|s| s.trim().to_lowercase()) + .collect(), + market_refresh_advance_secs: env::var("MARKET_REFRESH_ADVANCE_SECS") + .unwrap_or_else(|_| "5".to_string()) + .parse() + .unwrap_or(5), + risk_max_exposure_usdc: env::var("RISK_MAX_EXPOSURE_USDC") + .unwrap_or_else(|_| "1000.0".to_string()) + .parse() + .unwrap_or(1000.0), + risk_imbalance_threshold: env::var("RISK_IMBALANCE_THRESHOLD") + .unwrap_or_else(|_| "0.1".to_string()) + .parse() + .unwrap_or(0.1), + hedge_take_profit_pct: env::var("HEDGE_TAKE_PROFIT_PCT") + .unwrap_or_else(|_| "0.05".to_string()) + .parse() + .unwrap_or(0.05), // default 5% take-profit + hedge_stop_loss_pct: env::var("HEDGE_STOP_LOSS_PCT") + .unwrap_or_else(|_| "0.05".to_string()) + .parse() + .unwrap_or(0.05), // default 5% stop-loss + arbitrage_execution_spread: env::var("ARBITRAGE_EXECUTION_SPREAD") + .unwrap_or_else(|_| "0.01".to_string()) + .parse() + .unwrap_or(0.01), // default 0.01 + slippage: parse_slippage(&env::var("SLIPPAGE").unwrap_or_else(|_| "0,0.01".to_string())), + gtd_expiration_secs: env::var("GTD_EXPIRATION_SECS") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .unwrap_or(300), // default 300s (5 min) + arbitrage_order_type: parse_arbitrage_order_type( + &env::var("ARBITRAGE_ORDER_TYPE").unwrap_or_else(|_| "GTD".to_string()), + ), + stop_arbitrage_before_end_minutes: env::var("STOP_ARBITRAGE_BEFORE_END_MINUTES") + .unwrap_or_else(|_| "0".to_string()) + .parse() + .unwrap_or(0), // default 0 (no stop) + merge_interval_minutes: env::var("MERGE_INTERVAL_MINUTES") + .unwrap_or_else(|_| "0".to_string()) + .parse() + .unwrap_or(0), // 0=disabled + min_yes_price_threshold: env::var("MIN_YES_PRICE_THRESHOLD") + .unwrap_or_else(|_| "0.0".to_string()) + .parse() + .unwrap_or(0.0), // default 0.0 (no limit) + min_no_price_threshold: env::var("MIN_NO_PRICE_THRESHOLD") + .unwrap_or_else(|_| "0.0".to_string()) + .parse() + .unwrap_or(0.0), // default 0.0 (no limit) + position_sync_interval_secs: env::var("POSITION_SYNC_INTERVAL_SECS") + .unwrap_or_else(|_| "10".to_string()) + .parse() + .unwrap_or(10), // default 10s + position_balance_interval_secs: env::var("POSITION_BALANCE_INTERVAL_SECS") + .unwrap_or_else(|_| "60".to_string()) + .parse() + .unwrap_or(60), // default 60s + position_balance_threshold: env::var("POSITION_BALANCE_THRESHOLD") + .unwrap_or_else(|_| "2.0".to_string()) + .parse() + .unwrap_or(2.0), // default 2.0 + position_balance_min_total: env::var("POSITION_BALANCE_MIN_TOTAL") + .unwrap_or_else(|_| "5.0".to_string()) + .parse() + .unwrap_or(5.0), // default 5.0 + wind_down_before_window_end_minutes: env::var("WIND_DOWN_BEFORE_WINDOW_END_MINUTES") + .unwrap_or_else(|_| "0".to_string()) + .parse() + .unwrap_or(0), // 0=disabled + wind_down_sell_price: env::var("WIND_DOWN_SELL_PRICE") + .unwrap_or_else(|_| "0.01".to_string()) + .parse() + .unwrap_or(0.01), // default 0.01 + }) + } +} diff --git a/src/deposit_wallet_relay.rs b/src/deposit_wallet_relay.rs new file mode 100644 index 0000000..f044a2a --- /dev/null +++ b/src/deposit_wallet_relay.rs @@ -0,0 +1,217 @@ +//! Polymarket V2 deposit wallet relayer (`WALLET` batch) for on-chain ops. + +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy::primitives::{Address, Bytes, U256}; +use alloy::sol_types::{eip712_domain, SolStruct}; +use anyhow::Result; +use tracing::info; + +use crate::proxy_relay::{relayer_submit_authed, to_hex_0x, RELAYER_URL_DEFAULT}; + +/// Polygon mainnet deposit wallet factory (Polymarket docs). +pub const DEPOSIT_WALLET_FACTORY: Address = { + use polymarket_client_sdk::types::address; + address!("0x00000000000Fb5C9ADea0298D729A0CB3823Cc07") +}; + +const RELAYER_GET_NONCE: &str = "/nonce"; +const WALLET_BATCH_DEADLINE_SECS: u64 = 600; + +alloy::sol! { + struct Call { + address target; + uint256 value; + bytes data; + } + struct Batch { + address wallet; + uint256 nonce; + uint256 deadline; + Call[] calls; + } +} + +/// True when `SIGNATURE_TYPE` indicates V2 deposit wallet (Poly1271). +pub fn use_deposit_wallet_relayer() -> bool { + match env::var("SIGNATURE_TYPE") { + Ok(v) => { + let s = v.trim().to_lowercase(); + s == "poly1271" || s == "deposit" || s == "deposit_wallet" || s == "3" + } + Err(_) => true, + } +} + +pub async fn get_wallet_nonce(relayer_url: &str, owner: Address) -> Result { + let client = reqwest::Client::new(); + let base = relayer_url.trim_end_matches('/'); + let url = format!( + "{}{}?address={:#x}&type=WALLET", + base, RELAYER_GET_NONCE, owner + ); + let resp = client.get(&url).send().await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + anyhow::bail!("GET /nonce (WALLET) failed status={} body={}", status, text); + } + let j: serde_json::Value = serde_json::from_str(&text)?; + let nonce = j + .get("nonce") + .and_then(|v| { + v.as_str() + .and_then(|s| s.parse().ok()) + .or_else(|| v.as_u64()) + }) + .unwrap_or(0); + Ok(U256::from(nonce)) +} + +async fn sign_deposit_wallet_batch( + signer: &impl alloy::signers::Signer, + chain_id: u64, + deposit_wallet: Address, + nonce: U256, + deadline: U256, + calls: Vec<(Address, U256, Vec)>, +) -> Result { + let domain = eip712_domain! { + name: "DepositWallet", + version: "1", + chain_id: chain_id, + verifying_contract: deposit_wallet, + }; + let batch_calls: Vec = calls + .into_iter() + .map(|(target, value, data)| Call { + target, + value, + data: Bytes::from(data), + }) + .collect(); + let batch = Batch { + wallet: deposit_wallet, + nonce, + deadline, + calls: batch_calls, + }; + let hash = batch.eip712_signing_hash(&domain); + let sig = signer + .sign_hash(&hash) + .await + .map_err(|e| anyhow::anyhow!("deposit wallet batch sign failed: {}", e))?; + let mut sig_bytes = sig.as_bytes().to_vec(); + if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) { + sig_bytes[64] += 27; + } + Ok(to_hex_0x(&sig_bytes)) +} + +/// Execute one or more calls on a deposit wallet via relayer `WALLET` batch. +pub async fn relayer_execute_deposit_wallet_calls( + calls: &[(Address, Vec)], + deposit_wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + metadata: &str, +) -> Result { + if calls.is_empty() { + anyhow::bail!("relayer_execute_deposit_wallet_calls: empty calls"); + } + let relayer_url = if relayer_url.is_empty() { + RELAYER_URL_DEFAULT + } else { + relayer_url + }; + let owner = signer.address(); + let chain_id = signer.chain_id().unwrap_or(137); + let nonce = get_wallet_nonce(relayer_url, owner).await?; + let deadline = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() + + WALLET_BATCH_DEADLINE_SECS; + + let typed_calls: Vec<(Address, U256, Vec)> = calls + .iter() + .map(|(target, data)| (*target, U256::ZERO, data.clone())) + .collect(); + let signature = sign_deposit_wallet_batch( + signer, + chain_id, + deposit_wallet, + nonce, + U256::from(deadline), + typed_calls, + ) + .await?; + + let calls_json: Vec = calls + .iter() + .map(|(target, data)| { + serde_json::json!({ + "target": format!("{:#x}", target), + "value": "0", + "data": to_hex_0x(data), + }) + }) + .collect(); + + let body = serde_json::json!({ + "type": "WALLET", + "from": format!("{:#x}", owner), + "to": format!("{:#x}", DEPOSIT_WALLET_FACTORY), + "nonce": nonce.to_string(), + "signature": signature, + "metadata": metadata, + "depositWalletParams": { + "depositWallet": format!("{:#x}", deposit_wallet), + "deadline": deadline.to_string(), + "calls": calls_json, + } + }); + + info!( + "Relayer WALLET batch | wallet={:?} | calls={} | nonce={}", + deposit_wallet, + calls.len(), + nonce + ); + + relayer_submit_authed( + body, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + ) + .await +} + +pub async fn relayer_execute_deposit_wallet_calldata( + calldata: &[u8], + target: Address, + deposit_wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + metadata: &str, +) -> Result { + relayer_execute_deposit_wallet_calls( + &[(target, calldata.to_vec())], + deposit_wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + metadata, + ) + .await +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ff39565 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! polypulse library: shared modules for the main binary and test binaries. + +pub mod clob_v2; +mod adapter_auth; +mod deposit_wallet_relay; +mod proxy_relay; +mod pusd_wrap; +mod wallet_kind; + +pub use clob_v2::{ + create_authenticated_clob_client, parse_signature_type, v1_address_to_v2, + AuthenticatedClobClient, CLOB_API_URL_DEFAULT, +}; + +pub use proxy_relay::{ + CTF_COLLATERAL_ADAPTER, CTF_POLYGON, NEG_RISK_ADAPTER, NEG_RISK_COLLATERAL_ADAPTER, + PROXY_MERGE_PUSD_GAS, PUSD_POLYGON, RPC_URL_DEFAULT, USDC_POLYGON, +}; + +pub mod merge; +pub mod positions; +pub mod redeem; +pub mod ui; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..12dbb35 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1070 @@ +mod config; +mod market; +mod monitor; +mod risk; +mod trading; +mod utils; + +use polypulse::merge; +use polypulse::positions::{get_positions, Position}; +use polypulse::ui::{decimal_to_f64, spawn_dashboard_thread, symbol_short, DashboardHandle}; + +use anyhow::Result; +use dashmap::DashMap; +use futures::StreamExt; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use std::collections::{HashMap, HashSet}; +use std::io::{stdout, IsTerminal}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::str::FromStr as _; +use std::time::{Duration, Instant}; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; +use polymarket_client_sdk::types::{Address, B256, U256}; + +use crate::config::Config; +use crate::market::{MarketDiscoverer, MarketInfo, MarketScheduler}; +use crate::monitor::{ArbitrageDetector, OrderBookMonitor}; +use crate::risk::positions::PositionTracker; +use crate::risk::{PositionBalancer, RiskManager}; +use crate::trading::{ + create_authenticated_clob_client, parse_signature_type, v1_address_to_v2, TradingExecutor, +}; + +/// Filter condition_ids from positions where **both YES and NO** are held; only these markets can merge. One-sided positions are skipped. +/// Data API may return outcome_index 0/1 (0=Yes, 1=No) or 1/2 (CTF index_set convention); both are supported. +fn condition_ids_with_both_sides(positions: &[Position]) -> Vec { + let mut by_condition: HashMap> = HashMap::new(); + for p in positions { + if p.size <= dec!(0) || !p.mergeable { + continue; + } + by_condition + .entry(p.condition_id) + .or_default() + .insert(p.outcome_index); + } + by_condition + .into_iter() + .filter(|(_, indices)| { + (indices.contains(&0) && indices.contains(&1)) || (indices.contains(&1) && indices.contains(&2)) + }) + .map(|(c, _)| c) + .collect() +} + +/// Build condition_id -> (yes_token_id, no_token_id, merge_amount) from positions for exposure deduction after successful merge. +/// Supports outcome_index 0/1 (0=Yes, 1=No) and 1/2 (CTF convention). +fn merge_info_with_both_sides(positions: &[Position]) -> HashMap { + // outcome_index -> (asset, size) grouped by condition + let mut by_condition: HashMap> = HashMap::new(); + for p in positions { + if p.size <= dec!(0) || !p.mergeable { + continue; + } + by_condition + .entry(p.condition_id) + .or_default() + .insert(p.outcome_index, (p.asset, p.size)); + } + by_condition + .into_iter() + .filter_map(|(c, map)| { + // Prefer CTF convention 1=Yes, 2=No; otherwise use 0=Yes, 1=No + if let (Some((yes_token, yes_size)), Some((no_token, no_size))) = + (map.get(&1).copied(), map.get(&2).copied()) + { + return Some((c, (yes_token, no_token, yes_size.min(no_size)))); + } + if let (Some((yes_token, yes_size)), Some((no_token, no_size))) = + (map.get(&0).copied(), map.get(&1).copied()) + { + return Some((c, (yes_token, no_token, yes_size.min(no_size)))); + } + None + }) + .collect() +} + +/// Convert on-chain share amount (6 decimals) to Decimal for position tracker updates. +fn raw_shares_to_decimal(raw: U256) -> Decimal { + Decimal::from_str(&raw.to_string()).unwrap_or(dec!(0)) / dec!(1_000_000) +} + +/// Scheduled Merge task: every interval_minutes minutes fetch **positions**, run merge_max **serially** only for markets with both YES+NO positions, +/// skip one-sided positions; delay between each merge, retry once on RPC rate limit. After merge success, deduct position_tracker holdings and exposure. +/// Brief initial delay before first run to avoid blocking the orderbook stream by competing for runtime at startup. +async fn run_merge_task( + interval_minutes: u64, + proxy: Address, + private_key: String, + position_tracker: Arc, + wind_down_in_progress: Arc, +) { + let interval = Duration::from_secs(interval_minutes * 60); + /// Delay between each merge to reduce RPC bursts + const DELAY_BETWEEN_MERGES: Duration = Duration::from_secs(30); + /// Duration to wait before retry on rate limit (slightly longer than "retry in 10s") + const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(12); + /// Initial delay so main loop can finish orderbook subscription and enter select! before first merge + const INITIAL_DELAY: Duration = Duration::from_secs(10); + + // Let main loop complete get_markets, create stream and start orderbook listening before first merge + sleep(INITIAL_DELAY).await; + + loop { + if wind_down_in_progress.load(Ordering::Relaxed) { + info!("Wind-down in progress, skipping merge for this round"); + sleep(interval).await; + continue; + } + let (condition_ids, merge_info) = match get_positions().await { + Ok(positions) => ( + condition_ids_with_both_sides(&positions), + merge_info_with_both_sides(&positions), + ), + Err(e) => { + warn!(error = %e, "❌ Failed to fetch positions, skipping merge for this round"); + sleep(interval).await; + continue; + } + }; + + if condition_ids.is_empty() { + debug!("🔄 Merge round: no markets with both YES+NO positions"); + } else { + info!( + count = condition_ids.len(), + "🔄 Merge round: {} markets have both YES+NO positions", + condition_ids.len() + ); + } + + for (i, &condition_id) in condition_ids.iter().enumerate() { + // For 2nd and later markets: wait 30s before merge to avoid overlap with previous on-chain tx + if i > 0 { + info!("Merge round: waiting 30s before merging next market ({}/{})", i + 1, condition_ids.len()); + sleep(DELAY_BETWEEN_MERGES).await; + } + let asset_hint = merge_info + .get(&condition_id) + .map(|(yes_token, no_token, _)| (*yes_token, *no_token)); + let mut result = + merge::merge_max(condition_id, proxy, &private_key, None, asset_hint).await; + if result.is_err() { + let msg = result.as_ref().unwrap_err().to_string(); + if msg.contains("rate limit") || msg.contains("retry in") { + warn!(condition_id = %condition_id, "⏳ RPC rate limit, waiting {}s before retry", RATE_LIMIT_BACKOFF.as_secs()); + sleep(RATE_LIMIT_BACKOFF).await; + result = merge::merge_max(condition_id, proxy, &private_key, None, asset_hint).await; + } + } + match result { + Ok(merge_result) => { + info!("✅ Merge complete | condition_id={:#x}", condition_id); + info!(" 📝 tx={}", merge_result.tx_hash); + let chain_amt = raw_shares_to_decimal(merge_result.merged_amount); + if let Some((yes_token, no_token, merge_amt)) = merge_info.get(&condition_id) { + let deduct = chain_amt.min(*merge_amt); + position_tracker.update_exposure_cost(*yes_token, dec!(0), -deduct); + position_tracker.update_exposure_cost(*no_token, dec!(0), -deduct); + position_tracker.update_position(*yes_token, -deduct); + position_tracker.update_position(*no_token, -deduct); + info!( + "💰 Merge deducted exposure | condition_id={:#x} | amount:{} (chain:{})", + condition_id, deduct, chain_amt + ); + } + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("no mergeable shares") { + debug!(condition_id = %condition_id, "⏭️ Skip merge: no mergeable shares"); + } else { + warn!(condition_id = %condition_id, error = %e, "❌ Merge failed"); + } + } + } + tokio::task::yield_now().await; + } + + sleep(interval).await; + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Must set rustls default crypto provider first, otherwise reqwest/alloy etc. will panic when using TLS + + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + let use_tui = utils::logger::tui_enabled_from_env() && stdout().is_terminal(); + utils::logger::init_logger(use_tui)?; + + let shutdown = Arc::new(AtomicBool::new(false)); + + if use_tui { + tracing::info!("Polymarket 5-minute arbitrage bot starting (dashboard mode, logs → bot.log)"); + } else { + tracing::info!("Polymarket 5-minute arbitrage bot starting"); + } + + // Load config + let config = Config::from_env()?; + tracing::info!("Config loaded"); + + let order_mode = format!("{:?}", config.arbitrage_order_type); + let dashboard = DashboardHandle::new_live(order_mode, config.risk_max_exposure_usdc); + if use_tui { + spawn_dashboard_thread(dashboard.arc(), shutdown.clone()); + dashboard.with_mut(|d| { + d.push_event("🚀 Dashboard ready — hunting arbitrage opportunities"); + }); + } + + // Initialize components (currently unused, main loop disabled) + let _discoverer = MarketDiscoverer::new(config.crypto_symbols.clone()); + let _scheduler = MarketScheduler::new(_discoverer, config.market_refresh_advance_secs); + let _detector = ArbitrageDetector::new(config.min_profit_threshold); + + // Validate private key format + info!("Verifying private key format..."); + use alloy::signers::local::LocalSigner; + use std::str::FromStr; + + let _signer_test = LocalSigner::from_str(&config.private_key) + .map_err(|e| anyhow::anyhow!("Invalid private key format: {}", e))?; + info!("Private key format validated"); + + // Initialize trading executor (requires API auth) + info!("Initializing trading executor (requires API auth)..."); + if let Some(ref proxy) = config.proxy_address { + info!( + proxy_address = %proxy, + signature_type = %config.signature_type, + "Using funder address for V2 CLOB (default SIGNATURE_TYPE=Poly1271 for deposit wallet; use Proxy only for legacy Magic proxy)" + ); + } else { + info!("Using EOA signature type (direct trading)"); + } + info!("Note: A 'Could not create api key' warning is normal. The SDK tries to create a new API key first; if it fails, it will use the derived method and auth will still succeed."); + info!(clob_url = %config.clob_api_url, signature_type = %config.signature_type, "CLOB V2 client config"); + + let v2_proxy = config.proxy_address.map(v1_address_to_v2); + let sig_type = parse_signature_type(&config.signature_type); + + let clob_client = match create_authenticated_clob_client( + &config.private_key, + &config.clob_api_url, + v2_proxy, + sig_type, + ) + .await + { + Ok(client) => { + info!("CLOB V2 client authenticated"); + client + } + Err(e) => { + error!(error = %e, "CLOB V2 authentication failed! Cannot continue."); + error!("Please check:"); + error!(" 1. POLYMARKET_PRIVATE_KEY is correctly set"); + error!(" 2. Private key format (64-char hex, no 0x prefix)"); + error!(" 3. Network connectivity"); + error!(" 4. Polymarket API availability"); + error!(" 5. CLOB_API_URL (use https://clob.polymarket.com, not clob-v2 host)"); + return Err(anyhow::anyhow!("Authentication failed, exiting: {}", e)); + } + }; + + let executor = Arc::new(TradingExecutor::from_client( + clob_client.clone(), + config.private_key.clone(), + config.max_order_size_usdc, + config.slippage, + config.gtd_expiration_secs, + config.arbitrage_order_type.clone(), + )); + + let _risk_manager = Arc::new(RiskManager::new(clob_client.clone(), &config)); + + // Hedge strategy is currently disabled (intentionally not instantiated). + // 启用方式: 在 orderbook 循环中调用 hedge_monitor.check_and_execute(&book).await + // let position_tracker = _risk_manager.position_tracker(); + // let _hedge_monitor = HedgeMonitor::new( + // clob_client.clone(), + // config.private_key.clone(), + // config.proxy_address.clone(), + // position_tracker, + // ); + + // Verify auth actually succeeded - try a simple API call + info!("Verifying authentication (via API call test)..."); + match executor.verify_authentication().await { + Ok(_) => { + info!("✅ Auth verified, API calls OK"); + dashboard.with_mut(|d| d.push_event("✅ CLOB authenticated — ready to trade")); + } + Err(e) => { + error!(error = %e, "❌ Auth verification failed! authenticate() did not error but API calls fail."); + error!("This indicates auth did not actually succeed. Possible causes:"); + error!(" 1. API key creation failed ('Could not create api key' warning)"); + error!(" 2. Account may not be registered on Polymarket"); + error!(" 3. Account may be restricted or suspended"); + error!(" 4. Network issues"); + error!("Exiting. Please fix authentication before running again."); + return Err(anyhow::anyhow!("Auth verification failed: {}", e)); + } + } + + info!("✅ All components initialized, auth verified"); + + // Create position balancer + let position_balancer = Arc::new(PositionBalancer::new( + clob_client.clone(), + _risk_manager.position_tracker(), + &config, + )); + + // Scheduled position sync: every N seconds fetch latest positions from API, overwrite local cache + let position_sync_interval = config.position_sync_interval_secs; + if position_sync_interval > 0 { + let position_tracker_sync = _risk_manager.position_tracker(); + tokio::spawn(async move { + let interval = Duration::from_secs(position_sync_interval); + loop { + match position_tracker_sync.sync_from_api().await { + Ok(_) => { + // Positions printed in sync_from_api + } + Err(e) => { + warn!(error = %e, "Position sync failed, will retry next loop"); + } + } + sleep(interval).await; + } + }); + info!( + interval_secs = position_sync_interval, + "Started position sync task, every {}s fetch latest positions from API", + position_sync_interval + ); + } else { + warn!("POSITION_SYNC_INTERVAL_SECS=0, position sync disabled"); + } + + // Scheduled position balance: every N seconds check positions and orders, cancel excess orders + // Note: balance task runs in main loop as it needs market mapping + let balance_interval = config.position_balance_interval_secs; + if balance_interval > 0 { + info!( + interval_secs = balance_interval, + "Position balance task runs in main loop every {}s", + balance_interval + ); + } else { + info!("Position balance not enabled (POSITION_BALANCE_INTERVAL_SECS=0)"); + } + + // Wind-down in progress flag: scheduled merge checks and skips to avoid race with wind-down merge + let wind_down_in_progress = Arc::new(AtomicBool::new(false)); + + // Minimum interval between two arbitrage trades + const MIN_TRADE_INTERVAL: Duration = Duration::from_secs(3); + let last_trade_time: Arc>> = Arc::new(tokio::sync::Mutex::new(None)); + + // Scheduled Merge: every N minutes run merge by positions, only for markets with both YES+NO + let merge_interval = config.merge_interval_minutes; + if merge_interval > 0 { + if let Some(proxy) = config.proxy_address { + let private_key = config.private_key.clone(); + let position_tracker = _risk_manager.position_tracker().clone(); + let wind_down_flag = wind_down_in_progress.clone(); + tokio::spawn(async move { + run_merge_task(merge_interval, proxy, private_key, position_tracker, wind_down_flag).await; + }); + info!( + interval_minutes = merge_interval, + "Started scheduled Merge task, every {} minutes (YES+NO both positions only)", + merge_interval + ); + } else { + warn!("MERGE_INTERVAL_MINUTES={} but POLYMARKET_PROXY_ADDRESS not set, scheduled Merge disabled", merge_interval); + } + } else { + info!("Scheduled Merge not enabled (MERGE_INTERVAL_MINUTES=0). To enable, set MERGE_INTERVAL_MINUTES in .env (e.g. 5 or 15)"); + } + + // Main loop enabled, start monitoring and trading + #[allow(unreachable_code)] + loop { + if shutdown.load(Ordering::Relaxed) { + tracing::info!("Shutdown requested from dashboard"); + return Ok(()); + } + + // Fetch markets for current window immediately, or wait for next window on failure + let markets = match _scheduler.get_markets_immediately_or_wait().await { + Ok(markets) => markets, + Err(e) => { + error!(error = %e, "Failed to fetch markets"); + sleep(Duration::from_secs(60)).await; + continue; + } + }; + + if markets.is_empty() { + warn!("No markets found, skipping current window"); + continue; + } + + // New round: reset risk exposure so this round accumulates from 0 + _risk_manager.position_tracker().reset_exposure(); + + dashboard.with_mut(|d| { + d.window_pnl = 0.0; + d.markets.clear(); + }); + + // Initialize orderbook monitor + let mut monitor = OrderBookMonitor::new(); + + // Subscribe to all markets + for market in &markets { + dashboard.with_mut(|d| d.ensure_market(symbol_short(&market.crypto_symbol))); + if let Err(e) = monitor.subscribe_market(market) { + error!(error = %e, market_id = %market.market_id, "Failed to subscribe to market"); + } + } + + // Create orderbook stream + let mut stream = match monitor.create_orderbook_stream() { + Ok(stream) => stream, + Err(e) => { + error!(error = %e, "Failed to create orderbook stream"); + continue; + } + }; + + info!(market_count = markets.len(), "Monitoring orderbook"); + + let window_label = markets + .first() + .map(|m| format!("{}-updown-5m", symbol_short(&m.crypto_symbol).to_lowercase())) + .unwrap_or_else(|| "updown-5m".to_string()); + + dashboard.with_mut(|d| { + d.set_window(&window_label, 300); + d.set_connected(true); + d.push_event(format!("📡 Live — {} markets in {}", markets.len(), window_label)); + }); + + // Record current window timestamp for cycle switch and wind-down detection + use chrono::Utc; + use crate::market::discoverer::FIVE_MIN_SECS; + let current_window_timestamp = MarketDiscoverer::calculate_current_window_timestamp(Utc::now()); + let window_end = chrono::DateTime::from_timestamp(current_window_timestamp + FIVE_MIN_SECS, 0) + .unwrap_or_else(|| Utc::now()); + let mut wind_down_done = false; + + // Create market_id -> market info mapping + let market_map: HashMap = markets.iter() + .map(|m| (m.market_id, m)) + .collect(); + + // Create market mapping (condition_id -> (yes_token_id, no_token_id)) for position balance + let market_token_map: HashMap = markets.iter() + .map(|m| (m.market_id, (m.yes_token_id, m.no_token_id))) + .collect(); + + // Create position balance timer + let balance_interval = config.position_balance_interval_secs; + let mut balance_timer = if balance_interval > 0 { + let mut timer = tokio::time::interval(Duration::from_secs(balance_interval)); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + timer.tick().await; // Fire first tick immediately + Some(timer) + } else { + None + }; + + // Record last best-ask per market for direction (↑↓) calc (single HashMap read/write, no perf impact) + let last_prices: DashMap = DashMap::new(); + + // Monitor orderbook updates + loop { + // Wind-down check: run once when <= N minutes to window end (stay in loop until natural switch by new-window detection) + // Use second precision; num_minutes() truncation may miss in 5min windows + if config.wind_down_before_window_end_minutes > 0 && !wind_down_done { + let now = Utc::now(); + let seconds_until_end = (window_end - now).num_seconds(); + let threshold_seconds = config.wind_down_before_window_end_minutes as i64 * 60; + if seconds_until_end <= threshold_seconds { + info!("🛑 Wind-down triggered | {}s until window end", seconds_until_end); + wind_down_done = true; + wind_down_in_progress.store(true, Ordering::Relaxed); + + // Wind-down runs in separate task to avoid blocking orderbook; 30s between merges per market + let executor_wd = executor.clone(); + let config_wd = config.clone(); + let risk_manager_wd = _risk_manager.clone(); + let wind_down_flag = wind_down_in_progress.clone(); + tokio::spawn(async move { + const MERGE_INTERVAL: Duration = Duration::from_secs(30); + + // 1. Cancel all orders + if let Err(e) = executor_wd.cancel_all_orders().await { + warn!(error = %e, "Wind-down: failed to cancel all orders, continuing with Merge and sell"); + } else { + info!("✅ Wind-down: all orders cancelled"); + } + + // Wait 10s after cancel before Merge so recent fills can settle on-chain + const DELAY_AFTER_CANCEL: Duration = Duration::from_secs(10); + sleep(DELAY_AFTER_CANCEL).await; + + // 2. Merge both sides (30s between markets) and update exposure + let position_tracker = risk_manager_wd.position_tracker(); + let mut did_any_merge = false; + if let Some(proxy) = config_wd.proxy_address { + match get_positions().await { + Ok(positions) => { + let condition_ids = condition_ids_with_both_sides(&positions); + let merge_info = merge_info_with_both_sides(&positions); + let n = condition_ids.len(); + for (i, condition_id) in condition_ids.iter().enumerate() { + let asset_hint = merge_info + .get(condition_id) + .map(|(yes_token, no_token, _)| (*yes_token, *no_token)); + match merge::merge_max( + *condition_id, + proxy, + &config_wd.private_key, + None, + asset_hint, + ) + .await { + Ok(merge_result) => { + did_any_merge = true; + info!( + "✅ Wind-down: Merge done | condition_id={:#x} | tx={}", + condition_id, merge_result.tx_hash + ); + let chain_amt = raw_shares_to_decimal(merge_result.merged_amount); + if let Some((yes_token, no_token, merge_amt)) = merge_info.get(condition_id) { + let deduct = chain_amt.min(*merge_amt); + position_tracker.update_exposure_cost(*yes_token, dec!(0), -deduct); + position_tracker.update_exposure_cost(*no_token, dec!(0), -deduct); + position_tracker.update_position(*yes_token, -deduct); + position_tracker.update_position(*no_token, -deduct); + info!( + "💰 Wind-down: Merge deducted exposure | condition_id={:#x} | amount:{} (chain:{})", + condition_id, deduct, chain_amt + ); + } + } + Err(e) => { + warn!(condition_id = %condition_id, error = %e, "Wind-down: Merge failed"); + } + } + // Wait 30s after each market merge before next, for on-chain settlement + if i + 1 < n { + info!("Wind-down: waiting 30s before next merge"); + sleep(MERGE_INTERVAL).await; + } + } + } + Err(e) => { warn!(error = %e, "Wind-down: failed to get positions, skipping Merge"); } + } + } else { + warn!("Wind-down: POLYMARKET_PROXY_ADDRESS not set, skipping Merge"); + } + + // If merge ran, wait 30s before selling one-sided legs for on-chain; else no wait + if did_any_merge { + sleep(MERGE_INTERVAL).await; + } + + // 3. Market-sell remaining one-sided positions + let wind_down_sell_price = Decimal::try_from(config_wd.wind_down_sell_price).unwrap_or(dec!(0.01)); + match get_positions().await { + Ok(positions) => { + for pos in positions.iter().filter(|p| p.size > dec!(0)) { + let size_floor = (pos.size * dec!(100)).floor() / dec!(100); + if size_floor < dec!(0.01) { + debug!(token_id = %pos.asset, size = %pos.size, "Wind-down: position too small, skip sell"); + continue; + } + if let Err(e) = executor_wd.sell_at_price(pos.asset, wind_down_sell_price, size_floor).await { + warn!(token_id = %pos.asset, size = %pos.size, error = %e, "Wind-down: failed to sell one-sided leg"); + } else { + info!("✅ Wind-down: sell order placed | token_id={:#x} | amount:{} | price:{:.4}", pos.asset, size_floor, wind_down_sell_price); + } + } + } + Err(e) => { warn!(error = %e, "Wind-down: failed to get positions, skipping sell"); } + } + + info!("🛑 Wind-down complete, continuing to monitor until window end"); + wind_down_flag.store(false, Ordering::Relaxed); + }); + } + } + + tokio::select! { + // Handle orderbook updates + book_result = stream.next() => { + match book_result { + Some(Ok(book)) => { + // Process orderbook update (book will be moved) + if let Some(pair) = monitor.handle_book_update(book) { + // Note: asks last element is best ask + let yes_best_ask = pair.yes_book.asks.last().map(|a| (a.price, a.size)); + let no_best_ask = pair.no_book.asks.last().map(|a| (a.price, a.size)); + let total_ask_price = yes_best_ask.and_then(|(p, _)| no_best_ask.map(|(np, _)| p + np)); + + let market_id = pair.market_id; + // Compare with prev tick for direction (↑ up ↓ down − flat), no arrow on first + let (yes_dir, no_dir) = match (yes_best_ask, no_best_ask) { + (Some((yp, _)), Some((np, _))) => { + let prev = last_prices.get(&market_id).map(|r| (r.0, r.1)); + let (y_dir, n_dir) = prev + .map(|(ly, ln)| ( + if yp > ly { "↑" } else if yp < ly { "↓" } else { "−" }, + if np > ln { "↑" } else if np < ln { "↓" } else { "−" }, + )) + .unwrap_or(("", "")); + last_prices.insert(market_id, (yp, np)); + (y_dir, n_dir) + } + _ => ("", ""), + }; + + let market_info = market_map.get(&pair.market_id); + let market_title = market_info.map(|m| m.title.as_str()).unwrap_or("unknown market"); + let market_symbol = market_info.map(|m| m.crypto_symbol.as_str()).unwrap_or(""); + let market_display = if !market_symbol.is_empty() { + format!("{} prediction market", market_symbol) + } else { + market_title.to_string() + }; + + let (prefix, spread_info) = total_ask_price + .map(|t| { + if t < dec!(1.0) { + let profit_pct = (dec!(1.0) - t) * dec!(100.0); + ("🚨 Arbitrage", format!("total:{:.4} profit:{:.2}%", t, profit_pct)) + } else { + ("📊", format!("total:{:.4} (no arb)", t)) + } + }) + .unwrap_or_else(|| ("📊", "no data".to_string())); + + // Direction arrows shown only for arbitrage + let is_arbitrage = prefix == "🚨 Arbitrage"; + let yes_info = yes_best_ask + .map(|(p, s)| { + if is_arbitrage && !yes_dir.is_empty() { + format!("Yes:{:.4} size:{} {}", p, s, yes_dir) + } else { + format!("Yes:{:.4} size:{}", p, s) + } + }) + .unwrap_or_else(|| "Yes:none".to_string()); + let no_info = no_best_ask + .map(|(p, s)| { + if is_arbitrage && !no_dir.is_empty() { + format!("No:{:.4} size:{} {}", p, s, no_dir) + } else { + format!("No:{:.4} size:{}", p, s) + } + }) + .unwrap_or_else(|| "No:none".to_string()); + + if use_tui { + if let (Some((yp, _)), Some((np, _))) = (yes_best_ask, no_best_ask) { + let sym = symbol_short(market_symbol); + let is_arb = total_ask_price + .map(|t| t < dec!(1.0)) + .unwrap_or(false); + dashboard.with_mut(|d| { + d.update_market( + &sym, + decimal_to_f64(yp), + decimal_to_f64(np), + is_arb, + ); + }); + } + } else { + info!( + "{} {} | {} | {} | {}", + prefix, + market_display, + yes_info, + no_info, + spread_info + ); + } + + // Keep structured debug log (optional) + debug!( + market_id = %pair.market_id, + yes_token = %pair.yes_book.asset_id, + no_token = %pair.no_book.asset_id, + "Orderbook pair details" + ); + + // Detect arbitrage (monitoring: execute only when total <= 1 - execution spread) + use rust_decimal::Decimal; + let execution_threshold = dec!(1.0) - Decimal::try_from(config.arbitrage_execution_spread) + .unwrap_or(dec!(0.01)); + if let Some(total_price) = total_ask_price { + if total_price <= execution_threshold { + if let Some(opp) = _detector.check_arbitrage( + &pair.yes_book, + &pair.no_book, + &pair.market_id, + ) { + // Check YES price threshold + if config.min_yes_price_threshold > 0.0 { + use rust_decimal::Decimal; + let min_yes_price_decimal = Decimal::try_from(config.min_yes_price_threshold) + .unwrap_or(dec!(0.0)); + if opp.yes_ask_price < min_yes_price_decimal { + debug!( + "⏸️ YES price below threshold, skip arbitrage | market:{} | YES:{:.4} | threshold:{:.4}", + market_display, + opp.yes_ask_price, + config.min_yes_price_threshold + ); + continue; // Skip this arbitrage + } + } + + // Check NO price threshold + if config.min_no_price_threshold > 0.0 { + use rust_decimal::Decimal; + let min_no_price_decimal = Decimal::try_from(config.min_no_price_threshold) + .unwrap_or(dec!(0.0)); + if opp.no_ask_price < min_no_price_decimal { + debug!( + "⏸️ NO price below threshold, skip arbitrage | market:{} | NO:{:.4} | threshold:{:.4}", + market_display, + opp.no_ask_price, + config.min_no_price_threshold + ); + continue; // Skip this arbitrage + } + } + + // Check if near market end (if stop time configured) + // Second precision; num_minutes() may miss in 5min markets + if config.stop_arbitrage_before_end_minutes > 0 { + if let Some(market_info) = market_map.get(&pair.market_id) { + use chrono::Utc; + let now = Utc::now(); + let time_until_end = market_info.end_date.signed_duration_since(now); + let seconds_until_end = time_until_end.num_seconds(); + let threshold_seconds = config.stop_arbitrage_before_end_minutes as i64 * 60; + + if seconds_until_end <= threshold_seconds { + debug!( + "⏰ Near market end, skip arbitrage | market:{} | seconds to end:{} | stop threshold:{}min", + market_display, + seconds_until_end, + config.stop_arbitrage_before_end_minutes + ); + continue; // Skip this arbitrage + } + } + } + + // Calculate order cost (USD) + // Unit reference (per Polymarket CLOB spec): + // - opp.yes_size / opp.no_size → available SHARES (股数, from orderbook depth) + // - config.max_order_size_usdc → TOTAL COMBINED USD BUDGET (YES + NO 双侧合计预算,USDC) + // - price → USDC per share (0.00~1.00) + // Budget cap derivation: + // order_size × (yes_price + no_price) ≤ total_budget + // order_size ≤ total_budget / (yes_price + no_price) + use rust_decimal::Decimal; + let total_budget_usd = Decimal::try_from(config.max_order_size_usdc).unwrap_or(dec!(100.0)); + let total_price = opp.yes_ask_price + opp.no_ask_price; + let max_shares_by_budget = if total_price > dec!(0) { + total_budget_usd / total_price + } else { + dec!(0) + }; + let order_size = opp.yes_size.min(opp.no_size).min(max_shares_by_budget); + let yes_cost = opp.yes_ask_price * order_size; + let no_cost = opp.no_ask_price * order_size; + let total_cost = yes_cost + no_cost; + + // Check risk exposure limit + let position_tracker = _risk_manager.position_tracker(); + let current_exposure = position_tracker.calculate_exposure(); + + if position_tracker.would_exceed_limit(yes_cost, no_cost) { + warn!( + "�� ️ Risk exposure limit exceeded, skip arbitrage | market:{} | exposure:{:.2} USD | order cost:{:.2} USD | limit:{:.2} USD", + market_display, + current_exposure, + total_cost, + position_tracker.max_exposure() + ); + continue; // Skip this arbitrage + } + + // Check position balance (local cache, zero latency) + if position_balancer.should_skip_arbitrage(opp.yes_token_id, opp.no_token_id) { + warn!( + "�� ️ Positions severely imbalanced, skip arbitrage | market:{}", + market_display + ); + continue; // Skip this arbitrage + } + + // Check trade interval: min 3s between trades + { + let mut guard = last_trade_time.lock().await; + let now = Instant::now(); + if let Some(last) = *guard { + if now.saturating_duration_since(last) < MIN_TRADE_INTERVAL { + let elapsed = now.saturating_duration_since(last).as_secs_f32(); + debug!( + "⏱️ Trade interval < 3s, skip | market:{} | since last:{}s", + market_display, + elapsed + ); + continue; // Skip this arb + } + } + *guard = Some(now); + } + + // Polymarket CLOB requires each token leg to be >= $1.00 notional (separate, not combined) + // Skip locally before sending — saves API calls and avoids "Order size below min" rejections. + const MIN_CLOB_PER_TOKEN_NOTIONAL: Decimal = dec!(1.00); + if yes_cost < MIN_CLOB_PER_TOKEN_NOTIONAL || no_cost < MIN_CLOB_PER_TOKEN_NOTIONAL { + debug!( + "💸 Skip: notional below Polymarket $1.00/leg minimum | market:{} | YES cost:{} | NO cost:{}", + market_display, yes_cost, no_cost + ); + continue; + } + + let trade_sym = symbol_short(market_symbol); + let profit_usd = decimal_to_f64( + (dec!(1.0) - opp.yes_ask_price - opp.no_ask_price) + * order_size, + ); + if use_tui { + dashboard.with_mut(|d| { + d.set_exposure(decimal_to_f64(current_exposure)); + d.record_trade_attempt( + &trade_sym, + decimal_to_f64(opp.profit_percentage), + decimal_to_f64(order_size), + decimal_to_f64(total_cost), + ); + }); + } else { + info!( + "⚡ Execute arbitrage | market:{} | profit:{:.2}% | size:{} | cost:{:.2} USD | exposure:{:.2} USD", + market_display, + opp.profit_percentage, + order_size, + total_cost, + current_exposure + ); + } + + // 🔒 发现套利机会 → 先记录到审计日志(下单前落盘,便于事后追溯) + use crate::utils::arbitrage_logger::{log_arbitrage_opportunity_async, DEFAULT_ARBITRAGE_LOG}; + log_arbitrage_opportunity_async( + &opp, + &market_display, + DEFAULT_ARBITRAGE_LOG, + ).await; + + // Simplified exposure: add on arb execution regardless of fill + let _pt = _risk_manager.position_tracker(); + _pt.update_exposure_cost(opp.yes_token_id, opp.yes_ask_price, order_size); + _pt.update_exposure_cost(opp.no_token_id, opp.no_ask_price, order_size); + + // Arb execution: run whenever total <= threshold, direction only for slippage (down=second, up/flat=first) + // Clone vars for spawned task (direction used for slippage allocation) + let executor_clone = executor.clone(); + let risk_manager_clone = _risk_manager.clone(); + let opp_clone = opp.clone(); + let yes_dir_s = yes_dir.to_string(); + let no_dir_s = no_dir.to_string(); + let dashboard_trade = dashboard.clone(); + let trade_sym_spawn = trade_sym.clone(); + let order_size_clone = order_size; + + // Spawn async to avoid blocking orderbook updates + tokio::spawn(async move { + // Execute arbitrage (slippage: down=second, up/flat=first) + match executor_clone.execute_arbitrage_pair(&opp_clone, &yes_dir_s, &no_dir_s).await { + Ok(result) => { + if result.success { + dashboard_trade.with_mut(|d| { + d.record_trade_success( + &trade_sym_spawn, + profit_usd, + decimal_to_f64(opp_clone.profit_percentage), + ); + }); + } else { + // 🔴 No fill: roll back the exposure we reserved before dispatching + let pt = risk_manager_clone.position_tracker(); + pt.update_exposure_cost(opp_clone.yes_token_id, opp_clone.yes_ask_price, -order_size_clone); + pt.update_exposure_cost(opp_clone.no_token_id, opp_clone.no_ask_price, -order_size_clone); + warn!( + "♻️ Rolled back exposure (no fill) | market:{} | size:{}", + trade_sym_spawn, order_size_clone + ); + dashboard_trade.with_mut(|d| { + d.record_trade_failure( + &trade_sym_spawn, + "orders not filled", + ); + }); + } + + // Save pair_id first, result will be moved + let pair_id = result.pair_id.clone(); + + // Register with risk manager (with prices for exposure calc) + risk_manager_clone.register_order_pair( + result, + opp_clone.market_id, + opp_clone.yes_token_id, + opp_clone.no_token_id, + opp_clone.yes_ask_price, + opp_clone.no_ask_price, + ); + + // Handle risk recovery + // Hedge strategy disabled; no action on one-sided fills + match risk_manager_clone.handle_order_pair(&pair_id).await { + Ok(action) => { + // Hedge strategy off; no MonitorForExit/SellExcess + match action { + crate::risk::recovery::RecoveryAction::None => { + // Normal case, no action + } + crate::risk::recovery::RecoveryAction::MonitorForExit { .. } => { + info!("One-sided fill, hedge strategy disabled, no action"); + } + crate::risk::recovery::RecoveryAction::SellExcess { .. } => { + info!("Partial fill imbalance, hedge strategy disabled, no action"); + } + crate::risk::recovery::RecoveryAction::ManualIntervention { reason } => { + warn!("Manual intervention needed: {}", reason); + } + } + } + Err(e) => { + error!("Risk handling failed: {}", e); + } + } + } + Err(e) => { + // 🔴 Execution error: roll back exposure we reserved before dispatching + let pt = risk_manager_clone.position_tracker(); + pt.update_exposure_cost(opp_clone.yes_token_id, opp_clone.yes_ask_price, -order_size_clone); + pt.update_exposure_cost(opp_clone.no_token_id, opp_clone.no_ask_price, -order_size_clone); + warn!( + "♻️ Rolled back exposure (executor error) | market:{} | size:{}", + trade_sym_spawn, order_size_clone + ); + // Error details in executor; brief summary here + let error_msg = e.to_string(); + dashboard_trade.with_mut(|d| { + d.record_trade_failure(&trade_sym_spawn, &error_msg); + }); + // Extract simplified error + if error_msg.contains("Arbitrage failed") { + // Error already formatted + error!("{}", error_msg); + } else { + error!("Arbitrage execution failed: {}", error_msg); + } + } + } + }); + } + } + } + } + } + Some(Err(e)) => { + error!(error = %e, "Orderbook update error"); + // Stream error, recreate stream + break; + } + None => { + warn!("Orderbook stream ended, recreating"); + break; + } + } + } + + // Position balance task + _ = async { + if let Some(ref mut timer) = balance_timer { + timer.tick().await; + if let Err(e) = position_balancer.check_and_balance_positions(&market_token_map).await { + warn!(error = %e, "Position balance check failed"); + } + } else { + futures::future::pending::<()>().await; + } + } => { + // Balance task done + } + + // Periodic check: 1) new 5min window 2) wind-down trigger + _ = sleep(Duration::from_secs(1)) => { + if shutdown.load(Ordering::Relaxed) { + return Ok(()); + } + + let now = Utc::now(); + let seconds_until_end = + (window_end - now).num_seconds().max(0) as u32; + let exposure = _risk_manager.position_tracker().calculate_exposure(); + dashboard.with_mut(|d| { + d.set_window(&window_label, seconds_until_end); + d.set_exposure(decimal_to_f64(exposure)); + }); + + let new_window_timestamp = MarketDiscoverer::calculate_current_window_timestamp(now); + + // If window timestamp changed, we're in a new window + if new_window_timestamp != current_window_timestamp { + info!( + old_window = current_window_timestamp, + new_window = new_window_timestamp, + "New 5min window detected, cancelling old subscriptions and switching" + ); + // Drop stream to release monitor borrow, then clear old subs + drop(stream); + monitor.clear(); + break; + } + } + } + } + + // monitor is dropped at loop end, no manual cleanup + info!("Current window monitoring ended, refreshing markets for next round"); + } +} \ No newline at end of file diff --git a/src/market/discoverer.rs b/src/market/discoverer.rs new file mode 100644 index 0000000..62a0e27 --- /dev/null +++ b/src/market/discoverer.rs @@ -0,0 +1,139 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use polymarket_client_sdk::gamma::{Client, types::request::MarketsRequest}; +use polymarket_client_sdk::types::{B256, U256}; +use tracing::{info, warn}; + +/// 5-minute window duration in seconds (for main etc. to compute window_end) +pub const FIVE_MIN_SECS: i64 = 300; + +#[derive(Debug, Clone)] +pub struct MarketInfo { + pub market_id: B256, + #[allow(dead_code)] + pub slug: String, + pub yes_token_id: U256, + pub no_token_id: U256, + pub title: String, + pub end_date: DateTime, + pub crypto_symbol: String, +} + +pub struct MarketDiscoverer { + gamma_client: Client, + crypto_symbols: Vec, +} + +impl MarketDiscoverer { + pub fn new(crypto_symbols: Vec) -> Self { + Self { + gamma_client: Client::default(), + crypto_symbols, + } + } + + /// Current 5-minute window start timestamp (UTC) + /// Window aligned to 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55 + pub fn calculate_current_window_timestamp(now: DateTime) -> i64 { + let ts = now.timestamp(); + (ts / FIVE_MIN_SECS) * FIVE_MIN_SECS + } + + /// Next 5-minute window start timestamp (UTC) + pub fn calculate_next_window_timestamp(now: DateTime) -> i64 { + let ts = now.timestamp(); + ((ts / FIVE_MIN_SECS) + 1) * FIVE_MIN_SECS + } + + /// Generate market slugs, e.g. btc-updown-5m-1770972300 + pub fn generate_market_slugs(&self, timestamp: i64) -> Vec { + self.crypto_symbols + .iter() + .map(|symbol| format!("{}-updown-5m-{}", symbol, timestamp)) + .collect() + } + + /// Fetch 5-minute markets for given timestamp + pub async fn get_markets_for_timestamp(&self, timestamp: i64) -> Result> { + // Generate slugs for all crypto symbols + let slugs = self.generate_market_slugs(timestamp); + + info!(timestamp, slug_count = slugs.len(), "Querying markets"); + + // Batch query Gamma API + let request = MarketsRequest::builder() + .slug(slugs.clone()) + .build(); + + match self.gamma_client.markets(&request).await { + Ok(markets) => { + // Filter and parse markets + let valid_markets: Vec = markets + .into_iter() + .filter_map(|market| self.parse_market(market)) + .collect(); + + info!(count = valid_markets.len(), "Found valid markets"); + Ok(valid_markets) + } + Err(e) => { + warn!(error = %e, timestamp = timestamp, "Market query failed, markets may not exist yet"); + Ok(Vec::new()) + } + } + } + + /// Parse market, extract YES and NO token_ids + fn parse_market(&self, market: polymarket_client_sdk::gamma::types::response::Market) -> Option { + // Check market is active, orderbook enabled and accepting orders + if !market.active.unwrap_or(false) + || !market.enable_order_book.unwrap_or(false) + || !market.accepting_orders.unwrap_or(false) { + return None; + } + + // Check outcomes are ["Up", "Down"] + let outcomes = market.outcomes.as_ref()?; + + if outcomes.len() != 2 + || !outcomes.contains(&"Up".to_string()) + || !outcomes.contains(&"Down".to_string()) { + return None; + } + + // Get clobTokenIds + let token_ids = market.clob_token_ids.as_ref()?; + + if token_ids.len() != 2 { + return None; + } + + // First is Up token_id, second is Down + let yes_token_id = token_ids[0]; + let no_token_id = token_ids[1]; + + // Get conditionId + let market_id = market.condition_id?; + + // Extract crypto symbol from slug + let slug = market.slug.as_ref()?; + let crypto_symbol = slug + .split('-') + .next() + .unwrap_or("") + .to_string(); + + // Get endDate + let end_date = market.end_date?; + + Some(MarketInfo { + market_id, + slug: slug.clone(), + yes_token_id, + no_token_id, + title: market.question.unwrap_or_default(), + end_date, + crypto_symbol, + }) + } +} \ No newline at end of file diff --git a/src/market/mod.rs b/src/market/mod.rs new file mode 100644 index 0000000..eae327f --- /dev/null +++ b/src/market/mod.rs @@ -0,0 +1,5 @@ +pub mod discoverer; +pub mod scheduler; + +pub use discoverer::*; +pub use scheduler::*; diff --git a/src/market/scheduler.rs b/src/market/scheduler.rs new file mode 100644 index 0000000..1913729 --- /dev/null +++ b/src/market/scheduler.rs @@ -0,0 +1,117 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use std::time::Duration; +use tokio::time::sleep; +use tracing::{error, info, warn}; + +use super::discoverer::{MarketDiscoverer, MarketInfo}; + +pub struct MarketScheduler { + discoverer: MarketDiscoverer, + refresh_advance_secs: u64, +} + +impl MarketScheduler { + pub fn new(discoverer: MarketDiscoverer, refresh_advance_secs: u64) -> Self { + Self { + discoverer, + refresh_advance_secs, + } + } + + /// Calculate wait time until next 5-minute window + pub fn calculate_wait_time(&self, now: DateTime) -> Duration { + let next_window_ts = MarketDiscoverer::calculate_next_window_timestamp(now); + let next_window = DateTime::from_timestamp(next_window_ts, 0) + .expect("Invalid timestamp"); + + // Query a few seconds early so markets are created + let wait_duration = next_window + .signed_duration_since(now) + .to_std() + .unwrap_or(Duration::ZERO) + .saturating_sub(Duration::from_secs(self.refresh_advance_secs)); + + wait_duration.max(Duration::ZERO) + } + + /// Fetch markets for current window immediately, or wait for next on failure + pub async fn get_markets_immediately_or_wait(&self) -> Result> { + // Try to fetch current window markets first + let now = Utc::now(); + let current_timestamp = MarketDiscoverer::calculate_current_window_timestamp(now); + let next_timestamp = MarketDiscoverer::calculate_next_window_timestamp(now); + + // If current and next window same (shouldn't happen for 5m), use wait logic + if current_timestamp == next_timestamp { + return self.wait_for_next_window().await; + } + + info!("Fetching markets for current window"); + match self.discoverer.get_markets_for_timestamp(current_timestamp).await { + Ok(markets) => { + if !markets.is_empty() { + info!(count = markets.len(), "Found markets for current window"); + return Ok(markets); + } + // No markets: maybe not created yet; retry with short interval (5m markets usually ready in seconds) + // Calling wait_for_next_window would skip to next boundary and miss this window + const RETRY_SECS: u64 = 2; + const MAX_RETRY_SECS: u64 = 90; // Max retry ~90s + let mut elapsed = 0u64; + while elapsed < MAX_RETRY_SECS { + info!("Current window empty, retrying in {}s (waited {}s)", RETRY_SECS, elapsed); + sleep(Duration::from_secs(RETRY_SECS)).await; + elapsed += RETRY_SECS; + match self.discoverer.get_markets_for_timestamp(current_timestamp).await { + Ok(markets) if !markets.is_empty() => { + info!(count = markets.len(), "Retry succeeded, found markets"); + return Ok(markets); + } + _ => {} + } + } + // Retry timed out, wait for next window + warn!("No markets after {}s retry, waiting for next window", MAX_RETRY_SECS); + self.wait_for_next_window().await + } + Err(e) => { + warn!(error = %e, "Failed to fetch current window markets, waiting for next"); + self.wait_for_next_window().await + } + } + } + + /// Wait for next 5-minute window and fetch markets + pub async fn wait_for_next_window(&self) -> Result> { + loop { + let wait_time = self.calculate_wait_time(Utc::now()); + if wait_time > Duration::ZERO { + info!( + wait_secs = wait_time.as_secs(), + "Waiting for next 5-minute window" + ); + sleep(wait_time).await; + } + + // Query current window markets + let now = Utc::now(); + let timestamp = MarketDiscoverer::calculate_current_window_timestamp(now); + match self.discoverer.get_markets_for_timestamp(timestamp).await { + Ok(markets) => { + if !markets.is_empty() { + info!(count = markets.len(), "Found new markets"); + return Ok(markets); + } + // Markets not created yet, wait and retry + info!("Markets not created yet, waiting to retry..."); + sleep(Duration::from_secs(2)).await; + } + Err(e) => { + error!(error = %e, "Failed to fetch markets, retrying..."); + sleep(Duration::from_secs(2)).await; + } + } + } + } +} diff --git a/src/merge.rs b/src/merge.rs new file mode 100644 index 0000000..a9ddafe --- /dev/null +++ b/src/merge.rs @@ -0,0 +1,634 @@ +//! CTF Merge module: merge equal YES/NO tokens back to pUSD (V2) or USDC.e (legacy). +//! +//! Supports **Gnosis Safe** (execTransaction), **Magic/Email** (PROXY relayer), +//! and **V2 deposit wallet** (WALLET batch relayer when `SIGNATURE_TYPE=Poly1271`). +//! V2 默认经 CollateralAdapter merge 为 pUSD;USDC.e 抵押持仓 merge 后可自动 wrap 为 pUSD(`MERGE_WRAP_TO_PUSD`,默认开启)。 +//! 设 `MERGE_OUTPUT_TOKEN=USDC.e` 可走 legacy 路径。 +//! Merge amount is automatically `min(YES_balance, NO_balance)`. + +use std::env; + +use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::signers::local::LocalSigner; +use alloy::signers::Signer as _; +use anyhow::Result; +use polymarket_client_sdk::ctf::types::{CollectionIdRequest, MergePositionsRequest, PositionIdRequest}; +use polymarket_client_sdk::ctf::Client; +use polymarket_client_sdk::{contract_config, POLYGON}; +use std::str::FromStr as _; +use tracing::{info, warn}; + +use crate::adapter_auth::{ensure_adapter_approved, encode_set_approval_for_all, wait_relayer_tx}; +use crate::deposit_wallet_relay::relayer_execute_deposit_wallet_calldata; +use crate::pusd_wrap::{merge_wrap_to_pusd, wrap_usdce_to_pusd}; +use crate::wallet_kind::{classify_wallet, WalletKind}; +use crate::proxy_relay::{ + self, derive_proxy_wallet, relayer_execute_proxy_calldata, IGnosisSafe, CTF_COLLATERAL_ADAPTER, + CTF_POLYGON, PROXY_FACTORY, PROXY_MERGE_PUSD_GAS, PUSD_POLYGON, RELAYER_URL_DEFAULT, + RPC_URL_DEFAULT, USDC_POLYGON, +}; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IERC1155Balance { + function balanceOf(address account, uint256 id) external view returns (uint256); + } +} + +fn merge_to_pusd() -> bool { + match env::var("MERGE_OUTPUT_TOKEN") { + Ok(v) => { + let s = v.trim().to_lowercase(); + s != "usdc.e" && s != "usdc" + } + Err(_) => true, + } +} + +fn merge_collateral() -> Address { + if merge_to_pusd() { + PUSD_POLYGON + } else { + USDC_POLYGON + } +} + +fn merge_target_for_collateral(ctf: Address, collateral: Address) -> Address { + if collateral == PUSD_POLYGON && merge_to_pusd() { + CTF_COLLATERAL_ADAPTER + } else { + ctf + } +} + +fn merge_gas_for_collateral(collateral: Address) -> Option { + if collateral == PUSD_POLYGON && merge_to_pusd() { + Some(PROXY_MERGE_PUSD_GAS) + } else { + None + } +} + +struct ResolvedMerge { + collateral: Address, + yes_id: U256, + no_id: U256, + b_yes: U256, + b_no: U256, +} + +async fn binary_position_ids( + client: &Client, + condition_id: B256, + collateral: Address, +) -> Result<(U256, U256)> { + let req_col_yes = CollectionIdRequest::builder() + .parent_collection_id(B256::ZERO) + .condition_id(condition_id) + .index_set(U256::from(1)) + .build(); + let req_col_no = CollectionIdRequest::builder() + .parent_collection_id(B256::ZERO) + .condition_id(condition_id) + .index_set(U256::from(2)) + .build(); + let col_yes = client.collection_id(&req_col_yes).await?; + let col_no = client.collection_id(&req_col_no).await?; + let pos_yes = client + .position_id( + &PositionIdRequest::builder() + .collateral_token(collateral) + .collection_id(col_yes.collection_id) + .build(), + ) + .await?; + let pos_no = client + .position_id( + &PositionIdRequest::builder() + .collateral_token(collateral) + .collection_id(col_no.collection_id) + .build(), + ) + .await?; + Ok((pos_yes.position_id, pos_no.position_id)) +} + +async fn resolve_merge_balances( + client: &Client, + prov_read: &impl Provider, + wallet: Address, + condition_id: B256, + asset_hint: Option<(U256, U256)>, +) -> Result { + let collaterals = if merge_to_pusd() { + [PUSD_POLYGON, USDC_POLYGON] + } else { + [USDC_POLYGON, PUSD_POLYGON] + }; + + if let Some((yes_id, no_id)) = asset_hint { + let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?; + let b_no = erc1155_balance(prov_read, wallet, no_id).await?; + if b_yes > 0 && b_no > 0 { + for &collateral in &collaterals { + let (py, pn) = binary_position_ids(client, condition_id, collateral).await?; + if py == yes_id && pn == no_id { + return Ok(ResolvedMerge { + collateral, + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + warn!( + "API asset IDs have balance but don't match computed position IDs; using preferred collateral {:?}", + merge_collateral() + ); + return Ok(ResolvedMerge { + collateral: merge_collateral(), + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + + for &collateral in &collaterals { + let (yes_id, no_id) = binary_position_ids(client, condition_id, collateral).await?; + let b_yes = erc1155_balance(prov_read, wallet, yes_id).await?; + let b_no = erc1155_balance(prov_read, wallet, no_id).await?; + if b_yes > 0 && b_no > 0 { + if collateral == USDC_POLYGON && merge_to_pusd() && merge_wrap_to_pusd() { + info!("链上持仓为 USDC.e 抵押,merge 后将自动 wrap 为 pUSD"); + } else if collateral == USDC_POLYGON && merge_to_pusd() { + warn!("链上持仓为 USDC.e 抵押 outcome token,merge 产出 USDC.e(MERGE_WRAP_TO_PUSD=0)"); + } + return Ok(ResolvedMerge { + collateral, + yes_id, + no_id, + b_yes, + b_no, + }); + } + } + + let (pref_yes, pref_no) = binary_position_ids(client, condition_id, merge_collateral()).await?; + let pref_by = erc1155_balance(prov_read, wallet, pref_yes).await.unwrap_or(U256::ZERO); + let pref_bn = erc1155_balance(prov_read, wallet, pref_no).await.unwrap_or(U256::ZERO); + if let Some((yes_id, no_id)) = asset_hint { + let hint_yes = erc1155_balance(prov_read, wallet, yes_id) + .await + .unwrap_or(U256::ZERO); + let hint_no = erc1155_balance(prov_read, wallet, no_id) + .await + .unwrap_or(U256::ZERO); + anyhow::bail!( + "No mergeable shares: computed YES={} NO={} | API assets yes={} no={} (balances {}/{}) | wallet={:?}", + pref_by, + pref_bn, + yes_id, + no_id, + hint_yes, + hint_no, + wallet + ); + } + anyhow::bail!( + "No mergeable shares: YES={} NO={} (token ids {} / {}), wallet={:?}", + pref_by, + pref_bn, + pref_yes, + pref_no, + wallet + ); +} + +fn encode_merge_calldata(req: &MergePositionsRequest) -> Vec { + let sel = &keccak256(b"mergePositions(address,bytes32,bytes32,uint256[],uint256)")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(req.collateral_token.as_slice()); + out.extend_from_slice(req.parent_collection_id.as_slice()); + out.extend_from_slice(req.condition_id.as_slice()); + out.extend_from_slice(&U256::from(160u64).to_be_bytes::<32>()); + out.extend_from_slice(&req.amount.to_be_bytes::<32>()); + out.extend_from_slice(&U256::from(req.partition.len()).to_be_bytes::<32>()); + for p in &req.partition { + out.extend_from_slice(&p.to_be_bytes::<32>()); + } + out +} + +/// Result of a successful merge: on-chain tx hash and verified merged share amount (6-decimal raw). +#[derive(Debug, Clone)] +pub struct MergeResult { + pub tx_hash: String, + pub merged_amount: U256, +} + +async fn verify_merged( + provider: &P, + wallet: Address, + pos_yes: U256, + pos_no: U256, + before_yes: U256, + before_no: U256, + expected: U256, +) -> Result<()> { + let after_yes = erc1155_balance(provider, wallet, pos_yes).await?; + let after_no = erc1155_balance(provider, wallet, pos_no).await?; + let merged_yes = before_yes.saturating_sub(after_yes); + let merged_no = before_no.saturating_sub(after_no); + if merged_yes == U256::ZERO || merged_no == U256::ZERO { + anyhow::bail!( + "Merge 后 YES/NO 余额未减少 (YES before={} after={} | NO before={} after={}),链上 merge 可能失败", + before_yes, + after_yes, + before_no, + after_no + ); + } + let actual = merged_yes.min(merged_no); + if actual < expected { + warn!( + "Merge 实际数量 {} 小于预期 {},以链上为准", + actual, expected + ); + } + Ok(()) +} + +async fn erc1155_balance( + provider: &P, + account: Address, + token_id: U256, +) -> Result { + let erc1155 = IERC1155Balance::new(CTF_POLYGON, provider); + erc1155 + .balanceOf(account, token_id) + .call() + .await + .map_err(|e| anyhow::anyhow!("balanceOf failed: {}", e)) +} + +/// Shorten long 0x-prefixed hex for logs (reused for compatibility). +pub fn short_hex(s: &str) -> String { + proxy_relay::short_hex(s) +} + +async fn relayer_merge( + wallet_kind: WalletKind, + merge_calldata: &[u8], + merge_to: Address, + wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + gas_limit: Option, +) -> Result { + match wallet_kind { + WalletKind::DepositWallet => { + relayer_execute_deposit_wallet_calldata( + merge_calldata, + merge_to, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Merge positions", + ) + .await + } + WalletKind::MagicProxy => { + relayer_execute_proxy_calldata( + merge_calldata, + merge_to, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Merge positions", + gas_limit, + ) + .await + } + WalletKind::GnosisSafe => { + anyhow::bail!("relayer_merge called with GnosisSafe wallet kind"); + } + } +} + +async fn safe_exec_call( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + let nonce: U256 = safe + .nonce() + .call() + .await + .map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?; + let tx_hash_data = safe + .encodeTransactionData( + to, + U256::ZERO, + calldata.clone().into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + nonce, + ) + .call() + .await + .map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))? + .0; + let tx_hash = keccak256(tx_hash_data.as_ref()); + let sig = signer + .sign_hash(&tx_hash) + .await + .map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?; + let mut sig_bytes = sig.as_bytes().to_vec(); + if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) { + sig_bytes[64] += 27; + } + let pending = safe + .execTransaction( + to, + U256::ZERO, + calldata.into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + sig_bytes.into(), + ) + .send() + .await + .map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?; + let tx_hash_out = *pending.tx_hash(); + let receipt = pending + .get_receipt() + .await + .map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?; + if !receipt.status() { + anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out); + } + Ok(tx_hash_out) +} + +async fn maybe_wrap_merge_output( + provider: &P, + prov_read: &impl Provider, + wallet: Address, + collateral: Address, + merged_amount: U256, + wallet_kind: WalletKind, + safe: Option<&IGnosisSafe::IGnosisSafeInstance

>, + signer: &impl alloy::signers::Signer, + builder: Option<(&str, &str, &str)>, + relayer_url: &str, +) -> Result<()> { + if collateral != USDC_POLYGON || !merge_to_pusd() || !merge_wrap_to_pusd() { + return Ok(()); + } + let (bk, bs, bp) = match builder { + Some((k, s, p)) => (Some(k), Some(s), Some(p)), + None => (None, None, None), + }; + wrap_usdce_to_pusd( + provider, + prov_read, + wallet, + merged_amount, + wallet_kind, + safe, + signer, + bk, + bs, + bp, + relayer_url, + ) + .await +} + +/// Merge maximum available YES+NO to pUSD (default) or USDC.e for given `condition_id` on `wallet`. +/// Pass `asset_hint` (yes_asset, no_asset) from Data API when available for accurate on-chain lookup. +pub async fn merge_max( + condition_id: B256, + wallet: Address, + private_key: &str, + rpc_url: Option<&str>, + asset_hint: Option<(U256, U256)>, +) -> Result { + let rpc = rpc_url.unwrap_or(RPC_URL_DEFAULT); + let chain = POLYGON; + let signer = LocalSigner::from_str(private_key)?.with_chain_id(Some(chain)); + let eoa = signer.address(); + + let output = if merge_to_pusd() { "pUSD" } else { "USDC.e" }; + + let provider = ProviderBuilder::new().wallet(signer.clone()).connect(rpc).await?; + let client = Client::new(provider.clone(), chain)?; + let config = contract_config(chain, false).ok_or_else(|| anyhow::anyhow!("Unsupported chain_id: {}", chain))?; + let prov_read = ProviderBuilder::new().connect(rpc).await?; + let ctf = config.conditional_tokens; + + let resolved = resolve_merge_balances(&client, &prov_read, wallet, condition_id, asset_hint).await?; + let ResolvedMerge { + collateral, + yes_id: pos_yes_id, + no_id: pos_no_id, + b_yes, + b_no, + } = resolved; + + let merge_amount = b_yes.min(b_no); + if merge_amount == U256::ZERO { + anyhow::bail!("No mergeable shares: YES={} NO={}, at least one is 0.", b_yes, b_no); + } + info!( + "🔄 Merge amount: {} ({}) | wallet={:?} | collateral={:?} | target={}", + merge_amount, + merge_amount / U256::from(1_000_000), + wallet, + collateral, + output + ); + + let merge_req = MergePositionsRequest::for_binary_market(collateral, condition_id, merge_amount); + let merge_calldata = encode_merge_calldata(&merge_req); + let merge_to = merge_target_for_collateral(ctf, collateral); + let gas_limit = merge_gas_for_collateral(collateral); + + let code = provider.get_code_at(wallet).await.unwrap_or_default(); + let wallet_kind = classify_wallet(code.len()); + + match wallet_kind { + WalletKind::DepositWallet | WalletKind::MagicProxy => { + if matches!(wallet_kind, WalletKind::MagicProxy) { + let derived = derive_proxy_wallet(eoa, PROXY_FACTORY); + let try_anyway = env::var("MERGE_TRY_ANYWAY") + .map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true")) + .unwrap_or(false); + if derived != wallet { + if !try_anyway { + anyhow::bail!( + "POLYMARKET_PROXY_ADDRESS ({:?}) does not match ProxyFactory CREATE2 derive ({:?}). \ + Use Polymarket web merge or set MERGE_TRY_ANYWAY=1 to force.", + wallet, + derived + ); + } + warn!("MERGE_TRY_ANYWAY=1: derive != proxy, still sending Relayer request."); + } + } + let builder_key = env::var("POLY_BUILDER_API_KEY").ok(); + let builder_secret = env::var("POLY_BUILDER_SECRET").ok(); + let builder_passphrase = env::var("POLY_BUILDER_PASSPHRASE").ok(); + let relayer_url = + env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string()); + match ( + builder_key.as_deref(), + builder_secret.as_deref(), + builder_passphrase.as_deref(), + ) { + (Some(k), Some(s), Some(p)) => { + if collateral == PUSD_POLYGON && merge_to_pusd() { + ensure_adapter_approved( + &provider, + wallet, + CTF_COLLATERAL_ADAPTER, + &signer, + k, + s, + p, + &relayer_url, + ) + .await?; + } + let out = relayer_merge( + wallet_kind, + &merge_calldata, + merge_to, + wallet, + &signer, + k, + s, + p, + &relayer_url, + gas_limit, + ) + .await?; + wait_relayer_tx(&provider, &out).await?; + verify_merged( + &prov_read, + wallet, + pos_yes_id, + pos_no_id, + b_yes, + b_no, + merge_amount, + ) + .await?; + let after_yes = erc1155_balance(&prov_read, wallet, pos_yes_id).await?; + let after_no = erc1155_balance(&prov_read, wallet, pos_no_id).await?; + let merged_amount = b_yes + .saturating_sub(after_yes) + .min(b_no.saturating_sub(after_no)); + maybe_wrap_merge_output( + &provider, + &prov_read, + wallet, + collateral, + merged_amount, + wallet_kind, + None, + &signer, + Some((k, s, p)), + &relayer_url, + ) + .await?; + info!("✅ Relayer merge confirmed: {} | merged={}", out, merged_amount); + return Ok(MergeResult { + tx_hash: out, + merged_amount, + }); + } + _ => anyhow::bail!( + "Relayer merge requires POLY_BUILDER_API_KEY, POLY_BUILDER_SECRET, POLY_BUILDER_PASSPHRASE.", + ), + } + } + WalletKind::GnosisSafe => {} + } + + let safe = IGnosisSafe::new(wallet, provider.clone()); + if collateral == PUSD_POLYGON + && merge_to_pusd() + && !crate::adapter_auth::is_adapter_approved( + &provider, + CTF_POLYGON, + wallet, + CTF_COLLATERAL_ADAPTER, + ) + .await? + { + let approve_calldata = encode_set_approval_for_all(CTF_COLLATERAL_ADAPTER, true); + let tx = safe_exec_call(&safe, &signer, CTF_POLYGON, approve_calldata).await?; + info!("✅ Safe setApprovalForAll tx: {:#x}", tx); + } + + let tx_hash_out = safe_exec_call(&safe, &signer, merge_to, merge_calldata).await?; + verify_merged( + &prov_read, + wallet, + pos_yes_id, + pos_no_id, + b_yes, + b_no, + merge_amount, + ) + .await?; + let after_yes = erc1155_balance(&prov_read, wallet, pos_yes_id).await?; + let after_no = erc1155_balance(&prov_read, wallet, pos_no_id).await?; + let merged_amount = b_yes + .saturating_sub(after_yes) + .min(b_no.saturating_sub(after_no)); + let relayer_url = env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string()); + maybe_wrap_merge_output( + &provider, + &prov_read, + wallet, + collateral, + merged_amount, + wallet_kind, + Some(&safe), + &signer, + None, + &relayer_url, + ) + .await?; + info!("✅ Merge success (Safe) tx: {:#x} | merged={}", tx_hash_out, merged_amount); + Ok(MergeResult { + tx_hash: format!("{:#x}", tx_hash_out), + merged_amount, + }) +} diff --git a/src/monitor/arbitrage.rs b/src/monitor/arbitrage.rs new file mode 100644 index 0000000..97a92d1 --- /dev/null +++ b/src/monitor/arbitrage.rs @@ -0,0 +1,163 @@ +use polymarket_client_sdk::clob::ws::types::response::BookUpdate; +use polymarket_client_sdk::types::{B256, Decimal, U256}; +use rust_decimal_macros::dec; +use tracing::debug; + +#[derive(Debug, Clone)] +pub struct ArbitrageOpportunity { + pub market_id: B256, + pub yes_token_id: U256, + pub no_token_id: U256, + pub yes_ask_price: Decimal, + pub no_ask_price: Decimal, + pub total_cost: Decimal, + pub profit_percentage: Decimal, + pub yes_size: Decimal, + pub no_size: Decimal, +} + +pub struct ArbitrageDetector { + min_profit_threshold: Decimal, + max_depth: usize, // Max depth to probe + min_order_value_usd: Decimal, // Min order value (USD) +} + +impl ArbitrageDetector { + pub fn new(min_profit_threshold: f64) -> Self { + Self { + min_profit_threshold: Decimal::try_from(min_profit_threshold) + .unwrap_or(dec!(0.001)), + max_depth: 10, // Max 10 levels + min_order_value_usd: dec!(1.0), // Min $1 per order + } + } + + /// Select prices: probe up to `max_depth` levels. Returns (yes_ask, no_ask, size, profit_pct, total_price). + /// Executor will: compare prices, add slippage, create orders. + fn find_best_opportunity( + &self, + yes_book: &BookUpdate, + no_book: &BookUpdate, + ) -> Option<(Decimal, Decimal, Decimal, Decimal, Decimal)> { + // Probe at most self.max_depth levels, clamp to actual book depth + let depth = self.max_depth.min(yes_book.asks.len().min(no_book.asks.len())); + if depth == 0 { + return None; + } + + let mut best: Option<(Decimal, Decimal, Decimal, Decimal, Decimal)> = None; + + // asks last element is best ask (lowest sell); iterate from best to depth levels + for d in 0..depth { + let yes_level = yes_book.asks.iter().rev().nth(d)?; + let no_level = no_book.asks.iter().rev().nth(d)?; + + let yes_price = yes_level.price.round_dp(2); + let no_price = no_level.price.round_dp(2); + let total_price = yes_price + no_price; + + if total_price > dec!(1.0) { + continue; // This depth total > 1, try deeper (unlikely but safe) + } + + // Min profit check: use self.min_profit_threshold (from .env config) + let profit_pct = (dec!(1.0) - total_price) * dec!(100.0); + if profit_pct < self.min_profit_threshold { + continue; + } + + let raw_size = yes_level.size.min(no_level.size); + let final_size = if raw_size.is_zero() { + dec!(0.01) + } else { + (raw_size * dec!(100.0)).floor() / dec!(100.0) + }; + + let yes_order_value = yes_price * final_size; + let no_order_value = no_price * final_size; + if yes_order_value < self.min_order_value_usd || no_order_value < self.min_order_value_usd { + continue; + } + + best = Some((yes_price, no_price, final_size, profit_pct, total_price)); + break; // Found valid at shallowest depth, no need to go deeper (worse price) + } + + best + } + + + /// Print order depth (debug level to avoid log spam) + fn print_orderbook_depth( + &self, + yes_book: &BookUpdate, + no_book: &BookUpdate, + yes_final_price: Decimal, + no_final_price: Decimal, + _yes_final_size: Decimal, + _no_final_size: Decimal, + ) { + let yes_asks = &yes_book.asks; + let yes_depth_str: Vec = yes_asks + .iter() + .rev() + .take(5) + .map(|level| { + let m = if (level.price - yes_final_price).abs() < dec!(0.001) { "←" } else { "" }; + format!("{:.2}@{:.2}{}", level.price, level.size, m) + }) + .collect(); + let no_asks = &no_book.asks; + let no_depth_str: Vec = no_asks + .iter() + .rev() + .take(5) + .map(|level| { + let m = if (level.price - no_final_price).abs() < dec!(0.001) { "←" } else { "" }; + format!("{:.2}@{:.2}{}", level.price, level.size, m) + }) + .collect(); + debug!( + yes_depth = yes_depth_str.join(", "), + no_depth = no_depth_str.join(", "), + "Order depth" + ); + // Level selection log moved to executor; prints slippage-adjusted prices + } + + /// Check if orderbook has arbitrage opportunity + pub fn check_arbitrage( + &self, + yes_book: &BookUpdate, + no_book: &BookUpdate, + market_id: &B256, + ) -> Option { + // Select best ask first; executor: compare prices, add slippage, create orders + let (yes_ask, no_ask, final_size, net_profit_pct, total_price) = + self.find_best_opportunity(yes_book, no_book)?; + + self.print_orderbook_depth(yes_book, no_book, yes_ask, no_ask, final_size, final_size); + + debug!( + market_id = %market_id, + yes_price = %yes_ask, + no_price = %no_ask, + total_price = %total_price, + net_profit_pct = %net_profit_pct, + order_size = %final_size, + "Arbitrage opportunity (best ask)" + ); + + Some(ArbitrageOpportunity { + market_id: *market_id, + yes_token_id: yes_book.asset_id, + no_token_id: no_book.asset_id, + yes_ask_price: yes_ask, + no_ask_price: no_ask, + total_cost: total_price * final_size, + profit_percentage: net_profit_pct, + yes_size: final_size, + no_size: final_size, + }) + } +} \ No newline at end of file diff --git a/src/monitor/mod.rs b/src/monitor/mod.rs new file mode 100644 index 0000000..b4eb139 --- /dev/null +++ b/src/monitor/mod.rs @@ -0,0 +1,5 @@ +pub mod arbitrage; +pub mod orderbook; + +pub use arbitrage::*; +pub use orderbook::*; diff --git a/src/monitor/orderbook.rs b/src/monitor/orderbook.rs new file mode 100644 index 0000000..8c9ab70 --- /dev/null +++ b/src/monitor/orderbook.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use dashmap::DashMap; +use futures::Stream; +use futures::StreamExt; +use polymarket_client_sdk::clob::ws::{Client as WsClient, types::response::BookUpdate}; +use polymarket_client_sdk::types::{B256, U256}; +use std::collections::HashMap; +use std::pin::Pin; +use tracing::{debug, info}; + +use crate::market::MarketInfo; + +/// Shorten B256 for logs: 0x + first 8 hex, e.g. 0xb91126b7.. +#[inline] +fn short_b256(b: &B256) -> String { + let s = format!("{b}"); + if s.len() > 12 { format!("{}..", &s[..10]) } else { s } +} + +/// Shorten U256 for logs: last 8 digits, e.g. ..67033653 +#[inline] +fn short_u256(u: &U256) -> String { + let s = format!("{u}"); + if s.len() > 12 { + format!("..{}", &s[s.len().saturating_sub(8)..]) + } else { + s + } +} + +pub struct OrderBookMonitor { + ws_client: WsClient, + books: DashMap, + market_map: HashMap, // market_id -> (yes_token_id, no_token_id) +} + +pub struct OrderBookPair { + pub yes_book: BookUpdate, + pub no_book: BookUpdate, + pub market_id: B256, +} + +impl OrderBookMonitor { + pub fn new() -> Self { + Self { + // Use unauthenticated client: orderbook is public, no auth needed + // Only user data (orders, trades) requires auth + ws_client: WsClient::default(), + books: DashMap::new(), + market_map: HashMap::new(), + } + } + + /// Subscribe to new market + pub fn subscribe_market(&mut self, market: &MarketInfo) -> Result<()> { + // Record market mapping + self.market_map.insert( + market.market_id, + (market.yes_token_id, market.no_token_id), + ); + + info!( + market_id = short_b256(&market.market_id), + yes = short_u256(&market.yes_token_id), + no = short_u256(&market.no_token_id), + "Subscribe to market orderbook" + ); + + Ok(()) + } + + /// Create orderbook subscription stream + /// + /// Note: Orderbook uses unauthenticated WebSocket; orderbook data is public. + /// Only user data (order status, trade history) needs auth. + pub fn create_orderbook_stream( + &self, + ) -> Result> + Send + '_>>> { + // Collect all token_ids to subscribe + let token_ids: Vec = self + .market_map + .values() + .flat_map(|(yes, no)| [*yes, *no]) + .collect(); + + if token_ids.is_empty() { + return Err(anyhow::anyhow!("No markets to subscribe")); + } + + info!(token_count = token_ids.len(), "Creating orderbook stream (unauthenticated)"); + + // subscribe_orderbook does not need auth + let stream = self.ws_client.subscribe_orderbook(token_ids)?; + // Convert SDK Error to anyhow::Error + let stream = stream.map(|result| result.map_err(|e| anyhow::anyhow!("{}", e))); + Ok(Box::pin(stream)) + } + + /// Handle orderbook update + pub fn handle_book_update(&self, book: BookUpdate) -> Option { + + // Print top 5 bid/ask (debug) + if !book.bids.is_empty() { + let top_bids: Vec = book.bids.iter() + .take(5) + .map(|b| format!("{}@{}", b.size, b.price)) + .collect(); + debug!( + asset_id = %book.asset_id, + "Top 5 bids: {}", + top_bids.join(", ") + ); + } + if !book.asks.is_empty() { + let top_asks: Vec = book.asks.iter() + .take(5) + .map(|a| format!("{}@{}", a.size, a.price)) + .collect(); + debug!( + asset_id = short_u256(&book.asset_id), + "Top 5 asks: {}", + top_asks.join(", ") + ); + } + + // Update orderbook cache + self.books.insert(book.asset_id, book.clone()); + + // Find which market this token belongs to; either side update returns OrderBookPair for arbitrage + for (market_id, (yes_token, no_token)) in &self.market_map { + if book.asset_id == *yes_token { + if let Some(no_book) = self.books.get(no_token) { + return Some(OrderBookPair { + yes_book: book.clone(), + no_book: no_book.clone(), + market_id: *market_id, + }); + } + } else if book.asset_id == *no_token { + if let Some(yes_book) = self.books.get(yes_token) { + return Some(OrderBookPair { + yes_book: yes_book.clone(), + no_book: book.clone(), + market_id: *market_id, + }); + } + } + } + + None + } + + /// Get orderbook if present + #[allow(dead_code)] + pub fn get_book(&self, token_id: U256) -> Option { + self.books.get(&token_id).map(|b| b.clone()) + } + + /// Clear all subscriptions + pub fn clear(&mut self) { + self.books.clear(); + self.market_map.clear(); + } +} \ No newline at end of file diff --git a/src/positions.rs b/src/positions.rs new file mode 100644 index 0000000..93da6f9 --- /dev/null +++ b/src/positions.rs @@ -0,0 +1,45 @@ +//! Fetch user positions (Data API) + +use anyhow::{Context, Result}; +use polymarket_client_sdk::data::types::request::PositionsRequest; +use polymarket_client_sdk::data::Client; +use polymarket_client_sdk::types::Address; + +/// Position structure from Data API, re-exported for callers +pub use polymarket_client_sdk::data::types::response::Position; + +/// Read user address from `POLYMARKET_PROXY_ADDRESS`, call Data API for current open positions. +/// +/// # Environment variables +/// +/// - `POLYMARKET_PROXY_ADDRESS`: Required, Polymarket proxy wallet address (or EOA) +/// +/// # Errors +/// +/// - `POLYMARKET_PROXY_ADDRESS` not set +/// - Invalid address format +/// - Data API call failed +/// +/// # Example +/// +/// ```ignore +/// use polypulse::positions::{get_positions, Position}; +/// +/// let positions = get_positions().await?; +/// for p in positions { +/// println!("{}: {} @ {}", p.title, p.size, p.cur_price); +/// } +/// ``` +pub async fn get_positions() -> Result> { + dotenvy::dotenv().ok(); + let addr = std::env::var("POLYMARKET_PROXY_ADDRESS") + .context("POLYMARKET_PROXY_ADDRESS not set")? + .trim() + .to_string(); + let user: Address = addr + .parse() + .context("POLYMARKET_PROXY_ADDRESS invalid format")?; + let client = Client::default(); + let req = PositionsRequest::builder().user(user).build(); + client.positions(&req).await.context("Failed to fetch positions") +} diff --git a/src/proxy_relay.rs b/src/proxy_relay.rs new file mode 100644 index 0000000..670d8fb --- /dev/null +++ b/src/proxy_relay.rs @@ -0,0 +1,387 @@ +//! Proxy wallet + Relayer/Safe shared infrastructure for merge, redeem, withdraw. +//! +//! Relayer requests (/relay-payload, /submit), proxy call encoding, Gnosis Safe interface and signing. + +use std::env; + +use alloy::primitives::{keccak256, Address, B256, Bytes, U256}; +use alloy::sol_types::SolCall; +use anyhow::Result; +use tracing::info; + +use polymarket_client_sdk::types::address; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IGnosisSafe { + function nonce() external view returns (uint256); + function encodeTransactionData( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes memory); + function execTransaction( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + bytes memory signatures + ) external payable returns (bool success); + } +} + +sol! { + struct ProxyCallTuple { + uint8 typeCode; + address to; + uint256 value; + bytes data; + } + function proxy(ProxyCallTuple[] calls) external payable returns (bytes[] returnValues); +} + +pub const RPC_URL_DEFAULT: &str = "https://polygon-bor-rpc.publicnode.com"; +pub const RELAYER_URL_DEFAULT: &str = "https://relayer-v2.polymarket.com"; +/// USDC.e(bridged),V1 及旧持仓抵押品 +pub const USDC_POLYGON: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); +/// pUSD(Polymarket USD),V2 抵押品 +pub const PUSD_POLYGON: Address = address!("0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"); +/// CTF(Conditional Tokens Framework) +pub const CTF_POLYGON: Address = address!("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"); +/// NegRisk 适配器(V1 赎回 USDC.e) +pub const NEG_RISK_ADAPTER: Address = address!("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"); +/// V2 标准市场抵押品适配器(赎回/merge 为 pUSD) +pub const CTF_COLLATERAL_ADAPTER: Address = address!("0xAdA100Db00Ca00073811820692005400218FcE1f"); +/// V2 Collateral Onramp:USDC.e → pUSD wrap +pub const COLLATERAL_ONRAMP: Address = address!("0x93070a847efEf7F70739046A929D47a521F5B8ee"); +/// V2 NegRisk 抵押品适配器(赎回为 pUSD) +pub const NEG_RISK_COLLATERAL_ADAPTER: Address = address!("0xadA2005600Dec949baf300f4C6120000bDB6eAab"); + +const RELAYER_GET_RELAY_PAYLOAD: &str = "/relay-payload"; +const RELAYER_SUBMIT: &str = "/submit"; + +pub const PROXY_FACTORY: Address = address!("0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"); +const RELAY_HUB: Address = address!("0xD216153c06E857cD7f72665E0aF1d7D82172F494"); +const PROXY_INIT_CODE_HASH: [u8; 32] = [ + 0xd2, 0x1d, 0xf8, 0xdc, 0x65, 0x88, 0x0a, 0x86, 0x06, 0xf0, 0x9f, 0xe0, 0xce, 0x3d, 0xf9, 0xb8, + 0x86, 0x92, 0x87, 0xab, 0x0b, 0x05, 0x8b, 0xe0, 0x5a, 0xa9, 0xe8, 0xaf, 0x63, 0x30, 0xa0, 0x0b, +]; +pub const PROXY_DEFAULT_GAS: u64 = 160_000; +/// pUSD CollateralAdapter redeem 实测约需 370k+ gas +pub const PROXY_REDEEM_PUSD_GAS: u64 = 450_000; +/// legacy CTF redeem 实测约需 166k gas +pub const PROXY_REDEEM_LEGACY_GAS: u64 = 220_000; +/// pUSD CollateralAdapter merge 实测约需 370k+ gas +pub const PROXY_MERGE_PUSD_GAS: u64 = 450_000; + +/// Shorten long 0x-prefixed hex to `0x` + first 8 + `..` + last 6 for logs. +pub fn short_hex(s: &str) -> String { + let hex = s.strip_prefix("0x").unwrap_or(s); + if hex.len() > 14 { + let lo = hex.len().saturating_sub(6); + format!("0x{}..{}", &hex[..8.min(hex.len())], &hex[lo..]) + } else { + format!("0x{}", hex) + } +} + +use base64::Engine; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +type HmacSha256 = Hmac; + +pub(crate) fn derive_proxy_wallet(eoa: Address, proxy_factory: Address) -> Address { + let salt = keccak256(eoa.as_slice()); + let mut buf = [0u8; 1 + 20 + 32 + 32]; + buf[0] = 0xff; + buf[1..21].copy_from_slice(proxy_factory.as_slice()); + buf[21..53].copy_from_slice(salt.as_slice()); + buf[53..85].copy_from_slice(&PROXY_INIT_CODE_HASH); + let h = keccak256(buf); + Address::from_slice(&h.as_slice()[12..32]) +} + +pub(crate) fn to_hex_0x(b: &[u8]) -> String { + const HEX: &[u8] = b"0123456789abcdef"; + let mut s = String::with_capacity(2 + b.len() * 2); + s.push_str("0x"); + for &x in b { + s.push(HEX[(x >> 4) as usize] as char); + s.push(HEX[(x & 0xf) as usize] as char); + } + s +} + +fn build_hmac_signature(secret: &[u8], timestamp: u64, method: &str, path: &str, body: &str) -> String { + let msg = format!("{}{}{}{}", timestamp, method, path, body); + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key"); + mac.update(msg.as_bytes()); + let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()); + sig.replace('+', "-").replace('/', "_") +} + +pub(crate) async fn get_relay_payload(client: &reqwest::Client, base: &str, eoa: Address) -> Result<(Address, String)> { + let url = format!("{}{}", base.trim_end_matches('/'), RELAYER_GET_RELAY_PAYLOAD); + let resp = client + .get(&url) + .query(&[("address", format!("{:#x}", eoa)), ("type", "PROXY".to_string())]) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + anyhow::bail!("GET /relay-payload failed status={} body={}", status, text); + } + let j: serde_json::Value = serde_json::from_str(&text)?; + let addr = j.get("address").and_then(|v| v.as_str()).ok_or_else(|| anyhow::anyhow!("relay-payload missing address"))?; + let nonce = j + .get("nonce") + .map(|v| { + v.as_str() + .map(String::from) + .or_else(|| v.as_u64().map(|n| n.to_string())) + .unwrap_or_else(|| "0".into()) + }) + .unwrap_or_else(|| "0".into()); + let relay = addr.trim().parse::

().map_err(|e| anyhow::anyhow!("Failed to parse relay address: {}", e))?; + Ok((relay, nonce.to_string())) +} + +#[allow(dead_code)] +pub(crate) fn encode_proxy_call(target: Address, data: &[u8]) -> Vec { + encode_proxy_calls(&[(target, data)]) +} + +pub(crate) fn encode_proxy_calls(calls: &[(Address, &[u8])]) -> Vec { + let tuples: Vec = calls + .iter() + .map(|(to, data)| ProxyCallTuple { + typeCode: 1u8, + to: *to, + value: U256::ZERO, + data: Bytes::from(data.to_vec()), + }) + .collect(); + proxyCall { calls: tuples }.abi_encode().to_vec() +} + +pub(crate) fn create_struct_hash( + from: Address, + to: Address, + data: &[u8], + tx_fee: u64, + gas_price: u64, + gas_limit: u64, + nonce: &str, + relay_hub: Address, + relay: Address, +) -> B256 { + let mut buf = Vec::new(); + buf.extend_from_slice(b"rlx:"); + buf.extend_from_slice(from.as_slice()); + buf.extend_from_slice(to.as_slice()); + buf.extend_from_slice(data); + buf.extend_from_slice(&U256::from(tx_fee).to_be_bytes::<32>()); + buf.extend_from_slice(&U256::from(gas_price).to_be_bytes::<32>()); + buf.extend_from_slice(&U256::from(gas_limit).to_be_bytes::<32>()); + let n: u64 = nonce.parse().unwrap_or(0); + buf.extend_from_slice(&U256::from(n).to_be_bytes::<32>()); + buf.extend_from_slice(relay_hub.as_slice()); + buf.extend_from_slice(relay.as_slice()); + keccak256(buf) +} + +pub(crate) fn eip191_hash(struct_hash: B256) -> B256 { + let mut msg = b"\x19Ethereum Signed Message:\n32".to_vec(); + msg.extend_from_slice(struct_hash.as_slice()); + keccak256(msg) +} + +/// Execute one or more proxy calls via Relayer (gasless). Called by merge/redeem/withdraw. +pub(crate) async fn relayer_execute_proxy_calldata( + calldata: &[u8], + target_address: Address, + proxy_wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + metadata: &str, + gas_limit: Option, +) -> Result { + relayer_execute_proxy_calls( + &[(target_address, calldata.to_vec())], + proxy_wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + metadata, + gas_limit, + ) + .await +} + +pub(crate) async fn relayer_execute_proxy_calls( + calls: &[(Address, Vec)], + proxy_wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + metadata: &str, + gas_limit: Option, +) -> Result { + if calls.is_empty() { + anyhow::bail!("relayer_execute_proxy_calls: empty calls"); + } + let client = reqwest::Client::new(); + let eoa = signer.address(); + let base = relayer_url.trim_end_matches('/'); + + let (relay, nonce) = get_relay_payload(&client, base, eoa).await?; + let call_refs: Vec<(Address, &[u8])> = calls.iter().map(|(a, d)| (*a, d.as_slice())).collect(); + let proxy_data = encode_proxy_calls(&call_refs); + let base_gas = gas_limit.unwrap_or_else(|| { + env::var("MERGE_PROXY_GAS_LIMIT") + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(PROXY_DEFAULT_GAS) + }); + let gas_limit = base_gas.saturating_mul(calls.len() as u64).max(base_gas); + + if env::var("MERGE_PROXY_TO").map(|s| s.trim().eq_ignore_ascii_case("PROXY_WALLET")).unwrap_or(false) { + info!("ℹ️ MERGE_PROXY_TO=PROXY_WALLET ignored, using to=PROXY_FACTORY"); + } + let to = PROXY_FACTORY; + let struct_hash = create_struct_hash(eoa, to, &proxy_data, 0, 0, gas_limit, &nonce, RELAY_HUB, relay); + let to_sign = eip191_hash(struct_hash); + let sig = signer.sign_hash(&to_sign).await.map_err(|e| anyhow::anyhow!("EOA signing failed: {}", e))?; + let mut sig_bytes = sig.as_bytes().to_vec(); + if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) { + sig_bytes[64] += 27; + } + let signature_hex = to_hex_0x(&sig_bytes); + + let signature_params = serde_json::json!({ + "gasPrice": "0", + "gasLimit": gas_limit.to_string(), + "relayerFee": "0", + "relayHub": format!("{:#x}", RELAY_HUB), + "relay": format!("{:#x}", relay) + }); + let body = serde_json::json!({ + "from": format!("{:#x}", eoa), + "to": format!("{:#x}", to), + "proxyWallet": format!("{:#x}", proxy_wallet), + "data": to_hex_0x(&proxy_data), + "nonce": nonce, + "signature": signature_hex, + "signatureParams": signature_params, + "type": "PROXY", + "metadata": metadata + }); + let body_str = serde_json::to_string(&body)?; + + let path = RELAYER_SUBMIT; + let method = "POST"; + let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as u64; + let secret_b64 = builder_secret + .trim() + .replace('-', "+") + .replace('_', "/"); + let secret_bytes = base64::engine::general_purpose::STANDARD + .decode(&secret_b64) + .map_err(|e| anyhow::anyhow!("POLY_BUILDER_SECRET base64 decode failed: {}", e))?; + let sig_hmac = build_hmac_signature(&secret_bytes, timestamp, method, path, &body_str); + + let url = format!("{}{}", base, path); + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .header("POLY_BUILDER_API_KEY", builder_key.trim()) + .header("POLY_BUILDER_TIMESTAMP", timestamp.to_string()) + .header("POLY_BUILDER_PASSPHRASE", builder_passphrase.trim()) + .header("POLY_BUILDER_SIGNATURE", sig_hmac) + .body(body_str) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + anyhow::bail!("Relayer request failed status={} body={}", status, text); + } + parse_relayer_tx_hash(&text) +} + +pub(crate) fn parse_relayer_tx_hash(text: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(text)?; + let hash = json + .get("transactionHash") + .or_else(|| json.get("transaction_hash")) + .and_then(|v| v.as_str()) + .map(String::from); + Ok(hash.unwrap_or_else(|| text.to_string())) +} + +/// Submit a pre-built relayer JSON body with builder HMAC auth. +pub(crate) async fn relayer_submit_authed( + body: serde_json::Value, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result { + let client = reqwest::Client::new(); + let base = relayer_url.trim_end_matches('/'); + let path = RELAYER_SUBMIT; + let method = "POST"; + let body_str = serde_json::to_string(&body)?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let secret_b64 = builder_secret + .trim() + .replace('-', "+") + .replace('_', "/"); + let secret_bytes = base64::engine::general_purpose::STANDARD + .decode(&secret_b64) + .map_err(|e| anyhow::anyhow!("POLY_BUILDER_SECRET base64 decode failed: {}", e))?; + let sig_hmac = build_hmac_signature(&secret_bytes, timestamp, method, path, &body_str); + + let url = format!("{}{}", base, path); + let resp = client + .post(&url) + .header("Content-Type", "application/json") + .header("POLY_BUILDER_API_KEY", builder_key.trim()) + .header("POLY_BUILDER_TIMESTAMP", timestamp.to_string()) + .header("POLY_BUILDER_PASSPHRASE", builder_passphrase.trim()) + .header("POLY_BUILDER_SIGNATURE", sig_hmac) + .body(body_str) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + if !status.is_success() { + anyhow::bail!("Relayer request failed status={} body={}", status, text); + } + parse_relayer_tx_hash(&text) +} + +// IGnosisSafe from sol! above, used by merge/redeem/withdraw via crate::proxy_relay::IGnosisSafe \ No newline at end of file diff --git a/src/pusd_wrap.rs b/src/pusd_wrap.rs new file mode 100644 index 0000000..e8da738 --- /dev/null +++ b/src/pusd_wrap.rs @@ -0,0 +1,278 @@ +//! Wrap USDC.e → pUSD via Collateral Onramp after legacy-collateral merge. + +use std::env; + +use alloy::primitives::{keccak256, Address, U256}; +use alloy::providers::Provider; +use anyhow::Result; +use tracing::info; + +use crate::deposit_wallet_relay::relayer_execute_deposit_wallet_calls; +use crate::wallet_kind::WalletKind; +use crate::proxy_relay::{ + relayer_execute_proxy_calls, IGnosisSafe, COLLATERAL_ONRAMP, PUSD_POLYGON, USDC_POLYGON, +}; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + } +} + +/// True when merge output should be wrapped to pUSD (default on when `MERGE_OUTPUT_TOKEN=pUSD`). +pub fn merge_wrap_to_pusd() -> bool { + match env::var("MERGE_WRAP_TO_PUSD") { + Ok(v) => { + let s = v.trim().to_lowercase(); + s != "0" && s != "false" && s != "no" && s != "off" + } + Err(_) => true, + } +} + +pub fn encode_erc20_approve(spender: Address, amount: U256) -> Vec { + let sel = &keccak256(b"approve(address,uint256)")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(spender.as_slice()); + out.extend_from_slice(&amount.to_be_bytes::<32>()); + out +} + +pub fn encode_onramp_wrap(recipient: Address, amount: U256) -> Vec { + let sel = &keccak256(b"wrap(address,address,uint256)")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(USDC_POLYGON.as_slice()); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(recipient.as_slice()); + out.extend_from_slice(&amount.to_be_bytes::<32>()); + out +} + +async fn erc20_balance(provider: &P, owner: Address, token: Address) -> Result { + IERC20::new(token, provider) + .balanceOf(owner) + .call() + .await + .map_err(|e| anyhow::anyhow!("ERC20 balanceOf failed: {}", e)) +} + +async fn erc20_allowance( + provider: &P, + owner: Address, + token: Address, + spender: Address, +) -> Result { + IERC20::new(token, provider) + .allowance(owner, spender) + .call() + .await + .map_err(|e| anyhow::anyhow!("ERC20 allowance failed: {}", e)) +} + +async fn safe_exec_call( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + use alloy::primitives::keccak256; + let nonce: U256 = safe + .nonce() + .call() + .await + .map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?; + let tx_hash_data = safe + .encodeTransactionData( + to, + U256::ZERO, + calldata.clone().into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + nonce, + ) + .call() + .await + .map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))? + .0; + let tx_hash = keccak256(tx_hash_data.as_ref()); + let sig = signer + .sign_hash(&tx_hash) + .await + .map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?; + let mut sig_bytes = sig.as_bytes().to_vec(); + if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) { + sig_bytes[64] += 27; + } + let pending = safe + .execTransaction( + to, + U256::ZERO, + calldata.into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + sig_bytes.into(), + ) + .send() + .await + .map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?; + let tx_hash_out = *pending.tx_hash(); + let receipt = pending + .get_receipt() + .await + .map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?; + if !receipt.status() { + anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out); + } + Ok(tx_hash_out) +} + +async fn relayer_wrap_calls( + wallet_kind: WalletKind, + wallet: Address, + calls: Vec<(Address, Vec)>, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, +) -> Result { + match wallet_kind { + WalletKind::DepositWallet => { + relayer_execute_deposit_wallet_calls( + &calls, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Wrap USDC.e to pUSD", + ) + .await + } + WalletKind::MagicProxy => { + relayer_execute_proxy_calls( + &calls, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Wrap USDC.e to pUSD", + None, + ) + .await + } + WalletKind::GnosisSafe => { + anyhow::bail!("relayer_wrap_calls: unexpected GnosisSafe"); + } + } +} + +/// After a USDC.e-collateral merge, wrap `amount` USDC.e to pUSD in the deposit wallet. +pub async fn wrap_usdce_to_pusd( + provider: &P, + prov_read: &impl Provider, + wallet: Address, + amount: U256, + wallet_kind: WalletKind, + safe: Option<&IGnosisSafe::IGnosisSafeInstance

>, + signer: &impl alloy::signers::Signer, + builder_key: Option<&str>, + builder_secret: Option<&str>, + builder_passphrase: Option<&str>, + relayer_url: &str, +) -> Result<()> { + if amount == U256::ZERO { + return Ok(()); + } + + let usdc_before = erc20_balance(prov_read, wallet, USDC_POLYGON).await?; + if usdc_before < amount { + anyhow::bail!( + "wrap 需要 {} USDC.e,钱包余额仅 {}", + amount, + usdc_before + ); + } + let pusd_before = erc20_balance(prov_read, wallet, PUSD_POLYGON).await?; + info!( + "🔄 Wrap USDC.e → pUSD | amount={} ({}) | wallet={:?}", + amount, + amount / U256::from(1_000_000), + wallet + ); + + match wallet_kind { + WalletKind::GnosisSafe => { + let safe = safe.ok_or_else(|| anyhow::anyhow!("Safe instance required for wrap"))?; + let allowance = erc20_allowance(prov_read, wallet, USDC_POLYGON, COLLATERAL_ONRAMP).await?; + if allowance < amount { + let approve = encode_erc20_approve(COLLATERAL_ONRAMP, amount); + let tx = safe_exec_call(safe, signer, USDC_POLYGON, approve).await?; + info!("✅ Safe USDC.e approve for Onramp: {:#x}", tx); + } + let wrap_calldata = encode_onramp_wrap(wallet, amount); + let tx = safe_exec_call(safe, signer, COLLATERAL_ONRAMP, wrap_calldata).await?; + info!("✅ Safe wrap tx: {:#x}", tx); + } + WalletKind::DepositWallet | WalletKind::MagicProxy => { + let (k, s, p) = match (builder_key, builder_secret, builder_passphrase) { + (Some(k), Some(s), Some(p)) => (k, s, p), + _ => anyhow::bail!("Wrap via relayer requires POLY_BUILDER_* credentials"), + }; + let allowance = erc20_allowance(prov_read, wallet, USDC_POLYGON, COLLATERAL_ONRAMP).await?; + let mut calls: Vec<(Address, Vec)> = Vec::new(); + if allowance < amount { + calls.push(( + USDC_POLYGON, + encode_erc20_approve(COLLATERAL_ONRAMP, amount), + )); + } + calls.push((COLLATERAL_ONRAMP, encode_onramp_wrap(wallet, amount))); + let tx = relayer_wrap_calls( + wallet_kind, + wallet, + calls, + signer, + k, + s, + p, + relayer_url, + ) + .await?; + crate::adapter_auth::wait_relayer_tx(provider, &tx).await?; + info!("✅ Relayer wrap confirmed: {}", tx); + } + } + + let pusd_after = erc20_balance(prov_read, wallet, PUSD_POLYGON).await?; + if pusd_after < pusd_before + amount { + anyhow::bail!( + "wrap 后 pUSD 余额未增加 (before={} after={} expected +{})", + pusd_before, + pusd_after, + amount + ); + } + info!( + "✅ Wrapped to pUSD | +{} (wallet pUSD balance={})", + amount, + pusd_after + ); + Ok(()) +} diff --git a/src/redeem.rs b/src/redeem.rs new file mode 100644 index 0000000..19b8fc1 --- /dev/null +++ b/src/redeem.rs @@ -0,0 +1,406 @@ +//! CTF Redeem: redeem winner tokens for settled markets to pUSD (V2) or USDC.e (legacy). +//! +//! Supports **Gnosis Safe** (execTransaction) and **Magic/Email EIP-1167** (Polymarket Relayer). +//! V2 默认经 CollateralAdapter 赎回为 pUSD;设 `REDEEM_OUTPUT_TOKEN=USDC.e` 可走 legacy 路径。 +//! pUSD 首次赎回需对 CTF 执行 `setApprovalForAll(adapter, true)`,本模块会自动处理。 + +use std::env; + +use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::providers::{Provider, ProviderBuilder}; +use alloy::signers::local::LocalSigner; +use alloy::signers::Signer as _; +use anyhow::Result; +use polymarket_client_sdk::{contract_config, POLYGON}; +use std::str::FromStr as _; +use tracing::{info, warn}; + +use alloy::sol; +sol! { + #[sol(rpc)] + interface IERC1155 { + function balanceOf(address account, uint256 id) external view returns (uint256); + } +} + +use crate::adapter_auth::{ensure_adapter_approved, encode_set_approval_for_all, wait_relayer_tx}; +use crate::deposit_wallet_relay::{ + relayer_execute_deposit_wallet_calldata, use_deposit_wallet_relayer, +}; +use crate::proxy_relay::{ + derive_proxy_wallet, relayer_execute_proxy_calldata, IGnosisSafe, CTF_COLLATERAL_ADAPTER, + CTF_POLYGON, NEG_RISK_ADAPTER, NEG_RISK_COLLATERAL_ADAPTER, PROXY_FACTORY, + PROXY_REDEEM_LEGACY_GAS, PROXY_REDEEM_PUSD_GAS, RELAYER_URL_DEFAULT, RPC_URL_DEFAULT, + USDC_POLYGON, PUSD_POLYGON, +}; + +const PARENT_COLLECTION_ID: B256 = B256::ZERO; + +fn encode_redeem_calldata_4arg(collateral_token: Address, condition_id: B256) -> Vec { + let sel = &keccak256(b"redeemPositions(address,bytes32,bytes32,uint256[])")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(collateral_token.as_slice()); + out.extend_from_slice(PARENT_COLLECTION_ID.as_slice()); + out.extend_from_slice(condition_id.as_slice()); + out.extend_from_slice(&U256::from(128u64).to_be_bytes::<32>()); + out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>()); + out.extend_from_slice(&U256::from(1u64).to_be_bytes::<32>()); + out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>()); + out +} + +fn encode_redeem_calldata_neg_risk_legacy(condition_id: B256, amounts: [U256; 2]) -> Vec { + let sel = &keccak256(b"redeemPositions(bytes32,uint256[])")[..4]; + let mut out = Vec::from(sel); + out.extend_from_slice(condition_id.as_slice()); + out.extend_from_slice(&U256::from(128u64).to_be_bytes::<32>()); + out.extend_from_slice(&U256::from(2u64).to_be_bytes::<32>()); + out.extend_from_slice(&amounts[0].to_be_bytes::<32>()); + out.extend_from_slice(&amounts[1].to_be_bytes::<32>()); + out +} + +fn redeem_to_pusd() -> bool { + match env::var("REDEEM_OUTPUT_TOKEN") { + Ok(v) => { + let s = v.trim().to_lowercase(); + s != "usdc.e" && s != "usdc" + } + Err(_) => true, + } +} + +fn pusd_adapter(neg_risk: bool) -> Address { + if neg_risk { + NEG_RISK_COLLATERAL_ADAPTER + } else { + CTF_COLLATERAL_ADAPTER + } +} + +fn resolve_redeem_call( + neg_risk: bool, + condition_id: B256, + outcome_index: Option, + size_raw: Option, +) -> Result<(Address, Vec)> { + if redeem_to_pusd() { + let target = pusd_adapter(neg_risk); + let calldata = encode_redeem_calldata_4arg(PUSD_POLYGON, condition_id); + return Ok((target, calldata)); + } + + if neg_risk { + let idx = outcome_index.ok_or_else(|| { + anyhow::anyhow!("NegRisk legacy USDC.e 赎回需要 outcome_index(或设 REDEEM_OUTPUT_TOKEN=pUSD)") + })?; + let size = size_raw.ok_or_else(|| { + anyhow::anyhow!("NegRisk legacy USDC.e 赎回需要持仓 size(或设 REDEEM_OUTPUT_TOKEN=pUSD)") + })?; + let mut amounts = [U256::ZERO, U256::ZERO]; + if idx == 0 || idx == 1 { + amounts[idx as usize] = size; + } else { + anyhow::bail!("无效的 outcome_index: {}", idx); + } + let calldata = encode_redeem_calldata_neg_risk_legacy(condition_id, amounts); + return Ok((NEG_RISK_ADAPTER, calldata)); + } + + let config = contract_config(POLYGON, false) + .ok_or_else(|| anyhow::anyhow!("Unsupported chain_id: {}", POLYGON))?; + let calldata = encode_redeem_calldata_4arg(USDC_POLYGON, condition_id); + Ok((config.conditional_tokens, calldata)) +} + +async fn outcome_balance(provider: &P, proxy: Address, asset: U256) -> Result { + let ctf = IERC1155::new(CTF_POLYGON, provider); + ctf + .balanceOf(proxy, asset) + .call() + .await + .map_err(|e| anyhow::anyhow!("balanceOf failed: {}", e)) +} + +async fn verify_redeemed( + provider: &P, + proxy: Address, + assets: &[U256], + before: &[U256], +) -> Result<()> { + for (asset, prev) in assets.iter().zip(before.iter()) { + let after = outcome_balance(provider, proxy, *asset).await?; + if after >= *prev { + anyhow::bail!( + "赎回后 outcome token {} 余额未减少 (before={} after={}),Relayer 可能 gas 不足或链上 redeem 失败", + asset, + prev, + after + ); + } + } + Ok(()) +} + +fn redeem_gas_limit() -> u64 { + if redeem_to_pusd() { + PROXY_REDEEM_PUSD_GAS + } else { + PROXY_REDEEM_LEGACY_GAS + } +} + +fn use_relayer_by_config(code_len: usize) -> bool { + if use_deposit_wallet_relayer() { + return true; + } + let s = match env::var("SIGNATURE_TYPE") { + Ok(v) => v.trim().to_lowercase(), + Err(_) => return code_len < 150, + }; + if s == "proxy" { + return true; + } + if s == "gnosissafe" || s == "safe" { + return false; + } + code_len < 150 +} + +async fn relayer_redeem_calldata( + redeem_data: &[u8], + redeem_to: Address, + wallet: Address, + signer: &impl alloy::signers::Signer, + builder_key: &str, + builder_secret: &str, + builder_passphrase: &str, + relayer_url: &str, + gas_limit: Option, +) -> Result { + if use_deposit_wallet_relayer() { + relayer_execute_deposit_wallet_calldata( + redeem_data, + redeem_to, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Redeem positions", + ) + .await + } else { + relayer_execute_proxy_calldata( + redeem_data, + redeem_to, + wallet, + signer, + builder_key, + builder_secret, + builder_passphrase, + relayer_url, + "Redeem positions", + gas_limit, + ) + .await + } +} + +async fn safe_exec_call( + safe: &IGnosisSafe::IGnosisSafeInstance

, + signer: &impl alloy::signers::Signer, + to: Address, + calldata: Vec, +) -> Result { + let nonce: U256 = safe + .nonce() + .call() + .await + .map_err(|e| anyhow::anyhow!("Failed to read Safe nonce: {}", e))?; + let tx_hash_data = safe + .encodeTransactionData( + to, + U256::ZERO, + calldata.clone().into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + nonce, + ) + .call() + .await + .map_err(|e| anyhow::anyhow!("Safe.encodeTransactionData failed: {}", e))? + .0; + let tx_hash = keccak256(tx_hash_data.as_ref()); + let sig = signer + .sign_hash(&tx_hash) + .await + .map_err(|e| anyhow::anyhow!("Signing failed: {}", e))?; + let mut sig_bytes = sig.as_bytes().to_vec(); + if sig_bytes.len() == 65 && (sig_bytes[64] == 0 || sig_bytes[64] == 1) { + sig_bytes[64] += 27; + } + let pending = safe + .execTransaction( + to, + U256::ZERO, + calldata.into(), + 0u8, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + sig_bytes.into(), + ) + .send() + .await + .map_err(|e| anyhow::anyhow!("Safe.execTransaction failed: {}", e))?; + let tx_hash_out = *pending.tx_hash(); + let receipt = pending + .get_receipt() + .await + .map_err(|e| anyhow::anyhow!("Failed waiting for receipt: {}", e))?; + if !receipt.status() { + anyhow::bail!("Safe tx reverted: {:#x}", tx_hash_out); + } + Ok(tx_hash_out) +} + +async fn build_redeem_calls( + provider: &P, + proxy: Address, + neg_risk: bool, + redeem_target: Address, + redeem_calldata: Vec, +) -> Result)>> { + let _ = (provider, proxy, neg_risk); + Ok(vec![(redeem_target, redeem_calldata)]) +} + +/// Redeem winner tokens for `condition_id` on `proxy`. +/// `verify_assets`: 赎回前后校验这些 outcome token 余额是否减少(可为空跳过)。 +pub async fn redeem_one( + condition_id: B256, + neg_risk: bool, + proxy: Address, + private_key: &str, + rpc_url: Option<&str>, + outcome_index: Option, + size_raw: Option, + verify_assets: &[U256], +) -> Result { + let rpc = rpc_url.unwrap_or(RPC_URL_DEFAULT); + let chain = POLYGON; + let signer = LocalSigner::from_str(private_key)?.with_chain_id(Some(chain)); + let wallet = signer.address(); + + let (redeem_target, redeem_calldata) = + resolve_redeem_call(neg_risk, condition_id, outcome_index, size_raw)?; + + let output = if redeem_to_pusd() { "pUSD" } else { "USDC.e" }; + info!( + "Redeem {:?} | neg_risk={} | target={:?} | output={}", + condition_id, neg_risk, redeem_target, output + ); + + let provider = ProviderBuilder::new().wallet(signer.clone()).connect(rpc).await?; + let mut balances_before = Vec::new(); + for asset in verify_assets { + balances_before.push(outcome_balance(&provider, proxy, *asset).await?); + } + let gas_limit = Some(redeem_gas_limit()); + info!("Relayer redeem gasLimit={}", gas_limit.unwrap_or(PROXY_REDEEM_PUSD_GAS)); + let calls = build_redeem_calls(&provider, proxy, neg_risk, redeem_target, redeem_calldata).await?; + let code = provider.get_code_at(proxy).await.unwrap_or_default(); + let use_relayer = use_relayer_by_config(code.len()); + + if use_relayer { + if !use_deposit_wallet_relayer() { + let derived = derive_proxy_wallet(wallet, PROXY_FACTORY); + let try_anyway = env::var("MERGE_TRY_ANYWAY") + .map(|s| s.trim() == "1" || s.trim().eq_ignore_ascii_case("true")) + .unwrap_or(false); + if derived != proxy && !try_anyway { + anyhow::bail!( + "POLYMARKET_PROXY_ADDRESS ({:?}) does not match ProxyFactory derive ({:?}). Set MERGE_TRY_ANYWAY=1 to force.", + proxy, derived + ); + } + if derived != proxy { + warn!("MERGE_TRY_ANYWAY=1: derive != proxy, still sending Relayer request."); + } + } + let builder_key = env::var("POLY_BUILDER_API_KEY").ok(); + let builder_secret = env::var("POLY_BUILDER_SECRET").ok(); + let builder_passphrase = env::var("POLY_BUILDER_PASSPHRASE").ok(); + let relayer_url = env::var("RELAYER_URL").unwrap_or_else(|_| RELAYER_URL_DEFAULT.to_string()); + match (builder_key.as_deref(), builder_secret.as_deref(), builder_passphrase.as_deref()) { + (Some(k), Some(s), Some(p)) => { + if redeem_to_pusd() { + ensure_adapter_approved( + &provider, + proxy, + pusd_adapter(neg_risk), + &signer, + k, + s, + p, + &relayer_url, + ) + .await?; + } + let (redeem_to, redeem_data) = calls + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("missing redeem call"))?; + let out = relayer_redeem_calldata( + &redeem_data, + redeem_to, + proxy, + &signer, + k, + s, + p, + &relayer_url, + gas_limit, + ) + .await?; + wait_relayer_tx(&provider, &out).await?; + if !verify_assets.is_empty() { + verify_redeemed(&provider, proxy, verify_assets, &balances_before).await?; + } + info!("✅ Relayer redeem confirmed: {}", out); + return Ok(out); + } + _ => anyhow::bail!( + "Magic/Email requires POLY_BUILDER_API_KEY, POLY_BUILDER_SECRET, POLY_BUILDER_PASSPHRASE.", + ), + } + } + + let safe = IGnosisSafe::new(proxy, provider.clone()); + if redeem_to_pusd() { + let adapter = pusd_adapter(neg_risk); + if !crate::adapter_auth::is_adapter_approved(&provider, CTF_POLYGON, proxy, adapter).await? { + let approve_calldata = encode_set_approval_for_all(adapter, true); + let tx = safe_exec_call(&safe, &signer, CTF_POLYGON, approve_calldata).await?; + info!("✅ Safe setApprovalForAll tx: {:#x}", tx); + } + } + let mut last_tx = B256::ZERO; + for (to, calldata) in calls { + last_tx = safe_exec_call(&safe, &signer, to, calldata).await?; + info!("✅ Safe redeem tx: {:#x}", last_tx); + } + if !verify_assets.is_empty() { + verify_redeemed(&provider, proxy, verify_assets, &balances_before).await?; + } + Ok(format!("{:#x}", last_tx)) +} diff --git a/src/risk/hedge_monitor.rs b/src/risk/hedge_monitor.rs new file mode 100644 index 0000000..870e7ac --- /dev/null +++ b/src/risk/hedge_monitor.rs @@ -0,0 +1,599 @@ +//! Hedge Monitor: 止盈/止损 + GTC 卖出监控模块 +//! 注意:此模块完整实现了对冲策略,但主程序中暂未启用(策略设计选择)。 +//! 启用方式:在 main.rs 中接收到 OrderBook 更新后调用 `hedge_monitor.check_and_execute(&book).await` + +#![allow(dead_code)] + +use anyhow::Result; +use alloy::signers::Signer; +use alloy::signers::local::LocalSigner; +use dashmap::DashMap; +use polymarket_client_sdk::clob::ws::types::response::BookUpdate; +use polymarket_client_sdk::types::{Address, Decimal, U256}; +use polymarket_client_sdk_v2::clob::types::{OrderType, Side}; +use polymarket_client_sdk_v2::POLYGON; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal_macros::dec; +use std::str::FromStr; +use std::sync::Arc; +use tracing::{error, info, warn}; + +use super::positions::PositionTracker; +use super::recovery::RecoveryAction; +use crate::trading::AuthenticatedClobClient; + +#[derive(Debug, Clone)] +pub struct HedgePosition { + pub token_id: U256, + pub opposite_token_id: U256, // Opposite side token_id (for diff calc) + pub amount: Decimal, + pub entry_price: Decimal, // Buy price (best ask) + pub take_profit_price: Decimal, // Take-profit price + pub stop_loss_price: Decimal, // Stop-loss price + pub pair_id: String, + pub market_display: String, // Market display name (e.g. "btc prediction") + pub order_id: Option, // GTC order ID if placed + pub pending_sell_amount: Decimal, // Pending sell amount +} + +pub struct HedgeMonitor { + client: AuthenticatedClobClient, + private_key: String, + proxy_address: Option

, + positions: DashMap, // pair_id -> position + position_tracker: Arc, // For exposure updates +} + +impl HedgeMonitor { + pub fn new( + client: AuthenticatedClobClient, + private_key: String, + proxy_address: Option
, + position_tracker: Arc, + ) -> Self { + Self { + client, + private_key, + proxy_address, + positions: DashMap::new(), + position_tracker, + } + } + + /// Add hedge position to monitor + pub fn add_position(&self, action: &RecoveryAction) -> Result<()> { + if let RecoveryAction::MonitorForExit { + token_id, + opposite_token_id, + amount, + entry_price, + take_profit_pct, + stop_loss_pct, + pair_id, + market_display, + } = action + { + // Compute take-profit and stop-loss prices + let take_profit_price = *entry_price * (dec!(1.0) + *take_profit_pct); + let stop_loss_price = *entry_price * (dec!(1.0) - *stop_loss_pct); + + info!( + "🛡️ Start hedge monitor | market:{} | pos:{} | entry:{:.4} | TP:{:.4} | SL:{:.4}", + market_display, + amount, + entry_price, + take_profit_price, + stop_loss_price + ); + + let position = HedgePosition { + token_id: *token_id, + opposite_token_id: *opposite_token_id, + amount: *amount, + entry_price: *entry_price, + take_profit_price, + stop_loss_price, + pair_id: pair_id.clone(), + market_display: market_display.clone(), + order_id: None, + pending_sell_amount: dec!(0), + }; + + self.positions.insert(pair_id.clone(), position); + } + Ok(()) + } + + /// Update entry_price from orderbook best ask + pub fn update_entry_price(&self, pair_id: &str, entry_price: Decimal) { + if let Some(mut pos) = self.positions.get_mut(pair_id) { + let old_entry = pos.entry_price; + pos.entry_price = entry_price; + // Recompute take-profit and stop-loss + let take_profit_pct = (pos.take_profit_price - old_entry) / old_entry; + let stop_loss_pct = (old_entry - pos.stop_loss_price) / old_entry; + pos.take_profit_price = entry_price * (dec!(1.0) + take_profit_pct); + pos.stop_loss_price = entry_price * (dec!(1.0) - stop_loss_pct); + + info!( + pair_id = %pair_id, + old_entry = %old_entry, + new_entry = %entry_price, + take_profit_price = %pos.take_profit_price, + stop_loss_price = %pos.stop_loss_price, + "Update buy price" + ); + } + } + + /// Check orderbook; sell if take-profit or stop-loss hit + pub async fn check_and_execute(&self, book: &BookUpdate) -> Result<()> { + // Best bid (last in bids, bids are price descending) + let best_bid = book.bids.last(); + let best_bid_price = match best_bid { + Some(bid) => bid.price, + None => return Ok(()), // No bids, cannot sell + }; + + // Find positions to check + let positions_to_check: Vec<(String, HedgePosition)> = self + .positions + .iter() + .filter(|entry| entry.value().token_id == book.asset_id) + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect(); + + for (pair_id, position) in positions_to_check { + // Check if GTC order exists; if so, repost at latest price + if let Some(ref order_id) = position.order_id { + let pending_amount = position.pending_sell_amount; + if pending_amount > dec!(0) { + // Unfilled order; repost at latest price + info!( + "🔄 Unfilled order | market:{} | order_id:{} | remain:{} | repost at {:.4}", + position.market_display, + &order_id[..16], + pending_amount, + best_bid_price + ); + // Clear old order_id before repost + if let Some(mut pos) = self.positions.get_mut(&pair_id) { + pos.order_id = None; + } + // Proceed with sell logic using pending_amount + } else { + // Order submitted but pending_amount=0, possibly in progress; skip + continue; + } + } + + // Check take-profit or stop-loss + let (should_sell, reason) = if best_bid_price >= position.take_profit_price { + let profit_pct = ((best_bid_price - position.entry_price) / position.entry_price * dec!(100.0)).to_f64().unwrap_or(0.0); + (true, format!("Take-profit ({:.2}%)", profit_pct)) + } else if best_bid_price <= position.stop_loss_price { + let loss_pct = ((position.entry_price - best_bid_price) / position.entry_price * dec!(100.0)).to_f64().unwrap_or(0.0); + (true, format!("Stop-loss ({:.2}%)", loss_pct)) + } else { + (false, String::new()) + }; + + if should_sell { + // Get current and opposite positions + let current_position = self.position_tracker.get_position(position.token_id); + let opposite_position = self.position_tracker.get_position(position.opposite_token_id); + + // Diff: current - opposite + let difference = current_position - opposite_position; + + // If diff <= 0, opposite covers; no sell + if difference <= dec!(0) { + info!( + "⏸️ No sell needed | market:{} | pos:{} | opposite:{} | diff:{} | opposite covers", + position.market_display, + current_position, + opposite_position, + difference + ); + continue; + } + + // Determine sell amount + let sell_amount = if position.order_id.is_some() && position.pending_sell_amount > dec!(0) { + // Use pending_sell_amount if unfilled order exists + position.pending_sell_amount + } else { + // Else use diff + difference + }; + + // Diff > 0; sell diff amount + info!( + "✅ {} hit | market:{} | bid:{:.4} | entry:{:.4} | pos:{} | opposite:{} | diff:{} | sell:{}", + reason, + position.market_display, + best_bid_price, + position.entry_price, + current_position, + opposite_position, + difference, + sell_amount + ); + + // Sell via GTC order + // Spawn async to avoid blocking main loop + let position_clone = position.clone(); + let pair_id_clone = pair_id.clone(); + let position_tracker = self.position_tracker.clone(); + let positions = self.positions.clone(); + let client = self.client.clone(); + let private_key = self.private_key.clone(); + + // Mark processing to avoid duplicate orders + if let Some((_, mut pos)) = self.positions.remove(&pair_id) { + pos.order_id = Some("processing".to_string()); + self.positions.insert(pair_id.clone(), pos); + } + + tokio::spawn(async move { + // Recreate signer (cannot use self in spawn) + let signer = match LocalSigner::from_str(&private_key) { + Ok(s) => s.with_chain_id(Some(POLYGON)), + Err(e) => { + error!( + "❌ Create signer failed | market:{} | err:{}", + position_clone.market_display, + e + ); + return; + } + }; + + // Execute sell + match Self::execute_sell_order( + &client, + &signer, + &position_clone, + best_bid_price, + sell_amount, + ).await { + Ok((order_id, filled, remaining)) => { + // Update position, mark order placed + let order_id_short = order_id[..16].to_string(); + if let Some((_, mut pos)) = positions.remove(&pair_id_clone) { + if remaining > dec!(0) { + // Partial fill; save order_id + pos.order_id = Some(order_id); + pos.pending_sell_amount = remaining; + info!("🔒 Position order_id updated | market:{} | id:{} | remain:{}", + position_clone.market_display, order_id_short, remaining); + } else { + // Full fill; clear order_id + pos.order_id = None; + pos.pending_sell_amount = dec!(0); + info!("✅ Sell order fully filled | market:{} | id:{} | filled:{}", + position_clone.market_display, order_id_short, filled); + } + positions.insert(pair_id_clone.clone(), pos); + } else { + warn!("⚠️ Position not found | pair_id:{}", pair_id_clone); + } + + // Only update position and exposure for actual fills + if filled > dec!(0) { + info!("📊 Updating position | market:{} | reduce:{}", + position_clone.market_display, filled); + position_tracker.update_position(position_clone.token_id, -filled); + info!("📊 Position update done | market:{}", position_clone.market_display); + + // Update exposure cost + info!("💰 Updating exposure | market:{} | entry:{} | sell:{}", + position_clone.market_display, + position_clone.entry_price, + filled); + position_tracker.update_exposure_cost( + position_clone.token_id, + position_clone.entry_price, + -filled, + ); + info!("💰 Exposure update done | market:{}", position_clone.market_display); + + // Compute exposure + let current_exposure = position_tracker.calculate_exposure(); + info!( + "📉 Exposure updated | market:{} | sold:{} | exposure:{:.2} USD", + position_clone.market_display, + filled, + current_exposure + ); + } + } + Err(e) => { + error!( + "❌ Sell order failed | market:{} | price:{:.4} | err:{}", + position_clone.market_display, + best_bid_price, + e + ); + // On failure, clear processing + if let Some(mut pos) = positions.get_mut(&pair_id_clone) { + pos.order_id = None; + } + } + } + }); + } + } + + Ok(()) + } + + /// Compute actual sell amount (with fee) + fn calculate_sell_amount(&self, position: &HedgePosition) -> Decimal { + self.calculate_sell_amount_with_size(position, position.amount) + } + + /// Compute actual sell amount for given size (with fee) + fn calculate_sell_amount_with_size(&self, position: &HedgePosition, base_amount: Decimal) -> Decimal { + // Compute fee + let p = position.entry_price.to_f64().unwrap_or(0.0); + let c = 100.0; + let fee_rate = 0.25; + let exponent = 2.0; + + let base = p * (1.0 - p); + let fee_value = c * fee_rate * base.powf(exponent); + let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0)); + + // Compute available amount + let available_amount = if fee_decimal >= dec!(100.0) { + dec!(0.01) + } else { + let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0); + base_amount * multiplier + }; + + // Floor to 2 decimals + let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0); + + if floored_size.is_zero() { + dec!(0.01) + } else { + floored_size + } + } + + /// Static: compute actual sell amount for given size (with fee) + fn calculate_sell_amount_static(position: &HedgePosition, base_amount: Decimal) -> Decimal { + // Compute fee + let p = position.entry_price.to_f64().unwrap_or(0.0); + let c = 100.0; + let fee_rate = 0.25; + let exponent = 2.0; + + let base = p * (1.0 - p); + let fee_value = c * fee_rate * base.powf(exponent); + let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0)); + + // Compute available amount + let available_amount = if fee_decimal >= dec!(100.0) { + dec!(0.01) + } else { + let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0); + base_amount * multiplier + }; + + // Floor to 2 decimals + let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0); + + if floored_size.is_zero() { + dec!(0.01) + } else { + floored_size + } + } + + /// Static: execute sell order + async fn execute_sell_order( + client: &AuthenticatedClobClient, + signer: &impl Signer, + position: &HedgePosition, + price: Decimal, + size: Decimal, + ) -> Result<(String, Decimal, Decimal)> { + // Compute fee + let p = position.entry_price.to_f64().unwrap_or(0.0); + let c = 100.0; + let fee_rate = 0.25; + let exponent = 2.0; + + let base = p * (1.0 - p); + let fee_value = c * fee_rate * base.powf(exponent); + let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0)); + + // Compute available amount + let available_amount = if fee_decimal >= dec!(100.0) { + dec!(0.01) + } else { + let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0); + size * multiplier + }; + + // Floor to 2 decimals + let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0); + let order_size = if floored_size.is_zero() { + dec!(0.01) + } else { + floored_size + }; + + info!( + "💰 Sell amount | market:{} | base:{:.2} | entry:{:.4} | fee:{:.2}% | avail:{:.2} | order:{:.2}", + position.market_display, + size, + position.entry_price, + fee_decimal, + available_amount, + order_size + ); + + // Build GTC sell order + let sell_order = client + .limit_order() + .token_id(position.token_id) + .side(Side::Sell) + .price(price) + .size(order_size) + .order_type(OrderType::GTC) + .build() + .await?; + + // Sign order + let signed_order = client.sign(signer, sell_order).await?; + + // Submit order + let result = client.post_order(signed_order).await?; + + if !result.success { + let error_msg = result.error_msg.as_deref().unwrap_or("unknown error"); + return Err(anyhow::anyhow!("GTC sell order failed: {}", error_msg)); + } + + // Check immediate fill + let filled = result.taking_amount; + let remaining = order_size - filled; + + if filled > dec!(0) { + info!( + "💰 Sell order partial fill | market:{} | id:{} | filled:{} | remain:{}", + position.market_display, + &result.order_id[..16], + filled, + remaining + ); + } else { + info!( + "📋 Sell order posted (no immediate fill) | market:{} | id:{} | size:{} | price:{:.4}", + position.market_display, + &result.order_id[..16], + order_size, + price + ); + } + + Ok((result.order_id, filled, remaining)) + } + + /// Sell via GTC order; size: optional, else position.amount + async fn sell_with_gtc( + &self, + position: &HedgePosition, + price: Decimal, + size: Option, + ) -> Result<(String, Decimal, Decimal)> { + let signer = LocalSigner::from_str(&self.private_key)? + .with_chain_id(Some(POLYGON)); + + // Compute fee + // fee = c * fee_rate * (p * (1-p))^exponent; p=entry_price, c=100 + let p = position.entry_price.to_f64().unwrap_or(0.0); + let c = 100.0; + let fee_rate = 0.25; + let exponent = 2.0; + + // Fee ratio (0-1.56) + let base = p * (1.0 - p); + let fee_value = c * fee_rate * base.powf(exponent); + + // To Decimal + let fee_decimal = Decimal::try_from(fee_value).unwrap_or(dec!(0)); + + // Use size or position.amount + let base_amount = size.unwrap_or(position.amount); + + // Available = filled size * (100 - Fee) / 100 + // If Fee >= 100, use min tradeable unit + let available_amount = if fee_decimal >= dec!(100.0) { + dec!(0.01) // Edge case: min unit + } else { + // Normal: available = filled * (100 - Fee) / 100 + let multiplier = (dec!(100.0) - fee_decimal) / dec!(100.0); + base_amount * multiplier + }; + + let floored_size = (available_amount * dec!(100.0)).floor() / dec!(100.0); + let order_size = if floored_size.is_zero() { + dec!(0.01) + } else { + floored_size + }; + + info!( + "💰 Sell amount | market:{} | base:{:.2} | entry:{:.4} | fee:{:.2}% | avail:{:.2} | order:{:.2}", + position.market_display, + base_amount, + position.entry_price, + fee_decimal, + available_amount, + order_size + ); + + // Build GTC sell order + let sell_order = self + .client + .limit_order() + .token_id(position.token_id) + .side(Side::Sell) + .price(price) + .size(order_size) + .order_type(OrderType::GTC) + .build() + .await?; + + // Sign order + let signed_order = self.client.sign(&signer, sell_order).await?; + + // Submit order + let result = self.client.post_order(signed_order).await?; + + if !result.success { + let error_msg = result.error_msg.as_deref().unwrap_or("unknown error"); + return Err(anyhow::anyhow!("GTC sell order failed: {}", error_msg)); + } + + // Check immediate fill + let filled = result.taking_amount; + let remaining = order_size - filled; + + if filled > dec!(0) { + info!( + "💰 Sell order partial fill | market:{} | id:{} | filled:{} | remain:{}", + position.market_display, + &result.order_id[..16], + filled, + remaining + ); + } else { + info!( + "📋 Sell order posted (no immediate fill) | market:{} | id:{} | size:{} | price:{:.4}", + position.market_display, + &result.order_id[..16], + order_size, + price + ); + } + + Ok((result.order_id, filled, remaining)) + } + + /// Remove completed position + pub fn remove_position(&self, pair_id: &str) { + self.positions.remove(pair_id); + info!(pair_id = %pair_id, "Remove hedge position"); + } + + /// Get all monitored positions + pub fn get_positions(&self) -> Vec { + self.positions.iter().map(|e| e.value().clone()).collect() + } +} \ No newline at end of file diff --git a/src/risk/manager.rs b/src/risk/manager.rs new file mode 100644 index 0000000..355f879 --- /dev/null +++ b/src/risk/manager.rs @@ -0,0 +1,173 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use polymarket_client_sdk::types::{B256, Decimal, U256}; +use rust_decimal_macros::dec; +use tracing::{debug, error, info}; + +use super::positions::PositionTracker; +use super::recovery::{RecoveryAction, RecoveryStrategy}; +use crate::config::Config as BotConfig; +use crate::trading::executor::OrderPairResult; +use crate::trading::AuthenticatedClobClient; + +#[derive(Debug, Clone, PartialEq)] +pub enum PairStatus { + #[allow(dead_code)] + Submitted, + BothFilled, + PartiallyFilled, + OneFailed, + BothFailed, + #[allow(dead_code)] + Recovering, +} + +#[derive(Debug, Clone)] +pub struct OrderPair { + pub pair_id: String, + #[allow(dead_code)] + pub market_id: B256, + #[allow(dead_code)] + pub yes_order_id: String, + #[allow(dead_code)] + pub no_order_id: String, + #[allow(dead_code)] + pub yes_token_id: U256, + #[allow(dead_code)] + pub no_token_id: U256, + #[allow(dead_code)] + pub yes_size: Decimal, + #[allow(dead_code)] + pub no_size: Decimal, + #[allow(dead_code)] + pub yes_filled: Decimal, + #[allow(dead_code)] + pub no_filled: Decimal, + pub status: PairStatus, + #[allow(dead_code)] + pub created_at: DateTime, +} + +pub struct RiskManager { + #[allow(dead_code)] + clob_client: AuthenticatedClobClient, + pending_pairs: DashMap, + position_tracker: std::sync::Arc, + recovery_strategy: RecoveryStrategy, +} + +impl RiskManager { + pub fn new( + clob_client: AuthenticatedClobClient, + config: &BotConfig, + ) -> Self { + Self { + clob_client, + pending_pairs: DashMap::new(), + position_tracker: std::sync::Arc::new(PositionTracker::new( + Decimal::try_from(config.risk_max_exposure_usdc).unwrap_or(dec!(1000.0)), + )), + recovery_strategy: RecoveryStrategy::new( + config.risk_imbalance_threshold, + config.hedge_take_profit_pct, + config.hedge_stop_loss_pct, + ), + } + } + + /// Register new order pair + /// yes_price: YES buy price, no_price: NO buy price + pub fn register_order_pair( + &self, + result: OrderPairResult, + market_id: B256, + yes_token: U256, + no_token: U256, + _yes_price: Decimal, + _no_price: Decimal, + ) { + let status = if result.yes_filled == result.yes_size && result.no_filled == result.no_size { + PairStatus::BothFilled + } else if result.yes_filled > dec!(0) && result.no_filled > dec!(0) { + PairStatus::PartiallyFilled + } else if result.yes_filled > dec!(0) && result.no_filled == dec!(0) { + PairStatus::OneFailed + } else if result.yes_filled == dec!(0) && result.no_filled > dec!(0) { + PairStatus::OneFailed + } else { + PairStatus::BothFailed + }; + + let pair = OrderPair { + pair_id: result.pair_id.clone(), + market_id, + yes_order_id: result.yes_order_id, + no_order_id: result.no_order_id, + yes_token_id: yes_token, + no_token_id: no_token, + yes_size: result.yes_size, + no_size: result.no_size, + yes_filled: result.yes_filled, + no_filled: result.no_filled, + status: status.clone(), + created_at: Utc::now(), + }; + + // Update positions (exposure added during arbitrage execution, no exposure update here) + self.position_tracker.update_position(yes_token, pair.yes_filled); + self.position_tracker.update_position(no_token, pair.no_filled); + + // Log already printed in executor + debug!( + pair_id = %pair.pair_id, + status = ?status, + yes_filled = %pair.yes_filled, + no_filled = %pair.no_filled, + "Register order pair" + ); + + // Use pair.pair_id clone; DashMap needs ownership + self.pending_pairs.insert(pair.pair_id.clone(), pair); + } + + /// Handle order pair and decide recovery strategy + pub async fn handle_order_pair(&self, pair_id: &str) -> Result { + let pair = self + .pending_pairs + .get(pair_id) + .ok_or_else(|| anyhow::anyhow!("Order pair {} not found", pair_id))? + .clone(); + + match pair.status { + PairStatus::BothFilled => { + info!(pair_id = %pair.pair_id, "Both orders fully filled, no recovery needed"); + Ok(RecoveryAction::None) + } + PairStatus::PartiallyFilled => { + self.recovery_strategy + .handle_partial_fill(&pair, &self.position_tracker) + .await + } + PairStatus::OneFailed => { + self.recovery_strategy + .handle_one_sided_fill(&pair, &self.position_tracker) + .await + } + PairStatus::BothFailed => { + error!( + "❌ Arbitrage failed | YES and NO orders both unfilled; possible causes: price moved or liquidity insufficient" + ); + Ok(RecoveryAction::ManualIntervention { + reason: "Both orders failed".to_string(), + }) + } + _ => Ok(RecoveryAction::None), + } + } + + /// Get position tracker (Arc reference) + pub fn position_tracker(&self) -> std::sync::Arc { + self.position_tracker.clone() + } +} \ No newline at end of file diff --git a/src/risk/mod.rs b/src/risk/mod.rs new file mode 100644 index 0000000..c001e24 --- /dev/null +++ b/src/risk/mod.rs @@ -0,0 +1,10 @@ +pub mod hedge_monitor; +pub mod manager; +pub mod position_balancer; +pub mod positions; +pub mod recovery; + +#[allow(unused_imports)] +pub use hedge_monitor::HedgeMonitor; +pub use manager::RiskManager; +pub use position_balancer::PositionBalancer; \ No newline at end of file diff --git a/src/risk/position_balancer.rs b/src/risk/position_balancer.rs new file mode 100644 index 0000000..c0941c1 --- /dev/null +++ b/src/risk/position_balancer.rs @@ -0,0 +1,375 @@ +//! Position balancer: periodically check positions and orders, cancel excess orders to maintain balance + +use anyhow::Result; +use polymarket_client_sdk_v2::clob::types::request::OrdersRequest; +use polymarket_client_sdk_v2::clob::types::Side; +use polymarket_client_sdk::types::{B256, Decimal, U256}; +use rust_decimal_macros::dec; +use std::collections::HashMap; +use tracing::{debug, error, info, warn}; + +use super::positions::PositionTracker; +use crate::config::Config as BotConfig; +use crate::trading::AuthenticatedClobClient; +use polypulse::positions::get_positions; + +/// Position balancer +pub struct PositionBalancer { + clob_client: AuthenticatedClobClient, + position_tracker: std::sync::Arc, + threshold: Decimal, + min_total: Decimal, + #[allow(dead_code)] + max_order_size: Decimal, +} + +impl PositionBalancer { + pub fn new( + clob_client: AuthenticatedClobClient, + position_tracker: std::sync::Arc, + config: &BotConfig, + ) -> Self { + Self { + clob_client, + position_tracker, + threshold: Decimal::try_from(config.position_balance_threshold).unwrap_or(dec!(2.0)), + min_total: Decimal::try_from(config.position_balance_min_total).unwrap_or(dec!(5.0)), + max_order_size: Decimal::try_from(config.max_order_size_usdc).unwrap_or(dec!(5.0)), + } + } + + /// Check and balance positions: fetch positions and orders, analyze YES/NO balance per market, cancel excess orders + pub async fn check_and_balance_positions( + &self, + market_map: &HashMap, // condition_id -> (yes_token_id, no_token_id) + ) -> Result<()> { + // Fetch all active orders (handle pagination) + let mut all_orders = Vec::new(); + let mut cursor: Option = None; + loop { + let page = self + .clob_client + .orders(&OrdersRequest::default(), cursor) + .await?; + + all_orders.extend(page.data); + + if page.next_cursor.is_empty() || page.next_cursor == "LTE=" { + break; + } + cursor = Some(page.next_cursor); + } + + if all_orders.is_empty() { + debug!("No active orders, skipping position balance check"); + return Ok(()); + } + + // Get positions (from PositionTracker, updated by scheduled sync) + let positions = get_positions().await?; + + // Group orders and positions by market + let mut market_data: HashMap = HashMap::new(); + + // Initialize market data + for (condition_id, (yes_token, no_token)) in market_map { + market_data.insert(*condition_id, MarketBalanceData { + condition_id: *condition_id, + yes_token_id: *yes_token, + no_token_id: *no_token, + yes_position: dec!(0), + no_position: dec!(0), + yes_orders: Vec::new(), + no_orders: Vec::new(), + }); + } + + // Fill position data + for pos in positions { + if let Some(data) = market_data.get_mut(&pos.condition_id) { + // outcome_index: 0=YES, 1=NO + if pos.outcome_index == 0 { + data.yes_position = pos.size; + } else if pos.outcome_index == 1 { + data.no_position = pos.size; + } + } + } + + // Fill order data + for order in all_orders { + // Only process buy orders (Side::Buy) + if order.side != Side::Buy { + continue; + } + + // Find market for each order + for data in market_data.values_mut() { + if order.asset_id == data.yes_token_id { + let pending_size = order.original_size - order.size_matched; + if pending_size > dec!(0) { + data.yes_orders.push(OrderInfo { + order_id: order.id.clone(), + price: order.price, + pending_size, + }); + } + } else if order.asset_id == data.no_token_id { + let pending_size = order.original_size - order.size_matched; + if pending_size > dec!(0) { + data.no_orders.push(OrderInfo { + order_id: order.id.clone(), + price: order.price, + pending_size, + }); + } + } + } + } + + // Balance check per market + for data in market_data.values() { + if let Err(e) = self.balance_market(data).await { + warn!(error = %e, "❌ Market position balance failed"); + } + } + + Ok(()) + } + + /// Balance a single market + async fn balance_market(&self, data: &MarketBalanceData) -> Result<()> { + // Compute actual position diff + let position_diff = (data.yes_position - data.no_position).abs(); + + // Compute pending order amounts + let yes_pending: Decimal = data.yes_orders.iter().map(|o| o.pending_size).sum(); + let no_pending: Decimal = data.no_orders.iter().map(|o| o.pending_size).sum(); + + // Compute total positions + let yes_total = data.yes_position + yes_pending; + let no_total = data.no_position + no_pending; + let total = yes_total + no_total; + + // Skip if total below minimum + if total < self.min_total { + debug!("Total position {} below min {}; skip balance", total, self.min_total); + return Ok(()); + } + + // Case 1: actual positions imbalanced (without pending) + if position_diff >= self.threshold { + if data.yes_position > data.no_position { + // YES excess: cancel all YES orders, cancel matching NO orders + let cancel_yes_order_ids: Vec = data.yes_orders.iter().map(|o| o.order_id.clone()).collect(); + let cancel_yes_count = cancel_yes_order_ids.len(); + + // Cancel NO size: min(no_pending, yes_pending) + let cancel_no_size = yes_pending.min(no_pending); + + if cancel_yes_count > 0 || cancel_no_size > dec!(0) { + info!( + "⚠️ YES excess detected | YES:{} NO:{} | cancel {} YES orders and ~{} NO pending", + data.yes_position, + data.no_position, + cancel_yes_count, + cancel_no_size + ); + + // Cancel YES orders + if cancel_yes_count > 0 { + let yes_order_ids: Vec<&str> = cancel_yes_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&yes_order_ids).await { + error!(error = %e, "❌ Cancel YES orders failed"); + } else { + info!("✅ Cancelled {} YES orders", cancel_yes_count); + } + } + + // Cancel NO orders (by price, lowest first, until cancel_no_size) + if cancel_no_size > dec!(0) { + let mut no_orders_sorted = data.no_orders.clone(); + no_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal)); + + let mut cancel_no_order_ids = Vec::new(); + let mut accumulated_size = dec!(0); + + for order in no_orders_sorted { + if accumulated_size >= cancel_no_size { + break; + } + cancel_no_order_ids.push(order.order_id.clone()); + accumulated_size += order.pending_size; + } + + if !cancel_no_order_ids.is_empty() { + let cancel_no_order_ids_ref: Vec<&str> = cancel_no_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&cancel_no_order_ids_ref).await { + error!(error = %e, "Cancel NO orders failed"); + } else { + info!("Cancelled {} NO orders (acc {} shares)", cancel_no_order_ids.len(), accumulated_size); + } + } + } + } + } else { + // NO excess: cancel all NO orders, cancel matching YES orders + let cancel_no_order_ids: Vec = data.no_orders.iter().map(|o| o.order_id.clone()).collect(); + let cancel_no_count = cancel_no_order_ids.len(); + + // Cancel YES size: min(yes_pending, no_pending) + let cancel_yes_size = no_pending.min(yes_pending); + + if cancel_no_count > 0 || cancel_yes_size > dec!(0) { + info!( + "⚠️ NO excess detected | YES:{} NO:{} | cancel {} NO orders and ~{} YES pending", + data.yes_position, + data.no_position, + cancel_no_count, + cancel_yes_size + ); + + // Cancel NO orders + if cancel_no_count > 0 { + let no_order_ids: Vec<&str> = cancel_no_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&no_order_ids).await { + error!(error = %e, "Cancel NO orders failed"); + } else { + info!("Cancelled {} NO orders", cancel_no_count); + } + } + + // Cancel YES orders (by price, lowest first) + if cancel_yes_size > dec!(0) { + let mut yes_orders_sorted = data.yes_orders.clone(); + yes_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal)); + + let mut cancel_yes_order_ids = Vec::new(); + let mut accumulated_size = dec!(0); + + for order in yes_orders_sorted { + if accumulated_size >= cancel_yes_size { + break; + } + cancel_yes_order_ids.push(order.order_id.clone()); + accumulated_size += order.pending_size; + } + + if !cancel_yes_order_ids.is_empty() { + let cancel_yes_order_ids_ref: Vec<&str> = cancel_yes_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&cancel_yes_order_ids_ref).await { + error!(error = %e, "❌ Cancel YES orders failed"); + } else { + info!("✅ Cancelled {} YES orders (total {} shares)", cancel_yes_order_ids.len(), accumulated_size); + } + } + } + } + } + return Ok(()); + } + + // Case 2: actual positions balanced but pending causes total imbalance + let target = (yes_total + no_total) / dec!(2); + let yes_imbalance = yes_total - target; + let no_imbalance = no_total - target; + + // Cancel excess YES orders + if yes_imbalance.abs() >= self.threshold && yes_imbalance > dec!(0) { + let mut yes_orders_sorted = data.yes_orders.clone(); + yes_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal)); + + let mut cancel_size = dec!(0); + let mut cancel_order_ids = Vec::new(); + + for order in yes_orders_sorted { + if cancel_size >= yes_imbalance { + break; + } + cancel_order_ids.push(order.order_id.clone()); + cancel_size += order.pending_size; + } + + if !cancel_order_ids.is_empty() { + info!("⚠️ YES pending excess, cancelling {} YES orders", cancel_order_ids.len()); + + let cancel_order_ids_ref: Vec<&str> = cancel_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&cancel_order_ids_ref).await { + error!(error = %e, "❌ Cancel YES orders failed"); + } else { + info!("✅ Cancelled {} YES orders", cancel_order_ids.len()); + } + } + } + + // Cancel excess NO orders + if no_imbalance.abs() >= self.threshold && no_imbalance > dec!(0) { + let mut no_orders_sorted = data.no_orders.clone(); + no_orders_sorted.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal)); + + let mut cancel_size = dec!(0); + let mut cancel_order_ids = Vec::new(); + + for order in no_orders_sorted { + if cancel_size >= no_imbalance { + break; + } + cancel_order_ids.push(order.order_id.clone()); + cancel_size += order.pending_size; + } + + if !cancel_order_ids.is_empty() { + info!("NO pending excess, cancelling {} NO orders", cancel_order_ids.len()); + + let cancel_order_ids_ref: Vec<&str> = cancel_order_ids.iter().map(|s| s.as_str()).collect(); + if let Err(e) = self.clob_client.cancel_orders(&cancel_order_ids_ref).await { + error!(error = %e, "Cancel NO orders failed"); + } else { + info!("Cancelled {} NO orders", cancel_order_ids.len()); + } + } + } + + Ok(()) + } + + /// Check if market should skip arbitrage (if severely imbalanced) + /// Uses local cached positions, zero latency + pub fn should_skip_arbitrage(&self, yes_token: U256, no_token: U256) -> bool { + let (yes_pos, no_pos) = self.position_tracker.get_pair_positions(yes_token, no_token); + let position_diff = (yes_pos - no_pos).abs(); + + if position_diff >= self.threshold { + warn!( + yes_position = %yes_pos, + no_position = %no_pos, + position_diff = %position_diff, + threshold = %self.threshold, + "⛔ Positions severely imbalanced, skip arbitrage" + ); + return true; + } + + false + } +} + +/// Market balance data +struct MarketBalanceData { + #[allow(dead_code)] + condition_id: B256, + yes_token_id: U256, + no_token_id: U256, + yes_position: Decimal, + no_position: Decimal, + yes_orders: Vec, + no_orders: Vec, +} + +/// Order info +#[derive(Clone)] +struct OrderInfo { + order_id: String, + price: Decimal, + pending_size: Decimal, +} \ No newline at end of file diff --git a/src/risk/positions.rs b/src/risk/positions.rs new file mode 100644 index 0000000..e3be653 --- /dev/null +++ b/src/risk/positions.rs @@ -0,0 +1,250 @@ +use anyhow::Result; +use dashmap::DashMap; +use polymarket_client_sdk::types::{Decimal, U256}; +use rust_decimal_macros::dec; +use tracing::{info, trace}; + +use polypulse::positions::{get_positions, Position}; + +pub struct PositionTracker { + positions: DashMap, // token_id -> amount (pos=long, neg=short) + exposure_costs: DashMap, // token_id -> cost (USD) for risk exposure + max_exposure: Decimal, +} + +impl PositionTracker { + pub fn new(max_exposure: Decimal) -> Self { + Self { + positions: DashMap::new(), + exposure_costs: DashMap::new(), + max_exposure, + } + } + + pub fn update_position(&self, token_id: U256, delta: Decimal) { + trace!("update_position: start | token_id:{} | delta:{}", token_id, delta); + + trace!("update_position: acquiring positions write lock"); + let mut entry = self.positions.entry(token_id).or_insert(dec!(0)); + trace!("update_position: positions write lock acquired"); + *entry += delta; + trace!("update_position: position updated, new value:{}", *entry); + + // Clean up if position goes to ~0 + // Key fix: release positions write lock before accessing exposure_costs to avoid deadlock + let should_remove = entry.abs() < dec!(0.0001); + trace!("update_position: should_remove:{}", should_remove); + if should_remove { + *entry = dec!(0); + trace!("update_position: position zeroed"); + } + drop(entry); + trace!("update_position: positions write lock released"); + + // Now safe to access exposure_costs + if should_remove { + trace!("update_position: removing exposure_costs"); + self.exposure_costs.remove(&token_id); + trace!("update_position: exposure_costs removed"); + } + + trace!("update_position: done"); + } + + /// Update risk exposure cost (USD) + /// price: buy price, delta: position change (pos=buy, neg=sell) + pub fn update_exposure_cost(&self, token_id: U256, price: Decimal, delta: Decimal) { + trace!("update_exposure_cost: start | token_id:{} | price:{} | delta:{}", token_id, price, delta); + + if delta == dec!(0) { + trace!("update_exposure_cost: delta=0, return"); + return; + } + + trace!("update_exposure_cost: acquiring positions read lock"); + // Key fix: get positions read lock first, then exposure_costs write lock to avoid deadlock + let current_pos = if delta < dec!(0) { + trace!("update_exposure_cost: sell, getting positions read lock"); + let pos = self.positions.get(&token_id); + trace!("update_exposure_cost: positions read lock acquired"); + let result = pos.map(|v| *v.value()).unwrap_or(dec!(0)); + trace!("update_exposure_cost: positions read released, current_pos:{}", result); + result + } else { + trace!("update_exposure_cost: buy, no positions read needed"); + dec!(0) + }; + + trace!("update_exposure_cost: acquiring exposure_costs write lock"); + let mut entry = self.exposure_costs.entry(token_id).or_insert(dec!(0)); + trace!("update_exposure_cost: exposure_costs write lock acquired"); + + if delta > dec!(0) { + trace!("update_exposure_cost: buy branch, compute cost_delta"); + let cost_delta = price * delta; + *entry += cost_delta; + trace!("update_exposure_cost: buy done, new cost:{}", *entry); + } else { + trace!("update_exposure_cost: sell branch, current_pos:{}", current_pos); + if current_pos > dec!(0) { + trace!("update_exposure_cost: compute sell ratio"); + let sell_amount = (-delta).min(current_pos); + let reduction_ratio = sell_amount / current_pos; + trace!("update_exposure_cost: sell_amount:{} | reduction_ratio:{} | current cost:{}", sell_amount, reduction_ratio, *entry); + *entry = (*entry * (dec!(1) - reduction_ratio)).max(dec!(0)); + trace!("update_exposure_cost: sell done, new cost:{}", *entry); + } else { + trace!("update_exposure_cost: current_pos=0, zero out"); + *entry = dec!(0); + } + } + + trace!("update_exposure_cost: check cleanup, current cost:{}", *entry); + if *entry < dec!(0.01) { + trace!("update_exposure_cost: cost near 0, cleanup"); + *entry = dec!(0); + drop(entry); + trace!("update_exposure_cost: lock released, removing"); + self.exposure_costs.remove(&token_id); + trace!("update_exposure_cost: remove done"); + } else { + trace!("update_exposure_cost: cost nonzero, keep entry"); + drop(entry); + } + + trace!("update_exposure_cost: done"); + } + + /// Get max risk exposure limit + pub fn max_exposure(&self) -> Decimal { + self.max_exposure + } + + /// Reset exposure (called at round start; clears cost cache so round starts from 0) + pub fn reset_exposure(&self) { + self.exposure_costs.clear(); + info!("🔄 Risk exposure reset (new round)"); + } + + pub fn get_position(&self, token_id: U256) -> Decimal { + self.positions + .get(&token_id) + .map(|v| *v.value()) + .unwrap_or(dec!(0)) + } + + /// Compute position imbalance (0.0 = balanced, 1.0 = fully imbalanced) + #[allow(dead_code)] + pub fn calculate_imbalance(&self, yes_token: U256, no_token: U256) -> Decimal { + let yes_pos = self.get_position(yes_token); + let no_pos = self.get_position(no_token); + + let total = yes_pos + no_pos; + if total == dec!(0) { + return dec!(0); // fully balanced + } + + // imbalance = abs(yes - no) / (yes + no) + let imbalance = (yes_pos - no_pos).abs() / total; + imbalance + } + + /// Compute total risk exposure (USD), sum of all position costs + pub fn calculate_exposure(&self) -> Decimal { + // Sum all position costs; collect to Vec to avoid holding lock long + let costs: Vec = self.exposure_costs + .iter() + .map(|entry| *entry.value()) + .collect(); + costs.iter().sum() + } + + #[allow(dead_code)] + pub fn is_within_limits(&self) -> bool { + self.calculate_exposure() <= self.max_exposure + } + + /// Check if new order would exceed exposure limit + /// yes_cost, no_cost: order costs (price * size) + pub fn would_exceed_limit(&self, yes_cost: Decimal, no_cost: Decimal) -> bool { + let current_exposure = self.calculate_exposure(); + let new_order_cost = yes_cost + no_cost; + (current_exposure + new_order_cost) > self.max_exposure + } + + /// Get YES and NO positions + pub fn get_pair_positions(&self, yes_token: U256, no_token: U256) -> (Decimal, Decimal) { + (self.get_position(yes_token), self.get_position(no_token)) + } + + /// Sync positions from Data API, fully overwrite local cache + /// For scheduled sync; ensures local matches on-chain positions + pub async fn sync_from_api(&self) -> Result> { + use std::collections::HashMap; + use polymarket_client_sdk::types::B256; + + let positions = get_positions().await?; + + // Clear positions (exposure only from arbitrage execution and Merge, not API) + self.positions.clear(); + + // Update local cache from API positions + let mut updated_count = 0; + let mut valid_positions = Vec::new(); + + for pos in positions { + if pos.size > dec!(0) { + // Position.asset is token_id + self.positions.insert(pos.asset, pos.size); + valid_positions.push(pos); + updated_count += 1; + } + } + + // Print positions grouped by market + if !valid_positions.is_empty() { + let mut by_market: HashMap> = HashMap::new(); + for pos in &valid_positions { + by_market.entry(pos.condition_id).or_default().push(pos); + } + + info!("📊 Position sync done | {} positions, {} markets", updated_count, by_market.len()); + + // Print one line per market + for (_condition_id, market_positions) in by_market.iter() { + let mut yes_pos = dec!(0); + let mut no_pos = dec!(0); + let mut market_title = ""; + + for pos in market_positions { + if pos.outcome_index == 0 { + yes_pos = pos.size; + } else if pos.outcome_index == 1 { + no_pos = pos.size; + } + if market_title.is_empty() { + market_title = &pos.title; + } + } + + // Truncate long titles + let title_display = if market_title.len() > 40 { + format!("{}...", &market_title[..37]) + } else { + market_title.to_string() + }; + + info!( + " 📈 {} | YES:{} NO:{}", + title_display, + yes_pos, + no_pos + ); + } + } else { + info!("📊 Position sync done | no positions"); + } + + Ok(valid_positions) + } +} \ No newline at end of file diff --git a/src/risk/recovery.rs b/src/risk/recovery.rs new file mode 100644 index 0000000..1abcb3f --- /dev/null +++ b/src/risk/recovery.rs @@ -0,0 +1,166 @@ +use anyhow::Result; +use polymarket_client_sdk::types::{Decimal, U256}; +use rust_decimal_macros::dec; +use tracing::debug; + +use super::manager::OrderPair; +use super::positions::PositionTracker; + +#[derive(Debug, Clone)] +pub enum RecoveryAction { + None, + #[allow(dead_code)] + SellExcess { token_id: String, amount: Decimal }, + #[allow(dead_code)] + MonitorForExit { + token_id: U256, + opposite_token_id: U256, // Opposite side token_id (for diff calc) + amount: Decimal, + entry_price: Decimal, // Buy price (best ask) + take_profit_pct: Decimal, // Take-profit % (e.g. 0.05 = 5%) + stop_loss_pct: Decimal, // Stop-loss % (e.g. 0.05 = 5%) + pair_id: String, + market_display: String, // Market display name (e.g. "btc prediction") + }, + ManualIntervention { reason: String }, +} + +pub struct RecoveryStrategy { + imbalance_threshold: Decimal, + #[allow(dead_code)] + take_profit_pct: Decimal, // Take-profit % + #[allow(dead_code)] + stop_loss_pct: Decimal, // Stop-loss % +} + +impl RecoveryStrategy { + pub fn new(imbalance_threshold: f64, take_profit_pct: f64, stop_loss_pct: f64) -> Self { + Self { + imbalance_threshold: Decimal::try_from(imbalance_threshold) + .unwrap_or(dec!(0.1)), + take_profit_pct: Decimal::try_from(take_profit_pct) + .unwrap_or(dec!(0.05)), // default 5% take-profit + stop_loss_pct: Decimal::try_from(stop_loss_pct) + .unwrap_or(dec!(0.05)), // default 5% stop-loss + } + } + + /// Handle partial fill (GTC orders); hedge disabled, no action on imbalance + pub async fn handle_partial_fill( + &self, + pair: &OrderPair, + _position_tracker: &PositionTracker, + ) -> Result { + // Compute imbalance amount + let imbalance = (pair.yes_filled - pair.no_filled).abs(); + let total_filled = pair.yes_filled + pair.no_filled; + + // Compute imbalance ratio + let imbalance_ratio = if total_filled > dec!(0) { + imbalance / total_filled + } else { + dec!(0) + }; + + // Hedge disabled, no action on partial fill imbalance + if imbalance_ratio > self.imbalance_threshold { + let (side, amount) = if pair.yes_filled > pair.no_filled { + // YES filled more + ("YES", pair.yes_filled - pair.no_filled) + } else { + // NO filled more + ("NO", pair.no_filled - pair.yes_filled) + }; + + debug!( + pair_id = %pair.pair_id, + side = side, + imbalance_amount = %amount, + imbalance_ratio = %imbalance_ratio, + "Partial fill imbalance; hedge off, no action" + ); + } + + // Return None, no hedge action + Ok(RecoveryAction::None) + + // Legacy: if imbalance > threshold, hedge + // if imbalance_ratio > self.imbalance_threshold { + // let (token_to_sell, amount) = if pair.yes_filled > pair.no_filled { + // // YES filled more, sell excess YES + // (pair.yes_token_id, pair.yes_filled - pair.no_filled) + // } else { + // // NO filled more, sell excess NO + // (pair.no_token_id, pair.no_filled - pair.yes_filled) + // }; + // + // info!( + // pair_id = %pair.pair_id, + // token_id = %token_to_sell, + // amount = %amount, + // imbalance_ratio = %imbalance_ratio, + // "Partial fill imbalance, hedge" + // ); + // + // return Ok(RecoveryAction::SellExcess { + // token_id: token_to_sell.to_string(), + // amount, + // }); + // } + // + // // Imbalance within acceptable range + // Ok(RecoveryAction::None) + } + + /// Handle one-sided fill (GTC orders); hedge disabled, no action + pub async fn handle_one_sided_fill( + &self, + pair: &OrderPair, + _position_tracker: &PositionTracker, + ) -> Result { + // Determine which order succeeded, which failed + let (side, filled_amount) = + if pair.yes_filled > dec!(0) && pair.no_filled == dec!(0) { + // YES success, NO failed (may still be pending) + ("YES", pair.yes_filled) + } else if pair.no_filled > dec!(0) && pair.yes_filled == dec!(0) { + // NO success, YES failed (may still be pending) + ("NO", pair.no_filled) + } else { + return Ok(RecoveryAction::None); + }; + + // Hedge disabled; one-sided fill logged by executor + debug!( + "One-sided fill | {} filled {} shares | hedge off, no action", + side, filled_amount + ); + + // Return None, no hedge action + Ok(RecoveryAction::None) + + // Legacy: hedge strategy - monitor best bid, sell on TP/SL + // // Resolve opposite side token_id + // let success_token = if pair.yes_filled > dec!(0) { + // pair.yes_token_id + // } else { + // pair.no_token_id + // }; + // let opposite_token = if success_token == pair.yes_token_id { + // pair.no_token_id + // } else { + // pair.yes_token_id + // }; + // + // Ok(RecoveryAction::MonitorForExit { + // token_id: success_token, + // opposite_token_id: opposite_token, + // amount: filled_amount, + // entry_price: dec!(0), // Placeholder, get from orderbook in main + // take_profit_pct: self.take_profit_pct, + // stop_loss_pct: self.stop_loss_pct, + // pair_id: pair.pair_id.clone(), + // market_display: "unknown".to_string(), // Placeholder, get from market info in main + // }) + } +} \ No newline at end of file diff --git a/src/trading/clob_client.rs b/src/trading/clob_client.rs new file mode 100644 index 0000000..f82900e --- /dev/null +++ b/src/trading/clob_client.rs @@ -0,0 +1 @@ +pub use polypulse::clob_v2::*; diff --git a/src/trading/executor.rs b/src/trading/executor.rs new file mode 100644 index 0000000..f5e6f08 --- /dev/null +++ b/src/trading/executor.rs @@ -0,0 +1,479 @@ +use anyhow::Result; +use alloy::signers::Signer; +use alloy::signers::local::LocalSigner; +use chrono::Utc; +use polymarket_client_sdk_v2::clob::types::response::{CancelOrdersResponse, PostOrderResponse}; +use polymarket_client_sdk_v2::clob::types::{OrderType, Side}; +use polymarket_client_sdk_v2::types::{Decimal, U256}; +use polymarket_client_sdk_v2::POLYGON; +use rust_decimal_macros::dec; +use std::str::FromStr; +use std::time::Instant; +use tracing::{debug, error, info, warn}; +use uuid::Uuid; + +use crate::monitor::arbitrage::ArbitrageOpportunity; +use crate::trading::AuthenticatedClobClient; + +pub struct OrderPairResult { + pub pair_id: String, + pub yes_order_id: String, + pub no_order_id: String, + pub yes_filled: Decimal, + pub no_filled: Decimal, + pub yes_size: Decimal, + pub no_size: Decimal, + pub success: bool, +} + +pub struct TradingExecutor { + client: AuthenticatedClobClient, + private_key: String, + max_order_size: Decimal, + slippage: [Decimal; 2], // [first, second]: down uses second, up/flat uses first + gtd_expiration_secs: u64, + arbitrage_order_type: OrderType, +} + +impl TradingExecutor { + pub fn from_client( + client: AuthenticatedClobClient, + private_key: String, + max_order_size_usdc: f64, + slippage: [f64; 2], + gtd_expiration_secs: u64, + arbitrage_order_type: OrderType, + ) -> Self { + Self { + client, + private_key, + max_order_size: Decimal::try_from(max_order_size_usdc) + .unwrap_or(rust_decimal_macros::dec!(100.0)), + slippage: [ + Decimal::try_from(slippage[0]).unwrap_or(dec!(0.0)), + Decimal::try_from(slippage[1]).unwrap_or(dec!(0.01)), + ], + gtd_expiration_secs, + arbitrage_order_type, + } + } + + #[allow(dead_code)] + pub fn client(&self) -> &AuthenticatedClobClient { + &self.client + } + + /// Verify auth actually succeeded via api_keys() + pub async fn verify_authentication(&self) -> Result<()> { + self.client + .api_keys() + .await + .map_err(|e| anyhow::anyhow!("Auth verification failed: API error: {}", e))?; + Ok(()) + } + + /// Cancel all orders for this account (for wind-down) + pub async fn cancel_all_orders(&self) -> Result { + self.client + .cancel_all_orders() + .await + .map_err(|e| anyhow::anyhow!("Failed to cancel all orders: {}", e)) + } + + /// Place GTC sell at given price (wind-down: market-intent for one-sided leg) + pub async fn sell_at_price( + &self, + token_id: U256, + price: Decimal, + size: Decimal, + ) -> Result { + let signer = LocalSigner::from_str(&self.private_key)? + .with_chain_id(Some(POLYGON)); + let order = self + .client + .limit_order() + .token_id(token_id) + .side(Side::Sell) + .price(price) + .size(size) + .order_type(OrderType::GTC) + .build() + .await?; + let signed = self.client.sign(&signer, order).await?; + self.client + .post_order(signed) + .await + .map_err(|e| anyhow::anyhow!("Sell order submit failed: {}", e)) + } + + /// Slippage by direction: down(↓) uses second, up(↑) and flat(−/empty) use first + fn slippage_for_direction(&self, dir: &str) -> Decimal { + if dir == "↓" { + self.slippage[1] + } else { + self.slippage[0] + } + } + + /// Execute arbitrage: submit YES+NO via sequential post_order (V2); order type from config + /// yes_dir / no_dir: direction "↑" "↓" "−" or "" for slippage (down=second, up/flat=first) + pub async fn execute_arbitrage_pair( + &self, + opp: &ArbitrageOpportunity, + yes_dir: &str, + no_dir: &str, + ) -> Result { + let total_start = Instant::now(); + + let expiry_info = if matches!(self.arbitrage_order_type, OrderType::GTD) { + format!("expiry:{}s", self.gtd_expiration_secs) + } else { + "no expiry".to_string() + }; + debug!( + market_id = %opp.market_id, + profit_pct = %opp.profit_percentage, + order_type = %self.arbitrage_order_type, + "Execute arbitrage (V2 sequential, type:{}, {})", + self.arbitrage_order_type, + expiry_info + ); + + let yes_token_id = U256::from_str(&opp.yes_token_id.to_string())?; + let no_token_id = U256::from_str(&opp.no_token_id.to_string())?; + + // Unit reference (per Polymarket CLOB spec): + // - opp.yes_size / opp.no_size → available SHARES (股数, from orderbook depth) + // - self.max_order_size → TOTAL COMBINED USD BUDGET (YES + NO 双侧合计预算,USDC) + // - price → USDC per share (0.00~1.00) + // Budget cap derivation: + // order_size × (yes_price + no_price) ≤ total_budget + // order_size ≤ total_budget / (yes_price + no_price) + // NOTE: main.rs already caps this before dispatching; we recompute here so + // direct calls to execute_arbitrage_pair() also stay within budget. + let total_price = opp.yes_ask_price + opp.no_ask_price; + let max_shares_by_budget = if total_price > dec!(0) { + self.max_order_size / total_price + } else { + dec!(0) + }; + let order_size = opp.yes_size.min(opp.no_size).min(max_shares_by_budget); + + let pair_id = Uuid::new_v4().to_string(); + let expiration = Utc::now() + chrono::Duration::seconds(self.gtd_expiration_secs as i64); + + let yes_slippage_apply = self.slippage_for_direction(yes_dir); + let no_slippage_apply = self.slippage_for_direction(no_dir); + let yes_price_with_slippage = (opp.yes_ask_price + yes_slippage_apply).min(dec!(1.0)); + let no_price_with_slippage = (opp.no_ask_price + no_slippage_apply).min(dec!(1.0)); + + info!( + "📋 Level | YES {:.4}×{:.2} NO {:.4}×{:.2}", + yes_price_with_slippage, order_size, + no_price_with_slippage, order_size + ); + + let expiry_suffix = if matches!(self.arbitrage_order_type, OrderType::GTD) { + format!(" | GTD {}s", self.gtd_expiration_secs) + } else { + String::new() + }; + info!( + "📤 Order | YES {:.4}→{:.4}×{} NO {:.4}→{:.4}×{} | {}{}", + opp.yes_ask_price, yes_price_with_slippage, order_size, + opp.no_ask_price, no_price_with_slippage, order_size, + self.arbitrage_order_type, expiry_suffix + ); + + let yes_amount_usd = yes_price_with_slippage * order_size; + let no_amount_usd = no_price_with_slippage * order_size; + if yes_amount_usd <= dec!(1) || no_amount_usd <= dec!(1) { + warn!( + "⏭️ Skip order | YES:{:.2} pUSD NO:{:.2} pUSD | both must be > $1", + yes_amount_usd, no_amount_usd + ); + return Err(anyhow::anyhow!( + "Order size below min: YES {:.2} pUSD, NO {:.2} pUSD; both must be > $1", + yes_amount_usd, no_amount_usd + )); + } + + let build_start = Instant::now(); + let (yes_order, no_order) = tokio::join!( + async { + let b = self.client + .limit_order() + .token_id(yes_token_id) + .side(Side::Buy) + .price(yes_price_with_slippage) + .size(order_size) + .order_type(self.arbitrage_order_type.clone()); + if matches!(&self.arbitrage_order_type, OrderType::GTD) { + b.expiration(expiration).build().await + } else { + b.build().await + } + }, + async { + let b = self.client + .limit_order() + .token_id(no_token_id) + .side(Side::Buy) + .price(no_price_with_slippage) + .size(order_size) + .order_type(self.arbitrage_order_type.clone()); + if matches!(&self.arbitrage_order_type, OrderType::GTD) { + b.expiration(expiration).build().await + } else { + b.build().await + } + } + ); + + let yes_order = yes_order?; + let no_order = no_order?; + let build_elapsed = build_start.elapsed().as_millis(); + + let sign_start = Instant::now(); + let signer = LocalSigner::from_str(&self.private_key)? + .with_chain_id(Some(POLYGON)); + + let (signed_yes_result, signed_no_result) = tokio::join!( + self.client.sign(&signer, yes_order), + self.client.sign(&signer, no_order) + ); + + let signed_yes = signed_yes_result?; + let signed_no = signed_no_result?; + let sign_elapsed = sign_start.elapsed().as_millis(); + + let send_start = Instant::now(); + let yes_first = yes_price_with_slippage >= no_price_with_slippage; + + let (yes_result, no_result) = if yes_first { + let yes_res = self.client.post_order(signed_yes).await; + let no_res = self.client.post_order(signed_no).await; + match (yes_res, no_res) { + (Ok(y), Ok(n)) => (y, n), + (Err(e), _) | (_, Err(e)) => { + return Self::log_send_error( + &pair_id, + yes_price_with_slippage, + no_price_with_slippage, + order_size, + build_elapsed, + sign_elapsed, + send_start, + total_start, + e, + ); + } + } + } else { + let no_res = self.client.post_order(signed_no).await; + let yes_res = self.client.post_order(signed_yes).await; + match (no_res, yes_res) { + (Ok(n), Ok(y)) => (y, n), + (Err(e), _) | (_, Err(e)) => { + return Self::log_send_error( + &pair_id, + yes_price_with_slippage, + no_price_with_slippage, + order_size, + build_elapsed, + sign_elapsed, + send_start, + total_start, + e, + ); + } + } + }; + + let send_elapsed = send_start.elapsed().as_millis(); + let total_elapsed = total_start.elapsed().as_millis(); + info!( + "⏱️ Latency | {} | build {}ms sign {}ms send {}ms total {}ms", + &pair_id[..8], build_elapsed, sign_elapsed, send_elapsed, total_elapsed + ); + + let yes_filled = yes_result.taking_amount; + let no_filled = no_result.taking_amount; + + if yes_filled == dec!(0) && no_filled == dec!(0) { + let yes_error_msg = yes_result + .error_msg + .as_deref() + .unwrap_or("unknown error"); + let no_error_msg = no_result + .error_msg + .as_deref() + .unwrap_or("unknown error"); + + let yes_error_simple = if yes_error_msg.contains("no orders found to match") { + "No matching orders in orderbook" + } else if yes_error_msg.contains("GTD") + || yes_error_msg.contains("FOK") + || yes_error_msg.contains("FAK") + || yes_error_msg.contains("GTC") + { + "Order cannot fill" + } else { + yes_error_msg + }; + + let no_error_simple = if no_error_msg.contains("no orders found to match") { + "No matching orders in orderbook" + } else if no_error_msg.contains("GTD") + || no_error_msg.contains("FOK") + || no_error_msg.contains("FAK") + || no_error_msg.contains("GTC") + { + "Order cannot fill" + } else { + no_error_msg + }; + + error!( + "❌ Arbitrage failed | pair_id:{} | YES:{} | NO:{}", + &pair_id[..8], + yes_error_simple, + no_error_simple + ); + + debug!( + pair_id = %pair_id, + yes_order_id = ?yes_result.order_id, + no_order_id = ?no_result.order_id, + yes_success = yes_result.success, + no_success = no_result.success, + yes_error = %yes_error_msg, + no_error = %no_error_msg, + "Both orders unfilled (details)" + ); + + return Err(anyhow::anyhow!( + "Arbitrage failed: YES and NO orders both unfilled | YES: {}, NO: {}", + yes_error_simple, + no_error_simple + )); + } + + if !yes_result.success || !no_result.success { + let yes_error_msg = yes_result + .error_msg + .as_deref() + .unwrap_or("unknown error"); + let no_error_msg = no_result + .error_msg + .as_deref() + .unwrap_or("unknown error"); + + let yes_error_simple = if yes_error_msg.contains("no orders found to match") { + "Partially unfilled (order posted)" + } else if yes_error_msg.contains("GTD") + || yes_error_msg.contains("FOK") + || yes_error_msg.contains("FAK") + || yes_error_msg.contains("GTC") + { + "Partially unfilled (order posted)" + } else { + "Status abnormal" + }; + + let no_error_simple = if no_error_msg.contains("no orders found to match") { + "Partially unfilled (order posted)" + } else if no_error_msg.contains("GTD") + || no_error_msg.contains("FOK") + || no_error_msg.contains("FAK") + || no_error_msg.contains("GTC") + { + "Partially unfilled (order posted)" + } else { + "Status abnormal" + }; + + warn!( + "⚠️ Partial order status | pair_id:{} | YES:{} (filled:{}) | NO:{} (filled:{}) | risk mgmt triggered", + &pair_id[..8], + yes_error_simple, + yes_filled, + no_error_simple, + no_filled + ); + + debug!( + pair_id = %pair_id, + yes_order_id = ?yes_result.order_id, + no_order_id = ?no_result.order_id, + yes_success = yes_result.success, + no_success = no_result.success, + yes_error = %yes_error_msg, + no_error = %no_error_msg, + "Order submit status details" + ); + } + + if yes_filled > dec!(0) && no_filled > dec!(0) { + info!( + "✅ Arbitrage success | pair_id:{} | YES filled:{} | NO filled:{} | total:{}", + &pair_id[..8], + yes_filled, + no_filled, + yes_filled.min(no_filled) + ); + } else if yes_filled > dec!(0) || no_filled > dec!(0) { + let side = if yes_filled > dec!(0) { "YES" } else { "NO" }; + let filled = if yes_filled > dec!(0) { yes_filled } else { no_filled }; + let other_side = if yes_filled > dec!(0) { "NO" } else { "YES" }; + warn!( + "⚠️ One-sided fill | {} | {} filled {}, {} unfilled (handed to risk)", + &pair_id[..8], side, filled, other_side + ); + } else { + warn!( + "❌ Arbitrage failed | pair_id:{} | YES and NO both unfilled", + &pair_id[..8] + ); + } + + Ok(OrderPairResult { + pair_id, + yes_order_id: yes_result.order_id.clone(), + no_order_id: no_result.order_id.clone(), + yes_filled, + no_filled, + yes_size: order_size, + no_size: order_size, + success: true, + }) + } + + fn log_send_error( + pair_id: &str, + yes_price: Decimal, + no_price: Decimal, + order_size: Decimal, + build_elapsed: u128, + sign_elapsed: u128, + send_start: Instant, + total_start: Instant, + e: impl std::fmt::Display, + ) -> Result { + let send_elapsed = send_start.elapsed().as_millis(); + let total_elapsed = total_start.elapsed().as_millis(); + error!( + "❌ V2 order API failed | pair_id:{} | YES:{} NO:{} size:{} | build {}ms sign {}ms send {}ms total {}ms | err:{}", + &pair_id[..8], + yes_price, + no_price, + order_size, + build_elapsed, + sign_elapsed, + send_elapsed, + total_elapsed, + e + ); + Err(anyhow::anyhow!("V2 order API failed: {}", e)) + } +} \ No newline at end of file diff --git a/src/trading/mod.rs b/src/trading/mod.rs new file mode 100644 index 0000000..295349b --- /dev/null +++ b/src/trading/mod.rs @@ -0,0 +1,9 @@ +pub mod clob_client; +pub mod executor; +pub mod orders; + +pub use clob_client::{ + create_authenticated_clob_client, parse_signature_type, v1_address_to_v2, + AuthenticatedClobClient, CLOB_API_URL_DEFAULT, +}; +pub use executor::TradingExecutor; diff --git a/src/trading/orders.rs b/src/trading/orders.rs new file mode 100644 index 0000000..41419e4 --- /dev/null +++ b/src/trading/orders.rs @@ -0,0 +1,2 @@ +// Order-related helper types and functions +// Currently empty; extend as needed diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..b881d58 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,11 @@ +//! Full-screen terminal dashboard for the arbitrage bot. + +mod render; +mod runner; +mod state; + +pub use render::draw; +pub use runner::spawn_dashboard_thread; +pub use state::{ + decimal_to_f64, symbol_short, DashboardAction, DashboardHandle, DashboardState, +}; diff --git a/src/ui/render.rs b/src/ui/render.rs new file mode 100644 index 0000000..1ad1e52 --- /dev/null +++ b/src/ui/render.rs @@ -0,0 +1,702 @@ +use crate::ui::state::{DashboardState, HealthStatus, PriceDir}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{block::Position, Block, Borders, Cell, Gauge, Paragraph, Row, Sparkline, Table}, + Frame, +}; + +fn border_style() -> Style { + Style::default().fg(Color::Rgb(40, 40, 60)) +} + +fn title_style() -> Style { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) +} + +fn label_style() -> Style { + Style::default().fg(Color::Gray) +} + +fn profit_color(pnl: f64) -> Color { + if pnl > 0.0 { + Color::LightGreen + } else if pnl < 0.0 { + Color::Red + } else { + Color::White + } +} + +fn dir_style(dir: PriceDir) -> Style { + match dir { + PriceDir::Up => Style::default().fg(Color::Green), + PriceDir::Down => Style::default().fg(Color::Red), + PriceDir::Flat => Style::default().fg(Color::DarkGray), + } +} + +fn health_color(status: HealthStatus) -> Color { + match status { + HealthStatus::Ok => Color::Green, + HealthStatus::Warn => Color::Yellow, + HealthStatus::Err => Color::Red, + } +} + +pub fn draw(frame: &mut Frame, app: &DashboardState) { + let area = frame.area(); + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(11), + Constraint::Min(8), + Constraint::Length(9), + Constraint::Length(3), + ]) + .split(area); + + draw_header(frame, root[0], app); + draw_hero_profit(frame, root[1], app); + draw_body(frame, root[2], app); + draw_ticker(frame, root[3], app); + draw_status_bar(frame, root[4], app); +} + +fn draw_header(frame: &mut Frame, area: Rect, app: &DashboardState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style()) + .title(Span::styled( + " POLYMARKET ARB BOT ", + Style::default().fg(Color::Cyan).bold(), + )) + .title_alignment(Alignment::Center); + let inner = block.inner(area); + frame.render_widget(block, area); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(35), + Constraint::Percentage(35), + Constraint::Percentage(30), + ]) + .split(inner); + + let status = if app.connected { + Span::styled(" ● LIVE ", Style::default().fg(Color::Green).bold()) + } else { + Span::styled(" ○ INIT ", Style::default().fg(Color::Yellow)) + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + status, + Span::styled(format!(" uptime {}", app.uptime()), Style::default().fg(Color::White)), + ])), + cols[0], + ); + + frame.render_widget( + Paragraph::new("Automated YES+NO Spread Arbitrage") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Blue).italic()), + cols[1], + ); + + frame.render_widget( + Paragraph::new(format!("UTC {}", app.utc_now().format("%H:%M:%S"))) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::White)), + cols[2], + ); +} + +fn draw_hero_profit(frame: &mut Frame, area: Rect, app: &DashboardState) { + let pulse = app.profit_pulse > 0; + let bg = if pulse { + Color::Rgb(0, 55, 20) + } else { + Color::Rgb(10, 10, 25) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(if pulse { + Style::default().fg(Color::LightGreen) + } else { + Style::default().fg(Color::Yellow) + }) + .style(Style::default().bg(bg)) + .title(Span::styled( + " 💰 SESSION PROFIT 💰 ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )) + .title_alignment(Alignment::Center); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(2), + ]) + .split(inner); + + let sign = if app.session_pnl >= 0.0 { "+" } else { "" }; + let hero = format!("{sign}${:.2}", app.session_pnl); + let dollar_glow = if pulse { " $$$ " } else { " $ " }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()), + Span::styled( + hero, + Style::default() + .fg(profit_color(app.session_pnl)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()), + ])) + .alignment(Alignment::Center), + rows[0], + ); + + let last_trade = if app.last_trade_pnl > 0.0 && !app.last_trade_symbol.is_empty() { + format!( + "▲ +${:.2} last trade ({})", + app.last_trade_pnl, app.last_trade_symbol + ) + } else if app.total_trades > 0 { + "Scanning for next opportunity…".to_string() + } else { + "Waiting for first arbitrage capture…".to_string() + }; + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + last_trade, + Style::default().fg(Color::Green), + ))) + .alignment(Alignment::Center), + rows[1], + ); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(34), + Constraint::Percentage(33), + ]) + .split(rows[2]); + + let window_sign = if app.window_pnl >= 0.0 { "+" } else { "" }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Window PnL ", label_style()), + Span::styled( + format!("{window_sign}${:.2}", app.window_pnl), + Style::default().fg(profit_color(app.window_pnl)).bold(), + ), + ])) + .alignment(Alignment::Center), + cols[0], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Trades ", label_style()), + Span::styled( + format!( + "{} / {} wins", + app.total_trades, app.successful_trades + ), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + format!(" ({:.0}%)", app.win_rate()), + Style::default().fg(Color::Cyan), + ), + ])) + .alignment(Alignment::Center), + cols[1], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Best trade ", label_style()), + Span::styled( + format!("+${:.2}", app.best_trade), + Style::default().fg(Color::LightGreen).bold(), + ), + ])) + .alignment(Alignment::Center), + cols[2], + ); + + let curve_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[3]); + + draw_sparkline_block( + frame, + curve_cols[0], + " SESSION PNL ", + &app.pnl_sparkline, + Color::LightGreen, + ); + draw_sparkline_block( + frame, + curve_cols[1], + " WINDOW PNL ", + &app.window_pnl_sparkline, + Color::Yellow, + ); +} + +fn draw_body(frame: &mut Frame, area: Rect, app: &DashboardState) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area); + + draw_markets_panel(frame, cols[0], app); + draw_side_panel(frame, cols[1], app); +} + +fn draw_markets_panel(frame: &mut Frame, area: Rect, app: &DashboardState) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(48), + Constraint::Length(5), + Constraint::Min(4), + ]) + .split(area); + + draw_market_table(frame, rows[0], app); + draw_global_charts(frame, rows[1], app); + draw_market_edge_curves(frame, rows[2], app); +} + +fn draw_market_table(frame: &mut Frame, area: Rect, app: &DashboardState) { + let header = Row::new(vec!["SYM", "YES", "NO", "Σ", "EDGE"]) + .style(Style::default().fg(Color::Yellow).bold()) + .height(1); + + let table_rows: Vec = if app.markets.is_empty() { + vec![Row::new(vec![Cell::from("—"), Cell::from("waiting…"), Cell::from(""), Cell::from(""), Cell::from("")])] + } else { + app.markets + .iter() + .enumerate() + .map(|(i, m)| { + let total = m.yes_price + m.no_price; + let edge = app.profit_pct(m); + let edge_str = if edge > 0.0 { + format!("+{edge:.2}%") + } else { + "—".to_string() + }; + + let mut style = Style::default().fg(Color::White); + if m.is_arb && app.flash_arb { + style = style.bg(Color::Rgb(0, 48, 0)); + } + if i == app.selected_market { + style = style.fg(Color::Cyan).bold(); + } + + Row::new(vec![ + Cell::from(m.symbol.clone()), + Cell::from(format!("{:.3}{}", m.yes_price, m.yes_dir.arrow())) + .style(dir_style(m.yes_dir)), + Cell::from(format!("{:.3}{}", m.no_price, m.no_dir.arrow())) + .style(dir_style(m.no_dir)), + Cell::from(format!("{total:.3}")), + Cell::from(edge_str).style(if edge > 0.3 { + Style::default().fg(Color::LightGreen).bold() + } else { + Style::default().fg(Color::DarkGray) + }), + ]) + .style(style) + .height(1) + }) + .collect() + }; + + let table = Table::new( + table_rows, + [ + Constraint::Length(5), + Constraint::Length(11), + Constraint::Length(11), + Constraint::Length(7), + Constraint::Min(8), + ], + ) + .header(header) + .block( + Block::default() + .title(" LIVE MARKETS ") + .borders(Borders::ALL) + .border_style(border_style()) + .title_style(title_style()), + ); + + frame.render_widget(table, area); +} + +fn draw_sparkline_block( + frame: &mut Frame, + area: Rect, + title: &str, + data: &[u64], + color: Color, +) { + let spark = Sparkline::default() + .block( + Block::default() + .title(title) + .title_style(title_style()) + .borders(Borders::ALL) + .border_style(border_style()) + .title_position(Position::Top), + ) + .data(data) + .style(Style::default().fg(color)); + frame.render_widget(spark, area); +} + +fn draw_global_charts(frame: &mut Frame, area: Rect, app: &DashboardState) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); + + draw_sparkline_block( + frame, + cols[0], + " EDGE ", + &app.edge_sparkline, + Color::Magenta, + ); + draw_sparkline_block( + frame, + cols[1], + " EXPOSURE ", + &app.exposure_sparkline, + Color::Cyan, + ); + draw_sparkline_block( + frame, + cols[2], + " SCAN RATE ", + &app.scan_rate_sparkline, + Color::Blue, + ); + draw_sparkline_block( + frame, + cols[3], + " PNL FLOW ", + &app.pnl_sparkline, + Color::LightGreen, + ); +} + +fn draw_market_edge_curves(frame: &mut Frame, area: Rect, app: &DashboardState) { + let block = Block::default() + .title(" MARKET EDGE CURVES ") + .title_style(title_style()) + .borders(Borders::ALL) + .border_style(border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.markets.is_empty() { + frame.render_widget( + Paragraph::new("Waiting for market data…").style(label_style()), + inner, + ); + return; + } + + let row_h = inner.height.saturating_sub(1) / app.markets.len().max(1) as u16; + let row_h = row_h.max(2); + + for (i, market) in app.markets.iter().enumerate() { + let y = inner.y + (i as u16 * row_h); + if y >= inner.bottom() { + break; + } + let row_area = Rect { + x: inner.x, + y, + width: inner.width, + height: row_h.min(inner.bottom().saturating_sub(y)), + }; + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(5), Constraint::Min(8), Constraint::Length(8)]) + .split(row_area); + + let edge = app.profit_pct(market); + let edge_label = if edge > 0.0 { + format!("+{edge:.2}%") + } else { + "—".to_string() + }; + + let sym_style = if i == app.selected_market { + Style::default().fg(Color::Cyan).bold() + } else if market.is_arb { + Style::default().fg(Color::LightGreen).bold() + } else { + Style::default().fg(Color::Yellow) + }; + + frame.render_widget(Paragraph::new(market.symbol.as_str()).style(sym_style), cols[0]); + + let curve_color = if market.is_arb { + Color::LightGreen + } else { + Color::Rgb(80, 120, 200) + }; + let spark = Sparkline::default() + .block(Block::default().borders(Borders::NONE)) + .data(market.sparkline.as_slice()) + .style(Style::default().fg(curve_color)); + frame.render_widget(spark, cols[1]); + + frame.render_widget( + Paragraph::new(edge_label) + .alignment(Alignment::Right) + .style(if edge > 0.3 { + Style::default().fg(Color::Green).bold() + } else { + label_style() + }), + cols[2], + ); + } +} + +fn draw_side_panel(frame: &mut Frame, area: Rect, app: &DashboardState) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(42), + Constraint::Length(4), + Constraint::Min(4), + ]) + .split(area); + + draw_risk(frame, rows[0], app); + draw_sparkline_block( + frame, + rows[1], + " EXPOSURE TREND ", + &app.exposure_sparkline, + Color::Cyan, + ); + draw_system(frame, rows[2], app); +} + +fn draw_risk(frame: &mut Frame, area: Rect, app: &DashboardState) { + let block = Block::default() + .title(" RISK ") + .borders(Borders::ALL) + .border_style(border_style()) + .title_style(title_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Length(3), Constraint::Min(2)]) + .split(inner); + + frame.render_widget( + Paragraph::new(format!( + "Exposure ${:.2} / ${:.0}", + app.exposure, app.exposure_limit + )), + rows[0], + ); + + let pct = app.exposure_pct(); + let gauge_color = if pct > 90.0 { + Color::Red + } else if pct > 75.0 { + Color::Yellow + } else { + Color::Green + }; + let gauge = Gauge::default() + .block(Block::default().borders(Borders::NONE)) + .gauge_style(Style::default().fg(gauge_color)) + .percent(pct.clamp(0.0, 100.0) as u16) + .label(format!("{pct:.0}%")); + frame.render_widget(gauge, rows[1]); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(format!("Positions: {}", app.positions)), + Line::from(format!( + "Last trade: {:.0}s ago", + app.last_trade_secs.min(999.0) + )), + ]), + rows[2], + ); +} + +fn draw_system(frame: &mut Frame, area: Rect, app: &DashboardState) { + let lines: Vec = app + .services + .iter() + .map(|svc| { + Line::from(vec![ + Span::styled( + format!("{} ", svc.status.dot()), + Style::default().fg(health_color(svc.status)), + ), + Span::styled(format!("{:<10}", svc.name), Style::default().fg(Color::White)), + Span::styled( + if svc.latency_ms > 0 { + format!("{}ms", svc.latency_ms) + } else { + "ok".to_string() + }, + label_style(), + ), + ]) + }) + .chain(std::iter::once(Line::from(vec![ + Span::styled("Merge ", label_style()), + Span::styled(&app.merge_status, Style::default().fg(Color::Cyan)), + ]))) + .collect(); + + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .title(" SYSTEM ") + .borders(Borders::ALL) + .border_style(border_style()) + .title_style(title_style()), + ), + area, + ); +} + +fn draw_ticker(frame: &mut Frame, area: Rect, app: &DashboardState) { + let inner_h = area.height.saturating_sub(2) as usize; + let max_lines = inner_h.saturating_sub(1).max(4); + let recent = app.recent_events(max_lines); + let last = recent.len().saturating_sub(1); + + let lines: Vec = recent + .into_iter() + .enumerate() + .map(|(i, msg)| { + let style = if i == last { + Style::default().fg(Color::White) + } else if msg.contains('💰') || msg.contains("ARB") || msg.contains('⚡') { + Style::default().fg(Color::LightGreen) + } else if msg.contains('❌') || msg.contains('⚠') { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + Line::from(vec![ + Span::styled(" › ", Style::default().fg(Color::Rgb(60, 60, 80))), + Span::styled(msg, style), + ]) + }) + .collect(); + + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .title(" EVENT LOG ") + .title_style(title_style()) + .borders(Borders::ALL) + .border_style(border_style()), + ), + area, + ); +} + +fn draw_status_bar(frame: &mut Frame, area: Rect, app: &DashboardState) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(35), + Constraint::Percentage(25), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("WINDOW ", label_style()), + Span::styled(&app.window_label, Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + Span::styled("ends ", label_style()), + Span::styled(app.window_countdown(), Style::default().fg(Color::Yellow).bold()), + ]), + ]), + cols[0], + ); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(vec![ + Span::styled("scans ", label_style()), + Span::styled(format!("{}", app.arb_scans), Style::default().fg(Color::White).bold()), + ]), + Line::from(vec![ + Span::styled("mode ", label_style()), + Span::styled(&app.order_mode, Style::default().fg(Color::Magenta)), + ]), + ]) + .alignment(Alignment::Center), + cols[1], + ); + + frame.render_widget( + Paragraph::new("q quit bot") + .alignment(Alignment::Right) + .style(Style::default().fg(Color::DarkGray)), + cols[2], + ); +} \ No newline at end of file diff --git a/src/ui/runner.rs b/src/ui/runner.rs new file mode 100644 index 0000000..2537d28 --- /dev/null +++ b/src/ui/runner.rs @@ -0,0 +1,86 @@ +use std::io::{self, stdout, Stdout}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crossterm::{ + event::{self, Event, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use crate::ui::state::{DashboardAction, DashboardState}; +use crate::ui::render; + +pub fn spawn_dashboard_thread( + state: Arc>, + shutdown: Arc, +) { + std::thread::spawn(move || { + if let Err(e) = run_dashboard(state, shutdown) { + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("bot.log") + .and_then(|mut f| { + use std::io::Write; + writeln!(f, "TUI exited: {e}") + }); + } + }); +} + +fn run_dashboard( + state: Arc>, + shutdown: Arc, +) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout: Stdout = stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let tick_rate = Duration::from_millis(200); + let mut last_tick = Instant::now(); + + loop { + if shutdown.load(Ordering::Relaxed) { + break; + } + + { + let app = state.lock().expect("dashboard lock"); + terminal.draw(|frame| render::draw(frame, &app))?; + } + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + let action = { + let mut app = state.lock().expect("dashboard lock"); + app.on_key(key.code) + }; + if matches!(action, DashboardAction::Quit) { + shutdown.store(true, Ordering::Relaxed); + break; + } + } + } + } + + if last_tick.elapsed() >= tick_rate { + { + let mut app = state.lock().expect("dashboard lock"); + app.on_render_tick(); + } + last_tick = Instant::now(); + } + } + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/ui/state.rs b/src/ui/state.rs new file mode 100644 index 0000000..7300ac2 --- /dev/null +++ b/src/ui/state.rs @@ -0,0 +1,619 @@ +use chrono::{DateTime, Utc}; +use rand::Rng; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const SPARKLINE_LEN: usize = 36; +const EVENT_COUNT: usize = 30; + +fn push_sparkline(buf: &mut Vec, val: u64) { + if buf.len() >= SPARKLINE_LEN { + buf.remove(0); + } + buf.push(val.max(1)); +} + +fn random_sparkline(rng: &mut impl Rng, len: usize, start: u64, drift: i64) -> Vec { + let mut out = Vec::with_capacity(len); + let mut cur = start; + for _ in 0..len { + cur = ((cur as i64) + rng.gen_range(-1..=2) + drift).clamp(1, 20) as u64; + out.push(cur); + } + out +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PriceDir { + Up, + Down, + Flat, +} + +impl PriceDir { + pub fn arrow(self) -> &'static str { + match self { + PriceDir::Up => "↑", + PriceDir::Down => "↓", + PriceDir::Flat => "−", + } + } + + pub fn from_delta(delta: f64) -> Self { + if delta > 0.0005 { + PriceDir::Up + } else if delta < -0.0005 { + PriceDir::Down + } else { + PriceDir::Flat + } + } +} + +#[derive(Clone)] +pub struct MarketRow { + pub symbol: String, + pub yes_price: f64, + pub no_price: f64, + pub yes_dir: PriceDir, + pub no_dir: PriceDir, + pub is_arb: bool, + pub sparkline: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum HealthStatus { + Ok, + Warn, + Err, +} + +impl HealthStatus { + pub fn dot(self) -> &'static str { + match self { + HealthStatus::Ok => "●", + HealthStatus::Warn => "◐", + HealthStatus::Err => "○", + } + } +} + +#[derive(Clone)] +pub struct ServiceHealth { + pub name: &'static str, + pub status: HealthStatus, + pub latency_ms: u32, +} + +pub struct DashboardState { + pub started_at: Instant, + pub frame: u64, + pub live_mode: bool, + pub markets: Vec, + pub selected_market: usize, + pub bid_depth: f64, + pub ask_depth: f64, + pub spread: f64, + pub depth_k: f64, + pub last_trade_secs: f32, + pub exposure: f64, + pub exposure_limit: f64, + pub positions: u32, + pub arb_scans: u64, + pub window_secs_left: u32, + pub window_label: String, + pub order_mode: String, + pub events: Vec, + pub services: Vec, + pub merge_status: String, + pub flash_arb: bool, + /// Hero metrics — profit is the star of the show. + pub session_pnl: f64, + pub window_pnl: f64, + pub last_trade_pnl: f64, + pub total_trades: u32, + pub successful_trades: u32, + pub best_trade: f64, + pub pnl_sparkline: Vec, + pub window_pnl_sparkline: Vec, + pub exposure_sparkline: Vec, + pub edge_sparkline: Vec, + pub scan_rate_sparkline: Vec, + /// Frames remaining for profit pulse animation after a win. + pub profit_pulse: u32, + pub last_trade_symbol: String, + pub connected: bool, +} + +impl DashboardState { + pub fn new_live(order_mode: impl Into, exposure_limit: f64) -> Self { + Self { + started_at: Instant::now(), + frame: 0, + live_mode: true, + markets: Vec::new(), + selected_market: 0, + bid_depth: 0.5, + ask_depth: 0.5, + spread: 0.0, + depth_k: 0.0, + last_trade_secs: 999.0, + exposure: 0.0, + exposure_limit, + positions: 0, + arb_scans: 0, + window_secs_left: 300, + window_label: "updown-5m".to_string(), + order_mode: order_mode.into(), + events: vec!["🚀 Bot started — scanning for arbitrage…".to_string()], + services: vec![ + ServiceHealth { + name: "CLOB WS", + status: HealthStatus::Ok, + latency_ms: 0, + }, + ServiceHealth { + name: "CLOB API", + status: HealthStatus::Ok, + latency_ms: 0, + }, + ], + merge_status: "idle".to_string(), + flash_arb: false, + session_pnl: 0.0, + window_pnl: 0.0, + last_trade_pnl: 0.0, + total_trades: 0, + successful_trades: 0, + best_trade: 0.0, + pnl_sparkline: vec![2, 3, 3, 4, 5, 6, 5, 7, 8, 9, 10, 11], + window_pnl_sparkline: vec![1, 2, 2, 3, 4, 5, 4, 6, 7, 8], + exposure_sparkline: vec![3, 4, 5, 4, 6, 5, 7, 6, 8, 7], + edge_sparkline: vec![2, 4, 3, 5, 6, 7, 5, 8, 9, 7], + scan_rate_sparkline: vec![4, 5, 6, 5, 7, 8, 7, 9, 8, 10], + profit_pulse: 0, + last_trade_symbol: String::new(), + connected: false, + } + } + + pub fn new_demo() -> Self { + let mut rng = rand::thread_rng(); + + // Pretend the bot has been running for 25–175 minutes. + let elapsed_secs = rng.gen_range(25 * 60..175 * 60); + let session_pnl = rng.gen_range(48.0..286.0); + let window_ratio = rng.gen_range(0.12..0.38); + let window_pnl = session_pnl * window_ratio; + let total_trades = rng.gen_range(18..76); + let win_ratio = rng.gen_range(0.58..0.82); + let successful_trades = ((total_trades as f64) * win_ratio).round() as u32; + let best_trade = rng.gen_range(3.5..14.0); + let last_trade_pnl = rng.gen_range(1.2..best_trade); + let exposure = rng.gen_range(180.0..780.0); + let arb_scans = rng.gen_range(900..5200); + let window_secs_left = rng.gen_range(45..260); + let last_trade_secs = rng.gen_range(1.5..18.0); + let symbols = ["BTC", "ETH", "SOL", "XRP"]; + let last_sym = symbols[rng.gen_range(0..symbols.len())]; + + let mut s = Self::new_live("GTD/FAK", 1000.0); + s.live_mode = false; + s.started_at = Instant::now() - Duration::from_secs(elapsed_secs); + s.frame = rng.gen_range(200..1200); + s.session_pnl = session_pnl; + s.window_pnl = window_pnl; + s.last_trade_pnl = last_trade_pnl; + s.total_trades = total_trades; + s.successful_trades = successful_trades.min(total_trades); + s.best_trade = best_trade; + s.last_trade_secs = last_trade_secs; + s.last_trade_symbol = last_sym.to_string(); + s.connected = true; + s.exposure = exposure; + s.arb_scans = arb_scans; + s.window_secs_left = window_secs_left; + s.window_label = format!("{}-updown-5m", last_sym.to_lowercase()); + + let pnl_end = ((session_pnl / 4.0).clamp(2.0, 20.0)) as u64; + s.pnl_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, pnl_end.saturating_sub(8), 1); + s.window_pnl_sparkline = + random_sparkline(&mut rng, SPARKLINE_LEN, (pnl_end / 2).max(2), 1); + s.exposure_sparkline = random_sparkline( + &mut rng, + SPARKLINE_LEN, + ((exposure / 1000.0) * 16.0) as u64 + 2, + 0, + ); + let edge_start = rng.gen_range(4..9); + let scan_start = rng.gen_range(5..10); + s.edge_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, edge_start, 0); + s.scan_rate_sparkline = random_sparkline(&mut rng, SPARKLINE_LEN, scan_start, 0); + + s.markets = vec![ + MarketRow { + symbol: "BTC".into(), + yes_price: rng.gen_range(0.44..0.52), + no_price: rng.gen_range(0.46..0.54), + yes_dir: PriceDir::Up, + no_dir: PriceDir::Down, + is_arb: true, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 6, 1), + }, + MarketRow { + symbol: "ETH".into(), + yes_price: rng.gen_range(0.46..0.54), + no_price: rng.gen_range(0.44..0.52), + yes_dir: PriceDir::Flat, + no_dir: PriceDir::Up, + is_arb: false, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0), + }, + MarketRow { + symbol: "SOL".into(), + yes_price: rng.gen_range(0.42..0.50), + no_price: rng.gen_range(0.48..0.56), + yes_dir: PriceDir::Down, + no_dir: PriceDir::Up, + is_arb: true, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 7, 1), + }, + MarketRow { + symbol: "XRP".into(), + yes_price: rng.gen_range(0.47..0.53), + no_price: rng.gen_range(0.46..0.52), + yes_dir: PriceDir::Up, + no_dir: PriceDir::Flat, + is_arb: false, + sparkline: random_sparkline(&mut rng, SPARKLINE_LEN / 2, 4, 0), + }, + ]; + + s.events = Self::demo_event_history( + &mut rng, + session_pnl, + window_pnl, + last_sym, + last_trade_pnl, + successful_trades, + ); + + // Fast-forward 60–180 ticks so curves & prices look mid-session, not freshly booted. + let warmup_ticks = rng.gen_range(60..180); + s.warmup_demo(warmup_ticks); + + s + } + + fn demo_event_history( + rng: &mut impl Rng, + session_pnl: f64, + window_pnl: f64, + last_sym: &str, + last_trade_pnl: f64, + wins: u32, + ) -> Vec { + let symbols = ["BTC", "ETH", "SOL", "XRP"]; + let mut events = vec![ + format!("🚀 Session started — running for a while already"), + format!("📡 Subscribed 8 orderbook tokens (4 markets)"), + format!( + "💰 Window PnL +${window_pnl:.2} | session +${session_pnl:.2}" + ), + ]; + for _ in 0..rng.gen_range(2..5) { + let sym = symbols[rng.gen_range(0..symbols.len())]; + let p = rng.gen_range(0.8..6.5); + events.push(format!("💰 +${p:.2} captured on {sym}")); + } + events.push(format!( + "⚡ ARB {last_sym} +${last_trade_pnl:.2} | {wins} wins so far" + )); + events.push("📊 Spread stable — scanning next window".to_string()); + events + } + + /// Silently simulate market motion before the dashboard is shown. + fn warmup_demo(&mut self, ticks: u64) { + for _ in 0..ticks { + self.frame = self.frame.wrapping_add(1); + if self.frame % 2 == 0 { + self.tick_sparklines(); + } + self.on_demo_tick(false); + } + } + + pub fn win_rate(&self) -> f64 { + if self.total_trades == 0 { + 0.0 + } else { + (self.successful_trades as f64 / self.total_trades as f64) * 100.0 + } + } + + pub fn uptime(&self) -> String { + let secs = self.started_at.elapsed().as_secs(); + format!("{:02}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60) + } + + pub fn utc_now(&self) -> DateTime { + Utc::now() + } + + pub fn window_countdown(&self) -> String { + let m = self.window_secs_left / 60; + let s = self.window_secs_left % 60; + format!("{m:02}:{s:02}") + } + + pub fn exposure_pct(&self) -> f64 { + if self.exposure_limit <= 0.0 { + 0.0 + } else { + (self.exposure / self.exposure_limit) * 100.0 + } + } + + pub fn profit_pct(&self, market: &MarketRow) -> f64 { + let t = market.yes_price + market.no_price; + if t < 1.0 { + (1.0 - t) * 100.0 + } else { + 0.0 + } + } + + /// Return the most recent events for the log panel (oldest first). + pub fn recent_events(&self, count: usize) -> Vec { + if self.events.is_empty() { + return vec!["Waiting for events…".to_string()]; + } + let take = count.min(self.events.len()); + self.events[self.events.len() - take..].to_vec() + } + + pub fn push_event(&mut self, msg: impl Into) { + let msg = msg.into(); + if self.events.len() >= EVENT_COUNT { + self.events.remove(0); + } + self.events.push(msg); + } + + pub fn set_window(&mut self, label: impl Into, secs_left: u32) { + self.window_label = label.into(); + self.window_secs_left = secs_left; + } + + pub fn set_connected(&mut self, connected: bool) { + self.connected = connected; + } + + pub fn set_exposure(&mut self, exposure: f64) { + self.exposure = exposure; + } + + pub fn set_merge_status(&mut self, status: impl Into) { + self.merge_status = status.into(); + } + + pub fn ensure_market(&mut self, symbol: impl Into) { + let symbol = symbol.into(); + if !self.markets.iter().any(|m| m.symbol == symbol) { + self.markets.push(MarketRow { + symbol, + yes_price: 0.0, + no_price: 0.0, + yes_dir: PriceDir::Flat, + no_dir: PriceDir::Flat, + is_arb: false, + sparkline: vec![1], + }); + } + } + + pub fn update_market( + &mut self, + symbol: &str, + yes: f64, + no: f64, + is_arb: bool, + ) { + self.ensure_market(symbol); + if let Some(row) = self.markets.iter_mut().find(|m| m.symbol == symbol) { + let yes_dir = PriceDir::from_delta(yes - row.yes_price); + let no_dir = PriceDir::from_delta(no - row.no_price); + if row.yes_price > 0.0 { + let edge = ((1.0 - yes - no).max(0.0) * 100.0 * 10.0) as u64; + push_sparkline(&mut row.sparkline, edge); + } + row.yes_price = yes; + row.no_price = no; + row.yes_dir = yes_dir; + row.no_dir = no_dir; + row.is_arb = is_arb; + } + self.arb_scans = self.arb_scans.saturating_add(1); + self.connected = true; + self.spread = (yes - no).abs(); + self.bid_depth = 0.4 + (yes * 0.3); + self.ask_depth = 0.4 + (no * 0.3); + self.depth_k = yes + no; + } + + pub fn record_trade_attempt(&mut self, symbol: &str, profit_pct: f64, size: f64, cost: f64) { + self.total_trades = self.total_trades.saturating_add(1); + self.last_trade_symbol = symbol.to_string(); + self.push_event(format!( + "⚡ Executing {symbol} | edge +{profit_pct:.2}% | ${cost:.2}" + )); + let _ = size; + } + + pub fn record_trade_success(&mut self, symbol: &str, profit_usd: f64, profit_pct: f64) { + self.successful_trades = self.successful_trades.saturating_add(1); + self.session_pnl += profit_usd; + self.window_pnl += profit_usd; + self.last_trade_pnl = profit_usd; + self.last_trade_symbol = symbol.to_string(); + self.last_trade_secs = 0.0; + if profit_usd > self.best_trade { + self.best_trade = profit_usd; + } + self.profit_pulse = 15; + let spark_val = ((self.session_pnl / 5.0).clamp(2.0, 20.0)) as u64; + push_sparkline(&mut self.pnl_sparkline, spark_val); + push_sparkline( + &mut self.window_pnl_sparkline, + ((self.window_pnl / 2.0).clamp(1.0, 20.0)) as u64, + ); + self.push_event(format!( + "💰 +${profit_usd:.2} captured on {symbol} (+{profit_pct:.2}% edge) | session ${:.2}", + self.session_pnl + )); + } + + pub fn record_trade_failure(&mut self, symbol: &str, err: &str) { + self.push_event(format!("❌ {symbol} failed: {err}")); + } + + pub fn on_render_tick(&mut self) { + self.frame = self.frame.wrapping_add(1); + + if self.last_trade_secs < 999.0 { + self.last_trade_secs += 0.2; + } + + self.flash_arb = self.frame % 16 < 4; + if self.profit_pulse > 0 { + self.profit_pulse -= 1; + } + + if self.frame % 2 == 0 { + self.tick_sparklines(); + } + + if !self.live_mode { + self.on_demo_tick(true); + } + } + + fn tick_sparklines(&mut self) { + let pnl_val = ((self.session_pnl / 4.0).clamp(1.0, 20.0)) as u64; + push_sparkline(&mut self.pnl_sparkline, pnl_val); + + let window_val = ((self.window_pnl / 2.0).clamp(1.0, 20.0)) as u64; + push_sparkline(&mut self.window_pnl_sparkline, window_val); + + let exp_pct = if self.exposure_limit > 0.0 { + self.exposure / self.exposure_limit + } else { + 0.0 + }; + let exp_val = (exp_pct * 18.0).round() as u64 + 2; + push_sparkline(&mut self.exposure_sparkline, exp_val); + + let avg_edge = if self.markets.is_empty() { + 2 + } else { + let sum: f64 = self.markets.iter().map(|m| self.profit_pct(m)).sum(); + ((sum / self.markets.len() as f64) * 4.0).clamp(1.0, 20.0) as u64 + }; + push_sparkline(&mut self.edge_sparkline, avg_edge); + + let breath = 6 + ((self.frame as f64 * 0.12).sin() * 4.0).round() as u64; + let scan_val = breath.saturating_add(self.arb_scans % 5); + push_sparkline(&mut self.scan_rate_sparkline, scan_val); + } + + fn on_demo_tick(&mut self, grow_pnl: bool) { + let mut rng = rand::thread_rng(); + if grow_pnl && self.frame % 5 == 0 && self.window_secs_left > 0 { + self.window_secs_left = self.window_secs_left.saturating_sub(1); + } + if grow_pnl { + self.session_pnl += rng.gen_range(0.0..0.15); + self.window_pnl += rng.gen_range(0.0..0.08); + self.arb_scans = self.arb_scans.wrapping_add(rng.gen_range(1..=4)); + } + for market in &mut self.markets { + let j: f64 = rng.gen_range(-0.002..0.002); + market.yes_price = (market.yes_price + j).clamp(0.01, 0.99); + market.no_price = (market.no_price - j * 0.5).clamp(0.01, 0.99); + let edge = ((1.0 - market.yes_price - market.no_price).max(0.0) * 100.0 * 10.0) as u64; + push_sparkline(&mut market.sparkline, edge.max(1)); + } + } + + pub fn on_key(&mut self, key: crossterm::event::KeyCode) -> DashboardAction { + match key { + crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => { + DashboardAction::Quit + } + crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => { + if self.selected_market > 0 { + self.selected_market -= 1; + } + DashboardAction::None + } + crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => { + if self.selected_market + 1 < self.markets.len() { + self.selected_market += 1; + } + DashboardAction::None + } + _ => DashboardAction::None, + } + } +} + +pub enum DashboardAction { + None, + Quit, +} + +#[derive(Clone)] +pub struct DashboardHandle { + inner: Arc>, +} + +impl DashboardHandle { + pub fn new_live(order_mode: impl Into, exposure_limit: f64) -> Self { + Self { + inner: Arc::new(Mutex::new(DashboardState::new_live( + order_mode, + exposure_limit, + ))), + } + } + + pub fn new_demo() -> Self { + Self { + inner: Arc::new(Mutex::new(DashboardState::new_demo())), + } + } + + pub fn arc(&self) -> Arc> { + self.inner.clone() + } + + pub fn with_mut(&self, f: impl FnOnce(&mut DashboardState) -> R) -> R { + let mut guard = self.inner.lock().expect("dashboard lock"); + f(&mut guard) + } +} + +pub fn symbol_short(crypto_symbol: &str) -> String { + match crypto_symbol.to_lowercase().as_str() { + "bitcoin" => "BTC".into(), + "ethereum" => "ETH".into(), + "solana" => "SOL".into(), + "xrp" => "XRP".into(), + other => other.chars().take(4).collect::().to_uppercase(), + } +} + +pub fn decimal_to_f64(d: rust_decimal::Decimal) -> f64 { + d.to_string().parse().unwrap_or(0.0) +} diff --git a/src/utils/arbitrage_logger.rs b/src/utils/arbitrage_logger.rs new file mode 100644 index 0000000..137cf13 --- /dev/null +++ b/src/utils/arbitrage_logger.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use chrono::Utc; +use serde::Serialize; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use tracing::error; + +use crate::monitor::ArbitrageOpportunity; + +#[derive(Serialize)] +struct ArbitrageRecord<'a> { + timestamp: String, + market_id: String, + market_name: &'a str, + yes_token_id: String, + no_token_id: String, + yes_ask_price: String, + no_ask_price: String, + total_cost: String, + profit_percentage: String, + yes_size: String, + no_size: String, +} + +/// 默认日志文件路径(可配置) +pub const DEFAULT_ARBITRAGE_LOG: &str = "arbitrage_opportunities.jsonl"; + +/// Write arbitrage opportunity to file (JSON Lines format,每行一条记录,方便解析) +pub fn log_arbitrage_opportunity( + opp: &ArbitrageOpportunity, + market_name: &str, + file_path: &str, +) -> Result<()> { + let record = ArbitrageRecord { + timestamp: Utc::now().to_rfc3339(), + market_id: format!("{:?}", opp.market_id), + market_name, + yes_token_id: opp.yes_token_id.to_string(), + no_token_id: opp.no_token_id.to_string(), + yes_ask_price: opp.yes_ask_price.to_string(), + no_ask_price: opp.no_ask_price.to_string(), + total_cost: opp.total_cost.to_string(), + profit_percentage: opp.profit_percentage.to_string(), + yes_size: opp.yes_size.to_string(), + no_size: opp.no_size.to_string(), + }; + + let json = serde_json::to_string(&record)?; + + if !Path::new(file_path).exists() { + if let Some(parent) = Path::new(file_path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + } + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(file_path)?; + + writeln!(file, "{}", json)?; + file.flush()?; + + Ok(()) +} + +/// Async version of arbitrage log (avoids blocking) +pub async fn log_arbitrage_opportunity_async( + opp: &ArbitrageOpportunity, + market_name: &str, + file_path: &str, +) { + let market_name = market_name.to_string(); + let opp = opp.clone(); + let file_path = file_path.to_string(); + tokio::task::spawn_blocking(move || { + if let Err(e) = log_arbitrage_opportunity(&opp, &market_name, &file_path) { + error!(error = %e, "Failed to write arbitrage log file"); + } + }) + .await + .ok(); +} \ No newline at end of file diff --git a/src/utils/errors.rs b/src/utils/errors.rs new file mode 100644 index 0000000..4beb264 --- /dev/null +++ b/src/utils/errors.rs @@ -0,0 +1,2 @@ +// Simplified error handling using anyhow::Error +// Extend with finer-grained types if needed diff --git a/src/utils/logger.rs b/src/utils/logger.rs new file mode 100644 index 0000000..cb9fa9c --- /dev/null +++ b/src/utils/logger.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use std::fs::File; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +pub fn init_logger(quiet_stdout: bool) -> Result<()> { + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")); + + if quiet_stdout { + let path = std::env::var("LOG_FILE").unwrap_or_else(|_| "bot.log".to_string()); + let file = File::create(&path)?; + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else if let Ok(path) = std::env::var("LOG_FILE") { + let file = File::create(path)?; + tracing_subscriber::registry() + .with(env_filter) + .with( + tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false), + ) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .init(); + } + + Ok(()) +} + +pub fn tui_enabled_from_env() -> bool { + if std::env::var("PLAIN_LOGS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + { + return false; + } + !std::env::var("TUI_ENABLED") + .map(|v| v == "0" || v.eq_ignore_ascii_case("false")) + .unwrap_or(false) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..02fd6b6 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod arbitrage_logger; +pub mod errors; +pub mod logger; diff --git a/src/wallet_kind.rs b/src/wallet_kind.rs new file mode 100644 index 0000000..777c3d0 --- /dev/null +++ b/src/wallet_kind.rs @@ -0,0 +1,20 @@ +//! Wallet execution routing for relayer / Safe on-chain ops. + +use crate::deposit_wallet_relay::use_deposit_wallet_relayer; + +#[derive(Debug, Clone, Copy)] +pub enum WalletKind { + DepositWallet, + MagicProxy, + GnosisSafe, +} + +pub fn classify_wallet(code_len: usize) -> WalletKind { + if use_deposit_wallet_relayer() { + WalletKind::DepositWallet + } else if code_len < 150 { + WalletKind::MagicProxy + } else { + WalletKind::GnosisSafe + } +}