wayfinder-paths 0.1.1__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 (115) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +394 -0
  2. wayfinder_paths/__init__.py +21 -0
  3. wayfinder_paths/config.example.json +20 -0
  4. wayfinder_paths/conftest.py +31 -0
  5. wayfinder_paths/core/__init__.py +13 -0
  6. wayfinder_paths/core/adapters/BaseAdapter.py +48 -0
  7. wayfinder_paths/core/adapters/__init__.py +5 -0
  8. wayfinder_paths/core/adapters/base.py +5 -0
  9. wayfinder_paths/core/clients/AuthClient.py +83 -0
  10. wayfinder_paths/core/clients/BRAPClient.py +90 -0
  11. wayfinder_paths/core/clients/ClientManager.py +231 -0
  12. wayfinder_paths/core/clients/HyperlendClient.py +151 -0
  13. wayfinder_paths/core/clients/LedgerClient.py +222 -0
  14. wayfinder_paths/core/clients/PoolClient.py +96 -0
  15. wayfinder_paths/core/clients/SimulationClient.py +180 -0
  16. wayfinder_paths/core/clients/TokenClient.py +73 -0
  17. wayfinder_paths/core/clients/TransactionClient.py +47 -0
  18. wayfinder_paths/core/clients/WalletClient.py +90 -0
  19. wayfinder_paths/core/clients/WayfinderClient.py +258 -0
  20. wayfinder_paths/core/clients/__init__.py +48 -0
  21. wayfinder_paths/core/clients/protocols.py +295 -0
  22. wayfinder_paths/core/clients/sdk_example.py +115 -0
  23. wayfinder_paths/core/config.py +369 -0
  24. wayfinder_paths/core/constants/__init__.py +26 -0
  25. wayfinder_paths/core/constants/base.py +25 -0
  26. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  27. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  28. wayfinder_paths/core/engine/VaultJob.py +182 -0
  29. wayfinder_paths/core/engine/__init__.py +5 -0
  30. wayfinder_paths/core/engine/manifest.py +97 -0
  31. wayfinder_paths/core/services/__init__.py +0 -0
  32. wayfinder_paths/core/services/base.py +177 -0
  33. wayfinder_paths/core/services/local_evm_txn.py +429 -0
  34. wayfinder_paths/core/services/local_token_txn.py +231 -0
  35. wayfinder_paths/core/services/web3_service.py +45 -0
  36. wayfinder_paths/core/settings.py +61 -0
  37. wayfinder_paths/core/strategies/Strategy.py +183 -0
  38. wayfinder_paths/core/strategies/__init__.py +5 -0
  39. wayfinder_paths/core/strategies/base.py +7 -0
  40. wayfinder_paths/core/utils/__init__.py +1 -0
  41. wayfinder_paths/core/utils/evm_helpers.py +165 -0
  42. wayfinder_paths/core/utils/wallets.py +77 -0
  43. wayfinder_paths/core/wallets/README.md +91 -0
  44. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  45. wayfinder_paths/core/wallets/__init__.py +7 -0
  46. wayfinder_paths/run_strategy.py +409 -0
  47. wayfinder_paths/scripts/__init__.py +0 -0
  48. wayfinder_paths/scripts/create_strategy.py +181 -0
  49. wayfinder_paths/scripts/make_wallets.py +160 -0
  50. wayfinder_paths/scripts/validate_manifests.py +213 -0
  51. wayfinder_paths/tests/__init__.py +0 -0
  52. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  53. wayfinder_paths/tests/test_test_coverage.py +212 -0
  54. wayfinder_paths/tests/test_utils.py +64 -0
  55. wayfinder_paths/vaults/__init__.py +0 -0
  56. wayfinder_paths/vaults/adapters/__init__.py +0 -0
  57. wayfinder_paths/vaults/adapters/balance_adapter/README.md +104 -0
  58. wayfinder_paths/vaults/adapters/balance_adapter/adapter.py +257 -0
  59. wayfinder_paths/vaults/adapters/balance_adapter/examples.json +6 -0
  60. wayfinder_paths/vaults/adapters/balance_adapter/manifest.yaml +8 -0
  61. wayfinder_paths/vaults/adapters/balance_adapter/test_adapter.py +83 -0
  62. wayfinder_paths/vaults/adapters/brap_adapter/README.md +249 -0
  63. wayfinder_paths/vaults/adapters/brap_adapter/__init__.py +7 -0
  64. wayfinder_paths/vaults/adapters/brap_adapter/adapter.py +717 -0
  65. wayfinder_paths/vaults/adapters/brap_adapter/examples.json +175 -0
  66. wayfinder_paths/vaults/adapters/brap_adapter/manifest.yaml +11 -0
  67. wayfinder_paths/vaults/adapters/brap_adapter/test_adapter.py +288 -0
  68. wayfinder_paths/vaults/adapters/hyperlend_adapter/__init__.py +7 -0
  69. wayfinder_paths/vaults/adapters/hyperlend_adapter/adapter.py +298 -0
  70. wayfinder_paths/vaults/adapters/hyperlend_adapter/manifest.yaml +10 -0
  71. wayfinder_paths/vaults/adapters/hyperlend_adapter/test_adapter.py +267 -0
  72. wayfinder_paths/vaults/adapters/ledger_adapter/README.md +158 -0
  73. wayfinder_paths/vaults/adapters/ledger_adapter/__init__.py +7 -0
  74. wayfinder_paths/vaults/adapters/ledger_adapter/adapter.py +286 -0
  75. wayfinder_paths/vaults/adapters/ledger_adapter/examples.json +131 -0
  76. wayfinder_paths/vaults/adapters/ledger_adapter/manifest.yaml +11 -0
  77. wayfinder_paths/vaults/adapters/ledger_adapter/test_adapter.py +202 -0
  78. wayfinder_paths/vaults/adapters/pool_adapter/README.md +218 -0
  79. wayfinder_paths/vaults/adapters/pool_adapter/__init__.py +7 -0
  80. wayfinder_paths/vaults/adapters/pool_adapter/adapter.py +289 -0
  81. wayfinder_paths/vaults/adapters/pool_adapter/examples.json +143 -0
  82. wayfinder_paths/vaults/adapters/pool_adapter/manifest.yaml +10 -0
  83. wayfinder_paths/vaults/adapters/pool_adapter/test_adapter.py +222 -0
  84. wayfinder_paths/vaults/adapters/token_adapter/README.md +101 -0
  85. wayfinder_paths/vaults/adapters/token_adapter/__init__.py +3 -0
  86. wayfinder_paths/vaults/adapters/token_adapter/adapter.py +92 -0
  87. wayfinder_paths/vaults/adapters/token_adapter/examples.json +26 -0
  88. wayfinder_paths/vaults/adapters/token_adapter/manifest.yaml +6 -0
  89. wayfinder_paths/vaults/adapters/token_adapter/test_adapter.py +135 -0
  90. wayfinder_paths/vaults/strategies/__init__.py +0 -0
  91. wayfinder_paths/vaults/strategies/config.py +85 -0
  92. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/README.md +99 -0
  93. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/examples.json +16 -0
  94. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  95. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/strategy.py +2328 -0
  96. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/test_strategy.py +319 -0
  97. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/README.md +95 -0
  98. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/examples.json +17 -0
  99. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  100. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/strategy.py +1684 -0
  101. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/test_strategy.py +350 -0
  102. wayfinder_paths/vaults/templates/adapter/README.md +105 -0
  103. wayfinder_paths/vaults/templates/adapter/adapter.py +26 -0
  104. wayfinder_paths/vaults/templates/adapter/examples.json +8 -0
  105. wayfinder_paths/vaults/templates/adapter/manifest.yaml +6 -0
  106. wayfinder_paths/vaults/templates/adapter/test_adapter.py +49 -0
  107. wayfinder_paths/vaults/templates/strategy/README.md +152 -0
  108. wayfinder_paths/vaults/templates/strategy/examples.json +11 -0
  109. wayfinder_paths/vaults/templates/strategy/manifest.yaml +8 -0
  110. wayfinder_paths/vaults/templates/strategy/strategy.py +57 -0
  111. wayfinder_paths/vaults/templates/strategy/test_strategy.py +197 -0
  112. wayfinder_paths-0.1.1.dist-info/LICENSE +21 -0
  113. wayfinder_paths-0.1.1.dist-info/METADATA +727 -0
  114. wayfinder_paths-0.1.1.dist-info/RECORD +115 -0
  115. wayfinder_paths-0.1.1.dist-info/WHEEL +4 -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 Vaults 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/vault.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,183 @@
1
+ import os
2
+ import traceback
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, TypedDict
5
+
6
+ from loguru import logger
7
+
8
+ from wayfinder_paths.core.services.base import Web3Service
9
+
10
+
11
+ class StatusDict(TypedDict):
12
+ portfolio_value: float
13
+ net_deposit: float
14
+ strategy_status: Any
15
+
16
+
17
+ StatusTuple = tuple[bool, str]
18
+
19
+
20
+ class Strategy(ABC):
21
+ name: str = None
22
+ description: str = None
23
+ summary: str = None
24
+
25
+ def __init__(
26
+ self,
27
+ config: dict[str, Any] | None = None,
28
+ *,
29
+ main_wallet: dict[str, Any] | None = None,
30
+ vault_wallet: dict[str, Any] | None = None,
31
+ simulation: bool = False,
32
+ web3_service: Web3Service = None,
33
+ api_key: str | None = None,
34
+ ):
35
+ self.adapters = {}
36
+ self.ledger = None
37
+ self.logger = logger.bind(strategy=self.__class__.__name__)
38
+ if api_key:
39
+ os.environ["WAYFINDER_API_KEY"] = api_key
40
+
41
+ async def setup(self):
42
+ """Initialize strategy-specific setup after construction"""
43
+ pass
44
+
45
+ async def log(self, msg: str) -> None:
46
+ """Log messages - can be overridden by subclasses"""
47
+ self.logger.info(msg)
48
+
49
+ async def temp_ui_message(self, msg: str) -> None:
50
+ """Hook for temporary UI messages (e.g., progress) to the chat window."""
51
+ # No-op by default; strategies/hosts can override
52
+ return None
53
+
54
+ async def quote(self):
55
+ """Get quotes for potential trades - optional for strategies"""
56
+ pass
57
+
58
+ @abstractmethod
59
+ async def deposit(self, **kwargs) -> StatusTuple:
60
+ """
61
+ Deposit funds into the strategy
62
+ Returns: (success: bool, message: str)
63
+ """
64
+ pass
65
+
66
+ async def withdraw(self, **kwargs) -> StatusTuple:
67
+ """
68
+ Withdraw funds from the strategy
69
+ Default implementation unwinds all operations
70
+ Returns: (success: bool, message: str)
71
+ """
72
+ if hasattr(self, "ledger") and self.ledger:
73
+ while self.ledger.positions.operations:
74
+ node = self.ledger.positions.operations[-1]
75
+ adapter = self.adapters.get(node.adapter)
76
+ if adapter and hasattr(adapter, "unwind_op"):
77
+ await adapter.unwind_op(node)
78
+ self.ledger.positions.operations.pop()
79
+
80
+ await self.ledger.save()
81
+
82
+ return (True, "Withdrawal complete")
83
+
84
+ @abstractmethod
85
+ async def update(self) -> StatusTuple:
86
+ """
87
+ Update strategy positions/rebalance
88
+ Returns: (success: bool, message: str)
89
+ """
90
+ pass
91
+
92
+ @staticmethod
93
+ def policies() -> list[str]:
94
+ """Return policy strings for this strategy (Django-compatible)."""
95
+ raise NotImplementedError
96
+
97
+ @abstractmethod
98
+ async def _status(self) -> StatusDict:
99
+ """
100
+ Return status payload. Subclasses should implement this.
101
+ Should include Django-compatible keys (portfolio_value, net_deposit, strategy_status).
102
+ Backward-compatible keys (active_amount, total_earned) may also be included.
103
+ """
104
+ pass
105
+
106
+ async def status(self) -> StatusDict:
107
+ """
108
+ Wrapper to compute and return strategy status. In Django, this also snapshots.
109
+ Here we simply delegate to _status for compatibility.
110
+ """
111
+ return await self._status()
112
+
113
+ def register_adapters(self, adapters: list[Any]) -> None:
114
+ """Register adapters for use by the strategy"""
115
+ self.adapters = {}
116
+ for adapter in adapters:
117
+ if hasattr(adapter, "adapter_type"):
118
+ self.adapters[adapter.adapter_type] = adapter
119
+ elif hasattr(adapter, "__class__"):
120
+ self.adapters[adapter.__class__.__name__] = adapter
121
+
122
+ def unwind_on_error(self, func):
123
+ """
124
+ Decorator to unwind operations on error
125
+ Useful for deposit operations that need cleanup on failure
126
+ """
127
+
128
+ async def wrapper(*args, **kwargs):
129
+ try:
130
+ return await func(*args, **kwargs)
131
+ except Exception:
132
+ trace = traceback.format_exc()
133
+ try:
134
+ await self.withdraw()
135
+ return (
136
+ False,
137
+ f"Strategy failed during operation and was unwound. Failure: {trace}",
138
+ )
139
+ except Exception:
140
+ trace2 = traceback.format_exc()
141
+ return (
142
+ False,
143
+ f"Strategy failed and unwinding also failed. Operation error: {trace}. Unwind error: {trace2}",
144
+ )
145
+ finally:
146
+ if hasattr(self, "ledger") and self.ledger:
147
+ await self.ledger.save()
148
+
149
+ return wrapper
150
+
151
+ @classmethod
152
+ def get_metadata(cls) -> dict[str, Any]:
153
+ """
154
+ Return metadata about this strategy
155
+ Can be overridden to provide discovery information
156
+ """
157
+ return {
158
+ "name": cls.name,
159
+ "description": cls.description,
160
+ "summary": cls.summary,
161
+ }
162
+
163
+ async def health_check(self) -> dict[str, Any]:
164
+ """
165
+ Check strategy health and dependencies
166
+ """
167
+ health = {"status": "healthy", "strategy": self.name, "adapters": {}}
168
+
169
+ for name, adapter in self.adapters.items():
170
+ if hasattr(adapter, "health_check"):
171
+ health["adapters"][name] = await adapter.health_check()
172
+ else:
173
+ health["adapters"][name] = {"status": "unknown"}
174
+
175
+ return health
176
+
177
+ async def partial_liquidate(self, usd_value: float) -> StatusTuple:
178
+ """
179
+ Partially liquidate strategy positions by USD value
180
+ Optional method that can be overridden by subclasses
181
+ Returns: (success: bool, message: str)
182
+ """
183
+ 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
+ ]
@@ -0,0 +1 @@
1
+ """Utility helpers for local functionality not tied to external APIs."""
@@ -0,0 +1,165 @@
1
+ """
2
+ EVM helper utilities for common blockchain operations.
3
+
4
+ This module provides reusable functions for EVM-related operations that are shared
5
+ across multiple adapters, extracted from evm_transaction_adapter.
6
+ """
7
+
8
+ import os
9
+ from typing import Any
10
+
11
+ from loguru import logger
12
+ from web3 import AsyncHTTPProvider, AsyncWeb3
13
+
14
+ from wayfinder_paths.core.constants.base import CHAIN_CODE_TO_ID
15
+
16
+
17
+ def chain_code_to_chain_id(chain_code: str | None) -> int | None:
18
+ """
19
+ Convert chain code to chain ID.
20
+
21
+ Args:
22
+ chain_code: Chain code string (e.g., "ethereum", "base")
23
+
24
+ Returns:
25
+ Chain ID as integer, or None if not found
26
+ """
27
+ if not chain_code:
28
+ return None
29
+ return CHAIN_CODE_TO_ID.get(chain_code.lower())
30
+
31
+
32
+ def resolve_chain_id(token_info: dict[str, Any], logger_instance=None) -> int | None:
33
+ """
34
+ Extract chain ID from token_info dictionary.
35
+
36
+ Args:
37
+ token_info: Dictionary containing token information with 'chain' key
38
+ logger_instance: Optional logger instance for debug messages
39
+
40
+ Returns:
41
+ Chain ID as integer, or None if not found
42
+ """
43
+ log = logger_instance or logger
44
+ chain_meta = token_info.get("chain") or {}
45
+ chain_id = chain_meta.get("chain_id")
46
+ try:
47
+ if chain_id is not None:
48
+ return int(chain_id)
49
+ except (ValueError, TypeError):
50
+ log.debug("Invalid chain_id in token_info.chain_id: %s", chain_id)
51
+ return chain_code_to_chain_id(chain_meta.get("code"))
52
+
53
+
54
+ def resolve_rpc_url(
55
+ chain_id: int | None,
56
+ config: dict[str, Any],
57
+ explicit_rpc_url: str | None = None,
58
+ ) -> str:
59
+ """
60
+ Resolve RPC URL from config or environment variables.
61
+
62
+ Args:
63
+ chain_id: Chain ID to look up RPC URL for
64
+ config: Configuration dictionary
65
+ explicit_rpc_url: Explicitly provided RPC URL (takes precedence)
66
+
67
+ Returns:
68
+ RPC URL string
69
+
70
+ Raises:
71
+ ValueError: If RPC URL cannot be resolved
72
+ """
73
+ if explicit_rpc_url:
74
+ return explicit_rpc_url
75
+ strategy_cfg = config.get("strategy") or {}
76
+ mapping = strategy_cfg.get("rpc_urls") if isinstance(strategy_cfg, dict) else None
77
+ if chain_id is not None and isinstance(mapping, dict):
78
+ by_int = mapping.get(chain_id)
79
+ if by_int:
80
+ return str(by_int)
81
+ by_str = mapping.get(str(chain_id))
82
+ if by_str:
83
+ return str(by_str)
84
+ env_rpc = os.getenv("RPC_URL")
85
+ if env_rpc:
86
+ return env_rpc
87
+ raise ValueError("RPC URL not provided. Set strategy.rpc_urls or env RPC_URL.")
88
+
89
+
90
+ async def get_next_nonce(
91
+ from_address: str, rpc_url: str, use_latest: bool = False
92
+ ) -> int:
93
+ """
94
+ Get the next nonce for the given address.
95
+
96
+ Args:
97
+ from_address: Address to get nonce for
98
+ rpc_url: RPC URL to connect to
99
+ use_latest: If True, use 'latest' block instead of 'pending'
100
+
101
+ Returns:
102
+ Next nonce as integer
103
+ """
104
+ w3 = AsyncWeb3(AsyncHTTPProvider(rpc_url))
105
+ try:
106
+ if use_latest:
107
+ return await w3.eth.get_transaction_count(from_address, "latest")
108
+ return await w3.eth.get_transaction_count(from_address)
109
+ finally:
110
+ try:
111
+ await w3.provider.session.close()
112
+ except Exception:
113
+ pass
114
+
115
+
116
+ def resolve_private_key_for_from_address(
117
+ from_address: str, config: dict[str, Any]
118
+ ) -> str | None:
119
+ """
120
+ Resolve private key for the given address from config or environment.
121
+
122
+ Args:
123
+ from_address: Address to resolve private key for
124
+ config: Configuration dictionary containing wallet information
125
+
126
+ Returns:
127
+ Private key string, or None if not found
128
+ """
129
+ from_addr_norm = (from_address or "").lower()
130
+ main_wallet = config.get("main_wallet")
131
+ vault_wallet = config.get("vault_wallet")
132
+
133
+ main_pk = None
134
+ vault_pk = None
135
+ try:
136
+ if isinstance(main_wallet, dict):
137
+ main_pk = main_wallet.get("private_key") or main_wallet.get(
138
+ "private_key_hex"
139
+ )
140
+ if isinstance(vault_wallet, dict):
141
+ vault_pk = vault_wallet.get("private_key") or vault_wallet.get(
142
+ "private_key_hex"
143
+ )
144
+ except (AttributeError, TypeError) as e:
145
+ logger.debug("Error resolving private keys from wallet config: %s", e)
146
+
147
+ main_addr = None
148
+ vault_addr = None
149
+ try:
150
+ main_addr = (main_wallet or {}).get("address") or (
151
+ (main_wallet or {}).get("evm") or {}
152
+ ).get("address")
153
+ vault_addr = (vault_wallet or {}).get("address") or (
154
+ (vault_wallet or {}).get("evm") or {}
155
+ ).get("address")
156
+ except (AttributeError, TypeError) as e:
157
+ logger.debug("Error resolving addresses from wallet config: %s", e)
158
+
159
+ if main_addr and from_addr_norm == (main_addr or "").lower():
160
+ return main_pk or os.getenv("PRIVATE_KEY")
161
+ if vault_addr and from_addr_norm == (vault_addr or "").lower():
162
+ return vault_pk or os.getenv("PRIVATE_KEY_VAULT") or os.getenv("PRIVATE_KEY")
163
+
164
+ # Fallback to environment variables
165
+ return os.getenv("PRIVATE_KEY_VAULT") or os.getenv("PRIVATE_KEY")
@@ -0,0 +1,77 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from eth_account import Account
6
+
7
+
8
+ def make_random_wallet() -> dict[str, str]:
9
+ """Generate a new random wallet.
10
+
11
+ Returns a mapping with keys: "address" and "private_key_hex" (0x-prefixed).
12
+ """
13
+ acct = Account.create() # uses os.urandom
14
+ return {
15
+ "address": acct.address,
16
+ "private_key_hex": acct.key.hex(),
17
+ }
18
+
19
+
20
+ def _load_existing_wallets(file_path: Path) -> list[dict[str, Any]]:
21
+ if not file_path.exists():
22
+ return []
23
+ try:
24
+ parsed = json.loads(file_path.read_text())
25
+ if isinstance(parsed, list):
26
+ return parsed
27
+ if isinstance(parsed, dict):
28
+ wallets = parsed.get("wallets")
29
+ if isinstance(wallets, list):
30
+ return wallets
31
+ return []
32
+ except Exception:
33
+ # If the file is malformed, start fresh rather than raising.
34
+ return []
35
+
36
+
37
+ def _save_wallets(file_path: Path, wallets: list[dict[str, Any]]) -> None:
38
+ # Ensure stable ordering by address for readability
39
+ sorted_wallets = sorted(wallets, key=lambda w: w.get("address", ""))
40
+ file_path.write_text(json.dumps(sorted_wallets, indent=2))
41
+
42
+
43
+ def write_wallet_to_json(
44
+ wallet: dict[str, str], out_dir: str | Path = ".", filename: str = "wallets.json"
45
+ ) -> Path:
46
+ """Create or update a wallets.json with the provided wallet.
47
+
48
+ - Ensures the output directory exists.
49
+ - Merges with existing entries keyed by address (updates if present, appends otherwise).
50
+ - Writes a pretty-printed JSON list of wallet objects.
51
+ """
52
+ out_dir_path = Path(out_dir)
53
+ out_dir_path.mkdir(parents=True, exist_ok=True)
54
+ file_path = out_dir_path / filename
55
+
56
+ existing = _load_existing_wallets(file_path)
57
+ index_by_address: dict[str, int] = {}
58
+ for i, w in enumerate(existing):
59
+ addr = w.get("address")
60
+ if isinstance(addr, str):
61
+ index_by_address[addr.lower()] = i
62
+
63
+ addr_key = wallet["address"].lower()
64
+ if addr_key in index_by_address:
65
+ existing[index_by_address[addr_key]] = wallet
66
+ else:
67
+ existing.append(wallet)
68
+
69
+ _save_wallets(file_path, existing)
70
+ return file_path
71
+
72
+
73
+ def load_wallets(
74
+ out_dir: str | Path = ".", filename: str = "wallets.json"
75
+ ) -> list[dict[str, Any]]:
76
+ """Public helper to read wallets.json as a list of wallet dicts."""
77
+ return _load_existing_wallets(Path(out_dir) / filename)
@@ -0,0 +1,91 @@
1
+ # Wallet Abstraction Layer
2
+
3
+ Wayfinder strategies interact with blockchains through a single abstraction: the `EvmTxn` interface defined in `wayfinder_paths/core/services/base.py`. The default implementation (`LocalEvmTxn`) signs transactions with private keys pulled from config/environment variables, while `WalletManager` resolves which provider to use at runtime.
4
+
5
+ ## Pieces
6
+
7
+ 1. **`EvmTxn` (interface)** – describes balance lookups, ERC-20 approvals, raw transaction broadcasting, and AsyncWeb3 access.
8
+ 2. **`LocalEvmTxn`** – the built-in provider that signs transactions locally via `eth_account`. It handles RPC resolution, nonce management, gas estimation, and status checks.
9
+ 3. **`WalletManager`** – light factory that reads `wallet_type` from strategy config and returns the appropriate `EvmTxn`. Today it always returns `LocalEvmTxn` unless you inject your own provider.
10
+ 4. **`DefaultWeb3Service`** – convenience wrapper that bundles an `EvmTxn` (wallet provider) with a `LocalTokenTxnService` (transaction builders used by adapters).
11
+
12
+ ## Using the defaults
13
+
14
+ ```python
15
+ from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
16
+ from wayfinder_paths.core.wallets.WalletManager import WalletManager
17
+
18
+ config = {...} # contains main_wallet / vault_wallet entries
19
+ wallet_provider = WalletManager.get_provider(config)
20
+ web3_service = DefaultWeb3Service(config, wallet_provider=wallet_provider)
21
+
22
+ # Strategies typically pass web3_service.evm_transactions into adapters that require wallet access.
23
+ balance_adapter = BalanceAdapter(config, web3_service=web3_service)
24
+ ```
25
+
26
+ If you want to provide a custom wallet provider (e.g., Privy, Turnkey, Fireblocks), implement the `EvmTxn` interface and hand it to `DefaultWeb3Service`/adapters directly—`WalletManager` is purely a helper.
27
+
28
+ ## Implementing a custom provider
29
+
30
+ Subclass `EvmTxn` and implement every abstract method:
31
+
32
+ ```python
33
+ from typing import Any
34
+ from web3 import AsyncWeb3
35
+
36
+ from wayfinder_paths.core.services.base import EvmTxn
37
+
38
+
39
+ class PrivyWallet(EvmTxn):
40
+ def __init__(self, privy_client):
41
+ self._client = privy_client
42
+
43
+ async def get_balance(self, address: str, token_address: str | None, chain_id: int) -> tuple[bool, Any]:
44
+ ...
45
+
46
+ async def read_erc20_allowance(self, chain_id: int, token_address: str, owner_address: str, spender_address: str) -> tuple[bool, Any]:
47
+ ...
48
+
49
+ async def approve_token(...):
50
+ ...
51
+
52
+ async def broadcast_transaction(...):
53
+ ...
54
+
55
+ async def transaction_succeeded(self, tx_hash: str, chain_id: int, timeout: int = 120) -> bool:
56
+ ...
57
+
58
+ def get_web3(self, chain_id: int) -> AsyncWeb3:
59
+ ...
60
+ ```
61
+
62
+ Methods should mirror `LocalEvmTxn`’s behavior: return `(True, payload)` on success, `(False, "reason")` on failure, and expose AsyncWeb3 instances tied to the requested chain.
63
+
64
+ Once implemented:
65
+
66
+ ```python
67
+ custom_wallet = PrivyWallet(privy_client)
68
+ web3_service = DefaultWeb3Service(config, wallet_provider=custom_wallet)
69
+ ```
70
+
71
+ ## Configuration hints
72
+
73
+ `WalletManager.get_provider` looks for `wallet_type` on the top-level config, `main_wallet`, and `vault_wallet`. Example:
74
+
75
+ ```json
76
+ {
77
+ "strategy": {
78
+ "wallet_type": "local",
79
+ "main_wallet": {"address": "0x...", "wallet_type": "local"},
80
+ "vault_wallet": {"address": "0x..."}
81
+ }
82
+ }
83
+ ```
84
+
85
+ Currently only `"local"` is supported through configuration—the custom path is injection.
86
+
87
+ ## Why this layer?
88
+
89
+ - Strategies never touch raw `AsyncWeb3`; they call adapters, which call clients, which call a wallet provider.
90
+ - Alternate signing backends can be plugged in without modifying strategy code.
91
+ - Tests can patch `WalletManager.get_provider` or inject stub `EvmTxn` implementations to avoid real RPC calls.