wayfinder-paths 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (149) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +399 -0
  2. wayfinder_paths/__init__.py +22 -0
  3. wayfinder_paths/abis/generic/erc20.json +383 -0
  4. wayfinder_paths/adapters/__init__.py +0 -0
  5. wayfinder_paths/adapters/balance_adapter/README.md +94 -0
  6. wayfinder_paths/adapters/balance_adapter/adapter.py +238 -0
  7. wayfinder_paths/adapters/balance_adapter/examples.json +6 -0
  8. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  9. wayfinder_paths/adapters/balance_adapter/test_adapter.py +59 -0
  10. wayfinder_paths/adapters/brap_adapter/README.md +249 -0
  11. wayfinder_paths/adapters/brap_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/brap_adapter/adapter.py +726 -0
  13. wayfinder_paths/adapters/brap_adapter/examples.json +175 -0
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +11 -0
  15. wayfinder_paths/adapters/brap_adapter/test_adapter.py +286 -0
  16. wayfinder_paths/adapters/hyperlend_adapter/__init__.py +7 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +305 -0
  18. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +10 -0
  19. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +274 -0
  20. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +18 -0
  21. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +1093 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +549 -0
  23. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +8 -0
  24. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +1050 -0
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +126 -0
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +219 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +220 -0
  28. wayfinder_paths/adapters/hyperliquid_adapter/utils.py +134 -0
  29. wayfinder_paths/adapters/ledger_adapter/README.md +145 -0
  30. wayfinder_paths/adapters/ledger_adapter/__init__.py +7 -0
  31. wayfinder_paths/adapters/ledger_adapter/adapter.py +289 -0
  32. wayfinder_paths/adapters/ledger_adapter/examples.json +137 -0
  33. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +11 -0
  34. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +205 -0
  35. wayfinder_paths/adapters/pool_adapter/README.md +206 -0
  36. wayfinder_paths/adapters/pool_adapter/__init__.py +7 -0
  37. wayfinder_paths/adapters/pool_adapter/adapter.py +282 -0
  38. wayfinder_paths/adapters/pool_adapter/examples.json +143 -0
  39. wayfinder_paths/adapters/pool_adapter/manifest.yaml +10 -0
  40. wayfinder_paths/adapters/pool_adapter/test_adapter.py +220 -0
  41. wayfinder_paths/adapters/token_adapter/README.md +101 -0
  42. wayfinder_paths/adapters/token_adapter/__init__.py +3 -0
  43. wayfinder_paths/adapters/token_adapter/adapter.py +96 -0
  44. wayfinder_paths/adapters/token_adapter/examples.json +26 -0
  45. wayfinder_paths/adapters/token_adapter/manifest.yaml +6 -0
  46. wayfinder_paths/adapters/token_adapter/test_adapter.py +125 -0
  47. wayfinder_paths/config.example.json +22 -0
  48. wayfinder_paths/conftest.py +31 -0
  49. wayfinder_paths/core/__init__.py +18 -0
  50. wayfinder_paths/core/adapters/BaseAdapter.py +65 -0
  51. wayfinder_paths/core/adapters/__init__.py +5 -0
  52. wayfinder_paths/core/adapters/base.py +5 -0
  53. wayfinder_paths/core/adapters/models.py +46 -0
  54. wayfinder_paths/core/analytics/__init__.py +11 -0
  55. wayfinder_paths/core/analytics/bootstrap.py +57 -0
  56. wayfinder_paths/core/analytics/stats.py +48 -0
  57. wayfinder_paths/core/analytics/test_analytics.py +170 -0
  58. wayfinder_paths/core/clients/AuthClient.py +83 -0
  59. wayfinder_paths/core/clients/BRAPClient.py +109 -0
  60. wayfinder_paths/core/clients/ClientManager.py +210 -0
  61. wayfinder_paths/core/clients/HyperlendClient.py +192 -0
  62. wayfinder_paths/core/clients/LedgerClient.py +443 -0
  63. wayfinder_paths/core/clients/PoolClient.py +128 -0
  64. wayfinder_paths/core/clients/SimulationClient.py +192 -0
  65. wayfinder_paths/core/clients/TokenClient.py +89 -0
  66. wayfinder_paths/core/clients/TransactionClient.py +63 -0
  67. wayfinder_paths/core/clients/WalletClient.py +94 -0
  68. wayfinder_paths/core/clients/WayfinderClient.py +269 -0
  69. wayfinder_paths/core/clients/__init__.py +48 -0
  70. wayfinder_paths/core/clients/protocols.py +392 -0
  71. wayfinder_paths/core/clients/sdk_example.py +110 -0
  72. wayfinder_paths/core/config.py +458 -0
  73. wayfinder_paths/core/constants/__init__.py +26 -0
  74. wayfinder_paths/core/constants/base.py +42 -0
  75. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  76. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  77. wayfinder_paths/core/engine/StrategyJob.py +188 -0
  78. wayfinder_paths/core/engine/__init__.py +5 -0
  79. wayfinder_paths/core/engine/manifest.py +97 -0
  80. wayfinder_paths/core/services/__init__.py +0 -0
  81. wayfinder_paths/core/services/base.py +179 -0
  82. wayfinder_paths/core/services/local_evm_txn.py +430 -0
  83. wayfinder_paths/core/services/local_token_txn.py +231 -0
  84. wayfinder_paths/core/services/web3_service.py +45 -0
  85. wayfinder_paths/core/settings.py +61 -0
  86. wayfinder_paths/core/strategies/Strategy.py +280 -0
  87. wayfinder_paths/core/strategies/__init__.py +5 -0
  88. wayfinder_paths/core/strategies/base.py +7 -0
  89. wayfinder_paths/core/strategies/descriptors.py +81 -0
  90. wayfinder_paths/core/utils/__init__.py +1 -0
  91. wayfinder_paths/core/utils/evm_helpers.py +206 -0
  92. wayfinder_paths/core/utils/wallets.py +77 -0
  93. wayfinder_paths/core/wallets/README.md +91 -0
  94. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  95. wayfinder_paths/core/wallets/__init__.py +7 -0
  96. wayfinder_paths/policies/enso.py +17 -0
  97. wayfinder_paths/policies/erc20.py +34 -0
  98. wayfinder_paths/policies/evm.py +21 -0
  99. wayfinder_paths/policies/hyper_evm.py +19 -0
  100. wayfinder_paths/policies/hyperlend.py +12 -0
  101. wayfinder_paths/policies/hyperliquid.py +30 -0
  102. wayfinder_paths/policies/moonwell.py +54 -0
  103. wayfinder_paths/policies/prjx.py +30 -0
  104. wayfinder_paths/policies/util.py +27 -0
  105. wayfinder_paths/run_strategy.py +411 -0
  106. wayfinder_paths/scripts/__init__.py +0 -0
  107. wayfinder_paths/scripts/create_strategy.py +181 -0
  108. wayfinder_paths/scripts/make_wallets.py +169 -0
  109. wayfinder_paths/scripts/run_strategy.py +124 -0
  110. wayfinder_paths/scripts/validate_manifests.py +213 -0
  111. wayfinder_paths/strategies/__init__.py +0 -0
  112. wayfinder_paths/strategies/basis_trading_strategy/README.md +213 -0
  113. wayfinder_paths/strategies/basis_trading_strategy/__init__.py +3 -0
  114. wayfinder_paths/strategies/basis_trading_strategy/constants.py +1 -0
  115. wayfinder_paths/strategies/basis_trading_strategy/examples.json +16 -0
  116. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +23 -0
  117. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1011 -0
  118. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +4522 -0
  119. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +727 -0
  120. wayfinder_paths/strategies/basis_trading_strategy/types.py +39 -0
  121. wayfinder_paths/strategies/config.py +85 -0
  122. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +100 -0
  123. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +8 -0
  124. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  125. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +2270 -0
  126. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +352 -0
  127. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +96 -0
  128. wayfinder_paths/strategies/stablecoin_yield_strategy/examples.json +17 -0
  129. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  130. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1810 -0
  131. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +520 -0
  132. wayfinder_paths/templates/adapter/README.md +105 -0
  133. wayfinder_paths/templates/adapter/adapter.py +26 -0
  134. wayfinder_paths/templates/adapter/examples.json +8 -0
  135. wayfinder_paths/templates/adapter/manifest.yaml +6 -0
  136. wayfinder_paths/templates/adapter/test_adapter.py +49 -0
  137. wayfinder_paths/templates/strategy/README.md +153 -0
  138. wayfinder_paths/templates/strategy/examples.json +11 -0
  139. wayfinder_paths/templates/strategy/manifest.yaml +8 -0
  140. wayfinder_paths/templates/strategy/strategy.py +57 -0
  141. wayfinder_paths/templates/strategy/test_strategy.py +197 -0
  142. wayfinder_paths/tests/__init__.py +0 -0
  143. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  144. wayfinder_paths/tests/test_test_coverage.py +212 -0
  145. wayfinder_paths/tests/test_utils.py +64 -0
  146. wayfinder_paths-0.1.7.dist-info/LICENSE +21 -0
  147. wayfinder_paths-0.1.7.dist-info/METADATA +777 -0
  148. wayfinder_paths-0.1.7.dist-info/RECORD +149 -0
  149. wayfinder_paths-0.1.7.dist-info/WHEEL +4 -0
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from eth_utils import to_checksum_address
6
+ from loguru import logger
7
+ from web3 import AsyncWeb3
8
+
9
+ from wayfinder_paths.core.clients.TokenClient import TokenClient
10
+ from wayfinder_paths.core.clients.TransactionClient import TransactionClient
11
+ from wayfinder_paths.core.constants import ZERO_ADDRESS
12
+ from wayfinder_paths.core.constants.erc20_abi import ERC20_APPROVAL_ABI
13
+ from wayfinder_paths.core.services.base import EvmTxn, TokenTxn
14
+ from wayfinder_paths.core.utils.evm_helpers import resolve_chain_id
15
+
16
+
17
+ class LocalTokenTxnService(TokenTxn):
18
+ """Default transaction builder used by adapters."""
19
+
20
+ def __init__(
21
+ self,
22
+ config: dict[str, Any] | None,
23
+ *,
24
+ wallet_provider: EvmTxn,
25
+ simulation: bool = False,
26
+ ) -> None:
27
+ del config, simulation
28
+ self.wallet_provider = wallet_provider
29
+ self.logger = logger.bind(service="DefaultEvmTransactionService")
30
+ self.token_client = TokenClient()
31
+ self.builder = _EvmTransactionBuilder()
32
+
33
+ async def build_send(
34
+ self,
35
+ *,
36
+ token_id: str,
37
+ amount: float,
38
+ from_address: str,
39
+ to_address: str,
40
+ token_info: dict[str, Any] | None = None,
41
+ ) -> tuple[bool, dict[str, Any] | str]:
42
+ """Build the transaction dict for sending tokens between wallets."""
43
+ token_meta = token_info
44
+ if token_meta is None:
45
+ token_meta = await self.token_client.get_token_details(token_id)
46
+ if not token_meta:
47
+ return False, f"Token not found: {token_id}"
48
+
49
+ chain_id = resolve_chain_id(token_meta, self.logger)
50
+ if chain_id is None:
51
+ return False, f"Token {token_id} is missing a chain id"
52
+
53
+ token_address = (token_meta or {}).get("address") or ZERO_ADDRESS
54
+
55
+ try:
56
+ tx = await self.builder.build_send_transaction(
57
+ from_address=from_address,
58
+ to_address=to_address,
59
+ token_address=token_address,
60
+ amount=amount,
61
+ chain_id=int(chain_id),
62
+ )
63
+ except Exception as exc: # noqa: BLE001
64
+ return False, f"Failed to build send transaction: {exc}"
65
+
66
+ return True, tx
67
+
68
+ def build_erc20_approve(
69
+ self,
70
+ *,
71
+ chain_id: int,
72
+ token_address: str,
73
+ from_address: str,
74
+ spender: str,
75
+ amount: int,
76
+ ) -> tuple[bool, dict[str, Any] | str]:
77
+ """Build the transaction dictionary for an ERC20 approval."""
78
+ try:
79
+ web3 = self.wallet_provider.get_web3(chain_id)
80
+ token_checksum = to_checksum_address(token_address)
81
+ from_checksum = to_checksum_address(from_address)
82
+ spender_checksum = to_checksum_address(spender)
83
+ amount_int = int(amount)
84
+ except (TypeError, ValueError) as exc:
85
+ return False, str(exc)
86
+
87
+ approve_tx = self.builder.build_erc20_approval_transaction(
88
+ chain_id=chain_id,
89
+ token_address=token_checksum,
90
+ from_address=from_checksum,
91
+ spender=spender_checksum,
92
+ amount=amount_int,
93
+ web3=web3,
94
+ )
95
+ return True, approve_tx
96
+
97
+ async def read_erc20_allowance(
98
+ self, chain: Any, token_address: str, from_address: str, spender_address: str
99
+ ) -> dict[str, Any]:
100
+ try:
101
+ chain_id = self._chain_id(chain)
102
+ except (TypeError, ValueError) as exc:
103
+ return {"error": str(exc), "allowance": 0}
104
+
105
+ w3 = self.get_web3(chain_id)
106
+ try:
107
+ contract = w3.eth.contract(
108
+ address=to_checksum_address(token_address), abi=ERC20_APPROVAL_ABI
109
+ )
110
+ allowance = await contract.functions.allowance(
111
+ to_checksum_address(from_address),
112
+ to_checksum_address(spender_address),
113
+ ).call()
114
+ return (True, {"allowance": int(allowance)})
115
+ except Exception as exc: # noqa: BLE001
116
+ self.logger.error(f"Failed to read allowance: {exc}")
117
+ return {"error": f"Allowance query failed: {exc}", "allowance": 0}
118
+ finally:
119
+ await self._close_web3(w3)
120
+
121
+ def _chain_id(self, chain: Any) -> int:
122
+ if isinstance(chain, dict):
123
+ chain_id = chain.get("id") or chain.get("chain_id")
124
+ else:
125
+ chain_id = getattr(chain, "id", None)
126
+ if chain_id is None:
127
+ raise ValueError("Chain ID is required")
128
+ return int(chain_id)
129
+
130
+
131
+ class _EvmTransactionBuilder:
132
+ """Helpers that only build transaction dictionaries for sends and approvals."""
133
+
134
+ def __init__(self) -> None:
135
+ self.transaction_client = TransactionClient()
136
+
137
+ async def build_send_transaction(
138
+ self,
139
+ *,
140
+ from_address: str,
141
+ to_address: str,
142
+ token_address: str | None,
143
+ amount: float,
144
+ chain_id: int,
145
+ ) -> dict[str, Any]:
146
+ """Build the transaction dict for sending native or ERC20 tokens."""
147
+ payload = await self.transaction_client.build_send(
148
+ from_address=from_address,
149
+ to_address=to_address,
150
+ token_address=token_address or "",
151
+ amount=float(amount),
152
+ chain_id=int(chain_id),
153
+ )
154
+ return self._payload_to_tx(
155
+ payload=payload,
156
+ from_address=from_address,
157
+ is_native=not token_address or token_address.lower() == ZERO_ADDRESS,
158
+ )
159
+
160
+ def build_erc20_approval_transaction(
161
+ self,
162
+ *,
163
+ chain_id: int,
164
+ token_address: str,
165
+ from_address: str,
166
+ spender: str,
167
+ amount: int,
168
+ web3: AsyncWeb3,
169
+ ) -> dict[str, Any]:
170
+ """Build an ERC20 approval transaction dict."""
171
+ token_checksum = to_checksum_address(token_address)
172
+ spender_checksum = to_checksum_address(spender)
173
+ from_checksum = to_checksum_address(from_address)
174
+ amount_int = int(amount)
175
+
176
+ contract = web3.eth.contract(address=token_checksum, abi=ERC20_APPROVAL_ABI)
177
+ data = contract.encodeABI(
178
+ fn_name="approve", args=[spender_checksum, amount_int]
179
+ )
180
+
181
+ return {
182
+ "chainId": int(chain_id),
183
+ "from": from_checksum,
184
+ "to": token_checksum,
185
+ "data": data,
186
+ "value": 0,
187
+ }
188
+
189
+ def _payload_to_tx(
190
+ self, payload: dict[str, Any], from_address: str, is_native: bool
191
+ ) -> dict[str, Any]:
192
+ data_root = payload.get("data", payload)
193
+ tx_src = data_root.get("transaction") or data_root
194
+
195
+ chain_id = tx_src.get("chainId") or data_root.get("chain_id")
196
+ if chain_id is None:
197
+ raise ValueError("Transaction payload missing chainId")
198
+
199
+ tx: dict[str, Any] = {"chainId": int(chain_id)}
200
+ tx["from"] = to_checksum_address(from_address)
201
+
202
+ if tx_src.get("to"):
203
+ tx["to"] = to_checksum_address(tx_src["to"])
204
+ if tx_src.get("data"):
205
+ data = tx_src["data"]
206
+ tx["data"] = data if str(data).startswith("0x") else f"0x{data}"
207
+
208
+ val = tx_src.get("value", 0)
209
+ tx["value"] = self._normalize_value(val) if is_native else 0
210
+
211
+ if tx_src.get("gas"):
212
+ tx["gas"] = int(tx_src["gas"])
213
+ if tx_src.get("maxFeePerGas"):
214
+ tx["maxFeePerGas"] = int(tx_src["maxFeePerGas"])
215
+ if tx_src.get("maxPriorityFeePerGas"):
216
+ tx["maxPriorityFeePerGas"] = int(tx_src["maxPriorityFeePerGas"])
217
+ if tx_src.get("gasPrice"):
218
+ tx["gasPrice"] = int(tx_src["gasPrice"])
219
+ if tx_src.get("nonce") is not None:
220
+ tx["nonce"] = int(tx_src["nonce"])
221
+
222
+ return tx
223
+
224
+ def _normalize_value(self, value: Any) -> int:
225
+ if isinstance(value, str):
226
+ if value.startswith("0x"):
227
+ return int(value, 16)
228
+ return int(float(value))
229
+ if isinstance(value, (int, float)):
230
+ return int(value)
231
+ return 0
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from wayfinder_paths.core.services.base import EvmTxn, TokenTxn, Web3Service
4
+ from wayfinder_paths.core.services.local_evm_txn import LocalEvmTxn
5
+ from wayfinder_paths.core.services.local_token_txn import (
6
+ LocalTokenTxnService,
7
+ )
8
+
9
+
10
+ class DefaultWeb3Service(Web3Service):
11
+ """Default implementation that simply wires the provided dependencies together."""
12
+
13
+ def __init__(
14
+ self,
15
+ config: dict | None = None,
16
+ *,
17
+ wallet_provider: EvmTxn | None = None,
18
+ evm_transactions: TokenTxn | None = None,
19
+ simulation: bool = False,
20
+ ) -> None:
21
+ """
22
+ Initialize the service with optional dependency injection.
23
+
24
+ Strategies that already constructed wallet providers or transaction helpers
25
+ can pass them in directly. Otherwise we fall back to the legacy behavior of
26
+ building a LocalWalletProvider + DefaultEvmTransactionService from config.
27
+ """
28
+ cfg = config or {}
29
+ self._wallet_provider = wallet_provider or LocalEvmTxn(cfg)
30
+ if evm_transactions is not None:
31
+ self._evm_transactions = evm_transactions
32
+ else:
33
+ self._evm_transactions = LocalTokenTxnService(
34
+ config=cfg,
35
+ wallet_provider=self._wallet_provider,
36
+ simulation=simulation,
37
+ )
38
+
39
+ @property
40
+ def evm_transactions(self) -> EvmTxn:
41
+ return self._wallet_provider
42
+
43
+ @property
44
+ def token_transactions(self) -> TokenTxn:
45
+ return self._evm_transactions
@@ -0,0 +1,61 @@
1
+ import json
2
+ import os
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class CoreSettings(BaseSettings):
9
+ """
10
+ Core settings for Wayfinder Paths Engine
11
+ These are minimal settings required by the core engine
12
+ """
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env",
16
+ env_file_encoding="utf-8",
17
+ case_sensitive=False,
18
+ extra="ignore", # Ignore extra environment variables (e.g., from Django)
19
+ )
20
+
21
+ # Core API Configuration
22
+ API_ENV: str = Field("development", env="API_ENV")
23
+
24
+ def _compute_default_api_url() -> str:
25
+ """
26
+ Determine default API base URL from config.json if present, otherwise fallback.
27
+ Do not mutate the value (consistent with rpc_urls resolution).
28
+ """
29
+ cfg_path = os.getenv("WAYFINDER_CONFIG_PATH", "config.json")
30
+ base = None
31
+ try:
32
+ with open(cfg_path) as f:
33
+ cfg = json.load(f)
34
+ system = cfg.get("system", {}) if isinstance(cfg, dict) else {}
35
+ candidate = system.get("api_base_url")
36
+ if isinstance(candidate, str) and candidate.strip():
37
+ base = candidate.strip()
38
+ except Exception:
39
+ # Config is optional; ignore errors and use fallback
40
+ pass
41
+
42
+ if not base:
43
+ # Provide a sensible default that includes the full API root
44
+ base = "https://wayfinder.ai/api/v1"
45
+ return base
46
+
47
+ WAYFINDER_API_URL: str = Field(_compute_default_api_url(), env="WAYFINDER_API_URL")
48
+
49
+ # Network Configuration
50
+ NETWORK: str = Field("testnet", env="NETWORK") # mainnet, testnet, devnet
51
+
52
+ # Logging
53
+ LOG_LEVEL: str = Field("INFO", env="LOG_LEVEL")
54
+ LOG_FILE: str = Field("logs/strategy.log", env="LOG_FILE")
55
+
56
+ # Safety
57
+ DRY_RUN: bool = Field(False, env="DRY_RUN")
58
+
59
+
60
+ # Core settings instance
61
+ settings = CoreSettings()
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import traceback
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any, TypedDict
8
+
9
+ from loguru import logger
10
+
11
+ from wayfinder_paths.core.clients.TokenClient import TokenDetails
12
+ from wayfinder_paths.core.services.base import Web3Service
13
+ from wayfinder_paths.core.strategies.descriptors import StratDescriptor
14
+
15
+
16
+ class StatusDict(TypedDict):
17
+ portfolio_value: float
18
+ net_deposit: float
19
+ strategy_status: Any
20
+ gas_available: float
21
+ gassed_up: bool
22
+
23
+
24
+ StatusTuple = tuple[bool, str]
25
+
26
+
27
+ class WalletConfig(TypedDict, total=False):
28
+ """Wallet configuration structure - allows additional fields for flexibility"""
29
+
30
+ address: str
31
+ private_key: str | None
32
+ private_key_hex: str | None
33
+ wallet_type: str | None
34
+
35
+
36
+ class StrategyConfig(TypedDict, total=False):
37
+ """Base strategy configuration structure - allows additional fields for flexibility"""
38
+
39
+ main_wallet: WalletConfig | None
40
+ strategy_wallet: WalletConfig | None
41
+ wallet_type: str | None
42
+
43
+
44
+ class LiquidationResult(TypedDict):
45
+ usd_value: float
46
+ token: TokenDetails
47
+ amt: int
48
+
49
+
50
+ class Strategy(ABC):
51
+ name: str | None = None
52
+ INFO: StratDescriptor | None = None
53
+
54
+ def __init__(
55
+ self,
56
+ config: StrategyConfig | dict[str, Any] | None = None,
57
+ *,
58
+ main_wallet: WalletConfig | dict[str, Any] | None = None,
59
+ strategy_wallet: WalletConfig | dict[str, Any] | None = None,
60
+ simulation: bool = False,
61
+ web3_service: Web3Service | None = None,
62
+ api_key: str | None = None,
63
+ ):
64
+ self.adapters = {}
65
+ self.ledger_adapter = None
66
+ self.logger = logger.bind(strategy=self.__class__.__name__)
67
+ if api_key:
68
+ os.environ["WAYFINDER_API_KEY"] = api_key
69
+
70
+ self.config = config
71
+
72
+ async def setup(self) -> None:
73
+ """Initialize strategy-specific setup after construction"""
74
+ pass
75
+
76
+ async def log(self, msg: str) -> None:
77
+ """Log messages - can be overridden by subclasses"""
78
+ self.logger.info(msg)
79
+
80
+ async def quote(self) -> None:
81
+ """Get quotes for potential trades - optional for strategies"""
82
+ pass
83
+
84
+ def _get_strategy_wallet_address(self) -> str:
85
+ """Get strategy wallet address with validation."""
86
+ strategy_wallet = self.config.get("strategy_wallet")
87
+ if not strategy_wallet or not isinstance(strategy_wallet, dict):
88
+ raise ValueError("strategy_wallet not configured in strategy config")
89
+ address = strategy_wallet.get("address")
90
+ if not address:
91
+ raise ValueError("strategy_wallet address not found in config")
92
+ return str(address)
93
+
94
+ def _get_main_wallet_address(self) -> str:
95
+ """Get main wallet address with validation."""
96
+ main_wallet = self.config.get("main_wallet")
97
+ if not main_wallet or not isinstance(main_wallet, dict):
98
+ raise ValueError("main_wallet not configured in strategy config")
99
+ address = main_wallet.get("address")
100
+ if not address:
101
+ raise ValueError("main_wallet address not found in config")
102
+ return str(address)
103
+
104
+ @abstractmethod
105
+ async def deposit(self, **kwargs) -> StatusTuple:
106
+ """
107
+ Deposit funds into the strategy.
108
+
109
+ Args:
110
+ **kwargs: Strategy-specific deposit parameters. Common parameters include:
111
+ - main_token_amount: Amount of main token to deposit (float)
112
+ - gas_token_amount: Amount of gas token to deposit (float)
113
+
114
+ Returns:
115
+ Tuple of (success: bool, message: str)
116
+
117
+ Raises:
118
+ ValueError: If required parameters are missing or invalid.
119
+ """
120
+ pass
121
+
122
+ async def withdraw(self, **kwargs) -> StatusTuple:
123
+ """
124
+ Withdraw funds from the strategy.
125
+ Default implementation unwinds all operations.
126
+
127
+ Args:
128
+ **kwargs: Strategy-specific withdrawal parameters (optional).
129
+
130
+ Returns:
131
+ Tuple of (success: bool, message: str)
132
+
133
+ Note:
134
+ Subclasses may override this method to add validation or custom
135
+ withdrawal logic. The base implementation unwinds all ledger operations.
136
+ """
137
+ if hasattr(self, "ledger_adapter") and self.ledger_adapter:
138
+ while self.ledger_adapter.positions.operations:
139
+ node = self.ledger_adapter.positions.operations[-1]
140
+ adapter = self.adapters.get(node.adapter)
141
+ if adapter and hasattr(adapter, "unwind_op"):
142
+ await adapter.unwind_op(node)
143
+ self.ledger_adapter.positions.operations.pop()
144
+
145
+ await self.ledger_adapter.save()
146
+
147
+ return (True, "Withdrawal complete")
148
+
149
+ @abstractmethod
150
+ async def update(self) -> StatusTuple:
151
+ """
152
+ Update strategy positions/rebalance
153
+ Returns: (success: bool, message: str)
154
+ """
155
+ pass
156
+
157
+ @staticmethod
158
+ async def policies() -> list[str]:
159
+ """Return policy strings for this strategy (Django-compatible)."""
160
+ raise NotImplementedError
161
+
162
+ @abstractmethod
163
+ async def _status(self) -> StatusDict:
164
+ """
165
+ Return status payload. Subclasses should implement this.
166
+ Should include Django-compatible keys (portfolio_value, net_deposit, strategy_status).
167
+ Backward-compatible keys (active_amount, total_earned) may also be included.
168
+ """
169
+ pass
170
+
171
+ async def status(self) -> StatusDict:
172
+ """
173
+ Wrapper to compute and return strategy status. In Django, this also snapshots.
174
+ Here we simply delegate to _status for compatibility.
175
+ """
176
+
177
+ status = await self._status()
178
+ await self.ledger_adapter.record_strategy_snapshot(
179
+ wallet_address=self._get_strategy_wallet_address(),
180
+ strategy_status=status,
181
+ )
182
+
183
+ return status
184
+
185
+ def register_adapters(self, adapters: list[Any]) -> None:
186
+ """Register adapters for use by the strategy"""
187
+ self.adapters = {}
188
+ for adapter in adapters:
189
+ if hasattr(adapter, "adapter_type"):
190
+ self.adapters[adapter.adapter_type] = adapter
191
+ elif hasattr(adapter, "__class__"):
192
+ self.adapters[adapter.__class__.__name__] = adapter
193
+
194
+ def unwind_on_error(
195
+ self, func: Callable[..., Awaitable[StatusTuple]]
196
+ ) -> Callable[..., Awaitable[StatusTuple]]:
197
+ """
198
+ Decorator to unwind operations on error
199
+ Useful for deposit operations that need cleanup on failure
200
+ """
201
+
202
+ async def wrapper(*args: Any, **kwargs: Any) -> StatusTuple:
203
+ try:
204
+ return await func(*args, **kwargs)
205
+ except Exception:
206
+ trace = traceback.format_exc()
207
+ try:
208
+ await self.withdraw()
209
+ return (
210
+ False,
211
+ f"Strategy failed during operation and was unwound. Failure: {trace}",
212
+ )
213
+ except Exception:
214
+ trace2 = traceback.format_exc()
215
+ return (
216
+ False,
217
+ f"Strategy failed and unwinding also failed. Operation error: {trace}. Unwind error: {trace2}",
218
+ )
219
+ finally:
220
+ if hasattr(self, "ledger_adapter") and self.ledger_adapter:
221
+ await self.ledger_adapter.save()
222
+
223
+ return wrapper
224
+
225
+ @classmethod
226
+ def get_metadata(cls) -> dict[str, Any]:
227
+ """
228
+ Return metadata about this strategy.
229
+ Can be overridden to provide discovery information.
230
+
231
+ Returns:
232
+ Dictionary containing strategy metadata. The following keys are optional
233
+ and will be None if not defined on the class:
234
+ - name: Strategy name
235
+ - description: Strategy description
236
+ - summary: Strategy summary
237
+ """
238
+ return {
239
+ "name": getattr(cls, "name", None),
240
+ "description": getattr(cls, "description", None),
241
+ "summary": getattr(cls, "summary", None),
242
+ }
243
+
244
+ async def health_check(self) -> dict[str, Any]:
245
+ """
246
+ Check strategy health and dependencies
247
+ """
248
+ health = {"status": "healthy", "strategy": self.name, "adapters": {}}
249
+
250
+ for name, adapter in self.adapters.items():
251
+ if hasattr(adapter, "health_check"):
252
+ health["adapters"][name] = await adapter.health_check()
253
+ else:
254
+ health["adapters"][name] = {"status": "unknown"}
255
+
256
+ return health
257
+
258
+ async def partial_liquidate(
259
+ self, usd_value: float
260
+ ) -> tuple[bool, LiquidationResult]:
261
+ """
262
+ Partially liquidate strategy positions by USD value.
263
+ Optional method that can be overridden by subclasses.
264
+
265
+ Args:
266
+ usd_value: USD value to liquidate (must be positive).
267
+
268
+ Returns:
269
+ Tuple of (success: bool, message: str)
270
+
271
+ Raises:
272
+ ValueError: If usd_value is not positive.
273
+
274
+ Note:
275
+ Base implementation returns failure. Subclasses should override
276
+ to implement partial liquidation logic.
277
+ """
278
+ if usd_value <= 0:
279
+ raise ValueError(f"usd_value must be positive, got {usd_value}")
280
+ return (False, "Partial liquidation not implemented for this strategy")
@@ -0,0 +1,5 @@
1
+ """Core Strategy Module - SDK surface re-export"""
2
+
3
+ from .base import StatusDict, StatusTuple, Strategy
4
+
5
+ __all__ = ["Strategy", "StatusDict", "StatusTuple"]
@@ -0,0 +1,7 @@
1
+ from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
2
+
3
+ __all__ = [
4
+ "Strategy",
5
+ "StatusDict",
6
+ "StatusTuple",
7
+ ]