Skip to content

Commit e8eda8d

Browse files
Herklosclaude
andcommitted
Add seed phrase support and export for all wallets
- Wallet creation generates and stores a BIP-39 mnemonic - New import_wallet_from_seed: derive private key from seed phrase (BIP-44) - Export modal: private key and seed hidden by default with show/hide toggles; seed section only shown when available - Admin sees export button on all wallets (not just their own); exporting another wallet prompts for that wallet's passphrase - Import modal: toggle between private key and seed phrase input - Backend export endpoint accepts optional address + passphrase params for admin-initiated export of other wallets - Fix displayedWallets case-insensitive address comparison (EIP-55 vs lowercase) - Tests for seed generation, BIP-39 import, duplicate detection, entry retrieval - Fix generate-tentacles-zip PYTHONPATH to use source tentacles during build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 80df46c commit e8eda8d

9 files changed

Lines changed: 390 additions & 65 deletions

File tree

octobot/community/authentication.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,9 @@ def create_wallet(self, name: typing.Optional[str], passphrase: str, is_admin: b
653653
def import_wallet(self, private_key: str, passphrase: str, name: typing.Optional[str], is_admin: bool = False):
654654
return self._wallet_backend.import_wallet(private_key, passphrase, name, is_admin)
655655

656+
def import_wallet_from_seed(self, seed: str, passphrase: str, name: typing.Optional[str], is_admin: bool = False):
657+
return self._wallet_backend.import_wallet_from_seed(seed, passphrase, name, is_admin)
658+
656659
def authenticate_wallet(self, address: str, passphrase: str) -> dict:
657660
"""Verify passphrase and return wallet metadata in a single storage read.
658661
@@ -667,6 +670,9 @@ def verify_wallet_passphrase(self, address: str, passphrase: str) -> bool:
667670
def decrypt_wallet_by_address(self, address: str, passphrase: str):
668671
return self._wallet_backend.decrypt_wallet_by_address(address, passphrase)
669672

673+
def decrypt_wallet_entry_by_address(self, address: str, passphrase: str):
674+
return self._wallet_backend.decrypt_wallet_entry_by_address(address, passphrase)
675+
670676
def remove_wallet(self, address: str) -> None:
671677
return self._wallet_backend.remove_wallet(address)
672678

octobot/community/wallet_backend/community_wallet.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class WalletEntry(commons_dataclasses.FlexibleDataclass):
5757
is_admin: bool = False
5858
private_key: str = ""
5959
passphrase_hash: str = ""
60+
seed: typing.Optional[str] = None
6061

6162

6263
def _hash_passphrase(passphrase: str) -> str:
@@ -123,8 +124,8 @@ def create_wallet(
123124
passphrase: str,
124125
is_admin: bool = False,
125126
) -> sync_chain.Wallet:
126-
wallet = sync_chain.create_evm_wallet()
127-
return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin)
127+
wallet, mnemonic = sync_chain.create_evm_wallet_with_mnemonic()
128+
return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin, seed=mnemonic)
128129

129130
def import_wallet(
130131
self,
@@ -139,13 +140,27 @@ def import_wallet(
139140
raise InvalidPrivateKeyError(f"Invalid EVM private key: {err}") from err
140141
return self._add_wallet_entry(private_key, address, name, passphrase, is_admin)
141142

143+
def import_wallet_from_seed(
144+
self,
145+
seed: str,
146+
passphrase: str,
147+
name: typing.Optional[str],
148+
is_admin: bool = False,
149+
) -> sync_chain.Wallet:
150+
try:
151+
wallet = sync_chain.wallet_from_mnemonic(seed.strip())
152+
except Exception as err:
153+
raise InvalidPrivateKeyError(f"Invalid seed phrase: {err}") from err
154+
return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin, seed=seed.strip())
155+
142156
def _add_wallet_entry(
143157
self,
144158
private_key: str,
145159
address: str,
146160
name: typing.Optional[str],
147161
passphrase: str,
148162
is_admin: bool,
163+
seed: typing.Optional[str] = None,
149164
) -> sync_chain.Wallet:
150165
if len(passphrase) < 8:
151166
raise PassphraseTooShortError("Passphrase must be at least 8 characters")
@@ -162,6 +177,7 @@ def _add_wallet_entry(
162177
is_admin=is_admin,
163178
private_key=private_key.removeprefix("0x"),
164179
passphrase_hash=_hash_passphrase(passphrase),
180+
seed=seed,
165181
)
166182
node_wallets.append(entry)
167183
self._save_node_wallets_list(node_wallets)
@@ -195,6 +211,14 @@ def decrypt_wallet_by_address(self, address: str, passphrase: str) -> sync_chain
195211
raise InvalidPassphraseError("Invalid passphrase")
196212
return self._wallet_from_entry(entry)
197213

214+
def decrypt_wallet_entry_by_address(self, address: str, passphrase: str) -> WalletEntry:
215+
entry = self._find_wallet_entry(address)
216+
if entry is None:
217+
raise WalletNotFoundError(f"Wallet {address} not found")
218+
if not _verify_passphrase_hash(passphrase, entry.passphrase_hash):
219+
raise InvalidPassphraseError("Invalid passphrase")
220+
return entry
221+
198222
def get_wallet_for_bot(self, address: str) -> sync_chain.Wallet:
199223
"""Return wallet without passphrase verification — for bot auto-unlock at startup."""
200224
entry = self._find_wallet_entry(address)

packages/sync/octobot_sync/chain/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
from octobot_sync.chain.evm import (
1818
Wallet,
1919
create_evm_wallet,
20+
create_evm_wallet_with_mnemonic,
21+
wallet_from_mnemonic,
2022
address_from_evm_key,
2123
)
2224

2325
__all__ = [
2426
"Wallet",
2527
"create_evm_wallet",
28+
"create_evm_wallet_with_mnemonic",
29+
"wallet_from_mnemonic",
2630
"address_from_evm_key",
2731
]

packages/sync/octobot_sync/chain/evm.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,17 @@ def create_evm_wallet() -> Wallet:
3030
return Wallet(private_key=account.key.hex(), address=account.address)
3131

3232

33+
def create_evm_wallet_with_mnemonic() -> tuple[Wallet, str]:
34+
web3.Account.enable_unaudited_hdwallet_features() # pylint: disable=no-value-for-parameter
35+
account, mnemonic = web3.Account.create_with_mnemonic() # pylint: disable=no-value-for-parameter
36+
return Wallet(private_key=account.key.hex(), address=account.address), mnemonic
37+
38+
39+
def wallet_from_mnemonic(mnemonic: str) -> Wallet:
40+
web3.Account.enable_unaudited_hdwallet_features() # pylint: disable=no-value-for-parameter
41+
account = web3.Account.from_mnemonic(mnemonic) # pylint: disable=no-value-for-parameter
42+
return Wallet(private_key=account.key.hex(), address=account.address)
43+
44+
3345
def address_from_evm_key(private_key: str) -> str:
3446
return web3.Account.from_key(private_key).address # pylint: disable=no-value-for-parameter

packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class SetupResult(pydantic.BaseModel):
5050
class WalletExport(pydantic.BaseModel):
5151
address: str
5252
private_key: str
53+
seed: typing.Optional[str] = None
5354

5455

5556
@router.get("/setup/status", response_model=SetupStatus)
@@ -105,15 +106,30 @@ def init_setup(body: SetupInit) -> SetupResult:
105106
def export_wallet(
106107
current_user: CurrentUser,
107108
credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)],
109+
address: typing.Optional[str] = None,
110+
passphrase: typing.Optional[str] = None,
108111
) -> WalletExport:
109112
auth = community_auth.CommunityAuthentication.instance()
110113
if auth is None or credentials is None:
111114
raise HTTPException(
112115
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
113116
detail="Node not configured",
114117
)
118+
target_address = (address or current_user.email).lower()
119+
is_own_wallet = target_address == current_user.email.lower()
120+
if not is_own_wallet and not current_user.is_superuser:
121+
raise HTTPException(
122+
status_code=status.HTTP_403_FORBIDDEN,
123+
detail="Only the admin can export other wallets",
124+
)
125+
target_passphrase = credentials.password if is_own_wallet else passphrase
126+
if not target_passphrase:
127+
raise HTTPException(
128+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
129+
detail="Passphrase required",
130+
)
115131
try:
116-
wallet = auth.decrypt_wallet_by_address(current_user.email, credentials.password)
132+
entry = auth.decrypt_wallet_entry_by_address(target_address, target_passphrase)
117133
except wallet_backend.WalletNotFoundError:
118134
raise HTTPException(
119135
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -124,4 +140,4 @@ def export_wallet(
124140
status_code=status.HTTP_401_UNAUTHORIZED,
125141
detail="Invalid passphrase",
126142
)
127-
return WalletExport(address=wallet.address, private_key=wallet.private_key)
143+
return WalletExport(address=entry.address, private_key=entry.private_key, seed=entry.seed or None)

packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class CreateWalletBody(pydantic.BaseModel):
4141
passphrase: str
4242
name: typing.Optional[str] = None
4343
private_key: typing.Optional[str] = None
44+
seed: typing.Optional[str] = None
4445

4546

4647
class UpdateWalletBody(pydantic.BaseModel):
@@ -96,6 +97,13 @@ def create_wallet(body: CreateWalletBody, current_user: CurrentUser) -> WalletIn
9697
name=body.name,
9798
is_admin=False,
9899
)
100+
elif body.seed:
101+
wallet = auth.import_wallet_from_seed(
102+
seed=body.seed,
103+
passphrase=body.passphrase,
104+
name=body.name,
105+
is_admin=False,
106+
)
99107
else:
100108
wallet = auth.create_wallet(
101109
name=body.name,

packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,10 @@ def test_setup_init_invalid_passphrase_returns_422(client):
116116

117117

118118
def test_wallet_export_success(admin_client, mock_auth):
119-
mock_auth.decrypt_wallet_by_address.return_value = mock.MagicMock(
119+
mock_auth.decrypt_wallet_entry_by_address.return_value = mock.MagicMock(
120120
address=ADMIN_ADDRESS,
121121
private_key="0xdeadbeef",
122+
seed=None,
122123
)
123124
resp = admin_client.get(
124125
"/api/v1/setup/wallet/export",

0 commit comments

Comments
 (0)