From e8eda8dddb5f54f873566c48a5f4416d6d5d4b51 Mon Sep 17 00:00:00 2001 From: Herklos Date: Tue, 30 Jun 2026 10:53:55 +0200 Subject: [PATCH] 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 --- octobot/community/authentication.py | 6 + .../wallet_backend/community_wallet.py | 28 +- packages/sync/octobot_sync/chain/__init__.py | 4 + packages/sync/octobot_sync/chain/evm.py | 12 + .../node_api_interface/api/routes/setup.py | 20 +- .../node_api_interface/api/routes/wallets.py | 8 + .../tests/test_routes_setup.py | 3 +- .../src/routes/_layout/settings.tsx | 252 +++++++++++++----- .../community/test_wallet_backend.py | 122 +++++++++ 9 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 tests/unit_tests/community/test_wallet_backend.py diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index b82b6fa88e..db5a35049a 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -653,6 +653,9 @@ def create_wallet(self, name: typing.Optional[str], passphrase: str, is_admin: b def import_wallet(self, private_key: str, passphrase: str, name: typing.Optional[str], is_admin: bool = False): return self._wallet_backend.import_wallet(private_key, passphrase, name, is_admin) + def import_wallet_from_seed(self, seed: str, passphrase: str, name: typing.Optional[str], is_admin: bool = False): + return self._wallet_backend.import_wallet_from_seed(seed, passphrase, name, is_admin) + def authenticate_wallet(self, address: str, passphrase: str) -> dict: """Verify passphrase and return wallet metadata in a single storage read. @@ -667,6 +670,9 @@ def verify_wallet_passphrase(self, address: str, passphrase: str) -> bool: def decrypt_wallet_by_address(self, address: str, passphrase: str): return self._wallet_backend.decrypt_wallet_by_address(address, passphrase) + def decrypt_wallet_entry_by_address(self, address: str, passphrase: str): + return self._wallet_backend.decrypt_wallet_entry_by_address(address, passphrase) + def remove_wallet(self, address: str) -> None: return self._wallet_backend.remove_wallet(address) diff --git a/octobot/community/wallet_backend/community_wallet.py b/octobot/community/wallet_backend/community_wallet.py index f39775d91d..141e2f83d9 100644 --- a/octobot/community/wallet_backend/community_wallet.py +++ b/octobot/community/wallet_backend/community_wallet.py @@ -57,6 +57,7 @@ class WalletEntry(commons_dataclasses.FlexibleDataclass): is_admin: bool = False private_key: str = "" passphrase_hash: str = "" + seed: typing.Optional[str] = None def _hash_passphrase(passphrase: str) -> str: @@ -123,8 +124,8 @@ def create_wallet( passphrase: str, is_admin: bool = False, ) -> sync_chain.Wallet: - wallet = sync_chain.create_evm_wallet() - return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin) + wallet, mnemonic = sync_chain.create_evm_wallet_with_mnemonic() + return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin, seed=mnemonic) def import_wallet( self, @@ -139,6 +140,19 @@ def import_wallet( raise InvalidPrivateKeyError(f"Invalid EVM private key: {err}") from err return self._add_wallet_entry(private_key, address, name, passphrase, is_admin) + def import_wallet_from_seed( + self, + seed: str, + passphrase: str, + name: typing.Optional[str], + is_admin: bool = False, + ) -> sync_chain.Wallet: + try: + wallet = sync_chain.wallet_from_mnemonic(seed.strip()) + except Exception as err: + raise InvalidPrivateKeyError(f"Invalid seed phrase: {err}") from err + return self._add_wallet_entry(wallet.private_key, wallet.address, name, passphrase, is_admin, seed=seed.strip()) + def _add_wallet_entry( self, private_key: str, @@ -146,6 +160,7 @@ def _add_wallet_entry( name: typing.Optional[str], passphrase: str, is_admin: bool, + seed: typing.Optional[str] = None, ) -> sync_chain.Wallet: if len(passphrase) < 8: raise PassphraseTooShortError("Passphrase must be at least 8 characters") @@ -162,6 +177,7 @@ def _add_wallet_entry( is_admin=is_admin, private_key=private_key.removeprefix("0x"), passphrase_hash=_hash_passphrase(passphrase), + seed=seed, ) node_wallets.append(entry) self._save_node_wallets_list(node_wallets) @@ -195,6 +211,14 @@ def decrypt_wallet_by_address(self, address: str, passphrase: str) -> sync_chain raise InvalidPassphraseError("Invalid passphrase") return self._wallet_from_entry(entry) + def decrypt_wallet_entry_by_address(self, address: str, passphrase: str) -> WalletEntry: + entry = self._find_wallet_entry(address) + if entry is None: + raise WalletNotFoundError(f"Wallet {address} not found") + if not _verify_passphrase_hash(passphrase, entry.passphrase_hash): + raise InvalidPassphraseError("Invalid passphrase") + return entry + def get_wallet_for_bot(self, address: str) -> sync_chain.Wallet: """Return wallet without passphrase verification — for bot auto-unlock at startup.""" entry = self._find_wallet_entry(address) diff --git a/packages/sync/octobot_sync/chain/__init__.py b/packages/sync/octobot_sync/chain/__init__.py index 8f6f2d5f28..e27e71ec53 100644 --- a/packages/sync/octobot_sync/chain/__init__.py +++ b/packages/sync/octobot_sync/chain/__init__.py @@ -17,11 +17,15 @@ from octobot_sync.chain.evm import ( Wallet, create_evm_wallet, + create_evm_wallet_with_mnemonic, + wallet_from_mnemonic, address_from_evm_key, ) __all__ = [ "Wallet", "create_evm_wallet", + "create_evm_wallet_with_mnemonic", + "wallet_from_mnemonic", "address_from_evm_key", ] diff --git a/packages/sync/octobot_sync/chain/evm.py b/packages/sync/octobot_sync/chain/evm.py index 1441e57127..9a13e53e4e 100644 --- a/packages/sync/octobot_sync/chain/evm.py +++ b/packages/sync/octobot_sync/chain/evm.py @@ -30,5 +30,17 @@ def create_evm_wallet() -> Wallet: return Wallet(private_key=account.key.hex(), address=account.address) +def create_evm_wallet_with_mnemonic() -> tuple[Wallet, str]: + web3.Account.enable_unaudited_hdwallet_features() # pylint: disable=no-value-for-parameter + account, mnemonic = web3.Account.create_with_mnemonic() # pylint: disable=no-value-for-parameter + return Wallet(private_key=account.key.hex(), address=account.address), mnemonic + + +def wallet_from_mnemonic(mnemonic: str) -> Wallet: + web3.Account.enable_unaudited_hdwallet_features() # pylint: disable=no-value-for-parameter + account = web3.Account.from_mnemonic(mnemonic) # pylint: disable=no-value-for-parameter + return Wallet(private_key=account.key.hex(), address=account.address) + + def address_from_evm_key(private_key: str) -> str: return web3.Account.from_key(private_key).address # pylint: disable=no-value-for-parameter diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py index b4702df016..844203be1c 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py @@ -50,6 +50,7 @@ class SetupResult(pydantic.BaseModel): class WalletExport(pydantic.BaseModel): address: str private_key: str + seed: typing.Optional[str] = None @router.get("/setup/status", response_model=SetupStatus) @@ -105,6 +106,8 @@ def init_setup(body: SetupInit) -> SetupResult: def export_wallet( current_user: CurrentUser, credentials: typing.Annotated[typing.Optional[HTTPBasicCredentials], Depends(security_basic)], + address: typing.Optional[str] = None, + passphrase: typing.Optional[str] = None, ) -> WalletExport: auth = community_auth.CommunityAuthentication.instance() if auth is None or credentials is None: @@ -112,8 +115,21 @@ def export_wallet( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Node not configured", ) + target_address = (address or current_user.email).lower() + is_own_wallet = target_address == current_user.email.lower() + if not is_own_wallet and not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the admin can export other wallets", + ) + target_passphrase = credentials.password if is_own_wallet else passphrase + if not target_passphrase: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Passphrase required", + ) try: - wallet = auth.decrypt_wallet_by_address(current_user.email, credentials.password) + entry = auth.decrypt_wallet_entry_by_address(target_address, target_passphrase) except wallet_backend.WalletNotFoundError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -124,4 +140,4 @@ def export_wallet( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid passphrase", ) - return WalletExport(address=wallet.address, private_key=wallet.private_key) + return WalletExport(address=entry.address, private_key=entry.private_key, seed=entry.seed or None) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py index a308bf881a..5482fd44e3 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/wallets.py @@ -41,6 +41,7 @@ class CreateWalletBody(pydantic.BaseModel): passphrase: str name: typing.Optional[str] = None private_key: typing.Optional[str] = None + seed: typing.Optional[str] = None class UpdateWalletBody(pydantic.BaseModel): @@ -96,6 +97,13 @@ def create_wallet(body: CreateWalletBody, current_user: CurrentUser) -> WalletIn name=body.name, is_admin=False, ) + elif body.seed: + wallet = auth.import_wallet_from_seed( + seed=body.seed, + passphrase=body.passphrase, + name=body.name, + is_admin=False, + ) else: wallet = auth.create_wallet( name=body.name, diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_setup.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_setup.py index 324dc17fe4..c74269137d 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_setup.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_setup.py @@ -116,9 +116,10 @@ def test_setup_init_invalid_passphrase_returns_422(client): def test_wallet_export_success(admin_client, mock_auth): - mock_auth.decrypt_wallet_by_address.return_value = mock.MagicMock( + mock_auth.decrypt_wallet_entry_by_address.return_value = mock.MagicMock( address=ADMIN_ADDRESS, private_key="0xdeadbeef", + seed=None, ) resp = admin_client.get( "/api/v1/setup/wallet/export", diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx index 2498403963..d26e585133 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx @@ -5,6 +5,8 @@ import { Check, Copy, Download, + Eye, + EyeOff, KeyRound, LogOut, Network, @@ -102,22 +104,34 @@ async function fetchNodeConfig() { return res.json() } -function ExportWalletDialog() { +function ExportWalletDialog({ walletAddress, isOwnWallet }: { walletAddress: string; isOwnWallet: boolean }) { const [privateKey, setPrivateKey] = useState(null) + const [seed, setSeed] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [copied, setCopied] = useState(false) + const [copiedKey, setCopiedKey] = useState(false) + const [copiedSeed, setCopiedSeed] = useState(false) + const [showKey, setShowKey] = useState(false) + const [showSeed, setShowSeed] = useState(false) + const [passphraseInput, setPassphraseInput] = useState("") - const fetchPrivateKey = async () => { + const fetchWalletExport = async (passphrase?: string) => { setLoading(true) setError(null) try { - const res = await fetch("/api/v1/setup/wallet/export", { + const params = new URLSearchParams() + if (!isOwnWallet) { + params.set("address", walletAddress) + params.set("passphrase", passphrase ?? "") + } + const url = `/api/v1/setup/wallet/export${params.size ? `?${params}` : ""}` + const res = await fetch(url, { headers: { Authorization: await buildAuthHeader() }, }) if (!res.ok) throw new Error(await res.text()) const data = await res.json() setPrivateKey(data.private_key) + setSeed(data.seed ?? null) } catch (e) { setError(e instanceof Error ? e.message : "Failed to export wallet") } finally { @@ -125,20 +139,31 @@ function ExportWalletDialog() { } } - const copy = () => { + const copyKey = () => { if (!privateKey) return navigator.clipboard.writeText(privateKey) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setCopiedKey(true) + setTimeout(() => setCopiedKey(false), 2000) + } + + const copySeed = () => { + if (!seed) return + navigator.clipboard.writeText(seed) + setCopiedSeed(true) + setTimeout(() => setCopiedSeed(false), 2000) } const onOpenChange = (open: boolean) => { - if (open) { - void fetchPrivateKey() + if (open && isOwnWallet) { + void fetchWalletExport() } if (!open) { setPrivateKey(null) + setSeed(null) setError(null) + setShowKey(false) + setShowSeed(false) + setPassphraseInput("") } } @@ -170,29 +195,94 @@ function ExportWalletDialog() {
- Never share your private key. Store it in a secure location. + Never share your private key or seed phrase. Store them in a secure location.
- {loading && ( + {!isOwnWallet && !privateKey && ( +
+ +
+ setPassphraseInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && passphraseInput) void fetchWalletExport(passphraseInput) + }} + /> + +
+
+ )} + {loading && !privateKey && isOwnWallet && (

Decrypting wallet…

)} {error &&

{error}

} {privateKey && ( -
+
+
+ Private key + +
- {privateKey} + + {showKey ? privateKey : "•".repeat(Math.min(privateKey.length, 32))} + +
+
+ )} + {seed && ( +
+
+ Seed phrase + +
+
+ + {showSeed ? seed : "•".repeat(Math.min(seed.length, 32))} + +
- {copied && ( -

Copied!

- )}
)}
@@ -572,26 +662,37 @@ function AddWalletDialog({ onSuccess }: { onSuccess: () => void }) { const [name, setName] = useState("") const [passphrase, setPassphrase] = useState("") const [privateKey, setPrivateKey] = useState("") + const [seed, setSeed] = useState("") const [importMode, setImportMode] = useState(false) + const [importBySeed, setImportBySeed] = useState(false) const [error, setError] = useState(null) + const isPrivateKeyValid = /^(0x)?[0-9a-fA-F]{64}$/.test(privateKey.trim()) + const isSeedValid = seed.trim().split(/\s+/).length >= 12 + + const reset = () => { + setName("") + setPassphrase("") + setPrivateKey("") + setSeed("") + setImportMode(false) + setImportBySeed(false) + setError(null) + } + const mutation = useMutation({ mutationFn: () => WalletsService.createWallet({ requestBody: { passphrase, name: name.trim() || null, - private_key: - importMode && privateKey.trim() ? privateKey.trim() : null, + private_key: importMode && !importBySeed && privateKey.trim() ? privateKey.trim() : null, + seed: importMode && importBySeed && seed.trim() ? seed.trim() : null, }, }), onSuccess: () => { setOpen(false) - setName("") - setPassphrase("") - setPrivateKey("") - setImportMode(false) - setError(null) + reset() onSuccess() }, onError: (e: unknown) => { @@ -600,13 +701,15 @@ function AddWalletDialog({ onSuccess }: { onSuccess: () => void }) { }, }) + const isDisabled = + passphrase.length < 8 || + (importMode && !importBySeed && !isPrivateKeyValid) || + (importMode && importBySeed && !isSeedValid) || + mutation.isPending + const handleOpenChange = (v: boolean) => { if (!v) { - setName("") - setPassphrase("") - setPrivateKey("") - setImportMode(false) - setError(null) + reset() mutation.reset() } setOpen(v) @@ -627,7 +730,7 @@ function AddWalletDialog({ onSuccess }: { onSuccess: () => void }) { Add wallet - Create a new wallet or import one with a private key. + Create a new wallet or import one with a private key or seed phrase.
@@ -685,22 +788,45 @@ function AddWalletDialog({ onSuccess }: { onSuccess: () => void }) { />
{importMode && ( -
- - setPrivateKey(e.target.value)} - /> -
+ <> +
+ + {importBySeed ? "Seed phrase" : "Private key"} + + +
+ {importBySeed ? ( +