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.
Files changed (67) hide show
  1. iwa/core/chain/interface.py +51 -61
  2. iwa/core/chain/models.py +7 -7
  3. iwa/core/chain/rate_limiter.py +21 -10
  4. iwa/core/cli.py +27 -2
  5. iwa/core/constants.py +6 -5
  6. iwa/core/contracts/abis/erc20.json +930 -0
  7. iwa/core/contracts/abis/multisend.json +24 -0
  8. iwa/core/contracts/abis/multisend_call_only.json +17 -0
  9. iwa/core/contracts/contract.py +16 -4
  10. iwa/core/ipfs.py +149 -0
  11. iwa/core/keys.py +259 -29
  12. iwa/core/mnemonic.py +3 -13
  13. iwa/core/models.py +28 -6
  14. iwa/core/pricing.py +4 -4
  15. iwa/core/secrets.py +77 -0
  16. iwa/core/services/safe.py +3 -3
  17. iwa/core/utils.py +6 -1
  18. iwa/core/wallet.py +4 -0
  19. iwa/plugins/gnosis/safe.py +2 -2
  20. iwa/plugins/gnosis/tests/test_safe.py +1 -1
  21. iwa/plugins/olas/constants.py +8 -0
  22. iwa/plugins/olas/contracts/abis/activity_checker.json +110 -0
  23. iwa/plugins/olas/contracts/abis/mech.json +740 -0
  24. iwa/plugins/olas/contracts/abis/mech_marketplace.json +1293 -0
  25. iwa/plugins/olas/contracts/abis/mech_new.json +954 -0
  26. iwa/plugins/olas/contracts/abis/service_manager.json +1382 -0
  27. iwa/plugins/olas/contracts/abis/service_registry.json +1909 -0
  28. iwa/plugins/olas/contracts/abis/staking.json +1400 -0
  29. iwa/plugins/olas/contracts/abis/staking_token.json +1274 -0
  30. iwa/plugins/olas/contracts/mech.py +30 -2
  31. iwa/plugins/olas/plugin.py +2 -2
  32. iwa/plugins/olas/tests/test_plugin_full.py +3 -3
  33. iwa/plugins/olas/tests/test_staking_integration.py +2 -2
  34. iwa/tools/__init__.py +1 -0
  35. iwa/tools/check_profile.py +6 -5
  36. iwa/tools/list_contracts.py +136 -0
  37. iwa/tools/release.py +9 -3
  38. iwa/tools/reset_env.py +2 -2
  39. iwa/tools/reset_tenderly.py +26 -24
  40. iwa/tools/wallet_check.py +150 -0
  41. iwa/web/dependencies.py +4 -4
  42. iwa/web/routers/state.py +1 -0
  43. iwa/web/static/app.js +3096 -0
  44. iwa/web/static/index.html +543 -0
  45. iwa/web/static/style.css +1443 -0
  46. iwa/web/tests/test_web_endpoints.py +3 -2
  47. iwa/web/tests/test_web_swap_coverage.py +156 -0
  48. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/METADATA +6 -3
  49. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/RECORD +64 -44
  50. iwa-0.0.1a4.dist-info/entry_points.txt +6 -0
  51. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/top_level.txt +0 -1
  52. tests/test_chain.py +1 -1
  53. tests/test_chain_interface_coverage.py +92 -0
  54. tests/test_contract.py +2 -0
  55. tests/test_keys.py +58 -15
  56. tests/test_migration.py +52 -0
  57. tests/test_mnemonic.py +1 -1
  58. tests/test_pricing.py +7 -7
  59. tests/test_safe_coverage.py +1 -1
  60. tests/test_safe_service.py +3 -3
  61. tests/test_staking_router.py +13 -1
  62. tools/verify_drain.py +1 -1
  63. conftest.py +0 -22
  64. iwa/core/settings.py +0 -95
  65. iwa-0.0.1a2.dist-info/entry_points.txt +0 -2
  66. {iwa-0.0.1a2.dist-info → iwa-0.0.1a4.dist-info}/WHEEL +0 -0
  67. {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
+ ]
@@ -0,0 +1,17 @@
1
+ [
2
+ {
3
+ "inputs":[
4
+ {
5
+ "internalType":"bytes",
6
+ "name":"transactions",
7
+ "type":"bytes"
8
+ }
9
+ ],
10
+ "name":"multiSend",
11
+ "outputs":[
12
+
13
+ ],
14
+ "stateMutability":"payable",
15
+ "type":"function"
16
+ }
17
+ ]
@@ -54,11 +54,20 @@ class ContractInstance:
54
54
  else:
55
55
  self.abi = contract_abi
56
56
 
57
- self.contract: Contract = self.chain_interface.web3.eth.contract(
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 method(*args).call()
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.settings import settings
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: str
31
- nonce: str
32
- ciphertext: str
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(password: str, salt: bytes) -> bytes:
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=32,
40
- n=2**14,
41
- r=8,
42
- p=1,
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 settings.wallet_password:
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 = settings.wallet_password.get_secret_value()
52
- salt_bytes = base64.b64decode(self.salt)
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
- key = EncryptedAccount.derive_key(password, salt_bytes)
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
- salt = os.urandom(16)
65
- key = EncryptedAccount.derive_key(password, salt)
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(12)
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
- salt=base64.b64encode(salt).decode(),
74
- nonce=base64.b64encode(nonce).decode(),
75
- ciphertext=base64.b64encode(ciphertext).decode(),
76
- tag=tag,
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 = settings.wallet_password.get_secret_value()
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) if "signers" not in v else StoredSafeAccount(**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
- acct = Account.create()
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(BaseModel):
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.cypher != "aesgcm":
54
+ if self.cipher != "aesgcm":
65
55
  raise ValueError("Unsupported cipher, expected 'aesgcm'")
66
56
 
67
57
  nonce = base64.b64decode(self.nonce)