iwa 0.0.1a2__py3-none-any.whl → 0.0.1a4__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/abis/erc20.json +930 -0
- iwa/core/contracts/abis/multisend.json +24 -0
- iwa/core/contracts/abis/multisend_call_only.json +17 -0
- 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/abis/activity_checker.json +110 -0
- iwa/plugins/olas/contracts/abis/mech.json +740 -0
- iwa/plugins/olas/contracts/abis/mech_marketplace.json +1293 -0
- iwa/plugins/olas/contracts/abis/mech_new.json +954 -0
- iwa/plugins/olas/contracts/abis/service_manager.json +1382 -0
- iwa/plugins/olas/contracts/abis/service_registry.json +1909 -0
- iwa/plugins/olas/contracts/abis/staking.json +1400 -0
- iwa/plugins/olas/contracts/abis/staking_token.json +1274 -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/static/app.js +3096 -0
- iwa/web/static/index.html +543 -0
- iwa/web/static/style.css +1443 -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.1a4.dist-info}/METADATA +6 -3
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/RECORD +64 -44
- iwa-0.0.1a4.dist-info/entry_points.txt +6 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/top_level.txt +0 -1
- 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
- conftest.py +0 -22
- 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.1a4.dist-info}/WHEEL +0 -0
- {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"inputs":[
|
|
4
|
+
|
|
5
|
+
],
|
|
6
|
+
"stateMutability":"nonpayable",
|
|
7
|
+
"type":"constructor"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"inputs":[
|
|
11
|
+
{
|
|
12
|
+
"internalType":"bytes",
|
|
13
|
+
"name":"transactions",
|
|
14
|
+
"type":"bytes"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"name":"multiSend",
|
|
18
|
+
"outputs":[
|
|
19
|
+
|
|
20
|
+
],
|
|
21
|
+
"stateMutability":"payable",
|
|
22
|
+
"type":"function"
|
|
23
|
+
}
|
|
24
|
+
]
|
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:])
|
iwa/core/keys.py
CHANGED
|
@@ -4,55 +4,113 @@ import base64
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import shutil
|
|
7
|
+
import sys
|
|
7
8
|
from datetime import datetime
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import Dict, Optional, Union
|
|
10
|
-
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from bip_utils import (
|
|
13
|
+
Bip39MnemonicGenerator,
|
|
14
|
+
Bip39SeedGenerator,
|
|
15
|
+
Bip39WordsNum,
|
|
16
|
+
Bip44,
|
|
17
|
+
Bip44Changes,
|
|
18
|
+
Bip44Coins,
|
|
19
|
+
)
|
|
11
20
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
12
21
|
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
13
22
|
from eth_account import Account
|
|
14
23
|
from eth_account.signers.local import LocalAccount
|
|
15
|
-
from pydantic import BaseModel, PrivateAttr
|
|
24
|
+
from pydantic import BaseModel, PrivateAttr, model_validator
|
|
16
25
|
|
|
17
26
|
from iwa.core.constants import WALLET_PATH
|
|
18
|
-
from iwa.core.models import EthereumAddress, StoredAccount, StoredSafeAccount
|
|
19
|
-
from iwa.core.
|
|
27
|
+
from iwa.core.models import EncryptedData, EthereumAddress, StoredAccount, StoredSafeAccount
|
|
28
|
+
from iwa.core.secrets import secrets
|
|
20
29
|
from iwa.core.utils import (
|
|
21
30
|
configure_logger,
|
|
22
31
|
)
|
|
23
32
|
|
|
24
33
|
logger = configure_logger()
|
|
25
34
|
|
|
35
|
+
# Mnemonic constants
|
|
36
|
+
MNEMONIC_WORD_NUMBER = Bip39WordsNum.WORDS_NUM_24
|
|
37
|
+
SCRYPT_N = 2**14
|
|
38
|
+
SCRYPT_R = 8
|
|
39
|
+
SCRYPT_P = 1
|
|
40
|
+
SCRYPT_LEN = 32
|
|
41
|
+
AES_NONCE_LEN = 12
|
|
42
|
+
SALT_LEN = 16
|
|
43
|
+
|
|
26
44
|
|
|
27
|
-
class EncryptedAccount(StoredAccount):
|
|
45
|
+
class EncryptedAccount(StoredAccount, EncryptedData):
|
|
28
46
|
"""EncryptedAccount"""
|
|
29
47
|
|
|
30
|
-
salt
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
# We do NOT define 'salt' here to avoid serialization duplication.
|
|
49
|
+
# Legacy 'salt' is handled in the validator.
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="before")
|
|
52
|
+
@classmethod
|
|
53
|
+
def upgrade_legacy_format(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
54
|
+
"""Upgrade legacy account format to new EncryptedData structure."""
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
# Check if this is a legacy format (has 'salt')
|
|
57
|
+
if "salt" in data:
|
|
58
|
+
# Map to kdf_salt if missing
|
|
59
|
+
if "kdf_salt" not in data:
|
|
60
|
+
data["kdf_salt"] = data["salt"]
|
|
61
|
+
|
|
62
|
+
# Remove 'salt' to avoid "extra fields" error and duplication
|
|
63
|
+
data.pop("salt")
|
|
64
|
+
|
|
65
|
+
# Default KDF params for legacy accounts were:
|
|
66
|
+
# n=2**14 (16384), r=8, p=1, len=32
|
|
67
|
+
data.setdefault("kdf_n", SCRYPT_N)
|
|
68
|
+
data.setdefault("kdf_r", SCRYPT_R)
|
|
69
|
+
data.setdefault("kdf_p", SCRYPT_P)
|
|
70
|
+
data.setdefault("kdf_len", SCRYPT_LEN)
|
|
71
|
+
data.setdefault("kdf", "scrypt")
|
|
72
|
+
data.setdefault("cipher", "aesgcm")
|
|
73
|
+
return data
|
|
33
74
|
|
|
34
75
|
@staticmethod
|
|
35
|
-
def derive_key(
|
|
76
|
+
def derive_key(
|
|
77
|
+
password: str,
|
|
78
|
+
salt: bytes,
|
|
79
|
+
n: int = SCRYPT_N,
|
|
80
|
+
r: int = SCRYPT_R,
|
|
81
|
+
p: int = SCRYPT_P,
|
|
82
|
+
length: int = SCRYPT_LEN,
|
|
83
|
+
) -> bytes:
|
|
36
84
|
"""Derive key"""
|
|
37
85
|
kdf = Scrypt(
|
|
38
86
|
salt=salt,
|
|
39
|
-
length=
|
|
40
|
-
n=
|
|
41
|
-
r=
|
|
42
|
-
p=
|
|
87
|
+
length=length,
|
|
88
|
+
n=n,
|
|
89
|
+
r=r,
|
|
90
|
+
p=p,
|
|
43
91
|
)
|
|
44
92
|
return kdf.derive(password.encode())
|
|
45
93
|
|
|
46
94
|
def decrypt_private_key(self, password: Optional[str] = None) -> str:
|
|
47
95
|
"""decrypt_private_key"""
|
|
48
|
-
if not password and not
|
|
96
|
+
if not password and not secrets.wallet_password:
|
|
49
97
|
raise ValueError("Password must be provided or set in secrets.env (WALLET_PASSWORD)")
|
|
50
98
|
if not password:
|
|
51
|
-
password =
|
|
52
|
-
|
|
99
|
+
password = secrets.wallet_password.get_secret_value()
|
|
100
|
+
|
|
101
|
+
# Use kdf_salt (populated by upgrade_legacy_format if needed)
|
|
102
|
+
salt_bytes = base64.b64decode(self.kdf_salt)
|
|
53
103
|
nonce_bytes = base64.b64decode(self.nonce)
|
|
54
104
|
ciphertext_bytes = base64.b64decode(self.ciphertext)
|
|
55
|
-
|
|
105
|
+
|
|
106
|
+
key = EncryptedAccount.derive_key(
|
|
107
|
+
password,
|
|
108
|
+
salt_bytes,
|
|
109
|
+
n=self.kdf_n,
|
|
110
|
+
r=self.kdf_r,
|
|
111
|
+
p=self.kdf_p,
|
|
112
|
+
length=self.kdf_len,
|
|
113
|
+
)
|
|
56
114
|
aesgcm = AESGCM(key)
|
|
57
115
|
return aesgcm.decrypt(nonce_bytes, ciphertext_bytes, None).decode()
|
|
58
116
|
|
|
@@ -61,19 +119,36 @@ class EncryptedAccount(StoredAccount):
|
|
|
61
119
|
private_key: str, password: str, tag: Optional[str] = None
|
|
62
120
|
) -> "EncryptedAccount":
|
|
63
121
|
"""Encrypt private key"""
|
|
64
|
-
|
|
65
|
-
|
|
122
|
+
# Generate new random salt
|
|
123
|
+
salt = os.urandom(SALT_LEN)
|
|
124
|
+
|
|
125
|
+
# Use standard constants for new encryptions
|
|
126
|
+
kdf_n = SCRYPT_N
|
|
127
|
+
kdf_r = SCRYPT_R
|
|
128
|
+
kdf_p = SCRYPT_P
|
|
129
|
+
kdf_len = SCRYPT_LEN
|
|
130
|
+
|
|
131
|
+
key = EncryptedAccount.derive_key(password, salt, n=kdf_n, r=kdf_r, p=kdf_p, length=kdf_len)
|
|
66
132
|
aesgcm = AESGCM(key)
|
|
67
|
-
nonce = os.urandom(
|
|
133
|
+
nonce = os.urandom(AES_NONCE_LEN)
|
|
68
134
|
ciphertext = aesgcm.encrypt(nonce, private_key.encode(), None)
|
|
69
135
|
|
|
70
136
|
acct = Account.from_key(private_key)
|
|
137
|
+
|
|
71
138
|
return EncryptedAccount(
|
|
72
139
|
address=acct.address,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
140
|
+
tag=tag or "",
|
|
141
|
+
# EncryptedData fields
|
|
142
|
+
kdf="scrypt",
|
|
143
|
+
kdf_salt=base64.b64encode(salt).decode("utf-8"),
|
|
144
|
+
kdf_n=kdf_n,
|
|
145
|
+
kdf_r=kdf_r,
|
|
146
|
+
kdf_p=kdf_p,
|
|
147
|
+
kdf_len=kdf_len,
|
|
148
|
+
cipher="aesgcm",
|
|
149
|
+
nonce=base64.b64encode(nonce).decode("utf-8"),
|
|
150
|
+
ciphertext=base64.b64encode(ciphertext).decode("utf-8"),
|
|
151
|
+
# NO redundant 'salt' field
|
|
77
152
|
)
|
|
78
153
|
|
|
79
154
|
|
|
@@ -81,8 +156,10 @@ class KeyStorage(BaseModel):
|
|
|
81
156
|
"""KeyStorage"""
|
|
82
157
|
|
|
83
158
|
accounts: Dict[EthereumAddress, Union[EncryptedAccount, StoredSafeAccount]] = {}
|
|
159
|
+
encrypted_mnemonic: Optional[dict] = None # Encrypted BIP-39 mnemonic for master
|
|
84
160
|
_path: Path = PrivateAttr() # not stored nor validated
|
|
85
161
|
_password: str = PrivateAttr()
|
|
162
|
+
_pending_mnemonic: Optional[str] = PrivateAttr(default=None) # Temp storage for display
|
|
86
163
|
|
|
87
164
|
def __init__(self, path: Path = Path(WALLET_PATH), password: Optional[str] = None):
|
|
88
165
|
"""Initialize key storage."""
|
|
@@ -109,7 +186,7 @@ class KeyStorage(BaseModel):
|
|
|
109
186
|
|
|
110
187
|
self._path = path
|
|
111
188
|
if password is None:
|
|
112
|
-
password =
|
|
189
|
+
password = secrets.wallet_password.get_secret_value()
|
|
113
190
|
self._password = password
|
|
114
191
|
|
|
115
192
|
if os.path.exists(path):
|
|
@@ -117,9 +194,12 @@ class KeyStorage(BaseModel):
|
|
|
117
194
|
with open(path, "r") as f:
|
|
118
195
|
data = json.load(f)
|
|
119
196
|
self.accounts = {
|
|
120
|
-
k: EncryptedAccount(**v)
|
|
197
|
+
EthereumAddress(k): EncryptedAccount(**v)
|
|
198
|
+
if "signers" not in v
|
|
199
|
+
else StoredSafeAccount(**v)
|
|
121
200
|
for k, v in data.get("accounts", {}).items()
|
|
122
201
|
}
|
|
202
|
+
self.encrypted_mnemonic = data.get("encrypted_mnemonic")
|
|
123
203
|
except json.JSONDecodeError:
|
|
124
204
|
logger.error(f"Failed to load wallet from {path}: File is corrupted.")
|
|
125
205
|
self.accounts = {}
|
|
@@ -165,21 +245,171 @@ class KeyStorage(BaseModel):
|
|
|
165
245
|
# Enforce read/write only for the owner
|
|
166
246
|
os.chmod(self._path, 0o600)
|
|
167
247
|
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _encrypt_mnemonic(mnemonic: str, password: str) -> dict:
|
|
250
|
+
"""Encrypt a mnemonic with AES-GCM using a scrypt-derived key."""
|
|
251
|
+
password_b = password.encode("utf-8")
|
|
252
|
+
salt = os.urandom(SALT_LEN)
|
|
253
|
+
kdf = Scrypt(salt=salt, length=SCRYPT_LEN, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P)
|
|
254
|
+
key = kdf.derive(password_b)
|
|
255
|
+
aesgcm = AESGCM(key)
|
|
256
|
+
nonce = os.urandom(AES_NONCE_LEN)
|
|
257
|
+
ct = aesgcm.encrypt(nonce, mnemonic.encode("utf-8"), None)
|
|
258
|
+
return {
|
|
259
|
+
"kdf": "scrypt",
|
|
260
|
+
"kdf_salt": base64.b64encode(salt).decode(),
|
|
261
|
+
"kdf_n": SCRYPT_N,
|
|
262
|
+
"kdf_r": SCRYPT_R,
|
|
263
|
+
"kdf_p": SCRYPT_P,
|
|
264
|
+
"kdf_len": SCRYPT_LEN,
|
|
265
|
+
"cipher": "aesgcm",
|
|
266
|
+
"nonce": base64.b64encode(nonce).decode(),
|
|
267
|
+
"ciphertext": base64.b64encode(ct).decode(),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _decrypt_mnemonic(encobj: dict, password: str) -> str:
|
|
272
|
+
"""Decrypt a mnemonic previously created by `_encrypt_mnemonic`."""
|
|
273
|
+
if encobj.get("kdf") != "scrypt":
|
|
274
|
+
raise ValueError(f"Unsupported kdf: {encobj.get('kdf')}")
|
|
275
|
+
if encobj.get("cipher") != "aesgcm":
|
|
276
|
+
raise ValueError(f"Unsupported cipher: {encobj.get('cipher')}")
|
|
277
|
+
|
|
278
|
+
salt = base64.b64decode(encobj["kdf_salt"])
|
|
279
|
+
nonce = base64.b64decode(encobj["nonce"])
|
|
280
|
+
ct = base64.b64decode(encobj["ciphertext"])
|
|
281
|
+
|
|
282
|
+
n = encobj.get("kdf_n", SCRYPT_N)
|
|
283
|
+
r = encobj.get("kdf_r", SCRYPT_R)
|
|
284
|
+
p = encobj.get("kdf_p", SCRYPT_P)
|
|
285
|
+
length = encobj.get("kdf_len", SCRYPT_LEN)
|
|
286
|
+
|
|
287
|
+
kdf = Scrypt(salt=salt, length=length, n=n, r=r, p=p)
|
|
288
|
+
key = kdf.derive(password.encode("utf-8"))
|
|
289
|
+
aesgcm = AESGCM(key)
|
|
290
|
+
pt = aesgcm.decrypt(nonce, ct, None)
|
|
291
|
+
return pt.decode("utf-8")
|
|
292
|
+
|
|
293
|
+
def decrypt_mnemonic(self) -> str:
|
|
294
|
+
"""Decrypt the stored mnemonic using the wallet password."""
|
|
295
|
+
if not self.encrypted_mnemonic:
|
|
296
|
+
raise ValueError("No encrypted mnemonic found in wallet.")
|
|
297
|
+
return self._decrypt_mnemonic(self.encrypted_mnemonic, self._password)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _derive_private_key_from_mnemonic(mnemonic: str, index: int = 0) -> str:
|
|
301
|
+
"""Derive ETH private key from mnemonic using BIP-44 path."""
|
|
302
|
+
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
|
|
303
|
+
bip44_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
|
|
304
|
+
addr_ctx = (
|
|
305
|
+
bip44_ctx.Purpose().Coin().Account(0).Change(Bip44Changes.CHAIN_EXT).AddressIndex(index)
|
|
306
|
+
)
|
|
307
|
+
return addr_ctx.PrivateKey().Raw().ToHex()
|
|
308
|
+
|
|
309
|
+
def _create_master_from_mnemonic(self) -> Tuple[EncryptedAccount, str]:
|
|
310
|
+
"""Create master account from a new BIP-39 mnemonic.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Tuple of (EncryptedAccount, plaintext_mnemonic).
|
|
314
|
+
The mnemonic should be shown to user ONCE and never stored in plaintext.
|
|
315
|
+
|
|
316
|
+
"""
|
|
317
|
+
# Generate 24-word mnemonic
|
|
318
|
+
mnemonic = Bip39MnemonicGenerator().FromWordsNumber(MNEMONIC_WORD_NUMBER)
|
|
319
|
+
mnemonic_str = mnemonic.ToStr()
|
|
320
|
+
|
|
321
|
+
# Derive first account (index 0)
|
|
322
|
+
private_key_hex = self._derive_private_key_from_mnemonic(mnemonic_str, 0)
|
|
323
|
+
|
|
324
|
+
# Encrypt mnemonic for storage
|
|
325
|
+
self.encrypted_mnemonic = self._encrypt_mnemonic(mnemonic_str, self._password)
|
|
326
|
+
|
|
327
|
+
# Encrypt private key and create account
|
|
328
|
+
encrypted_acct = EncryptedAccount.encrypt_private_key(
|
|
329
|
+
private_key_hex, self._password, "master"
|
|
330
|
+
)
|
|
331
|
+
self.accounts[encrypted_acct.address] = encrypted_acct
|
|
332
|
+
self.save()
|
|
333
|
+
|
|
334
|
+
return encrypted_acct, mnemonic_str
|
|
335
|
+
|
|
168
336
|
def create_account(self, tag: str) -> EncryptedAccount:
|
|
169
|
-
"""Create account"""
|
|
337
|
+
"""Create account. Master is derived from mnemonic, others are random."""
|
|
170
338
|
tags = [acct.tag for acct in self.accounts.values()]
|
|
171
339
|
if not tags:
|
|
172
340
|
tag = "master" # First account is always master
|
|
173
341
|
if tag in tags:
|
|
174
342
|
raise ValueError(f"Tag '{tag}' already exists in wallet.")
|
|
175
343
|
|
|
176
|
-
|
|
344
|
+
# Master account: derive from mnemonic
|
|
345
|
+
if tag == "master":
|
|
346
|
+
encrypted_acct, mnemonic = self._create_master_from_mnemonic()
|
|
347
|
+
self._pending_mnemonic = mnemonic # Store temporarily for display
|
|
348
|
+
return encrypted_acct
|
|
177
349
|
|
|
350
|
+
# Non-master: random key as before
|
|
351
|
+
acct = Account.create()
|
|
178
352
|
encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
|
|
179
353
|
self.accounts[acct.address] = encrypted
|
|
180
354
|
self.save()
|
|
181
355
|
return encrypted
|
|
182
356
|
|
|
357
|
+
def get_pending_mnemonic(self) -> Optional[str]:
|
|
358
|
+
"""Get and clear the pending mnemonic (for one-time display).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The mnemonic string if available, None otherwise.
|
|
362
|
+
Clears the pending mnemonic after returning.
|
|
363
|
+
|
|
364
|
+
"""
|
|
365
|
+
mnemonic = self._pending_mnemonic
|
|
366
|
+
self._pending_mnemonic = None
|
|
367
|
+
return mnemonic
|
|
368
|
+
|
|
369
|
+
def display_pending_mnemonic(self) -> bool:
|
|
370
|
+
"""Display the pending mnemonic to the user and wait for confirmation.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
True if a mnemonic was displayed, False otherwise.
|
|
374
|
+
|
|
375
|
+
"""
|
|
376
|
+
# SECURITY: Do NOT print mnemonic if not in an interactive terminal (avoids Docker logs)
|
|
377
|
+
if not sys.stdout.isatty():
|
|
378
|
+
if self._pending_mnemonic:
|
|
379
|
+
print("\n" + "!" * 60)
|
|
380
|
+
print("⚠️ SECURITY WARNING: MASTER ACCOUNT CREATED FROM MNEMONIC")
|
|
381
|
+
print("!" * 60)
|
|
382
|
+
print("\nSince this is a non-interactive terminal (e.g. Docker background),")
|
|
383
|
+
print("the mnemonic is NOT displayed here to prevent it from being")
|
|
384
|
+
print("stored in log files.")
|
|
385
|
+
print("\nTo view and backup your mnemonic, run:")
|
|
386
|
+
print(" just mnemonic")
|
|
387
|
+
print("!" * 60 + "\n")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
mnemonic = self.get_pending_mnemonic()
|
|
391
|
+
if not mnemonic:
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
print("\n" + "=" * 60)
|
|
395
|
+
print("⚠️ NEW MASTER ACCOUNT CREATED - BACKUP YOUR MNEMONIC!")
|
|
396
|
+
print("=" * 60)
|
|
397
|
+
print("\nWrite down these 24 words and store them in a safe place.")
|
|
398
|
+
print("This is the ONLY time they will be shown.\n")
|
|
399
|
+
print("-" * 60)
|
|
400
|
+
words = mnemonic.split()
|
|
401
|
+
for i in range(0, 24, 4):
|
|
402
|
+
print(
|
|
403
|
+
f" {i + 1:2}. {words[i]:12} {i + 2:2}. {words[i + 1]:12} "
|
|
404
|
+
f"{i + 3:2}. {words[i + 2]:12} {i + 4:2}. {words[i + 3]:12}"
|
|
405
|
+
)
|
|
406
|
+
print("-" * 60)
|
|
407
|
+
print("\n⚠️ If you lose this mnemonic, you CANNOT recover your funds!")
|
|
408
|
+
print("=" * 60)
|
|
409
|
+
input("\nPress ENTER after you have saved your mnemonic...")
|
|
410
|
+
print()
|
|
411
|
+
return True
|
|
412
|
+
|
|
183
413
|
def remove_account(self, address_or_tag: str):
|
|
184
414
|
"""Remove account"""
|
|
185
415
|
account = self.find_stored_account(address_or_tag)
|
iwa/core/mnemonic.py
CHANGED
|
@@ -21,7 +21,7 @@ from eth_account import Account
|
|
|
21
21
|
from pydantic import BaseModel
|
|
22
22
|
|
|
23
23
|
from iwa.core.constants import WALLET_PATH
|
|
24
|
-
from iwa.core.models import EthereumAddress, StoredAccount
|
|
24
|
+
from iwa.core.models import EncryptedData, EthereumAddress, StoredAccount
|
|
25
25
|
|
|
26
26
|
MNEMONIC_WORD_NUMBER = Bip39WordsNum.WORDS_NUM_24
|
|
27
27
|
SCRYPT_N = 2**14
|
|
@@ -32,19 +32,9 @@ AES_NONCE_LEN = 12
|
|
|
32
32
|
SALT_LEN = 16
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
class EncryptedMnemonic(
|
|
35
|
+
class EncryptedMnemonic(EncryptedData):
|
|
36
36
|
"""EncryptedMnemonic"""
|
|
37
37
|
|
|
38
|
-
kdf: str = "scrypt"
|
|
39
|
-
kdf_salt: str
|
|
40
|
-
kdf_n: int = SCRYPT_N
|
|
41
|
-
kdf_r: int = SCRYPT_R
|
|
42
|
-
kdf_p: int = SCRYPT_P
|
|
43
|
-
kdf_len: int = SCRYPT_LEN
|
|
44
|
-
cypher: str = "aesgcm"
|
|
45
|
-
nonce: str
|
|
46
|
-
ciphertext: str
|
|
47
|
-
|
|
48
38
|
def derive_key(self, password: bytes) -> bytes:
|
|
49
39
|
"""Derive a key from a password and salt using scrypt."""
|
|
50
40
|
kdf = Scrypt(
|
|
@@ -61,7 +51,7 @@ class EncryptedMnemonic(BaseModel):
|
|
|
61
51
|
# validate expected algorithms
|
|
62
52
|
if self.kdf != "scrypt":
|
|
63
53
|
raise ValueError(f"Unsupported kdf: {self.kdf}")
|
|
64
|
-
if self.
|
|
54
|
+
if self.cipher != "aesgcm":
|
|
65
55
|
raise ValueError("Unsupported cipher, expected 'aesgcm'")
|
|
66
56
|
|
|
67
57
|
nonce = base64.b64decode(self.nonce)
|