Merge pull request #11918 from Chia-Network/nft1_claim

Add set_nft_did API
This commit is contained in:
Amine Khaldi
2022-06-16 18:10:32 +01:00
committed by GitHub
5 changed files with 333 additions and 25 deletions
+18 -1
View File
@@ -137,6 +137,7 @@ class WalletRpcApi:
"/nft_mint_nft": self.nft_mint_nft,
"/nft_get_nfts": self.nft_get_nfts,
"/nft_get_by_did": self.nft_get_by_did,
"/nft_set_nft_did": self.nft_set_nft_did,
"/nft_get_wallet_did": self.nft_get_wallet_did,
"/nft_get_wallets_with_dids": self.nft_get_wallets_with_dids,
"/nft_get_info": self.nft_get_info,
@@ -1399,6 +1400,22 @@ class WalletRpcApi:
nft_info_list.append(nft_puzzles.get_nft_info_from_puzzle(nft))
return {"wallet_id": wallet_id, "success": True, "nft_list": nft_info_list}
async def nft_set_nft_did(self, request):
try:
assert self.service.wallet_state_manager is not None
wallet_id = uint32(request["wallet_id"])
nft_wallet: NFTWallet = self.service.wallet_state_manager.wallets[wallet_id]
did_id: Optional[bytes32] = None
if "did_id" in request:
did_id = decode_puzzle_hash(request["did_id"])
nft_coin_info = nft_wallet.get_nft_coin_by_id(bytes32.from_hexstr(request["nft_coin_id"]))
fee = uint64(request.get("fee", 0))
spend_bundle = await nft_wallet.set_nft_did(nft_coin_info, did_id, fee=fee)
return {"wallet_id": wallet_id, "success": True, "spend_bundle": spend_bundle}
except Exception as e:
log.exception(f"Failed to set DID on NFT: {e}")
return {"success": False, "error": f"Failed to set DID on NFT: {e}"}
async def nft_get_by_did(self, request) -> Dict:
did_id: Optional[bytes32] = None
if "did_id" in request:
@@ -1469,7 +1486,7 @@ class WalletRpcApi:
txs = await nft_wallet.generate_signed_transaction(
[nft_coin_info.coin.amount],
[puzzle_hash],
coins=[nft_coin_info.coin],
coins={nft_coin_info.coin},
fee=fee,
)
spend_bundle: Optional[SpendBundle] = None
+13 -3
View File
@@ -226,7 +226,7 @@ def create_ownership_layer_puzzle(
def create_ownership_layer_transfer_solution(
new_did: bytes,
new_did_inner_hash: bytes32,
new_did_inner_hash: bytes,
trade_prices_list: List[List[int]],
new_puzhash: bytes32,
) -> Program:
@@ -256,7 +256,7 @@ def create_ownership_layer_transfer_solution(
return solution
def get_metadata_and_phs(unft: UncurriedNFT, puzzle: Program, solution: SerializedProgram) -> Tuple[Program, bytes32]:
def get_metadata_and_phs(unft: UncurriedNFT, solution: SerializedProgram) -> Tuple[Program, bytes32]:
conditions = unft.p2_puzzle.run(unft.get_innermost_solution(solution.to_program()))
metadata = unft.metadata
puzhash_for_derivation: Optional[bytes32] = None
@@ -285,7 +285,7 @@ def get_metadata_and_phs(unft: UncurriedNFT, puzzle: Program, solution: Serializ
def recurry_nft_puzzle(unft: UncurriedNFT, solution: Program, sp2_puzzle: Program) -> Program:
log.debug("Generating NFT puzzle with ownership support: %s", disassemble(solution))
conditions = solution.at("frfr").as_iter()
new_did_id = None
new_did_id = unft.owner_did
new_puzhash = None
for condition in conditions:
if condition.first().as_int() == -10:
@@ -298,3 +298,13 @@ def recurry_nft_puzzle(unft: UncurriedNFT, solution: Program, sp2_puzzle: Progra
assert unft.transfer_program
inner_puzzle = construct_ownership_layer(new_did_id, unft.transfer_program, sp2_puzzle)
return inner_puzzle
def get_new_owner_did(solution: Program) -> Optional[bytes32]:
conditions = solution.at("rrfffrfr").as_iter()
new_did_id = None
for condition in conditions:
if condition.first().as_int() == -10:
# this is the change owner magic condition
new_did_id = condition.at("rf").atom
return new_did_id
+58 -15
View File
@@ -192,7 +192,7 @@ class NFTWallet:
self.log.debug("Puzzle solution received to wallet: %s", self.wallet_info)
coin_name = coin_spend.coin.name()
puzzle: Program = Program.from_bytes(bytes(coin_spend.puzzle_reveal))
delegated_puz_solution: Program = Program.from_bytes(bytes(coin_spend.solution)).rest().rest().first().first()
delegated_puz_solution: Program = coin_spend.solution.to_program().rest().rest().first().first()
# At this point, the puzzle must be a NFT puzzle.
# This method will be called only when the wallet state manager uncurried this coin as a NFT puzzle.
@@ -202,7 +202,7 @@ class NFTWallet:
)
singleton_id = uncurried_nft.singleton_launcher_id
parent_inner_puzhash = uncurried_nft.nft_state_layer.get_tree_hash()
metadata, p2_puzzle_hash = get_metadata_and_phs(uncurried_nft, puzzle, coin_spend.solution)
metadata, p2_puzzle_hash = get_metadata_and_phs(uncurried_nft, coin_spend.solution)
self.log.debug("Got back puzhash from solution: %s", p2_puzzle_hash)
self.log.debug("Got back updated metadata: %s", metadata)
derivation_record: Optional[
@@ -314,24 +314,27 @@ class NFTWallet:
async def get_did_approval_info(
self,
nft_id: bytes32,
did_id: bytes32 = None,
) -> Tuple[bytes32, SpendBundle]:
"""Get DID spend with announcement created we need to transfer NFT with did with current inner hash of DID
We also store `did_id` and then iterate to find the did wallet as we'd otherwise have to subscribe to
any changes to DID wallet and storing wallet_id is not guaranteed to be consistent on wallet crash/reset.
"""
if did_id is None:
did_id = self.did_id
for _, wallet in self.wallet_state_manager.wallets.items():
self.log.debug("Checking wallet type %s", wallet.type())
if wallet.type() == WalletType.DISTRIBUTED_ID:
self.log.debug("Found a DID wallet, checking did: %r == %r", wallet.get_my_DID(), self.did_id)
if bytes32.fromhex(wallet.get_my_DID()) == self.did_id:
self.log.debug("Found a DID wallet, checking did: %r == %r", wallet.get_my_DID(), did_id)
if bytes32.fromhex(wallet.get_my_DID()) == did_id:
self.log.debug("Creating announcement from DID for nft_id: %s", nft_id)
did_bundle = await wallet.create_message_spend(puzzle_announcements=[nft_id])
self.log.debug("Sending DID announcement from puzzle: %s", did_bundle.removals())
did_inner_hash = wallet.did_info.current_inner.get_tree_hash()
break
else:
raise ValueError(f"Missing DID Wallet for did_id: {self.did_id}")
raise ValueError(f"Missing DID Wallet for did_id: {did_id}")
return did_inner_hash, did_bundle
async def generate_new_nft(
@@ -412,10 +415,11 @@ class NFTWallet:
record: Optional[DerivationRecord] = None
# Create inner solution for eve spend
if did_id is not None:
did_inner_hash, did_bundle = await self.get_did_approval_info(launcher_coin.name())
did_inner_hash = b""
if did_id != b"":
did_inner_hash, did_bundle = await self.get_did_approval_info(launcher_coin.name())
bundles_to_agg.append(did_bundle)
innersol = create_ownership_layer_transfer_solution(did_id, did_inner_hash, [], target_puzzle_hash)
bundles_to_agg.append(did_bundle)
self.log.debug("Created an inner DID NFT solution: %s", disassemble(innersol))
else:
condition_list = [make_create_coin_condition(target_puzzle_hash, amount, [target_puzzle_hash])]
@@ -567,7 +571,6 @@ class NFTWallet:
condition_list = [
[51, puzzle_hash, coin.amount, [puzzle_hash]],
[-24, NFT_METADATA_UPDATER, (key, uri)],
[-10, [], [], []],
]
inner_solution = Program.to([[solution_for_conditions(condition_list)]])
else:
@@ -703,9 +706,10 @@ class NFTWallet:
coin_announcements_to_consume: Optional[Set[Announcement]] = None,
puzzle_announcements_to_consume: Optional[Set[Announcement]] = None,
ignore_max_send_amount: bool = False,
new_owner: Optional[bytes32] = None,
new_did_inner_hash: Optional[bytes32] = None,
new_owner: Optional[bytes] = None,
new_did_inner_hash: Optional[bytes] = None,
trade_prices_list: Optional[Program] = None,
additional_bundles: List[SpendBundle] = [],
) -> List[TransactionRecord]:
if memos is None:
memos = [[] for _ in range(len(puzzle_hashes))]
@@ -727,9 +731,13 @@ class NFTWallet:
coins=coins,
coin_announcements_to_consume=coin_announcements_to_consume,
puzzle_announcements_to_consume=puzzle_announcements_to_consume,
new_owner=new_owner,
new_did_inner_hash=new_did_inner_hash,
trade_prices_list=trade_prices_list,
)
spend_bundle = await self.sign(unsigned_spend_bundle)
spend_bundle = await self.sign(unsigned_spend_bundle)
spend_bundle = SpendBundle.aggregate([spend_bundle] + additional_bundles)
tx_list = [
TransactionRecord(
confirmed_at_height=uint32(0),
@@ -782,8 +790,8 @@ class NFTWallet:
coins: Set[Coin] = None,
coin_announcements_to_consume: Optional[Set[Announcement]] = None,
puzzle_announcements_to_consume: Optional[Set[Announcement]] = None,
new_owner: Optional[bytes32] = None,
new_did_inner_hash: Optional[bytes32] = None,
new_owner: Optional[bytes] = None,
new_did_inner_hash: Optional[bytes] = None,
trade_prices_list: Optional[Program] = None,
) -> Tuple[SpendBundle, Optional[TransactionRecord]]:
if coins is None or len(coins) > 1:
@@ -840,4 +848,39 @@ class NFTWallet:
unsigned_spend_bundle = SpendBundle.aggregate([nft_spend_bundle, chia_spend_bundle])
return (unsigned_spend_bundle, chia_tx)
return unsigned_spend_bundle, chia_tx
async def set_nft_did(
self, nft_coin_info: NFTCoinInfo, did_id: Optional[bytes32], fee: uint64 = uint64(0)
) -> SpendBundle:
self.log.debug("Setting NFT DID with parameters: nft=%s did=%s", nft_coin_info, did_id)
unft = UncurriedNFT.uncurry(nft_coin_info.full_puzzle)
nft_id = unft.singleton_launcher_id
puzzle_hashes_to_sign = [unft.p2_puzzle.get_tree_hash()]
if did_id:
did_inner_hash, did_bundle = await self.get_did_approval_info(nft_id, did_id)
additional_bundles = [did_bundle]
else:
did_inner_hash = None
additional_bundles = []
nft_tx_record = await self.generate_signed_transaction(
[nft_coin_info.coin.amount],
puzzle_hashes_to_sign,
fee,
{nft_coin_info.coin},
new_owner=did_id if did_id else b"",
new_did_inner_hash=did_inner_hash if did_inner_hash else b"",
additional_bundles=additional_bundles,
)
spend_bundle: Optional[SpendBundle] = None
for tx in nft_tx_record:
if spend_bundle is None:
spend_bundle = tx.spend_bundle
else:
spend_bundle.aggregate([tx.spend_bundle])
await self.standard_wallet.push_transaction(tx)
self.wallet_state_manager.state_changed("nft_coin_did_set", self.wallet_info.id)
if spend_bundle:
return spend_bundle
else:
raise ValueError("Couldn't set DID on given NFT")
+11 -3
View File
@@ -42,7 +42,7 @@ from chia.wallet.did_wallet.did_wallet import DIDWallet
from chia.wallet.did_wallet.did_wallet_puzzles import DID_INNERPUZ_MOD, create_fullpuz, match_did_puzzle
from chia.wallet.key_val_store import KeyValStore
from chia.wallet.nft_wallet.nft_info import NFTWalletInfo
from chia.wallet.nft_wallet.nft_puzzles import get_metadata_and_phs
from chia.wallet.nft_wallet.nft_puzzles import get_metadata_and_phs, get_new_owner_did
from chia.wallet.nft_wallet.nft_wallet import NFTWallet
from chia.wallet.nft_wallet.uncurry_nft import UncurriedNFT
from chia.wallet.outer_puzzles import AssetType
@@ -721,7 +721,16 @@ class WalletStateManager:
"""
wallet_id = None
wallet_type = None
did_id = uncurried_nft.owner_did
did_id = None
if uncurried_nft.supports_did:
# Try to get the latest owner DID
did_id = get_new_owner_did(coin_spend.solution.to_program())
if did_id is None:
# No DID owner update, use the original DID
did_id = uncurried_nft.owner_did
if did_id == b"":
# Owner DID is updated to None
did_id = None
self.log.debug("Handling NFT: %s DID: %s", coin_spend, did_id)
for wallet_info in await self.get_all_wallet_info_entries(wallet_type=WalletType.NFT):
nft_wallet_info: NFTWalletInfo = NFTWalletInfo.from_json_dict(json.loads(wallet_info.data))
@@ -750,7 +759,6 @@ class WalletStateManager:
)
metadata, p2_puzzle_hash = get_metadata_and_phs(
uncurried_nft,
Program.from_bytes(bytes(coin_spend.puzzle_reveal)),
coin_spend.solution,
)
derivation_record: Optional[
+233 -3
View File
@@ -853,6 +853,7 @@ async def test_nft_transfer_nft_with_did(two_wallet_nodes: Any, trusted: Any) ->
wallet_0 = wallet_node_0.wallet_state_manager.main_wallet
wallet_1 = wallet_node_1.wallet_state_manager.main_wallet
api_0 = WalletRpcApi(wallet_node_0)
api_1 = WalletRpcApi(wallet_node_1)
ph = await wallet_0.get_new_puzzlehash()
ph1 = await wallet_1.get_new_puzzlehash()
@@ -931,6 +932,7 @@ async def test_nft_transfer_nft_with_did(two_wallet_nodes: Any, trusted: Any) ->
assert coins_response.get("success")
coins = coins_response["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did.hex() == hex_did_id
try:
wallet_1.wallet_state_manager.wallets[2]
@@ -945,6 +947,8 @@ async def test_nft_transfer_nft_with_did(two_wallet_nodes: Any, trusted: Any) ->
nft_coin_id=coins[0].nft_coin_id.hex(),
)
)
tx = await did_wallet.transfer_did(ph1, uint64(0), True)
assert tx
assert resp.get("success")
sb = resp["spend_bundle"]
assert compute_memos(sb)
@@ -961,9 +965,53 @@ async def test_nft_transfer_nft_with_did(two_wallet_nodes: Any, trusted: Any) ->
time_left -= 0.5
else:
raise AssertionError("NFT not transferred")
# Check if the NFT owner DID is reset
resp = await api_1.nft_get_by_did(dict())
assert resp.get("success")
nft_wallet_id_1 = resp.get("wallet_id")
time_left = 10.0
while time_left > 0:
coins_response = await api_1.nft_get_nfts(dict(wallet_id=nft_wallet_id_1))
if len(coins_response["nft_list"]) == 0:
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert coins_response.get("success")
assert len(coins_response["nft_list"]) == 1
assert coins_response["nft_list"][0].owner_did is None
nft_coin_id = coins_response["nft_list"][0].nft_coin_id
# Set DID
nft_wallet_1 = wallet_1.wallet_state_manager.wallets[2]
await time_out_assert(15, len, 1, nft_wallet_1.my_nft_coins)
resp = await api_1.nft_set_nft_did(
dict(wallet_id=nft_wallet_id_1, did_id=hmr_did_id, nft_coin_id=nft_coin_id.hex())
)
assert resp.get("success")
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph1))
time_left = 5.0
coins_response = {}
while time_left > 0:
coins_response = await api_1.nft_get_by_did(dict(did_id=hmr_did_id))
if coins_response.get("wallet_id"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
nft_wallet_1_id = coins_response.get("wallet_id")
assert nft_wallet_1_id
# Check NFT DID
time_left = 15.0
while time_left > 0:
resp = await api_1.nft_get_nfts(dict(wallet_id=nft_wallet_1_id))
if coins_response.get("nft_list"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert resp.get("success")
coins = resp["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did.hex() == hex_did_id
@pytest.mark.parametrize(
@@ -1074,7 +1122,7 @@ async def test_update_metadata_for_nft_did(two_wallet_nodes: Any, trusted: Any)
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
# check that new URI was added
time_left = 5.0
time_left = 10.0
while time_left > 0:
coins_response = await api_0.nft_get_nfts(dict(wallet_id=nft_wallet_0_id))
try:
@@ -1095,3 +1143,185 @@ async def test_update_metadata_for_nft_did(two_wallet_nodes: Any, trusted: Any)
raise
await asyncio.sleep(0.5)
time_left -= 0.5
@pytest.mark.parametrize(
"trusted",
[True, False],
)
@pytest.mark.asyncio
async def test_nft_set_did(two_wallet_nodes: Any, trusted: Any) -> None:
num_blocks = 5
full_nodes, wallets = two_wallet_nodes
full_node_api: FullNodeSimulator = full_nodes[0]
full_node_server = full_node_api.server
wallet_node_0, server_0 = wallets[0]
wallet_node_1, server_1 = wallets[1]
wallet_0 = wallet_node_0.wallet_state_manager.main_wallet
api_0 = WalletRpcApi(wallet_node_0)
ph = await wallet_0.get_new_puzzlehash()
if trusted:
wallet_node_0.config["trusted_peers"] = {
full_node_api.full_node.server.node_id.hex(): full_node_api.full_node.server.node_id.hex()
}
wallet_node_1.config["trusted_peers"] = {
full_node_api.full_node.server.node_id.hex(): full_node_api.full_node.server.node_id.hex()
}
else:
wallet_node_0.config["trusted_peers"] = {}
wallet_node_1.config["trusted_peers"] = {}
await server_0.start_client(PeerInfo("localhost", uint16(full_node_server._port)), None)
await server_1.start_client(PeerInfo("localhost", uint16(full_node_server._port)), None)
for _ in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
funds = sum(
[calculate_pool_reward(uint32(i)) + calculate_base_farmer_reward(uint32(i)) for i in range(1, num_blocks - 1)]
)
await time_out_assert(10, wallet_0.get_unconfirmed_balance, funds)
await time_out_assert(10, wallet_0.get_confirmed_balance, funds)
did_wallet: DIDWallet = await DIDWallet.create_new_did_wallet(
wallet_node_0.wallet_state_manager, wallet_0, uint64(1)
)
spend_bundle_list = await wallet_node_0.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(wallet_0.id())
spend_bundle = spend_bundle_list[0].spend_bundle
await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, spend_bundle.name())
for _ in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
await time_out_assert(15, wallet_0.get_pending_change_balance, 0)
hex_did_id = did_wallet.get_my_DID()
hmr_did_id = encode_puzzle_hash(bytes32.from_hexstr(hex_did_id), DID_HRP)
res = await api_0.create_new_wallet(dict(wallet_type="nft_wallet", name="NFT WALLET 1"))
assert isinstance(res, dict)
assert res.get("success")
nft_wallet_0_id = res["wallet_id"]
await time_out_assert(5, did_wallet.get_confirmed_balance, 1)
# Create a NFT with DID
resp = await api_0.nft_mint_nft(
{
"wallet_id": nft_wallet_0_id,
"hash": "0xD4584AD463139FA8C0D9F68F4B59F185",
"uris": ["https://www.chia.net/img/branding/chia-logo.svg"],
"mu": ["https://www.chia.net/img/branding/chia-logo.svg"],
"did_id": "",
}
)
assert resp.get("success")
sb = resp["spend_bundle"]
# ensure hints are generated
assert compute_memos(sb)
await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, sb.name())
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
# Check DID NFT
time_left = 5.0
coins_response = {}
while time_left > 0:
coins_response = await api_0.nft_get_nfts(dict(wallet_id=nft_wallet_0_id))
if coins_response.get("nft_list"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert coins_response["nft_list"], isinstance(coins_response, dict)
assert coins_response.get("success")
coins = coins_response["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did is None
nft_coin_id = coins[0].nft_coin_id
# Test set None -> DID1
did_wallet1: DIDWallet = await DIDWallet.create_new_did_wallet(
wallet_node_0.wallet_state_manager, wallet_0, uint64(1)
)
resp = await api_0.nft_set_nft_did(
dict(wallet_id=nft_wallet_0_id, did_id=hmr_did_id, nft_coin_id=nft_coin_id.hex())
)
assert resp.get("success")
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
time_left = 5.0
coins_response = {}
while time_left > 0:
coins_response = await api_0.nft_get_by_did(dict(did_id=hmr_did_id))
if coins_response.get("wallet_id"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
nft_wallet_1_id = coins_response.get("wallet_id")
assert nft_wallet_1_id
# Check NFT DID
time_left = 10.0
while time_left > 0:
resp = await api_0.nft_get_nfts(dict(wallet_id=nft_wallet_1_id))
if resp.get("nft_list"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert resp.get("success")
coins = resp["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did.hex() == hex_did_id
nft_coin_id = coins[0].nft_coin_id
# Test set DID1 -> DID2
hex_did_id = did_wallet1.get_my_DID()
hmr_did_id = encode_puzzle_hash(bytes32.from_hexstr(hex_did_id), DID_HRP)
resp = await api_0.nft_set_nft_did(
dict(wallet_id=nft_wallet_1_id, did_id=hmr_did_id, nft_coin_id=nft_coin_id.hex())
)
assert resp.get("success")
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
time_left = 5.0
coins_response = {}
while time_left > 0:
coins_response = await api_0.nft_get_by_did(dict(did_id=hmr_did_id))
if coins_response.get("wallet_id"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
nft_wallet_2_id = coins_response.get("wallet_id")
assert nft_wallet_2_id
# Check NFT DID
time_left = 10.0
while time_left > 0:
resp = await api_0.nft_get_nfts(dict(wallet_id=nft_wallet_2_id))
if resp.get("nft_list"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert resp.get("success")
coins = resp["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did.hex() == hex_did_id
nft_coin_id = coins[0].nft_coin_id
# Test set DID2 -> None
resp = await api_0.nft_set_nft_did(dict(wallet_id=nft_wallet_2_id, nft_coin_id=nft_coin_id.hex()))
assert resp.get("success")
for i in range(1, num_blocks):
await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph))
# Check NFT DID
time_left = 10.0
while time_left > 0:
resp = await api_0.nft_get_nfts(dict(wallet_id=nft_wallet_0_id))
if resp.get("nft_list"):
break
await asyncio.sleep(0.5)
time_left -= 0.5
assert resp.get("success")
coins = resp["nft_list"]
assert len(coins) == 1
assert coins[0].owner_did is None