iwa 0.0.1a2__py3-none-any.whl → 0.0.1a3__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.
- iwa/core/chain/interface.py +51 -61
- iwa/core/chain/models.py +7 -7
- iwa/core/chain/rate_limiter.py +21 -10
- iwa/core/cli.py +27 -2
- iwa/core/constants.py +6 -5
- iwa/core/contracts/contract.py +16 -4
- iwa/core/ipfs.py +149 -0
- iwa/core/keys.py +259 -29
- iwa/core/mnemonic.py +3 -13
- iwa/core/models.py +28 -6
- iwa/core/pricing.py +4 -4
- iwa/core/secrets.py +77 -0
- iwa/core/services/safe.py +3 -3
- iwa/core/utils.py +6 -1
- iwa/core/wallet.py +4 -0
- iwa/plugins/gnosis/safe.py +2 -2
- iwa/plugins/gnosis/tests/test_safe.py +1 -1
- iwa/plugins/olas/constants.py +8 -0
- iwa/plugins/olas/contracts/mech.py +30 -2
- iwa/plugins/olas/plugin.py +2 -2
- iwa/plugins/olas/tests/test_plugin_full.py +3 -3
- iwa/plugins/olas/tests/test_staking_integration.py +2 -2
- iwa/tools/__init__.py +1 -0
- iwa/tools/check_profile.py +6 -5
- iwa/tools/list_contracts.py +136 -0
- iwa/tools/release.py +9 -3
- iwa/tools/reset_env.py +2 -2
- iwa/tools/reset_tenderly.py +26 -24
- iwa/tools/wallet_check.py +150 -0
- iwa/web/dependencies.py +4 -4
- iwa/web/routers/state.py +1 -0
- iwa/web/tests/test_web_endpoints.py +3 -2
- iwa/web/tests/test_web_swap_coverage.py +156 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/METADATA +6 -3
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/RECORD +50 -43
- iwa-0.0.1a3.dist-info/entry_points.txt +6 -0
- tests/test_chain.py +1 -1
- tests/test_chain_interface_coverage.py +92 -0
- tests/test_contract.py +2 -0
- tests/test_keys.py +58 -15
- tests/test_migration.py +52 -0
- tests/test_mnemonic.py +1 -1
- tests/test_pricing.py +7 -7
- tests/test_safe_coverage.py +1 -1
- tests/test_safe_service.py +3 -3
- tests/test_staking_router.py +13 -1
- tools/verify_drain.py +1 -1
- iwa/core/settings.py +0 -95
- iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/WHEEL +0 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a3.dist-info}/top_level.txt +0 -0
iwa/core/chain/interface.py
CHANGED
|
@@ -21,8 +21,8 @@ DEFAULT_RPC_TIMEOUT = 10
|
|
|
21
21
|
class ChainInterface:
|
|
22
22
|
"""ChainInterface with rate limiting, retry logic, and RPC rotation support."""
|
|
23
23
|
|
|
24
|
-
DEFAULT_MAX_RETRIES =
|
|
25
|
-
DEFAULT_RETRY_DELAY = 0
|
|
24
|
+
DEFAULT_MAX_RETRIES = 6 # Allow trying most/all available RPCs on rate limit
|
|
25
|
+
DEFAULT_RETRY_DELAY = 1.0 # Base delay between retries (exponential backoff)
|
|
26
26
|
|
|
27
27
|
chain: SupportedChain
|
|
28
28
|
|
|
@@ -54,42 +54,40 @@ class ChainInterface:
|
|
|
54
54
|
return "tenderly" in rpc.lower() or "virtual" in rpc.lower()
|
|
55
55
|
|
|
56
56
|
def init_block_tracking(self):
|
|
57
|
-
"""Initialize block tracking for limit detection.
|
|
57
|
+
"""Initialize block tracking for limit detection.
|
|
58
|
+
|
|
59
|
+
Only enables block limit warnings if we have a valid tenderly config file
|
|
60
|
+
with initial_block set. Otherwise, leaves _initial_block at 0 which
|
|
61
|
+
disables the warnings (since we can't accurately track usage without
|
|
62
|
+
knowing the fork point).
|
|
63
|
+
"""
|
|
64
|
+
if not self.is_tenderly:
|
|
65
|
+
return # Only track for Tenderly vNets
|
|
66
|
+
|
|
58
67
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
logger.warning(
|
|
83
|
-
f"Tenderly detected but no initial_block in config. using session start: {self._initial_block}"
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
logger.warning(
|
|
87
|
-
"Monitoring Tenderly vNet block usage (Limit ~50 blocks from vNet start)"
|
|
88
|
-
)
|
|
89
|
-
except Exception as ex:
|
|
90
|
-
logger.warning(f"Failed to load Tenderly config for block tracking: {ex}")
|
|
91
|
-
except Exception as e:
|
|
92
|
-
logger.warning(f"Failed to init block tracking: {e}")
|
|
68
|
+
from iwa.core.constants import get_tenderly_config_path
|
|
69
|
+
from iwa.core.models import TenderlyConfig
|
|
70
|
+
|
|
71
|
+
profile = Config().core.tenderly_profile
|
|
72
|
+
config_path = get_tenderly_config_path(profile)
|
|
73
|
+
|
|
74
|
+
if not config_path.exists():
|
|
75
|
+
logger.debug(f"Tenderly config not found at {config_path}, skipping block tracking")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
t_config = TenderlyConfig.load(config_path)
|
|
79
|
+
vnet = t_config.vnets.get(self.chain.name)
|
|
80
|
+
if not vnet:
|
|
81
|
+
vnet = t_config.vnets.get(self.chain.name.lower())
|
|
82
|
+
|
|
83
|
+
if vnet and vnet.initial_block > 0:
|
|
84
|
+
self._initial_block = vnet.initial_block
|
|
85
|
+
logger.info(f"Tenderly block tracking enabled (genesis: {self._initial_block})")
|
|
86
|
+
else:
|
|
87
|
+
logger.debug(f"Tenderly config exists but no initial_block for {self.chain.name}")
|
|
88
|
+
|
|
89
|
+
except Exception as ex:
|
|
90
|
+
logger.warning(f"Failed to load Tenderly config for block tracking: {ex}")
|
|
93
91
|
|
|
94
92
|
def check_block_limit(self, show_progress_bar: bool = False):
|
|
95
93
|
"""Check if approaching block limit (heuristic).
|
|
@@ -160,7 +158,13 @@ class ChainInterface:
|
|
|
160
158
|
"""Initialize Web3 with current RPC."""
|
|
161
159
|
rpc_url = self.chain.rpcs[self._current_rpc_index] if self.chain.rpcs else ""
|
|
162
160
|
raw_web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": DEFAULT_RPC_TIMEOUT}))
|
|
163
|
-
|
|
161
|
+
|
|
162
|
+
# Use duck typing to check if current web3 is a RateLimitedWeb3 wrapper
|
|
163
|
+
# (isinstance check fails when RateLimitedWeb3 is mocked in tests)
|
|
164
|
+
if hasattr(self, "web3") and hasattr(self.web3, "set_backend"):
|
|
165
|
+
self.web3.set_backend(raw_web3)
|
|
166
|
+
else:
|
|
167
|
+
self.web3 = RateLimitedWeb3(raw_web3, self._rate_limiter, self)
|
|
164
168
|
|
|
165
169
|
def _is_rate_limit_error(self, error: Exception) -> bool:
|
|
166
170
|
"""Check if error is a rate limit (429) error."""
|
|
@@ -268,30 +272,16 @@ class ChainInterface:
|
|
|
268
272
|
if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
|
|
269
273
|
return False
|
|
270
274
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
|
|
276
|
-
attempts += 1
|
|
277
|
-
|
|
278
|
-
if self._rpc_failure_counts.get(self._current_rpc_index, 0) >= 5:
|
|
279
|
-
continue
|
|
280
|
-
|
|
281
|
-
logger.info(f"Rotating RPC for {self.chain.name} to index {self._current_rpc_index}")
|
|
282
|
-
self._init_web3()
|
|
283
|
-
|
|
284
|
-
if self.check_rpc_health():
|
|
285
|
-
return True
|
|
286
|
-
else:
|
|
287
|
-
logger.warning(f"RPC at index {self._current_rpc_index} failed health check")
|
|
288
|
-
self._rpc_failure_counts[self._current_rpc_index] = (
|
|
289
|
-
self._rpc_failure_counts.get(self._current_rpc_index, 0) + 1
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
self._current_rpc_index = original_index
|
|
275
|
+
# Simple Round Robin rotation
|
|
276
|
+
# We don't check health here because the health check itself might consume rate limits
|
|
277
|
+
# or fail flakily. Better to just switch and try the operation.
|
|
278
|
+
self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
|
|
293
279
|
self._init_web3()
|
|
294
|
-
|
|
280
|
+
|
|
281
|
+
logger.info(
|
|
282
|
+
f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
|
|
283
|
+
)
|
|
284
|
+
return True
|
|
295
285
|
|
|
296
286
|
def check_rpc_health(self) -> bool:
|
|
297
287
|
"""Check if the current RPC is healthy."""
|
iwa/core/chain/models.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
|
|
7
7
|
from iwa.core.models import EthereumAddress
|
|
8
|
-
from iwa.core.
|
|
8
|
+
from iwa.core.secrets import secrets
|
|
9
9
|
from iwa.core.utils import singleton
|
|
10
10
|
|
|
11
11
|
|
|
@@ -75,8 +75,8 @@ class Gnosis(SupportedChain):
|
|
|
75
75
|
def __init__(self, **data):
|
|
76
76
|
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
77
77
|
super().__init__(**data)
|
|
78
|
-
if not self.rpcs and
|
|
79
|
-
self.rpcs =
|
|
78
|
+
if not self.rpcs and secrets.gnosis_rpc:
|
|
79
|
+
self.rpcs = secrets.gnosis_rpc.get_secret_value().split(",")
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
@singleton
|
|
@@ -95,8 +95,8 @@ class Ethereum(SupportedChain):
|
|
|
95
95
|
def __init__(self, **data):
|
|
96
96
|
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
97
97
|
super().__init__(**data)
|
|
98
|
-
if not self.rpcs and
|
|
99
|
-
self.rpcs =
|
|
98
|
+
if not self.rpcs and secrets.ethereum_rpc:
|
|
99
|
+
self.rpcs = secrets.ethereum_rpc.get_secret_value().split(",")
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
@singleton
|
|
@@ -115,8 +115,8 @@ class Base(SupportedChain):
|
|
|
115
115
|
def __init__(self, **data):
|
|
116
116
|
"""Initialize with RPCs from settings (after testing override is applied)."""
|
|
117
117
|
super().__init__(**data)
|
|
118
|
-
if not self.rpcs and
|
|
119
|
-
self.rpcs =
|
|
118
|
+
if not self.rpcs and secrets.base_rpc:
|
|
119
|
+
self.rpcs = secrets.base_rpc.get_secret_value().split(",")
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
@singleton
|
iwa/core/chain/rate_limiter.py
CHANGED
|
@@ -152,17 +152,19 @@ class RateLimitedEth:
|
|
|
152
152
|
delattr(self._eth, name)
|
|
153
153
|
|
|
154
154
|
def _wrap_with_rate_limit(self, method, method_name):
|
|
155
|
-
"""Wrap a method with rate limiting
|
|
155
|
+
"""Wrap a method with rate limiting.
|
|
156
|
+
|
|
157
|
+
Note: Error handling (rotation, retry) is NOT done here.
|
|
158
|
+
It is the responsibility of `ChainInterface.with_retry()` to handle
|
|
159
|
+
errors and rotate RPCs as needed. This wrapper only ensures
|
|
160
|
+
rate limiting.
|
|
161
|
+
"""
|
|
156
162
|
|
|
157
163
|
def wrapper(*args, **kwargs):
|
|
158
164
|
if not self._rate_limiter.acquire(timeout=30.0):
|
|
159
165
|
raise TimeoutError(f"Rate limit timeout waiting for {method_name}")
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
return method(*args, **kwargs)
|
|
163
|
-
except Exception as e:
|
|
164
|
-
self._chain_interface._handle_rpc_error(e)
|
|
165
|
-
raise
|
|
167
|
+
return method(*args, **kwargs)
|
|
166
168
|
|
|
167
169
|
return wrapper
|
|
168
170
|
|
|
@@ -178,14 +180,23 @@ class RateLimitedWeb3:
|
|
|
178
180
|
self._rate_limiter = rate_limiter
|
|
179
181
|
self._chain_interface = chain_interface
|
|
180
182
|
self._eth_wrapper = None
|
|
183
|
+
# Initialize eth wrapper immediately
|
|
184
|
+
self._update_eth_wrapper()
|
|
185
|
+
|
|
186
|
+
def set_backend(self, new_web3):
|
|
187
|
+
"""Update the underlying Web3 instance (hot-swap)."""
|
|
188
|
+
self._web3 = new_web3
|
|
189
|
+
self._update_eth_wrapper()
|
|
190
|
+
|
|
191
|
+
def _update_eth_wrapper(self):
|
|
192
|
+
"""Update the eth wrapper to point to the current _web3.eth."""
|
|
193
|
+
self._eth_wrapper = RateLimitedEth(
|
|
194
|
+
self._web3.eth, self._rate_limiter, self._chain_interface
|
|
195
|
+
)
|
|
181
196
|
|
|
182
197
|
@property
|
|
183
198
|
def eth(self):
|
|
184
199
|
"""Return rate-limited eth interface."""
|
|
185
|
-
if self._eth_wrapper is None:
|
|
186
|
-
self._eth_wrapper = RateLimitedEth(
|
|
187
|
-
self._web3.eth, self._rate_limiter, self._chain_interface
|
|
188
|
-
)
|
|
189
200
|
return self._eth_wrapper
|
|
190
201
|
|
|
191
202
|
def __getattr__(self, name):
|
iwa/core/cli.py
CHANGED
|
@@ -67,6 +67,31 @@ def account_list(
|
|
|
67
67
|
)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
@wallet_cli.command("mnemonic")
|
|
71
|
+
def show_mnemonic():
|
|
72
|
+
"""Show the master account mnemonic (requires password)"""
|
|
73
|
+
key_storage = KeyStorage()
|
|
74
|
+
try:
|
|
75
|
+
mnemonic = key_storage.decrypt_mnemonic()
|
|
76
|
+
print("\n" + "=" * 60)
|
|
77
|
+
print("📜 MASTER ACCOUNT MNEMONIC (BIP-39)")
|
|
78
|
+
print("=" * 60)
|
|
79
|
+
print("\nWrite down these 24 words and store them in a safe place.")
|
|
80
|
+
print("-" * 60)
|
|
81
|
+
words = mnemonic.split()
|
|
82
|
+
for i in range(0, 24, 4):
|
|
83
|
+
print(
|
|
84
|
+
f" {i + 1:2}. {words[i]:12} {i + 2:2}. {words[i + 1]:12} "
|
|
85
|
+
f"{i + 3:2}. {words[i + 2]:12} {i + 4:2}. {words[i + 3]:12}"
|
|
86
|
+
)
|
|
87
|
+
print("-" * 60)
|
|
88
|
+
print("\n⚠️ Keep this phrase secret! Anyone with it can access your funds.")
|
|
89
|
+
print("=" * 60)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
typer.echo(f"Error: {e}")
|
|
92
|
+
raise typer.Exit(code=1) from e
|
|
93
|
+
|
|
94
|
+
|
|
70
95
|
@wallet_cli.command("send")
|
|
71
96
|
def account_send(
|
|
72
97
|
from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
|
|
@@ -164,10 +189,10 @@ def web_server(
|
|
|
164
189
|
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to listen on"),
|
|
165
190
|
):
|
|
166
191
|
"""Start Web Interface."""
|
|
167
|
-
from iwa.core.
|
|
192
|
+
from iwa.core.models import Config
|
|
168
193
|
from iwa.web.server import run_server
|
|
169
194
|
|
|
170
|
-
server_port = port or
|
|
195
|
+
server_port = port or Config().core.web_port
|
|
171
196
|
typer.echo(f"Starting web server on http://{host}:{server_port}")
|
|
172
197
|
run_server(host=host, port=server_port)
|
|
173
198
|
|
iwa/core/constants.py
CHANGED
|
@@ -6,14 +6,15 @@ from iwa.core.types import EthereumAddress
|
|
|
6
6
|
|
|
7
7
|
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
|
8
8
|
|
|
9
|
-
# Data directory for
|
|
10
|
-
DATA_DIR =
|
|
9
|
+
# Data directory for runtime files
|
|
10
|
+
DATA_DIR = Path("data")
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# secrets.env is at project root (NOT in data/)
|
|
13
|
+
SECRETS_PATH = Path("secrets.env")
|
|
13
14
|
CONFIG_PATH = DATA_DIR / "config.yaml"
|
|
14
15
|
WALLET_PATH = DATA_DIR / "wallet.json"
|
|
15
16
|
BACKUP_DIR = DATA_DIR / "backup"
|
|
16
|
-
TENDERLY_CONFIG_PATH =
|
|
17
|
+
TENDERLY_CONFIG_PATH = Path("tenderly.yaml")
|
|
17
18
|
|
|
18
19
|
ABI_PATH = PROJECT_ROOT / "src" / "iwa" / "core" / "contracts" / "abis"
|
|
19
20
|
|
|
@@ -25,4 +26,4 @@ DEFAULT_MECH_CONTRACT_ADDRESS = EthereumAddress("0x77af31De935740567Cf4FF1986D04
|
|
|
25
26
|
|
|
26
27
|
def get_tenderly_config_path(profile: int = 1) -> Path:
|
|
27
28
|
"""Get the path to a profile-specific Tenderly config file."""
|
|
28
|
-
return
|
|
29
|
+
return Path(f"tenderly_{profile}.yaml")
|
iwa/core/contracts/contract.py
CHANGED
|
@@ -54,11 +54,20 @@ class ContractInstance:
|
|
|
54
54
|
else:
|
|
55
55
|
self.abi = contract_abi
|
|
56
56
|
|
|
57
|
-
self.
|
|
58
|
-
address=self.address, abi=self.abi
|
|
59
|
-
)
|
|
57
|
+
self._contract_cache = None
|
|
60
58
|
self.error_selectors = self.load_error_selectors()
|
|
61
59
|
|
|
60
|
+
@property
|
|
61
|
+
def contract(self) -> Contract:
|
|
62
|
+
"""Get contract instance using the current Web3 provider.
|
|
63
|
+
|
|
64
|
+
This property ensures that after an RPC rotation, contract calls
|
|
65
|
+
use the updated provider instead of the original one.
|
|
66
|
+
"""
|
|
67
|
+
# Always create a fresh contract to use the current Web3 provider
|
|
68
|
+
# This is necessary because RPC rotation changes the underlying provider
|
|
69
|
+
return self.chain_interface.web3.eth.contract(address=self.address, abi=self.abi)
|
|
70
|
+
|
|
62
71
|
def load_error_selectors(self) -> Dict[str, Any]:
|
|
63
72
|
"""Load error selectors from the contract ABI."""
|
|
64
73
|
selectors = {}
|
|
@@ -183,7 +192,10 @@ class ContractInstance:
|
|
|
183
192
|
"""
|
|
184
193
|
method = getattr(self.contract.functions, method_name)
|
|
185
194
|
try:
|
|
186
|
-
return
|
|
195
|
+
return self.chain_interface.with_retry(
|
|
196
|
+
lambda: method(*args).call(),
|
|
197
|
+
operation_name=f"call {method_name} on {self.name}",
|
|
198
|
+
)
|
|
187
199
|
except Exception as e:
|
|
188
200
|
error_data = self._extract_error_data(e)
|
|
189
201
|
if error_data:
|
iwa/core/ipfs.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""IPFS utilities for pushing and retrieving data.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to push metadata to IPFS using
|
|
4
|
+
direct HTTP API calls, avoiding heavy dependencies like open-aea.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import aiohttp
|
|
13
|
+
from multiformats import CID
|
|
14
|
+
|
|
15
|
+
from iwa.core.models import Config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _compute_cid_v1_hex(data: bytes) -> str:
|
|
19
|
+
"""Compute CIDv1 hex representation from raw data.
|
|
20
|
+
|
|
21
|
+
This creates a CIDv1 with:
|
|
22
|
+
- multibase: 'f' (base16 lowercase)
|
|
23
|
+
- version: 1
|
|
24
|
+
- codec: raw (0x55)
|
|
25
|
+
- multihash: sha2-256
|
|
26
|
+
|
|
27
|
+
:param data: The raw data bytes.
|
|
28
|
+
:return: The CIDv1 as hex string (f01...).
|
|
29
|
+
"""
|
|
30
|
+
# SHA-256 hash
|
|
31
|
+
digest = hashlib.sha256(data).digest()
|
|
32
|
+
|
|
33
|
+
# Build CIDv1: raw codec (0x55), sha2-256 multihash (0x12), 32 bytes length (0x20)
|
|
34
|
+
cid = CID("base16", 1, "raw", ("sha2-256", digest))
|
|
35
|
+
return str(cid)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def push_to_ipfs_async(
|
|
39
|
+
data: bytes,
|
|
40
|
+
api_url: Optional[str] = None,
|
|
41
|
+
pin: bool = True,
|
|
42
|
+
) -> Tuple[str, str]:
|
|
43
|
+
"""Push raw data to IPFS using the HTTP API.
|
|
44
|
+
|
|
45
|
+
:param data: The data bytes to push.
|
|
46
|
+
:param api_url: Optional IPFS API URL. Defaults to IPFS_API_URL env var or localhost.
|
|
47
|
+
:param pin: Whether to pin the content (default True).
|
|
48
|
+
:return: Tuple of (CIDv1 string, CIDv1 hex representation).
|
|
49
|
+
"""
|
|
50
|
+
url = api_url or Config().core.ipfs_api_url
|
|
51
|
+
endpoint = f"{url}/api/v0/add"
|
|
52
|
+
|
|
53
|
+
params = {"pin": str(pin).lower(), "cid-version": "1"}
|
|
54
|
+
|
|
55
|
+
# Create multipart form data
|
|
56
|
+
form = aiohttp.FormData()
|
|
57
|
+
form.add_field("file", data, filename="data", content_type="application/octet-stream")
|
|
58
|
+
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
async with session.post(endpoint, data=form, params=params) as response:
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
result = await response.json()
|
|
63
|
+
|
|
64
|
+
cid_str = result["Hash"]
|
|
65
|
+
cid = CID.decode(cid_str)
|
|
66
|
+
|
|
67
|
+
# Convert to hex representation (f01 prefix for base16 + CIDv1)
|
|
68
|
+
# We need to reconstruct with the multihash as a tuple (name, digest)
|
|
69
|
+
cid_hex = str(CID("base16", cid.version, cid.codec, (cid.hashfun.name, cid.raw_digest)))
|
|
70
|
+
|
|
71
|
+
return cid_str, cid_hex
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def push_to_ipfs_sync(
|
|
75
|
+
data: bytes,
|
|
76
|
+
api_url: Optional[str] = None,
|
|
77
|
+
pin: bool = True,
|
|
78
|
+
) -> Tuple[str, str]:
|
|
79
|
+
"""Push raw data to IPFS using the HTTP API (synchronous version).
|
|
80
|
+
|
|
81
|
+
:param data: The data bytes to push.
|
|
82
|
+
:param api_url: Optional IPFS API URL. Defaults to IPFS_API_URL env var or localhost.
|
|
83
|
+
:param pin: Whether to pin the content (default True).
|
|
84
|
+
:return: Tuple of (CIDv1 string, CIDv1 hex representation).
|
|
85
|
+
"""
|
|
86
|
+
import requests
|
|
87
|
+
|
|
88
|
+
url = api_url or Config().core.ipfs_api_url
|
|
89
|
+
endpoint = f"{url}/api/v0/add"
|
|
90
|
+
|
|
91
|
+
params = {"pin": str(pin).lower(), "cid-version": "1"}
|
|
92
|
+
|
|
93
|
+
files = {"file": ("data", data, "application/octet-stream")}
|
|
94
|
+
|
|
95
|
+
response = requests.post(endpoint, files=files, params=params, timeout=60)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
result = response.json()
|
|
98
|
+
|
|
99
|
+
cid_str = result["Hash"]
|
|
100
|
+
cid = CID.decode(cid_str)
|
|
101
|
+
|
|
102
|
+
# Convert to hex representation (f01 prefix for base16 + CIDv1)
|
|
103
|
+
# We need to reconstruct with the multihash as a tuple (name, digest)
|
|
104
|
+
cid_hex = str(CID("base16", cid.version, cid.codec, (cid.hashfun.name, cid.raw_digest)))
|
|
105
|
+
|
|
106
|
+
return cid_str, cid_hex
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def push_metadata_to_ipfs(
|
|
110
|
+
metadata: Dict[str, Any],
|
|
111
|
+
extra_attributes: Optional[Dict[str, Any]] = None,
|
|
112
|
+
api_url: Optional[str] = None,
|
|
113
|
+
) -> Tuple[str, str]:
|
|
114
|
+
"""Push a metadata dict to IPFS synchronously.
|
|
115
|
+
|
|
116
|
+
A unique nonce is added automatically to ensure uniqueness.
|
|
117
|
+
|
|
118
|
+
:param metadata: Metadata dictionary to push.
|
|
119
|
+
:param extra_attributes: Extra attributes to include in the metadata.
|
|
120
|
+
:param api_url: Optional IPFS API URL.
|
|
121
|
+
:return: Tuple of (truncated hash with 0x prefix for contract calls, full CID hex).
|
|
122
|
+
"""
|
|
123
|
+
data = {**metadata, "nonce": str(uuid.uuid4())}
|
|
124
|
+
if extra_attributes:
|
|
125
|
+
data.update(extra_attributes)
|
|
126
|
+
|
|
127
|
+
json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
128
|
+
_, cid_hex = push_to_ipfs_sync(json_bytes, api_url)
|
|
129
|
+
|
|
130
|
+
# The truncated hash format expected by mech contracts: 0x + hex after the f01 prefix
|
|
131
|
+
# CIDv1 hex format: f01{codec}{multihash} -> we want just the multihash part
|
|
132
|
+
# For compatibility with triton, we return "0x" + cid_hex[9:] (skip f01 + 2-byte codec)
|
|
133
|
+
truncated_hash = "0x" + cid_hex[9:]
|
|
134
|
+
|
|
135
|
+
return truncated_hash, cid_hex
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def metadata_to_request_data(
|
|
139
|
+
metadata: Dict[str, Any],
|
|
140
|
+
api_url: Optional[str] = None,
|
|
141
|
+
) -> bytes:
|
|
142
|
+
"""Convert a metadata dict to mech request data by pushing to IPFS.
|
|
143
|
+
|
|
144
|
+
:param metadata: Metadata dictionary (typically contains 'prompt', 'tool', etc.).
|
|
145
|
+
:param api_url: Optional IPFS API URL.
|
|
146
|
+
:return: The request data as bytes (truncated IPFS hash).
|
|
147
|
+
"""
|
|
148
|
+
truncated_hash, _ = push_metadata_to_ipfs(metadata, api_url=api_url)
|
|
149
|
+
return bytes.fromhex(truncated_hash[2:])
|