iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
iwa/core/db.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Database models and utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from peewee import (
|
|
8
|
+
CharField,
|
|
9
|
+
DateTimeField,
|
|
10
|
+
FloatField,
|
|
11
|
+
Model,
|
|
12
|
+
SqliteDatabase,
|
|
13
|
+
)
|
|
14
|
+
from playhouse.migrate import SqliteMigrator, migrate
|
|
15
|
+
|
|
16
|
+
from iwa.core.constants import DATA_DIR
|
|
17
|
+
|
|
18
|
+
# Database stored in data directory alongside other data files
|
|
19
|
+
DB_PATH = DATA_DIR / "activity.db"
|
|
20
|
+
|
|
21
|
+
db = SqliteDatabase(
|
|
22
|
+
str(DB_PATH),
|
|
23
|
+
pragmas={
|
|
24
|
+
"journal_mode": "wal",
|
|
25
|
+
"cache_size": -1 * 64000,
|
|
26
|
+
"foreign_keys": 1,
|
|
27
|
+
"ignore_check_constraints": 0,
|
|
28
|
+
"synchronous": 0,
|
|
29
|
+
"busy_timeout": 5000,
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseModel(Model):
|
|
35
|
+
"""Base Peewee model."""
|
|
36
|
+
|
|
37
|
+
class Meta:
|
|
38
|
+
"""Meta configuration."""
|
|
39
|
+
|
|
40
|
+
database = db
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SentTransaction(BaseModel):
|
|
44
|
+
"""Model for sent transactions."""
|
|
45
|
+
|
|
46
|
+
tx_hash = CharField(primary_key=True)
|
|
47
|
+
from_address = CharField(index=True)
|
|
48
|
+
from_tag = CharField(null=True)
|
|
49
|
+
to_address = CharField(index=True)
|
|
50
|
+
to_tag = CharField(null=True)
|
|
51
|
+
token = CharField() # Contract Address (ERC20) or Symbol (Native)
|
|
52
|
+
amount_wei = CharField() # Store as string to avoid precision loss
|
|
53
|
+
chain = CharField()
|
|
54
|
+
timestamp = DateTimeField(default=datetime.now)
|
|
55
|
+
status = CharField(default="Pending")
|
|
56
|
+
# Pricing info
|
|
57
|
+
price_eur = FloatField(null=True)
|
|
58
|
+
value_eur = FloatField(null=True)
|
|
59
|
+
gas_cost = CharField(null=True) # Wei
|
|
60
|
+
gas_value_eur = FloatField(null=True)
|
|
61
|
+
tags = CharField(null=True) # JSON-encoded list of strings
|
|
62
|
+
extra_data = CharField(null=True) # JSON-encoded dictionary for arbitrary metadata
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _migration_drop_deprecated_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
|
|
66
|
+
"""Drop deprecated columns."""
|
|
67
|
+
if "token_symbol" in columns:
|
|
68
|
+
try:
|
|
69
|
+
migrate(migrator.drop_column("senttransaction", "token_symbol"))
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.warning(f"Migration (drop token_symbol) failed: {e}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _migration_add_tag_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
|
|
75
|
+
"""Add from_tag, to_tag, and token_symbol columns."""
|
|
76
|
+
if "from_tag" not in columns:
|
|
77
|
+
try:
|
|
78
|
+
migrate(
|
|
79
|
+
migrator.add_column("senttransaction", "from_tag", CharField(null=True)),
|
|
80
|
+
migrator.add_column("senttransaction", "to_tag", CharField(null=True)),
|
|
81
|
+
migrator.add_column("senttransaction", "token_symbol", CharField(null=True)),
|
|
82
|
+
)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.warning(f"Migration (tags/symbol) failed: {e}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _migration_add_pricing_columns(migrator: SqliteMigrator, columns: list[str]) -> None:
|
|
88
|
+
"""Add pricing related columns."""
|
|
89
|
+
if "price_eur" not in columns:
|
|
90
|
+
try:
|
|
91
|
+
migrate(
|
|
92
|
+
migrator.add_column("senttransaction", "price_eur", FloatField(null=True)),
|
|
93
|
+
migrator.add_column("senttransaction", "value_eur", FloatField(null=True)),
|
|
94
|
+
migrator.add_column("senttransaction", "gas_cost", CharField(null=True)),
|
|
95
|
+
migrator.add_column("senttransaction", "gas_value_eur", FloatField(null=True)),
|
|
96
|
+
)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning(f"Migration (pricing) failed: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _migration_add_tags_column(migrator: SqliteMigrator, columns: list[str]) -> None:
|
|
102
|
+
"""Add tags column."""
|
|
103
|
+
if "tags" not in columns:
|
|
104
|
+
try:
|
|
105
|
+
migrate(migrator.add_column("senttransaction", "tags", CharField(null=True)))
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning(f"Migration (tags) failed: {e}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _migration_add_extra_data_column(migrator: SqliteMigrator, columns: list[str]) -> None:
|
|
111
|
+
"""Add extra_data column."""
|
|
112
|
+
if "extra_data" not in columns:
|
|
113
|
+
try:
|
|
114
|
+
migrate(migrator.add_column("senttransaction", "extra_data", CharField(null=True)))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.warning(f"Migration (extra_data) failed: {e}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_migrations(columns: list[str]) -> None:
|
|
120
|
+
"""Run database migrations."""
|
|
121
|
+
migrator = SqliteMigrator(db)
|
|
122
|
+
|
|
123
|
+
migrations = [
|
|
124
|
+
_migration_drop_deprecated_columns,
|
|
125
|
+
_migration_add_tag_columns,
|
|
126
|
+
_migration_add_pricing_columns,
|
|
127
|
+
_migration_add_tags_column,
|
|
128
|
+
_migration_add_extra_data_column,
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
for migration in migrations:
|
|
132
|
+
migration(migrator, columns)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def init_db():
|
|
136
|
+
"""Initialize the database."""
|
|
137
|
+
if db.is_closed():
|
|
138
|
+
db.connect()
|
|
139
|
+
db.create_tables([SentTransaction], safe=True)
|
|
140
|
+
|
|
141
|
+
# Simple migration: check if columns exist, if not add them
|
|
142
|
+
try:
|
|
143
|
+
columns = [c.name for c in db.get_columns("senttransaction")]
|
|
144
|
+
run_migrations(columns)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
if not db.is_closed():
|
|
149
|
+
db.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_existing_transaction_data(tx_hash: str) -> tuple[SentTransaction | None, list, dict]:
|
|
153
|
+
"""Retrieve existing transaction and parse its tags/extra_data."""
|
|
154
|
+
if not tx_hash.startswith("0x"):
|
|
155
|
+
tx_hash = "0x" + tx_hash
|
|
156
|
+
|
|
157
|
+
existing = SentTransaction.get_or_none(SentTransaction.tx_hash == tx_hash)
|
|
158
|
+
existing_tags = []
|
|
159
|
+
existing_extra = {}
|
|
160
|
+
|
|
161
|
+
if existing:
|
|
162
|
+
if existing.tags:
|
|
163
|
+
try:
|
|
164
|
+
existing_tags = json.loads(existing.tags)
|
|
165
|
+
except Exception:
|
|
166
|
+
existing_tags = []
|
|
167
|
+
if existing.extra_data:
|
|
168
|
+
try:
|
|
169
|
+
existing_extra = json.loads(existing.extra_data)
|
|
170
|
+
except Exception:
|
|
171
|
+
existing_extra = {}
|
|
172
|
+
|
|
173
|
+
return existing, existing_tags, existing_extra
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _merge_transaction_tags(existing_tags: list, new_tags: list | None) -> list:
|
|
177
|
+
"""Merge existing and new tags."""
|
|
178
|
+
tags_to_add = list(new_tags) if new_tags else []
|
|
179
|
+
return list(set(existing_tags + tags_to_add))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _merge_transaction_extra_data(existing_extra: dict, new_extra: dict | None) -> dict:
|
|
183
|
+
"""Merge existing and new extra_data."""
|
|
184
|
+
extra_to_add = new_extra if new_extra else {}
|
|
185
|
+
return {**existing_extra, **extra_to_add}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_final_token_and_amount(
|
|
189
|
+
existing: SentTransaction | None,
|
|
190
|
+
token: str,
|
|
191
|
+
amount_wei: int,
|
|
192
|
+
price_eur: float | None,
|
|
193
|
+
value_eur: float | None,
|
|
194
|
+
) -> tuple[str, str, float | None, float | None]:
|
|
195
|
+
"""Resolve token name and amount, preserving ERC20 info over native currency if needed."""
|
|
196
|
+
final_token = token
|
|
197
|
+
final_amount_wei = str(amount_wei)
|
|
198
|
+
final_price_eur = price_eur
|
|
199
|
+
final_value_eur = value_eur
|
|
200
|
+
|
|
201
|
+
# Native currency names that should not overwrite ERC20 tokens
|
|
202
|
+
native_tokens = {"TOKEN", "NATIVE", "xDAI", "ETH", "XDAI"}
|
|
203
|
+
|
|
204
|
+
if existing and existing.token:
|
|
205
|
+
# If existing token is a real ERC20 (not native), preserve it
|
|
206
|
+
if existing.token.upper() not in native_tokens and token.upper() in native_tokens:
|
|
207
|
+
final_token = existing.token
|
|
208
|
+
# Force preservation of price and value even if new ones are passed
|
|
209
|
+
final_price_eur = existing.price_eur
|
|
210
|
+
final_value_eur = existing.value_eur
|
|
211
|
+
# Only preserve amount if the new one is 0
|
|
212
|
+
if int(amount_wei) == 0:
|
|
213
|
+
final_amount_wei = existing.amount_wei
|
|
214
|
+
|
|
215
|
+
return final_token, final_amount_wei, final_price_eur, final_value_eur
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _prepare_transaction_record(
|
|
219
|
+
tx_hash: str,
|
|
220
|
+
from_addr: str,
|
|
221
|
+
from_tag: str | None,
|
|
222
|
+
to_addr: str,
|
|
223
|
+
to_tag: str | None,
|
|
224
|
+
chain: str,
|
|
225
|
+
gas_cost: str | None,
|
|
226
|
+
gas_value_eur: float | None,
|
|
227
|
+
existing: SentTransaction | None,
|
|
228
|
+
final_token: str,
|
|
229
|
+
final_amount_wei: str,
|
|
230
|
+
final_price_eur: float | None,
|
|
231
|
+
final_value_eur: float | None,
|
|
232
|
+
merged_tags: list,
|
|
233
|
+
merged_extra: dict,
|
|
234
|
+
) -> dict:
|
|
235
|
+
"""Prepare the dictionary for database insertion."""
|
|
236
|
+
if not tx_hash.startswith("0x"):
|
|
237
|
+
tx_hash = "0x" + tx_hash
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"tx_hash": tx_hash,
|
|
241
|
+
"from_address": from_addr,
|
|
242
|
+
"from_tag": from_tag or (existing.from_tag if existing else None),
|
|
243
|
+
"to_address": to_addr,
|
|
244
|
+
"to_tag": to_tag or (existing.to_tag if existing else None),
|
|
245
|
+
"token": final_token,
|
|
246
|
+
"status": "Confirmed",
|
|
247
|
+
"amount_wei": final_amount_wei,
|
|
248
|
+
"chain": chain,
|
|
249
|
+
"price_eur": final_price_eur
|
|
250
|
+
if final_price_eur is not None
|
|
251
|
+
else (existing.price_eur if existing else None),
|
|
252
|
+
"value_eur": final_value_eur
|
|
253
|
+
if final_value_eur is not None
|
|
254
|
+
else (existing.value_eur if existing else None),
|
|
255
|
+
"gas_cost": str(gas_cost)
|
|
256
|
+
if gas_cost is not None
|
|
257
|
+
else (existing.gas_cost if existing else None),
|
|
258
|
+
"gas_value_eur": gas_value_eur
|
|
259
|
+
if gas_value_eur is not None
|
|
260
|
+
else (existing.gas_value_eur if existing else None),
|
|
261
|
+
"tags": json.dumps(merged_tags) if merged_tags else (existing.tags if existing else None),
|
|
262
|
+
"extra_data": json.dumps(merged_extra)
|
|
263
|
+
if merged_extra
|
|
264
|
+
else (existing.extra_data if existing else None),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def log_transaction(
|
|
269
|
+
tx_hash,
|
|
270
|
+
from_addr,
|
|
271
|
+
to_addr,
|
|
272
|
+
token,
|
|
273
|
+
amount_wei,
|
|
274
|
+
chain,
|
|
275
|
+
from_tag=None,
|
|
276
|
+
to_tag=None,
|
|
277
|
+
price_eur=None,
|
|
278
|
+
value_eur=None,
|
|
279
|
+
gas_cost=None,
|
|
280
|
+
gas_value_eur=None,
|
|
281
|
+
tags=None,
|
|
282
|
+
extra_data=None,
|
|
283
|
+
):
|
|
284
|
+
"""Log a transaction to the database (create or update)."""
|
|
285
|
+
try:
|
|
286
|
+
with db:
|
|
287
|
+
existing, existing_tags, existing_extra = _get_existing_transaction_data(tx_hash)
|
|
288
|
+
|
|
289
|
+
merged_tags = _merge_transaction_tags(existing_tags, tags)
|
|
290
|
+
merged_extra = _merge_transaction_extra_data(existing_extra, extra_data)
|
|
291
|
+
|
|
292
|
+
final_token, final_amount_wei, final_price, final_value = (
|
|
293
|
+
_resolve_final_token_and_amount(existing, token, amount_wei, price_eur, value_eur)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
data = _prepare_transaction_record(
|
|
297
|
+
tx_hash,
|
|
298
|
+
from_addr,
|
|
299
|
+
from_tag,
|
|
300
|
+
to_addr,
|
|
301
|
+
to_tag,
|
|
302
|
+
chain,
|
|
303
|
+
gas_cost,
|
|
304
|
+
gas_value_eur,
|
|
305
|
+
existing,
|
|
306
|
+
final_token,
|
|
307
|
+
final_amount_wei,
|
|
308
|
+
final_price,
|
|
309
|
+
final_value,
|
|
310
|
+
merged_tags,
|
|
311
|
+
merged_extra,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
SentTransaction.insert(**data).on_conflict_replace().execute()
|
|
315
|
+
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Failed to log transaction: {e}")
|
iwa/core/keys.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Wallet management"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Optional, Union
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
12
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
13
|
+
from eth_account import Account
|
|
14
|
+
from eth_account.signers.local import LocalAccount
|
|
15
|
+
from pydantic import BaseModel, PrivateAttr
|
|
16
|
+
|
|
17
|
+
from iwa.core.constants import WALLET_PATH
|
|
18
|
+
from iwa.core.models import EthereumAddress, StoredAccount, StoredSafeAccount
|
|
19
|
+
from iwa.core.settings import settings
|
|
20
|
+
from iwa.core.utils import (
|
|
21
|
+
configure_logger,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = configure_logger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EncryptedAccount(StoredAccount):
|
|
28
|
+
"""EncryptedAccount"""
|
|
29
|
+
|
|
30
|
+
salt: str
|
|
31
|
+
nonce: str
|
|
32
|
+
ciphertext: str
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
36
|
+
"""Derive key"""
|
|
37
|
+
kdf = Scrypt(
|
|
38
|
+
salt=salt,
|
|
39
|
+
length=32,
|
|
40
|
+
n=2**14,
|
|
41
|
+
r=8,
|
|
42
|
+
p=1,
|
|
43
|
+
)
|
|
44
|
+
return kdf.derive(password.encode())
|
|
45
|
+
|
|
46
|
+
def decrypt_private_key(self, password: Optional[str] = None) -> str:
|
|
47
|
+
"""decrypt_private_key"""
|
|
48
|
+
if not password and not settings.wallet_password:
|
|
49
|
+
raise ValueError("Password must be provided or set in secrets.env (WALLET_PASSWORD)")
|
|
50
|
+
if not password:
|
|
51
|
+
password = settings.wallet_password.get_secret_value()
|
|
52
|
+
salt_bytes = base64.b64decode(self.salt)
|
|
53
|
+
nonce_bytes = base64.b64decode(self.nonce)
|
|
54
|
+
ciphertext_bytes = base64.b64decode(self.ciphertext)
|
|
55
|
+
key = EncryptedAccount.derive_key(password, salt_bytes)
|
|
56
|
+
aesgcm = AESGCM(key)
|
|
57
|
+
return aesgcm.decrypt(nonce_bytes, ciphertext_bytes, None).decode()
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def encrypt_private_key(
|
|
61
|
+
private_key: str, password: str, tag: Optional[str] = None
|
|
62
|
+
) -> "EncryptedAccount":
|
|
63
|
+
"""Encrypt private key"""
|
|
64
|
+
salt = os.urandom(16)
|
|
65
|
+
key = EncryptedAccount.derive_key(password, salt)
|
|
66
|
+
aesgcm = AESGCM(key)
|
|
67
|
+
nonce = os.urandom(12)
|
|
68
|
+
ciphertext = aesgcm.encrypt(nonce, private_key.encode(), None)
|
|
69
|
+
|
|
70
|
+
acct = Account.from_key(private_key)
|
|
71
|
+
return EncryptedAccount(
|
|
72
|
+
address=acct.address,
|
|
73
|
+
salt=base64.b64encode(salt).decode(),
|
|
74
|
+
nonce=base64.b64encode(nonce).decode(),
|
|
75
|
+
ciphertext=base64.b64encode(ciphertext).decode(),
|
|
76
|
+
tag=tag,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class KeyStorage(BaseModel):
|
|
81
|
+
"""KeyStorage"""
|
|
82
|
+
|
|
83
|
+
accounts: Dict[EthereumAddress, Union[EncryptedAccount, StoredSafeAccount]] = {}
|
|
84
|
+
_path: Path = PrivateAttr() # not stored nor validated
|
|
85
|
+
_password: str = PrivateAttr()
|
|
86
|
+
|
|
87
|
+
def __init__(self, path: Path = Path(WALLET_PATH), password: Optional[str] = None):
|
|
88
|
+
"""Initialize key storage."""
|
|
89
|
+
super().__init__()
|
|
90
|
+
|
|
91
|
+
# PROTECTION: Prevent tests from accidentally using real wallet.json
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
is_test = "pytest" in sys.modules or "unittest" in sys.modules
|
|
95
|
+
if is_test:
|
|
96
|
+
real_wallet = Path(WALLET_PATH).resolve()
|
|
97
|
+
given_path = Path(path).resolve()
|
|
98
|
+
# Block if path points to the real wallet (even if mocked)
|
|
99
|
+
if given_path == real_wallet or str(given_path).endswith("wallet.json"):
|
|
100
|
+
# Check if we're in a temp directory (allowed)
|
|
101
|
+
import tempfile
|
|
102
|
+
|
|
103
|
+
temp_base = Path(tempfile.gettempdir()).resolve()
|
|
104
|
+
if not str(given_path).startswith(str(temp_base)):
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"SECURITY: Tests cannot use real wallet path '{path}'. "
|
|
107
|
+
f"Use tmp_path fixture instead: KeyStorage(tmp_path / 'wallet.json')"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self._path = path
|
|
111
|
+
if password is None:
|
|
112
|
+
password = settings.wallet_password.get_secret_value()
|
|
113
|
+
self._password = password
|
|
114
|
+
|
|
115
|
+
if os.path.exists(path):
|
|
116
|
+
try:
|
|
117
|
+
with open(path, "r") as f:
|
|
118
|
+
data = json.load(f)
|
|
119
|
+
self.accounts = {
|
|
120
|
+
k: EncryptedAccount(**v) if "signers" not in v else StoredSafeAccount(**v)
|
|
121
|
+
for k, v in data.get("accounts", {}).items()
|
|
122
|
+
}
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
logger.error(f"Failed to load wallet from {path}: File is corrupted.")
|
|
125
|
+
self.accounts = {}
|
|
126
|
+
else:
|
|
127
|
+
self.accounts = {}
|
|
128
|
+
|
|
129
|
+
# Ensure 'master' account exists
|
|
130
|
+
if not self.get_address_by_tag("master"):
|
|
131
|
+
logger.info("Master account not found. Creating new 'master' account...")
|
|
132
|
+
try:
|
|
133
|
+
self.create_account("master")
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.error(f"Failed to create master account: {e}")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def master_account(self) -> EncryptedAccount:
|
|
139
|
+
"""Get the master account"""
|
|
140
|
+
master_account = self.get_account("master")
|
|
141
|
+
|
|
142
|
+
if not master_account:
|
|
143
|
+
return list(self.accounts.values())[0]
|
|
144
|
+
|
|
145
|
+
return master_account
|
|
146
|
+
|
|
147
|
+
def save(self):
|
|
148
|
+
"""Save with automatic backup."""
|
|
149
|
+
# Backup existing file before overwriting
|
|
150
|
+
if self._path.exists():
|
|
151
|
+
# Use backup directory relative to wallet path (supports tests with tmp_path)
|
|
152
|
+
backup_dir = self._path.parent / "backup"
|
|
153
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
155
|
+
backup_path = backup_dir / f"wallet.json.{timestamp}.bkp"
|
|
156
|
+
shutil.copy2(self._path, backup_path)
|
|
157
|
+
logger.debug(f"Backed up wallet to {backup_path}")
|
|
158
|
+
|
|
159
|
+
# Ensure directory exists
|
|
160
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
|
|
162
|
+
with open(self._path, "w", encoding="utf-8") as f:
|
|
163
|
+
json.dump(self.model_dump(), f, indent=4)
|
|
164
|
+
|
|
165
|
+
# Enforce read/write only for the owner
|
|
166
|
+
os.chmod(self._path, 0o600)
|
|
167
|
+
|
|
168
|
+
def create_account(self, tag: str) -> EncryptedAccount:
|
|
169
|
+
"""Create account"""
|
|
170
|
+
tags = [acct.tag for acct in self.accounts.values()]
|
|
171
|
+
if not tags:
|
|
172
|
+
tag = "master" # First account is always master
|
|
173
|
+
if tag in tags:
|
|
174
|
+
raise ValueError(f"Tag '{tag}' already exists in wallet.")
|
|
175
|
+
|
|
176
|
+
acct = Account.create()
|
|
177
|
+
|
|
178
|
+
encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
|
|
179
|
+
self.accounts[acct.address] = encrypted
|
|
180
|
+
self.save()
|
|
181
|
+
return encrypted
|
|
182
|
+
|
|
183
|
+
def remove_account(self, address_or_tag: str):
|
|
184
|
+
"""Remove account"""
|
|
185
|
+
account = self.find_stored_account(address_or_tag)
|
|
186
|
+
if not account:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
del self.accounts[account.address]
|
|
190
|
+
self.save()
|
|
191
|
+
|
|
192
|
+
def _get_private_key(self, address: str) -> Optional[str]:
|
|
193
|
+
"""Get private key (Internal)"""
|
|
194
|
+
account = self.accounts.get(EthereumAddress(address))
|
|
195
|
+
if not account:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(account, StoredSafeAccount):
|
|
198
|
+
raise ValueError(f"Cannot get private key for Safe account {address}")
|
|
199
|
+
|
|
200
|
+
return account.decrypt_private_key(self._password)
|
|
201
|
+
|
|
202
|
+
# NOTE: get_private_key_unsafe() was removed for security reasons.
|
|
203
|
+
# Use sign_transaction(), sign_message(), or get_signer() instead.
|
|
204
|
+
|
|
205
|
+
def sign_message(self, message: bytes, signer_address_or_tag: str) -> bytes:
|
|
206
|
+
"""Sign a message internally without exposing the private key.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
message: The message bytes to sign
|
|
210
|
+
signer_address_or_tag: The address or tag of the signer
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The signature bytes
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
signer_account = self.find_stored_account(signer_address_or_tag)
|
|
217
|
+
if not signer_account:
|
|
218
|
+
raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
|
|
219
|
+
|
|
220
|
+
if isinstance(signer_account, StoredSafeAccount):
|
|
221
|
+
raise ValueError("Direct message signing not supported for Safe accounts.")
|
|
222
|
+
|
|
223
|
+
private_key = self._get_private_key(signer_account.address)
|
|
224
|
+
if not private_key:
|
|
225
|
+
raise ValueError(f"Private key not found for {signer_address_or_tag}")
|
|
226
|
+
|
|
227
|
+
from eth_account.messages import encode_defunct
|
|
228
|
+
|
|
229
|
+
message_hash = encode_defunct(primitive=message)
|
|
230
|
+
signed = Account.sign_message(message_hash, private_key=private_key)
|
|
231
|
+
return signed.signature
|
|
232
|
+
|
|
233
|
+
def sign_typed_data(self, typed_data: dict, signer_address_or_tag: str) -> bytes:
|
|
234
|
+
"""Sign EIP-712 typed data internally without exposing the private key.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
typed_data: EIP-712 typed data dictionary
|
|
238
|
+
signer_address_or_tag: The address or tag of the signer
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The signature bytes
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
signer_account = self.find_stored_account(signer_address_or_tag)
|
|
245
|
+
if not signer_account:
|
|
246
|
+
raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
|
|
247
|
+
|
|
248
|
+
if isinstance(signer_account, StoredSafeAccount):
|
|
249
|
+
raise ValueError("Direct message signing not supported for Safe accounts.")
|
|
250
|
+
|
|
251
|
+
private_key = self._get_private_key(signer_account.address)
|
|
252
|
+
if not private_key:
|
|
253
|
+
raise ValueError(f"Private key not found for {signer_address_or_tag}")
|
|
254
|
+
|
|
255
|
+
signed = Account.sign_typed_data(private_key=private_key, full_message=typed_data)
|
|
256
|
+
return signed.signature
|
|
257
|
+
|
|
258
|
+
def get_signer(self, address_or_tag: str) -> Optional[LocalAccount]:
|
|
259
|
+
"""Get a LocalAccount signer for the address or tag.
|
|
260
|
+
|
|
261
|
+
⚠️ SECURITY WARNING: This method returns a LocalAccount object which
|
|
262
|
+
encapsulates the private key. The private key is accessible via the
|
|
263
|
+
.key property on the returned object.
|
|
264
|
+
|
|
265
|
+
USE CASES:
|
|
266
|
+
- Only use this when an external library requires a signer object
|
|
267
|
+
(e.g., CowSwap SDK, safe-eth-py for certain operations)
|
|
268
|
+
|
|
269
|
+
DO NOT:
|
|
270
|
+
- Log or serialize the returned LocalAccount object
|
|
271
|
+
- Store the returned object longer than necessary
|
|
272
|
+
- Pass the .key property to any external system
|
|
273
|
+
|
|
274
|
+
ALTERNATIVES:
|
|
275
|
+
- For signing transactions: use sign_transaction() instead
|
|
276
|
+
- For message signing: use sign_message() or sign_typed_data()
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
address_or_tag: Address or tag of the account to get signer for.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
LocalAccount if found and is an EOA, None otherwise.
|
|
283
|
+
Returns None for Safe accounts (they cannot sign directly).
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
account = self.find_stored_account(address_or_tag)
|
|
287
|
+
if not account:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# Safe accounts cannot be signers directly in this context (usually)
|
|
291
|
+
if isinstance(account, StoredSafeAccount):
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
private_key = self._get_private_key(account.address)
|
|
295
|
+
if not private_key:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
return Account.from_key(private_key)
|
|
299
|
+
|
|
300
|
+
def sign_transaction(self, transaction: dict, signer_address_or_tag: str):
|
|
301
|
+
"""Sign a transaction"""
|
|
302
|
+
signer_account = self.find_stored_account(signer_address_or_tag)
|
|
303
|
+
if not signer_account:
|
|
304
|
+
raise ValueError(f"Signer account '{signer_address_or_tag}' not found.")
|
|
305
|
+
|
|
306
|
+
if isinstance(signer_account, StoredSafeAccount):
|
|
307
|
+
raise ValueError("Direct transaction signing not supported for Safe accounts.")
|
|
308
|
+
|
|
309
|
+
private_key = self._get_private_key(signer_account.address)
|
|
310
|
+
if not private_key:
|
|
311
|
+
raise ValueError(f"Private key not found for {signer_address_or_tag}")
|
|
312
|
+
|
|
313
|
+
signed = Account.sign_transaction(transaction, private_key)
|
|
314
|
+
return signed
|
|
315
|
+
|
|
316
|
+
# ... (create_safe omitted for brevity, but I should log there too if needed)
|
|
317
|
+
|
|
318
|
+
def find_stored_account(
|
|
319
|
+
self, address_or_tag: str
|
|
320
|
+
) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
|
|
321
|
+
"""Find a stored account by address or tag."""
|
|
322
|
+
# Try tag first
|
|
323
|
+
for acc in self.accounts.values():
|
|
324
|
+
if acc.tag == address_or_tag:
|
|
325
|
+
return acc
|
|
326
|
+
|
|
327
|
+
# Then try address
|
|
328
|
+
try:
|
|
329
|
+
addr = EthereumAddress(address_or_tag)
|
|
330
|
+
return self.accounts.get(addr)
|
|
331
|
+
except ValueError:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
def get_account(self, address_or_tag: str) -> Optional[Union[StoredAccount, StoredSafeAccount]]:
|
|
335
|
+
"""Get basic account info without exposing any possibility of private key access."""
|
|
336
|
+
stored = self.find_stored_account(address_or_tag)
|
|
337
|
+
if not stored:
|
|
338
|
+
return None
|
|
339
|
+
if isinstance(stored, StoredSafeAccount):
|
|
340
|
+
return stored
|
|
341
|
+
return StoredAccount(address=stored.address, tag=stored.tag)
|
|
342
|
+
|
|
343
|
+
def get_account_info(
|
|
344
|
+
self, address_or_tag: str
|
|
345
|
+
) -> Optional[Union[StoredAccount, StoredSafeAccount]]:
|
|
346
|
+
"""Alias for get_account for clarity when specifically requesting metadata."""
|
|
347
|
+
return self.get_account(address_or_tag)
|
|
348
|
+
|
|
349
|
+
def get_tag_by_address(self, address: EthereumAddress) -> Optional[str]:
|
|
350
|
+
"""Get tag by address"""
|
|
351
|
+
account = self.accounts.get(EthereumAddress(address))
|
|
352
|
+
if account:
|
|
353
|
+
return account.tag
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
def get_address_by_tag(self, tag: str) -> Optional[EthereumAddress]:
|
|
357
|
+
"""Get address by tag"""
|
|
358
|
+
for account in self.accounts.values():
|
|
359
|
+
if account.tag == tag:
|
|
360
|
+
return EthereumAddress(account.address)
|
|
361
|
+
return None
|