Repository: ApeWorX/ape

Issue:
load multiple accounts from mnemonic
### Describe

a very common usecase in brownie was running multiple accounts that are created from a single externally managed mnemonic.
https://github.com/eth-brownie/brownie/blob/3d057f32ad83934baefacce01f69607f2a05f6cd/brownie/network/account.py#L152

ape has a similar feature with `ape.utils.testing.generate_dev_accounts`, but it's not quite the same api and it's only used internally.

i suggest we refactor this and make it easier to generate multiple accounts from a mnemonic at runtime.

### Specification

```python
"""
replicates this brownie method in ape
https://github.com/eth-brownie/brownie/blob/3d057f32ad83934baefacce01f69607f2a05f6cd/brownie/network/account.py#L152
"""
from eth_account.hdaccount import generate_mnemonic
from ape.utils.testing import generate_dev_accounts
from ape_test import TestAccount

def from_mnemonic(mnemonic, count):
  devs = generate_dev_accounts(mnemonic, count)
  return [TestAccount(index=i, address_str=x.address, private_key=x.private_key) for i, x in enumerate(devs)]

mnemonic = generate_mnemonic(12, 'english')
devs = from_mnemonic(mnemonic, 50)

print(mnemonic, devs, sep='\n')
```

### Dependencies

none

Hints from issue comments:
@linear: <p><a href="https://linear.app/apeworx/issue/APE-1895/load-multiple-accounts-from-mnemonic">APE-1895 load multiple accounts from mnemonic</a></p>@antazoey: You can set the mnemonic programmatically:

```python
from ape import project, accounts

project.reconfigure(test={"mnemonic": "your mneomonic here"})
print([x for x in accounts.test_accounts])
``@fubuloubu: > You can set the mnemonic programmatically:
> 
> ```py
> from ape import project, accounts
> 
> project.reconfigure(test={"mnemonic": "your mneomonic here"})
> print([x for x in accounts.test_accounts])
> ```

@banteg would this work for this use case? (if properly documented with an example)@antazoey: We could also add a setter for mneomonic:

```
from accounts import accounts

accounts.test_accounts.mnemonic = "my new seed phrase"
print([x for x in accounts.test_accounts])
```

If you really want it to be more of a class-feel, i am ok with adding:

```
from ape import accounts

test_accounts = accounts.test_accounts.from_mnemonic("your seed phrase here")

# ~ or ~
from ape.accounts import TestAccountManager

test_accounts = TestAccountManager.from_mnemonic("your seed phrase here")

```@banteg: interesting, didn't know about reconfigure. this might be the shortest option at the moment.

i also like the last two `.from_mnemonic()` examples, i one of them might be worth adding.@antazoey: I am working on some improvements here, I will have a PR up shortly

Solution patch:
diff --git a/docs/userguides/accounts.md b/docs/userguides/accounts.md
index 18870a6dd..ca498fa6f 100644
--- a/docs/userguides/accounts.md
+++ b/docs/userguides/accounts.md
@@ -46,6 +46,16 @@ test:
   number_of_accounts: 5
 ```
 
+You can also change settings at run-time, such as the mnemonic:
+
+```python
+from ape import accounts
+
+accounts.test_accounts.mnemonic = "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat"
+print(accounts.test_accounts[0])
+# 0x627306090abaB3A6e1400e9345bC60c78a8BEf57
+```
+
 ```{warning}
 NEVER put a seed phrase with real funds here.
 ```
diff --git a/src/ape/api/accounts.py b/src/ape/api/accounts.py
index c908faf26..7be1d39fd 100644
--- a/src/ape/api/accounts.py
+++ b/src/ape/api/accounts.py
@@ -29,6 +29,11 @@ from ape.types.address import AddressType
 from ape.types.signatures import MessageSignature, SignableMessage
 from ape.utils.basemodel import BaseInterfaceModel
 from ape.utils.misc import raises_not_implemented
+from ape.utils.testing import (
+    DEFAULT_NUMBER_OF_TEST_ACCOUNTS,
+    DEFAULT_TEST_HD_PATH,
+    DEFAULT_TEST_MNEMONIC,
+)
 
 if TYPE_CHECKING:
     from eth_pydantic_types import HexBytes
@@ -626,6 +631,30 @@ class TestAccountContainerAPI(AccountContainerAPI):
     ``AccountContainerAPI`` directly. Then, they show up in the ``accounts`` test fixture.
     """
 
+    @property
+    def mnemonic(self) -> str:
+        return self.config_manager.test.get("mnemonic", DEFAULT_TEST_MNEMONIC)
+
+    @mnemonic.setter
+    def mnemonic(self, value: str):
+        self.config_manager.test.mnemonic = value
+
+    @property
+    def number_of_accounts(self) -> int:
+        return self.config_manager.test.get("number_of_accounts", DEFAULT_NUMBER_OF_TEST_ACCOUNTS)
+
+    @number_of_accounts.setter
+    def number_of_accounts(self, value: int):
+        self.config_manager.test.number_of_accounts = value
+
+    @property
+    def hd_path(self) -> str:
+        return self.config_manager.test.get("hd_path", DEFAULT_TEST_HD_PATH)
+
+    @hd_path.setter
+    def hd_path(self, value: str):
+        self.config_manager.test.hd_path = value
+
     @cached_property
     def data_folder(self) -> Path:
         """
diff --git a/src/ape/managers/accounts.py b/src/ape/managers/accounts.py
index 69ebc3190..2439b9de8 100644
--- a/src/ape/managers/accounts.py
+++ b/src/ape/managers/accounts.py
@@ -6,7 +6,7 @@ from typing import Optional, Union
 
 from eth_utils import is_hex
 
-from ape.api.accounts import (
+from ape.api import (
     AccountAPI,
     AccountContainerAPI,
     ImpersonatedAccount,
@@ -53,6 +53,35 @@ class TestAccountManager(list, ManagerAccessMixin):
             for plugin_name, (container_type, account_type) in account_types
         }
 
+    @property
+    def mnemonic(self) -> str:
+        """
+        The seed phrase for generated test accounts.
+        """
+        return self.config_manager.get_config("test").mnemonic
+
+    @mnemonic.setter
+    def mnemonic(self, value: str):
+        """
+        The seed phrase for generated test accounts.
+        **WARNING**: Changing the test-mnemonic mid-session
+           re-starts the provider (if connected to one).
+        """
+        self.config_manager.test.mnemonic = value
+        self.containers["test"].mnemonic = value
+
+        if provider := self.network_manager.active_provider:
+            provider.update_settings({"mnemonic": value})
+
+        self._accounts_by_index = {}
+
+    @property
+    def number_of_accounts(self) -> int:
+        """
+        The number of test accounts to generate and fund by default.
+        """
+        return self.config_manager.test.number_of_accounts
+
     @property
     def hd_path(self) -> str:
         """
diff --git a/src/ape/managers/project.py b/src/ape/managers/project.py
index 4d0cc3512..05fede30a 100644
--- a/src/ape/managers/project.py
+++ b/src/ape/managers/project.py
@@ -1952,7 +1952,7 @@ class Project(ProjectManager):
         Change a project's config.
 
         Args:
-            **overrides: Config key-value pairs. Completely overridesfe
+            **overrides: Config key-value pairs. Completely overrides
               existing.
         """
 
diff --git a/src/ape_accounts/accounts.py b/src/ape_accounts/accounts.py
index cec087b50..3fdd41ace 100644
--- a/src/ape_accounts/accounts.py
+++ b/src/ape_accounts/accounts.py
@@ -319,7 +319,7 @@ def generate_account(
     Args:
         alias (str): The alias name of the account.
         passphrase (str): Passphrase used to encrypt the account storage file.
-        hd_path (str): The hierarchal deterministic path to use when generating the account.
+        hd_path (str): The hierarchical deterministic path to use when generating the account.
             Defaults to `m/44'/60'/0'/0/0`.
         word_count (int): The amount of words to use in the generated mnemonic.
 
@@ -347,7 +347,7 @@ def import_account_from_mnemonic(
         alias (str): The alias name of the account.
         passphrase (str): Passphrase used to encrypt the account storage file.
         mnemonic (str): List of space-separated words representing the mnemonic seed phrase.
-        hd_path (str): The hierarchal deterministic path to use when generating the account.
+        hd_path (str): The hierarchical deterministic path to use when generating the account.
             Defaults to `m/44'/60'/0'/0/0`.
 
     Returns:


Test patch:
diff --git a/src/ape_test/accounts.py b/src/ape_test/accounts.py
index 6f41dbe61..867b5f73e 100644
--- a/src/ape_test/accounts.py
+++ b/src/ape_test/accounts.py
@@ -15,12 +15,7 @@ from ape.exceptions import ProviderNotConnectedError, SignatureError
 from ape.types.signatures import MessageSignature, TransactionSignature
 from ape.utils._web3_compat import sign_hash
 from ape.utils.misc import log_instead_of_fail
-from ape.utils.testing import (
-    DEFAULT_NUMBER_OF_TEST_ACCOUNTS,
-    DEFAULT_TEST_HD_PATH,
-    DEFAULT_TEST_MNEMONIC,
-    generate_dev_accounts,
-)
+from ape.utils.testing import generate_dev_accounts
 
 if TYPE_CHECKING:
     from ape.api.transactions import TransactionAPI
@@ -36,21 +31,25 @@ class TestAccountContainer(TestAccountContainerAPI):
     def __len__(self) -> int:
         return self.number_of_accounts + len(self.generated_accounts)
 
-    @property
-    def config(self):
-        return self.config_manager.get_config("test")
-
     @property
     def mnemonic(self) -> str:
-        return self.config.get("mnemonic", DEFAULT_TEST_MNEMONIC)
+        # Overridden so we can overload the setter.
+        return self.config_manager.test.mnemonic
 
-    @property
-    def number_of_accounts(self) -> int:
-        return self.config.get("number_of_accounts", DEFAULT_NUMBER_OF_TEST_ACCOUNTS)
+    @mnemonic.setter
+    def mnemonic(self, mnemonic: str) -> None:
+        # Overridden so we can also clear out generated accounts cache.
+        self.config_manager.test.mnemonic = mnemonic
+        self.generated_accounts = []
+
+    @TestAccountContainerAPI.mnemonic.setter
+    def mnemonic(self, mnemonic: str) -> None:
+        self.config_manager.test.mnemonic = mnemonic
+        self.generated_accounts = []
 
     @property
-    def hd_path(self) -> str:
-        return self.config.get("hd_path", DEFAULT_TEST_HD_PATH)
+    def config(self):
+        return self.config_manager.get_config("test")
 
     @property
     def aliases(self) -> Iterator[str]:
diff --git a/tests/functional/test_accounts.py b/tests/functional/test_accounts.py
index 35fcd5576..92279897f 100644
--- a/tests/functional/test_accounts.py
+++ b/tests/functional/test_accounts.py
@@ -20,6 +20,7 @@ from ape.exceptions import (
 )
 from ape.types.gas import AutoGasLimit
 from ape.types.signatures import recover_signer
+from ape.utils.testing import DEFAULT_TEST_MNEMONIC
 from ape_accounts.accounts import (
     KeyfileAccount,
     generate_account,
@@ -696,18 +697,26 @@ def test_using_different_hd_path(accounts, project, eth_tester_provider):
     assert old_address != new_address
 
 
-def test_using_random_mnemonic(accounts, project, eth_tester_provider):
-    mnemonic = "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat"
-    test_config = {"test": {"mnemonic": mnemonic}}
+def test_mnemonic(accounts):
+    actual = accounts.mnemonic
+    expected = DEFAULT_TEST_MNEMONIC
+    assert actual == expected
 
-    old_address = accounts[0].address
-    original_settings = eth_tester_provider.settings.model_dump(by_alias=True)
-    with project.temp_config(**test_config):
-        eth_tester_provider.update_settings(test_config["test"])
-        new_address = accounts[0].address
 
-    eth_tester_provider.update_settings(original_settings)
-    assert old_address != new_address
+def test_mnemonic_setter(accounts):
+    original_mnemonic = accounts.mnemonic
+    new_mnemonic = "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat"
+    original_address = accounts[0].address
+
+    # Change.
+    accounts.mnemonic = new_mnemonic
+    new_address = accounts[0].address
+
+    # Put back.
+    accounts.mnemonic = original_mnemonic
+
+    # Assert.
+    assert new_address != original_address
 
 
 def test_iter_test_accounts(accounts):