[LABS-355] PlotNFT2 Wallet (#20410)

* Use a separate config for pooling information

* New PlotNFT drivers

* PlotNFT2 Wallet

* Comment by @cursor

* whoops

* Comments by @cursor

* Comments by @cursor

* Comments by @cursor

* Some tidying

* fix test for memo adjustment

* tweak additional memos again

* Comments by cursor

* Fix test for coverage

* Upstream wallet fixes

* Add `REMARK` option to `launch`

* pre-commit

* chmod

* test coverage

* Add wallet name

* moar test coverage

* fix custody architecture namespace

* Comments by @matt-o-how

* diff minimization

* Contemporize(?) action scope test

* diff minimization

* Remove potentially unnecessary change

* Comment by @cursor

* whoops
This commit is contained in:
Matt Hauff
2026-06-25 18:17:39 -05:00
committed by GitHub
parent 49c105b889
commit ec1b01a68f
14 changed files with 1948 additions and 21 deletions
+3
View File
@@ -211,6 +211,9 @@ async def wallet_environments(
if trusted_full_node
else {}
),
"reuse_public_key_for_change": {
str(service._node.logged_in_fingerprint): tx_config.reuse_puzhash
},
**config_overrides,
}
service._node.wallet_state_manager.config = service._node.config
@@ -0,0 +1,3 @@
from __future__ import annotations
checkout_blocks_and_plots = True
@@ -0,0 +1,972 @@
from __future__ import annotations
import dataclasses
import re
from unittest.mock import Mock
import pytest
from chia_rs import G1Element
from chia_rs.chia_rs import G2Element
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint32, uint64
from chia._tests.environments.wallet import WalletStateTransition, WalletTestFramework
from chia.pools.plotnft_drivers import PlotNFT, PoolConfig, UserConfig
from chia.simulator.simulator_protocol import ReorgProtocol
from chia.types.blockchain_format.program import Program
from chia.types.peer_info import PeerInfo
from chia.wallet.plotnft_wallet.plotnft_wallet import PlotNFT2Wallet
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo
from chia.wallet.wallet_request_types import PushTX
from chia.wallet.wallet_spend_bundle import WalletSpendBundle
@pytest.mark.parametrize(
"wallet_environments",
[
{
"num_environments": 1,
"blocks_needed": [1],
}
],
indirect=True,
)
@pytest.mark.limit_consensus_modes(reason="irrelevant")
@pytest.mark.anyio
async def test_plotnft_lifecycle(wallet_environments: WalletTestFramework, self_hostname: str) -> None:
env = wallet_environments.environments[0]
env.wallet_aliases = {
"xch": 1,
"plotnft": 2,
}
POOL_REWARD_AMOUNT = uint64(1_750_000_000_000)
# CREATION
creation_fee = POOL_REWARD_AMOUNT + 1
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await PlotNFT2Wallet.create_new(
wallet_state_manager=env.wallet_state_manager,
xch_wallet=env.xch_wallet,
action_scope=action_scope,
fee=uint64(creation_fee),
)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"unconfirmed_wallet_balance": -(creation_fee + 1),
"<=#spendable_balance": -(creation_fee + 1),
"<=#max_send_amount": -(creation_fee + 1),
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
}
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -(creation_fee + 1),
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {"init": True, "unspent_coin_count": 1},
},
)
]
)
plotnft_wallet = env.wallet_state_manager.get_wallet(
uint32(env.wallet_aliases["plotnft"]), required_type=PlotNFT2Wallet
)
# Reorg (creation)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"confirmed_wallet_balance": creation_fee + 1,
"<=#spendable_balance": creation_fee + 1,
"<=#max_send_amount": creation_fee + 1,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
">=#unspent_coin_count": 0,
},
"plotnft": {"unspent_coin_count": -1},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -(creation_fee + 1),
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {"unspent_coin_count": 1},
},
)
]
)
# (check an error)
with pytest.raises(ValueError, match=re.escape("`leave_pool` called on a non-pooling or exiting PlotNFT")):
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await plotnft_wallet.leave_pool(action_scope=action_scope)
# REWARDS GENERATED
NUM_REWARDS_FARMED = 2
REWARDS_GAINED = POOL_REWARD_AMOUNT * NUM_REWARDS_FARMED
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=NUM_REWARDS_FARMED, farm_to=plotnft_wallet.p2_singleton_puzzle_hash, guarantee_transaction_blocks=True
)
await wallet_environments.full_node.farm_blocks_to_puzzlehash(count=1)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await env.change_balances(
{
"plotnft": {
"confirmed_wallet_balance": REWARDS_GAINED,
"unconfirmed_wallet_balance": REWARDS_GAINED,
"max_send_amount": REWARDS_GAINED,
"spendable_balance": REWARDS_GAINED,
"unspent_coin_count": NUM_REWARDS_FARMED,
}
}
)
await env.check_balances()
# Reorg (rewards generated)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 3), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await env.change_balances(
{
"plotnft": {
"confirmed_wallet_balance": -REWARDS_GAINED,
"unconfirmed_wallet_balance": -REWARDS_GAINED,
"max_send_amount": -REWARDS_GAINED,
"spendable_balance": -REWARDS_GAINED,
"unspent_coin_count": -NUM_REWARDS_FARMED,
}
}
)
await env.check_balances()
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=NUM_REWARDS_FARMED, farm_to=plotnft_wallet.p2_singleton_puzzle_hash, guarantee_transaction_blocks=True
)
await wallet_environments.full_node.farm_blocks_to_puzzlehash(count=1)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await env.change_balances(
{
"plotnft": {
"confirmed_wallet_balance": REWARDS_GAINED,
"unconfirmed_wallet_balance": REWARDS_GAINED,
"max_send_amount": REWARDS_GAINED,
"spendable_balance": REWARDS_GAINED,
"unspent_coin_count": NUM_REWARDS_FARMED,
}
}
)
await env.check_balances()
# check a branch of `get_unconfirmed_balance` (no records specified)
assert await plotnft_wallet.get_unconfirmed_balance() == REWARDS_GAINED
# CLAIM REWARDS
amount_to_succeed_in_claiming = 100
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
with pytest.raises(ValueError, match="Fee is greater than the total amount of rewards"):
await plotnft_wallet.claim_rewards(
action_scope=action_scope,
fee=uint64(REWARDS_GAINED + 1),
)
await plotnft_wallet.claim_rewards(
action_scope=action_scope,
fee=uint64(REWARDS_GAINED - amount_to_succeed_in_claiming),
)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"plotnft": {
"unconfirmed_wallet_balance": -REWARDS_GAINED,
"spendable_balance": -REWARDS_GAINED,
"max_send_amount": -REWARDS_GAINED,
"pending_coin_removal_count": NUM_REWARDS_FARMED + 1,
}
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": amount_to_succeed_in_claiming,
"unconfirmed_wallet_balance": amount_to_succeed_in_claiming,
"spendable_balance": amount_to_succeed_in_claiming,
"max_send_amount": amount_to_succeed_in_claiming,
"unspent_coin_count": 1,
},
"plotnft": {
"confirmed_wallet_balance": -REWARDS_GAINED,
"pending_coin_removal_count": -NUM_REWARDS_FARMED - 1,
"unspent_coin_count": -NUM_REWARDS_FARMED,
},
},
)
]
)
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
with pytest.raises(
ValueError,
match=re.escape("No rewards to claim"),
):
await plotnft_wallet.claim_rewards(action_scope=action_scope)
# Reorg (claim rewards)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -amount_to_succeed_in_claiming,
"unconfirmed_wallet_balance": -amount_to_succeed_in_claiming,
"spendable_balance": -amount_to_succeed_in_claiming,
"max_send_amount": -amount_to_succeed_in_claiming,
"unspent_coin_count": -1,
},
"plotnft": {
"confirmed_wallet_balance": REWARDS_GAINED,
"pending_coin_removal_count": NUM_REWARDS_FARMED + 1,
"unspent_coin_count": NUM_REWARDS_FARMED,
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": amount_to_succeed_in_claiming,
"unconfirmed_wallet_balance": amount_to_succeed_in_claiming,
"spendable_balance": amount_to_succeed_in_claiming,
"max_send_amount": amount_to_succeed_in_claiming,
"unspent_coin_count": 1,
},
"plotnft": {
"confirmed_wallet_balance": -REWARDS_GAINED,
"pending_coin_removal_count": -NUM_REWARDS_FARMED - 1,
"unspent_coin_count": -NUM_REWARDS_FARMED,
},
},
)
]
)
# JOIN POOL
joining_fee = uint64(1_000)
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await plotnft_wallet.join_pool(
pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
pool_url="https://daurl.com",
action_scope=action_scope,
fee=joining_fee,
)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"unconfirmed_wallet_balance": -joining_fee,
"<=#spendable_balance": -joining_fee,
"<=#max_send_amount": -joining_fee,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
},
"plotnft": {"pending_coin_removal_count": 1},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -joining_fee,
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {"pending_coin_removal_count": -1},
},
)
]
)
assert (
await env.wallet_state_manager.plotnft2_store.get_latest_remark(plotnft_wallet.plotnft_id)
== "https://daurl.com"
)
# Reorg (join pool)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"confirmed_wallet_balance": joining_fee,
"<=#spendable_balance": -1,
"<=#max_send_amount": -1,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
">=#unspent_coin_count": 0,
},
"plotnft": {"pending_coin_removal_count": 1},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -joining_fee,
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {"pending_coin_removal_count": -1},
},
)
]
)
# RECEIVE REWARDS (while pooling)
EXTRA_POOLING_REWARDS = 2
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=NUM_REWARDS_FARMED + EXTRA_POOLING_REWARDS,
farm_to=plotnft_wallet.p2_singleton_puzzle_hash,
guarantee_transaction_blocks=True,
)
await wallet_environments.full_node.farm_blocks_to_puzzlehash(count=1)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await env.change_balances(
{
"plotnft": {
"confirmed_wallet_balance": REWARDS_GAINED + POOL_REWARD_AMOUNT * EXTRA_POOLING_REWARDS,
"unconfirmed_wallet_balance": REWARDS_GAINED + POOL_REWARD_AMOUNT * EXTRA_POOLING_REWARDS,
"max_send_amount": REWARDS_GAINED + POOL_REWARD_AMOUNT * EXTRA_POOLING_REWARDS,
"spendable_balance": REWARDS_GAINED + POOL_REWARD_AMOUNT * EXTRA_POOLING_REWARDS,
"unspent_coin_count": NUM_REWARDS_FARMED + EXTRA_POOLING_REWARDS,
}
}
)
await env.check_balances()
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
with pytest.raises(
ValueError,
match=re.escape("Cannot claim rewards while pooling. If you're a pool, try `forward_pool_rewards`"),
):
await plotnft_wallet.claim_rewards(action_scope=action_scope)
# LOSE REWARDS (while pooling)
pool_rewards = await env.wallet_state_manager.plotnft2_store.get_pool_rewards(plotnft_id=plotnft_wallet.plotnft_id)
plotnft = await plotnft_wallet.get_current_plotnft()
coin_spends = []
singleton_coin_spend = None
for reward in pool_rewards[0:-1]:
new_coin_spends = plotnft.forward_pool_reward(reward)
coin_spends += new_coin_spends
singleton_coin_spend = next(iter(spend for spend in new_coin_spends if spend.coin.amount == 1))
plotnft = PlotNFT.get_next_from_coin_spend(
coin_spend=singleton_coin_spend, genesis_challenge=None, pre_uncurry=None, previous_plotnft_puzzle=plotnft
)
NUM_CLAIMED = len(pool_rewards) - 1
await wallet_environments.full_node_rpc_client.push_tx(WalletSpendBundle(coin_spends, G2Element()))
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={},
post_block_balance_updates={
"plotnft": {
"confirmed_wallet_balance": -POOL_REWARD_AMOUNT * NUM_CLAIMED,
"unconfirmed_wallet_balance": -POOL_REWARD_AMOUNT * NUM_CLAIMED,
"max_send_amount": -POOL_REWARD_AMOUNT * NUM_CLAIMED,
"spendable_balance": -POOL_REWARD_AMOUNT * NUM_CLAIMED,
"unspent_coin_count": -NUM_CLAIMED,
}
},
)
]
)
# LEAVE POOL (to another)
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
finish_leaving_fee = uint64(1_000_000_000)
await plotnft_wallet.join_pool(
action_scope=action_scope,
fee=uint64(0),
finish_leaving_fee=finish_leaving_fee,
pool_url="https://daurl2.com",
pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# (check an error)
with pytest.raises(ValueError, match=re.escape("`leave_pool` called on a non-pooling or exiting PlotNFT")):
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await plotnft_wallet.leave_pool(action_scope=action_scope)
# Reorg (leave pool to another)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# FINISH LEAVING (to new pool)
plotnft = await plotnft_wallet.get_current_plotnft()
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=plotnft.guaranteed_pool_config.heightlock + 2, guarantee_transaction_blocks=True
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"unconfirmed_wallet_balance": -finish_leaving_fee,
"<=#spendable_balance": -finish_leaving_fee,
"<=#max_send_amount": -finish_leaving_fee,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
},
"plotnft": {
"pending_coin_removal_count": 2, # one for the exit, one for the join
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -finish_leaving_fee,
">=#spendable_balance": 0,
">=#max_send_amount": 0,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": -2,
},
},
)
]
)
# LEAVE POOL
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
leave_fee = uint64(1_000_000)
finish_leaving_fee = uint64(1_000_000_000)
await plotnft_wallet.leave_pool(action_scope=action_scope, fee=leave_fee, finish_leaving_fee=finish_leaving_fee)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"unconfirmed_wallet_balance": -leave_fee,
"<=#spendable_balance": -leave_fee,
"<=#max_send_amount": -leave_fee,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -leave_fee,
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
with pytest.raises(
ValueError,
match=re.escape("Cannot claim rewards while pooling. If you're a pool, try `forward_pool_rewards`"),
):
await plotnft_wallet.claim_rewards(action_scope=action_scope)
# Reorg (leave pool)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"confirmed_wallet_balance": leave_fee,
"<=#spendable_balance": -1,
"<=#max_send_amount": -1,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
">=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -leave_fee,
">=#spendable_balance": 1,
">=#max_send_amount": 1,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# LOSE REWARDS (while leaving)
plotnft = await plotnft_wallet.get_current_plotnft()
[pool_reward] = await env.wallet_state_manager.plotnft2_store.get_pool_rewards(plotnft_id=plotnft_wallet.plotnft_id)
coin_spends = plotnft.forward_pool_reward(pool_reward)
await env.rpc_client.push_tx(PushTX(spend_bundle=WalletSpendBundle(coin_spends, G2Element())))
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={},
post_block_balance_updates={
"plotnft": {
"confirmed_wallet_balance": -POOL_REWARD_AMOUNT,
"unconfirmed_wallet_balance": -POOL_REWARD_AMOUNT,
"max_send_amount": -POOL_REWARD_AMOUNT,
"spendable_balance": -POOL_REWARD_AMOUNT,
"unspent_coin_count": -1,
}
},
)
]
)
# FINISH LEAVING
plotnft = await plotnft_wallet.get_current_plotnft()
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=plotnft.guaranteed_pool_config.heightlock + 2, guarantee_transaction_blocks=True
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"unconfirmed_wallet_balance": -finish_leaving_fee,
"<=#spendable_balance": -finish_leaving_fee,
"<=#max_send_amount": -finish_leaving_fee,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -finish_leaving_fee,
">=#spendable_balance": 0,
">=#max_send_amount": 0,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# Reorg (finish leaving)
height = wallet_environments.full_node.full_node.blockchain.get_peak_height()
assert height is not None
await wallet_environments.full_node.reorg_from_index_to_new_index(
ReorgProtocol(uint32(height - 1), uint32(height + 1), bytes32.zeros, None)
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {
"confirmed_wallet_balance": finish_leaving_fee,
"<=#spendable_balance": 0,
"<=#max_send_amount": 0,
">=#pending_change": 0,
">=#pending_coin_removal_count": 1,
">=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": 1,
},
},
post_block_balance_updates={
"xch": {
"confirmed_wallet_balance": -finish_leaving_fee,
">=#spendable_balance": 0,
">=#max_send_amount": 0,
"<=#pending_change": 0,
"<=#pending_coin_removal_count": -1,
"<=#unspent_coin_count": 0,
},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# Resync start
env.node._close()
await env.node._await_closed()
env.node.config["database_path"] = "wallet/db/blockchain_wallet_v2_test1_CHALLENGE_KEY.sqlite"
# use second node to start the same wallet, reusing config
await env.node._start()
await env.peer_server.start_client(
PeerInfo(self_hostname, wallet_environments.full_node.full_node.server.get_port()), None
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states([WalletStateTransition(), WalletStateTransition()])
rediscovered_plotnft_wallet = env.node.wallet_state_manager.wallets[uint32(env.wallet_aliases["plotnft"])]
assert isinstance(rediscovered_plotnft_wallet, PlotNFT2Wallet)
rediscovered_plotnft = await rediscovered_plotnft_wallet.get_current_plotnft()
# and test just a normal restart
env.node._close()
await env.node._await_closed()
await env.node._start()
await env.peer_server.start_client(
PeerInfo(self_hostname, wallet_environments.full_node.full_node.server.get_port()), None
)
env.node.config["selected_network"] = "simulator"
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
rediscovered_plotnft_wallet = env.node.wallet_state_manager.wallets[uint32(env.wallet_aliases["plotnft"])]
assert isinstance(rediscovered_plotnft_wallet, PlotNFT2Wallet)
assert await rediscovered_plotnft_wallet.get_current_plotnft() == rediscovered_plotnft
@pytest.mark.parametrize(
"wallet_environments",
[
{
"num_environments": 1,
"blocks_needed": [1],
}
],
indirect=True,
)
@pytest.mark.limit_consensus_modes(reason="irrelevant")
@pytest.mark.anyio
async def test_plotnft_errors(wallet_environments: WalletTestFramework, self_hostname: str) -> None:
env = wallet_environments.environments[0]
env.wallet_aliases = {
"xch": 1,
"plotnft": 2,
}
# creation error
with pytest.raises(ValueError, match="pool_url and pool_config must be both None or both not None"):
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await PlotNFT2Wallet.create_new(
wallet_state_manager=env.wallet_state_manager,
xch_wallet=env.xch_wallet,
action_scope=action_scope,
fee=uint64(0),
pool_config=None,
pool_url="https://daurl.com",
)
with pytest.raises(ValueError, match="pool_url and pool_config must be both None or both not None"):
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await PlotNFT2Wallet.create_new(
wallet_state_manager=env.wallet_state_manager,
xch_wallet=env.xch_wallet,
action_scope=action_scope,
fee=uint64(0),
pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
pool_url=None,
)
# create to pool
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
await PlotNFT2Wallet.create_new(
wallet_state_manager=env.wallet_state_manager,
xch_wallet=env.xch_wallet,
action_scope=action_scope,
fee=uint64(0),
pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
pool_url="https://daurl.com",
)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={"xch": {"set_remainder": True}},
post_block_balance_updates={
"xch": {"set_remainder": True},
"plotnft": {"init": True, "set_remainder": True},
},
)
]
)
plotnft_wallet = env.wallet_state_manager.wallets[uint32(env.wallet_aliases["plotnft"])]
assert isinstance(plotnft_wallet, PlotNFT2Wallet)
# check a quick DB error
with pytest.raises(ValueError, match="coin_ids must not be empty"):
await env.wallet_state_manager.plotnft2_store.get_plotnfts(coin_ids=[])
# some `leave_pool` argument checks
with pytest.raises(ValueError, match="Both new_pool_url or new_pool_config must be provided together"):
await plotnft_wallet.leave_pool(
action_scope=action_scope,
new_pool_url=None,
new_pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
)
with pytest.raises(ValueError, match="Both new_pool_url or new_pool_config must be provided together"):
await plotnft_wallet.leave_pool(
action_scope=action_scope,
new_pool_url="https://daurl2.com",
new_pool_config=None,
)
# try to leave and delete necessary info while leaving to make sure wallet still leaves (with no fee and no pool)
async with env.wallet_state_manager.new_action_scope(wallet_environments.tx_config, push=True) as action_scope:
finish_leaving_fee = uint64(1_000_000_000)
await plotnft_wallet.leave_pool(
action_scope=action_scope,
fee=uint64(0),
finish_leaving_fee=finish_leaving_fee,
new_pool_url="https://daurl2.com",
new_pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={"xch": {"set_remainder": True}, "plotnft": {"set_remainder": True}},
post_block_balance_updates={"xch": {"set_remainder": True}, "plotnft": {"set_remainder": True}},
)
]
)
# deleting the finish_exiting_info so the wallet has to adapt and try just leave with no fee
async with env.wallet_state_manager.plotnft2_store.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute("DELETE FROM finish_exiting_info WHERE wallet_id = ?", (plotnft_wallet.id(),))
# also adding an unconfirmed transaction to test that completion is not attempted when state is uncertain
await env.wallet_state_manager.add_transaction(
env.wallet_state_manager.new_outgoing_transaction(
wallet_id=plotnft_wallet.id(),
puzzle_hash=bytes32.zeros,
amount=uint64(0),
fee=uint64(0),
spend_bundle=WalletSpendBundle([], G2Element()),
additions=[],
removals=[],
name=bytes32.zeros,
)
)
# farm to where completion should happen
plotnft = await plotnft_wallet.get_current_plotnft()
await wallet_environments.full_node.farm_blocks_to_puzzlehash(
count=plotnft.guaranteed_pool_config.heightlock + 2, guarantee_transaction_blocks=True
)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
# make sure nothing happens
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={"xch": {}, "plotnft": {}},
post_block_balance_updates={"xch": {}, "plotnft": {}},
)
],
invalid_transactions=[bytes32.zeros],
)
# delete the tx_record
async with env.wallet_state_manager.tx_store.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute("DELETE FROM transaction_record WHERE bundle_id = ?", (bytes32.zeros,))
env.wallet_state_manager.tx_store.unconfirmed_txs = [
tx for tx in env.wallet_state_manager.tx_store.unconfirmed_txs if tx.name != bytes32.zeros
]
# farm a block to re-trigger new_peak
await wallet_environments.full_node.farm_blocks_to_puzzlehash(count=1, guarantee_transaction_blocks=True)
await wallet_environments.full_node.wait_for_wallet_synced(env.node)
await wallet_environments.process_pending_states(
[
WalletStateTransition(
pre_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": 1, # only one, because no join
},
},
post_block_balance_updates={
"xch": {},
"plotnft": {
"pending_coin_removal_count": -1,
},
},
)
]
)
# check a `join_pool` argument check
with pytest.raises(ValueError, match="A fee to finish leaving was specified but PlotNFT does not need to leave"):
await plotnft_wallet.join_pool(
action_scope=action_scope,
pool_config=PoolConfig(
pool_puzzle_hash=bytes32.zeros, heightlock=uint32(5), pool_memoization=Program.to(None)
),
pool_url="https://daurl.com",
finish_leaving_fee=uint64(1),
)
# check a `coin_added` type guard
plotnft = await plotnft_wallet.get_current_plotnft()
with pytest.raises(ValueError, match="No index found for synthetic pubkey"):
await plotnft_wallet.coin_added(
coin=Mock(),
height=uint32(0),
peer=Mock(),
coin_data=PlotNFT(
launcher_id=bytes32.zeros,
genesis_challenge=bytes32.zeros,
user_config=UserConfig(synthetic_pubkey=G1Element()), # the important bit
exiting=False,
coin=Mock(),
singleton_lineage_proof=Mock(),
),
)
plotnft_after_raise = await plotnft_wallet.get_current_plotnft()
assert plotnft_after_raise == plotnft
# check a __post_init__
target_state = PlotNFTTargetStateInfo(
wallet_id=plotnft_wallet.id(),
exiting_fee=uint64(0),
next_pool_url="blah",
next_pool_puzzle_hash=bytes32.zeros,
next_heightlock=uint32(0),
next_pool_memoization=Program.to(None),
)
for field_name in ("next_pool_url", "next_pool_puzzle_hash", "next_heightlock", "next_pool_memoization"):
with pytest.raises(
ValueError, match="Error initializing next PlotNFT target state, not all options for join were specified"
):
dataclasses.replace(target_state, **{field_name: None}) # type: ignore[arg-type]
@@ -13,7 +13,7 @@ from chia.types.blockchain_format.coin import Coin
from chia.wallet.signer_protocol import SigningResponse
from chia.wallet.transaction_record import TransactionRecord
from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG
from chia.wallet.wallet_action_scope import WalletSideEffects
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo, WalletSideEffects
from chia.wallet.wallet_spend_bundle import WalletSpendBundle
from chia.wallet.wallet_state_manager import WalletStateManager
@@ -44,6 +44,7 @@ class MockWalletStateManager:
list[SigningResponse],
list[WalletSpendBundle],
list[SingletonRecord],
PlotNFTTargetStateInfo | None,
]
| None
) = None
@@ -57,6 +58,7 @@ class MockWalletStateManager:
additional_signing_responses: list[SigningResponse],
extra_spends: list[WalletSpendBundle],
singleton_records: list[SingletonRecord],
plotnft_exiting_info: PlotNFTTargetStateInfo | None,
) -> list[TransactionRecord]:
self.most_recent_call = (
txs,
@@ -66,6 +68,7 @@ class MockWalletStateManager:
additional_signing_responses,
extra_spends,
singleton_records,
plotnft_exiting_info,
)
return txs
@@ -92,7 +95,7 @@ async def test_wallet_action_scope() -> None:
action_scope.side_effects
assert action_scope.side_effects.transactions == [STD_TX]
assert wsm.most_recent_call == ([STD_TX], True, False, True, [], [], [])
assert wsm.most_recent_call == ([STD_TX], True, False, True, [], [], [], None)
async with wsm.new_action_scope( # type: ignore[attr-defined]
DEFAULT_TX_CONFIG,
@@ -107,4 +110,5 @@ async def test_wallet_action_scope() -> None:
interface.side_effects.transactions = []
assert action_scope.side_effects.transactions == []
assert wsm.most_recent_call == ([], False, True, True, [], [], [])
# same as above because the lack of transactions prevents the add_pending_transactions call
assert wsm.most_recent_call == ([STD_TX], True, False, True, [], [], [], None)
+5 -1
View File
@@ -10,7 +10,7 @@ import yaml
from chia_rs import G1Element
from chia_rs.sized_byte_class import hexstr_to_bytes
from chia_rs.sized_bytes import bytes32
from typing_extensions import Self
from typing_extensions import NotRequired, Self
from chia.util.config import lock_and_load_config, save_config
from chia.util.lock import Lockfile
@@ -24,6 +24,7 @@ class _PoolConfig(TypedDict):
p2_singleton_puzzle_hash: str
owner_public_key: str
key_derivation_index: int
version: NotRequired[int]
@dataclass(kw_only=True)
@@ -35,6 +36,7 @@ class PoolingShareState:
p2_singleton_puzzle_hash: bytes32
owner_public_key: G1Element
key_derivation_index: int
version: int = 1
@staticmethod
def state_path(root_path: Path) -> Path:
@@ -102,6 +104,7 @@ class PoolingShareState:
p2_singleton_puzzle_hash=p2_singleton_puzzle_hash,
owner_public_key=G1Element.from_bytes(hexstr_to_bytes(config["owner_public_key"])),
key_derivation_index=config["key_derivation_index"],
version=config.get("version", 1),
)
yield self # noqa: RUF075 # update in-memory entry only on successful exit
for i, conf in enumerate(loaded_list):
@@ -118,6 +121,7 @@ class PoolingShareState:
"owner_public_key": bytes(self.owner_public_key).hex(),
"p2_singleton_puzzle_hash": self.p2_singleton_puzzle_hash.hex(),
"key_derivation_index": self.key_derivation_index,
"version": self.version,
}
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
from sqlite3 import Row
from chia_rs import Coin, G1Element
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint32, uint64
from typing_extensions import Self
from chia.pools.plotnft_drivers import PlotNFT, PoolConfig, PoolReward, UserConfig
from chia.types.blockchain_format.program import Program
from chia.util.db_wrapper import DBWrapper2
from chia.wallet.conditions import Remark
from chia.wallet.lineage_proof import LineageProof
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo
DEFAULT_POOL_REWARDS_PER_CLAIM = 20
def _row_to_plotnft(row: Row, genesis_challenge: bytes32) -> PlotNFT:
return PlotNFT(
coin=Coin(parent_coin_info=bytes32(row[1]), puzzle_hash=bytes32(row[2]), amount=uint64.from_bytes(row[3])),
singleton_lineage_proof=LineageProof.from_bytes(row[4]),
launcher_id=bytes32(row[5]),
user_config=UserConfig(synthetic_pubkey=G1Element.from_bytes(row[6])),
pool_config=PoolConfig(
pool_puzzle_hash=bytes32(row[7]),
heightlock=uint32.from_bytes(row[8]),
pool_memoization=Program.from_bytes(row[9]),
)
if row[7:10] != (b"", b"", b"")
else None,
genesis_challenge=genesis_challenge,
exiting=False if row[10] == 0 else True,
remarks=[Remark(Program.to(row[11]))] if row[11] is not None else [],
)
class PlotNFTStore:
db_wrapper: DBWrapper2
genesis_challenge: bytes32
@classmethod
async def create(cls, wrapper: DBWrapper2, genesis_challenge: bytes32) -> Self:
self = cls()
self.db_wrapper = wrapper
self.genesis_challenge = genesis_challenge
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute(
"CREATE TABLE IF NOT EXISTS plotnft2s( "
" coin_id blob PRIMARY KEY,"
" parent_coin_id blob,"
" puzzle_hash blob,"
" amount blob,"
" lineage_proof blob,"
" launcher_id blob,"
" synthetic_pubkey blob,"
" pool_puzzle_hash blob,"
" timelock blob,"
" pool_memoization blob,"
" exiting boolean,"
" remark string,"
" created_height int)"
)
await conn.execute(
"CREATE TABLE IF NOT EXISTS pool_reward2s( "
" coin_id blob PRIMARY KEY,"
" parent_coin_id blob,"
" puzzle_hash blob,"
" amount blob,"
" singleton_id blob,"
" height int,"
" spent_height int)"
)
await conn.execute(
"CREATE TABLE IF NOT EXISTS finish_exiting_info (wallet_id int PRIMARY KEY, exiting_info blob)"
)
await conn.execute(
"CREATE TABLE IF NOT EXISTS finish_exiting_height (wallet_id int PRIMARY KEY, height int)"
)
return self
async def add_plotnft(self, *, plotnft: PlotNFT, created_height: uint32) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute_insert(
"INSERT OR REPLACE INTO plotnft2s "
"(coin_id, parent_coin_id, puzzle_hash, amount, lineage_proof, launcher_id, synthetic_pubkey, "
"pool_puzzle_hash, timelock, pool_memoization, exiting, remark, created_height) "
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
plotnft.coin.name(),
plotnft.coin.parent_coin_info,
plotnft.coin.puzzle_hash,
bytes(plotnft.coin.amount),
bytes(plotnft.singleton_lineage_proof),
plotnft.launcher_id,
bytes(plotnft.user_config.synthetic_pubkey),
plotnft.pool_config.pool_puzzle_hash if plotnft.pool_config is not None else b"",
bytes(plotnft.pool_config.heightlock) if plotnft.pool_config is not None else b"",
bytes(plotnft.pool_config.pool_memoization) if plotnft.pool_config is not None else b"",
plotnft.exiting,
str(plotnft.remarks[0].rest.atom, "utf8")
if len(plotnft.remarks) > 0 and plotnft.remarks[0].rest.atom is not None
else None,
created_height,
),
)
async def add_pool_reward(self, *, pool_reward: PoolReward) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute_insert(
"INSERT OR REPLACE INTO pool_reward2s ("
"coin_id, parent_coin_id, puzzle_hash, amount, singleton_id, height, spent_height) "
"VALUES(?, ?, ?, ?, ?, ?, ?)",
(
pool_reward.coin.name(),
pool_reward.coin.parent_coin_info,
pool_reward.coin.puzzle_hash,
bytes(pool_reward.coin.amount),
pool_reward.singleton_id,
pool_reward.height,
None,
),
)
async def mark_pool_reward_as_spent(self, *, reward_id: bytes32, spent_height: uint32) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute_insert(
"UPDATE pool_reward2s SET spent_height = ? WHERE coin_id = ?",
(spent_height, reward_id),
)
async def get_latest_plotnft(self, launcher_id: bytes32) -> PlotNFT:
async with self.db_wrapper.reader() as conn:
rows = await conn.execute_fetchall(
"""
SELECT *
FROM plotnft2s
WHERE launcher_id=?
ORDER BY created_height DESC
LIMIT 1;
""",
(launcher_id,),
)
return _row_to_plotnft(next(iter(rows)), self.genesis_challenge)
async def get_latest_remark(self, launcher_id: bytes32) -> str:
async with self.db_wrapper.reader() as conn:
rows = await conn.execute_fetchall(
"""
SELECT remark
FROM plotnft2s
WHERE launcher_id=?
AND remark IS NOT NULL
ORDER BY created_height DESC
LIMIT 1;
""",
(launcher_id,),
)
return str(next(iter(rows))[0])
async def get_plotnfts(self, *, coin_ids: list[bytes32]) -> list[PlotNFT]:
if coin_ids == []:
raise ValueError("coin_ids must not be empty")
async with self.db_wrapper.reader() as conn:
rows = await conn.execute_fetchall(
f"SELECT * from plotnft2s where coin_id in ({', '.join(['?'] * len(coin_ids))})", coin_ids
)
plot_nfts_selected = [_row_to_plotnft(row, self.genesis_challenge) for row in rows]
if len(plot_nfts_selected) != len(coin_ids):
symmetric_difference = set(bytes32(row[0]) for row in rows) ^ set(coin_ids)
raise ValueError(f"coin IDs {symmetric_difference} not found in PlotNFTStore")
else:
return plot_nfts_selected
async def get_pool_rewards(
self,
*,
plotnft_id: bytes32,
max: int = DEFAULT_POOL_REWARDS_PER_CLAIM,
include_spent: bool = False,
) -> list[PoolReward]:
async with self.db_wrapper.reader() as conn:
rows = await conn.execute_fetchall(
(
"SELECT * from pool_reward2s WHERE singleton_id = ?"
+ (" AND spent_height IS NULL" if not include_spent else "")
+ " ORDER BY spent_height ASC LIMIT ?"
),
(plotnft_id, max),
)
pool_rewards_selected = [
PoolReward(
coin=Coin(
parent_coin_info=bytes32(row[1]), puzzle_hash=bytes32(row[2]), amount=uint64.from_bytes(row[3])
),
singleton_id=bytes32(row[4]),
)
for row in rows
]
return pool_rewards_selected
async def add_exiting_info(self, *, exiting_info: PlotNFTTargetStateInfo) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute(
("INSERT OR REPLACE INTO finish_exiting_info (wallet_id, exiting_info) VALUES (?, ?)"),
(exiting_info.wallet_id, bytes(exiting_info)),
)
async def get_exiting_info(self, *, wallet_id: uint32) -> PlotNFTTargetStateInfo:
async with self.db_wrapper.reader() as conn:
rows = list(
await conn.execute_fetchall(
("SELECT exiting_info FROM finish_exiting_info WHERE wallet_id = ?"),
(wallet_id,),
)
)
if len(rows) == 0:
return PlotNFTTargetStateInfo(
wallet_id=wallet_id,
exiting_fee=uint64(0),
next_pool_url=None,
next_pool_puzzle_hash=None,
next_heightlock=None,
next_pool_memoization=None,
)
else:
return PlotNFTTargetStateInfo.from_bytes(rows[0][0])
async def add_exiting_height(self, *, wallet_id: uint32, height: uint32) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute(
("INSERT OR REPLACE INTO finish_exiting_height (wallet_id, height) VALUES (?, ?)"),
(wallet_id, height),
)
async def get_exiting_height(self, wallet_id: uint32) -> uint32 | None:
async with self.db_wrapper.reader() as conn:
rows = list(
await conn.execute_fetchall(
("SELECT height FROM finish_exiting_height WHERE wallet_id = ?"), (wallet_id,)
)
)
if len(rows) == 0:
return None
else:
return uint32(rows[0][0])
async def clear_exiting_info(self, wallet_id: uint32) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute("DELETE FROM finish_exiting_info WHERE wallet_id = ?", (wallet_id,))
await conn.execute("DELETE FROM finish_exiting_height WHERE wallet_id = ?", (wallet_id,))
async def rollback_to_block(self, *, height: int) -> None:
async with self.db_wrapper.writer_maybe_transaction() as conn:
await conn.execute("DELETE FROM plotnft2s WHERE created_height > ?", (height,))
await conn.execute("DELETE FROM pool_reward2s WHERE height > ?", (height,))
await conn.execute("UPDATE pool_reward2s SET spent_height = NULL WHERE spent_height > ?", (height,))
@@ -0,0 +1,545 @@
from __future__ import annotations
import dataclasses
import logging
from typing import TYPE_CHECKING, ClassVar, cast, final
from chia_rs import G2Element
from chia_rs.chia_rs import Coin, G1Element
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint32, uint64, uint128
from typing_extensions import Self, Unpack
from chia.pools.plotnft_drivers import PlotNFT, PoolConfig, PoolReward, RewardPuzzle, SingletonStruct, UserConfig
from chia.pools.pool_config import PoolingShareState
from chia.server.ws_connection import WSChiaConnection
from chia.types.blockchain_format.program import Program
from chia.wallet.conditions import AssertCoinAnnouncement, Condition, CreateCoin, CreateCoinAnnouncement, Remark
from chia.wallet.puzzles.custody.custody_architecture import DelegatedPuzzleAndSolution
from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import puzzle_hash_for_synthetic_public_key
from chia.wallet.util.wallet_types import WalletType
from chia.wallet.wallet import Wallet
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo, WalletActionScope
from chia.wallet.wallet_coin_record import WalletCoinRecord
from chia.wallet.wallet_info import WalletInfo
from chia.wallet.wallet_protocol import GSTOptionalArgs
from chia.wallet.wallet_spend_bundle import WalletSpendBundle
if TYPE_CHECKING:
from chia.wallet.wallet_state_manager import WalletStateManager
@final
@dataclasses.dataclass
class PlotNFT2Wallet:
if TYPE_CHECKING:
from chia.wallet.wallet_protocol import WalletProtocol
_protocol_check: ClassVar[WalletProtocol[object]] = cast("PlotNFT2Wallet", None)
wallet_state_manager: WalletStateManager
xch_wallet: Wallet
log: logging.Logger
wallet_info: WalletInfo
@classmethod
async def create(
cls, *, wallet_state_manager: WalletStateManager, xch_wallet: Wallet, wallet_info: WalletInfo
) -> Self:
self = cls(
wallet_state_manager=wallet_state_manager,
xch_wallet=xch_wallet,
log=logging.getLogger(__name__),
wallet_info=wallet_info,
)
await wallet_state_manager.add_interested_puzzle_hashes(
puzzle_hashes=[self.p2_singleton_puzzle_hash, self.hint], wallet_ids=[self.id(), self.id()]
)
if await wallet_state_manager.user_store.get_wallet_by_id(wallet_info.id) is None:
await wallet_state_manager.user_store.create_wallet(
name=wallet_info.name,
wallet_type=wallet_info.type,
data=wallet_info.data,
id=wallet_info.id,
)
return self
@property
def hint(self) -> bytes32:
return SingletonStruct(launcher_id=self.plotnft_id).struct_hash()
@property
def plotnft_id(self) -> bytes32:
return bytes32.from_hexstr(self.wallet_info.data)
@property
def p2_singleton_puzzle_hash(self) -> bytes32:
return RewardPuzzle(singleton_id=self.plotnft_id).puzzle_hash()
@property
def rewards_claim_puzhash(self) -> bytes32:
with PoolingShareState.acquire(
root_path=self.wallet_state_manager.root_path, p2_singleton_puzzle_hash=self.p2_singleton_puzzle_hash
) as pool_config:
return bytes32.from_hexstr(pool_config.payout_instructions)
@classmethod
def type(cls) -> WalletType:
return WalletType.PLOTNFT_2
def id(self) -> uint32:
return self.wallet_info.id
def get_name(self) -> str:
# A WalletProtocol stub that we may as well implement but there's no real use for it
# Probably a good case to get it out of the WalletProtocol
return self.wallet_info.name # pragma: no cover
# Actions
@classmethod
async def create_new(
cls,
*,
wallet_state_manager: WalletStateManager,
xch_wallet: Wallet,
action_scope: WalletActionScope,
fee: uint64,
pool_config: PoolConfig | None = None,
pool_url: str | None = None,
extra_conditions: tuple[Condition, ...] = tuple(),
) -> PlotNFT:
if (pool_url is None and pool_config is not None) or (pool_url is not None and pool_config is None):
raise ValueError("pool_url and pool_config must be both None or both not None")
target_puzzle_hash = await action_scope.get_puzzle_hash(wallet_state_manager)
target_pubkey = G1Element.from_bytes(await wallet_state_manager.get_public_key(target_puzzle_hash))
origin_coins = await xch_wallet.select_coins(amount=uint64(fee + 1), action_scope=action_scope)
announcement_assertions, coin_spends, new_plotnft = PlotNFT.launch(
origin_coins=list(origin_coins),
user_config=UserConfig(synthetic_pubkey=xch_wallet.convert_public_key_to_synthetic(target_pubkey)),
genesis_challenge=wallet_state_manager.constants.GENESIS_CHALLENGE,
hint=target_puzzle_hash,
pool_config=pool_config,
remark=Remark(rest=Program.to(pool_url)) if pool_url is not None else None,
)
async with action_scope.use() as interface:
interface.side_effects.extra_spends.append(WalletSpendBundle(coin_spends, G2Element()))
await xch_wallet.generate_signed_transaction(
amounts=[uint64(1)],
puzzle_hashes=[new_plotnft.singleton_struct.singleton_puzzles.singleton_launcher_hash],
action_scope=action_scope,
fee=fee,
coins=origin_coins,
extra_conditions=(*announcement_assertions, *extra_conditions),
origin_id=coin_spends[0].coin.parent_coin_info,
)
return new_plotnft
async def claim_rewards(
self,
*,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
) -> None:
rewards_to_claim = await self.wallet_state_manager.plotnft2_store.get_pool_rewards(plotnft_id=self.plotnft_id)
if len(rewards_to_claim) == 0:
raise ValueError("No rewards to claim")
total_reward_amount = uint64(sum(reward.coin.amount for reward in rewards_to_claim))
if fee > total_reward_amount:
raise ValueError("Fee is greater than the total amount of rewards")
plotnft = await self.get_current_plotnft()
coin_spends = plotnft.claim_pool_rewards(
rewards_to_claim=rewards_to_claim,
reward_delegated_puzzles_and_solutions=[
DelegatedPuzzleAndSolution(
puzzle=self.xch_wallet.make_solution(
primaries=[
CreateCoin(
puzzle_hash=self.rewards_claim_puzhash,
amount=uint64(total_reward_amount - fee),
),
],
fee=fee,
conditions=(*extra_conditions, CreateCoinAnnouncement(b""))
if len(rewards_to_claim) > 1
else extra_conditions,
).at("rf"), # strips away to just the delegated puzzle (bit of a hack)
solution=Program.to(None),
)
if i == 0
else DelegatedPuzzleAndSolution(
puzzle=Program.to(
(
1,
[
AssertCoinAnnouncement(
asserted_id=rewards_to_claim[0].coin.name(), asserted_msg=b""
).to_program()
],
)
),
solution=Program.to(None),
)
for i, reward in enumerate(rewards_to_claim)
],
)
spend_bundle = WalletSpendBundle(coin_spends, G2Element())
async with action_scope.use() as interface:
interface.side_effects.transactions.append(
self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=self.rewards_claim_puzhash,
amount=total_reward_amount,
fee=fee,
spend_bundle=spend_bundle,
additions=[
Coin(
parent_coin_info=rewards_to_claim[0].coin.name(),
puzzle_hash=self.rewards_claim_puzhash,
amount=uint64(total_reward_amount - fee),
),
Coin(
parent_coin_info=plotnft.coin.name(),
puzzle_hash=plotnft.puzzle_hash(nonce=uint64(0)),
amount=uint64(1),
),
],
removals=[reward.coin for reward in rewards_to_claim] + [plotnft.coin],
name=spend_bundle.name(),
extra_conditions=extra_conditions,
)
)
async def join_pool(
self,
*,
pool_config: PoolConfig,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
finish_leaving_fee: uint64 = uint64(0),
pool_url: str,
extra_conditions: tuple[Condition, ...] = tuple(),
plotnft_override: PlotNFT | None = None,
) -> None:
if plotnft_override is None:
plotnft = await self.get_current_plotnft()
else:
plotnft = plotnft_override
if plotnft.pool_config is not None:
await self.leave_pool(
action_scope=action_scope,
fee=fee,
finish_leaving_fee=finish_leaving_fee,
extra_conditions=extra_conditions,
new_pool_url=pool_url,
new_pool_config=pool_config,
)
return
elif finish_leaving_fee != uint64(0):
raise ValueError("A fee to finish leaving was specified but PlotNFT does not need to leave")
fee_hook = CreateCoinAnnouncement(msg=b"", coin_id=plotnft.coin.name())
url_remark = Remark(rest=Program.to(pool_url))
coin_spends = plotnft.join_pool(
user_config=plotnft.user_config,
pool_config=pool_config,
extra_conditions=(*extra_conditions, fee_hook, url_remark),
)
if fee > 0:
await self.xch_wallet.create_tandem_xch_tx(
fee=fee,
action_scope=action_scope,
extra_conditions=(fee_hook.corresponding_assertion(),),
)
spend_bundle = WalletSpendBundle(coin_spends, G2Element())
async with action_scope.use() as interface:
interface.side_effects.transactions.append(
self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=pool_config.pool_puzzle_hash,
amount=uint64(1),
fee=fee,
spend_bundle=spend_bundle,
additions=[
Coin(
parent_coin_info=plotnft.coin.name(),
puzzle_hash=dataclasses.replace(plotnft, pool_config=pool_config).puzzle_hash(nonce=0),
amount=uint64(1),
)
],
removals=[plotnft.coin],
name=spend_bundle.name(),
extra_conditions=extra_conditions,
)
)
async def leave_pool(
self,
*,
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
finish_leaving_fee: uint64 = uint64(0),
extra_conditions: tuple[Condition, ...] = tuple(),
new_pool_url: str | None = None,
new_pool_config: PoolConfig | None = None,
) -> None:
if (new_pool_url is None and new_pool_config is not None) or (
new_pool_url is not None and new_pool_config is None
):
raise ValueError("Both new_pool_url or new_pool_config must be provided together")
plotnft = await self.get_current_plotnft()
if not plotnft.pooling or plotnft.exiting:
raise ValueError("`leave_pool` called on a non-pooling or exiting PlotNFT")
next_plotnft = dataclasses.replace(plotnft, exiting=True)
fee_hook = CreateCoinAnnouncement(msg=b"", coin_id=plotnft.coin.name())
exit_create_coin = plotnft.exit_to_waiting_room_condition()
exit_to_waiting_room_dpuz_and_sol = DelegatedPuzzleAndSolution(
puzzle=self.xch_wallet.make_solution(
primaries=[exit_create_coin],
conditions=(*extra_conditions, fee_hook),
).at("rf"), # strips away to just the delegated puzzle (bit of a hack)
solution=Program.to(None),
)
coin_spends = plotnft.exit_to_waiting_room(exit_to_waiting_room_dpuz_and_sol)
if fee > 0:
await self.xch_wallet.create_tandem_xch_tx(
fee=fee,
action_scope=action_scope,
extra_conditions=(fee_hook.corresponding_assertion(),),
)
spend_bundle = WalletSpendBundle(coin_spends, G2Element())
async with action_scope.use() as interface:
interface.side_effects.plotnft_exiting_info = PlotNFTTargetStateInfo(
wallet_id=self.id(),
exiting_fee=finish_leaving_fee,
next_pool_url=new_pool_url,
next_pool_puzzle_hash=new_pool_config.pool_puzzle_hash if new_pool_config is not None else None,
next_heightlock=new_pool_config.heightlock if new_pool_config is not None else None,
next_pool_memoization=new_pool_config.pool_memoization if new_pool_config is not None else None,
)
interface.side_effects.transactions.append(
self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=exit_create_coin.puzzle_hash,
amount=uint64(1),
fee=fee,
spend_bundle=spend_bundle,
additions=[
Coin(
parent_coin_info=plotnft.coin.name(),
puzzle_hash=next_plotnft.puzzle_hash(nonce=0),
amount=uint64(1),
)
],
removals=[plotnft.coin],
name=spend_bundle.name(),
extra_conditions=extra_conditions,
)
)
async def _finish_leaving_pool(
self,
*,
action_scope: WalletActionScope,
exiting_info: PlotNFTTargetStateInfo,
extra_conditions: tuple[Condition, ...] = tuple(),
) -> None:
plotnft = await self.get_current_plotnft()
fee_hook = CreateCoinAnnouncement(msg=b"", coin_id=plotnft.coin.name())
heightlock, exit_create_coin = plotnft.exit_from_waiting_room_conditions()
exit_to_waiting_room_dpuz_and_sol = DelegatedPuzzleAndSolution(
puzzle=self.xch_wallet.make_solution(
primaries=[exit_create_coin],
conditions=(fee_hook, heightlock, *extra_conditions),
).at("rf"), # strips away to just the delegated puzzle (bit of a hack)
solution=Program.to(None),
)
coin_spends = plotnft.exit_waiting_room(exit_to_waiting_room_dpuz_and_sol)
next_plotnft = PlotNFT.get_next_from_coin_spend(
coin_spend=coin_spends[0],
genesis_challenge=self.wallet_state_manager.constants.GENESIS_CHALLENGE,
previous_plotnft_puzzle=plotnft,
)
if exiting_info.exiting_fee > 0:
await self.xch_wallet.create_tandem_xch_tx(
fee=exiting_info.exiting_fee,
action_scope=action_scope,
extra_conditions=(fee_hook.corresponding_assertion(),),
)
spend_bundle = WalletSpendBundle(coin_spends, G2Element())
async with action_scope.use() as interface:
interface.side_effects.transactions.append(
self.wallet_state_manager.new_outgoing_transaction(
wallet_id=self.id(),
puzzle_hash=exit_create_coin.puzzle_hash,
amount=uint64(1),
fee=exiting_info.exiting_fee,
spend_bundle=spend_bundle,
additions=[
Coin(
parent_coin_info=plotnft.coin.name(),
puzzle_hash=dataclasses.replace(plotnft, pool_config=None, exiting=False).puzzle_hash(
nonce=0
),
amount=uint64(1),
)
],
removals=[plotnft.coin],
name=spend_bundle.name(),
extra_conditions=(heightlock,),
)
)
if exiting_info.pool_url_and_config is not None:
pool_url, pool_config = exiting_info.pool_url_and_config
await self.join_pool(
action_scope=action_scope,
pool_config=pool_config,
pool_url=pool_url,
plotnft_override=next_plotnft,
)
# Syncing
async def coin_added(self, coin: Coin, height: uint32, peer: WSChiaConnection, coin_data: object | None) -> None:
if isinstance(coin_data, PlotNFT):
index = await self.wallet_state_manager.puzzle_store.index_for_puzzle_hash(
puzzle_hash_for_synthetic_public_key(coin_data.user_config.synthetic_pubkey)
)
if index is None:
raise ValueError(f"No index found for synthetic pubkey for launcher_id: {coin_data.launcher_id}")
await self.wallet_state_manager.plotnft2_store.add_plotnft(plotnft=coin_data, created_height=height)
self.log.info(f"Added PlotNFT {coin_data} at height {height}")
if self.p2_singleton_puzzle_hash in PoolingShareState.get_all_p2_singleton_puzzle_hashes(
root_path=self.wallet_state_manager.root_path
):
with PoolingShareState.acquire(
root_path=self.wallet_state_manager.root_path,
p2_singleton_puzzle_hash=self.p2_singleton_puzzle_hash,
) as pool_config:
pool_config.owner_public_key = coin_data.user_config.synthetic_pubkey
pool_config.key_derivation_index = int(index)
if coin_data.pool_config is not None:
if coin_data.pool_config.pool_puzzle_hash != pool_config.target_puzzle_hash:
pool_config.pool_url = await self.wallet_state_manager.plotnft2_store.get_latest_remark(
coin_data.launcher_id
)
pool_config.target_puzzle_hash = coin_data.pool_config.pool_puzzle_hash
else:
pool_config.target_puzzle_hash = bytes32.from_hexstr(pool_config.payout_instructions)
else:
async with self.wallet_state_manager.new_action_scope(
self.wallet_state_manager.tx_config, push=True
) as action_scope:
payout_puzzle_hash = await action_scope.get_puzzle_hash(self.wallet_state_manager)
PoolingShareState(
launcher_id=coin_data.launcher_id,
pool_url=await self.wallet_state_manager.plotnft2_store.get_latest_remark(coin_data.launcher_id)
if coin_data.pool_config is not None
else "",
owner_public_key=coin_data.user_config.synthetic_pubkey,
target_puzzle_hash=coin_data.pool_config.pool_puzzle_hash
if coin_data.pool_config is not None
else payout_puzzle_hash,
p2_singleton_puzzle_hash=self.p2_singleton_puzzle_hash,
payout_instructions=payout_puzzle_hash.hex(),
key_derivation_index=int(index),
version=2,
).add(root_path=self.wallet_state_manager.root_path)
if coin_data.exiting:
await self.wallet_state_manager.plotnft2_store.add_exiting_height(
wallet_id=self.id(), height=uint32(height + coin_data.guaranteed_pool_config.heightlock)
)
else:
finish_height = await self.wallet_state_manager.plotnft2_store.get_exiting_height(wallet_id=self.id())
if finish_height is not None and finish_height < height:
await self.wallet_state_manager.plotnft2_store.clear_exiting_info(wallet_id=self.id())
elif coin_data is None and coin.puzzle_hash == self.p2_singleton_puzzle_hash:
if coin.parent_coin_info[0:16] == self.wallet_state_manager.constants.GENESIS_CHALLENGE[0:16]:
await self.wallet_state_manager.plotnft2_store.add_pool_reward(
pool_reward=PoolReward(singleton_id=self.plotnft_id, coin=coin)
)
else:
raise ValueError(f"A non-pooling reward coin was paid to PlotNFT with id: {self.plotnft_id}")
async def new_peak(self, height: uint32) -> None:
finish_height = await self.wallet_state_manager.plotnft2_store.get_exiting_height(wallet_id=self.id())
if finish_height is not None and finish_height <= height - 2: # 2 blocks for a little reorg safety
if await self.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(wallet_id=self.id()) != []:
self.log.info(f"Not finishing plotnft from wallet {self.id()} due to unconfirmed transactions")
return None
finish_info = await self.wallet_state_manager.plotnft2_store.get_exiting_info(wallet_id=self.id())
async with self.wallet_state_manager.new_action_scope(
self.wallet_state_manager.tx_config, push=True, sign=True, merge_spends=True
) as action_scope:
await self._finish_leaving_pool(action_scope=action_scope, exiting_info=finish_info)
# State
async def get_current_plotnft(self) -> PlotNFT:
return await self.wallet_state_manager.plotnft2_store.get_latest_plotnft(self.plotnft_id)
async def get_confirmed_balance(self, record_list: set[WalletCoinRecord] | None = None) -> uint128:
return uint128(
sum(
cr.coin.amount
for cr in await self.wallet_state_manager.coin_store.get_unspent_coins_for_wallet(self.id())
if cr.coin.amount != 1 # bit of a hack, but should work well enough to filter out the plotnft
)
)
async def get_unconfirmed_balance(self, unspent_records: set[WalletCoinRecord] | None = None) -> uint128:
# bit of a hack, but should work well enough to filter out the plotnft
if unspent_records is None:
unspent_records = await self.wallet_state_manager.coin_store.get_unspent_coins_for_wallet(self.id())
unspent_records = set(cr for cr in unspent_records if cr.coin.amount != 1)
return await self.wallet_state_manager.get_confirmed_spendable_balance_for_wallet(self.id(), unspent_records)
async def get_spendable_balance(self, unspent_records: set[WalletCoinRecord] | None = None) -> uint128:
return await self.get_unconfirmed_balance(unspent_records=unspent_records)
async def get_pending_change_balance(self) -> uint64:
return uint64(0)
async def get_max_send_amount(self, records: set[WalletCoinRecord] | None = None) -> uint128:
return await self.get_spendable_balance(records)
async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool: # pragma: no cover
# We're choosing not to implement this for now as it shouldn't be necessary
return False
# Wallet Protocol Stubs
def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32: # pragma: no cover
raise RuntimeError("puzzle_hash_for_pk is not implemented for PlotNFT2Wallet")
def require_derivation_paths(self) -> bool:
return False
async def generate_signed_transaction(
self,
amounts: list[uint64],
puzzle_hashes: list[bytes32],
action_scope: WalletActionScope,
fee: uint64 = uint64(0),
coins: set[Coin] | None = None,
memos: list[list[bytes]] | None = None,
extra_conditions: tuple[Condition, ...] = tuple(),
**kwargs: Unpack[GSTOptionalArgs],
) -> None: # pragma: no cover
raise RuntimeError(
"generate_signed_transaction is not implemented for PlotNFT2Wallet. Try join/leave/exit_pool instead."
)
async def select_coins(
self,
amount: uint64,
action_scope: WalletActionScope,
) -> set[Coin]: # pragma: no cover
raise RuntimeError("PlotNFT2Wallet does not support select_coins()")
+1 -1
View File
@@ -21,7 +21,7 @@ def compute_memos_for_spend(coin_spend: CoinSpend) -> dict[bytes32, list[bytes]]
if type(condition[3]) is not list:
# If it's not a list, it's not the correct format
continue
memos[coin_added.name()] = condition[3]
memos[coin_added.name()] = [mem for mem in condition[3] if isinstance(mem, bytes)]
return memos
+1
View File
@@ -29,6 +29,7 @@ class WalletType(IntEnum):
VC = 13
CRCAT = 57
RCAT = 132
PLOTNFT_2 = 209
REMOTE = 205
def to_json_dict(self) -> str:
+4
View File
@@ -31,6 +31,7 @@ from chia.wallet.puzzles.clawback.metadata import ClawbackMetadata
from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import (
DEFAULT_HIDDEN_PUZZLE_HASH,
calculate_synthetic_offset,
calculate_synthetic_public_key,
calculate_synthetic_secret_key,
puzzle_for_pk,
puzzle_hash_for_pk,
@@ -114,6 +115,9 @@ class Wallet:
def convert_secret_key_to_synthetic(self, secret_key: PrivateKey) -> PrivateKey:
return calculate_synthetic_secret_key(secret_key, DEFAULT_HIDDEN_PUZZLE_HASH)
def convert_public_key_to_synthetic(self, public_key: G1Element) -> G1Element:
return calculate_synthetic_public_key(public_key, DEFAULT_HIDDEN_PUZZLE_HASH)
async def get_confirmed_balance(self, record_list: set[WalletCoinRecord] | None = None) -> uint128:
return await self.wallet_state_manager.get_confirmed_balance_for_wallet(self.id(), record_list)
+59 -9
View File
@@ -7,8 +7,10 @@ from typing import TYPE_CHECKING, cast, final
from chia_rs.chia_rs import G1Element
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint32, uint64
from chia.data_layer.singleton_record import SingletonRecord
from chia.pools.plotnft_drivers import PoolConfig
from chia.types.blockchain_format.coin import Coin
from chia.types.blockchain_format.program import Program
from chia.util.action_scope import ActionScope
@@ -25,6 +27,50 @@ if TYPE_CHECKING:
from chia.wallet.wallet_state_manager import WalletStateManager
@streamable
@dataclass(kw_only=True, frozen=True)
class PlotNFTTargetStateInfo(Streamable):
wallet_id: uint32
exiting_fee: uint64
next_pool_url: str | None
next_pool_puzzle_hash: bytes32 | None
next_heightlock: uint32 | None
next_pool_memoization: Program | None
def __post_init__(self) -> None:
if (self.next_pool_url, self.next_pool_puzzle_hash, self.next_heightlock, self.next_pool_memoization) != (
None,
None,
None,
None,
) and (
None
in {
self.next_pool_url,
self.next_pool_puzzle_hash,
self.next_heightlock,
}
or self.next_pool_memoization is None
):
raise ValueError("Error initializing next PlotNFT target state, not all options for join were specified")
return super().__post_init__()
@property
def pool_url_and_config(self) -> tuple[str, PoolConfig] | None:
if (
self.next_pool_url is None
or self.next_pool_puzzle_hash is None
or self.next_heightlock is None
or self.next_pool_memoization is None
):
return None
return self.next_pool_url, PoolConfig(
pool_puzzle_hash=self.next_pool_puzzle_hash,
heightlock=self.next_heightlock,
pool_memoization=self.next_pool_memoization,
)
@streamable
@dataclass(frozen=True)
class _StreamableWalletSideEffects(Streamable):
@@ -33,6 +79,7 @@ class _StreamableWalletSideEffects(Streamable):
extra_spends: list[WalletSpendBundle]
selected_coins: list[Coin]
singleton_records: list[SingletonRecord]
plotnft_exiting_info: PlotNFTTargetStateInfo | None
get_unused_derivation_record_result: StreambleGetUnusedDerivationRecordResult | None
@@ -43,6 +90,7 @@ class WalletSideEffects:
extra_spends: list[WalletSpendBundle] = field(default_factory=list)
selected_coins: list[Coin] = field(default_factory=list)
singleton_records: list[SingletonRecord] = field(default_factory=list)
plotnft_exiting_info: PlotNFTTargetStateInfo | None = None
get_unused_derivation_record_result: StreambleGetUnusedDerivationRecordResult | None = None
def __bytes__(self) -> bytes:
@@ -156,14 +204,16 @@ async def new_wallet_action_scope(
yield self
self.side_effects.transactions = await wallet_state_manager.add_pending_transactions(
self.side_effects.transactions,
push=push,
merge_spends=merge_spends,
sign=sign,
additional_signing_responses=self.side_effects.signing_responses,
extra_spends=self.side_effects.extra_spends,
singleton_records=self.side_effects.singleton_records,
)
if self.side_effects.transactions != []:
self.side_effects.transactions = await wallet_state_manager.add_pending_transactions(
self.side_effects.transactions,
push=push,
merge_spends=merge_spends,
sign=sign,
additional_signing_responses=self.side_effects.signing_responses,
extra_spends=self.side_effects.extra_spends,
singleton_records=self.side_effects.singleton_records,
plotnft_exiting_info=self.side_effects.plotnft_exiting_info,
)
if push and self.side_effects.get_unused_derivation_record_result is not None:
await self.side_effects.get_unused_derivation_record_result.to_standard().commit(wallet_state_manager)
+86 -7
View File
@@ -27,10 +27,8 @@ from chia.data_layer.data_layer_wallet import DataLayerWallet
from chia.data_layer.dl_wallet_store import DataLayerStore
from chia.data_layer.singleton_record import SingletonRecord
from chia.pools import pool_config
from chia.pools.pool_puzzles import (
get_most_recent_singleton_coin_from_coin_spend,
solution_to_pool_state,
)
from chia.pools.plotnft_drivers import GetNextPlotNFTError, PlotNFT
from chia.pools.pool_puzzles import get_most_recent_singleton_coin_from_coin_spend, solution_to_pool_state
from chia.pools.pool_wallet import PoolWallet
from chia.protocols.outbound_message import NodeType
from chia.rpc.rpc_server import StateChangedProtocol
@@ -89,6 +87,8 @@ from chia.wallet.nft_wallet.nft_wallet import NFTWallet
from chia.wallet.nft_wallet.uncurry_nft import NFTCoinData, UncurriedNFT
from chia.wallet.notification_manager import NotificationManager
from chia.wallet.outer_puzzles import AssetType
from chia.wallet.plotnft_wallet.plotnft_store import PlotNFTStore
from chia.wallet.plotnft_wallet.plotnft_wallet import PlotNFT2Wallet
from chia.wallet.puzzle_drivers import PuzzleInfo
from chia.wallet.puzzles.clawback.drivers import generate_clawback_spend_bundle, match_clawback_puzzle
from chia.wallet.puzzles.clawback.metadata import ClawbackMetadata, ClawbackVersion
@@ -134,7 +134,7 @@ from chia.wallet.vc_wallet.vc_drivers import VerifiedCredential, match_revocatio
from chia.wallet.vc_wallet.vc_store import VCStore
from chia.wallet.vc_wallet.vc_wallet import VCWallet
from chia.wallet.wallet import Wallet
from chia.wallet.wallet_action_scope import WalletActionScope, new_wallet_action_scope
from chia.wallet.wallet_action_scope import PlotNFTTargetStateInfo, WalletActionScope, new_wallet_action_scope
from chia.wallet.wallet_blockchain import WalletBlockchain
from chia.wallet.wallet_coin_record import MetadataTypes, WalletCoinRecord
from chia.wallet.wallet_coin_store import CoinRecordOrder, WalletCoinStore
@@ -206,6 +206,7 @@ class WalletStateManager:
interested_store: WalletInterestedStore
remote_coin_store: RemoteCoinStore
retry_store: WalletRetryStore
plotnft2_store: PlotNFTStore
multiprocessing_context: multiprocessing.context.BaseContext
server: ChiaServer
root_path: Path
@@ -268,6 +269,7 @@ class WalletStateManager:
self.interested_store = await WalletInterestedStore.create(self.db_wrapper)
self.remote_coin_store = await RemoteCoinStore.create(self.db_wrapper)
self.retry_store = await WalletRetryStore.create(self.db_wrapper)
self.plotnft2_store = await PlotNFTStore.create(self.db_wrapper, self.constants.GENESIS_CHALLENGE)
self.default_cats = DEFAULT_CATS
self.wallet_node = wallet_node
@@ -360,6 +362,12 @@ class WalletStateManager:
self.main_wallet,
wallet_info,
)
elif wallet_type == WalletType.PLOTNFT_2:
wallet = await PlotNFT2Wallet.create(
wallet_state_manager=self,
xch_wallet=self.main_wallet,
wallet_info=wallet_info,
)
elif wallet_type == WalletType.REMOTE:
wallet = await RemoteWallet.create(
self,
@@ -967,6 +975,53 @@ class WalletStateManager:
vc: VerifiedCredential = VerifiedCredential.get_next_from_coin_spend(coin_spend)
return await self.handle_vc(vc), vc
# Check if the coin is a PlotNFT
if uncurried.mod == PlotNFT.singleton_puzzles.singleton_mod:
try:
try:
previous_plotnft = (await self.plotnft2_store.get_plotnfts(coin_ids=[coin_spend.coin.name()]))[0]
except ValueError:
try:
previous_plotnft = await self.plotnft2_store.get_latest_plotnft(
launcher_id=bytes32(uncurried.args.at("frf").as_atom())
)
except RuntimeError:
previous_plotnft = None
next_plot_nft = PlotNFT.get_next_from_coin_spend(
coin_spend=coin_spend,
genesis_challenge=self.constants.GENESIS_CHALLENGE,
pre_uncurry=uncurried,
previous_plotnft_puzzle=previous_plotnft,
)
for id, wallet in self.wallets.items():
if isinstance(wallet, PlotNFT2Wallet) and wallet.plotnft_id == next_plot_nft.launcher_id:
matched_plotnft_wallet_id = id
break
else:
matched_plotnft_wallet_id = None
if matched_plotnft_wallet_id is None and coin_spend.coin.parent_coin_info == next_plot_nft.launcher_id:
matched_plotnft_wallet_id = uint32(len(self.wallets) + 1)
self.wallets[matched_plotnft_wallet_id] = await PlotNFT2Wallet.create(
wallet_state_manager=self,
xch_wallet=self.main_wallet,
wallet_info=WalletInfo(
id=matched_plotnft_wallet_id,
name=next_plot_nft.launcher_id.hex(),
type=uint8(WalletType.PLOTNFT_2),
data=next_plot_nft.launcher_id.hex(),
),
)
if matched_plotnft_wallet_id is None: # pragma: no cover
# TODO: add support for receiving plotnfts you don't know about
raise ValueError(f"No wallet id for plotnft with id {next_plot_nft.launcher_id}")
# the Streamable hint is in error so we need this type ignore
return WalletIdentifier( # type: ignore[return-value]
id=matched_plotnft_wallet_id,
type=WalletType.PLOTNFT_2,
), next_plot_nft
except GetNextPlotNFTError:
pass
await self.notification_manager.potentially_add_new_notification(coin_state, coin_spend)
return None, None
@@ -2082,6 +2137,25 @@ class WalletStateManager:
if coin_state.spent_height is not None:
vc_wallet = self.get_wallet(id=uint32(record.wallet_id), required_type=VCWallet)
await vc_wallet.remove_coin(coin_state.coin, uint32(coin_state.spent_height))
elif record.wallet_type == WalletType.PLOTNFT_2:
if isinstance(coin_data, PlotNFT):
await self.coin_added(
coin_state.coin,
uint32(coin_state.created_height),
all_unconfirmed,
wallet_identifier.id,
wallet_identifier.type,
peer,
coin_name,
coin_data,
)
await self.coin_store.set_spent(coin_name, uint32(coin_state.spent_height))
await self.add_interested_coin_ids([coin_name])
else:
await self.plotnft2_store.mark_pool_reward_as_spent(
reward_id=coin_name,
spent_height=coin_state.spent_height,
)
# Check if a child is a singleton launcher
for child in children:
@@ -2342,6 +2416,7 @@ class WalletStateManager:
additional_signing_responses: list[SigningResponse] | None = None,
extra_spends: list[WalletSpendBundle] | None = None,
singleton_records: list[SingletonRecord] = [],
plotnft_exiting_info: PlotNFTTargetStateInfo | None = None,
) -> list[TransactionRecord]:
"""
Add a list of transactions to be submitted to the full node.
@@ -2391,6 +2466,9 @@ class WalletStateManager:
for singleton_record in singleton_records:
await self.dl_store.add_singleton_record(singleton_record)
if plotnft_exiting_info is not None:
await self.plotnft2_store.add_exiting_info(exiting_info=plotnft_exiting_info)
await self.add_interested_coin_ids(all_coins_names)
if actual_spend_involved:
@@ -2506,6 +2584,7 @@ class WalletStateManager:
await self.coin_store.rollback_to_block(height)
await self.interested_store.rollback_to_block(height)
await self.dl_store.rollback_to_block(height)
await self.plotnft2_store.rollback_to_block(height=height)
reorged: list[TransactionRecord] = await self.tx_store.get_transaction_above(height)
await self.tx_store.rollback_to_block(height)
for record in reorged:
@@ -2645,9 +2724,9 @@ class WalletStateManager:
async def new_peak(self, height: uint32) -> None:
for wallet_id, wallet in self.wallets.items():
if wallet.type() == WalletType.POOLING_WALLET:
assert isinstance(wallet, PoolWallet)
if isinstance(wallet, (PoolWallet, PlotNFT2Wallet)):
await wallet.new_peak(height)
current_time = int(time.time())
if self.wallet_node.last_wallet_tx_resend_time < current_time - self.wallet_node.wallet_tx_resend_timeout_secs: