Repository: Chia-Network/chia-blockchain
Pull request: Allow DIDs from other wallets with NIL recovery lists
Attempt to fix https://github.com/Chia-Network/chia-blockchain/issues/18947

This PR changes the `DIDCoinData` class member `recovery_list_hash` from `bytes32` to `Optional[bytes32]`. This makes it clear that None/NIL is allowed, but otherwise it should be a proper 32-byte hash. This class is marked as `Streamable`, however, I don't believe it is serialized and stored anywhere that requires backwards deserialization. This change should not introduce any backward-compatible issues.

With this change, the recovery_list_hash can be either the `NIL` program (as defined by `NIL = Program.to(None)` or the `NIL_TREEHASH`. This does require a little bit of work in a ternary so if using the NIL Program, we use None in DIDCoinData, otherwise, we expect 32 bytes of data.

This allows the DIDs from other wallets to be found and work correctly.

In order to spend this DID, the changes were a little more substantial - we need to make sure we don't curry in NIL_TREEHASH instead whenever we are creating inner puzzles for these DIDs - this was a little hacky to make sure that we always use the original curried in parameter unless we are adding recovery ids to the list.

If you add a recovery list and then remove it - you probably end up with NIL_TREEHASH , but that seems ok. Need to test that.

Added parameterized testing to most tests to include creating these types of DID

Problem statement:
[Bug] find_lost_did Bad Bytes initializer (versions 2.0.0 -> main)
### What happened?

Steps to reproduce:

1. create a wallet in pawket
2. create a did in pawket
3. click on the wallet fingerprint in pawket to reveal the 24 word mnemonic and import it into the chia reference client

NOTE - the did will be present in the pawket app but not in the chia client
Side note - the sage wallet is able to import the same mnemonics and use the DID without issue

4. run the find_lost_did command on the newly created DID
5. review the errors


When running the find lost did command on a did that is created in pawket (note it could be other dids also but this is a reproducible method to expose this issue) the chia client fails to find the did and returns an error of (note this same error is present in the debug log file)
```
Failed to find lost DID: RPC response failure: {"error": "bad bytes32 initializer b''", "success": false, "traceback": "Traceback (most recent call last):\n  File \"chia/rpc/util.py\", line 84, in inner\n  File \"chia/rpc/wallet_rpc_api.py\", line 2545, in did_find_lost_did\n  File \"chia_rs/sized_byte_class.py\", line 46, in __init__\nValueError: bad bytes32 initializer b''\n"}
```

When attempting the same command on main this error is duplicated:
```
Failed to find lost DID: RPC response failure: {"error": "bad bytes32 initializer b''", "success": false, "traceback": "Traceback (most recent call last):\n  File \"/Users/USERNAME/chia-blockchain/chia/rpc/util.py\", line 84, in inner\n    res_object = await f(request_data)\n                 ^^^^^^^^^^^^^^^^^^^^^\n  File \"/Users/USERNAME/chia-blockchain/chia/rpc/wallet_rpc_api.py\", line 2545, in did_find_lost_did\n    bytes32(recovery_list_hash.as_atom()),\n    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/Users/brandtholmes/chia-blockchain/.venv/lib/python3.12/site-packages/chia_rs/sized_byte_class.py\", line 46, in __init__\n    raise ValueError(f\"bad {type(self).__name__} initializer {v}\")\nValueError: bad bytes32 initializer b''\n"}
```

### Version

2.4.4

### What platform are you using?

macOS

### What ui mode are you using?

CLI

### Relevant log output

_No response_

Hints from issue discussion:
The cause of the issue is that the recovery list hash can be null on-chain but the reference wallet doesn't allow this. Both Pawket and Sage default to a null recovery list hash since it's 32 bytes cheaper to reveal on-chain every spend.

Commit message: Use Optional[bytes32] for DIDCoinData to show the list should be 32 bytes or nothing (where nothing is the NIL program)

Patch to review:
diff --git a/chia/_tests/cmds/wallet/test_did.py b/chia/_tests/cmds/wallet/test_did.py
index c488df2eae97..2982b4c9fd12 100644
--- a/chia/_tests/cmds/wallet/test_did.py
+++ b/chia/_tests/cmds/wallet/test_did.py
@@ -3,25 +3,43 @@
 from pathlib import Path
 from typing import Optional, Union
 
+import pytest
 from chia_rs import G2Element
-from chia_rs.sized_bytes import bytes48
+from chia_rs.sized_bytes import bytes32, bytes48
 from chia_rs.sized_ints import uint32, uint64
 
 from chia._tests.cmds.cmd_test_utils import TestRpcClients, TestWalletRpcClient, logType, run_cli_command_and_assert
 from chia._tests.cmds.wallet.test_consts import FINGERPRINT_ARG, STD_TX, STD_UTX, get_bytes32
 from chia.rpc.wallet_request_types import DIDMessageSpendResponse, DIDTransferDIDResponse, DIDUpdateMetadataResponse
+from chia.types.blockchain_format.program import NIL, Program
 from chia.types.signing_mode import SigningMode
 from chia.util.bech32m import encode_puzzle_hash
 from chia.util.config import load_config
 from chia.wallet.conditions import Condition, ConditionValidTimes, CreateCoinAnnouncement, CreatePuzzleAnnouncement
+from chia.wallet.did_wallet.did_info import (
+    did_recovery_is_nil,
+)
+from chia.wallet.util.curry_and_treehash import NIL_TREEHASH
 from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG, TXConfig
 from chia.wallet.wallet_spend_bundle import WalletSpendBundle
 
 test_condition_valid_times: ConditionValidTimes = ConditionValidTimes(min_time=uint64(100), max_time=uint64(150))
 
-# DID Commands
+
+@pytest.mark.parametrize(
+    argnames=["program", "result"],
+    argvalues=[
+        (Program.to(NIL_TREEHASH), True),
+        (NIL, True),
+        (Program.to(bytes32([1] * 32)), False),
+    ],
+)
+def test_did_recovery_is_nil(program: Program, result: bool) -> None:
+    # test that the alternate wallet nil recovery list bytes are used
+    assert did_recovery_is_nil(program) is result
 
 
+# DID Commands
 def test_did_create(capsys: object, get_test_cli_clients: tuple[TestRpcClients, Path]) -> None:
     test_rpc_clients, root_dir = get_test_cli_clients
 
diff --git a/chia/_tests/wallet/did_wallet/test_did.py b/chia/_tests/wallet/did_wallet/test_did.py
index 5303f00f7ff3..93103300daed 100644
--- a/chia/_tests/wallet/did_wallet/test_did.py
+++ b/chia/_tests/wallet/did_wallet/test_did.py
@@ -2,6 +2,7 @@
 
 import dataclasses
 import json
+from typing import Optional, Union
 
 import pytest
 from chia_rs import AugSchemeMPL, G1Element, G2Element
@@ -25,7 +26,14 @@
 from chia.util.bech32m import decode_puzzle_hash, encode_puzzle_hash
 from chia.util.condition_tools import conditions_dict_for_solution
 from chia.wallet.did_wallet.did_wallet import DIDWallet
-from chia.wallet.singleton import create_singleton_puzzle
+from chia.wallet.did_wallet.did_wallet_puzzles import (
+    DID_INNERPUZ_MOD,
+)
+from chia.wallet.singleton import (
+    SINGLETON_LAUNCHER_PUZZLE_HASH,
+    SINGLETON_TOP_LAYER_MOD_HASH,
+    create_singleton_puzzle,
+)
 from chia.wallet.util.address_type import AddressType
 from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG
 from chia.wallet.util.wallet_types import WalletType
@@ -2266,3 +2274,116 @@ async def test_did_coin_records(wallet_environments: WalletTestFramework, monkey
         )
 
     assert len(await wallet.wallet_state_manager.get_spendable_coins_for_wallet(did_wallet.id())) == 1
+
+
+@pytest.mark.limit_consensus_modes(allowed=[ConsensusMode.PLAIN], reason="irrelevant")
+@pytest.mark.anyio
+async def test_alternate_did_recovery(
+    self_hostname: str, simulator_and_wallet: OldSimulatorsAndWallets, monkeypatch: pytest.MonkeyPatch
+):
+    [full_node_api], [(wallet_node, wallet_server)], _ = simulator_and_wallet
+    api = WalletRpcApi(wallet_node)
+
+    wallet = wallet_node.wallet_state_manager.main_wallet
+    wallet_node.config["trusted_peers"] = {
+        full_node_api.full_node.server.node_id.hex(): full_node_api.full_node.server.node_id.hex()
+    }
+
+    await wallet_server.start_client(PeerInfo(self_hostname, full_node_api.server.get_port()), None)
+    await full_node_api.farm_blocks_to_wallet(1, wallet)
+
+    def alt_create_innerpuz(
+        p2_puzzle_or_hash: Union[Program, bytes32],
+        recovery_list: list[bytes32],
+        num_of_backup_ids_needed: uint64,
+        launcher_id: bytes32,
+        metadata: Program = Program.to([]),
+        recovery_list_hash: Optional[Program] = None,
+    ) -> Program:
+        # override the default of NIL_TREEHASH with NIL to match other wallet implementations
+        nil_recovery = Program.to(None)
+        singleton_struct = Program.to((SINGLETON_TOP_LAYER_MOD_HASH, (launcher_id, SINGLETON_LAUNCHER_PUZZLE_HASH)))
+        return DID_INNERPUZ_MOD.curry(p2_puzzle_or_hash, nil_recovery, 0, singleton_struct, metadata)
+
+    with monkeypatch.context() as m:
+        m.setattr("chia.wallet.did_wallet.did_wallet_puzzles.create_innerpuz", alt_create_innerpuz)
+        async with wallet.wallet_state_manager.new_action_scope(DEFAULT_TX_CONFIG, push=True) as action_scope:
+            ph = await action_scope.get_puzzle_hash(wallet.wallet_state_manager)
+            did_wallet = await DIDWallet.create_new_did_wallet(
+                wallet_node.wallet_state_manager, wallet, uint64(101), action_scope
+            )
+
+    with pytest.raises(RuntimeError, match="DID is not currently spendable"):
+        assert await did_wallet.get_coin() == set()
+    assert await did_wallet.get_info_for_recovery() is None
+    await full_node_api.process_transaction_records(records=action_scope.side_effects.transactions)
+    await full_node_api.wait_for_wallets_synced(wallet_nodes=[wallet_node])
+
+    await time_out_assert(15, did_wallet.get_confirmed_balance, 101)
+    await time_out_assert(15, did_wallet.get_unconfirmed_balance, 101)
+    await time_out_assert(15, did_wallet.get_pending_change_balance, 0)
+
+    response = await api.did_get_info({"coin_id": did_wallet.did_info.origin_coin.name().hex()})
+    # make sure the recovery list hash is empty as expected for this alternate implementation
+    assert response["recovery_list_hash"] == ""
+    await did_wallet.get_coin()
+
+    #
+    # Check that we can make an metadata update spend for this DID
+    #
+    parent_num = get_parent_num(did_wallet)
+    metadata = {}
+    metadata["Twitter"] = "http://www.twitter.com"
+    await did_wallet.update_metadata(metadata)
+    async with did_wallet.wallet_state_manager.new_action_scope(DEFAULT_TX_CONFIG, push=True) as action_scope:
+        await did_wallet.create_update_spend(action_scope, uint64(1000))
+
+    await full_node_api.process_transaction_records(records=action_scope.side_effects.transactions)
+    await full_node_api.wait_for_wallets_synced(wallet_nodes=[wallet_node])
+
+    assert get_parent_num(did_wallet) == parent_num + 2
+    assert did_wallet.did_info.metadata.find("Twitter") > 0
+    await did_wallet.get_coin()
+
+    #
+    # Check that we can make an recovery update spend for this DID
+    #
+    await did_wallet.update_recovery_list([ph], uint64(1))
+    async with did_wallet.wallet_state_manager.new_action_scope(DEFAULT_TX_CONFIG, push=True) as action_scope:
+        await did_wallet.create_update_spend(action_scope)
+
+    await full_node_api.process_transaction_records(records=action_scope.side_effects.transactions)
+    await full_node_api.wait_for_wallets_synced(wallet_nodes=[wallet_node])
+
+    response = await api.did_get_info({"coin_id": did_wallet.did_info.origin_coin.name().hex()})
+    # make sure the recovery list hash is empty as expected for this alternate implementation
+    assert response["recovery_list_hash"] == Program.to([ph]).get_tree_hash().hex()
+    coin = await did_wallet.get_coin()
+
+    #
+    # Test that we can delete and find the DID again
+    #
+    # Delete the coin and wallet
+    await wallet_node.wallet_state_manager.coin_store.delete_coin_record(coin.name())
+    await wallet_node.wallet_state_manager.delete_wallet(did_wallet.wallet_info.id)
+    wallet_node.wallet_state_manager.wallets.pop(did_wallet.wallet_info.id)
+    assert len(wallet_node.wallet_state_manager.wallets) == 1
+
+    # Find lost DID
+    assert did_wallet.did_info.origin_coin is not None  # mypy
+    resp = await api.did_find_lost_did({"coin_id": did_wallet.did_info.origin_coin.name().hex()})
+    assert resp["success"]
+    did_wallets = list(
+        filter(
+            lambda w: (w.type == WalletType.DECENTRALIZED_ID),
+            await wallet_node.wallet_state_manager.get_all_wallet_info_entries(),
+        )
+    )
+    assert len(wallet_node.wallet_state_manager.wallets) == 2
+    found_did_wallet = wallet_node.wallet_state_manager.wallets[did_wallets[0].id]
+    # assert did_wallet.did_info.origin_coin == found_did_wallet.did_info.origin_coin
+
+    await full_node_api.wait_for_wallets_synced(wallet_nodes=[wallet_node])
+    await time_out_assert(15, found_did_wallet.get_confirmed_balance, 101)
+    await time_out_assert(15, found_did_wallet.get_unconfirmed_balance, 101)
+    await time_out_assert(15, found_did_wallet.get_pending_change_balance, 0)
diff --git a/chia/rpc/wallet_rpc_api.py b/chia/rpc/wallet_rpc_api.py
index 2cf9145286f1..dd88488412c1 100644
--- a/chia/rpc/wallet_rpc_api.py
+++ b/chia/rpc/wallet_rpc_api.py
@@ -112,7 +112,7 @@
     match_address_to_sk,
 )
 from chia.wallet.did_wallet import did_wallet_puzzles
-from chia.wallet.did_wallet.did_info import DIDCoinData, DIDInfo
+from chia.wallet.did_wallet.did_info import DIDCoinData, DIDInfo, did_recovery_is_nil
 from chia.wallet.did_wallet.did_wallet import DIDWallet
 from chia.wallet.did_wallet.did_wallet_puzzles import (
     DID_INNERPUZ_MOD,
@@ -2600,7 +2600,7 @@ async def did_find_lost_did(self, request: dict[str, Any]) -> EndpointResult:
         p2_puzzle, recovery_list_hash, num_verification, singleton_struct, metadata = curried_args
         did_data: DIDCoinData = DIDCoinData(
             p2_puzzle,
-            bytes32(recovery_list_hash.as_atom()),
+            bytes32(recovery_list_hash.as_atom()) if recovery_list_hash != Program.to(None) else None,
             uint16(num_verification.as_int()),
             singleton_struct,
             metadata,
@@ -2729,7 +2729,7 @@ async def did_find_lost_did(self, request: dict[str, Any]) -> EndpointResult:
                     inner_solution: Program = full_solution.rest().rest().first()
                     recovery_list: list[bytes32] = []
                     backup_required: int = num_verification.as_int()
-                    if recovery_list_hash != NIL_TREEHASH:
+                    if not did_recovery_is_nil(recovery_list_hash):
                         try:
                             for did in inner_solution.rest().rest().rest().rest().rest().as_python():
                                 recovery_list.append(did[0])
diff --git a/chia/wallet/did_wallet/did_info.py b/chia/wallet/did_wallet/did_info.py
index 8cd2b7a4924b..a0595993c472 100644
--- a/chia/wallet/did_wallet/did_info.py
+++ b/chia/wallet/did_wallet/did_info.py
@@ -8,9 +8,18 @@
 
 from chia.protocols.wallet_protocol import CoinState
 from chia.types.blockchain_format.coin import Coin
-from chia.types.blockchain_format.program import Program
+from chia.types.blockchain_format.program import NIL, Program
 from chia.util.streamable import Streamable, streamable
 from chia.wallet.lineage_proof import LineageProof
+from chia.wallet.util.curry_and_treehash import NIL_TREEHASH
+
+
+def did_recovery_is_nil(recovery_program: Program) -> bool:
+    # cannot use set as not hashable
+    if recovery_program in (NIL, NIL_TREEHASH):  # noqa: PLR6201
+        return True
+    else:
+        return False
 
 
 @streamable
@@ -32,7 +41,7 @@ class DIDInfo(Streamable):
 @dataclass(frozen=True)
 class DIDCoinData(Streamable):
     p2_puzzle: Program
-    recovery_list_hash: bytes32
+    recovery_list_hash: Optional[bytes32]
     num_verification: uint16
     singleton_struct: Program
     metadata: Program
diff --git a/chia/wallet/did_wallet/did_wallet.py b/chia/wallet/did_wallet/did_wallet.py
index d3e9c5885f85..52ff0daeffb9 100644
--- a/chia/wallet/did_wallet/did_wallet.py
+++ b/chia/wallet/did_wallet/did_wallet.py
@@ -28,7 +28,7 @@
 )
 from chia.wallet.derivation_record import DerivationRecord
 from chia.wallet.did_wallet import did_wallet_puzzles
-from chia.wallet.did_wallet.did_info import DIDCoinData, DIDInfo
+from chia.wallet.did_wallet.did_info import DIDCoinData, DIDInfo, did_recovery_is_nil
 from chia.wallet.did_wallet.did_wallet_puzzles import match_did_puzzle, uncurry_innerpuz
 from chia.wallet.lineage_proof import LineageProof
 from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import (
@@ -229,7 +229,7 @@ async def create_new_did_wallet_from_coin_spend(
         inner_solution: Program = full_solution.rest().rest().first()
         recovery_list: list[bytes32] = []
         backup_required: int = num_verification.as_int()
-        if recovery_list_hash != NIL_TREEHASH:
+        if not did_recovery_is_nil(recovery_list_hash):
             try:
                 for did in inner_solution.rest().rest().rest().rest().rest().as_python():
                     recovery_list.append(bytes32(did[0]))
@@ -387,7 +387,9 @@ async def coin_added(self, coin: Coin, _: uint32, peer: WSChiaConnection, parent
             p2_puzzle, recovery_list_hash, num_verification, singleton_struct, metadata = did_curried_args
             did_data = DIDCoinData(
                 p2_puzzle=p2_puzzle,
-                recovery_list_hash=bytes32(recovery_list_hash.as_atom()),
+                recovery_list_hash=bytes32(recovery_list_hash.as_atom())
+                if recovery_list_hash != Program.to(None)
+                else None,
                 num_verification=uint16(num_verification.as_int()),
                 singleton_struct=singleton_struct,
                 metadata=metadata,
@@ -416,8 +418,10 @@ async def coin_added(self, coin: Coin, _: uint32, peer: WSChiaConnection, parent
         if not self._coin_is_first_singleton(coin):
             full_puzzle = create_singleton_puzzle(inner_puzzle, self.did_info.origin_coin.name())
             assert full_puzzle.get_tree_hash() == coin.puzzle_hash
+
         if self.did_info.temp_coin is not None:
             self.wallet_state_manager.state_changed("did_coin_added", self.wallet_info.id)
+
         new_info = DIDInfo(
             origin_coin=self.did_info.origin_coin,
             backup_ids=self.did_info.backup_ids,
@@ -482,6 +486,7 @@ async def load_parent(self, did_info: DIDInfo):
                 num_of_backup_ids_needed=did_info.num_of_backup_ids_needed,
                 launcher_id=did_info.origin_coin.name(),
                 metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+                recovery_list_hash=self.reset_recovery_list(),
             )
             wallet_node = self.wallet_state_manager.wallet_node
             parent_coin: Coin = did_info.origin_coin
@@ -537,6 +542,7 @@ def puzzle_for_pk(self, pubkey: G1Element) -> Program:
                 num_of_backup_ids_needed=self.did_info.num_of_backup_ids_needed,
                 launcher_id=self.did_info.origin_coin.name(),
                 metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+                recovery_list_hash=self.reset_recovery_list(),
             )
             return create_singleton_puzzle(innerpuz, self.did_info.origin_coin.name())
         else:
@@ -556,6 +562,7 @@ def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32:
             num_of_backup_ids_needed=self.did_info.num_of_backup_ids_needed,
             launcher_id=origin_coin_name,
             metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+            recovery_list_hash=self.reset_recovery_list(),
         )
         return create_singleton_puzzle_hash(innerpuz_hash, origin_coin_name)
 
@@ -701,6 +708,7 @@ async def transfer_did(
             num_of_backup_ids_needed=backup_required,
             launcher_id=self.did_info.origin_coin.name(),
             metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+            recovery_list_hash=self.reset_recovery_list(),
         )
         p2_solution = self.standard_wallet.make_solution(
             primaries=[CreateCoin(new_did_puzhash, uint64(coin.amount), [new_puzhash])],
@@ -789,7 +797,7 @@ async def create_message_spend(
         p2_ph = await action_scope.get_puzzle_hash(self.wallet_state_manager)
         new_innerpuzzle_hash = did_wallet_puzzles.get_inner_puzhash_by_p2(
             p2_puzhash=p2_ph,
-            recovery_list_hash=bytes32(id_list_hash.as_atom()),
+            recovery_list_hash=id_list_hash,
             num_of_backup_ids_needed=uint64(num_of_backup_ids_needed.as_int()),
             launcher_id=self.did_info.origin_coin.name(),
             metadata=metadata,
@@ -1129,6 +1137,7 @@ async def get_did_innerpuz(
             launcher_id = origin_id
         else:
             raise ValueError("must have origin coin")
+
         return did_wallet_puzzles.create_innerpuz(
             p2_puzzle_or_hash=await action_scope.get_puzzle(
                 self.wallet_state_manager, override_reuse_puzhash_with=override_reuse_puzhash_with
@@ -1137,6 +1146,7 @@ async def get_did_innerpuz(
             num_of_backup_ids_needed=self.did_info.num_of_backup_ids_needed,
             launcher_id=launcher_id,
             metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+            recovery_list_hash=self.reset_recovery_list(),
         )
 
     async def get_innerpuz_for_new_innerhash(self, pubkey: G1Element):
@@ -1154,6 +1164,7 @@ async def get_innerpuz_for_new_innerhash(self, pubkey: G1Element):
             num_of_backup_ids_needed=uint64(self.did_info.num_of_backup_ids_needed),
             launcher_id=self.did_info.origin_coin.name(),
             metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+            recovery_list_hash=self.reset_recovery_list(),
         )
 
     async def inner_puzzle_for_did_puzzle(self, did_hash: bytes32) -> Program:
@@ -1164,15 +1175,11 @@ async def inner_puzzle_for_did_puzzle(self, did_hash: bytes32) -> Program:
         assert self.did_info.current_inner is not None
         uncurried_args = uncurry_innerpuz(self.did_info.current_inner)
         assert uncurried_args is not None
-        old_recovery_list_hash: Optional[Program] = None
-        p2_puzzle, old_recovery_list_hash, _, _, _ = uncurried_args
+        p2_puzzle, _, _, _, _ = uncurried_args
         if record is None:
             record = await self.wallet_state_manager.puzzle_store.get_derivation_record_for_puzzle_hash(
                 p2_puzzle.get_tree_hash()
             )
-        if not (self.did_info.num_of_backup_ids_needed > 0 and len(self.did_info.backup_ids) == 0):
-            # We have the recovery list, don't reset it
-            old_recovery_list_hash = None
 
         inner_puzzle: Program = did_wallet_puzzles.create_innerpuz(
             p2_puzzle_or_hash=puzzle_for_pk(record.pubkey),
@@ -1180,10 +1187,27 @@ async def inner_puzzle_for_did_puzzle(self, did_hash: bytes32) -> Program:
             num_of_backup_ids_needed=self.did_info.num_of_backup_ids_needed,
             launcher_id=self.did_info.origin_coin.name(),
             metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
-            recovery_list_hash=old_recovery_list_hash,
+            recovery_list_hash=self.reset_recovery_list(),
         )
         return inner_puzzle
 
+    def reset_recovery_list(self) -> Optional[Program]:
+        if self.did_info.current_inner is None:
+            return None
+
+        uncurried_args = uncurry_innerpuz(self.did_info.current_inner)
+        if uncurried_args is None:
+            return None
+
+        _, og_recovery_list_hash, _, _, _ = uncurried_args
+        if self.did_info.num_of_backup_ids_needed == 0 and not did_recovery_is_nil(og_recovery_list_hash):
+            return None
+
+        if len(self.did_info.backup_ids) > 0:
+            return None
+
+        return og_recovery_list_hash
+
     def get_parent_for_coin(self, coin) -> Optional[LineageProof]:
         parent_info = None
         for name, ccparent in self.did_info.parent_info:
@@ -1503,6 +1527,7 @@ async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool:
                     num_of_backup_ids_needed=uint64(self.did_info.num_of_backup_ids_needed),
                     launcher_id=self.did_info.origin_coin.name(),
                     metadata=did_wallet_puzzles.metadata_to_program(json.loads(self.did_info.metadata)),
+                    recovery_list_hash=self.reset_recovery_list(),
                 ),
                 self.did_info.origin_coin.name(),
             ).get_tree_hash_precalc(hint)
diff --git a/chia/wallet/did_wallet/did_wallet_puzzles.py b/chia/wallet/did_wallet/did_wallet_puzzles.py
index 2788e3819037..e140a86b487e 100644
--- a/chia/wallet/did_wallet/did_wallet_puzzles.py
+++ b/chia/wallet/did_wallet/did_wallet_puzzles.py
@@ -70,7 +70,7 @@ def get_inner_puzhash_by_p2(
     launcher_id: bytes32,
     metadata: Program = Program.to([]),
     recovery_list: Optional[list[bytes32]] = None,
-    recovery_list_hash: Optional[bytes32] = None,
+    recovery_list_hash: Optional[Program] = None,
 ) -> bytes32:
     """
     Calculate DID inner puzzle hash based on a P2 puzzle hash
@@ -84,14 +84,14 @@ def get_inner_puzhash_by_p2(
 
     if recovery_list is None and recovery_list_hash is None:
         raise ValueError("Cannot construct DID inner puzzle without information about recovery list")
-    if recovery_list is not None and recovery_list_hash is not None:
-        raise ValueError("Must only specify recovery information a single way to construct DID inner puzzle")
 
-    if recovery_list is not None:
+    # Allow both recovery_list and recovery_list_hash to be provided but
+    # in that case the list is ignored and the hash is used
+    # this matches the behaviour of create_innerpuz
+    if recovery_list_hash is not None:
+        backup_ids_hash = bytes(recovery_list_hash.as_atom())
+    elif recovery_list is not None:
         backup_ids_hash = shatree_atom_list(recovery_list)
-    else:
-        assert recovery_list_hash is not None
-        backup_ids_hash = recovery_list_hash
 
     # singleton_struct = (MOD_HASH . (LAUNCHER_ID . LAUNCHER_PUZZLE_HASH))
     singleton_struct = shatree_pair(
diff --git a/chia/wallet/wallet_state_manager.py b/chia/wallet/wallet_state_manager.py
index 38c0caec6e5a..372012121948 100644
--- a/chia/wallet/wallet_state_manager.py
+++ b/chia/wallet/wallet_state_manager.py
@@ -33,7 +33,7 @@
 from chia.server.server import ChiaServer
 from chia.server.ws_connection import WSChiaConnection
 from chia.types.blockchain_format.coin import Coin
-from chia.types.blockchain_format.program import Program
+from chia.types.blockchain_format.program import NIL, Program
 from chia.types.coin_record import CoinRecord
 from chia.types.coin_spend import CoinSpend, compute_additions
 from chia.types.mempool_inclusion_status import MempoolInclusionStatus
@@ -867,7 +867,7 @@ async def determine_coin_type(
             p2_puzzle, recovery_list_hash, num_verification, singleton_struct, metadata = did_curried_args
             did_data: DIDCoinData = DIDCoinData(
                 p2_puzzle,
-                bytes32(recovery_list_hash.as_atom()),
+                bytes32(recovery_list_hash.as_atom()) if recovery_list_hash != Program.to(None) else None,
                 uint16(num_verification.as_int()),
                 singleton_struct,
                 metadata,
@@ -1258,11 +1258,23 @@ async def handle_did(
                 parent_data.singleton_struct,
                 parent_data.metadata,
             )
+            alt_did_puzzle_empty_recovery = DID_INNERPUZ_MOD.curry(
+                our_inner_puzzle,
+                NIL,
+                uint64(0),
+                parent_data.singleton_struct,
+                parent_data.metadata,
+            )
+
             full_puzzle_empty_recovery = create_singleton_puzzle(did_puzzle_empty_recovery, launch_id)
+            alt_full_puzzle_empty_recovery = create_singleton_puzzle(alt_did_puzzle_empty_recovery, launch_id)
             if full_puzzle.get_tree_hash() != coin_state.coin.puzzle_hash:
                 if full_puzzle_empty_recovery.get_tree_hash() == coin_state.coin.puzzle_hash:
                     did_puzzle = did_puzzle_empty_recovery
                     self.log.info("DID recovery list was reset by the previous owner.")
+                elif alt_full_puzzle_empty_recovery.get_tree_hash() == coin_state.coin.puzzle_hash:
+                    did_puzzle = alt_did_puzzle_empty_recovery
+                    self.log.info("DID recovery list was reset by the previous owner.")
                 else:
                     self.log.error("DID puzzle hash doesn't match, please check curried parameters.")
                     return None


Code review comments:
diff_hunk: @@ -2266,3 +2274,116 @@ async def test_did_coin_records(wallet_environments: WalletTestFramework, monkey
         )
 
     assert len(await wallet.wallet_state_manager.get_spendable_coins_for_wallet(did_wallet.id())) == 1
+
+
+@pytest.mark.limit_consensus_modes(allowed=[ConsensusMode.PLAIN], reason="irrelevant")
+@pytest.mark.anyio
+async def test_alternate_did_recovery(
+    self_hostname: str, simulator_and_wallet: OldSimulatorsAndWallets, monkeypatch: pytest.MonkeyPatch
+):
path: chia/_tests/wallet/did_wallet/test_did.py
text: @user1:
Sorry I didn't catch this in the first pass but can we use `WalletTestFramework` here please?

@user1:
actually, this seems copy pasted to some extent, so if it is, I guess it's fine to leave because there would have been a reason we didn't port the original to the framework, I remember there's some limitation to DID wallets that I intended to fix at some point.

@author:
This is now removed as I test the alternate DIDs now inside all the other existing tests. But github is being weird and my latest commit isn't there yet for some reason.

@author:
Yea, ok I had to kick github with an empty commit - so this is now removed