diff --git a/.flake8 b/.flake8 index 9c5194b95620..b8e8226726a4 100644 --- a/.flake8 +++ b/.flake8 @@ -7,3 +7,7 @@ per-file-ignores = tests/util/test_network_protocol_files.py:F405 tests/util/test_network_protocol_json.py:F405 tests/util/protocol_messages_json.py:E501 + chia/wallet/dao_wallet/dao_utils.py:E501 + chia/wallet/dao_wallet/dao_wallet.py:E501 + chia/wallet/cat_wallet/dao_cat_wallet.py:E501 + tests/wallet/dao_wallet/test_dao_clvm.py:E501 diff --git a/chia/cmds/chia.py b/chia/cmds/chia.py index 7ed5817c73dc..71b6fce10cbb 100644 --- a/chia/cmds/chia.py +++ b/chia/cmds/chia.py @@ -9,6 +9,7 @@ from chia.cmds.beta import beta_cmd from chia.cmds.completion import completion from chia.cmds.configure import configure_cmd +from chia.cmds.dao import dao_cmd from chia.cmds.data import data_cmd from chia.cmds.db import db_cmd from chia.cmds.dev import dev_cmd @@ -128,6 +129,7 @@ def run_daemon_cmd(ctx: click.Context, wait_for_unlock: bool) -> None: cli.add_command(passphrase_cmd) cli.add_command(beta_cmd) cli.add_command(completion) +cli.add_command(dao_cmd) cli.add_command(dev_cmd) diff --git a/chia/cmds/dao.py b/chia/cmds/dao.py new file mode 100644 index 000000000000..7deb99ad92a6 --- /dev/null +++ b/chia/cmds/dao.py @@ -0,0 +1,1003 @@ +from __future__ import annotations + +import asyncio +from typing import Optional, Sequence + +import click + +from chia.cmds.cmds_util import tx_config_args +from chia.cmds.plotnft import validate_fee + + +@click.group("dao", short_help="Create, manage or show state of DAOs", no_args_is_help=True) +@click.pass_context +def dao_cmd(ctx: click.Context) -> None: + pass + + +# ---------------------------------------------------------------------------------------- +# ADD + + +@dao_cmd.command("add", short_help="Create a wallet for an existing DAO", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-n", "--name", help="Set the DAO wallet name", type=str) +@click.option( + "-t", + "--treasury-id", + help="The Treasury ID of the DAO you want to track", + type=str, + required=True, +) +@click.option( + "-a", + "--filter-amount", + help="The minimum number of votes a proposal needs before the wallet will recognise it", + type=int, + default=1, + show_default=True, +) +def dao_add_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + treasury_id: str, + filter_amount: int, + name: Optional[str], +) -> None: + from .dao_funcs import add_dao_wallet + + extra_params = { + "name": name, + "treasury_id": treasury_id, + "filter_amount": filter_amount, + } + + asyncio.run(add_dao_wallet(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# CREATE + + +@dao_cmd.command("create", short_help="Create a new DAO wallet and treasury", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-n", "--name", help="Set the DAO wallet name", type=str) +@click.option( + "--proposal-timelock", + help="The minimum number of blocks before a proposal can close", + type=int, + default=1000, + show_default=True, +) +@click.option( + "--soft-close", + help="The number of blocks a proposal must remain unspent before closing", + type=int, + default=20, + show_default=True, +) +@click.option( + "--attendance-required", + help="The minimum number of votes a proposal must receive to be accepted", + type=int, + required=True, +) +@click.option( + "--pass-percentage", + help="The percentage of 'yes' votes in basis points a proposal must receive to be accepted. 100% = 10000", + type=int, + default=5000, + show_default=True, +) +@click.option( + "--self-destruct", + help="The number of blocks required before a proposal can be automatically removed", + type=int, + default=10000, + show_default=True, +) +@click.option( + "--oracle-delay", + help="The number of blocks required between oracle spends of the treasury", + type=int, + default=50, + show_default=True, +) +@click.option( + "--proposal-minimum", + help="The minimum amount (in xch) that a proposal must use to be created", + type=str, + default="0.000000000001", + show_default=True, +) +@click.option( + "--filter-amount", + help="The minimum number of votes a proposal needs before the wallet will recognise it", + type=int, + default=1, + show_default=True, +) +@click.option( + "--cat-amount", + help="The number of DAO CATs (in mojos) to create when initializing the DAO", + type=int, + required=True, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@click.option( + "--fee-for-cat", + help="Set the fees for the CAT creation transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_create_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + proposal_timelock: int, + soft_close: int, + attendance_required: int, + pass_percentage: int, + self_destruct: int, + oracle_delay: int, + proposal_minimum: str, + filter_amount: int, + cat_amount: int, + name: Optional[str], + fee: str, + fee_for_cat: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import create_dao_wallet + + print("Creating new DAO") + + extra_params = { + "fee": fee, + "fee_for_cat": fee_for_cat, + "name": name, + "proposal_timelock": proposal_timelock, + "soft_close_length": soft_close, + "attendance_required": attendance_required, + "pass_percentage": pass_percentage, + "self_destruct_length": self_destruct, + "oracle_spend_delay": oracle_delay, + "proposal_minimum_amount": proposal_minimum, + "filter_amount": filter_amount, + "amount_of_cats": cat_amount, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(create_dao_wallet(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# TREASURY INFO + + +@dao_cmd.command("get_id", short_help="Get the Treasury ID of a DAO", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="DAO Wallet ID", type=int, required=True) +def dao_get_id_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, +) -> None: + from .dao_funcs import get_treasury_id + + extra_params = { + "wallet_id": wallet_id, + } + asyncio.run(get_treasury_id(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("add_funds", short_help="Send funds to a DAO treasury", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="DAO Wallet ID which will receive the funds", type=int, required=True) +@click.option( + "-w", + "--funding-wallet-id", + help="ID of the wallet to send funds from", + type=int, + required=True, +) +@click.option( + "-a", + "--amount", + help="The amount of funds to send", + type=str, + required=True, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_add_funds_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + funding_wallet_id: int, + amount: str, + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import add_funds_to_treasury + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "funding_wallet_id": funding_wallet_id, + "amount": amount, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(add_funds_to_treasury(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("balance", short_help="Get the asset balances for a DAO treasury", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +def dao_get_balance_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, +) -> None: + from .dao_funcs import get_treasury_balance + + extra_params = { + "wallet_id": wallet_id, + } + asyncio.run(get_treasury_balance(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("rules", short_help="Get the current rules governing the DAO", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +def dao_rules_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, +) -> None: + from .dao_funcs import get_rules + + extra_params = { + "wallet_id": wallet_id, + } + asyncio.run(get_rules(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# LIST/SHOW PROPOSALS + + +@dao_cmd.command("list_proposals", short_help="List proposals for the DAO", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-c", + "--include-closed", + help="Include previously closed proposals", + is_flag=True, +) +def dao_list_proposals_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + include_closed: Optional[bool], +) -> None: + from .dao_funcs import list_proposals + + if not include_closed: + include_closed = False + + extra_params = { + "wallet_id": wallet_id, + "include_closed": include_closed, + } + asyncio.run(list_proposals(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("show_proposal", short_help="Show the details of a specific proposal", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-p", + "--proposal_id", + help="The ID of the proposal to fetch", + type=str, + required=True, +) +def dao_show_proposal_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + proposal_id: str, +) -> None: + from .dao_funcs import show_proposal + + extra_params = { + "wallet_id": wallet_id, + "proposal_id": proposal_id, + } + asyncio.run(show_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# VOTE + + +@dao_cmd.command("vote", short_help="Vote on a DAO proposal", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-p", + "--proposal-id", + help="The ID of the proposal you are voting on", + type=str, + required=True, +) +@click.option( + "-a", + "--vote-amount", + help="The number of votes you want to cast", + type=int, + required=True, +) +@click.option( + "-n", + "--vote-no", + help="Use this option to vote against a proposal. If not present then the vote is for the proposal", + is_flag=True, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_vote_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + proposal_id: str, + vote_amount: int, + vote_no: Optional[bool], + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import vote_on_proposal + + is_yes_vote = False if vote_no else True + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "proposal_id": proposal_id, + "vote_amount": vote_amount, + "is_yes_vote": is_yes_vote, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(vote_on_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# CLOSE PROPOSALS + + +@dao_cmd.command("close_proposal", short_help="Close a DAO proposal", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-p", + "--proposal-id", + help="The ID of the proposal you are voting on", + type=str, + required=True, +) +@click.option( + "-d", + "--self-destruct", + help="If a proposal is broken, use self destruct to force it to close", + is_flag=True, + default=False, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_close_proposal_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + proposal_id: str, + self_destruct: bool, + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import close_proposal + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "proposal_id": proposal_id, + "self_destruct": self_destruct, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(close_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# LOCKUP COINS + + +@dao_cmd.command("lockup_coins", short_help="Lock DAO CATs for voting", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-a", + "--amount", + help="The amount of CATs (not mojos) to lock in voting mode", + type=str, + required=True, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_lockup_coins_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + amount: str, + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import lockup_coins + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "amount": amount, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(lockup_coins(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("release_coins", short_help="Release closed proposals from DAO CATs", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_release_coins_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import release_coins + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(release_coins(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_cmd.command("exit_lockup", short_help="Release DAO CATs from voting mode", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_exit_lockup_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import exit_lockup + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(exit_lockup(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- +# CREATE PROPOSALS + + +@dao_cmd.group("create_proposal", short_help="Create and add a proposal to a DAO", no_args_is_help=True) +@click.pass_context +def dao_proposal(ctx: click.Context) -> None: + pass + + +@dao_proposal.command("spend", short_help="Create a proposal to spend DAO funds", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-t", + "--to-address", + help="The address the proposal will send funds to", + type=str, + required=False, + default=None, +) +@click.option( + "-a", + "--amount", + help="The amount of funds the proposal will send (in mojos)", + type=float, + required=False, + default=None, +) +@click.option( + "-v", + "--vote-amount", + help="The number of votes to add", + type=int, + required=False, + default=None, +) +@click.option( + "--asset-id", + help="The asset id of the funds the proposal will send. Leave blank for xch", + type=str, + required=False, + default=None, +) +@click.option( + "-j", + "--from-json", + help="Path to a json file containing a list of additions, for use in proposals with multiple spends", + type=str, + required=False, + default=None, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_create_spend_proposal_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + vote_amount: Optional[int], + to_address: Optional[str], + amount: Optional[str], + asset_id: Optional[str], + from_json: Optional[str], + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import create_spend_proposal + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "vote_amount": vote_amount, + "to_address": to_address, + "amount": amount, + "asset_id": asset_id, + "from_json": from_json, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(create_spend_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_proposal.command("update", short_help="Create a proposal to change the DAO rules", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-v", + "--vote-amount", + help="The number of votes to add", + type=int, + required=False, + default=None, +) +@click.option( + "--proposal-timelock", + help="The new minimum number of blocks before a proposal can close", + type=int, + default=None, + required=False, +) +@click.option( + "--soft-close", + help="The number of blocks a proposal must remain unspent before closing", + type=int, + default=None, + required=False, +) +@click.option( + "--attendance-required", + help="The minimum number of votes a proposal must receive to be accepted", + type=int, + default=None, + required=False, +) +@click.option( + "--pass-percentage", + help="The percentage of 'yes' votes in basis points a proposal must receive to be accepted. 100% = 10000", + type=int, + default=None, + required=False, +) +@click.option( + "--self-destruct", + help="The number of blocks required before a proposal can be automatically removed", + type=int, + default=None, + required=False, +) +@click.option( + "--oracle-delay", + help="The number of blocks required between oracle spends of the treasury", + type=int, + default=None, + required=False, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_create_update_proposal_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + vote_amount: Optional[int], + proposal_timelock: Optional[int], + soft_close: Optional[int], + attendance_required: Optional[int], + pass_percentage: Optional[int], + self_destruct: Optional[int], + oracle_delay: Optional[int], + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import create_update_proposal + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "vote_amount": vote_amount, + "proposal_timelock": proposal_timelock, + "soft_close_length": soft_close, + "attendance_required": attendance_required, + "pass_percentage": pass_percentage, + "self_destruct_length": self_destruct, + "oracle_spend_delay": oracle_delay, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(create_update_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +@dao_proposal.command("mint", short_help="Create a proposal to mint new DAO CATs", no_args_is_help=True) +@click.option( + "-wp", + "--wallet-rpc-port", + help="Set the port where the Wallet is hosting the RPC interface. See the rpc_port under wallet in config.yaml", + type=int, + default=None, +) +@click.option("-f", "--fingerprint", help="Set the fingerprint to specify which key to use", type=int) +@click.option("-i", "--wallet-id", help="Id of the wallet to use", type=int, required=True) +@click.option( + "-a", + "--amount", + help="The amount of new cats the proposal will mint (in mojos)", + type=int, + required=True, +) +@click.option( + "-t", + "--to-address", + help="The address new cats will be minted to", + type=str, + required=True, + default=None, +) +@click.option( + "-v", + "--vote-amount", + help="The number of votes to add", + type=int, + required=False, + default=None, +) +@click.option( + "-m", + "--fee", + help="Set the fees per transaction, in XCH.", + type=str, + default="0", + show_default=True, + callback=validate_fee, +) +@tx_config_args +def dao_create_mint_proposal_cmd( + wallet_rpc_port: Optional[int], + fingerprint: int, + wallet_id: int, + amount: int, + to_address: int, + vote_amount: Optional[int], + fee: str, + min_coin_amount: Optional[str], + max_coin_amount: Optional[str], + coins_to_exclude: Sequence[str], + amounts_to_exclude: Sequence[str], + reuse: Optional[bool], +) -> None: + from .dao_funcs import create_mint_proposal + + extra_params = { + "wallet_id": wallet_id, + "fee": fee, + "amount": amount, + "cat_target_address": to_address, + "vote_amount": vote_amount, + "min_coin_amount": min_coin_amount, + "max_coin_amount": max_coin_amount, + "coins_to_exclude": coins_to_exclude, + "amounts_to_exclude": amounts_to_exclude, + "reuse_puzhash": reuse, + } + asyncio.run(create_mint_proposal(extra_params, wallet_rpc_port, fingerprint)) + + +# ---------------------------------------------------------------------------------------- + +dao_cmd.add_command(dao_add_cmd) +dao_cmd.add_command(dao_create_cmd) +dao_cmd.add_command(dao_add_funds_cmd) +dao_cmd.add_command(dao_get_balance_cmd) +dao_cmd.add_command(dao_list_proposals_cmd) +dao_cmd.add_command(dao_show_proposal_cmd) +dao_cmd.add_command(dao_vote_cmd) +dao_cmd.add_command(dao_close_proposal_cmd) +dao_cmd.add_command(dao_lockup_coins_cmd) +dao_cmd.add_command(dao_exit_lockup_cmd) +dao_cmd.add_command(dao_release_coins_cmd) +dao_cmd.add_command(dao_proposal) diff --git a/chia/cmds/dao_funcs.py b/chia/cmds/dao_funcs.py new file mode 100644 index 000000000000..e772366b17db --- /dev/null +++ b/chia/cmds/dao_funcs.py @@ -0,0 +1,580 @@ +from __future__ import annotations + +import asyncio +import json +import time +from decimal import Decimal +from typing import Any, Dict, Optional + +from chia.cmds.cmds_util import CMDTXConfigLoader, get_wallet_client, transaction_status_msg, transaction_submitted_msg +from chia.cmds.units import units +from chia.cmds.wallet_funcs import get_mojo_per_unit, get_wallet_type +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.util.bech32m import decode_puzzle_hash, encode_puzzle_hash +from chia.util.config import selected_network_address_prefix +from chia.util.ints import uint64 +from chia.wallet.util.tx_config import DEFAULT_COIN_SELECTION_CONFIG +from chia.wallet.util.wallet_types import WalletType + + +async def add_dao_wallet(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + treasury_id = args["treasury_id"] + filter_amount = args["filter_amount"] + name = args["name"] + + print(f"Adding wallet for DAO: {treasury_id}") + print("This may take awhile.") + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.create_new_dao_wallet( + mode="existing", + tx_config=CMDTXConfigLoader.from_json_dict({"reuse_puzhash": True}).to_tx_config( + units["chia"], config, fingerprint + ), + dao_rules=None, + amount_of_cats=None, + treasury_id=treasury_id, + filter_amount=filter_amount, + name=name, + ) + + print("Successfully created DAO Wallet") + print("DAO Treasury ID: {treasury_id}".format(**res)) + print("DAO Wallet ID: {wallet_id}".format(**res)) + print("CAT Wallet ID: {cat_wallet_id}".format(**res)) + print("DAOCAT Wallet ID: {dao_cat_wallet_id}".format(**res)) + + +async def create_dao_wallet(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + proposal_minimum = uint64(int(Decimal(args["proposal_minimum_amount"]) * units["chia"])) + + if proposal_minimum % 2 == 0: + proposal_minimum = uint64(1 + proposal_minimum) + print("Adding 1 mojo to proposal minimum amount") + + dao_rules = { + "proposal_timelock": args["proposal_timelock"], + "soft_close_length": args["soft_close_length"], + "attendance_required": args["attendance_required"], + "pass_percentage": args["pass_percentage"], + "self_destruct_length": args["self_destruct_length"], + "oracle_spend_delay": args["oracle_spend_delay"], + "proposal_minimum_amount": proposal_minimum, + } + amount_of_cats = args["amount_of_cats"] + filter_amount = args["filter_amount"] + name = args["name"] + + fee = Decimal(args["fee"]) + final_fee: uint64 = uint64(int(fee * units["chia"])) + + fee_for_cat = Decimal(args["fee_for_cat"]) + final_fee_for_cat: uint64 = uint64(int(fee_for_cat * units["chia"])) + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + conf_coins, _, _ = await wallet_client.get_spendable_coins( + wallet_id=1, coin_selection_config=DEFAULT_COIN_SELECTION_CONFIG + ) + if len(conf_coins) < 2: # pragma: no cover + raise ValueError("DAO creation requires at least 2 xch coins in your wallet.") + res = await wallet_client.create_new_dao_wallet( + mode="new", + dao_rules=dao_rules, + amount_of_cats=amount_of_cats, + treasury_id=None, + filter_amount=filter_amount, + name=name, + fee=final_fee, + fee_for_cat=final_fee_for_cat, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + + print("Successfully created DAO Wallet") + print("DAO Treasury ID: {treasury_id}".format(**res)) + print("DAO Wallet ID: {wallet_id}".format(**res)) + print("CAT Wallet ID: {cat_wallet_id}".format(**res)) + print("DAOCAT Wallet ID: {dao_cat_wallet_id}".format(**res)) + + +async def get_treasury_id(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, _, _): + res = await wallet_client.dao_get_treasury_id(wallet_id=wallet_id) + treasury_id = res["treasury_id"] + print(f"Treasury ID: {treasury_id}") + + +async def get_rules(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, _, _): + res = await wallet_client.dao_get_rules(wallet_id=wallet_id) + rules = res["rules"] + for rule, val in rules.items(): + print(f"{rule}: {val}") + + +async def add_funds_to_treasury(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + funding_wallet_id = args["funding_wallet_id"] + amount = Decimal(args["amount"]) + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + try: + typ = await get_wallet_type(wallet_id=funding_wallet_id, wallet_client=wallet_client) + mojo_per_unit = get_mojo_per_unit(typ) + except LookupError: # pragma: no cover + print(f"Wallet id: {wallet_id} not found.") + return + + fee = Decimal(args["fee"]) + final_fee: uint64 = uint64(int(fee * units["chia"])) + final_amount: uint64 = uint64(int(amount * mojo_per_unit)) + + res = await wallet_client.dao_add_funds_to_treasury( + wallet_id=wallet_id, + funding_wallet_id=funding_wallet_id, + amount=final_amount, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def get_treasury_balance(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, _, _): + res = await wallet_client.dao_get_treasury_balance(wallet_id=wallet_id) + balances = res["balances"] + + if not balances: + print("The DAO treasury currently has no funds") + return None + + xch_mojos = get_mojo_per_unit(WalletType.STANDARD_WALLET) + cat_mojos = get_mojo_per_unit(WalletType.CAT) + for asset_id, balance in balances.items(): + if asset_id == "xch": + print(f"XCH: {balance / xch_mojos}") + else: + print(f"{asset_id}: {balance / cat_mojos}") + + +async def list_proposals(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + include_closed = args["include_closed"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, _, _): + res = await wallet_client.dao_get_proposals(wallet_id=wallet_id, include_closed=include_closed) + proposals = res["proposals"] + soft_close_length = res["soft_close_length"] + print("############################") + for prop in proposals: + print("Proposal ID: {proposal_id}".format(**prop)) + prop_status = "CLOSED" if prop["closed"] else "OPEN" + print(f"Status: {prop_status}") + print("Votes for: {yes_votes}".format(**prop)) + votes_against = prop["amount_voted"] - prop["yes_votes"] + print(f"Votes against: {votes_against}") + print("------------------------") + print(f"Proposals have {soft_close_length} blocks of soft close time.") + print("############################") + + +async def show_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + proposal_id = args["proposal_id"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, _, config): + res = await wallet_client.dao_parse_proposal(wallet_id, proposal_id) + pd = res["proposal_dictionary"] + blocks_needed = pd["state"]["blocks_needed"] + passed = pd["state"]["passed"] + closable = pd["state"]["closable"] + status = "CLOSED" if pd["state"]["closed"] else "OPEN" + votes_needed = pd["state"]["total_votes_needed"] + yes_needed = pd["state"]["yes_votes_needed"] + + ptype_val = pd["proposal_type"] + if (ptype_val == "s") and ("mint_amount" in pd): + ptype = "mint" + elif ptype_val == "s": + ptype = "spend" + elif ptype_val == "u": + ptype = "update" + + print("") + print(f"Details of Proposal: {proposal_id}") + print("---------------------------") + print("") + print(f"Type: {ptype.upper()}") + print(f"Status: {status}") + print(f"Passed: {passed}") + if not passed: + print(f"Yes votes needed: {yes_needed}") + + if not pd["state"]["closed"]: + print(f"Closable: {closable}") + if not closable: + print(f"Total votes needed: {votes_needed}") + print(f"Blocks remaining: {blocks_needed}") + + prefix = selected_network_address_prefix(config) + if ptype == "spend": + xch_conds = pd["xch_conditions"] + asset_conds = pd["asset_conditions"] + print("") + if xch_conds: + print("Proposal XCH Conditions") + for pmt in xch_conds: + puzzle_hash = encode_puzzle_hash(bytes32.from_hexstr(pmt["puzzle_hash"]), prefix) + amount = pmt["amount"] + print(f"Address: {puzzle_hash}\nAmount: {amount}\n") + if asset_conds: + print("Proposal asset Conditions") + for cond in asset_conds: + asset_id = cond["asset_id"] + print(f"Asset ID: {asset_id}") + conds = cond["conditions"] + for pmt in conds: + puzzle_hash = encode_puzzle_hash(bytes32.from_hexstr(pmt["puzzle_hash"]), prefix) + amount = pmt["amount"] + print(f"Address: {puzzle_hash}\nAmount: {amount}\n") + + elif ptype == "update": + print("") + print("Proposed Rules:") + for key, val in pd["dao_rules"].items(): + print(f"{key}: {val}") + + elif ptype == "mint": + mint_amount = pd["mint_amount"] + address = encode_puzzle_hash(bytes32.from_hexstr(pd["new_cat_puzhash"]), prefix) + print("") + print(f"Amount of CAT to mint: {mint_amount}") + print(f"Address: {address}") + + +async def vote_on_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + vote_amount = args["vote_amount"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + proposal_id = args["proposal_id"] + is_yes_vote = args["is_yes_vote"] + + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_vote_on_proposal( + wallet_id=wallet_id, + proposal_id=proposal_id, + vote_amount=vote_amount, + is_yes_vote=is_yes_vote, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def close_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + proposal_id = args["proposal_id"] + self_destruct = args["self_destruct"] + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_close_proposal( + wallet_id=wallet_id, + proposal_id=proposal_id, + fee=final_fee, + self_destruct=self_destruct, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def lockup_coins(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + amount = args["amount"] + final_amount: uint64 = uint64(int(Decimal(amount) * units["cat"])) + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_send_to_lockup( + wallet_id=wallet_id, + amount=final_amount, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def release_coins(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_free_coins_from_finished_proposals( + wallet_id=wallet_id, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def exit_lockup(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_exit_lockup( + wallet_id=wallet_id, + coins=[], + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + tx_id = res["tx_id"] + start = time.time() + while time.time() - start < 10: + await asyncio.sleep(0.1) + tx = await wallet_client.get_transaction(wallet_id, bytes32.from_hexstr(tx_id)) + if len(tx.sent_to) > 0: + print(transaction_submitted_msg(tx)) + print(transaction_status_msg(fingerprint, tx_id[2:])) + return None + print(f"Transaction not yet submitted to nodes. TX ID: {tx_id}") # pragma: no cover + + +async def create_spend_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + asset_id = args.get("asset_id") + address = args.get("to_address") + amount = args.get("amount") + additions_file = args.get("from_json") + if additions_file is None and (address is None or amount is None): + raise ValueError("Must include a json specification or an address / amount pair.") + if additions_file: # pragma: no cover + with open(additions_file, "r") as f: + additions_dict = json.load(f) + additions = [] + for addition in additions_dict: + addition["puzzle_hash"] = decode_puzzle_hash(addition["address"]).hex() + del addition["address"] + additions.append(addition) + else: + additions = None + vote_amount = args.get("vote_amount") + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + wallet_type = await get_wallet_type(wallet_id=wallet_id, wallet_client=wallet_client) + mojo_per_unit = get_mojo_per_unit(wallet_type=wallet_type) + final_amount: Optional[uint64] = uint64(int(Decimal(amount) * mojo_per_unit)) if amount else None + res = await wallet_client.dao_create_proposal( + wallet_id=wallet_id, + proposal_type="spend", + additions=additions, + amount=final_amount, + inner_address=address, + asset_id=asset_id, + vote_amount=vote_amount, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + if res["success"]: + asset_id_name = asset_id if asset_id else "XCH" + print(f"Created spend proposal for asset: {asset_id_name}") + print("Successfully created proposal.") + print("Proposal ID: {}".format(res["proposal_id"])) + else: # pragma: no cover + print("Failed to create proposal.") + + +async def create_update_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = Decimal(args["fee"]) + final_fee: uint64 = uint64(int(fee * units["chia"])) + proposal_timelock = args.get("proposal_timelock") + soft_close_length = args.get("soft_close_length") + attendance_required = args.get("attendance_required") + pass_percentage = args.get("pass_percentage") + self_destruct_length = args.get("self_destruct_length") + oracle_spend_delay = args.get("oracle_spend_delay") + vote_amount = args.get("vote_amount") + new_dao_rules = { + "proposal_timelock": proposal_timelock, + "soft_close_length": soft_close_length, + "attendance_required": attendance_required, + "pass_percentage": pass_percentage, + "self_destruct_length": self_destruct_length, + "oracle_spend_delay": oracle_spend_delay, + } + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_create_proposal( + wallet_id=wallet_id, + proposal_type="update", + new_dao_rules=new_dao_rules, + vote_amount=vote_amount, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + if res["success"]: + print("Successfully created proposal.") + print("Proposal ID: {}".format(res["proposal_id"])) + else: # pragma: no cover + print("Failed to create proposal.") + + +async def create_mint_proposal(args: Dict[str, Any], wallet_rpc_port: Optional[int], fp: int) -> None: + wallet_id = args["wallet_id"] + fee = args["fee"] + final_fee: uint64 = uint64(int(Decimal(fee) * units["chia"])) + cat_target_address = args["cat_target_address"] + amount = args["amount"] + vote_amount = args.get("vote_amount") + async with get_wallet_client(wallet_rpc_port, fp) as (wallet_client, fingerprint, config): + res = await wallet_client.dao_create_proposal( + wallet_id=wallet_id, + proposal_type="mint", + cat_target_address=cat_target_address, + amount=amount, + vote_amount=vote_amount, + fee=final_fee, + tx_config=CMDTXConfigLoader.from_json_dict( + { + "min_coin_amount": args["min_coin_amount"], + "max_coin_amount": args["max_coin_amount"], + "coins_to_exclude": args["coins_to_exclude"], + "amounts_to_exclude": args["amounts_to_exclude"], + "reuse_puzhash": args["reuse_puzhash"], + } + ).to_tx_config(units["chia"], config, fingerprint), + ) + if res["success"]: + print("Successfully created proposal.") + print("Proposal ID: {}".format(res["proposal_id"])) + else: # pragma: no cover + print("Failed to create proposal.") diff --git a/chia/cmds/wallet_funcs.py b/chia/cmds/wallet_funcs.py index 1fe3417d4ff5..b93f5152ac0e 100644 --- a/chia/cmds/wallet_funcs.py +++ b/chia/cmds/wallet_funcs.py @@ -95,6 +95,7 @@ def get_mojo_per_unit(wallet_type: WalletType) -> int: # pragma: no cover WalletType.POOLING_WALLET, WalletType.DATA_LAYER, WalletType.VC, + WalletType.DAO, }: mojo_per_unit = units["chia"] elif wallet_type in {WalletType.CAT, WalletType.CRCAT}: @@ -877,6 +878,10 @@ async def print_balances( my_did = get_did_response["did_id"] if my_did is not None and len(my_did) > 0: print(f"{indent}{'-DID ID:'.ljust(ljust)} {my_did}") + elif typ == WalletType.DAO: + get_id_response = await wallet_client.dao_get_treasury_id(wallet_id) + treasury_id = get_id_response["treasury_id"][2:] + print(f"{indent}{'-Treasury ID:'.ljust(ljust)} {treasury_id}") elif len(asset_id) > 0: print(f"{indent}{'-Asset ID:'.ljust(ljust)} {asset_id}") print(f"{indent}{'-Wallet ID:'.ljust(ljust)} {wallet_id}") diff --git a/chia/rpc/wallet_rpc_api.py b/chia/rpc/wallet_rpc_api.py index f6b644fc14c2..51eadd207e91 100644 --- a/chia/rpc/wallet_rpc_api.py +++ b/chia/rpc/wallet_rpc_api.py @@ -43,7 +43,17 @@ from chia.wallet.cat_wallet.cat_constants import DEFAULT_CATS from chia.wallet.cat_wallet.cat_info import CRCATInfo from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.dao_cat_info import LockedCoinInfo +from chia.wallet.cat_wallet.dao_cat_wallet import DAOCATWallet from chia.wallet.conditions import Condition +from chia.wallet.dao_wallet.dao_info import DAORules +from chia.wallet.dao_wallet.dao_utils import ( + generate_mint_proposal_innerpuz, + generate_simple_proposal_innerpuz, + generate_update_proposal_innerpuz, + get_treasury_rules_from_puzzle, +) +from chia.wallet.dao_wallet.dao_wallet import DAOWallet from chia.wallet.derive_keys import ( MAX_POOL_WALLETS, master_sk_to_farmer_sk, @@ -56,9 +66,9 @@ from chia.wallet.did_wallet.did_wallet import DIDWallet from chia.wallet.did_wallet.did_wallet_puzzles import ( DID_INNERPUZ_MOD, + did_program_to_metadata, match_did_puzzle, metadata_to_program, - program_to_metadata, ) from chia.wallet.nft_wallet import nft_puzzles from chia.wallet.nft_wallet.nft_info import NFTCoinInfo, NFTInfo @@ -71,7 +81,11 @@ from chia.wallet.puzzle_drivers import PuzzleInfo, Solver from chia.wallet.puzzles.clawback.metadata import AutoClaimSettings, ClawbackMetadata from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import puzzle_hash_for_synthetic_public_key -from chia.wallet.singleton import create_singleton_puzzle, get_inner_puzzle_from_singleton +from chia.wallet.singleton import ( + SINGLETON_LAUNCHER_PUZZLE_HASH, + create_singleton_puzzle, + get_inner_puzzle_from_singleton, +) from chia.wallet.trade_record import TradeRecord from chia.wallet.trading.offer import Offer from chia.wallet.transaction_record import TransactionRecord @@ -202,6 +216,21 @@ def get_routes(self) -> Dict[str, Endpoint]: "/did_message_spend": self.did_message_spend, "/did_get_info": self.did_get_info, "/did_find_lost_did": self.did_find_lost_did, + # DAO Wallets + "/dao_get_proposals": self.dao_get_proposals, + "/dao_create_proposal": self.dao_create_proposal, + "/dao_parse_proposal": self.dao_parse_proposal, + "/dao_vote_on_proposal": self.dao_vote_on_proposal, + "/dao_get_treasury_balance": self.dao_get_treasury_balance, + "/dao_get_treasury_id": self.dao_get_treasury_id, + "/dao_get_rules": self.dao_get_rules, + "/dao_close_proposal": self.dao_close_proposal, + "/dao_exit_lockup": self.dao_exit_lockup, + "/dao_adjust_filter_level": self.dao_adjust_filter_level, + "/dao_add_funds_to_treasury": self.dao_add_funds_to_treasury, + "/dao_send_to_lockup": self.dao_send_to_lockup, + "/dao_get_proposal_state": self.dao_get_proposal_state, + "/dao_free_coins_from_finished_proposals": self.dao_free_coins_from_finished_proposals, # NFT Wallet "/nft_mint_nft": self.nft_mint_nft, "/nft_count_nfts": self.nft_count_nfts, @@ -751,6 +780,44 @@ async def create_new_wallet( } else: # undefined did_type pass + elif request["wallet_type"] == "dao_wallet": + name = request.get("name", None) + mode = request.get("mode", None) + if mode == "new": + dao_rules_json = request.get("dao_rules", None) + if dao_rules_json: + dao_rules = DAORules.from_json_dict(dao_rules_json) + else: + raise ValueError("DAO rules must be specified for wallet creation") + async with self.service.wallet_state_manager.lock: + dao_wallet = await DAOWallet.create_new_dao_and_wallet( + wallet_state_manager, + main_wallet, + uint64(request.get("amount_of_cats", None)), + dao_rules, + tx_config, + uint64(request.get("filter_amount", 1)), + name, + uint64(request.get("fee", 0)), + uint64(request.get("fee_for_cat", 0)), + ) + elif mode == "existing": + # async with self.service.wallet_state_manager.lock: + dao_wallet = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_state_manager, + main_wallet, + bytes32.from_hexstr(request.get("treasury_id", None)), + uint64(request.get("filter_amount", 1)), + name, + ) + return { + "success": True, + "type": dao_wallet.type(), + "wallet_id": dao_wallet.id(), + "treasury_id": dao_wallet.dao_info.treasury_id, + "cat_wallet_id": dao_wallet.dao_info.cat_wallet_id, + "dao_cat_wallet_id": dao_wallet.dao_info.dao_cat_wallet_id, + } elif request["wallet_type"] == "nft_wallet": for wallet in self.service.wallet_state_manager.wallets.values(): did_id: Optional[bytes32] = None @@ -2120,7 +2187,7 @@ async def did_get_info(self, request: Dict[str, Any]) -> EndpointResult: "public_key": public_key.atom.hex(), "recovery_list_hash": recovery_list_hash.atom.hex(), "num_verification": num_verification.as_int(), - "metadata": program_to_metadata(metadata), + "metadata": did_program_to_metadata(metadata), "launcher_id": singleton_struct.rest().first().atom.hex(), "full_puzzle": full_puzzle, "solution": Program.from_bytes(bytes(coin_spend.solution)).as_python(), @@ -2297,7 +2364,7 @@ async def did_find_lost_did(self, request: Dict[str, Any]) -> EndpointResult: None, None, False, - json.dumps(did_wallet_puzzles.program_to_metadata(metadata)), + json.dumps(did_wallet_puzzles.did_program_to_metadata(metadata)), ) await did_wallet.save_info(did_info) await self.service.wallet_state_manager.update_wallet_puzzle_hashes(did_wallet.wallet_info.id) @@ -2528,6 +2595,347 @@ async def did_transfer_did( "transaction_id": txs.name, } + ########################################################################################## + # DAO Wallet + ########################################################################################## + + async def dao_adjust_filter_level(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + await dao_wallet.adjust_filter_level(uint64(request["filter_level"])) + return { + "success": True, + "dao_info": dao_wallet.dao_info, + } + + @tx_endpoint + async def dao_add_funds_to_treasury( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + funding_wallet_id = uint32(request["funding_wallet_id"]) + wallet_type = self.service.wallet_state_manager.wallets[funding_wallet_id].type() + amount = request.get("amount") + assert amount + if wallet_type not in [WalletType.STANDARD_WALLET, WalletType.CAT]: # pragma: no cover + raise ValueError(f"Cannot fund a treasury with assets from a {wallet_type.name} wallet") + funding_tx = await dao_wallet.create_add_funds_to_treasury_spend( + uint64(amount), + tx_config, + fee=uint64(request.get("fee", 0)), + funding_wallet_id=funding_wallet_id, + extra_conditions=extra_conditions, + ) + if push: + await self.service.wallet_state_manager.add_pending_transaction(funding_tx) + return {"success": True, "tx_id": funding_tx.name, "tx": funding_tx} + + async def dao_get_treasury_balance(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + asset_list = dao_wallet.dao_info.assets + balances = {} + for asset_id in asset_list: + balance = await dao_wallet.get_balance_by_asset_type(asset_id=asset_id) + if asset_id is None: + balances["xch"] = balance + else: + balances[asset_id.hex()] = balance + return {"success": True, "balances": balances} + + async def dao_get_treasury_id(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + treasury_id = dao_wallet.dao_info.treasury_id + return {"treasury_id": treasury_id} + + async def dao_get_rules(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + rules = dao_wallet.dao_rules + return {"rules": rules} + + @tx_endpoint + async def dao_send_to_lockup( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + dao_cat_wallet = self.service.wallet_state_manager.get_wallet( + id=dao_wallet.dao_info.dao_cat_wallet_id, required_type=DAOCATWallet + ) + amount = uint64(request["amount"]) + fee = uint64(request.get("fee", 0)) + txs = await dao_cat_wallet.enter_dao_cat_voting_mode( + amount, + tx_config, + fee=fee, + extra_conditions=extra_conditions, + ) + if push: + for tx in txs: + await self.service.wallet_state_manager.add_pending_transaction(tx) + return { + "success": True, + "tx_id": txs[0].name, + "txs": txs, + } + + async def dao_get_proposals(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + include_closed = request.get("include_closed", True) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + proposal_list = dao_wallet.dao_info.proposals_list + if not include_closed: + proposal_list = [prop for prop in proposal_list if not prop.closed] + dao_rules = get_treasury_rules_from_puzzle(dao_wallet.dao_info.current_treasury_innerpuz) + return { + "success": True, + "proposals": proposal_list, + "proposal_timelock": dao_rules.proposal_timelock, + "soft_close_length": dao_rules.soft_close_length, + } + + async def dao_get_proposal_state(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + state = await dao_wallet.get_proposal_state(bytes32.from_hexstr(request["proposal_id"])) + return {"success": True, "state": state} + + @tx_endpoint + async def dao_exit_lockup( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + dao_cat_wallet = self.service.wallet_state_manager.get_wallet( + id=dao_wallet.dao_info.dao_cat_wallet_id, required_type=DAOCATWallet + ) + assert dao_cat_wallet is not None + if request["coins"]: # pragma: no cover + coin_list = [Coin.from_json_dict(coin) for coin in request["coins"]] + coins: List[LockedCoinInfo] = [] + for lci in dao_cat_wallet.dao_cat_info.locked_coins: + if lci.coin in coin_list: + coins.append(lci) + else: + coins = [] + for lci in dao_cat_wallet.dao_cat_info.locked_coins: + if lci.active_votes == []: + coins.append(lci) + fee = uint64(request.get("fee", 0)) + exit_tx = await dao_cat_wallet.exit_vote_state( + coins, + tx_config, + fee=fee, + extra_conditions=extra_conditions, + ) + if push: + await self.service.wallet_state_manager.add_pending_transaction(exit_tx) + return {"success": True, "tx_id": exit_tx.name, "tx": exit_tx} + + @tx_endpoint + async def dao_create_proposal( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + + if request["proposal_type"] == "spend": + amounts: List[uint64] = [] + puzzle_hashes: List[bytes32] = [] + asset_types: List[Optional[bytes32]] = [] + additions: Optional[List[Dict[str, Any]]] = request.get("additions") + if additions is not None: + for addition in additions: + if "asset_id" in addition: + asset_id = bytes32.from_hexstr(addition["asset_id"]) + else: + asset_id = None + receiver_ph = bytes32.from_hexstr(addition["puzzle_hash"]) + amount = uint64(addition["amount"]) + amounts.append(amount) + puzzle_hashes.append(receiver_ph) + asset_types.append(asset_id) + else: # pragma: no cover + amounts.append(uint64(request["amount"])) + puzzle_hashes.append(decode_puzzle_hash(request["inner_address"])) + if request["asset_id"] is not None: + asset_types.append(bytes32.from_hexstr(request["asset_id"])) + else: + asset_types.append(None) + proposed_puzzle = generate_simple_proposal_innerpuz( + dao_wallet.dao_info.treasury_id, puzzle_hashes, amounts, asset_types + ) + + elif request["proposal_type"] == "update": + rules = dao_wallet.dao_rules + prop = request["new_dao_rules"] + new_rules = DAORules( + proposal_timelock=prop.get("proposal_timelock") or rules.proposal_timelock, + soft_close_length=prop.get("soft_close_length") or rules.soft_close_length, + attendance_required=prop.get("attendance_required") or rules.attendance_required, + proposal_minimum_amount=prop.get("proposal_minimum_amount") or rules.proposal_minimum_amount, + pass_percentage=prop.get("pass_percentage") or rules.pass_percentage, + self_destruct_length=prop.get("self_destruct_length") or rules.self_destruct_length, + oracle_spend_delay=prop.get("oracle_spend_delay") or rules.oracle_spend_delay, + ) + + current_innerpuz = dao_wallet.dao_info.current_treasury_innerpuz + assert current_innerpuz is not None + proposed_puzzle = await generate_update_proposal_innerpuz(current_innerpuz, new_rules) + elif request["proposal_type"] == "mint": + amount_of_cats = uint64(request["amount"]) + mint_address = decode_puzzle_hash(request["cat_target_address"]) + cat_wallet = self.service.wallet_state_manager.get_wallet( + id=dao_wallet.dao_info.cat_wallet_id, required_type=CATWallet + ) + proposed_puzzle = await generate_mint_proposal_innerpuz( + dao_wallet.dao_info.treasury_id, + cat_wallet.cat_info.limitations_program_hash, + amount_of_cats, + mint_address, + ) + else: # pragma: no cover + return {"success": False, "error": "Unknown proposal type."} + + vote_amount = request.get("vote_amount") + fee = uint64(request.get("fee", 0)) + proposal_tx = await dao_wallet.generate_new_proposal( + proposed_puzzle, + tx_config, + vote_amount=vote_amount, + fee=fee, + extra_conditions=extra_conditions, + ) + assert proposal_tx is not None + await self.service.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx.removals, List) + for coin in proposal_tx.removals: + if coin.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH: + proposal_id = coin.name() + break + else: # pragma: no cover + raise ValueError("Could not find proposal ID in transaction") + return { + "success": True, + "proposal_id": proposal_id, + "tx_id": proposal_tx.name.hex(), + "tx": proposal_tx, + } + + @tx_endpoint + async def dao_vote_on_proposal( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + vote_amount = None + if "vote_amount" in request: + vote_amount = uint64(request["vote_amount"]) + fee = uint64(request.get("fee", 0)) + vote_tx = await dao_wallet.generate_proposal_vote_spend( + bytes32.from_hexstr(request["proposal_id"]), + vote_amount, + request["is_yes_vote"], # bool + tx_config, + fee, + extra_conditions=extra_conditions, + ) + assert vote_tx is not None + if push: + await self.service.wallet_state_manager.add_pending_transaction(vote_tx) + return {"success": True, "tx_id": vote_tx.name, "tx": vote_tx} + + async def dao_parse_proposal(self, request: Dict[str, Any]) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + proposal_id = bytes32.from_hexstr(request["proposal_id"]) + proposal_dictionary = await dao_wallet.parse_proposal(proposal_id) + assert proposal_dictionary is not None + return {"success": True, "proposal_dictionary": proposal_dictionary} + + @tx_endpoint + async def dao_close_proposal( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + fee = uint64(request.get("fee", 0)) + if "genesis_id" in request: # pragma: no cover + genesis_id = bytes32.from_hexstr(request["genesis_id"]) + else: + genesis_id = None + self_destruct = request.get("self_destruct", None) + tx = await dao_wallet.create_proposal_close_spend( + bytes32.from_hexstr(request["proposal_id"]), + tx_config, + genesis_id, + fee=fee, + self_destruct=self_destruct, + extra_conditions=extra_conditions, + ) + assert tx is not None + await self.service.wallet_state_manager.add_pending_transaction(tx) + return {"success": True, "tx_id": tx.name, "tx": tx} + + @tx_endpoint + async def dao_free_coins_from_finished_proposals( + self, + request: Dict[str, Any], + tx_config: TXConfig = DEFAULT_TX_CONFIG, + push: bool = True, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> EndpointResult: + wallet_id = uint32(request["wallet_id"]) + fee = uint64(request.get("fee", 0)) + dao_wallet = self.service.wallet_state_manager.get_wallet(id=wallet_id, required_type=DAOWallet) + assert dao_wallet is not None + tx = await dao_wallet.free_coins_from_finished_proposals( + tx_config, + fee=fee, + extra_conditions=extra_conditions, + ) + assert tx is not None + await self.service.wallet_state_manager.add_pending_transaction(tx) + + return {"success": True, "tx_id": tx.name, "tx": tx} + ########################################################################################## # NFT Wallet ########################################################################################## diff --git a/chia/rpc/wallet_rpc_client.py b/chia/rpc/wallet_rpc_client.py index c14a26d414cc..e19d305d3607 100644 --- a/chia/rpc/wallet_rpc_client.py +++ b/chia/rpc/wallet_rpc_client.py @@ -1343,6 +1343,224 @@ async def sign_message_by_id(self, id: str, message: str) -> Tuple[str, str, str response = await self.fetch("sign_message_by_id", {"id": id, "message": message}) return response["pubkey"], response["signature"], response["signing_mode"] + # DAOs + async def create_new_dao_wallet( + self, + mode: str, + tx_config: TXConfig, + dao_rules: Optional[Dict[str, uint64]] = None, + amount_of_cats: Optional[uint64] = None, + treasury_id: Optional[bytes32] = None, + filter_amount: uint64 = uint64(1), + name: Optional[str] = None, + fee: uint64 = uint64(0), + fee_for_cat: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> Dict: + request: Dict[str, Any] = { + "wallet_type": "dao_wallet", + "mode": mode, + "treasury_id": treasury_id, + "dao_rules": dao_rules, + "amount_of_cats": amount_of_cats, + "filter_amount": filter_amount, + "name": name, + "fee": fee, + "fee_for_cat": fee_for_cat, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("create_new_wallet", request) + return response + + async def dao_get_treasury_id( + self, + wallet_id: int, + ) -> Dict: + request: Dict[str, Any] = {"wallet_id": wallet_id} + response = await self.fetch("dao_get_treasury_id", request) + return response + + async def dao_get_rules( + self, + wallet_id: int, + ) -> Dict: + request: Dict[str, Any] = {"wallet_id": wallet_id} + response = await self.fetch("dao_get_rules", request) + return response + + async def dao_create_proposal( + self, + wallet_id: int, + proposal_type: str, + tx_config: TXConfig, + additions: Optional[List[Dict]] = None, + amount: Optional[uint64] = None, + inner_address: Optional[str] = None, + asset_id: Optional[str] = None, + cat_target_address: Optional[str] = None, + vote_amount: Optional[int] = None, + new_dao_rules: Optional[Dict[str, Optional[uint64]]] = None, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> Dict: + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "proposal_type": proposal_type, + "additions": additions, + "amount": amount, + "inner_address": inner_address, + "asset_id": asset_id, + "cat_target_address": cat_target_address, + "vote_amount": vote_amount, + "new_dao_rules": new_dao_rules, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + + response = await self.fetch("dao_create_proposal", request) + return response + + async def dao_get_proposal_state(self, wallet_id: int, proposal_id: str): + request: Dict[str, Any] = {"wallet_id": wallet_id, "proposal_id": proposal_id} + response = await self.fetch("dao_get_proposal_state", request) + return response + + async def dao_parse_proposal(self, wallet_id: int, proposal_id: str): + request: Dict[str, Any] = {"wallet_id": wallet_id, "proposal_id": proposal_id} + response = await self.fetch("dao_parse_proposal", request) + return response + + async def dao_vote_on_proposal( + self, + wallet_id: int, + proposal_id: str, + vote_amount: uint64, + tx_config: TXConfig, + is_yes_vote: bool = True, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "proposal_id": proposal_id, + "vote_amount": vote_amount, + "is_yes_vote": is_yes_vote, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_vote_on_proposal", request) + return response + + async def dao_get_proposals(self, wallet_id: int, include_closed: bool = True): + request: Dict[str, Any] = {"wallet_id": wallet_id, "include_closed": include_closed} + response = await self.fetch("dao_get_proposals", request) + return response + + async def dao_close_proposal( + self, + wallet_id: int, + proposal_id: str, + tx_config: TXConfig, + self_destruct: bool = None, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "proposal_id": proposal_id, + "self_destruct": self_destruct, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_close_proposal", request) + return response + + async def dao_free_coins_from_finished_proposals( + self, + wallet_id: int, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_free_coins_from_finished_proposals", request) + return response + + async def dao_get_treasury_balance(self, wallet_id: int): + request: Dict[str, Any] = {"wallet_id": wallet_id} + response = await self.fetch("dao_get_treasury_balance", request) + return response + + async def dao_add_funds_to_treasury( + self, + wallet_id: int, + funding_wallet_id: int, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "funding_wallet_id": funding_wallet_id, + "amount": amount, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_add_funds_to_treasury", request) + return response + + async def dao_send_to_lockup( + self, + wallet_id: int, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "amount": amount, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_send_to_lockup", request) + return response + + async def dao_exit_lockup( + self, + wallet_id: int, + tx_config: TXConfig, + coins: Optional[List[Dict]] = None, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ): + request: Dict[str, Any] = { + "wallet_id": wallet_id, + "coins": coins, + "fee": fee, + "extra_conditions": list(extra_conditions), + **tx_config.to_json_dict(), + } + response = await self.fetch("dao_exit_lockup", request) + return response + + async def dao_adjust_filter_level(self, wallet_id: int, filter_level: int): + request: Dict[str, Any] = {"wallet_id": wallet_id, "filter_level": filter_level} + response = await self.fetch("dao_adjust_filter_level", request) + return response + async def vc_mint( self, did_id: bytes32, diff --git a/chia/wallet/cat_wallet/cat_wallet.py b/chia/wallet/cat_wallet/cat_wallet.py index f90c9ce13b23..70f3288247b3 100644 --- a/chia/wallet/cat_wallet/cat_wallet.py +++ b/chia/wallet/cat_wallet/cat_wallet.py @@ -813,7 +813,7 @@ async def generate_signed_transaction( if not ignore_max_send_amount: max_send = await self.get_max_send_amount() if payment_sum > max_send: - raise ValueError(f"Can't send more than {max_send} mojos in a single transaction") + raise ValueError(f" Insufficient funds. Your max amount is {max_send} mojos in a single transaction.") unsigned_spend_bundle, chia_tx = await self.generate_unsigned_spendbundle( payments, tx_config, @@ -870,7 +870,6 @@ async def generate_signed_transaction( valid_times=parse_timelock_info(extra_conditions), ) ) - return tx_list async def add_lineage(self, name: bytes32, lineage: Optional[LineageProof]) -> None: diff --git a/chia/wallet/cat_wallet/dao_cat_info.py b/chia/wallet/cat_wallet/dao_cat_info.py new file mode 100644 index 000000000000..a40a8cafa042 --- /dev/null +++ b/chia/wallet/cat_wallet/dao_cat_info.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.util.ints import uint64 +from chia.util.streamable import Streamable, streamable + + +@streamable +@dataclass(frozen=True) +class LockedCoinInfo(Streamable): + coin: Coin + inner_puzzle: Program # This is the lockup puzzle, not the lockup_puzzle's inner_puzzle + active_votes: List[Optional[bytes32]] + + +@streamable +@dataclass(frozen=True) +class DAOCATInfo(Streamable): + dao_wallet_id: uint64 + free_cat_wallet_id: uint64 + limitations_program_hash: bytes32 + my_tail: Optional[Program] # this is the program + locked_coins: List[LockedCoinInfo] diff --git a/chia/wallet/cat_wallet/dao_cat_wallet.py b/chia/wallet/cat_wallet/dao_cat_wallet.py new file mode 100644 index 000000000000..85e186666ef5 --- /dev/null +++ b/chia/wallet/cat_wallet/dao_cat_wallet.py @@ -0,0 +1,677 @@ +from __future__ import annotations + +import logging +import time +from secrets import token_bytes +from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Set, Tuple, cast + +from blspy import G1Element + +from chia.server.ws_connection import WSChiaConnection +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.spend_bundle import SpendBundle +from chia.util.byte_types import hexstr_to_bytes +from chia.util.ints import uint32, uint64, uint128 +from chia.wallet.cat_wallet.cat_utils import ( + CAT_MOD, + SpendableCAT, + construct_cat_puzzle, + unsigned_spend_bundle_for_spendable_cats, +) +from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.dao_cat_info import DAOCATInfo, LockedCoinInfo +from chia.wallet.cat_wallet.lineage_store import CATLineageStore +from chia.wallet.conditions import Condition, parse_timelock_info +from chia.wallet.dao_wallet.dao_utils import ( + add_proposal_to_active_list, + get_active_votes_from_lockup_puzzle, + get_finished_state_inner_puzzle, + get_innerpuz_from_lockup_puzzle, + get_lockup_puzzle, +) +from chia.wallet.lineage_proof import LineageProof +from chia.wallet.payment import Payment +from chia.wallet.transaction_record import TransactionRecord +from chia.wallet.util.curry_and_treehash import calculate_hash_of_quoted_mod_hash +from chia.wallet.util.transaction_type import TransactionType +from chia.wallet.util.tx_config import CoinSelectionConfig, TXConfig +from chia.wallet.util.wallet_sync_utils import fetch_coin_spend +from chia.wallet.util.wallet_types import WalletType +from chia.wallet.wallet import Wallet +from chia.wallet.wallet_coin_record import WalletCoinRecord +from chia.wallet.wallet_info import WalletInfo + +if TYPE_CHECKING: + from chia.wallet.wallet_state_manager import WalletStateManager + +CAT_MOD_HASH = CAT_MOD.get_tree_hash() +CAT_MOD_HASH_HASH = Program.to(CAT_MOD_HASH).get_tree_hash() +QUOTED_MOD_HASH = calculate_hash_of_quoted_mod_hash(CAT_MOD_HASH) + + +class DAOCATWallet: + if TYPE_CHECKING: + from chia.wallet.wallet_protocol import WalletProtocol + + _protocol_check: ClassVar[WalletProtocol[DAOCATInfo]] = cast("DAOCATWallet", None) + + wallet_state_manager: Any + log: logging.Logger + wallet_info: WalletInfo + dao_cat_info: DAOCATInfo + standard_wallet: Wallet + cost_of_single_tx: Optional[int] + lineage_store: CATLineageStore + + @classmethod + def type(cls) -> WalletType: + return WalletType.DAO_CAT + + @staticmethod + async def create( + wallet_state_manager: WalletStateManager, + wallet: Wallet, + wallet_info: WalletInfo, + ) -> DAOCATWallet: + self = DAOCATWallet() + self.log = logging.getLogger(__name__) + + self.cost_of_single_tx = None + self.wallet_state_manager = wallet_state_manager + self.wallet_info = wallet_info + self.standard_wallet = wallet + try: + self.dao_cat_info = DAOCATInfo.from_bytes(hexstr_to_bytes(self.wallet_info.data)) + self.lineage_store = await CATLineageStore.create(self.wallet_state_manager.db_wrapper, self.get_asset_id()) + except AssertionError as e: # pragma: no cover + self.log.error(f"Error creating DAO CAT wallet: {e}") + + return self + + @staticmethod + async def get_or_create_wallet_for_cat( + wallet_state_manager: Any, + wallet: Wallet, + limitations_program_hash_hex: str, + name: Optional[str] = None, + ) -> DAOCATWallet: + self = DAOCATWallet() + self.cost_of_single_tx = None + self.standard_wallet = wallet + self.log = logging.getLogger(__name__) + + limitations_program_hash_hex = bytes32.from_hexstr(limitations_program_hash_hex).hex() # Normalize the format + + dao_wallet_id = None + free_cat_wallet_id = None + for id, w in wallet_state_manager.wallets.items(): + if w.type() == DAOCATWallet.type(): + assert isinstance(w, DAOCATWallet) + if w.get_asset_id() == limitations_program_hash_hex: + self.log.warning("Not creating wallet for already existing DAO CAT wallet") + return w + elif w.type() == CATWallet.type(): + assert isinstance(w, CATWallet) + if w.get_asset_id() == limitations_program_hash_hex: + free_cat_wallet_id = w.id() + assert free_cat_wallet_id is not None + for id, w in wallet_state_manager.wallets.items(): + if w.type() == WalletType.DAO: + self.log.info(f"FOUND DAO WALLET: {w}") + self.log.info(f"ALL WALLETS: {wallet_state_manager.wallets}") + if w.get_cat_wallet_id() == free_cat_wallet_id: + dao_wallet_id = w.id() + assert dao_wallet_id is not None + self.wallet_state_manager = wallet_state_manager + if name is None: + name = CATWallet.default_wallet_name_for_unknown_cat(limitations_program_hash_hex) + + limitations_program_hash = bytes32(hexstr_to_bytes(limitations_program_hash_hex)) + + self.dao_cat_info = DAOCATInfo( + dao_wallet_id, + uint64(free_cat_wallet_id), + limitations_program_hash, + None, + [], + ) + info_as_string = bytes(self.dao_cat_info).hex() + self.wallet_info = await wallet_state_manager.user_store.create_wallet(name, WalletType.DAO_CAT, info_as_string) + + self.lineage_store = await CATLineageStore.create(self.wallet_state_manager.db_wrapper, self.get_asset_id()) + await self.wallet_state_manager.add_new_wallet(self) + return self + + async def coin_added(self, coin: Coin, height: uint32, peer: WSChiaConnection, coin_data: Optional[Any]) -> None: + """Notification from wallet state manager that wallet has been received.""" + self.log.info(f"DAO CAT wallet has been notified that {coin} was added") + wallet_node: Any = self.wallet_state_manager.wallet_node + parent_coin = (await wallet_node.get_coin_state([coin.parent_coin_info], peer, height))[0] + parent_spend = await fetch_coin_spend(height, parent_coin.coin, peer) + uncurried = parent_spend.puzzle_reveal.uncurry() + cat_inner = uncurried[1].at("rrf") + active_votes_list: List[Optional[bytes32]] = [] + + record = await self.wallet_state_manager.puzzle_store.get_derivation_record_for_puzzle_hash(coin.puzzle_hash) + if record: + inner_puzzle: Optional[Program] = self.standard_wallet.puzzle_for_pk(record.pubkey) + else: + inner_puzzle = get_innerpuz_from_lockup_puzzle(cat_inner) + assert isinstance(inner_puzzle, Program) + active_votes_list = get_active_votes_from_lockup_puzzle(cat_inner) + active_votes_list = [x.as_atom() for x in active_votes_list.as_iter()] + + if parent_spend.coin.puzzle_hash == coin.puzzle_hash: + # shortcut, works for change + lockup_puz = cat_inner + else: + solution = parent_spend.solution.to_program().first() + if solution.first() == Program.to(0): + # No vote is being added so inner puz stays the same + try: + removals = solution.at("rrrf") + if removals != Program.to(0): + for removal in removals.as_iter(): + active_votes_list.remove(bytes32(removal.as_atom())) + except Exception: + pass + else: + new_vote = solution.at("rrrf") + active_votes_list.insert(0, bytes32(new_vote.as_atom())) + + lockup_puz = get_lockup_puzzle( + self.dao_cat_info.limitations_program_hash, + active_votes_list, + inner_puzzle, + ) + + new_cat_puzhash = construct_cat_puzzle( + CAT_MOD, self.dao_cat_info.limitations_program_hash, lockup_puz + ).get_tree_hash() + + if new_cat_puzhash != coin.puzzle_hash: # pragma: no cover + raise ValueError(f"Cannot add coin - incorrect lockup puzzle: {coin}") + + lineage_proof = LineageProof(coin.parent_coin_info, lockup_puz.get_tree_hash(), uint64(coin.amount)) + await self.add_lineage(coin.name(), lineage_proof) + + # add the new coin to the list of locked coins and remove the spent coin + locked_coins = [x for x in self.dao_cat_info.locked_coins if x.coin != parent_spend.coin] + new_info = LockedCoinInfo(coin, lockup_puz, active_votes_list) + if new_info not in locked_coins: + locked_coins.append(LockedCoinInfo(coin, lockup_puz, active_votes_list)) + dao_cat_info: DAOCATInfo = DAOCATInfo( + self.dao_cat_info.dao_wallet_id, + self.dao_cat_info.free_cat_wallet_id, + self.dao_cat_info.limitations_program_hash, + self.dao_cat_info.my_tail, + locked_coins, + ) + await self.save_info(dao_cat_info) + + async def add_lineage(self, name: bytes32, lineage: Optional[LineageProof]) -> None: + """ + Lineage proofs are stored as a list of parent coins and the lineage proof you will need if they are the + parent of the coin you are trying to spend. 'If I'm your parent, here's the info you need to spend yourself' + """ + self.log.info(f"Adding parent {name.hex()}: {lineage}") + if lineage is not None: + await self.lineage_store.add_lineage_proof(name, lineage) + + async def get_lineage_proof_for_coin(self, coin: Coin) -> Optional[LineageProof]: + return await self.lineage_store.get_lineage_proof(coin.parent_coin_info) + + async def remove_lineage(self, name: bytes32) -> None: # pragma: no cover + self.log.info(f"Removing parent {name} (probably had a non-CAT parent)") + await self.lineage_store.remove_lineage_proof(name) + + async def advanced_select_coins(self, amount: uint64, proposal_id: bytes32) -> List[LockedCoinInfo]: + coins = [] + s = 0 + for coin in self.dao_cat_info.locked_coins: + compatible = True + for active_vote in coin.active_votes: + if active_vote == proposal_id: # pragma: no cover + compatible = False + break + if compatible: + coins.append(coin) + s += coin.coin.amount + if s >= amount: + break + if s < amount: # pragma: no cover + raise ValueError( + "We do not have enough CATs in Voting Mode right now. " + "Please convert some more or try again with permission to convert." + ) + return coins + + def id(self) -> uint32: + return self.wallet_info.id + + async def create_vote_spend( + self, + amount: uint64, + proposal_id: bytes32, + is_yes_vote: bool, + proposal_puzzle: Optional[Program] = None, + ) -> SpendBundle: + coins: List[LockedCoinInfo] = await self.advanced_select_coins(amount, proposal_id) + running_sum = 0 # this will be used for change calculation + change = sum(c.coin.amount for c in coins) - amount + extra_delta, limitations_solution = 0, Program.to([]) + limitations_program_reveal = Program.to([]) + spendable_cat_list = [] + dao_wallet = self.wallet_state_manager.wallets[self.dao_cat_info.dao_wallet_id] + if proposal_puzzle is None: # pragma: no cover + proposal_puzzle = dao_wallet.get_proposal_puzzle(proposal_id) + assert proposal_puzzle is not None + for lci in coins: + coin = lci.coin + vote_info = 0 + new_innerpuzzle = add_proposal_to_active_list(lci.inner_puzzle, proposal_id) + assert new_innerpuzzle is not None + standard_inner_puz = get_innerpuz_from_lockup_puzzle(new_innerpuzzle) + assert isinstance(standard_inner_puz, Program) + # add_proposal_to_active_list also verifies that the lci.inner_puzzle is accurate + # We must create either: one coin with the new puzzle and all our value + # OR + # a coin with the new puzzle and part of our amount AND a coin with our current puzzle and the change + # We must also create a puzzle announcement which announces the following: + # message = (sha256tree (list new_proposal_vote_id_or_removal_id vote_amount vote_info my_id)) + message = Program.to([proposal_id, amount, is_yes_vote, coin.name()]).get_tree_hash() + vote_amounts_list = [] + voting_coin_id_list = [] + previous_votes_list = [] + lockup_innerpuz_list = [] + if running_sum + coin.amount <= amount: + vote_amount = coin.amount + running_sum = running_sum + coin.amount + primaries = [ + Payment( + new_innerpuzzle.get_tree_hash(), + uint64(vote_amount), + [standard_inner_puz.get_tree_hash()], + ) + ] + message = Program.to([proposal_id, vote_amount, is_yes_vote, coin.name()]).get_tree_hash() + puzzle_announcements = set([message]) + inner_solution = self.standard_wallet.make_solution( + primaries=primaries, puzzle_announcements=puzzle_announcements + ) + else: + vote_amount = amount - running_sum + running_sum = running_sum + coin.amount + primaries = [ + Payment( + new_innerpuzzle.get_tree_hash(), + uint64(vote_amount), + [standard_inner_puz.get_tree_hash()], + ), + ] + if change > 0: + primaries.append( + Payment( + lci.inner_puzzle.get_tree_hash(), + uint64(change), + [lci.inner_puzzle.get_tree_hash()], + ) + ) + message = Program.to([proposal_id, vote_amount, is_yes_vote, coin.name()]).get_tree_hash() + puzzle_announcements = set([message]) + inner_solution = self.standard_wallet.make_solution( + primaries=primaries, puzzle_announcements=puzzle_announcements + ) + if is_yes_vote: + vote_info = 1 + vote_amounts_list.append(vote_amount) + voting_coin_id_list.append(coin.name()) + previous_votes_list.append(get_active_votes_from_lockup_puzzle(lci.inner_puzzle)) + lockup_innerpuz_list.append(get_innerpuz_from_lockup_puzzle(lci.inner_puzzle)) + solution = Program.to( + [ + coin.name(), + inner_solution, + coin.amount, + proposal_id, + proposal_puzzle.get_tree_hash(), + vote_info, + vote_amount, + lci.inner_puzzle.get_tree_hash(), + 0, + ] + ) + lineage_proof = await self.get_lineage_proof_for_coin(coin) + assert lineage_proof is not None + new_spendable_cat = SpendableCAT( + coin, + self.dao_cat_info.limitations_program_hash, + lci.inner_puzzle, + solution, + limitations_solution=limitations_solution, + extra_delta=extra_delta, + lineage_proof=lineage_proof, + limitations_program_reveal=limitations_program_reveal, + ) + spendable_cat_list.append(new_spendable_cat) + + cat_spend_bundle = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, spendable_cat_list) + spend_bundle = await self.wallet_state_manager.sign_transaction(cat_spend_bundle.coin_spends) + assert isinstance(spend_bundle, SpendBundle) + return spend_bundle + + async def enter_dao_cat_voting_mode( + self, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> List[TransactionRecord]: + """ + Enter existing CATs for the DAO into voting mode + """ + # check there are enough cats to convert + cat_wallet = self.wallet_state_manager.wallets[self.dao_cat_info.free_cat_wallet_id] + cat_balance = await cat_wallet.get_spendable_balance() + if cat_balance < amount: # pragma: no cover + raise ValueError(f"Insufficient CAT balance. Requested: {amount} Available: {cat_balance}") + # get the lockup puzzle hash + lockup_puzzle = await self.get_new_puzzle() + # create the cat spend + txs: List[TransactionRecord] = await cat_wallet.generate_signed_transaction( + [amount], + [lockup_puzzle.get_tree_hash()], + tx_config, + fee=fee, + extra_conditions=extra_conditions, + ) + cat_puzzle_hash: bytes32 = construct_cat_puzzle( + CAT_MOD, self.dao_cat_info.limitations_program_hash, lockup_puzzle + ).get_tree_hash() + await self.wallet_state_manager.add_interested_puzzle_hashes([cat_puzzle_hash], [self.id()]) + return txs + + async def exit_vote_state( + self, + coins: List[LockedCoinInfo], + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + extra_delta, limitations_solution = 0, Program.to([]) + limitations_program_reveal = Program.to([]) + spendable_cat_list = [] + total_amt = 0 + spent_coins = [] + for lci in coins: + coin = lci.coin + if tx_config.reuse_puzhash: # pragma: no cover + new_inner_puzhash = await self.standard_wallet.get_puzzle_hash(new=False) + else: + new_inner_puzhash = await self.standard_wallet.get_puzzle_hash(new=True) + + # CREATE_COIN new_puzzle coin.amount + primaries = [ + Payment( + new_inner_puzhash, + uint64(coin.amount), + [new_inner_puzhash], + ), + ] + total_amt += coin.amount + inner_solution = self.standard_wallet.make_solution( + primaries=primaries, + ) + # Create the solution using only the values needed for exiting the lockup mode (my_id = 0) + solution = Program.to( + [ + 0, # my_id + inner_solution, + coin.amount, + 0, # new_proposal_vote_id_or_removal_id + 0, # proposal_innerpuzhash + 0, # vote_info + 0, # vote_amount + 0, # my_inner_puzhash + ] + ) + lineage_proof = await self.get_lineage_proof_for_coin(coin) + assert lineage_proof is not None + new_spendable_cat = SpendableCAT( + coin, + self.dao_cat_info.limitations_program_hash, + lci.inner_puzzle, + solution, + limitations_solution=limitations_solution, + extra_delta=extra_delta, + lineage_proof=lineage_proof, + limitations_program_reveal=limitations_program_reveal, + ) + spendable_cat_list.append(new_spendable_cat) + spent_coins.append(coin) + + cat_spend_bundle = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, spendable_cat_list) + spend_bundle: SpendBundle = await self.wallet_state_manager.sign_transaction(cat_spend_bundle.coin_spends) + + if fee > 0: # pragma: no cover + chia_tx = await self.standard_wallet.create_tandem_xch_tx( + fee, + tx_config, + ) + assert chia_tx.spend_bundle is not None + full_spend = SpendBundle.aggregate([spend_bundle, chia_tx.spend_bundle]) + else: + full_spend = spend_bundle + + record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=new_inner_puzhash, + amount=uint64(total_amt), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=full_spend, + additions=full_spend.additions(), + removals=full_spend.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + + # TODO: Hack to just drop coins from locked list. Need to catch this event in WSM to + # check if we're adding CATs from our DAO CAT wallet and update the locked coin list + # accordingly + new_locked_coins = [x for x in self.dao_cat_info.locked_coins if x.coin not in spent_coins] + dao_cat_info: DAOCATInfo = DAOCATInfo( + self.dao_cat_info.dao_wallet_id, + self.dao_cat_info.free_cat_wallet_id, + self.dao_cat_info.limitations_program_hash, + self.dao_cat_info.my_tail, + new_locked_coins, + ) + await self.save_info(dao_cat_info) + return record + + async def remove_active_proposal( + self, proposal_id_list: List[bytes32], tx_config: TXConfig, fee: uint64 = uint64(0) + ) -> SpendBundle: + locked_coins: List[Tuple[LockedCoinInfo, List[bytes32]]] = [] + for lci in self.dao_cat_info.locked_coins: + my_finished_proposals = [] + for active_vote in lci.active_votes: + if active_vote in proposal_id_list: + my_finished_proposals.append(active_vote) + if my_finished_proposals: + locked_coins.append((lci, my_finished_proposals)) + extra_delta, limitations_solution = 0, Program.to([]) + limitations_program_reveal = Program.to([]) + spendable_cat_list = [] + + for lci_proposals_tuple in locked_coins: + proposal_innerpuzhashes = [] + coin = lci_proposals_tuple[0].coin + lci = lci_proposals_tuple[0] + proposals = lci_proposals_tuple[1] + for proposal_id in proposals: + INNERPUZ = get_finished_state_inner_puzzle(proposal_id) + proposal_innerpuzhashes.append(INNERPUZ) + # new_innerpuzzle = await cat_wallet.get_new_inner_puzzle() + # my_id ; if my_id is 0 we do the return to return_address (exit voting mode) spend case + # inner_solution + # my_amount + # new_proposal_vote_id_or_removal_id ; if we're exiting fully, set this to 0 + # proposal_curry_vals + # vote_info + # vote_amount + # my_puzhash + solution = Program.to( + [ + 0, + 0, + coin.amount, + proposals, + 0, + 0, + 0, + 0, + 0, + ] + ) + lineage_proof = await self.get_lineage_proof_for_coin(coin) + assert lineage_proof is not None + new_spendable_cat = SpendableCAT( + coin, + self.dao_cat_info.limitations_program_hash, + lci.inner_puzzle, + solution, + limitations_solution=limitations_solution, + extra_delta=extra_delta, + lineage_proof=lineage_proof, + limitations_program_reveal=limitations_program_reveal, + ) + spendable_cat_list.append(new_spendable_cat) + + cat_spend_bundle = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, spendable_cat_list) + spend_bundle = await self.wallet_state_manager.sign_transaction(cat_spend_bundle.coin_spends) + + if fee > 0: # pragma: no cover + chia_tx = await self.standard_wallet.create_tandem_xch_tx(fee, tx_config=tx_config) + assert chia_tx.spend_bundle is not None + full_spend = SpendBundle.aggregate([spend_bundle, chia_tx.spend_bundle]) + else: + full_spend = spend_bundle + + return full_spend + + def get_asset_id(self) -> str: + return bytes(self.dao_cat_info.limitations_program_hash).hex() + + async def get_new_inner_hash(self, tx_config: TXConfig) -> bytes32: + puzzle = await self.get_new_inner_puzzle(tx_config) + return puzzle.get_tree_hash() + + async def get_new_inner_puzzle(self, tx_config: TXConfig) -> Program: + return await self.standard_wallet.get_puzzle(new=not tx_config.reuse_puzhash) + + async def get_new_puzzle(self) -> Program: + record = await self.wallet_state_manager.get_unused_derivation_record(self.id()) + inner_puzzle = self.standard_wallet.puzzle_for_pk(record.pubkey) + puzzle = get_lockup_puzzle( + self.dao_cat_info.limitations_program_hash, + [], + inner_puzzle, + ) + cat_puzzle: Program = construct_cat_puzzle(CAT_MOD, self.dao_cat_info.limitations_program_hash, puzzle) + await self.wallet_state_manager.add_interested_puzzle_hashes([puzzle.get_tree_hash()], [self.id()]) + await self.wallet_state_manager.add_interested_puzzle_hashes([cat_puzzle.get_tree_hash()], [self.id()]) + return puzzle + + async def get_new_puzzlehash(self) -> bytes32: + puzzle = await self.get_new_puzzle() + return puzzle.get_tree_hash() + + def puzzle_for_pk(self, pubkey: G1Element) -> Program: + inner_puzzle = self.standard_wallet.puzzle_for_pk(pubkey) + puzzle = get_lockup_puzzle( + self.dao_cat_info.limitations_program_hash, + [], + inner_puzzle, + ) + cat_puzzle: Program = construct_cat_puzzle(CAT_MOD, self.dao_cat_info.limitations_program_hash, puzzle) + return cat_puzzle + + def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32: + puzzle = self.puzzle_for_pk(pubkey) + return puzzle.get_tree_hash() + + def require_derivation_paths(self) -> bool: + return True + + async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool: + raise NotImplementedError("Method not implemented for DAO CAT Wallet") # pragma: no cover + + async def get_spendable_balance(self, records: Optional[Set[WalletCoinRecord]] = None) -> uint128: + return uint128(0) + + async def get_confirmed_balance(self, record_list: Optional[Set[WalletCoinRecord]] = None) -> uint128: + amount = 0 + for coin in self.dao_cat_info.locked_coins: + amount += coin.coin.amount + return uint128(amount) + + async def get_unconfirmed_balance(self, unspent_records: Optional[Set[WalletCoinRecord]] = None) -> uint128: + return uint128(0) + + async def get_pending_change_balance(self) -> uint64: + return uint64(0) + + async def select_coins( + self, + amount: uint64, + coin_selection_config: CoinSelectionConfig, + ) -> Set[Coin]: + return set() + + async def get_max_send_amount(self, unspent_records: Optional[Set[WalletCoinRecord]] = None) -> uint128: + return uint128(0) + + async def get_votable_balance( + self, + proposal_id: Optional[bytes32] = None, + include_free_cats: bool = True, + ) -> uint64: + balance = 0 + for coin in self.dao_cat_info.locked_coins: + if proposal_id is not None: + compatible = True + for active_vote in coin.active_votes: + if active_vote == proposal_id: + compatible = False + break + if compatible: + balance += coin.coin.amount + else: + balance += coin.coin.amount + if include_free_cats: + cat_wallet = self.wallet_state_manager.wallets[self.dao_cat_info.free_cat_wallet_id] + cat_balance = await cat_wallet.get_spendable_balance() + balance += cat_balance + return uint64(balance) + + async def save_info(self, dao_cat_info: DAOCATInfo) -> None: + self.dao_cat_info = dao_cat_info + current_info = self.wallet_info + data_str = bytes(dao_cat_info).hex() + wallet_info = WalletInfo(current_info.id, current_info.name, current_info.type, data_str) + self.wallet_info = wallet_info + await self.wallet_state_manager.user_store.update_wallet(wallet_info) + + def get_name(self) -> str: + return self.wallet_info.name diff --git a/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp b/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp new file mode 100644 index 000000000000..77975679f9b5 --- /dev/null +++ b/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp @@ -0,0 +1,42 @@ +; This is a TAIL for use with cat.clvm. +; +; This checker allows new CATs to be created if they have a particular coin id as parent +; +; The genesis_id is curried in, making this lineage_check program unique and giving the CAT it's uniqueness +(mod ( + GENESIS_ID + MINT_LAUNCHER_PUZZLE_HASH + Truths + parent_is_cat + lineage_proof + delta + inner_conditions + ( ; solution + parent_parent_id + parent_amount + ) + ) + + (include cat_truths.clib) + (include curry-and-treehash.clib) + + (if delta + (x) + (if (= (my_parent_cat_truth Truths) GENESIS_ID) + () + (if + (= + (my_parent_cat_truth Truths) + (sha256 + parent_parent_id + MINT_LAUNCHER_PUZZLE_HASH + parent_amount + ) + ) + () + (x) + ) + ) + ) + +) diff --git a/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp.hex b/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp.hex new file mode 100644 index 000000000000..99dcc60450dd --- /dev/null +++ b/chia/wallet/cat_wallet/puzzles/genesis_by_coin_id_or_singleton.clsp.hex @@ -0,0 +1 @@ +ff02ffff03ff5fffff01ff0880ffff01ff02ffff03ffff09ff5bff0280ff80ffff01ff02ffff03ffff09ff5bffff0bff82027fff05ff82057f8080ff80ffff01ff088080ff018080ff018080ff0180 diff --git a/chia/wallet/dao_wallet/dao_info.py b/chia/wallet/dao_wallet/dao_info.py new file mode 100644 index 000000000000..4ae34bb47110 --- /dev/null +++ b/chia/wallet/dao_wallet/dao_info.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Tuple + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.util.ints import uint32, uint64 +from chia.util.streamable import Streamable, streamable +from chia.wallet.lineage_proof import LineageProof + + +@streamable +@dataclass(frozen=True) +class ProposalInfo(Streamable): + proposal_id: bytes32 # this is launcher_id + inner_puzzle: Program + amount_voted: uint64 + yes_votes: uint64 + current_coin: Coin + current_innerpuz: Optional[Program] + timer_coin: Optional[Coin] # if this is None then the proposal has finished + singleton_block_height: uint32 # Block height that current proposal singleton coin was created in + passed: Optional[bool] + closed: Optional[bool] + + +@streamable +@dataclass(frozen=True) +class DAOInfo(Streamable): + treasury_id: bytes32 + cat_wallet_id: uint32 + dao_cat_wallet_id: uint32 + proposals_list: List[ProposalInfo] + parent_info: List[Tuple[bytes32, Optional[LineageProof]]] # {coin.name(): LineageProof} + current_treasury_coin: Optional[Coin] + current_treasury_innerpuz: Optional[Program] + singleton_block_height: uint32 # the block height that the current treasury singleton was created in + filter_below_vote_amount: uint64 # we ignore proposals with fewer votes than this - defaults to 1 + assets: List[Optional[bytes32]] + current_height: uint64 + + +@streamable +@dataclass(frozen=True) +class DAORules(Streamable): + proposal_timelock: uint64 + soft_close_length: uint64 + attendance_required: uint64 + pass_percentage: uint64 + self_destruct_length: uint64 + oracle_spend_delay: uint64 + proposal_minimum_amount: uint64 + + +class ProposalType(Enum): + SPEND = "s" + UPDATE = "u" + MINT = "m" diff --git a/chia/wallet/dao_wallet/dao_utils.py b/chia/wallet/dao_wallet/dao_utils.py new file mode 100644 index 000000000000..a43d2c0946ec --- /dev/null +++ b/chia/wallet/dao_wallet/dao_utils.py @@ -0,0 +1,809 @@ +from __future__ import annotations + +import logging +from itertools import chain +from typing import Iterator, List, Optional, Tuple + +from clvm.EvalError import EvalError + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.util.ints import uint64 +from chia.wallet.cat_wallet.cat_utils import CAT_MOD, CAT_MOD_HASH, construct_cat_puzzle +from chia.wallet.dao_wallet.dao_info import DAORules, ProposalType +from chia.wallet.puzzles.load_clvm import load_clvm +from chia.wallet.puzzles.p2_delegated_puzzle_or_hidden_puzzle import MOD +from chia.wallet.singleton import get_singleton_struct_for_id +from chia.wallet.uncurried_puzzle import UncurriedPuzzle + +SINGLETON_MOD: Program = load_clvm("singleton_top_layer_v1_1.clsp") +SINGLETON_MOD_HASH: bytes32 = SINGLETON_MOD.get_tree_hash() +SINGLETON_LAUNCHER: Program = load_clvm("singleton_launcher.clsp") +SINGLETON_LAUNCHER_HASH: bytes32 = SINGLETON_LAUNCHER.get_tree_hash() +DAO_LOCKUP_MOD: Program = load_clvm("dao_lockup.clsp") +DAO_LOCKUP_MOD_HASH: bytes32 = DAO_LOCKUP_MOD.get_tree_hash() +DAO_PROPOSAL_TIMER_MOD: Program = load_clvm("dao_proposal_timer.clsp") +DAO_PROPOSAL_TIMER_MOD_HASH: bytes32 = DAO_PROPOSAL_TIMER_MOD.get_tree_hash() +DAO_PROPOSAL_MOD: Program = load_clvm("dao_proposal.clsp") +DAO_PROPOSAL_MOD_HASH: bytes32 = DAO_PROPOSAL_MOD.get_tree_hash() +DAO_PROPOSAL_VALIDATOR_MOD: Program = load_clvm("dao_proposal_validator.clsp") +DAO_PROPOSAL_VALIDATOR_MOD_HASH: bytes32 = DAO_PROPOSAL_VALIDATOR_MOD.get_tree_hash() +DAO_TREASURY_MOD: Program = load_clvm("dao_treasury.clsp") +DAO_TREASURY_MOD_HASH: bytes32 = DAO_TREASURY_MOD.get_tree_hash() +SPEND_P2_SINGLETON_MOD: Program = load_clvm("dao_spend_p2_singleton_v2.clsp") +SPEND_P2_SINGLETON_MOD_HASH: bytes32 = SPEND_P2_SINGLETON_MOD.get_tree_hash() +DAO_FINISHED_STATE: Program = load_clvm("dao_finished_state.clsp") +DAO_FINISHED_STATE_HASH: bytes32 = DAO_FINISHED_STATE.get_tree_hash() +DAO_CAT_TAIL: Program = load_clvm( + "genesis_by_coin_id_or_singleton.clsp", package_or_requirement="chia.wallet.cat_wallet.puzzles" +) +DAO_CAT_TAIL_HASH: bytes32 = DAO_CAT_TAIL.get_tree_hash() +DAO_CAT_LAUNCHER: Program = load_clvm("dao_cat_launcher.clsp") +P2_SINGLETON_MOD: Program = load_clvm("p2_singleton_via_delegated_puzzle.clsp") +P2_SINGLETON_MOD_HASH: bytes32 = P2_SINGLETON_MOD.get_tree_hash() +DAO_UPDATE_PROPOSAL_MOD: Program = load_clvm("dao_update_proposal.clsp") +DAO_UPDATE_PROPOSAL_MOD_HASH: bytes32 = DAO_UPDATE_PROPOSAL_MOD.get_tree_hash() +DAO_CAT_EVE: Program = load_clvm("dao_cat_eve.clsp") +P2_SINGLETON_AGGREGATOR_MOD: Program = load_clvm("p2_singleton_aggregator.clsp") + +log = logging.Logger(__name__) + + +def create_cat_launcher_for_singleton_id(id: bytes32) -> Program: + singleton_struct = get_singleton_struct_for_id(id) + return DAO_CAT_LAUNCHER.curry(singleton_struct) + + +def curry_cat_eve(next_puzzle_hash: bytes32) -> Program: + return DAO_CAT_EVE.curry(next_puzzle_hash) + + +def get_treasury_puzzle(dao_rules: DAORules, treasury_id: bytes32, cat_tail_hash: bytes32) -> Program: + singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + lockup_puzzle: Program = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + cat_tail_hash, + ) + proposal_self_hash = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + lockup_puzzle.get_tree_hash(), + cat_tail_hash, + treasury_id, + ).get_tree_hash() + + proposal_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + singleton_struct, + proposal_self_hash, + dao_rules.proposal_minimum_amount, + get_p2_singleton_puzzle( + treasury_id + ).get_tree_hash(), # TODO: let people set this later - for now a hidden feature + ) + puzzle = DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + proposal_validator, + dao_rules.proposal_timelock, + dao_rules.soft_close_length, + dao_rules.attendance_required, + dao_rules.pass_percentage, + dao_rules.self_destruct_length, + dao_rules.oracle_spend_delay, + ) + return puzzle + + +def get_proposal_validator(treasury_puz: Program, proposal_minimum_amount: uint64) -> Program: + _, uncurried_args = treasury_puz.uncurry() + validator: Program = uncurried_args.rest().first() + validator_args = validator.uncurry()[1] + ( + singleton_struct, + proposal_self_hash, + _, + p2_puzhash, + ) = validator_args.as_iter() + proposal_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + singleton_struct, + proposal_self_hash, + proposal_minimum_amount, + p2_puzhash, + ) + return proposal_validator + + +def get_update_proposal_puzzle(dao_rules: DAORules, proposal_validator: Program) -> Program: + validator_args = uncurry_proposal_validator(proposal_validator) + ( + singleton_struct, + proposal_self_hash, + _, + proposal_excess_puzhash, + ) = validator_args.as_iter() + update_proposal = DAO_UPDATE_PROPOSAL_MOD.curry( + DAO_TREASURY_MOD_HASH, + DAO_PROPOSAL_VALIDATOR_MOD_HASH, + singleton_struct, + proposal_self_hash, + dao_rules.proposal_minimum_amount, + proposal_excess_puzhash, + dao_rules.proposal_timelock, + dao_rules.soft_close_length, + dao_rules.attendance_required, + dao_rules.pass_percentage, + dao_rules.self_destruct_length, + dao_rules.oracle_spend_delay, + ) + return update_proposal + + +def get_dao_rules_from_update_proposal(puzzle: Program) -> DAORules: + mod, curried_args = puzzle.uncurry() + if mod != DAO_UPDATE_PROPOSAL_MOD: # pragma: no cover + raise ValueError("Not an update proposal.") + ( + _, + _, + _, + _, + proposal_minimum_amount, + _, + proposal_timelock, + soft_close_length, + attendance_required, + pass_percentage, + self_destruct_length, + oracle_spend_delay, + ) = curried_args.as_iter() + dao_rules = DAORules( + proposal_timelock.as_int(), + soft_close_length.as_int(), + attendance_required.as_int(), + pass_percentage.as_int(), + self_destruct_length.as_int(), + oracle_spend_delay.as_int(), + proposal_minimum_amount.as_int(), + ) + return dao_rules + + +def get_spend_p2_singleton_puzzle( + treasury_id: bytes32, xch_conditions: Optional[List], asset_conditions: Optional[List[Tuple]] # type: ignore +) -> Program: + # TODO: typecheck get_spend_p2_singleton_puzzle arguments + # TODO: add tests for get_spend_p2_singleton_puzzle: pass xch_conditions as Puzzle, List and ConditionWithArgs + # + + # CAT_MOD_HASH + # CONDITIONS ; XCH conditions, to be generated by the treasury + # LIST_OF_TAILHASH_CONDITIONS ; the delegated puzzlehash must be curried in to the proposal. + # ; Puzzlehash is only run in the last coin for that asset + # ; ((TAIL_HASH CONDITIONS) (TAIL_HASH CONDITIONS)... ) + # P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH + treasury_struct = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + puzzle: Program = SPEND_P2_SINGLETON_MOD.curry( + treasury_struct, + CAT_MOD_HASH, + xch_conditions, + asset_conditions, + P2_SINGLETON_MOD.curry(treasury_struct, P2_SINGLETON_AGGREGATOR_MOD).get_tree_hash(), + ) + return puzzle + + +def get_p2_singleton_puzzle(treasury_id: bytes32, asset_id: Optional[bytes32] = None) -> Program: + singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + inner_puzzle = P2_SINGLETON_MOD.curry(singleton_struct, P2_SINGLETON_AGGREGATOR_MOD) + if asset_id: + # CAT + puzzle = CAT_MOD.curry(CAT_MOD_HASH, asset_id, inner_puzzle) + return Program(puzzle) + else: + # XCH + return inner_puzzle + + +def get_p2_singleton_puzhash(treasury_id: bytes32, asset_id: Optional[bytes32] = None) -> bytes32: + puz = get_p2_singleton_puzzle(treasury_id, asset_id) + assert puz is not None + return puz.get_tree_hash() + + +def get_lockup_puzzle( + cat_tail_hash: bytes32, previous_votes_list: List[Optional[bytes32]], innerpuz: Optional[Program] +) -> Program: + self_hash: Program = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + cat_tail_hash, + ) + puzzle = self_hash.curry( + self_hash.get_tree_hash(), + previous_votes_list, # TODO: maybe format check this in this function + innerpuz, + ) + return puzzle + + +def add_proposal_to_active_list( + lockup_puzzle: Program, proposal_id: bytes32, inner_puzzle: Optional[Program] = None +) -> Program: + curried_args, c_a = uncurry_lockup(lockup_puzzle) + ( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_PUZHASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) = c_a.as_iter() + (SELF_HASH, ACTIVE_VOTES, INNERPUZ) = curried_args.as_iter() + new_active_votes = Program.to(proposal_id).cons(ACTIVE_VOTES) # (c proposal_id ACTIVE_VOTES) + if inner_puzzle is None: + inner_puzzle = INNERPUZ + return get_lockup_puzzle(CAT_TAIL_HASH, new_active_votes, inner_puzzle) + + +def get_active_votes_from_lockup_puzzle(lockup_puzzle: Program) -> Program: + curried_args, c_a = uncurry_lockup(lockup_puzzle) + ( + _SINGLETON_MOD_HASH, + _SINGLETON_LAUNCHER_HASH, + _DAO_FINISHED_STATE_HASH, + _CAT_MOD_HASH, + _CAT_TAIL_HASH, + ) = list(c_a.as_iter()) + ( + self_hash, + ACTIVE_VOTES, + _INNERPUZ, + ) = curried_args.as_iter() + return Program(ACTIVE_VOTES) + + +def get_innerpuz_from_lockup_puzzle(lockup_puzzle: Program) -> Optional[Program]: + try: + curried_args, c_a = uncurry_lockup(lockup_puzzle) + except Exception as e: # pragma: no cover + log.debug("Could not uncurry inner puzzle from lockup: %s", e) + return None + ( + _SINGLETON_MOD_HASH, + _SINGLETON_LAUNCHER_HASH, + _DAO_FINISHED_STATE_HASH, + _CAT_MOD_HASH, + _CAT_TAIL_HASH, + ) = list(c_a.as_iter()) + ( + self_hash, + _ACTIVE_VOTES, + INNERPUZ, + ) = list(curried_args.as_iter()) + return Program(INNERPUZ) + + +def get_proposal_puzzle( + *, + proposal_id: bytes32, + cat_tail_hash: bytes32, + treasury_id: bytes32, + votes_sum: uint64, + total_votes: uint64, + proposed_puzzle_hash: bytes32, +) -> Program: + """ + spend_or_update_flag can take on the following values, ranked from safest to most dangerous: + s for spend only + u for update only + d for dangerous (can do anything) + """ + lockup_puzzle: Program = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + cat_tail_hash, + ) + # SINGLETON_STRUCT ; (SINGLETON_MOD_HASH (SINGLETON_ID . LAUNCHER_PUZZLE_HASH)) + # PROPOSAL_TIMER_MOD_HASH ; proposal timer needs to know which proposal created it, AND + # CAT_MOD_HASH + # DAO_FINISHED_STATE_MOD_HASH + # TREASURY_MOD_HASH + # LOCKUP_SELF_HASH + # CAT_TAIL_HASH + # TREASURY_ID + # ; second hash + # SELF_HASH + # PROPOSED_PUZ_HASH ; this is what runs if this proposal is successful - the inner puzzle of this proposal + # YES_VOTES ; yes votes are +1, no votes don't tally - we compare yes_votes/total_votes at the end + # TOTAL_VOTES ; how many people responded + curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + lockup_puzzle.get_tree_hash(), + cat_tail_hash, + treasury_id, + ) + puzzle = curry_one.curry( + curry_one.get_tree_hash(), + proposal_id, + proposed_puzzle_hash, + votes_sum, + total_votes, + ) + return puzzle + + +def get_proposal_timer_puzzle( + cat_tail_hash: bytes32, + proposal_id: bytes32, + treasury_id: bytes32, +) -> Program: + parent_singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (proposal_id, SINGLETON_LAUNCHER_HASH))) + lockup_puzzle: Program = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + cat_tail_hash, + ) + PROPOSAL_SELF_HASH = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + lockup_puzzle.get_tree_hash(), + cat_tail_hash, + treasury_id, + ).get_tree_hash() + + puzzle: Program = DAO_PROPOSAL_TIMER_MOD.curry( + PROPOSAL_SELF_HASH, + parent_singleton_struct, + ) + return puzzle + + +def get_treasury_rules_from_puzzle(puzzle_reveal: Optional[Program]) -> DAORules: + assert isinstance(puzzle_reveal, Program) + curried_args = uncurry_treasury(puzzle_reveal) + ( + _DAO_TREASURY_MOD_HASH, + proposal_validator, + proposal_timelock, + soft_close_length, + attendance_required, + pass_percentage, + self_destruct_length, + oracle_spend_delay, + ) = curried_args + curried_args = uncurry_proposal_validator(proposal_validator) + ( + SINGLETON_STRUCT, + PROPOSAL_SELF_HASH, + PROPOSAL_MINIMUM_AMOUNT, + PAYOUT_PUZHASH, + ) = curried_args.as_iter() + return DAORules( + uint64(proposal_timelock.as_int()), + uint64(soft_close_length.as_int()), + uint64(attendance_required.as_int()), + uint64(pass_percentage.as_int()), + uint64(self_destruct_length.as_int()), + uint64(oracle_spend_delay.as_int()), + uint64(PROPOSAL_MINIMUM_AMOUNT.as_int()), + ) + + +# This takes the treasury puzzle and treasury solution, not the full puzzle and full solution +# This also returns the treasury puzzle and not the full puzzle +def get_new_puzzle_from_treasury_solution(puzzle_reveal: Program, solution: Program) -> Optional[Program]: + if solution.rest().rest().first() != Program.to(0): + # Proposal Spend + mod, curried_args = solution.at("rrf").uncurry() + if mod == DAO_UPDATE_PROPOSAL_MOD: + ( + DAO_TREASURY_MOD_HASH, + DAO_VALIDATOR_MOD_HASH, + TREASURY_SINGLETON_STRUCT, + PROPOSAL_SELF_HASH, + proposal_minimum_amount, + PROPOSAL_EXCESS_PAYOUT_PUZ_HASH, + proposal_timelock, + soft_close_length, + attendance_required, + pass_percentage, + self_destruct_length, + oracle_spend_delay, + ) = curried_args.as_iter() + new_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + TREASURY_SINGLETON_STRUCT, PROPOSAL_SELF_HASH, proposal_minimum_amount, PROPOSAL_EXCESS_PAYOUT_PUZ_HASH + ) + return DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + new_validator, + proposal_timelock, + soft_close_length, + attendance_required, + pass_percentage, + self_destruct_length, + oracle_spend_delay, + ) + else: + return puzzle_reveal + else: + # Oracle Spend - treasury is unchanged + return puzzle_reveal + + +# This takes the proposal puzzle and proposal solution, not the full puzzle and full solution +# This also returns the proposal puzzle and not the full puzzle +def get_new_puzzle_from_proposal_solution(puzzle_reveal: Program, solution: Program) -> Optional[Program]: + # Check if soft_close_length is in solution. If not, then add votes, otherwise close proposal + if len(solution.as_python()) == 1: + return puzzle_reveal # we're finished, shortcut this function + + if solution.at("rrrrrrf") == Program.to(0): + c_a, curried_args = uncurry_proposal(puzzle_reveal) + assert isinstance(curried_args, Program) + ( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_PUZHASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + lockup_self_hash, + cat_tail_hash, + treasury_id, + ) = curried_args.as_iter() + assert isinstance(c_a, Program) + ( + curry_one, + proposal_id, + proposed_puzzle_hash, + yes_votes, + total_votes, + ) = c_a.as_iter() + + added_votes = 0 + for vote_amount in solution.first().as_iter(): + added_votes += vote_amount.as_int() + + new_total_votes = total_votes.as_int() + added_votes + + if solution.at("rf") == Program.to(0): + # Vote Type: NO + new_yes_votes = yes_votes.as_int() + else: + # Vote Type: YES + new_yes_votes = yes_votes.as_int() + added_votes + return get_proposal_puzzle( + proposal_id=proposal_id.as_atom(), + cat_tail_hash=cat_tail_hash.as_atom(), + treasury_id=treasury_id.as_atom(), + votes_sum=new_yes_votes, + total_votes=new_total_votes, + proposed_puzzle_hash=proposed_puzzle_hash.as_atom(), + ) + else: + # we are in the finished state, puzzle is the same as ever + mod, currieds = puzzle_reveal.uncurry() # uncurry to self_hash + # check if our parent was the last non-finished state + if mod.uncurry()[0] == DAO_PROPOSAL_MOD: + c_a, curried_args = uncurry_proposal(puzzle_reveal) + ( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_PUZHASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + lockup_self_hash, + cat_tail_hash, + treasury_id, + ) = curried_args.as_iter() + ( + curry_one, + proposal_id, + proposed_puzzle_hash, + yes_votes, + total_votes, + ) = c_a.as_iter() + else: # pragma: no cover + SINGLETON_STRUCT, dao_finished_hash = currieds.as_iter() + proposal_id = SINGLETON_STRUCT.rest().first() + return get_finished_state_inner_puzzle(bytes32(proposal_id.as_atom())) + + +def get_finished_state_inner_puzzle(proposal_id: bytes32) -> Program: + singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (proposal_id, SINGLETON_LAUNCHER_HASH))) + finished_inner_puz: Program = DAO_FINISHED_STATE.curry(singleton_struct, DAO_FINISHED_STATE_HASH) + return finished_inner_puz + + +def get_finished_state_puzzle(proposal_id: bytes32) -> Program: + return curry_singleton(proposal_id, get_finished_state_inner_puzzle(proposal_id)) + + +def get_proposed_puzzle_reveal_from_solution(solution: Program) -> Program: + prog = Program.from_bytes(bytes(solution)) + return prog.at("rrfrrrrrf") + + +def get_asset_id_from_puzzle(puzzle: Program) -> Optional[bytes32]: + mod, curried_args = puzzle.uncurry() + if mod == MOD: # pragma: no cover + return None + elif mod == CAT_MOD: + return bytes32(curried_args.at("rf").as_atom()) + elif mod == SINGLETON_MOD: # pragma: no cover + return bytes32(curried_args.at("frf").as_atom()) + else: + raise ValueError("DAO received coin with unknown puzzle") # pragma: no cover + + +def uncurry_proposal_validator(proposal_validator_program: Program) -> Program: + try: + mod, curried_args = proposal_validator_program.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry treasury puzzle: error: %s", e) + raise e + + if mod != DAO_PROPOSAL_VALIDATOR_MOD: # pragma: no cover + raise ValueError("Not a Treasury mod.") + return curried_args + + +def uncurry_treasury(treasury_puzzle: Program) -> List[Program]: + try: + mod, curried_args = treasury_puzzle.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry treasury puzzle: error: %s", e) + raise e + + if mod != DAO_TREASURY_MOD: # pragma: no cover + raise ValueError("Not a Treasury mod.") + return list(curried_args.as_iter()) + + +def uncurry_proposal(proposal_puzzle: Program) -> Tuple[Program, Program]: + try: + mod, curried_args = proposal_puzzle.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry proposal puzzle: error: %s", e) + raise e + try: + mod, c_a = mod.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry lockup puzzle: error: %s", e) + raise e + if mod != DAO_PROPOSAL_MOD: + raise ValueError("Not a dao proposal mod.") + return curried_args, c_a + + +def uncurry_lockup(lockup_puzzle: Program) -> Tuple[Program, Program]: + try: + mod, curried_args = lockup_puzzle.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry lockup puzzle: error: %s", e) + raise e + try: + mod, c_a = mod.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry lockup puzzle: error: %s", e) + raise e + if mod != DAO_LOCKUP_MOD: + log.debug("Puzzle is not a dao cat lockup mod") + return curried_args, c_a + + +# This is the proposed puzzle +def get_proposal_args(puzzle: Program) -> Tuple[ProposalType, Program]: + try: + mod, curried_args = puzzle.uncurry() + except ValueError as e: # pragma: no cover + log.debug("Cannot uncurry spend puzzle: error: %s", e) + raise e + if mod == SPEND_P2_SINGLETON_MOD: + return ProposalType.SPEND, curried_args + elif mod == DAO_UPDATE_PROPOSAL_MOD: + return ProposalType.UPDATE, curried_args + else: + raise ValueError("Unrecognised proposal type") + + +def generate_cat_tail(genesis_coin_id: bytes32, treasury_id: bytes32) -> Program: + dao_cat_launcher = create_cat_launcher_for_singleton_id(treasury_id).get_tree_hash() + puzzle = DAO_CAT_TAIL.curry(genesis_coin_id, dao_cat_launcher) + return puzzle + + +def curry_singleton(singleton_id: bytes32, innerpuz: Program) -> Program: + singleton_struct = Program.to((SINGLETON_MOD_HASH, (singleton_id, SINGLETON_LAUNCHER_HASH))) + return SINGLETON_MOD.curry(singleton_struct, innerpuz) + + +# This is for use in the WalletStateManager to determine the type of coin received +def match_treasury_puzzle(mod: Program, curried_args: Program) -> Optional[Iterator[Program]]: + """ + Given a puzzle test if it's a Treasury, if it is, return the curried arguments + :param mod: Puzzle + :param curried_args: Puzzle + :return: Curried parameters + """ + try: + if mod == SINGLETON_MOD: + mod, curried_args = curried_args.rest().first().uncurry() + if mod == DAO_TREASURY_MOD: + return curried_args.first().as_iter() # type: ignore[no-any-return] + except ValueError: # pragma: no cover + import traceback + + print(f"exception: {traceback.format_exc()}") + return None + + +# This is for use in the WalletStateManager to determine the type of coin received +def match_proposal_puzzle(mod: Program, curried_args: Program) -> Optional[Iterator[Program]]: + """ + Given a puzzle test if it's a Proposal, if it is, return the curried arguments + :param curried_args: Puzzle + :return: Curried parameters + """ + try: + if mod == SINGLETON_MOD: + c_a, curried_args = uncurry_proposal(curried_args.rest().first()) + assert c_a is not None and curried_args is not None + ret = chain(c_a.as_iter(), curried_args.as_iter()) + return ret + except ValueError: + import traceback + + print(f"exception: {traceback.format_exc()}") + return None + + +def match_finished_puzzle(mod: Program, curried_args: Program) -> Optional[Iterator[Program]]: + """ + Given a puzzle test if it's a Proposal, if it is, return the curried arguments + :param curried_args: Puzzle + :return: Curried parameters + """ + try: + if mod == SINGLETON_MOD: + mod, curried_args = curried_args.rest().first().uncurry() + if mod == DAO_FINISHED_STATE: + return curried_args.as_iter() # type: ignore[no-any-return] + except ValueError: # pragma: no cover + import traceback + + print(f"exception: {traceback.format_exc()}") + return None + + +# This is used in WSM to determine whether we have a dao funding spend +def match_funding_puzzle( + uncurried: UncurriedPuzzle, solution: Program, coin: Coin, dao_ids: List[bytes32] = [] +) -> Optional[bool]: + if not dao_ids: + return None + try: + if uncurried.mod == CAT_MOD: + conditions = solution.at("frfr").as_iter() + elif uncurried.mod == MOD: + conditions = solution.at("rfr").as_iter() + elif uncurried.mod == SINGLETON_MOD: + inner_puz, _ = uncurried.args.at("rf").uncurry() + if inner_puz == DAO_TREASURY_MOD: + delegated_puz = solution.at("rrfrrf") + delegated_mod, delegated_args = delegated_puz.uncurry() + if delegated_puz.uncurry()[0] == SPEND_P2_SINGLETON_MOD: + if coin.puzzle_hash == delegated_args.at("rrrrf").as_atom(): # pragma: no cover + return True + return None # pragma: no cover + else: + return None + fund_puzhashes = [get_p2_singleton_puzhash(dao_id) for dao_id in dao_ids] + for cond in conditions: + if (cond.list_len() == 4) and (cond.first().as_int() == 51): + if cond.at("rrrff") in fund_puzhashes: + return True + except (ValueError, EvalError): + import traceback + + print(f"exception: {traceback.format_exc()}") + return None + + +def match_dao_cat_puzzle(uncurried: UncurriedPuzzle) -> Optional[Iterator[Program]]: + try: + if uncurried.mod == CAT_MOD: + arg_list = list(uncurried.args.as_iter()) + inner_puz = get_innerpuz_from_lockup_puzzle(uncurried.args.at("rrf")) + if inner_puz is not None: + dao_cat_args: Iterator[Program] = Program.to(arg_list).as_iter() + return dao_cat_args + except ValueError: + import traceback + + print(f"exception: {traceback.format_exc()}") + return None + + +def generate_simple_proposal_innerpuz( + treasury_id: bytes32, + recipient_puzhashes: List[bytes32], + amounts: List[uint64], + asset_types: List[Optional[bytes32]] = [None], +) -> Program: + if len(recipient_puzhashes) != len(amounts) != len(asset_types): # pragma: no cover + raise ValueError("Mismatch in the number of recipients, amounts, or asset types") + xch_conds = [] + cat_conds = [] + for recipient_puzhash, amount, asset_type in zip(recipient_puzhashes, amounts, asset_types): + if asset_type: + cat_conds.append([asset_type, [[51, recipient_puzhash, amount]]]) + else: + xch_conds.append([51, recipient_puzhash, amount]) + puzzle = get_spend_p2_singleton_puzzle(treasury_id, Program.to(xch_conds), Program.to(cat_conds)) + return puzzle + + +async def generate_update_proposal_innerpuz( + current_treasury_innerpuz: Program, + new_dao_rules: DAORules, + new_proposal_validator: Optional[Program] = None, +) -> Program: + if not new_proposal_validator: + assert isinstance(current_treasury_innerpuz, Program) + new_proposal_validator = get_proposal_validator( + current_treasury_innerpuz, new_dao_rules.proposal_minimum_amount + ) + return get_update_proposal_puzzle(new_dao_rules, new_proposal_validator) + + +async def generate_mint_proposal_innerpuz( + treasury_id: bytes32, + cat_tail_hash: bytes32, + amount_of_cats_to_create: uint64, + cats_new_innerpuzhash: bytes32, +) -> Program: + if amount_of_cats_to_create % 2 == 1: # pragma: no cover + raise ValueError("Minting proposals must mint an even number of CATs") + cat_launcher = create_cat_launcher_for_singleton_id(treasury_id) + + # cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + # cat_tail_hash = cat_wallet.cat_info.limitations_program_hash + eve_puz_hash = curry_cat_eve(cats_new_innerpuzhash) + full_puz = construct_cat_puzzle(CAT_MOD, cat_tail_hash, eve_puz_hash) + xch_conditions = [ + [ + 51, + cat_launcher.get_tree_hash(), + uint64(amount_of_cats_to_create), + [cats_new_innerpuzhash], + ], # create cat_launcher coin + [ + 60, + Program.to([ProposalType.MINT.value, full_puz.get_tree_hash()]).get_tree_hash(), + ], # make an announcement for the launcher to assert + ] + puzzle = get_spend_p2_singleton_puzzle(treasury_id, Program.to(xch_conditions), []) + return puzzle diff --git a/chia/wallet/dao_wallet/dao_wallet.py b/chia/wallet/dao_wallet/dao_wallet.py new file mode 100644 index 000000000000..caa1d681d195 --- /dev/null +++ b/chia/wallet/dao_wallet/dao_wallet.py @@ -0,0 +1,2155 @@ +from __future__ import annotations + +import copy +import dataclasses +import json +import logging +import re +import time +from secrets import token_bytes +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Set, Tuple, Union, cast + +from blspy import AugSchemeMPL, G1Element, G2Element +from clvm.casts import int_from_bytes + +import chia.wallet.singleton +from chia.full_node.full_node_api import FullNodeAPI +from chia.protocols.wallet_protocol import CoinState, RequestBlockHeader, RespondBlockHeader +from chia.server.ws_connection import WSChiaConnection +from chia.types.announcement import Announcement +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.types.condition_opcodes import ConditionOpcode +from chia.types.spend_bundle import SpendBundle +from chia.util.ints import uint32, uint64, uint128 +from chia.wallet import singleton +from chia.wallet.cat_wallet.cat_utils import CAT_MOD, SpendableCAT, construct_cat_puzzle +from chia.wallet.cat_wallet.cat_utils import get_innerpuzzle_from_puzzle as get_innerpuzzle_from_cat_puzzle +from chia.wallet.cat_wallet.cat_utils import unsigned_spend_bundle_for_spendable_cats +from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.dao_cat_wallet import DAOCATWallet +from chia.wallet.coin_selection import select_coins +from chia.wallet.conditions import Condition, parse_timelock_info +from chia.wallet.dao_wallet.dao_info import DAOInfo, DAORules, ProposalInfo, ProposalType +from chia.wallet.dao_wallet.dao_utils import ( + DAO_FINISHED_STATE, + DAO_PROPOSAL_MOD, + DAO_TREASURY_MOD, + SINGLETON_LAUNCHER, + create_cat_launcher_for_singleton_id, + curry_cat_eve, + curry_singleton, + generate_cat_tail, + get_active_votes_from_lockup_puzzle, + get_asset_id_from_puzzle, + get_dao_rules_from_update_proposal, + get_finished_state_inner_puzzle, + get_finished_state_puzzle, + get_innerpuz_from_lockup_puzzle, + get_new_puzzle_from_proposal_solution, + get_new_puzzle_from_treasury_solution, + get_p2_singleton_puzhash, + get_p2_singleton_puzzle, + get_proposal_args, + get_proposal_puzzle, + get_proposal_timer_puzzle, + get_proposed_puzzle_reveal_from_solution, + get_treasury_puzzle, + get_treasury_rules_from_puzzle, + match_funding_puzzle, + uncurry_proposal, + uncurry_treasury, +) +from chia.wallet.lineage_proof import LineageProof +from chia.wallet.singleton import ( + get_inner_puzzle_from_singleton, + get_most_recent_singleton_coin_from_coin_spend, + get_singleton_id_from_puzzle, + get_singleton_struct_for_id, +) +from chia.wallet.singleton_record import SingletonRecord +from chia.wallet.transaction_record import TransactionRecord +from chia.wallet.uncurried_puzzle import uncurry_puzzle +from chia.wallet.util.transaction_type import TransactionType +from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG, CoinSelectionConfig, TXConfig +from chia.wallet.util.wallet_sync_utils import fetch_coin_spend +from chia.wallet.util.wallet_types import WalletType +from chia.wallet.wallet import Wallet +from chia.wallet.wallet_coin_record import WalletCoinRecord +from chia.wallet.wallet_info import WalletInfo + + +class DAOWallet: + """ + This is a wallet in the sense that it conforms to the interface needed by WalletStateManager. + It is not a user-facing wallet. A user cannot spend or receive XCH though a wallet of this type. + + Wallets of type CAT and DAO_CAT are the user-facing wallets which hold the voting tokens a user + owns. The DAO Wallet is used for state-tracking of the Treasury Singleton and its associated + Proposals. + + State change Spends (spends this user creates, either from DAOWallet or DAOCATWallet: + * Create a proposal + * Add more votes to a proposal + * Lock / Unlock voting tokens + * Collect finished state of a Proposal - spend to read the oracle result and Get our CAT coins back + * Anyone can send money to the Treasury, whether in possession of a voting CAT or not + + Incoming spends we listen for: + * Update Treasury state if treasury is spent + * Hear about a finished proposal + * Hear about a new proposal -- check interest threshold (how many votes) + * Get Updated Proposal Data + """ + + if TYPE_CHECKING: + from chia.wallet.wallet_protocol import WalletProtocol + + _protocol_check: ClassVar[WalletProtocol[DAOInfo]] = cast("DAOWallet", None) + + wallet_state_manager: Any + log: logging.Logger + wallet_info: WalletInfo + dao_info: DAOInfo + dao_rules: DAORules + standard_wallet: Wallet + wallet_id: uint32 + + @staticmethod + async def create_new_dao_and_wallet( + wallet_state_manager: Any, + wallet: Wallet, + amount_of_cats: uint64, + dao_rules: DAORules, + tx_config: TXConfig, + filter_amount: uint64 = uint64(1), + name: Optional[str] = None, + fee: uint64 = uint64(0), + fee_for_cat: uint64 = uint64(0), + ) -> DAOWallet: + """ + Create a brand new DAO wallet + This must be called under the wallet state manager lock + :param wallet_state_manager: Wallet state manager + :param wallet: Standard wallet + :param amount_of_cats: Initial amount of voting CATs + :param dao_rules: The rules which govern the DAO + :param filter_amount: Min votes to see proposal (user defined) + :param name: Wallet name + :param fee: transaction fee + :param fee_for_cat: transaction fee for creating the CATs + :return: DAO wallet + """ + + self = DAOWallet() + self.wallet_state_manager = wallet_state_manager + if name is None: + name = self.generate_wallet_name() + + self.standard_wallet = wallet + self.log = logging.getLogger(name if name else __name__) + std_wallet_id = self.standard_wallet.wallet_id + bal = await wallet_state_manager.get_confirmed_balance_for_wallet(std_wallet_id) + if amount_of_cats > bal: + raise ValueError(f"Your balance of {bal} mojos is not enough to create {amount_of_cats} CATs") + + self.dao_info = DAOInfo( + treasury_id=bytes32([0] * 32), + cat_wallet_id=uint32(0), + dao_cat_wallet_id=uint32(0), + proposals_list=[], + parent_info=[], + current_treasury_coin=None, + current_treasury_innerpuz=None, + singleton_block_height=uint32(0), + filter_below_vote_amount=filter_amount, + assets=[], + current_height=uint64(0), + ) + self.dao_rules = dao_rules + info_as_string = json.dumps(self.dao_info.to_json_dict()) + self.wallet_info = await wallet_state_manager.user_store.create_wallet( + name, WalletType.DAO.value, info_as_string + ) + self.wallet_id = self.wallet_info.id + std_wallet_id = self.standard_wallet.wallet_id + + try: + await self.generate_new_dao( + amount_of_cats, + tx_config, + fee=fee, + fee_for_cat=fee_for_cat, + ) + except Exception as e_info: # pragma: no cover + await wallet_state_manager.user_store.delete_wallet(self.id()) + self.log.exception(f"Failed to create dao wallet: {e_info}") + raise + + await self.wallet_state_manager.add_new_wallet(self) + + # Now the dao wallet is created we can create the dao_cat wallet + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + cat_tail = cat_wallet.cat_info.limitations_program_hash + new_dao_cat_wallet = await DAOCATWallet.get_or_create_wallet_for_cat( + self.wallet_state_manager, self.standard_wallet, cat_tail.hex() + ) + dao_cat_wallet_id = new_dao_cat_wallet.wallet_info.id + dao_info = dataclasses.replace( + self.dao_info, cat_wallet_id=cat_wallet.id(), dao_cat_wallet_id=dao_cat_wallet_id + ) + await self.save_info(dao_info) + + return self + + @staticmethod + async def create_new_dao_wallet_for_existing_dao( + wallet_state_manager: Any, + main_wallet: Wallet, + treasury_id: bytes32, + filter_amount: uint64 = uint64(1), + name: Optional[str] = None, + ) -> DAOWallet: + """ + Create a DAO wallet for existing DAO + :param wallet_state_manager: Wallet state manager + :param main_wallet: Standard wallet + :param treasury_id: The singleton ID of the DAO treasury coin + :param filter_amount: Min votes to see proposal (user defined) + :param name: Wallet name + :return: DAO wallet + """ + self = DAOWallet() + self.wallet_state_manager = wallet_state_manager + if name is None: + name = self.generate_wallet_name() + + self.standard_wallet = main_wallet + self.log = logging.getLogger(name if name else __name__) + self.log.info("Creating DAO wallet for existent DAO ...") + self.dao_info = DAOInfo( + treasury_id=treasury_id, + cat_wallet_id=uint32(0), + dao_cat_wallet_id=uint32(0), + proposals_list=[], + parent_info=[], + current_treasury_coin=None, + current_treasury_innerpuz=None, + singleton_block_height=uint32(0), + filter_below_vote_amount=filter_amount, + assets=[], + current_height=uint64(0), + ) + info_as_string = json.dumps(self.dao_info.to_json_dict()) + self.wallet_info = await wallet_state_manager.user_store.create_wallet( + name, WalletType.DAO.value, info_as_string + ) + await self.wallet_state_manager.add_new_wallet(self) + await self.resync_treasury_state() + await self.save_info(self.dao_info) + self.wallet_id = self.wallet_info.id + + # Now the dao wallet is created we can create the dao_cat wallet + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + cat_tail = cat_wallet.cat_info.limitations_program_hash + new_dao_cat_wallet = await DAOCATWallet.get_or_create_wallet_for_cat( + self.wallet_state_manager, self.standard_wallet, cat_tail.hex() + ) + dao_cat_wallet_id = new_dao_cat_wallet.wallet_info.id + dao_info = dataclasses.replace( + self.dao_info, cat_wallet_id=cat_wallet.id(), dao_cat_wallet_id=dao_cat_wallet_id + ) + await self.save_info(dao_info) + + # add treasury id to interested puzzle hashes. This is hinted in funding coins so we can track them + funding_inner_hash = get_p2_singleton_puzhash(self.dao_info.treasury_id) + await self.wallet_state_manager.add_interested_puzzle_hashes( + [self.dao_info.treasury_id, funding_inner_hash], [self.id(), self.id()] + ) + return self + + @staticmethod + async def create( + wallet_state_manager: Any, + wallet: Wallet, + wallet_info: WalletInfo, + name: Optional[str] = None, + ) -> DAOWallet: + """ + Create a DID wallet based on the local database + :param wallet_state_manager: Wallet state manager + :param wallet: Standard wallet + :param wallet_info: Serialized WalletInfo + :param name: Wallet name + :return: + """ + self = DAOWallet() + self.log = logging.getLogger(name if name else __name__) + self.wallet_state_manager = wallet_state_manager + self.wallet_info = wallet_info + self.wallet_id = wallet_info.id + self.standard_wallet = wallet + self.dao_info = DAOInfo.from_json_dict(json.loads(wallet_info.data)) + self.dao_rules = get_treasury_rules_from_puzzle(self.dao_info.current_treasury_innerpuz) + return self + + @classmethod + def type(cls) -> WalletType: + return WalletType.DAO + + def id(self) -> uint32: + return self.wallet_info.id + + async def set_name(self, new_name: str) -> None: + new_info = dataclasses.replace(self.wallet_info, name=new_name) + self.wallet_info = new_info + await self.wallet_state_manager.user_store.update_wallet(self.wallet_info) + + def get_name(self) -> str: + return self.wallet_info.name + + async def match_hinted_coin(self, coin: Coin, hint: bytes32) -> bool: + raise NotImplementedError("Method not implemented for DAO Wallet") # pragma: no cover + + def puzzle_hash_for_pk(self, pubkey: G1Element) -> bytes32: + raise NotImplementedError("puzzle_hash_for_pk is not available in DAO wallets") # pragma: no cover + + async def get_new_p2_inner_hash(self) -> bytes32: + puzzle = await self.get_new_p2_inner_puzzle() + return puzzle.get_tree_hash() + + async def get_new_p2_inner_puzzle(self) -> Program: + return await self.standard_wallet.get_new_puzzle() + + def get_parent_for_coin(self, coin: Coin) -> Optional[LineageProof]: + parent_info = None + for name, ccparent in self.dao_info.parent_info: + if name == coin.parent_coin_info: + parent_info = ccparent + return parent_info + + async def get_max_send_amount(self, records: Optional[Set[WalletCoinRecord]] = None) -> uint128: + return uint128(0) # pragma: no cover + + async def get_spendable_balance(self, unspent_records: Optional[Set[WalletCoinRecord]] = None) -> uint128: + # No spendable or receivable value + return uint128(1) + + async def get_confirmed_balance(self, record_list: Optional[Set[WalletCoinRecord]] = None) -> uint128: + # No spendable or receivable value + return uint128(1) + + async def select_coins( + self, + amount: uint64, + coin_selection_config: CoinSelectionConfig, + ) -> Set[Coin]: + """ + Returns a set of coins that can be used for generating a new transaction. + Note: Must be called under wallet state manager lock + There is no need for max/min coin amount or excluded amount becuase the dao treasury should + always be a single coin with amount 1 + """ + spendable_amount: uint128 = await self.get_spendable_balance() + if amount > spendable_amount: + self.log.warning(f"Can't select {amount}, from spendable {spendable_amount} for wallet id {self.id()}") + return set() + + spendable_coins: List[WalletCoinRecord] = list( + await self.wallet_state_manager.get_spendable_coins_for_wallet(self.wallet_info.id) + ) + + # Try to use coins from the store, if there isn't enough of "unused" + # coins use change coins that are not confirmed yet + unconfirmed_removals: Dict[bytes32, Coin] = await self.wallet_state_manager.unconfirmed_removals_for_wallet( + self.wallet_info.id + ) + coins = await select_coins( + spendable_amount, + coin_selection_config, + spendable_coins, + unconfirmed_removals, + self.log, + uint128(amount), + ) + assert sum(c.amount for c in coins) >= amount + return coins + + async def get_pending_change_balance(self) -> uint64: + # No spendable or receivable value + return uint64(0) + + async def get_unconfirmed_balance(self, record_list: Optional[Set[WalletCoinRecord]] = None) -> uint128: + # No spendable or receivable value + return uint128(1) + + # if asset_id == None: then we get normal XCH + async def get_balance_by_asset_type(self, asset_id: Optional[bytes32] = None) -> uint128: + puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id, asset_id=asset_id) + records = await self.wallet_state_manager.coin_store.get_coin_records_by_puzzle_hash(puzhash) + return uint128(sum([cr.coin.amount for cr in records if not cr.spent])) + + # if asset_id == None: then we get normal XCH + async def select_coins_for_asset_type(self, amount: uint64, asset_id: Optional[bytes32] = None) -> List[Coin]: + puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id, asset_id=asset_id) + records = await self.wallet_state_manager.coin_store.get_coin_records_by_puzzle_hash(puzhash) + # TODO: smarter coin selection algorithm + total = 0 + coins = [] + for record in records: + if not record.spent: + total += record.coin.amount + coins.append(record.coin) + if total >= amount: + break + if total < amount: # pragma: no cover + raise ValueError(f"Not enough of asset {asset_id}: {total} < {amount}") + return coins + + async def coin_added(self, coin: Coin, height: uint32, peer: WSChiaConnection, coin_data: Optional[Any]) -> None: + """ + Notification from wallet state manager that a coin has been received. + This can be either a treasury coin update or funds added to the treasury + """ + self.log.info(f"DAOWallet.coin_added() called with the coin: {coin.name().hex()}:{coin}.") + wallet_node: Any = self.wallet_state_manager.wallet_node + peer = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + try: + # Get the parent coin spend + cs = (await wallet_node.get_coin_state([coin.parent_coin_info], peer, height))[0] + parent_spend = await fetch_coin_spend(cs.spent_height, cs.coin, peer) + + # check if it's a singleton and add to singleton_store + singleton_id = get_singleton_id_from_puzzle(parent_spend.puzzle_reveal) + + if singleton_id: + await self.wallet_state_manager.singleton_store.add_spend(self.id(), parent_spend, height) + puzzle = Program.from_bytes(bytes(parent_spend.puzzle_reveal)) + solution = Program.from_bytes(bytes(parent_spend.solution)) + uncurried = uncurry_puzzle(puzzle) + matched_funding_puz = match_funding_puzzle(uncurried, solution, coin, [self.dao_info.treasury_id]) + if matched_funding_puz: + # funding coin + xch_funds_puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id, asset_id=None) + if coin.puzzle_hash == xch_funds_puzhash: + asset_id = None + else: + asset_id = get_asset_id_from_puzzle(parent_spend.puzzle_reveal.to_program()) + if asset_id not in self.dao_info.assets: + new_asset_list = self.dao_info.assets.copy() + new_asset_list.append(asset_id) + dao_info = dataclasses.replace(self.dao_info, assets=new_asset_list) + await self.save_info(dao_info) + await self.wallet_state_manager.add_interested_puzzle_hashes([coin.puzzle_hash], [self.id()]) + self.log.info(f"DAO funding coin added: {coin.name().hex()}:{coin}. Asset ID: {asset_id}") + except Exception as e: # pragma: no cover + self.log.exception(f"Error occurred during dao wallet coin addition: {e}") + return + + def get_cat_tail_hash(self) -> bytes32: + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + return cat_wallet.cat_info.limitations_program_hash + + async def adjust_filter_level(self, new_filter_level: uint64) -> None: + dao_info = dataclasses.replace(self.dao_info, filter_below_vote_amount=new_filter_level) + await self.save_info(dao_info) + + async def clear_finished_proposals_from_memory(self) -> None: + dao_cat_wallet: DAOCATWallet = self.wallet_state_manager.wallets[self.dao_info.dao_cat_wallet_id] + new_list = [ + prop_info + for prop_info in self.dao_info.proposals_list + if not prop_info.closed + or prop_info.closed is None + or any(prop_info.proposal_id in lci.active_votes for lci in dao_cat_wallet.dao_cat_info.locked_coins) + ] + dao_info = dataclasses.replace(self.dao_info, proposals_list=new_list) + await self.save_info(dao_info) + return + + async def resync_treasury_state(self) -> None: + """ + This is called during create_new_dao_wallet_for_existing_dao. + When we want to sync to an existing DAO, we provide the treasury coins singleton ID, and then trace all + the child coins until we reach the current DAO treasury coin. We use the puzzle reveal and solution to + get the current state of the DAO, and to work out what the tail of the DAO CAT token is. + This also captures all the proposals that have been created and their state. + """ + parent_coin_id: bytes32 = self.dao_info.treasury_id + wallet_node: Any = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + + parent_coin = None + parent_parent_coin = None + while True: + children = await wallet_node.fetch_children(parent_coin_id, peer) + if len(children) == 0: + break + + children_state_list: List[CoinState] = [child for child in children if child.coin.amount % 2 == 1] + if len(children_state_list) == 0: # pragma: no cover + raise RuntimeError("Could not retrieve child_state") + children_state = children_state_list[0] + assert children_state is not None + child_coin = children_state.coin + if parent_coin is not None: + parent_parent_coin = parent_coin + parent_coin = child_coin + parent_coin_id = child_coin.name() + + if parent_parent_coin is None: # pragma: no cover + raise RuntimeError("could not get parent_parent_coin of %s", children) + + # get lineage proof of parent spend, and also current innerpuz + assert children_state.created_height + parent_spend = await fetch_coin_spend(children_state.created_height, parent_parent_coin, peer) + assert parent_spend is not None + parent_inner_puz = chia.wallet.singleton.get_inner_puzzle_from_singleton( + parent_spend.puzzle_reveal.to_program() + ) + if parent_inner_puz is None: # pragma: no cover + raise ValueError("get_innerpuzzle_from_puzzle failed") + + if parent_spend.puzzle_reveal.get_tree_hash() == child_coin.puzzle_hash: + current_inner_puz = parent_inner_puz + else: # pragma: no cover + # extract the treasury solution from the full singleton solution + inner_solution = parent_spend.solution.to_program().rest().rest().first() + # reconstruct the treasury puzzle + current_inner_puz = get_new_puzzle_from_treasury_solution(parent_inner_puz, inner_solution) + # set the treasury rules + self.dao_rules = get_treasury_rules_from_puzzle(current_inner_puz) + + current_lineage_proof = LineageProof( + parent_parent_coin.parent_coin_info, parent_inner_puz.get_tree_hash(), parent_parent_coin.amount + ) + await self.add_parent(parent_parent_coin.name(), current_lineage_proof) + + # Hack to find the cat tail hash from the memo of the genesis spend + launcher_state = await wallet_node.get_coin_state([self.dao_info.treasury_id], peer) + genesis_coin_id = launcher_state[0].coin.parent_coin_info + genesis_state = await wallet_node.get_coin_state([genesis_coin_id], peer) + genesis_spend = await fetch_coin_spend(genesis_state[0].spent_height, genesis_state[0].coin, peer) + cat_tail_hash = None + conds = genesis_spend.solution.to_program().at("rfr").as_iter() + for cond in conds: + if (cond.first().as_atom() == ConditionOpcode.CREATE_COIN) and ( + int_from_bytes(cond.at("rrf").as_atom()) == 1 + ): + cat_tail_hash = bytes32(cond.at("rrrff").as_atom()) + break + assert cat_tail_hash + + cat_wallet: Optional[CATWallet] = None + + # Get or create a cat wallet + for wallet_id in self.wallet_state_manager.wallets: + wallet = self.wallet_state_manager.wallets[wallet_id] + if wallet.type() == WalletType.CAT: # pragma: no cover + assert isinstance(wallet, CATWallet) + if wallet.cat_info.limitations_program_hash == cat_tail_hash: + cat_wallet = wallet + break + else: + # Didn't find a cat wallet, so create one + cat_wallet = await CATWallet.get_or_create_wallet_for_cat( + self.wallet_state_manager, self.standard_wallet, cat_tail_hash.hex() + ) + + assert cat_wallet is not None + cat_wallet_id = cat_wallet.wallet_info.id + dao_info = dataclasses.replace( + self.dao_info, + cat_wallet_id=uint32(cat_wallet_id), + dao_cat_wallet_id=uint32(0), + current_treasury_coin=child_coin, + current_treasury_innerpuz=current_inner_puz, + ) + await self.save_info(dao_info) + + future_parent = LineageProof( + child_coin.parent_coin_info, + dao_info.current_treasury_innerpuz.get_tree_hash(), + uint64(child_coin.amount), + ) + await self.add_parent(child_coin.name(), future_parent) + assert self.dao_info.parent_info is not None + + # get existing xch funds for treasury + xch_funds_puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id, asset_id=None) + await self.wallet_state_manager.add_interested_puzzle_hashes([xch_funds_puzhash], [self.id()]) + await self.wallet_state_manager.add_interested_puzzle_hashes([self.dao_info.treasury_id], [self.id()]) + await self.wallet_state_manager.add_interested_puzzle_hashes( + [self.dao_info.current_treasury_coin.puzzle_hash], [self.id()] + ) + + # Resync the wallet from when the treasury was created to get the existing funds + # TODO: Maybe split this out as an option for users since it may be slow? + if not wallet_node.is_trusted(peer): + # Untrusted nodes won't automatically send us the history of all the treasury and proposal coins, + # so we have to request them via sync_from_untrusted_close_to_peak + request = RequestBlockHeader(children_state.created_height) + response: Optional[RespondBlockHeader] = await peer.call_api(FullNodeAPI.request_block_header, request) + await wallet_node.sync_from_untrusted_close_to_peak(response.header_block, peer) + + return + + async def generate_new_dao( + self, + amount_of_cats_to_create: Optional[uint64], + tx_config: TXConfig, + cat_tail_hash: Optional[bytes32] = None, + fee: uint64 = uint64(0), + fee_for_cat: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> Optional[SpendBundle]: + """ + Create a new DAO treasury using the dao_rules object. This does the first spend to create the launcher + and eve coins. + The eve spend has to be completed in a separate tx using 'submit_eve_spend' once the number of blocks required + by dao_rules.oracle_spend_delay has passed. + This must be called under the wallet state manager lock + """ + + if amount_of_cats_to_create is not None and amount_of_cats_to_create < 0: # pragma: no cover + raise ValueError("amount_of_cats must be >= 0, or None") + if ( + amount_of_cats_to_create is None or amount_of_cats_to_create == 0 + ) and cat_tail_hash is None: # pragma: no cover + raise ValueError("amount_of_cats must be > 0 or cat_tail_hash must be specified") + if ( + amount_of_cats_to_create is not None and amount_of_cats_to_create > 0 and cat_tail_hash is not None + ): # pragma: no cover + raise ValueError("cannot create voting cats and use existing cat_tail_hash") + if self.dao_rules.pass_percentage > 10000 or self.dao_rules.pass_percentage < 0: # pragma: no cover + raise ValueError("proposal pass percentage must be between 0 and 10000") + + if amount_of_cats_to_create is not None and amount_of_cats_to_create > 0: + coins = await self.standard_wallet.select_coins( + uint64(amount_of_cats_to_create + fee + 1), + tx_config.coin_selection_config, + ) + else: # pragma: no cover + coins = await self.standard_wallet.select_coins(uint64(fee + 1), tx_config.coin_selection_config) + + if coins is None: # pragma: no cover + return None + # origin is normal coin which creates launcher coin + origin = coins.copy().pop() + + genesis_launcher_puz = SINGLETON_LAUNCHER + # launcher coin contains singleton launcher, launcher coin ID == singleton_id == treasury_id + launcher_coin = Coin(origin.name(), genesis_launcher_puz.get_tree_hash(), 1) + + if cat_tail_hash is None: + assert amount_of_cats_to_create is not None + different_coins = await self.standard_wallet.select_coins( + uint64(amount_of_cats_to_create + fee_for_cat), + coin_selection_config=tx_config.coin_selection_config.override( + excluded_coin_ids=[*tx_config.coin_selection_config.excluded_coin_ids, origin.name()] + ), + ) + cat_origin = different_coins.copy().pop() + assert origin.name() != cat_origin.name() + cat_tail = generate_cat_tail(cat_origin.name(), launcher_coin.name()) + cat_tail_hash = cat_tail.get_tree_hash() + + assert cat_tail_hash is not None + + dao_info: DAOInfo = DAOInfo( + launcher_coin.name(), + self.dao_info.cat_wallet_id, + self.dao_info.dao_cat_wallet_id, + self.dao_info.proposals_list, + self.dao_info.parent_info, + None, + None, + uint32(0), + self.dao_info.filter_below_vote_amount, + self.dao_info.assets, + self.dao_info.current_height, + ) + await self.save_info(dao_info) + new_cat_wallet = None + # This will also mint the coins + if amount_of_cats_to_create is not None and different_coins is not None: + cat_tail_info = { + "identifier": "genesis_by_id_or_singleton", + "treasury_id": launcher_coin.name(), + "coins": different_coins, + } + new_cat_wallet = await CATWallet.create_new_cat_wallet( + self.wallet_state_manager, + self.standard_wallet, + cat_tail_info, + amount_of_cats_to_create, + DEFAULT_TX_CONFIG, + fee=fee_for_cat, + ) + assert new_cat_wallet is not None + else: # pragma: no cover + for wallet in self.wallet_state_manager.wallets: + if self.wallet_state_manager.wallets[wallet].type() == WalletType.CAT: + if self.wallet_state_manager.wallets[wallet].cat_info.limitations_program_hash == cat_tail_hash: + new_cat_wallet = self.wallet_state_manager.wallets[wallet] + + assert new_cat_wallet is not None + cat_wallet_id = new_cat_wallet.wallet_info.id + + assert cat_tail_hash == new_cat_wallet.cat_info.limitations_program_hash + await new_cat_wallet.set_tail_program(bytes(cat_tail).hex()) + dao_info = DAOInfo( + self.dao_info.treasury_id, + cat_wallet_id, + self.dao_info.dao_cat_wallet_id, + self.dao_info.proposals_list, + self.dao_info.parent_info, + None, + None, + uint32(0), + self.dao_info.filter_below_vote_amount, + self.dao_info.assets, + self.dao_info.current_height, + ) + + await self.save_info(dao_info) + + dao_treasury_puzzle = get_treasury_puzzle(self.dao_rules, launcher_coin.name(), cat_tail_hash) + full_treasury_puzzle = curry_singleton(launcher_coin.name(), dao_treasury_puzzle) + full_treasury_puzzle_hash = full_treasury_puzzle.get_tree_hash() + + announcement_set: Set[Announcement] = set() + announcement_message = Program.to([full_treasury_puzzle_hash, 1, bytes(0x80)]).get_tree_hash() + announcement_set.add(Announcement(launcher_coin.name(), announcement_message)) + tx_records: List[TransactionRecord] = await self.standard_wallet.generate_signed_transaction( + uint64(1), + genesis_launcher_puz.get_tree_hash(), + tx_config, + fee, + origin_id=origin.name(), + coins=set(coins), + coin_announcements_to_consume=announcement_set, + memos=[new_cat_wallet.cat_info.limitations_program_hash], + ) + tx_record: TransactionRecord = tx_records[0] + + genesis_launcher_solution = Program.to([full_treasury_puzzle_hash, 1, bytes(0x80)]) + + launcher_cs = CoinSpend(launcher_coin, genesis_launcher_puz, genesis_launcher_solution) + launcher_sb = SpendBundle([launcher_cs], AugSchemeMPL.aggregate([])) + + launcher_proof = LineageProof( + bytes32(launcher_coin.parent_coin_info), + None, + uint64(launcher_coin.amount), + ) + await self.add_parent(launcher_coin.name(), launcher_proof) + + if tx_record is None or tx_record.spend_bundle is None: # pragma: no cover + return None + + eve_coin = Coin(launcher_coin.name(), full_treasury_puzzle_hash, uint64(1)) + dao_info = DAOInfo( + launcher_coin.name(), + cat_wallet_id, + self.dao_info.dao_cat_wallet_id, + self.dao_info.proposals_list, + self.dao_info.parent_info, + eve_coin, + dao_treasury_puzzle, + self.dao_info.singleton_block_height, + self.dao_info.filter_below_vote_amount, + self.dao_info.assets, + self.dao_info.current_height, + ) + await self.save_info(dao_info) + eve_spend = await self.generate_treasury_eve_spend(dao_treasury_puzzle, eve_coin) + + full_spend = SpendBundle.aggregate([tx_record.spend_bundle, launcher_sb, eve_spend]) + + treasury_record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=dao_treasury_puzzle.get_tree_hash(), + amount=uint64(1), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=full_spend, + additions=full_spend.additions(), + removals=full_spend.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + regular_record = dataclasses.replace(tx_record, spend_bundle=None) + await self.wallet_state_manager.add_pending_transaction(regular_record) + await self.wallet_state_manager.add_pending_transaction(treasury_record) + + funding_inner_puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id) + await self.wallet_state_manager.add_interested_puzzle_hashes([funding_inner_puzhash], [self.id()]) + await self.wallet_state_manager.add_interested_puzzle_hashes([launcher_coin.name()], [self.id()]) + await self.wallet_state_manager.add_interested_coin_ids([launcher_coin.name()], [self.wallet_id]) + + await self.wallet_state_manager.add_interested_coin_ids([eve_coin.name()], [self.wallet_id]) + return full_spend + + async def generate_treasury_eve_spend( + self, inner_puz: Program, eve_coin: Coin, fee: uint64 = uint64(0) + ) -> SpendBundle: + """ + Create the eve spend of the treasury + This can only be completed after a number of blocks > oracle_spend_delay have been farmed + """ + if self.dao_info.current_treasury_innerpuz is None: # pragma: no cover + raise ValueError("generate_treasury_eve_spend called with nil self.dao_info.current_treasury_innerpuz") + full_treasury_puzzle = curry_singleton(self.dao_info.treasury_id, inner_puz) + launcher_id, launcher_proof = self.dao_info.parent_info[0] + assert launcher_proof + assert inner_puz + inner_sol = Program.to([0, 0, 0, 0, get_singleton_struct_for_id(launcher_id)]) + fullsol = Program.to( + [ + launcher_proof.to_program(), + eve_coin.amount, + inner_sol, + ] + ) + eve_coin_spend = CoinSpend(eve_coin, full_treasury_puzzle, fullsol) + eve_spend_bundle = SpendBundle([eve_coin_spend], G2Element()) + + next_proof = LineageProof( + eve_coin.parent_coin_info, + inner_puz.get_tree_hash(), + uint64(eve_coin.amount), + ) + next_coin = Coin(eve_coin.name(), eve_coin.puzzle_hash, eve_coin.amount) + await self.add_parent(eve_coin.name(), next_proof) + await self.wallet_state_manager.add_interested_coin_ids([next_coin.name()], [self.wallet_id]) + + dao_info = dataclasses.replace(self.dao_info, current_treasury_coin=next_coin) + await self.save_info(dao_info) + await self.wallet_state_manager.singleton_store.add_spend(self.id(), eve_coin_spend) + return eve_spend_bundle + + async def generate_new_proposal( + self, + proposed_puzzle: Program, + tx_config: TXConfig, + vote_amount: Optional[uint64] = None, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + dao_rules = get_treasury_rules_from_puzzle(self.dao_info.current_treasury_innerpuz) + coins = await self.standard_wallet.select_coins( + uint64(fee + dao_rules.proposal_minimum_amount), + tx_config.coin_selection_config, + ) + if coins is None: # pragma: no cover + return None + # origin is normal coin which creates launcher coin + origin = coins.copy().pop() + genesis_launcher_puz = SINGLETON_LAUNCHER + # launcher coin contains singleton launcher, launcher coin ID == singleton_id == treasury_id + launcher_coin = Coin(origin.name(), genesis_launcher_puz.get_tree_hash(), dao_rules.proposal_minimum_amount) + + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + + if vote_amount is None: # pragma: no cover + dao_cat_wallet = self.wallet_state_manager.get_wallet( + id=self.dao_info.dao_cat_wallet_id, required_type=DAOCATWallet + ) + vote_amount = await dao_cat_wallet.get_votable_balance(include_free_cats=False) + assert vote_amount is not None + cat_tail_hash = cat_wallet.cat_info.limitations_program_hash + assert cat_tail_hash + dao_proposal_puzzle = get_proposal_puzzle( + proposal_id=launcher_coin.name(), + cat_tail_hash=cat_tail_hash, + treasury_id=self.dao_info.treasury_id, + votes_sum=uint64(0), + total_votes=uint64(0), + proposed_puzzle_hash=proposed_puzzle.get_tree_hash(), + ) + + full_proposal_puzzle = curry_singleton(launcher_coin.name(), dao_proposal_puzzle) + full_proposal_puzzle_hash = full_proposal_puzzle.get_tree_hash() + + announcement_set: Set[Announcement] = set() + announcement_message = Program.to( + [full_proposal_puzzle_hash, dao_rules.proposal_minimum_amount, bytes(0x80)] + ).get_tree_hash() + announcement_set.add(Announcement(launcher_coin.name(), announcement_message)) + + tx_records: List[TransactionRecord] = await self.standard_wallet.generate_signed_transaction( + uint64(dao_rules.proposal_minimum_amount), + genesis_launcher_puz.get_tree_hash(), + tx_config, + fee, + origin_id=origin.name(), + coins=coins, + coin_announcements_to_consume=announcement_set, + ) + tx_record: TransactionRecord = tx_records[0] + + genesis_launcher_solution = Program.to( + [full_proposal_puzzle_hash, dao_rules.proposal_minimum_amount, bytes(0x80)] + ) + + launcher_cs = CoinSpend(launcher_coin, genesis_launcher_puz, genesis_launcher_solution) + launcher_sb = SpendBundle([launcher_cs], AugSchemeMPL.aggregate([])) + eve_coin = Coin(launcher_coin.name(), full_proposal_puzzle_hash, dao_rules.proposal_minimum_amount) + + future_parent = LineageProof( + eve_coin.parent_coin_info, + dao_proposal_puzzle.get_tree_hash(), + uint64(eve_coin.amount), + ) + eve_parent = LineageProof( + bytes32(launcher_coin.parent_coin_info), + bytes32(launcher_coin.puzzle_hash), + uint64(launcher_coin.amount), + ) + + await self.add_parent(bytes32(eve_coin.parent_coin_info), eve_parent) + await self.add_parent(eve_coin.name(), future_parent) + + eve_spend = await self.generate_proposal_eve_spend( + eve_coin=eve_coin, + full_proposal_puzzle=full_proposal_puzzle, + dao_proposal_puzzle=dao_proposal_puzzle, + proposed_puzzle_reveal=proposed_puzzle, + launcher_coin=launcher_coin, + vote_amount=vote_amount, + ) + assert tx_record + assert tx_record.spend_bundle is not None + + full_spend = SpendBundle.aggregate([tx_record.spend_bundle, eve_spend, launcher_sb]) + + record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=full_proposal_puzzle.get_tree_hash(), + amount=uint64(dao_rules.proposal_minimum_amount), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=full_spend, + additions=full_spend.additions(), + removals=full_spend.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + return record + + async def generate_proposal_eve_spend( + self, + *, + eve_coin: Coin, + full_proposal_puzzle: Program, + dao_proposal_puzzle: Program, + proposed_puzzle_reveal: Program, + launcher_coin: Coin, + vote_amount: uint64, + ) -> SpendBundle: + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + cat_tail = cat_wallet.cat_info.limitations_program_hash + dao_cat_wallet = await DAOCATWallet.get_or_create_wallet_for_cat( + self.wallet_state_manager, self.standard_wallet, cat_tail.hex() + ) + assert dao_cat_wallet is not None + + dao_cat_spend = await dao_cat_wallet.create_vote_spend( + vote_amount, launcher_coin.name(), True, proposal_puzzle=dao_proposal_puzzle + ) + vote_amounts = [] + vote_coins = [] + previous_votes = [] + lockup_inner_puzhashes = [] + for spend in dao_cat_spend.coin_spends: + spend_vote_amount = Program.from_bytes(bytes(spend.solution)).at("frrrrrrf").as_int() + vote_amounts.append(spend_vote_amount) + vote_coins.append(spend.coin.name()) + previous_votes.append( + get_active_votes_from_lockup_puzzle( + get_innerpuzzle_from_cat_puzzle(Program.from_bytes(bytes(spend.puzzle_reveal))) + ) + ) + lockup_inner_puz = get_innerpuz_from_lockup_puzzle( + get_innerpuzzle_from_cat_puzzle(Program.from_bytes(bytes(spend.puzzle_reveal))) + ) + assert isinstance(lockup_inner_puz, Program) + lockup_inner_puzhashes.append(lockup_inner_puz.get_tree_hash()) + inner_sol = Program.to( + [ + vote_amounts, + 1, + vote_coins, + previous_votes, + lockup_inner_puzhashes, + proposed_puzzle_reveal, + 0, + 0, + 0, + 0, + eve_coin.amount, + ] + ) + # full solution is (lineage_proof my_amount inner_solution) + fullsol = Program.to( + [ + [launcher_coin.parent_coin_info, launcher_coin.amount], + eve_coin.amount, + inner_sol, + ] + ) + list_of_coinspends = [CoinSpend(eve_coin, full_proposal_puzzle, fullsol)] + unsigned_spend_bundle = SpendBundle(list_of_coinspends, G2Element()) + return unsigned_spend_bundle.aggregate([unsigned_spend_bundle, dao_cat_spend]) + + async def generate_proposal_vote_spend( + self, + proposal_id: bytes32, + vote_amount: Optional[uint64], + is_yes_vote: bool, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + self.log.info(f"Trying to create a proposal close spend with ID: {proposal_id}") + proposal_info = None + for pi in self.dao_info.proposals_list: + if pi.proposal_id == proposal_id: + proposal_info = pi + break + if proposal_info is None: # pragma: no cover + raise ValueError("Unable to find a proposal with that ID.") + if (proposal_info.timer_coin is None) and ( + proposal_info.current_innerpuz == get_finished_state_puzzle(proposal_info.proposal_id) + ): + raise ValueError("This proposal is already closed. Feel free to unlock your coins.") # pragma: no cover + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + cat_tail = cat_wallet.cat_info.limitations_program_hash + dao_cat_wallet = await DAOCATWallet.get_or_create_wallet_for_cat( + self.wallet_state_manager, self.standard_wallet, cat_tail.hex() + ) + assert dao_cat_wallet is not None + assert proposal_info.current_innerpuz is not None + + if vote_amount is None: # pragma: no cover + vote_amount = await dao_cat_wallet.get_votable_balance(proposal_id) + assert vote_amount is not None + dao_cat_spend = await dao_cat_wallet.create_vote_spend( + vote_amount, proposal_id, is_yes_vote, proposal_puzzle=proposal_info.current_innerpuz + ) + vote_amounts = [] + vote_coins = [] + previous_votes = [] + lockup_inner_puzhashes = [] + assert dao_cat_spend is not None + for spend in dao_cat_spend.coin_spends: + vote_amounts.append( + Program.from_bytes(bytes(spend.solution)).at("frrrrrrf") + ) # this is the vote_amount field of the solution + vote_coins.append(spend.coin.name()) + previous_votes.append( + get_active_votes_from_lockup_puzzle( + get_innerpuzzle_from_cat_puzzle(Program.from_bytes(bytes(spend.puzzle_reveal))) + ) + ) + lockup_inner_puz = get_innerpuz_from_lockup_puzzle( + get_innerpuzzle_from_cat_puzzle(Program.from_bytes(bytes(spend.puzzle_reveal))) + ) + assert isinstance(lockup_inner_puz, Program) + lockup_inner_puzhashes.append(lockup_inner_puz.get_tree_hash()) + inner_sol = Program.to( + [ + vote_amounts, + 1 if is_yes_vote else 0, + vote_coins, + previous_votes, + lockup_inner_puzhashes, + 0, + 0, + 0, + 0, + 0, + proposal_info.current_coin.amount, + ] + ) + parent_info = self.get_parent_for_coin(proposal_info.current_coin) + assert parent_info is not None + # full solution is (lineage_proof my_amount inner_solution) + fullsol = Program.to( + [ + [ + parent_info.parent_name, + parent_info.inner_puzzle_hash, + parent_info.amount, + ], + proposal_info.current_coin.amount, + inner_sol, + ] + ) + full_proposal_puzzle = curry_singleton(proposal_id, proposal_info.current_innerpuz) + list_of_coinspends = [CoinSpend(proposal_info.current_coin, full_proposal_puzzle, fullsol)] + unsigned_spend_bundle = SpendBundle(list_of_coinspends, G2Element()) + if fee > 0: + chia_tx = await self.standard_wallet.create_tandem_xch_tx( + fee, + tx_config, + ) + assert chia_tx.spend_bundle is not None + spend_bundle = unsigned_spend_bundle.aggregate([unsigned_spend_bundle, dao_cat_spend, chia_tx.spend_bundle]) + else: + spend_bundle = unsigned_spend_bundle.aggregate([unsigned_spend_bundle, dao_cat_spend]) + + record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=full_proposal_puzzle.get_tree_hash(), + amount=uint64(1), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=spend_bundle, + additions=spend_bundle.additions(), + removals=spend_bundle.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + return record + + async def create_proposal_close_spend( + self, + proposal_id: bytes32, + tx_config: TXConfig, + genesis_id: Optional[bytes32] = None, + fee: uint64 = uint64(0), + self_destruct: bool = False, + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + self.log.info(f"Trying to create a proposal close spend with ID: {proposal_id}") + proposal_info = None + for pi in self.dao_info.proposals_list: + if pi.proposal_id == proposal_id: + proposal_info = pi + break + if proposal_info is None: # pragma: no cover + raise ValueError("Unable to find a proposal with that ID.") + if proposal_info.timer_coin is None: # pragma: no cover + raise ValueError("This proposal is already closed. Feel free to unlock your coins.") + assert self.dao_info.current_treasury_innerpuz is not None + curried_args = uncurry_treasury(self.dao_info.current_treasury_innerpuz) + ( + _DAO_TREASURY_MOD_HASH, + proposal_validator, + proposal_timelock, + soft_close_length, + attendance_required, + pass_percentage, + self_destruct_length, + oracle_spend_delay, + ) = curried_args + proposal_state = await self.get_proposal_state(proposal_id) + if not proposal_state["closable"]: # pragma: no cover + raise ValueError(f"This proposal is not ready to be closed. proposal_id: {proposal_id}") + if proposal_state["passed"]: + self.log.info(f"Closing passed proposal: {proposal_id}") + else: + self.log.info(f"Closing failed proposal: {proposal_id}") + assert proposal_info.current_innerpuz is not None + full_proposal_puzzle = curry_singleton(proposal_id, proposal_info.current_innerpuz) + assert proposal_info.current_coin.puzzle_hash == full_proposal_puzzle.get_tree_hash() + solution = Program.to( + [ + proposal_validator.get_tree_hash(), + 0, + proposal_timelock, + pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_length, + oracle_spend_delay, + 1 if self_destruct else 0, + ] + ) + parent_info = self.get_parent_for_coin(proposal_info.current_coin) + assert parent_info is not None + fullsol = Program.to( + [ + [ + parent_info.parent_name, + parent_info.inner_puzzle_hash, + parent_info.amount, + ], + proposal_info.current_coin.amount, + solution, + ] + ) + proposal_cs = CoinSpend(proposal_info.current_coin, full_proposal_puzzle, fullsol) + if not self_destruct: + timer_puzzle = get_proposal_timer_puzzle( + self.get_cat_tail_hash(), + proposal_info.proposal_id, + self.dao_info.treasury_id, + ) + c_a, curried_args = uncurry_proposal(proposal_info.current_innerpuz) + ( + SELF_HASH, + PROPOSAL_ID, + PROPOSED_PUZ_HASH, + YES_VOTES, + TOTAL_VOTES, + ) = c_a.as_iter() + + if TOTAL_VOTES.as_int() < attendance_required.as_int(): # pragma: no cover + raise ValueError("Unable to pass this proposal as it has not met the minimum vote attendance.") + timer_solution = Program.to( + [ + YES_VOTES, + TOTAL_VOTES, + PROPOSED_PUZ_HASH, + proposal_timelock, + proposal_id, + proposal_info.current_coin.amount, + ] + ) + timer_cs = CoinSpend(proposal_info.timer_coin, timer_puzzle, timer_solution) + + full_treasury_puz = curry_singleton(self.dao_info.treasury_id, self.dao_info.current_treasury_innerpuz) + assert isinstance(self.dao_info.current_treasury_coin, Coin) + assert full_treasury_puz.get_tree_hash() == self.dao_info.current_treasury_coin.puzzle_hash + + cat_spend_bundle = None + delegated_puzzle_sb = None + puzzle_reveal = await self.fetch_proposed_puzzle_reveal(proposal_id) + if proposal_state["passed"] and not self_destruct: + validator_solution = Program.to( + [ + proposal_id, + TOTAL_VOTES, + YES_VOTES, + proposal_info.current_coin.parent_coin_info, + proposal_info.current_coin.amount, + ] + ) + + proposal_type, curried_args = get_proposal_args(puzzle_reveal) + if proposal_type == ProposalType.SPEND: + ( + TREASURY_SINGLETON_STRUCT, + CAT_MOD_HASH, + CONDITIONS, + LIST_OF_TAILHASH_CONDITIONS, + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH, + ) = curried_args.as_iter() + + sum = 0 + coin_spends = [] + xch_parent_amount_list = [] + tailhash_parent_amount_list = [] + treasury_inner_puzhash = self.dao_info.current_treasury_innerpuz.get_tree_hash() + p2_singleton_puzzle = get_p2_singleton_puzzle(self.dao_info.treasury_id) + cat_launcher = create_cat_launcher_for_singleton_id(self.dao_info.treasury_id) + + # handle CAT minting + for cond in CONDITIONS.as_iter(): + if cond.first().as_int() == 51: + if cond.rest().first().as_atom() == cat_launcher.get_tree_hash(): + cat_wallet: CATWallet = self.wallet_state_manager.wallets[self.dao_info.cat_wallet_id] + cat_tail_hash = cat_wallet.cat_info.limitations_program_hash + mint_amount = cond.rest().rest().first().as_int() + new_cat_puzhash = cond.rest().rest().rest().first().first().as_atom() + eve_puzzle = curry_cat_eve(new_cat_puzhash) + if genesis_id is None: + tail_reconstruction = cat_wallet.cat_info.my_tail + else: # pragma: no cover + tail_reconstruction = generate_cat_tail(genesis_id, self.dao_info.treasury_id) + assert tail_reconstruction is not None + assert tail_reconstruction.get_tree_hash() == cat_tail_hash + assert isinstance(self.dao_info.current_treasury_coin, Coin) + cat_launcher_coin = Coin( + self.dao_info.current_treasury_coin.name(), cat_launcher.get_tree_hash(), mint_amount + ) + full_puz = construct_cat_puzzle(CAT_MOD, cat_tail_hash, eve_puzzle) + + solution = Program.to( + [ + treasury_inner_puzhash, + self.dao_info.current_treasury_coin.parent_coin_info, + full_puz.get_tree_hash(), + mint_amount, + ] + ) + coin_spends.append(CoinSpend(cat_launcher_coin, cat_launcher, solution)) + eve_coin = Coin(cat_launcher_coin.name(), full_puz.get_tree_hash(), mint_amount) + tail_solution = Program.to([cat_launcher_coin.parent_coin_info, cat_launcher_coin.amount]) + solution = Program.to([mint_amount, tail_reconstruction, tail_solution]) + new_spendable_cat = SpendableCAT( + eve_coin, + cat_tail_hash, + eve_puzzle, + solution, + ) + if cat_spend_bundle is None: + cat_spend_bundle = unsigned_spend_bundle_for_spendable_cats( + CAT_MOD, [new_spendable_cat] + ) + else: # pragma: no cover + cat_spend_bundle = cat_spend_bundle.aggregate( + [ + cat_spend_bundle, + unsigned_spend_bundle_for_spendable_cats(CAT_MOD, [new_spendable_cat]), + ] + ) + + for condition_statement in CONDITIONS.as_iter(): + if condition_statement.first().as_int() == 51: + sum += condition_statement.rest().rest().first().as_int() + if sum > 0: + xch_coins = await self.select_coins_for_asset_type(uint64(sum)) + for xch_coin in xch_coins: + xch_parent_amount_list.append([xch_coin.parent_coin_info, xch_coin.amount]) + solution = Program.to( + [ + 0, + treasury_inner_puzhash, + 0, + 0, + xch_coin.name(), + ] + ) + coin_spends.append(CoinSpend(xch_coin, p2_singleton_puzzle, solution)) + delegated_puzzle_sb = SpendBundle(coin_spends, AugSchemeMPL.aggregate([])) + for tail_hash_conditions_pair in LIST_OF_TAILHASH_CONDITIONS.as_iter(): + tail_hash: bytes32 = tail_hash_conditions_pair.first().as_atom() + conditions: Program = tail_hash_conditions_pair.rest().first() + sum_of_conditions = 0 + sum_of_coins = 0 + spendable_cat_list = [] + for condition in conditions.as_iter(): + if condition.first().as_int() == 51: + sum_of_conditions += condition.rest().rest().first().as_int() + cat_coins = await self.select_coins_for_asset_type(uint64(sum_of_conditions), tail_hash) + parent_amount_list = [] + for cat_coin in cat_coins: + sum_of_coins += cat_coin.amount + parent_amount_list.append([cat_coin.parent_coin_info, cat_coin.amount]) + lineage_proof = await self.fetch_cat_lineage_proof(cat_coin) + if cat_coin == cat_coins[-1]: # the last coin is the one that makes the conditions + if sum_of_coins - sum_of_conditions > 0: + p2_singleton_puzhash = p2_singleton_puzzle.get_tree_hash() + change_condition = Program.to( + [ + 51, + p2_singleton_puzhash, + sum_of_coins - sum_of_conditions, + [p2_singleton_puzhash], + ] + ) + delegated_puzzle = Program.to((1, change_condition.cons(conditions))) + else: # pragma: no cover + delegated_puzzle = Program.to((1, conditions)) + + solution = Program.to( + [ + 0, + treasury_inner_puzhash, + delegated_puzzle, + 0, + cat_coin.name(), + ] + ) + else: + solution = Program.to( + [ + 0, + treasury_inner_puzhash, + 0, + 0, + cat_coin.name(), + ] + ) + new_spendable_cat = SpendableCAT( + cat_coin, + tail_hash, + p2_singleton_puzzle, + solution, + lineage_proof=lineage_proof, + ) + spendable_cat_list.append(new_spendable_cat) + # create or merge with other CAT spends + if cat_spend_bundle is None: + cat_spend_bundle = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, spendable_cat_list) + else: + cat_spend_bundle = cat_spend_bundle.aggregate( + [cat_spend_bundle, unsigned_spend_bundle_for_spendable_cats(CAT_MOD, spendable_cat_list)] + ) + tailhash_parent_amount_list.append([tail_hash, parent_amount_list]) + + delegated_solution = Program.to( + [ + xch_parent_amount_list, + tailhash_parent_amount_list, + treasury_inner_puzhash, + ] + ) + + elif proposal_type == ProposalType.UPDATE: + ( + TREASURY_MOD_HASH, + VALIDATOR_MOD_HASH, + SINGLETON_STRUCT, + PROPOSAL_SELF_HASH, + PROPOSAL_MINIMUM_AMOUNT, + PROPOSAL_EXCESS_PAYOUT_PUZHASH, + PROPOSAL_LENGTH, + PROPOSAL_SOFTCLOSE_LENGTH, + ATTENDANCE_REQUIRED, + PASS_MARGIN, + PROPOSAL_SELF_DESTRUCT_TIME, + ORACLE_SPEND_DELAY, + ) = curried_args.as_iter() + coin_spends = [] + treasury_inner_puzhash = self.dao_info.current_treasury_innerpuz.get_tree_hash() + delegated_solution = Program.to([]) + + treasury_solution = Program.to( + [ + [proposal_info.current_coin.name(), PROPOSED_PUZ_HASH.as_atom(), 0], + validator_solution, + puzzle_reveal, + delegated_solution, + ] + ) + else: + treasury_solution = Program.to([0, 0, 0, 0, 0, 0]) + + assert self.dao_info.current_treasury_coin is not None + parent_info = self.get_parent_for_coin(self.dao_info.current_treasury_coin) + assert parent_info is not None + full_treasury_solution = Program.to( + [ + [ + parent_info.parent_name, + parent_info.inner_puzzle_hash, + parent_info.amount, + ], + self.dao_info.current_treasury_coin.amount, + treasury_solution, + ] + ) + + treasury_cs = CoinSpend(self.dao_info.current_treasury_coin, full_treasury_puz, full_treasury_solution) + + if self_destruct: + spend_bundle = SpendBundle([proposal_cs, treasury_cs], AugSchemeMPL.aggregate([])) + else: + spend_bundle = SpendBundle([proposal_cs, timer_cs, treasury_cs], AugSchemeMPL.aggregate([])) + if fee > 0: + chia_tx = await self.standard_wallet.create_tandem_xch_tx(fee, tx_config) + assert chia_tx.spend_bundle is not None + full_spend = SpendBundle.aggregate([spend_bundle, chia_tx.spend_bundle]) + else: + full_spend = SpendBundle.aggregate([spend_bundle]) + if cat_spend_bundle is not None: + full_spend = full_spend.aggregate([full_spend, cat_spend_bundle]) + if delegated_puzzle_sb is not None: + full_spend = full_spend.aggregate([full_spend, delegated_puzzle_sb]) + + record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=get_finished_state_puzzle(proposal_info.proposal_id).get_tree_hash(), + amount=uint64(1), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=full_spend, + additions=full_spend.additions(), + removals=full_spend.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + return record + + async def fetch_proposed_puzzle_reveal(self, proposal_id: bytes32) -> Program: + wallet_node: Any = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + # The proposal_id is launcher coin, so proposal_id's child is eve and the eve spend contains the reveal + children = await wallet_node.fetch_children(proposal_id, peer) + eve_state = children[0] + + eve_spend = await fetch_coin_spend(eve_state.created_height, eve_state.coin, peer) + puzzle_reveal = get_proposed_puzzle_reveal_from_solution(eve_spend.solution.to_program()) + return puzzle_reveal + + async def fetch_cat_lineage_proof(self, cat_coin: Coin) -> LineageProof: + wallet_node: Any = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + state = await wallet_node.get_coin_state([cat_coin.parent_coin_info], peer) + assert state is not None + # CoinState contains Coin, spent_height, and created_height, + parent_spend = await fetch_coin_spend(state[0].spent_height, state[0].coin, peer) + parent_inner_puz = get_innerpuzzle_from_cat_puzzle(parent_spend.puzzle_reveal.to_program()) + return LineageProof(state[0].coin.parent_coin_info, parent_inner_puz.get_tree_hash(), state[0].coin.amount) + + async def _create_treasury_fund_transaction( + self, + funding_wallet: WalletProtocol[Any], + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> List[TransactionRecord]: + if funding_wallet.type() == WalletType.STANDARD_WALLET.value: + p2_singleton_puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id, asset_id=None) + wallet: Wallet = funding_wallet # type: ignore[assignment] + return await wallet.generate_signed_transaction( + amount, + p2_singleton_puzhash, + tx_config, + fee=fee, + memos=[p2_singleton_puzhash], + ) + elif funding_wallet.type() == WalletType.CAT.value: + cat_wallet: CATWallet = funding_wallet # type: ignore[assignment] + # generate_signed_transaction has a different type signature in Wallet and CATWallet + # CATWallet uses a List of amounts and a List of puzhashes as the first two arguments + p2_singleton_puzhash = get_p2_singleton_puzhash(self.dao_info.treasury_id) + tx_records: List[TransactionRecord] = await cat_wallet.generate_signed_transaction( + [amount], + [p2_singleton_puzhash], + tx_config, + fee=fee, + extra_conditions=extra_conditions, + ) + return tx_records + else: # pragma: no cover + raise ValueError(f"Assets of type {funding_wallet.type()} are not currently supported.") + + async def create_add_funds_to_treasury_spend( + self, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + funding_wallet_id: uint32 = uint32(1), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + # set up the p2_singleton + funding_wallet = self.wallet_state_manager.wallets[funding_wallet_id] + tx_record = await self._create_treasury_fund_transaction( + funding_wallet, amount, tx_config, fee, extra_conditions=extra_conditions + ) + return tx_record[0] + + async def fetch_singleton_lineage_proof(self, coin: Coin) -> LineageProof: + wallet_node: Any = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + state = await wallet_node.get_coin_state([coin.parent_coin_info], peer) + assert state is not None + # CoinState contains Coin, spent_height, and created_height, + parent_spend = await fetch_coin_spend(state[0].spent_height, state[0].coin, peer) + parent_inner_puz = get_inner_puzzle_from_singleton(parent_spend.puzzle_reveal.to_program()) + assert isinstance(parent_inner_puz, Program) + return LineageProof(state[0].coin.parent_coin_info, parent_inner_puz.get_tree_hash(), state[0].coin.amount) + + async def free_coins_from_finished_proposals( + self, + tx_config: TXConfig, + fee: uint64 = uint64(0), + extra_conditions: Tuple[Condition, ...] = tuple(), + ) -> TransactionRecord: + dao_cat_wallet: DAOCATWallet = self.wallet_state_manager.wallets[self.dao_info.dao_cat_wallet_id] + full_spend = None + spends = [] + closed_list = [] + finished_puz = None + for proposal_info in self.dao_info.proposals_list: + if proposal_info.closed: + closed_list.append(proposal_info.proposal_id) + inner_solution = Program.to( + [ + proposal_info.current_coin.amount, + ] + ) + lineage_proof: LineageProof = await self.fetch_singleton_lineage_proof(proposal_info.current_coin) + solution = Program.to([lineage_proof.to_program(), proposal_info.current_coin.amount, inner_solution]) + finished_puz = get_finished_state_puzzle(proposal_info.proposal_id) + cs = CoinSpend(proposal_info.current_coin, finished_puz, solution) + prop_sb = SpendBundle([cs], AugSchemeMPL.aggregate([])) + spends.append(prop_sb) + + sb = await dao_cat_wallet.remove_active_proposal(closed_list, tx_config=tx_config) + spends.append(sb) + + if not spends: # pragma: no cover + raise ValueError("No proposals are available for release") + + full_spend = SpendBundle.aggregate(spends) + if fee > 0: + chia_tx = await self.standard_wallet.create_tandem_xch_tx(fee, tx_config) + assert chia_tx.spend_bundle is not None + full_spend = full_spend.aggregate([full_spend, chia_tx.spend_bundle]) + + assert isinstance(finished_puz, Program) + record = TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=finished_puz.get_tree_hash(), + amount=uint64(1), + fee_amount=fee, + confirmed=False, + sent=uint32(10), + spend_bundle=full_spend, + additions=full_spend.additions(), + removals=full_spend.removals(), + wallet_id=self.id(), + sent_to=[], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(extra_conditions), + ) + return record + + async def parse_proposal(self, proposal_id: bytes32) -> Dict[str, Any]: + for prop_info in self.dao_info.proposals_list: + if prop_info.proposal_id == proposal_id: + state = await self.get_proposal_state(proposal_id) + proposed_puzzle_reveal = await self.fetch_proposed_puzzle_reveal(proposal_id) + proposal_type, curried_args = get_proposal_args(proposed_puzzle_reveal) + if proposal_type == ProposalType.SPEND: + cat_launcher = create_cat_launcher_for_singleton_id(self.dao_info.treasury_id) + ( + TREASURY_SINGLETON_STRUCT, + CAT_MOD_HASH, + CONDITIONS, + LIST_OF_TAILHASH_CONDITIONS, + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH, + ) = curried_args.as_iter() + mint_amount = None + new_cat_puzhash = None + xch_created_coins = [] + for cond in CONDITIONS.as_iter(): + if cond.first().as_int() == 51: + if cond.rest().first().as_atom() == cat_launcher.get_tree_hash(): + mint_amount = cond.rest().rest().first().as_int() + new_cat_puzhash = cond.rest().rest().rest().first().first().as_atom() + else: + cc = {"puzzle_hash": cond.at("rf").as_atom(), "amount": cond.at("rrf").as_int()} + xch_created_coins.append(cc) + + asset_create_coins: List[Dict[Any, Any]] = [] + for asset in LIST_OF_TAILHASH_CONDITIONS.as_iter(): + if asset == Program.to(0): # pragma: no cover + asset_dict: Optional[Dict[str, Any]] = None + else: + asset_id = asset.first().as_atom() + cc_list = [] + for cond in asset.rest().first().as_iter(): + if cond.first().as_int() == 51: + asset_dict = { + "puzzle_hash": cond.at("rf").as_atom(), + "amount": cond.at("rrf").as_int(), + } + # cc_list.append([asset_id, asset_dict]) + cc_list.append(asset_dict) + asset_create_coins.append({"asset_id": asset_id, "conditions": cc_list}) + dictionary: Dict[str, Any] = { + "state": state, + "proposal_type": proposal_type.value, + "proposed_puzzle_reveal": proposed_puzzle_reveal, + "xch_conditions": xch_created_coins, + "asset_conditions": asset_create_coins, + } + if mint_amount is not None and new_cat_puzhash is not None: + dictionary["mint_amount"] = mint_amount + dictionary["new_cat_puzhash"] = new_cat_puzhash + elif proposal_type == ProposalType.UPDATE: + dao_rules = get_dao_rules_from_update_proposal(proposed_puzzle_reveal) + dictionary = { + "state": state, + "proposal_type": proposal_type.value, + "dao_rules": dao_rules, + } + return dictionary + raise ValueError(f"Unable to find proposal with id: {proposal_id.hex()}") # pragma: no cover + + async def add_parent(self, name: bytes32, parent: Optional[LineageProof]) -> None: + self.log.info(f"Adding parent {name}: {parent}") + current_list = self.dao_info.parent_info.copy() + current_list.append((name, parent)) + dao_info: DAOInfo = DAOInfo( + self.dao_info.treasury_id, + self.dao_info.cat_wallet_id, + self.dao_info.dao_cat_wallet_id, + self.dao_info.proposals_list, + current_list, + self.dao_info.current_treasury_coin, + self.dao_info.current_treasury_innerpuz, + self.dao_info.singleton_block_height, + self.dao_info.filter_below_vote_amount, + self.dao_info.assets, + self.dao_info.current_height, + ) + await self.save_info(dao_info) + + async def save_info(self, dao_info: DAOInfo) -> None: + self.dao_info = dao_info + current_info = self.wallet_info + data_str = json.dumps(dao_info.to_json_dict()) + wallet_info = WalletInfo(current_info.id, current_info.name, current_info.type, data_str) + self.wallet_info = wallet_info + await self.wallet_state_manager.user_store.update_wallet(wallet_info) + + def generate_wallet_name(self) -> str: + """ + Generate a new DAO wallet name + :return: wallet name + """ + max_num = 0 + for wallet in self.wallet_state_manager.wallets.values(): + if wallet.type() == WalletType.DAO: # pragma: no cover + matched = re.search(r"^Profile (\d+)$", wallet.wallet_info.name) # TODO: bug: wallet.wallet_info + if matched and int(matched.group(1)) > max_num: + max_num = int(matched.group(1)) + return f"Profile {max_num + 1}" + + def require_derivation_paths(self) -> bool: + return False + + def get_cat_wallet_id(self) -> uint32: + return self.dao_info.cat_wallet_id + + async def enter_dao_cat_voting_mode( + self, + amount: uint64, + tx_config: TXConfig, + ) -> List[TransactionRecord]: + dao_cat_wallet: DAOCATWallet = self.wallet_state_manager.wallets[self.dao_info.dao_cat_wallet_id] + return await dao_cat_wallet.enter_dao_cat_voting_mode(amount, tx_config) + + @staticmethod + def get_next_interesting_coin(spend: CoinSpend) -> Optional[Coin]: # pragma: no cover + # CoinSpend of one of the coins that we cared about. This coin was spent in a block, but might be in a reorg + # If we return a value, it is a coin that we are also interested in (to support two transitions per block) + return get_most_recent_singleton_coin_from_coin_spend(spend) + + async def get_tip(self, singleton_id: bytes32) -> Optional[Tuple[uint32, SingletonRecord]]: + ret: List[ + Tuple[uint32, SingletonRecord] + ] = await self.wallet_state_manager.singleton_store.get_records_by_singleton_id(singleton_id) + if len(ret) == 0: # pragma: no cover + return None + return ret[-1] + + async def get_tip_created_height(self, singleton_id: bytes32) -> Optional[int]: # pragma: no cover + ret: List[ + Tuple[uint32, SingletonRecord] + ] = await self.wallet_state_manager.singleton_store.get_records_by_singleton_id(singleton_id) + if len(ret) < 1: + return None + assert isinstance(ret[-2], SingletonRecord) + return ret[-2].removed_height + + async def add_or_update_proposal_info( + self, + new_state: CoinSpend, + block_height: uint32, + ) -> None: + new_dao_info = copy.copy(self.dao_info) + puzzle = get_inner_puzzle_from_singleton(new_state.puzzle_reveal) + if puzzle is None: # pragma: no cover + raise ValueError("get_innerpuzzle_from_puzzle failed") + solution = ( + Program.from_bytes(bytes(new_state.solution)).rest().rest().first() + ) # get proposal solution from full singleton solution + singleton_id = singleton.get_singleton_id_from_puzzle(new_state.puzzle_reveal) + if singleton_id is None: # pragma: no cover + raise ValueError("get_singleton_id_from_puzzle failed") + ended = False + dao_rules = get_treasury_rules_from_puzzle(self.dao_info.current_treasury_innerpuz) + current_coin = get_most_recent_singleton_coin_from_coin_spend(new_state) + if current_coin is None: # pragma: no cover + raise ValueError("get_most_recent_singleton_coin_from_coin_spend failed") + + current_innerpuz = get_new_puzzle_from_proposal_solution(puzzle, solution) + assert isinstance(current_innerpuz, Program) + assert current_coin.puzzle_hash == curry_singleton(singleton_id, current_innerpuz).get_tree_hash() + # check if our parent puzzle was the finished state + if puzzle.uncurry()[0] == DAO_FINISHED_STATE: + ended = True + index = 0 + for current_info in new_dao_info.proposals_list: + # Search for current proposal_info + if current_info.proposal_id == singleton_id: + new_proposal_info = ProposalInfo( + singleton_id, + puzzle, + current_info.amount_voted, + current_info.yes_votes, + current_coin, + current_innerpuz, + current_info.timer_coin, + block_height, + current_info.passed, + ended, + ) + new_dao_info.proposals_list[index] = new_proposal_info + await self.save_info(new_dao_info) + future_parent = LineageProof( + new_state.coin.parent_coin_info, + puzzle.get_tree_hash(), + uint64(new_state.coin.amount), + ) + await self.add_parent(new_state.coin.name(), future_parent) + return + + # check if we are the finished state + if current_innerpuz == get_finished_state_inner_puzzle(singleton_id): + ended = True + + c_a, curried_args = uncurry_proposal(puzzle) + ( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_PUZHASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + _DAO_TREASURY_MOD_HASH, + lockup_self_hash, + cat_tail_hash, + treasury_id, + ) = curried_args.as_iter() + ( + curry_one, + proposal_id, + proposed_puzzle_hash, + yes_votes, + total_votes, + ) = c_a.as_iter() + + if current_coin is None: # pragma: no cover + raise RuntimeError("get_most_recent_singleton_coin_from_coin_spend({new_state}) failed") + + timer_coin = None + if solution.at("rrrrrrf").as_int() == 0: + # we need to add the vote amounts from the solution to get accurate totals + is_yes_vote = solution.at("rf").as_int() + votes_added = 0 + for vote_amount in solution.first().as_iter(): + votes_added += vote_amount.as_int() + else: + # If we have entered the finished state + # TODO: we need to alert the user that they can free up their coins + is_yes_vote = 0 + votes_added = 0 + + if current_coin.amount < dao_rules.proposal_minimum_amount and not ended: # pragma: no cover + raise ValueError("this coin does not meet the minimum requirements and can be ignored") + new_total_votes = total_votes.as_int() + votes_added + if new_total_votes < self.dao_info.filter_below_vote_amount: # pragma: no cover + return # ignore all proposals below the filter amount + + if is_yes_vote == 1: + new_yes_votes = yes_votes.as_int() + votes_added + else: + new_yes_votes = yes_votes.as_int() + + required_yes_votes = (self.dao_rules.attendance_required * self.dao_rules.pass_percentage) // 10000 + yes_votes_needed = max(0, required_yes_votes - new_yes_votes) + + passed = True if yes_votes_needed == 0 else False + + index = 0 + for current_info in new_dao_info.proposals_list: + # Search for current proposal_info + if current_info.proposal_id == singleton_id: + # If we are receiving a voting spend update + new_proposal_info = ProposalInfo( + singleton_id, + puzzle, + new_total_votes, + new_yes_votes, + current_coin, + current_innerpuz, + current_info.timer_coin, + block_height, + passed, + ended, + ) + new_dao_info.proposals_list[index] = new_proposal_info + await self.save_info(new_dao_info) + future_parent = LineageProof( + new_state.coin.parent_coin_info, + puzzle.get_tree_hash(), + uint64(new_state.coin.amount), + ) + await self.add_parent(new_state.coin.name(), future_parent) + return + index = index + 1 + + # Search for the timer coin + if not ended: + wallet_node: Any = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + children = await wallet_node.fetch_children(singleton_id, peer) + assert len(children) > 0 + found = False + parent_coin_id = singleton_id + + if self.dao_info.current_treasury_innerpuz is None: # pragma: no cover + raise ValueError("self.dao_info.current_treasury_innerpuz is None") + + timer_coin_puzhash = get_proposal_timer_puzzle( + cat_tail_hash.as_atom(), + singleton_id, + self.dao_info.treasury_id, + ).get_tree_hash() + + while not found and len(children) > 0: + children = await wallet_node.fetch_children(parent_coin_id, peer) + if len(children) == 0: # pragma: no cover + break + children_state = [child for child in children if child.coin.amount % 2 == 1] + assert children_state is not None + assert len(children_state) > 0 + child_state = children_state[0] + for child in children: + if child.coin.puzzle_hash == timer_coin_puzhash: + found = True + timer_coin = child.coin + break + child_coin = child_state.coin + parent_coin_id = child_coin.name() + + # If we reach here then we don't currently know about this coin + new_proposal_info = ProposalInfo( + singleton_id, + puzzle, + uint64(new_total_votes), + uint64(new_yes_votes), + current_coin, + current_innerpuz, + timer_coin, # if this is None then the proposal has finished + block_height, # block height that current proposal singleton coin was created + passed, + ended, + ) + new_dao_info.proposals_list.append(new_proposal_info) + await self.save_info(new_dao_info) + future_parent = LineageProof( + new_state.coin.parent_coin_info, + puzzle.get_tree_hash(), + uint64(new_state.coin.amount), + ) + await self.add_parent(new_state.coin.name(), future_parent) + return + + async def update_closed_proposal_coin(self, new_state: CoinSpend, block_height: uint32) -> None: + new_dao_info = copy.copy(self.dao_info) + puzzle = get_inner_puzzle_from_singleton(new_state.puzzle_reveal) + proposal_id = singleton.get_singleton_id_from_puzzle(new_state.puzzle_reveal) + current_coin = get_most_recent_singleton_coin_from_coin_spend(new_state) + index = 0 + for pi in self.dao_info.proposals_list: + if pi.proposal_id == proposal_id: + assert isinstance(current_coin, Coin) + new_info = ProposalInfo( + proposal_id, + pi.inner_puzzle, + pi.amount_voted, + pi.yes_votes, + current_coin, + pi.current_innerpuz, + pi.timer_coin, + pi.singleton_block_height, + pi.passed, + pi.closed, + ) + new_dao_info.proposals_list[index] = new_info + await self.save_info(new_dao_info) + assert isinstance(puzzle, Program) + future_parent = LineageProof( + new_state.coin.parent_coin_info, + puzzle.get_tree_hash(), + uint64(new_state.coin.amount), + ) + await self.add_parent(new_state.coin.name(), future_parent) + return + index = index + 1 + + async def get_proposal_state(self, proposal_id: bytes32) -> Dict[str, Union[int, bool]]: + """ + Use this to figure out whether a proposal has passed or failed and whether it can be closed + Given a proposal_id: + - if required yes votes are recorded then proposal passed. + - if timelock and attendance are met then proposal can close + Returns a dict of passed and closable bools, and the remaining votes/blocks needed + + Note that a proposal can be in a passed and closable state now, but become failed if a large number of + 'no' votes are recieved before the soft close is reached. + """ + for prop in self.dao_info.proposals_list: + if prop.proposal_id == proposal_id: + is_closed = prop.closed + break + else: # pragma: no cover + raise ValueError(f"Proposal not found for id {proposal_id}") + + wallet_node = self.wallet_state_manager.wallet_node + peer: WSChiaConnection = wallet_node.get_full_node_peer() + if peer is None: # pragma: no cover + raise ValueError("Could not find any peers to request puzzle and solution from") + assert isinstance(prop.timer_coin, Coin) + timer_cs = (await wallet_node.get_coin_state([prop.timer_coin.name()], peer))[0] + peak = await self.wallet_state_manager.blockchain.get_peak_block() + blocks_elapsed = peak.height - timer_cs.created_height + + required_yes_votes = (self.dao_rules.attendance_required * self.dao_rules.pass_percentage) // 10000 + total_votes_needed = max(0, self.dao_rules.attendance_required - prop.amount_voted) + yes_votes_needed = max(0, required_yes_votes - prop.yes_votes) + blocks_needed = max(0, self.dao_rules.proposal_timelock - blocks_elapsed) + + passed = True if yes_votes_needed == 0 else False + closable = True if total_votes_needed == blocks_needed == 0 else False + proposal_state = { + "total_votes_needed": total_votes_needed, + "yes_votes_needed": yes_votes_needed, + "blocks_needed": blocks_needed, + "passed": passed, + "closable": closable, + "closed": is_closed, + } + return proposal_state + + async def update_treasury_info( + self, + new_state: CoinSpend, + block_height: uint32, + ) -> None: + if self.dao_info.singleton_block_height <= block_height: + # TODO: what do we do here? + # return + pass + puzzle = get_inner_puzzle_from_singleton(new_state.puzzle_reveal) + if puzzle is None: # pragma: no cover + raise ValueError("get_innerpuzzle_from_puzzle failed") + solution = ( + Program.from_bytes(bytes(new_state.solution)).rest().rest().first() + ) # get proposal solution from full singleton solution + new_innerpuz = get_new_puzzle_from_treasury_solution(puzzle, solution) + child_coin = get_most_recent_singleton_coin_from_coin_spend(new_state) + assert isinstance(child_coin, Coin) + assert isinstance(self.dao_info.current_treasury_coin, Coin) + if child_coin.puzzle_hash != self.dao_info.current_treasury_coin.puzzle_hash: + # update dao rules + assert isinstance(new_innerpuz, Program) + self.dao_rules = get_treasury_rules_from_puzzle(new_innerpuz) + dao_info = dataclasses.replace( + self.dao_info, + current_treasury_coin=child_coin, + current_treasury_innerpuz=new_innerpuz, + singleton_block_height=block_height, + ) + await self.save_info(dao_info) + future_parent = LineageProof( + new_state.coin.parent_coin_info, + puzzle.get_tree_hash(), + uint64(new_state.coin.amount), + ) + await self.add_parent(new_state.coin.name(), future_parent) + return + + async def get_spend_history(self, singleton_id: bytes32) -> List[Tuple[uint32, CoinSpend]]: # pragma: no cover + ret: List[ + Tuple[uint32, CoinSpend] + ] = await self.wallet_state_manager.singleton_store.get_records_by_singleton_id(singleton_id) + if len(ret) == 0: + raise ValueError(f"No records found in singleton store for singleton id {singleton_id}") + return ret + + async def apply_state_transition(self, new_state: CoinSpend, block_height: uint32) -> bool: + """ + We are being notified of a singleton state transition. A Singleton has been spent. + Returns True iff the spend is a valid transition spend for the singleton, False otherwise. + """ + + self.log.info( + f"DAOWallet.apply_state_transition called with the height: {block_height} and CoinSpend of {new_state.coin.name()}." + ) + singleton_id = get_singleton_id_from_puzzle(new_state.puzzle_reveal) + if not singleton_id: # pragma: no cover + raise ValueError("Received a non singleton coin for dao wallet") + tip: Optional[Tuple[uint32, SingletonRecord]] = await self.get_tip(singleton_id) + if tip is None: # pragma: no cover + # this is our first time, just store it + await self.wallet_state_manager.singleton_store.add_spend(self.wallet_id, new_state, block_height) + else: + assert isinstance(tip, SingletonRecord) + tip_spend = tip.parent_coinspend + + tip_coin: Optional[Coin] = get_most_recent_singleton_coin_from_coin_spend(tip_spend) + assert tip_coin is not None + # TODO: Add check for pending transaction on our behalf in here + # if we have pending transaction that is now invalidated, then: + # check if we should auto re-create spend or flash error to use (should we have a failed tx db?) + await self.wallet_state_manager.singleton_store.add_spend(self.id(), new_state, block_height) + + # Consume new DAOBlockchainInfo + # Determine if this is a treasury spend or a proposal spend + puzzle = get_inner_puzzle_from_singleton(new_state.puzzle_reveal) + assert puzzle + try: + mod, curried_args = puzzle.uncurry() + except ValueError as e: # pragma: no cover + self.log.warning("Cannot uncurry puzzle in DAO Wallet: error: %s", e) + raise e + if mod == DAO_TREASURY_MOD: + await self.update_treasury_info(new_state, block_height) + elif (mod == DAO_PROPOSAL_MOD) or (mod.uncurry()[0] == DAO_PROPOSAL_MOD): + await self.add_or_update_proposal_info(new_state, block_height) + elif mod == DAO_FINISHED_STATE: + await self.update_closed_proposal_coin(new_state, block_height) + else: # pragma: no cover + raise ValueError(f"Unsupported spend in DAO Wallet: {self.id()}") + + return True diff --git a/chia/wallet/did_wallet/did_wallet.py b/chia/wallet/did_wallet/did_wallet.py index d24d7c3be39a..5d8390e073f9 100644 --- a/chia/wallet/did_wallet/did_wallet.py +++ b/chia/wallet/did_wallet/did_wallet.py @@ -235,7 +235,7 @@ async def create_new_did_wallet_from_coin_spend( None, None, False, - json.dumps(did_wallet_puzzles.program_to_metadata(metadata)), + json.dumps(did_wallet_puzzles.did_program_to_metadata(metadata)), ) self.check_existed_did() info_as_string = json.dumps(self.did_info.to_json_dict()) @@ -404,7 +404,7 @@ async def coin_added(self, coin: Coin, _: uint32, peer: WSChiaConnection, parent None, None, False, - json.dumps(did_wallet_puzzles.program_to_metadata(did_data.metadata)), + json.dumps(did_wallet_puzzles.did_program_to_metadata(did_data.metadata)), ) await self.save_info(new_info) diff --git a/chia/wallet/did_wallet/did_wallet_puzzles.py b/chia/wallet/did_wallet/did_wallet_puzzles.py index 0bbb99514cd2..9852dd545677 100644 --- a/chia/wallet/did_wallet/did_wallet_puzzles.py +++ b/chia/wallet/did_wallet/did_wallet_puzzles.py @@ -191,7 +191,7 @@ def metadata_to_program(metadata: Dict) -> Program: return Program.to(kv_list) -def program_to_metadata(program: Program) -> Dict: +def did_program_to_metadata(program: Program) -> Dict: """ Convert a program to a metadata dict :param program: Chialisp program contains the metadata diff --git a/chia/wallet/nft_wallet/nft_puzzles.py b/chia/wallet/nft_wallet/nft_puzzles.py index c8b0066a9cbc..85a9fad8e190 100644 --- a/chia/wallet/nft_wallet/nft_puzzles.py +++ b/chia/wallet/nft_wallet/nft_puzzles.py @@ -155,7 +155,7 @@ def metadata_to_program(metadata: Dict[bytes, Any]) -> Program: return program -def program_to_metadata(program: Program) -> Dict[bytes, Any]: +def nft_program_to_metadata(program: Program) -> Dict[bytes, Any]: """ Convert a program to a metadata dict :param program: Chialisp program contains the metadata @@ -190,7 +190,7 @@ def update_metadata(metadata: Program, update_condition: Program) -> Program: :param update_condition: Update metadata conditions :return: Updated metadata """ - new_metadata: Dict[bytes, Any] = program_to_metadata(metadata) + new_metadata: Dict[bytes, Any] = nft_program_to_metadata(metadata) uri: Program = update_condition.rest().rest().first() prepend_value(uri.first().as_python(), uri.rest(), new_metadata) return metadata_to_program(new_metadata) diff --git a/chia/wallet/puzzles/curry_by_index.clib b/chia/wallet/puzzles/curry_by_index.clib new file mode 100644 index 000000000000..299f2fd6975b --- /dev/null +++ b/chia/wallet/puzzles/curry_by_index.clib @@ -0,0 +1,16 @@ +( + (defun recurry_by_index_ordered ( + index_value_pairs_list ; MUST BE ORDERED + current_position ; must be 0 on initial call + CURRENT_PARAMS ; current list of curry params + ) + (if index_value_pairs_list + (if (= (f (f index_value_pairs_list)) current_position) + (c (r (f index_value_pairs_list)) (recurry_by_index_ordered (r index_value_pairs_list) (+ current_position 1) (r CURRENT_PARAMS))) + (c (f CURRENT_PARAMS) (recurry_by_index_ordered index_value_pairs_list (+ current_position 1) (r CURRENT_PARAMS))) + ) + () + ) + ) + +) diff --git a/chia/wallet/puzzles/dao_cat_eve.clsp b/chia/wallet/puzzles/dao_cat_eve.clsp new file mode 100644 index 000000000000..c19b8c16b794 --- /dev/null +++ b/chia/wallet/puzzles/dao_cat_eve.clsp @@ -0,0 +1,17 @@ +; This file is what the first form the CAT takes, and then it gets immediately eve spent out of here. +; This allows the coin to move into its real state already having been eve spent and validated to not be a fake CAT. +; The trick is that we won't know what the real state puzzlehash reveal is, but we will know what this is. +; Mint into this, eve spend out of this +(mod ( + NEW_PUZZLE_HASH ; this is the CAT inner_puzzle + my_amount + tail_reveal + tail_solution + ) + (include condition_codes.clib) + (list + (list CREATE_COIN NEW_PUZZLE_HASH my_amount (list NEW_PUZZLE_HASH)) + (list ASSERT_MY_AMOUNT my_amount) + (list CREATE_COIN 0 -113 tail_reveal tail_solution) ; this is secure because anything but the real values won't work + ) +) diff --git a/chia/wallet/puzzles/dao_cat_eve.clsp.hex b/chia/wallet/puzzles/dao_cat_eve.clsp.hex new file mode 100644 index 000000000000..23f72b9be40f --- /dev/null +++ b/chia/wallet/puzzles/dao_cat_eve.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ff06ffff04ff05ffff04ff0bffff04ffff04ff05ff8080ff8080808080ffff04ffff04ff04ffff04ff0bff808080ffff04ffff04ff06ffff04ff80ffff04ffff01818fffff04ff17ffff04ff2fff808080808080ff80808080ffff04ffff01ff4933ff018080 diff --git a/chia/wallet/puzzles/dao_cat_launcher.clsp b/chia/wallet/puzzles/dao_cat_launcher.clsp new file mode 100644 index 000000000000..61c8bdfe68a3 --- /dev/null +++ b/chia/wallet/puzzles/dao_cat_launcher.clsp @@ -0,0 +1,36 @@ +(mod ( + TREASURY_SINGLETON_STRUCT + treasury_inner_puz_hash + parent_parent + new_puzzle_hash ; the full CAT puzzle + amount + ) + (include condition_codes.clib) + (include curry-and-treehash.clib) + + (defun calculate_singleton_puzzle_hash (PROPOSAL_SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f PROPOSAL_SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree PROPOSAL_SINGLETON_STRUCT) + ) + ) + + (defun create_parent_conditions (parent_id new_puzzle_hash amount) + (list + (list ASSERT_COIN_ANNOUNCEMENT (sha256 parent_id (sha256tree (list 'm' new_puzzle_hash)))) + (list ASSERT_MY_PARENT_ID parent_id) + ) + ) + + (c + (list CREATE_COIN new_puzzle_hash amount (list new_puzzle_hash)) + (c + (list ASSERT_MY_AMOUNT amount) + (create_parent_conditions + (sha256 parent_parent (calculate_singleton_puzzle_hash TREASURY_SINGLETON_STRUCT treasury_inner_puz_hash) ONE) + new_puzzle_hash + amount + ) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_cat_launcher.clsp.hex b/chia/wallet/puzzles/dao_cat_launcher.clsp.hex new file mode 100644 index 000000000000..8e2dcfd95e16 --- /dev/null +++ b/chia/wallet/puzzles/dao_cat_launcher.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ff34ffff04ff2fffff04ff5fffff04ffff04ff2fff8080ff8080808080ffff04ffff04ff28ffff04ff5fff808080ffff02ff36ffff04ff02ffff04ffff0bff17ffff02ff26ffff04ff02ffff04ff05ffff04ff0bff8080808080ff3c80ffff04ff2fffff04ff5fff8080808080808080ffff04ffff01ffffff3dff4947ffff0233ff0401ffff01ff02ff02ffff03ff05ffff01ff02ff3affff04ff02ffff04ff0dffff04ffff0bff2affff0bff3cff2c80ffff0bff2affff0bff2affff0bff3cff1280ff0980ffff0bff2aff0bffff0bff3cff8080808080ff8080808080ffff010b80ff0180ffffff02ff2effff04ff02ffff04ff09ffff04ff0bffff04ffff02ff3effff04ff02ffff04ff05ff80808080ff808080808080ff04ffff04ff10ffff04ffff0bff05ffff02ff3effff04ff02ffff04ffff04ffff016dffff04ff0bff808080ff8080808080ff808080ffff04ffff04ff38ffff04ff05ff808080ff808080ffff0bff2affff0bff3cff2480ffff0bff2affff0bff2affff0bff3cff1280ff0580ffff0bff2affff02ff3affff04ff02ffff04ff07ffff04ffff0bff3cff3c80ff8080808080ffff0bff3cff8080808080ff02ffff03ffff07ff0580ffff01ff0bffff0102ffff02ff3effff04ff02ffff04ff09ff80808080ffff02ff3effff04ff02ffff04ff0dff8080808080ffff01ff0bffff0101ff058080ff0180ff018080 diff --git a/chia/wallet/puzzles/dao_finished_state.clsp b/chia/wallet/puzzles/dao_finished_state.clsp new file mode 100644 index 000000000000..d0d9673004c0 --- /dev/null +++ b/chia/wallet/puzzles/dao_finished_state.clsp @@ -0,0 +1,35 @@ +; This code is the end state of a proposal or a dividend. +; It is an oracle which simply recreates itself and emits an announcement that it has concluded operation + +(mod (SINGLETON_STRUCT DAO_FINISHED_STATE_MOD_HASH my_amount) + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun wrap_in_singleton (SINGLETON_STRUCT my_inner_puzhash) + (puzzle-hash-of-curried-function (f SINGLETON_STRUCT) + my_inner_puzhash + (sha256tree SINGLETON_STRUCT) + ) + ) + + (defun recreate_self (SINGLETON_STRUCT DAO_FINISHED_STATE_MOD_HASH) + (puzzle-hash-of-curried-function DAO_FINISHED_STATE_MOD_HASH + (sha256 ONE DAO_FINISHED_STATE_MOD_HASH) + (sha256tree SINGLETON_STRUCT) + ) + ) + + + (let + ( + (my_inner_puzhash (recreate_self SINGLETON_STRUCT DAO_FINISHED_STATE_MOD_HASH)) + ) + (list + (list ASSERT_MY_PUZZLEHASH (wrap_in_singleton SINGLETON_STRUCT my_inner_puzhash)) + (list ASSERT_MY_AMOUNT my_amount) + (list CREATE_COIN my_inner_puzhash my_amount) + (list CREATE_PUZZLE_ANNOUNCEMENT 0) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_finished_state.clsp.hex b/chia/wallet/puzzles/dao_finished_state.clsp.hex new file mode 100644 index 000000000000..e9bf9163b7dd --- /dev/null +++ b/chia/wallet/puzzles/dao_finished_state.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff0148ffff04ffff02ff16ffff04ff02ffff04ffff05ffff06ff018080ffff04ffff02ff1effff04ff02ffff04ff05ffff04ff0bff8080808080ff8080808080ffff01808080ffff04ffff04ffff0149ffff04ffff05ffff06ffff06ffff06ff0180808080ffff01808080ffff04ffff04ffff0133ffff04ffff02ff1effff04ff02ffff04ff05ffff04ff0bff8080808080ffff04ffff05ffff06ffff06ffff06ff0180808080ffff0180808080ffff04ffff04ffff013effff04ffff0180ffff01808080ffff018080808080ffff04ffff01ffffff02ffff03ff05ffff01ff02ffff01ff02ff08ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff08ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ffff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff0affff04ff02ffff04ffff05ff0580ff80808080ffff02ff0affff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ffff02ff0cffff04ff02ffff04ff09ffff04ff0bffff04ffff02ff0affff04ff02ffff04ff05ff80808080ff808080808080ff02ff0cffff04ff02ffff04ff0bffff04ffff0bffff0101ff0b80ffff04ffff02ff0affff04ff02ffff04ff05ff80808080ff808080808080ff018080 diff --git a/chia/wallet/puzzles/dao_finished_state.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_finished_state.clsp.hex.sha256tree new file mode 100644 index 000000000000..ecf06a70d925 --- /dev/null +++ b/chia/wallet/puzzles/dao_finished_state.clsp.hex.sha256tree @@ -0,0 +1 @@ +7f3cc356732907933a8f9b1ccf16f71735d07340eb38c847aa402e97d75eb40b diff --git a/chia/wallet/puzzles/dao_lockup.clsp b/chia/wallet/puzzles/dao_lockup.clsp new file mode 100644 index 000000000000..5ffb8320f6da --- /dev/null +++ b/chia/wallet/puzzles/dao_lockup.clsp @@ -0,0 +1,288 @@ +; This code is the "voting mode" for a DAO CAT. +; The coin can be spent from this state to vote on a proposal or claim a dividend. +; It locks the CAT in while it has active votes/dividends going on. +; Once a vote or dividend closes, then the coin can spend itself to remove that coin from the "active list" +; If the "active list" is empty the coin can leave the voting mode + +(mod ( + ; this is the first curry + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + DAO_FINISHED_STATE_MOD_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + ; this is the second curry + SELF_HASH ; this is the self_hash Optimization + ACTIVE_VOTES ; "active votes" list + INNERPUZ + ; this is the solution + my_id ; if my_id is 0 we do the return to return_address (exit voting mode) spend case + inner_solution + my_amount + new_proposal_vote_id_or_removal_id ; removal_id is a list of removal_ids + proposal_innerpuzhash ; list of singleton innerpuzhashes which should match the order of the new_proposal_vote_id list + vote_info + vote_amount + my_inner_puzhash + new_innerpuzhash ; only include this if we're changing owners - secured because coin is still made from inner_puz + ) + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun calculate_finished_state (singleton_struct dao_finished_state) + (puzzle-hash-of-curried-function dao_finished_state + (sha256 ONE dao_finished_state) + (sha256tree singleton_struct) + ) + ) + + ; take two lists and merge them into one + (defun merge_list (list_a list_b) + (if list_a + (c (f list_a) (merge_list (r list_a) list_b)) + list_b + ) + ) + + (defun wrap_in_cat_layer (CAT_MOD_HASH CAT_TAIL_HASH INNERPUZHASH) + (puzzle-hash-of-curried-function CAT_MOD_HASH + INNERPUZHASH + (sha256 ONE CAT_TAIL_HASH) + (sha256 ONE CAT_MOD_HASH) + ) + ) + + ; loop through conditions and check that they aren't trying to create anything they shouldn't + (defun check_conditions (conditions vote_added_puzhash my_amount message vote_amount my_inner_puzhash seen_vote seen_change) + (if conditions + (if (= (f (f conditions)) CREATE_COIN) ; this guarantees that the new coin is obeying the rules - other coins are banned to avoid re-voting + (if (= (f (r (f conditions))) vote_added_puzhash) + (if seen_vote ; assert we haven't already made a coin with the new vote included + (x) + (if (= (f (r (r (f conditions)))) my_amount) ; we vote with all our value + (if seen_change ; assert that we haven't already recreated ourself in some fashion + (x) + (c (f conditions) (check_conditions (r conditions) vote_added_puzhash my_amount message vote_amount my_inner_puzhash 1 1)) + ) + (if (= (f (r (r (f conditions)))) vote_amount) ; we vote with part of our power + (c (f conditions) (check_conditions (r conditions) vote_added_puzhash my_amount message vote_amount my_inner_puzhash 1 seen_change)) + (x) + ) + ) + ) + (if (all + (= (f (r (f conditions))) my_inner_puzhash) + (not seen_change) + (= (f (r (r (f conditions)))) (- my_amount vote_amount)) + ) ; we recreate ourselves with unused voting power + (c (f conditions) (check_conditions (r conditions) vote_added_puzhash my_amount message vote_amount my_inner_puzhash seen_vote 1)) + (x) + ) + ) + (if (= (f (f conditions)) CREATE_PUZZLE_ANNOUNCEMENT) ; this secures the values used to generate message - other messages are banned in case of LIES + (if (= (f (r (f conditions))) message) + (c (f conditions) (check_conditions (r conditions) vote_added_puzhash my_amount message vote_amount my_inner_puzhash seen_vote seen_change)) + (x) + ) + (c (f conditions) (check_conditions (r conditions) vote_added_puzhash my_amount message vote_amount my_inner_puzhash seen_vote seen_change)) + ) + ) + (if (all seen_vote seen_change) ; check all value is accounted for + () + (x) + ) + ) + ) + + ; go through our list of active votes and check that we aren't revoting + (defun check_not_previously_voted ( + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + INNERPUZ + my_id + new_vote_id + active_votes + proposal_innerpuzhash + ) + (if active_votes + (if (= new_vote_id (f active_votes)) ; check new vote id is not equal to an existent vote id + (x) + (check_not_previously_voted + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + INNERPUZ + my_id + new_vote_id + (r active_votes) + proposal_innerpuzhash + ) + ) + (list ASSERT_PUZZLE_ANNOUNCEMENT + (sha256 + (calculate_singleton_puzzle_hash + (c SINGLETON_MOD_HASH (c new_vote_id SINGLETON_LAUNCHER_PUZHASH)) + proposal_innerpuzhash + ) + my_id + ) + ) + ) + ) + + + (defun calculate_singleton_puzzle_hash (PROPOSAL_SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f PROPOSAL_SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree PROPOSAL_SINGLETON_STRUCT) + ) + ) + + (defun calculate_lockup_puzzlehash ( + SELF_HASH + active_votes + innerpuzhash + ) + (puzzle-hash-of-curried-function SELF_HASH + innerpuzhash + (sha256tree active_votes) + (sha256 ONE SELF_HASH) + ) + ) + + (defun for_every_removal_id ( + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + SELF_HASH + DAO_FINISHED_STATE_MOD_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + ACTIVE_VOTES + INNERPUZ + removal_ids + my_amount + unused_votes + ) + (if removal_ids + (c + (list + ASSERT_PUZZLE_ANNOUNCEMENT ; check proposal is actually finished + (sha256 + (calculate_singleton_puzzle_hash + (c SINGLETON_MOD_HASH (c (f removal_ids) SINGLETON_LAUNCHER_PUZHASH)) + (calculate_finished_state + (c SINGLETON_MOD_HASH (c (f removal_ids) SINGLETON_LAUNCHER_PUZHASH)) + DAO_FINISHED_STATE_MOD_HASH + ) + ) + 0 + ) + ) + (for_every_removal_id + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + SELF_HASH + DAO_FINISHED_STATE_MOD_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + ACTIVE_VOTES + INNERPUZ + (r removal_ids) + my_amount + (c (f removal_ids) unused_votes) + ) + ) + (list + (list ASSERT_MY_AMOUNT my_amount) ; assert that we aren't lying about our amount to free up money and re-vote + (list + CREATE_COIN ; recreate self with the finished proposal ID removed + (calculate_lockup_puzzlehash + SELF_HASH + (remove_list_one_entries_from_list_two unused_votes ACTIVE_VOTES) + (sha256tree INNERPUZ) + ) + my_amount + ) + ) + ) + ) + + (defun remove_list_one_entries_from_list_two (list_one list_two) + (if list_one + (remove_item_from_list (f list_one) (remove_list_one_entries_from_list_two (r list_one) list_two)) + list_two + ) + ) + + (defun remove_item_from_list (item list_one) + (if list_one + (if (= (f list_one) item) + (r list_one) ; assuming there are no duplicates + (c (f list_one) (remove_item_from_list item (r list_one))) + ) + () ; item was never in list_one, return list_two entirely + ) + ) + + + ; main + (if my_id + (c (list ASSERT_MY_PUZZLEHASH (wrap_in_cat_layer CAT_MOD_HASH CAT_TAIL_HASH my_inner_puzhash)) + (c + (list ASSERT_MY_AMOUNT my_amount) + (c + (list ASSERT_MY_COIN_ID my_id) + (c + (if new_proposal_vote_id_or_removal_id + (check_not_previously_voted ; this returns a single condition asserting announcement from vote singleton + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + INNERPUZ + my_id + new_proposal_vote_id_or_removal_id + ACTIVE_VOTES + proposal_innerpuzhash + ) + (list REMARK) + ) + + ; loop over conditions and check that we aren't trying to leave voting state + (check_conditions + (a INNERPUZ inner_solution) + (calculate_lockup_puzzlehash ; compare created coin to our own calculation on what the next puzzle should be + SELF_HASH + (if new_proposal_vote_id_or_removal_id (c new_proposal_vote_id_or_removal_id ACTIVE_VOTES) ACTIVE_VOTES) + (if new_innerpuzhash new_innerpuzhash (sha256tree INNERPUZ)) + ) + my_amount + ; TODO: add namespace to this announcement to allow announcements from the innerpuz + (sha256tree (list new_proposal_vote_id_or_removal_id vote_amount vote_info my_id)) + vote_amount + my_inner_puzhash + 0 + 0 + ) + ) + ) + ) + ) + + ; return to return_address or remove something from active list - check if our locked list is empty + (if ACTIVE_VOTES + (for_every_removal_id ; locked list is not empty, so we must be trying to remove something from it + SINGLETON_MOD_HASH + SINGLETON_LAUNCHER_PUZHASH + SELF_HASH + DAO_FINISHED_STATE_MOD_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + ACTIVE_VOTES + INNERPUZ + new_proposal_vote_id_or_removal_id + my_amount + () + ) + (a INNERPUZ inner_solution) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_lockup.clsp.hex b/chia/wallet/puzzles/dao_lockup.clsp.hex new file mode 100644 index 000000000000..793e3d66108d --- /dev/null +++ b/chia/wallet/puzzles/dao_lockup.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff02ffff03ff8205ffffff01ff02ffff01ff04ffff04ffff0148ffff04ffff02ff2cffff04ff02ffff04ff2fffff04ff5fffff04ff8302ffffff808080808080ffff01808080ffff04ffff04ffff0149ffff04ff8217ffffff01808080ffff04ffff04ffff0146ffff04ff8205ffffff01808080ffff04ffff02ffff03ff822fffffff01ff02ffff01ff02ff12ffff04ff02ffff04ff05ffff04ff0bffff04ff8202ffffff04ff8205ffffff04ff822fffffff04ff82017fffff04ff825fffff80808080808080808080ff0180ffff01ff02ffff01ff04ffff0101ffff018080ff018080ff0180ffff02ff3cffff04ff02ffff04ffff02ff8202ffff820bff80ffff04ffff02ff3affff04ff02ffff04ff8200bfffff04ffff02ffff03ff822fffffff01ff02ffff01ff04ff822fffff82017f80ff0180ffff01ff02ffff0182017fff018080ff0180ffff04ffff02ffff03ff8305ffffffff01ff02ffff018305ffffff0180ffff01ff02ffff01ff02ff38ffff04ff02ffff04ff8202ffff80808080ff018080ff0180ff808080808080ffff04ff8217ffffff04ffff02ff38ffff04ff02ffff04ffff04ff822fffffff04ff83017fffffff04ff8300bfffffff04ff8205ffffff018080808080ff80808080ffff04ff83017fffffff04ff8302ffffffff04ffff0180ffff04ffff0180ff808080808080808080808080808080ff0180ffff01ff02ffff01ff02ffff03ff82017fffff01ff02ffff01ff02ff16ffff04ff02ffff04ff05ffff04ff0bffff04ff8200bfffff04ff17ffff04ff2fffff04ff5fffff04ff82017fffff04ff8202ffffff04ff822fffffff04ff8217ffffff04ffff0180ff8080808080808080808080808080ff0180ffff01ff02ffff01ff02ff8202ffff820bff80ff018080ff0180ff018080ff0180ffff04ffff01ffffffff02ffff03ff05ffff01ff02ffff01ff02ff10ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ffff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff10ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff38ffff04ff02ffff04ffff05ff0580ff80808080ffff02ff38ffff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ffff02ff28ffff04ff02ffff04ff0bffff04ffff0bffff0101ff0b80ffff04ffff02ff38ffff04ff02ffff04ff05ff80808080ff808080808080ffff02ff28ffff04ff02ffff04ff05ffff04ff17ffff04ffff0bffff0101ff0b80ffff04ffff0bffff0101ff0580ff80808080808080ff02ffff03ff05ffff01ff02ffff01ff02ffff03ffff09ffff05ffff05ff058080ffff013380ffff01ff02ffff01ff02ffff03ffff09ffff05ffff06ffff05ff05808080ff0b80ffff01ff02ffff01ff02ffff03ff82017fffff01ff02ffff01ff0880ff0180ffff01ff02ffff01ff02ffff03ffff09ffff05ffff06ffff06ffff05ff0580808080ff1780ffff01ff02ffff01ff02ffff03ff8202ffffff01ff02ffff01ff0880ff0180ffff01ff02ffff01ff04ffff05ff0580ffff02ff3cffff04ff02ffff04ffff06ff0580ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ffff0101ffff04ffff0101ff808080808080808080808080ff018080ff0180ff0180ffff01ff02ffff01ff02ffff03ffff09ffff05ffff06ffff06ffff05ff0580808080ff5f80ffff01ff02ffff01ff04ffff05ff0580ffff02ff3cffff04ff02ffff04ffff06ff0580ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ffff0101ffff04ff8202ffff808080808080808080808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff018080ff0180ff018080ff0180ff0180ffff01ff02ffff01ff02ffff03ffff22ffff09ffff05ffff06ffff05ff05808080ff8200bf80ffff20ff8202ff80ffff09ffff05ffff06ffff06ffff05ff0580808080ffff11ff17ff5f808080ffff01ff02ffff01ff04ffff05ff0580ffff02ff3cffff04ff02ffff04ffff06ff0580ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ff82017fffff04ffff0101ff808080808080808080808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff018080ff0180ff0180ffff01ff02ffff01ff02ffff03ffff09ffff05ffff05ff058080ffff013e80ffff01ff02ffff01ff02ffff03ffff09ffff05ffff06ffff05ff05808080ff2f80ffff01ff02ffff01ff04ffff05ff0580ffff02ff3cffff04ff02ffff04ffff06ff0580ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ff82017fffff04ff8202ffff808080808080808080808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff04ffff05ff0580ffff02ff3cffff04ff02ffff04ffff06ff0580ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ff82017fffff04ff8202ffff808080808080808080808080ff018080ff0180ff018080ff0180ff0180ffff01ff02ffff01ff02ffff03ffff22ff82017fff8202ff80ffff01ff02ffff01ff0180ff0180ffff01ff02ffff01ff0880ff018080ff0180ff018080ff0180ffffff02ffff03ff8200bfffff01ff02ffff01ff02ffff03ffff09ff5fffff05ff8200bf8080ffff01ff02ffff01ff0880ff0180ffff01ff02ffff01ff02ff12ffff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ffff06ff8200bf80ffff04ff82017fff80808080808080808080ff018080ff0180ff0180ffff01ff02ffff01ff04ffff013fffff04ffff0bffff02ff2affff04ff02ffff04ffff04ff05ffff04ff5fff0b8080ffff04ff82017fff8080808080ff2f80ffff01808080ff018080ff0180ffff02ff28ffff04ff02ffff04ff09ffff04ff0bffff04ffff02ff38ffff04ff02ffff04ff05ff80808080ff808080808080ff02ff28ffff04ff02ffff04ff05ffff04ff17ffff04ffff02ff38ffff04ff02ffff04ff0bff80808080ffff04ffff0bffff0101ff0580ff80808080808080ffff02ffff03ff8205ffffff01ff02ffff01ff04ffff04ffff013fffff04ffff0bffff02ff2affff04ff02ffff04ffff04ff05ffff04ffff05ff8205ff80ff0b8080ffff04ffff02ff14ffff04ff02ffff04ffff04ff05ffff04ffff05ff8205ff80ff0b8080ffff04ff2fff8080808080ff8080808080ffff018080ffff01808080ffff02ff16ffff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ff82017fffff04ff8202ffffff04ffff06ff8205ff80ffff04ff820bffffff04ffff04ffff05ff8205ff80ff8217ff80ff808080808080808080808080808080ff0180ffff01ff02ffff01ff04ffff04ffff0149ffff04ff820bffffff01808080ffff04ffff04ffff0133ffff04ffff02ff3affff04ff02ffff04ff17ffff04ffff02ff2effff04ff02ffff04ff8217ffffff04ff82017fff8080808080ffff04ffff02ff38ffff04ff02ffff04ff8202ffff80808080ff808080808080ffff04ff820bffffff0180808080ffff01808080ff018080ff0180ffff02ffff03ff05ffff01ff02ffff01ff02ff3effff04ff02ffff04ffff05ff0580ffff04ffff02ff2effff04ff02ffff04ffff06ff0580ffff04ff0bff8080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ff02ffff03ff0bffff01ff02ffff01ff02ffff03ffff09ffff05ff0b80ff0580ffff01ff02ffff01ff06ff0b80ff0180ffff01ff02ffff01ff04ffff05ff0b80ffff02ff3effff04ff02ffff04ff05ffff04ffff06ff0b80ff808080808080ff018080ff0180ff0180ffff01ff02ffff01ff0180ff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/dao_lockup.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_lockup.clsp.hex.sha256tree new file mode 100644 index 000000000000..3932de2d5436 --- /dev/null +++ b/chia/wallet/puzzles/dao_lockup.clsp.hex.sha256tree @@ -0,0 +1 @@ +9fa63e652e131f89a9f8bb6f7abb5ffc6ac485a78dcfb8710cd9df5c368774d9 diff --git a/chia/wallet/puzzles/dao_proposal.clsp b/chia/wallet/puzzles/dao_proposal.clsp new file mode 100644 index 000000000000..e7a45949d968 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal.clsp @@ -0,0 +1,374 @@ +(mod ( + ; first hash + PROPOSAL_TIMER_MOD_HASH ; proposal timer needs to know which proposal created it + SINGLETON_MOD_HASH + LAUNCHER_PUZZLE_HASH + CAT_MOD_HASH + DAO_FINISHED_STATE_MOD_HASH + TREASURY_MOD_HASH + LOCKUP_SELF_HASH + CAT_TAIL_HASH + TREASURY_ID + ; second hash + SELF_HASH + SINGLETON_ID + PROPOSED_PUZ_HASH ; this is what runs if this proposal is successful - the inner puzzle of this proposal + YES_VOTES ; yes votes are +1, no votes don't tally - we compare yes_votes/total_votes at the end + TOTAL_VOTES ; how many people responded + ; solution + vote_amounts_or_proposal_validator_hash ; The qty of "votes" to add or subtract. ALWAYS POSITIVE. + vote_info ; vote_info is whether we are voting YES or NO. XXX rename vote_type? + vote_coin_ids_or_proposal_timelock_length ; this is either the coin ID we're taking a vote from + previous_votes_or_pass_margin ; this is the active votes of the lockup we're communicating with + ; OR this is what percentage of the total votes must be YES - represented as an integer from 0 to 10,000 - typically this is set at 5100 (51%) + lockup_innerpuzhashes_or_attendance_required ; this is either the innerpuz of the locked up CAT we're taking a vote from OR + ; the attendance required - the percentage of the current issuance which must have voted represented as 0 to 10,000 - this is announced by the treasury + innerpuz_reveal ; this is only added during the first vote + soft_close_length ; revealed by the treasury - 0 in add vote case + self_destruct_time ; revealed by the treasury + oracle_spend_delay ; used to recreate the treasury + self_destruct_flag ; if not 0, do the self-destruct spend + my_amount + ) + (include condition_codes.clib) + (include utility_macros.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defconstant TEN_THOUSAND 10000) + + (defun is_member (e L) + (if L + (if (= e (f L)) + 1 + (is_member e (r L)) + ) + 0 + ) + ) + + (defun-inline calculate_win_percentage (TOTAL PERCENTAGE) + (f (divmod (* TOTAL PERCENTAGE) TEN_THOUSAND)) + ) + + (defun calculate_finished_state (singleton_struct DAO_FINISHED_STATE_MOD_HASH) + (puzzle-hash-of-curried-function DAO_FINISHED_STATE_MOD_HASH + (sha256 ONE DAO_FINISHED_STATE_MOD_HASH) + (sha256tree singleton_struct) + ) + ) + + (defun calculate_timer_puzhash ( + PROPOSAL_TIMER_MOD_HASH + SELF_HASH + MY_SINGLETON_STRUCT + ) + (puzzle-hash-of-curried-function PROPOSAL_TIMER_MOD_HASH + (sha256tree MY_SINGLETON_STRUCT) + (sha256 ONE SELF_HASH) + ) + ) + + (defun calculate_lockup_puzzlehash ( + LOCKUP_SELF_HASH + previous_votes + lockup_innerpuzhash + ) + (puzzle-hash-of-curried-function LOCKUP_SELF_HASH + lockup_innerpuzhash + (sha256tree previous_votes) + (sha256 ONE LOCKUP_SELF_HASH) + ) + ) + + (defun recreate_self ( + SELF_HASH + PROPOSAL_ID + PROPOSED_PUZ_HASH + YES_VOTES + TOTAL_VOTES + ) + (puzzle-hash-of-curried-function SELF_HASH + (sha256 ONE TOTAL_VOTES) + (sha256 ONE YES_VOTES) + (sha256 ONE PROPOSED_PUZ_HASH) + (sha256 ONE PROPOSAL_ID) + (sha256 ONE SELF_HASH) + ) + ) + + (defun wrap_in_cat_layer (CAT_MOD_HASH CAT_TAIL_HASH INNERPUZHASH) + (puzzle-hash-of-curried-function CAT_MOD_HASH + INNERPUZHASH + (sha256 ONE CAT_TAIL_HASH) + (sha256 ONE CAT_MOD_HASH) + ) + ) + + (defun calculate_singleton_puzzle_hash (PROPOSAL_SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f PROPOSAL_SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree PROPOSAL_SINGLETON_STRUCT) + ) + ) + + (defun calculate_treasury_puzzlehash ( + treasury_singleton_struct + TREASURY_MOD_HASH + PROPOSAL_VALIDATOR_HASH + PROPOSAL_LENGTH + PROPOSAL_SOFTCLOSE_LENGTH + attendance_required + pass_percentage + self_destruct_time + oracle_spend_delay + ) + + (calculate_singleton_puzzle_hash treasury_singleton_struct + (puzzle-hash-of-curried-function TREASURY_MOD_HASH + (sha256 ONE oracle_spend_delay) + (sha256 ONE self_destruct_time) + (sha256 ONE pass_percentage) + (sha256 ONE attendance_required) + (sha256 ONE PROPOSAL_SOFTCLOSE_LENGTH) + (sha256 ONE PROPOSAL_LENGTH) + PROPOSAL_VALIDATOR_HASH + (sha256 ONE TREASURY_MOD_HASH) + ) + ) + ) + + (defun loop_over_vote_coins ( + SINGLETON_ID + LOCKUP_SELF_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + TREASURY_ID + SELF_HASH + YES_VOTES + TOTAL_VOTES + PROPOSED_PUZ_HASH + coin_id_list + vote_amount_list + previous_votes + lockup_innerpuzhashes + vote_info + sum + output + my_amount + distinct_ids + ) + (if coin_id_list + (if (> (f vote_amount_list) 0) + (c + (list CREATE_PUZZLE_ANNOUNCEMENT (f coin_id_list)) + (c + (list + ASSERT_PUZZLE_ANNOUNCEMENT ; take the vote + (sha256 + (wrap_in_cat_layer + CAT_MOD_HASH + CAT_TAIL_HASH + (calculate_lockup_puzzlehash ; because the message comes from + LOCKUP_SELF_HASH + (f previous_votes) + (f lockup_innerpuzhashes) + ) + ) + (sha256tree (list SINGLETON_ID (f vote_amount_list) vote_info (f coin_id_list))) + ) + ) + (loop_over_vote_coins + SINGLETON_ID + LOCKUP_SELF_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + TREASURY_ID + SELF_HASH + YES_VOTES + TOTAL_VOTES + PROPOSED_PUZ_HASH + (r coin_id_list) + (r vote_amount_list) + (r previous_votes) + (r lockup_innerpuzhashes) + vote_info + (+ (f vote_amount_list) sum) + output + my_amount + (if (is_member (f coin_id_list) distinct_ids) (x) (c (f coin_id_list) distinct_ids)) + ) + ) + ) + (x) + ) + (c + (list + CREATE_COIN ; recreate self with vote information added + (recreate_self + SELF_HASH + SINGLETON_ID + PROPOSED_PUZ_HASH + (if vote_info (+ YES_VOTES sum) YES_VOTES) + (+ TOTAL_VOTES sum) + ) + my_amount + (list TREASURY_ID) ; hint to Treasury ID so people can find it + ) + (c + (list ASSERT_MY_AMOUNT my_amount) + output + ) + ) + ) + + ) + + + (if self_destruct_flag + ; assert self_destruct_time > proposal_timelock_length + ; this is the code path for if we've not been accepted by the treasury for a long time, and we're "bad" for some reason + (if (> self_destruct_time vote_coin_ids_or_proposal_timelock_length) + (list + (list CREATE_COIN (calculate_finished_state (c SINGLETON_MOD_HASH (c SINGLETON_ID LAUNCHER_PUZZLE_HASH)) DAO_FINISHED_STATE_MOD_HASH) ONE (list TREASURY_ID)) + (list ASSERT_HEIGHT_RELATIVE self_destruct_time) + (list ASSERT_PUZZLE_ANNOUNCEMENT ; make sure that we have a matching treasury oracle spend + (sha256 + (calculate_treasury_puzzlehash + (c SINGLETON_MOD_HASH (c TREASURY_ID LAUNCHER_PUZZLE_HASH)) + TREASURY_MOD_HASH + vote_amounts_or_proposal_validator_hash + vote_coin_ids_or_proposal_timelock_length ; check the veracity of these values by if the treasury uses them + soft_close_length + lockup_innerpuzhashes_or_attendance_required + previous_votes_or_pass_margin + self_destruct_time + oracle_spend_delay + ) + 0 ; the arguments are secured implicitly in the puzzle of the treasury + ) + ) + ) + (x) + ) + ; We're not trying to self destruct + ; Check whether we have a soft close to either try closing the proposal or adding votes + ; soft_close_length is used to prevent people from spamming the proposal and preventing others from being able to vote. + ; Someone could add 1 'no' vote to the proposal in every block until the proposal timelock has passed and then close the proposal as failed. + ; soft_close_length imposes some fixed number of blocks have passed without the proposal being spent before it can be closed. + ; This means there will always be some time for people to vote if they want before a proposal is closed. + (if soft_close_length + ; Add the conditions which apply in both passed and failed cases + (c + (list ASSERT_HEIGHT_RELATIVE soft_close_length) + (c + (list CREATE_COIN (calculate_finished_state (c SINGLETON_MOD_HASH (c SINGLETON_ID LAUNCHER_PUZZLE_HASH)) DAO_FINISHED_STATE_MOD_HASH) ONE (list TREASURY_ID)) + (c + (list + ASSERT_PUZZLE_ANNOUNCEMENT + (sha256 ; external timer + (calculate_timer_puzhash + PROPOSAL_TIMER_MOD_HASH + SELF_HASH + (c SINGLETON_MOD_HASH (c SINGLETON_ID LAUNCHER_PUZZLE_HASH)) + + ) + SINGLETON_ID + ) + ) + (c + (list CREATE_PUZZLE_ANNOUNCEMENT vote_coin_ids_or_proposal_timelock_length) + ; We are trying to close the proposal, so check whether it passed or failed + (if + (all + (> TOTAL_VOTES lockup_innerpuzhashes_or_attendance_required) + (> YES_VOTES (calculate_win_percentage TOTAL_VOTES previous_votes_or_pass_margin)) + ) + ; Passed + (list + (list CREATE_COIN_ANNOUNCEMENT (sha256tree (list PROPOSED_PUZ_HASH 0))) ; the 0 at the end is announcement_args in proposal_validators + ; the above coin annnouncement lets us validate this coin in the proposal validator + (list ASSERT_PUZZLE_ANNOUNCEMENT ; make sure that we actually have a matching treasury spend + (sha256 + (calculate_treasury_puzzlehash + (c SINGLETON_MOD_HASH (c TREASURY_ID LAUNCHER_PUZZLE_HASH)) + TREASURY_MOD_HASH + vote_amounts_or_proposal_validator_hash + vote_coin_ids_or_proposal_timelock_length ; check the veracity of these values by if the treasury uses them + soft_close_length + lockup_innerpuzhashes_or_attendance_required + previous_votes_or_pass_margin + self_destruct_time + oracle_spend_delay + ) + SINGLETON_ID ; directed at singleton, but most values are implicitly announced in the puzzle + ) + ) + ) + ; Failed + (list + (list ASSERT_PUZZLE_ANNOUNCEMENT ; make sure that we verify solution values against the treasury's oracle spend + (sha256 + (calculate_treasury_puzzlehash + (c SINGLETON_MOD_HASH (c TREASURY_ID LAUNCHER_PUZZLE_HASH)) + TREASURY_MOD_HASH + vote_amounts_or_proposal_validator_hash + vote_coin_ids_or_proposal_timelock_length ; check the veracity of these values by if the treasury uses them + soft_close_length + lockup_innerpuzhashes_or_attendance_required + previous_votes_or_pass_margin + self_destruct_time + oracle_spend_delay + ) + 0 ; the arguments are secured implicitly in the puzzle of the treasury + ) + ) + ) + ) + ) + ) + ) + ) + + + ; no soft_close_length so run the add votes path + (loop_over_vote_coins + SINGLETON_ID + LOCKUP_SELF_HASH + CAT_MOD_HASH + CAT_TAIL_HASH + TREASURY_ID + SELF_HASH + YES_VOTES + TOTAL_VOTES + PROPOSED_PUZ_HASH + vote_coin_ids_or_proposal_timelock_length + vote_amounts_or_proposal_validator_hash + previous_votes_or_pass_margin + lockup_innerpuzhashes_or_attendance_required + vote_info + 0 + (if (any YES_VOTES TOTAL_VOTES) ; this prevents the timer from being created if the coin has been created with fake votes + () + (c + (list + CREATE_COIN + (calculate_timer_puzhash + PROPOSAL_TIMER_MOD_HASH + SELF_HASH + (c SINGLETON_MOD_HASH (c SINGLETON_ID LAUNCHER_PUZZLE_HASH)) ; SINGLETON_STRUCT + ) + 0 + ) + (if (= (sha256tree innerpuz_reveal) PROPOSED_PUZ_HASH) ; reveal the proposed code on chain with the first vote + () + (x) + ) + ) + ) + my_amount + () + ) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_proposal.clsp.hex b/chia/wallet/puzzles/dao_proposal.clsp.hex new file mode 100644 index 000000000000..54bc22952b6b --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff02ffff03ff8402ffffffffff01ff02ffff01ff02ffff03ffff15ff8400bfffffff8305ffff80ffff01ff02ffff01ff04ffff04ffff0133ffff04ffff02ff2cffff04ff02ffff04ffff04ff0bffff04ff8217ffff178080ffff04ff5fff8080808080ffff04ffff0101ffff04ffff04ff8205ffffff018080ffff018080808080ffff04ffff04ffff0152ffff04ff8400bfffffffff01808080ffff04ffff04ffff013fffff04ffff0bffff02ff2effff04ff02ffff04ffff04ff0bffff04ff8205ffff178080ffff04ff8200bfffff04ff83017fffffff04ff8305ffffffff04ff835fffffffff04ff8317ffffffff04ff830bffffffff04ff8400bfffffffff04ff84017fffffff808080808080808080808080ffff018080ffff01808080ffff0180808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff02ffff03ff835fffffffff01ff02ffff01ff04ffff04ffff0152ffff04ff835fffffffff01808080ffff04ffff04ffff0133ffff04ffff02ff2cffff04ff02ffff04ffff04ff0bffff04ff8217ffff178080ffff04ff5fff8080808080ffff04ffff0101ffff04ffff04ff8205ffffff018080ffff018080808080ffff04ffff04ffff013fffff04ffff0bffff02ff3cffff04ff02ffff04ff05ffff04ff820bffffff04ffff04ff0bffff04ff8217ffff178080ff808080808080ff8217ff80ffff01808080ffff04ffff04ffff013effff04ff8305ffffffff01808080ffff02ffff03ffff22ffff15ff8300bfffff8317ffff80ffff15ff825fffffff05ffff14ffff12ff8300bfffff830bffff80ffff0182271080808080ffff01ff02ffff01ff04ffff04ffff013cffff04ffff02ff38ffff04ff02ffff04ffff04ff822fffffff04ffff0180ffff01808080ff80808080ffff01808080ffff04ffff04ffff013fffff04ffff0bffff02ff2effff04ff02ffff04ffff04ff0bffff04ff8205ffff178080ffff04ff8200bfffff04ff83017fffffff04ff8305ffffffff04ff835fffffffff04ff8317ffffffff04ff830bffffffff04ff8400bfffffffff04ff84017fffffff808080808080808080808080ff8217ff80ffff01808080ffff01808080ff0180ffff01ff02ffff01ff04ffff04ffff013fffff04ffff0bffff02ff2effff04ff02ffff04ffff04ff0bffff04ff8205ffff178080ffff04ff8200bfffff04ff83017fffffff04ff8305ffffffff04ff835fffffffff04ff8317ffffffff04ff830bffffffff04ff8400bfffffffff04ff84017fffffff808080808080808080808080ffff018080ffff01808080ffff018080ff018080ff018080808080ff0180ffff01ff02ffff01ff02ff3effff04ff02ffff04ff8217ffffff04ff82017fffff04ff2fffff04ff8202ffffff04ff8205ffffff04ff820bffffff04ff825fffffff04ff8300bfffffff04ff822fffffff04ff8305ffffffff04ff83017fffffff04ff830bffffffff04ff8317ffffffff04ff8302ffffffff04ffff0180ffff04ffff02ffff03ffff21ff825fffff8300bfff80ffff01ff02ffff01ff0180ff0180ffff01ff02ffff01ff04ffff04ffff0133ffff04ffff02ff3cffff04ff02ffff04ff05ffff04ff820bffffff04ffff04ff0bffff04ff8217ffff178080ff808080808080ffff04ffff0180ffff0180808080ffff02ffff03ffff09ffff02ff38ffff04ff02ffff04ff832fffffff80808080ff822fff80ffff01ff02ffff01ff0180ff0180ffff01ff02ffff01ff0880ff018080ff018080ff018080ff0180ffff04ff8405ffffffffff04ffff0180ff808080808080808080808080808080808080808080ff018080ff0180ff018080ff0180ffff04ffff01ffffffff02ffff03ff05ffff01ff02ffff01ff02ff10ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ffff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff10ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff38ffff04ff02ffff04ffff05ff0580ff80808080ffff02ff38ffff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ffff02ffff03ff0bffff01ff02ffff01ff02ffff03ffff09ff05ffff05ff0b8080ffff01ff02ffff01ff0101ff0180ffff01ff02ffff01ff02ff14ffff04ff02ffff04ff05ffff04ffff06ff0b80ff8080808080ff018080ff0180ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff02ff28ffff04ff02ffff04ff0bffff04ffff0bffff0101ff0b80ffff04ffff02ff38ffff04ff02ffff04ff05ff80808080ff808080808080ff02ff28ffff04ff02ffff04ff05ffff04ffff02ff38ffff04ff02ffff04ff17ff80808080ffff04ffff0bffff0101ff0b80ff808080808080ffffff02ff28ffff04ff02ffff04ff05ffff04ff17ffff04ffff02ff38ffff04ff02ffff04ff0bff80808080ffff04ffff0bffff0101ff0580ff80808080808080ffff02ff28ffff04ff02ffff04ff05ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff2f80ffff04ffff0bffff0101ff1780ffff04ffff0bffff0101ff0b80ffff04ffff0bffff0101ff0580ff808080808080808080ff02ff28ffff04ff02ffff04ff05ffff04ff17ffff04ffff0bffff0101ff0b80ffff04ffff0bffff0101ff0580ff80808080808080ffff02ff28ffff04ff02ffff04ff09ffff04ff0bffff04ffff02ff38ffff04ff02ffff04ff05ff80808080ff808080808080ffff02ff16ffff04ff02ffff04ff05ffff04ffff02ff28ffff04ff02ffff04ff0bffff04ffff0bffff0101ff8205ff80ffff04ffff0bffff0101ff8202ff80ffff04ffff0bffff0101ff82017f80ffff04ffff0bffff0101ff8200bf80ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff2f80ffff04ff17ffff04ffff0bffff0101ff0b80ff808080808080808080808080ff8080808080ff02ffff03ff820bffffff01ff02ffff01ff02ffff03ffff15ffff05ff8217ff80ffff018080ffff01ff02ffff01ff04ffff04ffff013effff04ffff05ff820bff80ffff01808080ffff04ffff04ffff013fffff04ffff0bffff02ff3affff04ff02ffff04ff17ffff04ff2fffff04ffff02ff12ffff04ff02ffff04ff0bffff04ffff05ff822fff80ffff04ffff05ff825fff80ff808080808080ff808080808080ffff02ff38ffff04ff02ffff04ffff04ff05ffff04ffff05ff8217ff80ffff04ff8300bfffffff04ffff05ff820bff80ffff018080808080ff8080808080ffff01808080ffff02ff3effff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff8200bfffff04ff82017fffff04ff8202ffffff04ff8205ffffff04ffff06ff820bff80ffff04ffff06ff8217ff80ffff04ffff06ff822fff80ffff04ffff06ff825fff80ffff04ff8300bfffffff04ffff10ffff05ff8217ff80ff83017fff80ffff04ff8302ffffffff04ff8305ffffffff04ffff02ffff03ffff02ff14ffff04ff02ffff04ffff05ff820bff80ffff04ff830bffffff8080808080ffff01ff02ffff01ff0880ff0180ffff01ff02ffff01ff04ffff05ff820bff80ff830bffff80ff018080ff0180ff8080808080808080808080808080808080808080808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff04ffff04ffff0133ffff04ffff02ff2affff04ff02ffff04ff8200bfffff04ff05ffff04ff8205ffffff04ffff02ffff03ff8300bfffffff01ff02ffff01ff10ff82017fff83017fff80ff0180ffff01ff02ffff0182017fff018080ff0180ffff04ffff10ff8202ffff83017fff80ff8080808080808080ffff04ff8305ffffffff04ffff04ff5fffff018080ffff018080808080ffff04ffff04ffff0149ffff04ff8305ffffffff01808080ff8302ffff8080ff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/dao_proposal.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_proposal.clsp.hex.sha256tree new file mode 100644 index 000000000000..45c30b689164 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal.clsp.hex.sha256tree @@ -0,0 +1 @@ +a27440cdee44f910e80225592e51dc03721a9d819cc358165587fa2b34eef4cd diff --git a/chia/wallet/puzzles/dao_proposal_timer.clsp b/chia/wallet/puzzles/dao_proposal_timer.clsp new file mode 100644 index 000000000000..5bb04bfb4960 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_timer.clsp @@ -0,0 +1,78 @@ +; This is a persistent timer for a proposal which allows it to have a relative time that survives despite it being recreated. +; The closing time is contained in the timelock and passed in to the solution, and confirmed via an announcement from the Proposal +; It creates/asserts announcements to pair it with the finishing spend of a proposal + +(mod ( + PROPOSAL_SELF_HASH + MY_PARENT_SINGLETON_STRUCT + proposal_yes_votes + proposal_total_votes + proposal_innerpuzhash + proposal_timelock + parent_parent + parent_amount + ) + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun calculate_singleton_puzzle_hash (PROPOSAL_SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f PROPOSAL_SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree PROPOSAL_SINGLETON_STRUCT) + ) + ) + + (defun calculate_proposal_puzzlehash ( + PROPOSAL_SINGLETON_STRUCT + PROPOSAL_SELF_HASH + proposal_yes_votes + proposal_total_votes + proposal_innerpuzhash + ) + (calculate_singleton_puzzle_hash + PROPOSAL_SINGLETON_STRUCT + (puzzle-hash-of-curried-function PROPOSAL_SELF_HASH + (sha256 ONE proposal_total_votes) + (sha256 ONE proposal_yes_votes) + (sha256 ONE proposal_innerpuzhash) + (sha256 ONE (f (r PROPOSAL_SINGLETON_STRUCT))) + (sha256 ONE PROPOSAL_SELF_HASH) + ) + ) + ) + + ; main + (list + (list ASSERT_HEIGHT_RELATIVE proposal_timelock) + (list CREATE_PUZZLE_ANNOUNCEMENT (f (r MY_PARENT_SINGLETON_STRUCT))) + (list + ASSERT_PUZZLE_ANNOUNCEMENT + (sha256 + (calculate_proposal_puzzlehash + MY_PARENT_SINGLETON_STRUCT + PROPOSAL_SELF_HASH + proposal_yes_votes + proposal_total_votes + proposal_innerpuzhash + ) + proposal_timelock + ) + ) + (list + ASSERT_MY_PARENT_ID + (sha256 + parent_parent + (calculate_proposal_puzzlehash + MY_PARENT_SINGLETON_STRUCT + PROPOSAL_SELF_HASH + 0 + 0 + proposal_innerpuzhash + ) + parent_amount + ) + + ) + ) +) diff --git a/chia/wallet/puzzles/dao_proposal_timer.clsp.hex b/chia/wallet/puzzles/dao_proposal_timer.clsp.hex new file mode 100644 index 000000000000..06b062f73577 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_timer.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff0152ffff04ff8200bfffff01808080ffff04ffff04ffff013effff04ffff05ffff06ff0b8080ffff01808080ffff04ffff04ffff013fffff04ffff0bffff02ff1effff04ff02ffff04ff0bffff04ff05ffff04ff17ffff04ff2fffff04ff5fff8080808080808080ff8200bf80ffff01808080ffff04ffff04ffff0147ffff04ffff0bff82017fffff02ff1effff04ff02ffff04ff0bffff04ff05ffff04ffff0180ffff04ffff0180ffff04ff5fff8080808080808080ff8202ff80ffff01808080ffff018080808080ffff04ffff01ffffff02ffff03ff05ffff01ff02ffff01ff02ff08ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff08ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ffff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff0affff04ff02ffff04ffff05ff0580ff80808080ffff02ff0affff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ffff02ff0cffff04ff02ffff04ff09ffff04ff0bffff04ffff02ff0affff04ff02ffff04ff05ff80808080ff808080808080ff02ff16ffff04ff02ffff04ff05ffff04ffff02ff0cffff04ff02ffff04ff0bffff04ffff0bffff0101ff2f80ffff04ffff0bffff0101ff1780ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff1580ffff04ffff0bffff0101ff0b80ff808080808080808080ff8080808080ff018080 diff --git a/chia/wallet/puzzles/dao_proposal_timer.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_proposal_timer.clsp.hex.sha256tree new file mode 100644 index 000000000000..c642bef7fd04 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_timer.clsp.hex.sha256tree @@ -0,0 +1 @@ +5526d8dc33b60a23c86ac7184e8f7051515af16dbb7489555f389b84a5313c84 diff --git a/chia/wallet/puzzles/dao_proposal_validator.clsp b/chia/wallet/puzzles/dao_proposal_validator.clsp new file mode 100644 index 000000000000..ba6737663d20 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_validator.clsp @@ -0,0 +1,87 @@ +(mod + ( + SINGLETON_STRUCT ; (SINGLETON_MOD_HASH (SINGLETON_ID . LAUNCHER_PUZZLE_HASH)) + PROPOSAL_SELF_HASH + PROPOSAL_MINIMUM_AMOUNT + PROPOSAL_EXCESS_PAYOUT_PUZ_HASH ; this is where the excess money gets paid out to + Attendance_Required ; this is passed in as a Truth from above + Pass_Margin ; this is a pass in as a Truth from above + (announcement_source delegated_puzzle_hash announcement_args) + ( + proposal_id + total_votes + yes_votes + coin_parent + coin_amount + ) + conditions + ) + + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include utility_macros.clib) + (include *standard-cl-21*) + + (defconstant TEN_THOUSAND 10000) + + (defun-inline calculate_win_percentage (TOTAL PERCENTAGE) + (f (divmod (* TOTAL PERCENTAGE) TEN_THOUSAND)) + ) + + (defun-inline calculate_full_puzzle_hash (SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree SINGLETON_STRUCT) + ) + ) + + (defun-inline calculate_proposal_puzzle ( + PROPOSAL_SELF_HASH + proposal_singleton_id + proposal_yes_votes + proposal_total_votes + proposal_innerpuz_hash + ) + (puzzle-hash-of-curried-function PROPOSAL_SELF_HASH + (sha256 ONE proposal_total_votes) + (sha256 ONE proposal_yes_votes) + (sha256 ONE proposal_innerpuz_hash) + (sha256 ONE proposal_singleton_id) + (sha256 ONE PROPOSAL_SELF_HASH) + ) + ) + + (assert + ; (= (sha256tree my_solution) announcement_args) - quex suggested this. We don't need to check it now. Can be used for future functionality. + (> (+ coin_amount ONE) PROPOSAL_MINIMUM_AMOUNT) ; >= + (> total_votes Attendance_Required) ; TODO: we might want to change this to storing total cats and calculating like with yes votes + (> yes_votes (calculate_win_percentage total_votes Pass_Margin)) + (= + announcement_source + (calculate_coin_id + coin_parent + (calculate_full_puzzle_hash + (c (f SINGLETON_STRUCT) (c proposal_id (r (r SINGLETON_STRUCT)))) + (calculate_proposal_puzzle + PROPOSAL_SELF_HASH + proposal_id + yes_votes ; this is where we validate the yes votes and total votes + total_votes + delegated_puzzle_hash + ) + ) + coin_amount + ) + ) + (c + (list CREATE_PUZZLE_ANNOUNCEMENT proposal_id) ; specify the proposal we're talking about + (if (> (- coin_amount 1) 0) + (c + (list CREATE_COIN PROPOSAL_EXCESS_PAYOUT_PUZ_HASH (- coin_amount 1) (list (f (r SINGLETON_STRUCT)))) + conditions + ) + conditions + ) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_proposal_validator.clsp.hex b/chia/wallet/puzzles/dao_proposal_validator.clsp.hex new file mode 100644 index 000000000000..03e8835c5775 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_validator.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff02ffff03ffff15ffff10ff825effffff010180ff1780ffff01ff02ffff01ff02ffff03ffff15ff820affff5f80ffff01ff02ffff01ff02ffff03ffff15ff8216ffffff05ffff14ffff12ff820affff8200bf80ffff01822710808080ffff01ff02ffff01ff02ffff03ffff09ff82027fffff02ff0affff04ff02ffff04ff822effffff04ffff02ff0cffff04ff02ffff04ffff05ffff04ffff05ff0580ffff04ff8204ffffff06ffff06ff058080808080ffff04ffff02ff0cffff04ff02ffff04ff0bffff04ffff0bffff0101ff820aff80ffff04ffff0bffff0101ff8216ff80ffff04ffff0bffff0101ff82057f80ffff04ffff0bffff0101ff8204ff80ffff04ffff0bffff0101ff0b80ff808080808080808080ffff04ffff02ff0effff04ff02ffff04ffff04ffff05ff0580ffff04ff8204ffffff06ffff06ff0580808080ff80808080ff808080808080ffff04ff825effff80808080808080ffff01ff02ffff01ff04ffff04ffff013effff04ff8204ffffff01808080ffff02ffff03ffff15ffff11ff825effffff010180ffff018080ffff01ff02ffff01ff04ffff04ffff0133ffff04ff2fffff04ffff11ff825effffff010180ffff04ffff04ffff05ffff06ff058080ffff018080ffff018080808080ff8205ff80ff0180ffff01ff02ffff018205ffff018080ff018080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff0880ff018080ff0180ffff04ffff01ffffff02ffff03ff05ffff01ff02ffff01ff02ff08ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff08ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ffff02ffff03ffff22ffff09ffff0dff0580ffff012080ffff09ffff0dff0b80ffff012080ffff15ff17ffff0181ff8080ffff01ff02ffff01ff0bff05ff0bff1780ff0180ffff01ff02ffff01ff0880ff018080ff0180ff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff0effff04ff02ffff04ffff05ff0580ff80808080ffff02ff0effff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/dao_proposal_validator.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_proposal_validator.clsp.hex.sha256tree new file mode 100644 index 000000000000..dc8103326919 --- /dev/null +++ b/chia/wallet/puzzles/dao_proposal_validator.clsp.hex.sha256tree @@ -0,0 +1 @@ +edff0f36ca097ea55c867f8700cc4d48d267b91fd00ccee2db0fab6fe0645c67 diff --git a/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp new file mode 100644 index 000000000000..6e62d9bdaf70 --- /dev/null +++ b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp @@ -0,0 +1,226 @@ +(mod ( + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + CONDITIONS ; XCH conditions, to be generated by the treasury + LIST_OF_TAILHASH_CONDITIONS ; the delegated puzzlehash must be curried in to the proposal. + ; Puzzlehash is only run in the last coin for that asset + ; ((TAIL_HASH CONDITIONS) (TAIL_HASH CONDITIONS)... ) + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH + p2_singleton_parent_amount_list ; for xch this is just a list of (coin_parent coin_amount) + p2_singleton_tailhash_parent_amount_list ; list of ((asset (parent amount) (parent amount)... ) (asset (parent amount)... )... ), + ; must match order of curryed asset list + ; the last (parent amount) gets given the puzzlehash, the rest get given 0 + treasury_inner_puzhash + ) + ; we need to track CAT_TYPE and DELEGATED_PUZZLE + ; list of (asset_type (parent amount)) + + ; If you're writing a proposal you'll want to use this layer + ; if you don't, your proposal might be invalidated if the p2_singleton coins get spent + + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun-inline calculate_singleton_puzzle_hash (PROPOSAL_SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f PROPOSAL_SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree PROPOSAL_SINGLETON_STRUCT) + ) + ) + + (defun loop_through_list ( + TREASURY_SINGLETON_STRUCT + SPEND_AMOUNT + P2_SINGLETON_PUZHASH + p2_calculated + p2_singleton_list + total + output + ) + (c + (list CREATE_PUZZLE_ANNOUNCEMENT (sha256tree (list p2_calculated (sha256tree 0)))) + (c + (list ASSERT_COIN_ANNOUNCEMENT (sha256 p2_calculated '$')) + (if p2_singleton_list + (loop_through_list + TREASURY_SINGLETON_STRUCT + SPEND_AMOUNT + P2_SINGLETON_PUZHASH + (calculate_coin_id (f (f p2_singleton_list)) P2_SINGLETON_PUZHASH (f (r (f p2_singleton_list)))) + (r p2_singleton_list) + (+ total (f (r (f p2_singleton_list)))) + output + ) + (if (> (- total SPEND_AMOUNT) 0) + (c + (list CREATE_COIN P2_SINGLETON_PUZHASH (- total SPEND_AMOUNT) (list P2_SINGLETON_PUZHASH)) + output + ) + output + ) + ) + ) + ) + ) + + (defun add_announcements_to_result (p2_calculated delegated_puzhash output) + (c + (list CREATE_PUZZLE_ANNOUNCEMENT (sha256tree (list p2_calculated delegated_puzhash))) + (c + (list ASSERT_COIN_ANNOUNCEMENT (sha256 p2_calculated '$')) + output + ) + ) + ) + + (defun sum_create_coins (conditions) + (if conditions + (+ + (if + (= (f (f conditions)) CREATE_COIN) + (if + (> (f (r (r (f conditions)))) 0) ; make an exception for -113 and other magic conditions + (f (r (r (f conditions)))) + 0 + ) + 0 + ) + (sum_create_coins (r conditions)) + ) + 0 + ) + ) + + (defun-inline calculate_delegated_puzzlehash (CONDITIONS) + (sha256tree (c ONE CONDITIONS)) ; this makes (q . CONDITIONS) + ) + + (defun wrap_in_cat_layer (CAT_MOD_HASH CAT_TAIL_HASH INNERPUZHASH) + (puzzle-hash-of-curried-function CAT_MOD_HASH + INNERPUZHASH + (sha256 ONE CAT_TAIL_HASH) + (sha256 ONE CAT_MOD_HASH) + ) + ) + + ; for a given asset type, loop through the cat coins and generate the announcements required for each + (defun for_each_asset ( + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + CONDITIONS_FOR_THIS_ASSET_TYPE + P2_SINGLETON_PUZHASH + p2_singleton_puzzle_hash + parent_amount_list + total + create_coin_sum + output + ) + (if parent_amount_list + (add_announcements_to_result + (calculate_coin_id (f (f parent_amount_list)) p2_singleton_puzzle_hash (f (r (f parent_amount_list)))) + (if + (r parent_amount_list) ; this is the delegated_puzhash + (sha256tree 0) ; most coins destroy themselves + (calculate_delegated_puzzlehash ; the last coin creates the conditions + (let ((coin_sum (- (+ total (f (r (f parent_amount_list)))) create_coin_sum))) + (if (> coin_sum 0) + (c + (list CREATE_COIN P2_SINGLETON_PUZHASH coin_sum (list P2_SINGLETON_PUZHASH)) + CONDITIONS_FOR_THIS_ASSET_TYPE + ) + CONDITIONS_FOR_THIS_ASSET_TYPE + )) + ) + ) + (for_each_asset + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + CONDITIONS_FOR_THIS_ASSET_TYPE + P2_SINGLETON_PUZHASH + p2_singleton_puzzle_hash + (r parent_amount_list) + (+ total (f (r (f parent_amount_list)))) + create_coin_sum + output + ) + ) + output + ) + ) + + ; loops through the list of ((tailhash conditions)) + (defun for_each_asset_type ( + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + P2_SINGLETON_PUZHASH + LIST_OF_TAILHASH_CONDITIONS + p2_singleton_tailhash_parent_amount_list ; ((tailhash ((parent amount) (parent_amount)... ) (tailhash (parent amount))..) + output + ) + (if LIST_OF_TAILHASH_CONDITIONS + (for_each_asset_type + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + P2_SINGLETON_PUZHASH + (r LIST_OF_TAILHASH_CONDITIONS) + (r p2_singleton_tailhash_parent_amount_list) + (for_each_asset + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + (if + (= + (f (f LIST_OF_TAILHASH_CONDITIONS)) + (f (f p2_singleton_tailhash_parent_amount_list)) + ) + (f (r (f LIST_OF_TAILHASH_CONDITIONS))) + (x) ; bad solution format + ) + P2_SINGLETON_PUZHASH + (wrap_in_cat_layer CAT_MOD_HASH (f (f p2_singleton_tailhash_parent_amount_list)) P2_SINGLETON_PUZHASH) ; p2_singleton_puzzle_hash + (f (r (f p2_singleton_tailhash_parent_amount_list))) ; list of ((parent amount) (parent amount)...) + 0 ; current total - initialise as 0 + (sum_create_coins (f (r (f LIST_OF_TAILHASH_CONDITIONS)))) + output ; add new conditions to previous calculated output conditions + ) + ) + output ; at the end of the loop output our calculated conditions + ) + ) + + + ; main + (c + (list ASSERT_MY_PUZZLEHASH (calculate_singleton_puzzle_hash TREASURY_SINGLETON_STRUCT treasury_inner_puzhash)) + (c + (list CREATE_COIN treasury_inner_puzhash ONE (list (f (r TREASURY_SINGLETON_STRUCT)))) + (if CONDITIONS + (loop_through_list + TREASURY_SINGLETON_STRUCT + (sum_create_coins CONDITIONS) + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH + (if p2_singleton_parent_amount_list (calculate_coin_id (f (f p2_singleton_parent_amount_list)) P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH (f (r (f p2_singleton_parent_amount_list)))) ()) + (if p2_singleton_parent_amount_list (r p2_singleton_parent_amount_list) ()) + (if p2_singleton_parent_amount_list (f (r (f p2_singleton_parent_amount_list))) ()) + (for_each_asset_type + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH + LIST_OF_TAILHASH_CONDITIONS + p2_singleton_tailhash_parent_amount_list ; ((tailhash ((parent amount) (parent_amount)... ) (tailhash (parent amount))..) + CONDITIONS + ) + ) + (for_each_asset_type + TREASURY_SINGLETON_STRUCT + CAT_MOD_HASH + P2_SINGLETON_VIA_DELEGATED_PUZZLE_PUZHASH + LIST_OF_TAILHASH_CONDITIONS + p2_singleton_tailhash_parent_amount_list ; ((tailhash ((parent amount) (parent_amount)... ) (tailhash (parent amount))..) + CONDITIONS + ) + ) + ) + ) + +) diff --git a/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex new file mode 100644 index 000000000000..89b65a2d4246 --- /dev/null +++ b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff0148ffff04ffff02ff18ffff04ff02ffff04ffff05ff0580ffff04ff8202ffffff04ffff02ff2cffff04ff02ffff04ff05ff80808080ff808080808080ffff01808080ffff04ffff04ffff0133ffff04ff8202ffffff04ffff0101ffff04ffff04ffff05ffff06ff058080ffff018080ffff018080808080ffff02ffff03ff17ffff01ff02ffff01ff02ff3cffff04ff02ffff04ff05ffff04ffff02ff1affff04ff02ffff04ff17ff80808080ffff04ff5fffff04ffff02ffff03ff8200bfffff01ff02ffff01ff02ff14ffff04ff02ffff04ffff05ffff05ff8200bf8080ffff04ff5fffff04ffff05ffff06ffff05ff8200bf808080ff808080808080ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff04ffff02ffff03ff8200bfffff01ff02ffff01ff06ff8200bf80ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff04ffff02ffff03ff8200bfffff01ff02ffff01ff05ffff06ffff05ff8200bf808080ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff04ffff02ff3effff04ff02ffff04ff05ffff04ff0bffff04ff5fffff04ff2fffff04ff82017fffff04ff17ff808080808080808080ff80808080808080808080ff0180ffff01ff02ffff01ff02ff3effff04ff02ffff04ff05ffff04ff0bffff04ff5fffff04ff2fffff04ff82017fffff04ff17ff808080808080808080ff018080ff01808080ffff04ffff01ffffffff02ffff03ff05ffff01ff02ffff01ff02ff10ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff10ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ffff02ffff03ffff22ffff09ffff0dff0580ffff012080ffff09ffff0dff0b80ffff012080ffff15ff17ffff0181ff8080ffff01ff02ffff01ff0bff05ff0bff1780ff0180ffff01ff02ffff01ff0880ff018080ff0180ffff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff2cffff04ff02ffff04ffff05ff0580ff80808080ffff02ff2cffff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ff04ffff04ffff013effff04ffff02ff2cffff04ff02ffff04ffff04ff2fffff04ffff02ff2cffff04ff02ffff01ff80808080ff808080ff80808080ff808080ffff04ffff04ffff013dffff04ffff0bff2fffff012480ff808080ffff02ffff03ff5fffff01ff02ffff01ff02ff3cffff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ffff02ff14ffff04ff02ffff04ffff05ffff05ff5f8080ffff04ff17ffff04ffff05ffff06ffff05ff5f808080ff808080808080ffff04ffff06ff5f80ffff04ffff10ff8200bfffff05ffff06ffff05ff5f80808080ffff04ff82017fff80808080808080808080ff0180ffff01ff02ffff01ff02ffff03ffff15ffff11ff8200bfff0b80ffff018080ffff01ff02ffff01ff04ffff04ffff0133ffff04ff17ffff04ffff11ff8200bfff0b80ffff04ffff04ff17ffff018080ffff018080808080ff82017f80ff0180ffff01ff02ffff0182017fff018080ff0180ff018080ff01808080ffffff04ffff04ffff013effff04ffff02ff2cffff04ff02ffff04ffff04ff05ffff04ff0bff808080ff80808080ff808080ffff04ffff04ffff013dffff04ffff0bff05ffff012480ff808080ff178080ff02ffff03ff05ffff01ff02ffff01ff10ffff02ffff03ffff09ffff05ffff05ff058080ffff013380ffff01ff02ffff01ff02ffff03ffff15ffff05ffff06ffff06ffff05ff0580808080ffff018080ffff01ff02ffff01ff05ffff06ffff06ffff05ff0580808080ff0180ffff01ff02ffff01ff0180ff018080ff0180ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff02ff1affff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0180ff018080ff0180ffff02ff18ffff04ff02ffff04ff05ffff04ff17ffff04ffff0bffff0101ff0b80ffff04ffff0bffff0101ff0580ff80808080808080ffff02ffff03ff8200bfffff01ff02ffff01ff02ff12ffff04ff02ffff04ffff02ff14ffff04ff02ffff04ffff05ffff05ff8200bf8080ffff04ff5fffff04ffff05ffff06ffff05ff8200bf808080ff808080808080ffff04ffff02ffff03ffff06ff8200bf80ffff01ff02ffff01ff02ff2cffff04ff02ffff04ffff0180ff80808080ff0180ffff01ff02ffff01ff02ff2cffff04ff02ffff04ffff04ffff0101ffff02ffff03ffff15ffff11ffff10ff82017fffff05ffff06ffff05ff8200bf80808080ff8202ff80ffff018080ffff01ff02ffff01ff04ffff04ffff0133ffff04ffff05ffff06ffff06ffff06ffff06ff018080808080ffff04ffff11ffff10ff82017fffff05ffff06ffff05ff8200bf80808080ff8202ff80ffff04ffff04ffff05ffff06ffff06ffff06ffff06ff018080808080ffff018080ffff018080808080ffff05ffff06ffff06ffff06ff018080808080ff0180ffff01ff02ffff01ff05ffff06ffff06ffff06ff0180808080ff018080ff018080ff80808080ff018080ff0180ffff04ffff02ff2effff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ffff06ff8200bf80ffff04ffff10ff82017fffff05ffff06ffff05ff8200bf80808080ffff04ff8202ffffff04ff8205ffff808080808080808080808080ff808080808080ff0180ffff01ff02ffff018205ffff018080ff0180ff02ffff03ff2fffff01ff02ffff01ff02ff3effff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ffff06ff2f80ffff04ffff06ff5f80ffff04ffff02ff2effff04ff02ffff04ff05ffff04ff0bffff04ffff02ffff03ffff09ffff05ffff05ff2f8080ffff05ffff05ff5f808080ffff01ff02ffff01ff05ffff06ffff05ff2f808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ffff04ff17ffff04ffff02ff16ffff04ff02ffff04ff0bffff04ffff05ffff05ff5f8080ffff04ff17ff808080808080ffff04ffff05ffff06ffff05ff5f808080ffff04ffff0180ffff04ffff02ff1affff04ff02ffff04ffff05ffff06ffff05ff2f808080ff80808080ffff04ff8200bfff808080808080808080808080ff808080808080808080ff0180ffff01ff02ffff018200bfff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex.sha256tree b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex.sha256tree new file mode 100644 index 000000000000..9cde8be6a27c --- /dev/null +++ b/chia/wallet/puzzles/dao_spend_p2_singleton_v2.clsp.hex.sha256tree @@ -0,0 +1 @@ +fb3890f672c9df3cc69699e21446b89b55b65071d35bf5c80a49d11c9b79a68f diff --git a/chia/wallet/puzzles/dao_treasury.clsp b/chia/wallet/puzzles/dao_treasury.clsp new file mode 100644 index 000000000000..4b95dec4fbc6 --- /dev/null +++ b/chia/wallet/puzzles/dao_treasury.clsp @@ -0,0 +1,112 @@ +(mod + ( + TREASURY_MOD_HASH + PROPOSAL_VALIDATOR ; this is the curryed proposal validator + PROPOSAL_LENGTH + PROPOSAL_SOFTCLOSE_LENGTH + ATTENDANCE_REQUIRED + PASS_MARGIN ; this is a percentage 0 - 10,000 - 51% would be 5100 + PROPOSAL_SELF_DESTRUCT_TIME ; time in seconds after which proposals can be automatically closed + ORACLE_SPEND_DELAY ; timelock delay for oracle spend + (@ proposal_announcement (announcement_source delegated_puzzle_hash announcement_args)) + proposal_validator_solution + delegated_puzzle_reveal ; this is the reveal of the puzzle announced by the proposal + delegated_solution ; this is not secure unless the delegated puzzle secures it + my_singleton_struct + ) + (include utility_macros.clib) + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun-inline recreate_self ( + TREASURY_MOD_HASH + PROPOSAL_VALIDATOR + PROPOSAL_LENGTH + PROPOSAL_SOFTCLOSE_LENGTH + ATTENDANCE_REQUIRED + PASS_MARGIN + PROPOSAL_SELF_DESTRUCT_TIME + ORACLE_SPEND_DELAY + ) + (puzzle-hash-of-curried-function TREASURY_MOD_HASH + (sha256 ONE ORACLE_SPEND_DELAY) + (sha256 ONE PROPOSAL_SELF_DESTRUCT_TIME) + (sha256 ONE PASS_MARGIN) + (sha256 ONE ATTENDANCE_REQUIRED) + (sha256 ONE PROPOSAL_SOFTCLOSE_LENGTH) + (sha256 ONE PROPOSAL_LENGTH) + (sha256tree PROPOSAL_VALIDATOR) + (sha256 ONE TREASURY_MOD_HASH) + ) + ) + + (defun calculate_singleton_puzzle_hash (SINGLETON_STRUCT inner_puzzle_hash) + (puzzle-hash-of-curried-function (f SINGLETON_STRUCT) + inner_puzzle_hash + (sha256tree SINGLETON_STRUCT) + ) + ) + + (defun stager (ORACLE_SPEND_DELAY my_inner_puzhash singleton_struct) + (c + (if singleton_struct + (list ASSERT_MY_COIN_ID + (calculate_coin_id + (f (r singleton_struct)) + (calculate_singleton_puzzle_hash singleton_struct my_inner_puzhash) + ONE + ) + ;; TODO: When the new condition codes are available, use ASSERT_EPHEMERAL to ensure this + ;; spend path is only used in the eve spend. + ;; (list ASSERT_EPHEMERAL) + ) + (list ASSERT_HEIGHT_RELATIVE ORACLE_SPEND_DELAY) + ) + (list (list CREATE_COIN my_inner_puzhash ONE)) + ) + ) + + (c + (list CREATE_PUZZLE_ANNOUNCEMENT 0) ; the arguments are secured implicitly in the puzzle of the treasury + (if delegated_puzzle_reveal + ; if we're checking a proposal (testing if it has passed) + (if (= (sha256tree delegated_puzzle_reveal) delegated_puzzle_hash) + ; Merge the treasury conditions with the proposal validator conditions + ; If the update case then the validator returns the new treasury create coin + ; If the spend case then we need to recreate the treasury outselves + ; treasury specific conditions + + (c + (list ASSERT_COIN_ANNOUNCEMENT (sha256 announcement_source (sha256tree (list delegated_puzzle_hash announcement_args)))) ; announcement source is validated inside the ProposalValidator + (a + PROPOSAL_VALIDATOR + (list + ATTENDANCE_REQUIRED + PASS_MARGIN + proposal_announcement + proposal_validator_solution + (a delegated_puzzle_reveal delegated_solution) + ) + ) + ) + (x) + ) + ; no proposal_flag so create the oracle announcement + (stager + ORACLE_SPEND_DELAY + (recreate_self + TREASURY_MOD_HASH + PROPOSAL_VALIDATOR + PROPOSAL_LENGTH + PROPOSAL_SOFTCLOSE_LENGTH + ATTENDANCE_REQUIRED + PASS_MARGIN + PROPOSAL_SELF_DESTRUCT_TIME + ORACLE_SPEND_DELAY + ) + my_singleton_struct + ) + ) + ) +) diff --git a/chia/wallet/puzzles/dao_treasury.clsp.hex b/chia/wallet/puzzles/dao_treasury.clsp.hex new file mode 100644 index 000000000000..ef5c5c3e409e --- /dev/null +++ b/chia/wallet/puzzles/dao_treasury.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff013effff04ffff0180ffff01808080ffff02ffff03ff8217ffffff01ff02ffff01ff02ffff03ffff09ffff02ff0affff04ff02ffff04ff8217ffff80808080ff8215ff80ffff01ff02ffff01ff04ffff04ffff013dffff04ffff0bff8209ffffff02ff0affff04ff02ffff04ffff04ff8215ffffff04ff822dffffff01808080ff8080808080ffff01808080ffff02ff0bffff04ff5fffff04ff8200bfffff04ff8205ffffff04ff820bffffff04ffff02ff8217ffff822fff80ffff018080808080808080ff0180ffff01ff02ffff01ff0880ff018080ff0180ff0180ffff01ff02ffff01ff02ff1effff04ff02ffff04ff8202ffffff04ffff02ff14ffff04ff02ffff04ff05ffff04ffff0bffff0101ff8202ff80ffff04ffff0bffff0101ff82017f80ffff04ffff0bffff0101ff8200bf80ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff2f80ffff04ffff0bffff0101ff1780ffff04ffff02ff0affff04ff02ffff04ff0bff80808080ffff04ffff0bffff0101ff0580ff808080808080808080808080ffff04ff825fffff808080808080ff018080ff018080ffff04ffff01ffffff02ffff03ff05ffff01ff02ffff01ff02ff08ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ffff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff08ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ff02ffff03ffff22ffff09ffff0dff0580ffff012080ffff09ffff0dff0b80ffff012080ffff15ff17ffff0181ff8080ffff01ff02ffff01ff0bff05ff0bff1780ff0180ffff01ff02ffff01ff0880ff018080ff0180ffff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff0affff04ff02ffff04ffff05ff0580ff80808080ffff02ff0affff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ffff02ff14ffff04ff02ffff04ff09ffff04ff0bffff04ffff02ff0affff04ff02ffff04ff05ff80808080ff808080808080ff04ffff02ffff03ff17ffff01ff02ffff01ff04ffff0146ffff04ffff02ff1cffff04ff02ffff04ffff05ffff06ff178080ffff04ffff02ff16ffff04ff02ffff04ff17ffff04ff0bff8080808080ffff04ffff0101ff808080808080ffff01808080ff0180ffff01ff02ffff01ff04ffff0152ffff04ff05ffff01808080ff018080ff0180ffff04ffff04ffff0133ffff04ff0bffff01ff01808080ff808080ff018080 diff --git a/chia/wallet/puzzles/dao_update_proposal.clsp b/chia/wallet/puzzles/dao_update_proposal.clsp new file mode 100644 index 000000000000..cbbdc8defb8a --- /dev/null +++ b/chia/wallet/puzzles/dao_update_proposal.clsp @@ -0,0 +1,44 @@ +(mod + ( + TREASURY_MOD_HASH + VALIDATOR_MOD_HASH + SINGLETON_STRUCT ; (SINGLETON_MOD_HASH (SINGLETON_ID . LAUNCHER_PUZZLE_HASH)) + PROPOSAL_SELF_HASH + PROPOSAL_MINIMUM_AMOUNT + PROPOSAL_EXCESS_PAYOUT_PUZHASH + PROPOSAL_LENGTH + PROPOSAL_SOFTCLOSE_LENGTH + ATTENDANCE_REQUIRED + PASS_MARGIN + PROPOSAL_SELF_DESTRUCT_TIME + ORACLE_SPEND_DELAY + ) + ;; This is a proposal to update treasury conditions for a DAO + + + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + (include utility_macros.clib) + + (list + (list CREATE_COIN + (puzzle-hash-of-curried-function TREASURY_MOD_HASH + (sha256 ONE ORACLE_SPEND_DELAY) + (sha256 ONE PROPOSAL_SELF_DESTRUCT_TIME) + (sha256 ONE PASS_MARGIN) + (sha256 ONE ATTENDANCE_REQUIRED) + (sha256 ONE PROPOSAL_SOFTCLOSE_LENGTH) + (sha256 ONE PROPOSAL_LENGTH) + (puzzle-hash-of-curried-function VALIDATOR_MOD_HASH + (sha256 ONE PROPOSAL_EXCESS_PAYOUT_PUZHASH) + (sha256 ONE PROPOSAL_MINIMUM_AMOUNT) + (sha256 ONE PROPOSAL_SELF_HASH) + (sha256tree SINGLETON_STRUCT) + ) + (sha256 ONE TREASURY_MOD_HASH) + ) + ONE + ) + ) +) diff --git a/chia/wallet/puzzles/dao_update_proposal.clsp.hex b/chia/wallet/puzzles/dao_update_proposal.clsp.hex new file mode 100644 index 000000000000..6c6608d9f909 --- /dev/null +++ b/chia/wallet/puzzles/dao_update_proposal.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff0133ffff04ffff02ff0affff04ff02ffff04ff05ffff04ffff0bffff0101ff822fff80ffff04ffff0bffff0101ff8217ff80ffff04ffff0bffff0101ff820bff80ffff04ffff0bffff0101ff8205ff80ffff04ffff0bffff0101ff8202ff80ffff04ffff0bffff0101ff82017f80ffff04ffff02ff0affff04ff02ffff04ff0bffff04ffff0bffff0101ff8200bf80ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff2f80ffff04ffff02ff0effff04ff02ffff04ff17ff80808080ff8080808080808080ffff04ffff0bffff0101ff0580ff808080808080808080808080ffff04ffff0101ffff0180808080ffff018080ffff04ffff01ffff02ffff03ff05ffff01ff02ffff01ff02ff04ffff04ff02ffff04ffff06ff0580ffff04ffff0bffff0102ffff0bffff0101ffff010480ffff0bffff0102ffff0bffff0102ffff0bffff0101ffff010180ffff05ff058080ffff0bffff0102ff0bffff0bffff0101ffff018080808080ff8080808080ff0180ffff01ff02ffff010bff018080ff0180ffff0bffff0102ffff01a0a12871fee210fb8619291eaea194581cbd2531e4b23759d225f6806923f63222ffff0bffff0102ffff0bffff0102ffff01a09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b2ff0580ffff0bffff0102ffff02ff04ffff04ff02ffff04ff07ffff01ffa09dcf97a184f32623d11a73124ceb99a5709b083721e878a16d78f596718ba7b280808080ffff01a04bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a808080ff02ffff03ffff07ff0580ffff01ff02ffff01ff0bffff0102ffff02ff0effff04ff02ffff04ffff05ff0580ff80808080ffff02ff0effff04ff02ffff04ffff06ff0580ff8080808080ff0180ffff01ff02ffff01ff0bffff0101ff0580ff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/deployed_puzzle_hashes.json b/chia/wallet/puzzles/deployed_puzzle_hashes.json index 40f9dac6a50b..4db5f7a43397 100644 --- a/chia/wallet/puzzles/deployed_puzzle_hashes.json +++ b/chia/wallet/puzzles/deployed_puzzle_hashes.json @@ -7,6 +7,16 @@ "covenant_layer": "b982796850336aabf9ab17c3f21e299f0c633444117ab5e9ebeafadf1860d9fc", "create_nft_launcher_from_did": "7a32d2d9571d3436791c0ad3d7fcfdb9c43ace2b0f0ff13f98d29f0cc093f445", "credential_restriction": "2fdfc1f058cfd65e7ec4e253bfeb394da163ecd0036f508df8629b0a2b8fde96", + "dao_cat_eve": "488f55bedaca5a599544dfd5ab341e2e5c7e6fca67d9b98a3d856f876c52f53e", + "dao_cat_launcher": "a01a838d18d4e031e937c79fa3f80f213fa00a3e64af6c16a1f137770cd3a567", + "dao_finished_state": "694c99e1fb07671771bbca3d110880693a9ecc37a6529891ec979d0f3e760eba", + "dao_lockup": "d6215f0916715a69fbbf2d1a679f437fde81787adeb90c666642fb9c2deff7ce", + "dao_proposal": "b25dd85e418791aebb7d7486914b1998e117da989a66a40f00259f925b3ddb16", + "dao_proposal_timer": "1acd912fca662d1474f7a6c762280fc1430875bef518883387086c1125027526", + "dao_proposal_validator": "507197e6645e3741efc200de19edc4556be1352c09ce1c9edaad7f1a4fc9d6a1", + "dao_spend_p2_singleton_v2": "e76c813d409e11ab58989a234eff1907b17d295496e6fc125781750d6a530a58", + "dao_treasury": "a80ab006a05f8fa0156c4bec25b747075c61338c6d0c0ffe95fd04ea96c636d7", + "dao_update_proposal": "fc032384cfece9b542c3e1ea77ba119fb1013a3d74b622302c0b670447e4343d", "decompress_coin_spend_entry": "9d98ed08770d31be4bd1bde4705dab388db5e7e9c349f5a76fc3c347aa3a0b79", "decompress_coin_spend_entry_with_prefix": "92aa4bc8060a8836355a1884075141b4791ce1b67ae6092bb166b2845954bc89", "decompress_puzzle": "fe94c58f1117afe315e0450daca1c62460ec1a1c439cd4018d79967a5d7d1370", @@ -19,6 +29,7 @@ "exigent_metadata_layer": "d5fd32e069fda83e230ccd8f6a7c4f652231aed5c755514b3d996cbeff4182b8", "flag_proofs_checker": "fe2e3c631562fbb9be095297f762bf573705a0197164e9361ad5d50e045ba241", "genesis_by_coin_id": "493afb89eed93ab86741b2aa61b8f5de495d33ff9b781dfc8919e602b2afa150", + "genesis_by_coin_id_or_singleton": "40170305e3a71c3e7523f37fbcfc3188f9f949da0818a6331f28251e76e8c56f", "genesis_by_puzzle_hash": "de5a6e06d41518be97ff6365694f4f89475dda773dede267caa33da63b434e36", "graftroot_dl_offers": "0893e36a88c064fddfa6f8abdb42c044584a98cb4273b80cccc83b4867b701a1", "nft_intermediate_launcher": "7a32d2d9571d3436791c0ad3d7fcfdb9c43ace2b0f0ff13f98d29f0cc093f445", @@ -38,7 +49,9 @@ "p2_parent": "b10ce2d0b18dcf8c21ddfaf55d9b9f0adcbf1e0beb55b1a8b9cad9bbff4e5f22", "p2_puzzle_hash": "13e29a62b42cd2ef72a79e4bacdc59733ca6310d65af83d349360d36ec622363", "p2_singleton": "40f828d8dd55603f4ff9fbf6b73271e904e69406982f4fbefae2c8dcceaf9834", + "p2_singleton_aggregator": "f537d6ec44aa42f45da8180d9c172663d215828f5dbcc6e2f82a96e6536a30c3", "p2_singleton_or_delayed_puzhash": "adb656e0211e2ab4f42069a4c5efc80dc907e7062be08bf1628c8e5b6d94d25b", + "p2_singleton_via_delegated_puzzle": "9590eaa169e45b655a31d3c06bbd355a3e2b2e3e410d3829748ce08ab249c39e", "pool_member_innerpuz": "a8490702e333ddd831a3ac9c22d0fa26d2bfeaf2d33608deb22f0e0123eb0494", "pool_waitingroom_innerpuz": "a317541a765bf8375e1c6e7c13503d0d2cbf56cacad5182befe947e78e2c0307", "rom_bootstrap_generator": "161bade1f822dcd62ab712ebaf30f3922a301e48a639e4295c5685f8bece7bd9", diff --git a/chia/wallet/puzzles/p2_singleton_aggregator.clsp b/chia/wallet/puzzles/p2_singleton_aggregator.clsp new file mode 100644 index 000000000000..2e401a64221a --- /dev/null +++ b/chia/wallet/puzzles/p2_singleton_aggregator.clsp @@ -0,0 +1,71 @@ +;; Works with p2_singleton_via_delegated_puzzle +;; When we have many p2_singleton coins and want to aggregate them together + + +(mod + ( + my_id + my_puzhash + my_amount + list_of_parent_puzhash_amounts ; list of (parent_id puzhash amount) for the merge + output_parent_amount ; (parent_id amount) of the coin creating the output + ) + + (include condition_codes.clib) + (include curry-and-treehash.clib) + (include *standard-cl-21*) + + (defun cons_announcements_to_output (coin_id output) + (c + (list ASSERT_COIN_ANNOUNCEMENT (sha256 coin_id 0)) + output + ) + ) + + (defun for_parent_puzhash_amounts + ( + my_puzhash + (@ coin_info_list ((@ first (parent puzhash amount)) . rest)) + total + ) + (if coin_info_list + (cons_announcements_to_output + (calculate_coin_id parent puzhash amount) + (for_parent_puzhash_amounts my_puzhash rest (+ total amount)) + ) + (list + (list ASSERT_HEIGHT_RELATIVE 5) ; TODO: should this be higher or lower? + (list CREATE_COIN my_puzhash total (list my_puzhash)) + ) + ) + ) + + (defun-inline give_self_to_merge (my_puzhash (@ output_info (parent amount))) + ;; Coins being merged are asserting the output coin id, and insisting it has the same puzhash as them + ;; This ensures that the puzzle which issues the CREATE_COIN condition is the same as this puzzle. + (list + (list CREATE_COIN_ANNOUNCEMENT 0) + (list ASSERT_COIN_ANNOUNCEMENT (sha256 (calculate_coin_id parent my_puzhash amount) 0)) + ) + ) + + (c + (list ASSERT_MY_AMOUNT my_amount) + (c + (list ASSERT_MY_PUZZLEHASH my_puzhash) + (if list_of_parent_puzhash_amounts + ; we are making the output + (c + (list ASSERT_MY_COIN_ID my_id) + (c + (list CREATE_COIN_ANNOUNCEMENT 0) + (for_parent_puzhash_amounts my_puzhash list_of_parent_puzhash_amounts my_amount) + ) + ) + ; we are letting another coin make the output + (give_self_to_merge my_puzhash output_parent_amount) + ) + ) + ) + +) diff --git a/chia/wallet/puzzles/p2_singleton_aggregator.clsp.hex b/chia/wallet/puzzles/p2_singleton_aggregator.clsp.hex new file mode 100644 index 000000000000..547deea2e477 --- /dev/null +++ b/chia/wallet/puzzles/p2_singleton_aggregator.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff04ffff04ffff0149ffff04ff17ffff01808080ffff04ffff04ffff0148ffff04ff0bffff01808080ffff02ffff03ff2fffff01ff02ffff01ff04ffff04ffff0146ffff04ff05ffff01808080ffff04ffff04ffff013cffff04ffff0180ffff01808080ffff02ff0effff04ff02ffff04ff0bffff04ff2fffff04ff17ff8080808080808080ff0180ffff01ff02ffff01ff04ffff04ffff013cffff04ffff0180ffff01808080ffff04ffff04ffff013dffff04ffff0bffff02ff04ffff04ff02ffff04ffff05ff5f80ffff04ff0bffff04ffff05ffff06ff5f8080ff808080808080ffff018080ffff01808080ffff01808080ff018080ff01808080ffff04ffff01ffff02ffff03ffff22ffff09ffff0dff0580ffff012080ffff09ffff0dff0b80ffff012080ffff15ff17ffff0181ff8080ffff01ff02ffff01ff0bff05ff0bff1780ff0180ffff01ff02ffff01ff0880ff018080ff0180ffff04ffff04ffff013dffff04ffff0bff05ff8080ff808080ff0b80ff02ffff03ff0bffff01ff02ffff01ff02ff0affff04ff02ffff04ffff02ff04ffff04ff02ffff04ff23ffff04ff53ffff04ff8200b3ff808080808080ffff04ffff02ff0effff04ff02ffff04ff05ffff04ff1bffff04ffff10ff17ff8200b380ff808080808080ff8080808080ff0180ffff01ff02ffff01ff04ffff04ffff0152ffff04ffff0105ffff01808080ffff04ffff04ffff0133ffff04ff05ffff04ff17ffff04ffff04ff05ffff018080ffff018080808080ffff01808080ff018080ff0180ff018080 diff --git a/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp b/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp new file mode 100644 index 000000000000..8403b74efe02 --- /dev/null +++ b/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp @@ -0,0 +1,47 @@ +;; This puzzle holds an amount which can be spent via two spend paths: +;; 1. to a delegated puzzle provided our owner singleton creates a puzzle announcement of this coin's id and the delegated puzzle. +;; 2. coins of this puzzle type can be merged together without the owner singleton's permission. This spend type is useful for DAOs which use this puzzle to custody funds and want to keep a reasonable limit on the number of coins tracked by DAO wallets. +;; The AGGREGATOR_PUZZLE is curried in to preserve generality and so its logic can be updated without requiring any change to the spend to delegated path. Optionally the Aggregator puzzle can be `(x)` to close off this spend path + +(mod ( + SINGLETON_STRUCT + AGGREGATOR_PUZZLE + aggregator_solution ; (my_id my_puzhash list_of_parent_puzhash_amounts my_amount) + singleton_inner_puzhash + delegated_puzzle + delegated_solution + my_id + ) + + (include condition_codes.clib) + (include curry-and-treehash.clib) + + (defun-inline calculate_full_puzzle_hash (SINGLETON_STRUCT singleton_inner_puzhash) + (puzzle-hash-of-curried-function (f SINGLETON_STRUCT) + singleton_inner_puzhash + (sha256tree SINGLETON_STRUCT) + ) + ) + + (if aggregator_solution + ; we are merging coins to make a larger coin + (a AGGREGATOR_PUZZLE aggregator_solution) + ; we are being spent by our singleton + (c + (list + ASSERT_PUZZLE_ANNOUNCEMENT + (sha256 + (calculate_full_puzzle_hash SINGLETON_STRUCT singleton_inner_puzhash) + (sha256tree (list my_id (sha256tree delegated_puzzle))) + ) + ) + (c + (list CREATE_COIN_ANNOUNCEMENT '$') + (c + (list ASSERT_MY_COIN_ID my_id) + (a delegated_puzzle delegated_solution) + ) + ) + ) + ) +) diff --git a/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp.hex b/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp.hex new file mode 100644 index 000000000000..40d51fd27f0c --- /dev/null +++ b/chia/wallet/puzzles/p2_singleton_via_delegated_puzzle.clsp.hex @@ -0,0 +1 @@ +ff02ffff01ff02ffff03ff17ffff01ff02ff0bff1780ffff01ff04ffff04ff18ffff04ffff0bffff02ff2effff04ff02ffff04ff09ffff04ff2fffff04ffff02ff3effff04ff02ffff04ff05ff80808080ff808080808080ffff02ff3effff04ff02ffff04ffff04ff82017fffff04ffff02ff3effff04ff02ffff04ff5fff80808080ff808080ff8080808080ff808080ffff04ffff04ff2cffff01ff248080ffff04ffff04ff10ffff04ff82017fff808080ffff02ff5fff81bf8080808080ff0180ffff04ffff01ffffff463fff02ff3c04ffff01ff0102ffff02ffff03ff05ffff01ff02ff16ffff04ff02ffff04ff0dffff04ffff0bff3affff0bff12ff3c80ffff0bff3affff0bff3affff0bff12ff2a80ff0980ffff0bff3aff0bffff0bff12ff8080808080ff8080808080ffff010b80ff0180ffff0bff3affff0bff12ff1480ffff0bff3affff0bff3affff0bff12ff2a80ff0580ffff0bff3affff02ff16ffff04ff02ffff04ff07ffff04ffff0bff12ff1280ff8080808080ffff0bff12ff8080808080ff02ffff03ffff07ff0580ffff01ff0bffff0102ffff02ff3effff04ff02ffff04ff09ff80808080ffff02ff3effff04ff02ffff04ff0dff8080808080ffff01ff0bffff0101ff058080ff0180ff018080 diff --git a/chia/wallet/puzzles/tails.py b/chia/wallet/puzzles/tails.py index 7c8c5fabc6e8..e9cf83ea5941 100644 --- a/chia/wallet/puzzles/tails.py +++ b/chia/wallet/puzzles/tails.py @@ -2,6 +2,8 @@ from typing import Any, Dict, List, Optional, Tuple +from chia_rs import Coin + from chia.types.blockchain_format.program import Program from chia.types.blockchain_format.sized_bytes import bytes32 from chia.types.spend_bundle import SpendBundle @@ -15,6 +17,7 @@ unsigned_spend_bundle_for_spendable_cats, ) from chia.wallet.cat_wallet.lineage_store import CATLineageStore +from chia.wallet.dao_wallet.dao_utils import create_cat_launcher_for_singleton_id from chia.wallet.lineage_proof import LineageProof from chia.wallet.payment import Payment from chia.wallet.puzzles.load_clvm import load_clvm_maybe_recompile @@ -33,6 +36,9 @@ DELEGATED_LIMITATIONS_MOD = load_clvm_maybe_recompile( "delegated_tail.clsp", package_or_requirement="chia.wallet.cat_wallet.puzzles" ) +GENESIS_BY_ID_OR_SINGLETON_MOD = load_clvm_maybe_recompile( + "genesis_by_coin_id_or_singleton.clsp", package_or_requirement="chia.wallet.cat_wallet.puzzles" +) class LimitationsProgram: @@ -202,6 +208,93 @@ def solve(args: List[Program], solution_dict: Dict) -> Program: ) +class GenesisByIdOrSingleton(LimitationsProgram): + """ + This TAIL allows for another TAIL to be used, as long as a signature of that TAIL's puzzlehash is included. + """ + + @staticmethod + def match(uncurried_mod: Program, curried_args: Program) -> Tuple[bool, List[Program]]: # pragma: no cover + if uncurried_mod == GENESIS_BY_ID_OR_SINGLETON_MOD: + genesis_id = curried_args.first() + return True, [genesis_id.as_atom()] + else: + return False, [] + + @staticmethod + def construct(args: List[Program]) -> Program: + return GENESIS_BY_ID_OR_SINGLETON_MOD.curry( + args[0], + args[1], + ) + + @staticmethod + def solve(args: List[Program], solution_dict: Dict) -> Program: # pragma: no cover + pid = hexstr_to_bytes(solution_dict["parent_coin_info"]) + return Program.to([pid, solution_dict["amount"]]) + + @classmethod + async def generate_issuance_bundle( + cls, wallet, tail_info: Dict, amount: uint64, tx_config: TXConfig, fee: uint64 = uint64(0) + ) -> Tuple[TransactionRecord, SpendBundle]: + if "coins" in tail_info: + coins: List[Coin] = tail_info["coins"] + origin_id = coins.copy().pop().name() + else: # pragma: no cover + coins = await wallet.standard_wallet.select_coins(amount + fee, tx_config.coin_selection_config) + origin = coins.copy().pop() + origin_id = origin.name() + + cat_inner: Program = await wallet.get_new_inner_puzzle() + # GENESIS_ID + # TREASURY_SINGLETON_STRUCT ; (SINGLETON_MOD_HASH, (LAUNCHER_ID, LAUNCHER_PUZZLE_HASH)) + launcher_puzhash = create_cat_launcher_for_singleton_id(tail_info["treasury_id"]).get_tree_hash() + tail: Program = cls.construct( + [ + Program.to(origin_id), + Program.to(launcher_puzhash), + ] + ) + + wallet.lineage_store = await CATLineageStore.create( + wallet.wallet_state_manager.db_wrapper, tail.get_tree_hash().hex() + ) + await wallet.add_lineage(origin_id, LineageProof()) + + minted_cat_puzzle_hash: bytes32 = construct_cat_puzzle(CAT_MOD, tail.get_tree_hash(), cat_inner).get_tree_hash() + + tx_records: List[TransactionRecord] = await wallet.standard_wallet.generate_signed_transaction( + amount, minted_cat_puzzle_hash, tx_config, fee, coins=set(coins), origin_id=origin_id + ) + tx_record: TransactionRecord = tx_records[0] + assert tx_record.spend_bundle is not None + payment = Payment(cat_inner.get_tree_hash(), amount) + inner_solution = wallet.standard_wallet.add_condition_to_solution( + Program.to([51, 0, -113, tail, []]), + wallet.standard_wallet.make_solution( + primaries=[payment], + ), + ) + eve_spend = unsigned_spend_bundle_for_spendable_cats( + CAT_MOD, + [ + SpendableCAT( + list(filter(lambda a: a.amount == amount, tx_record.additions))[0], + tail.get_tree_hash(), + cat_inner, + inner_solution, + limitations_program_reveal=tail, + ) + ], + ) + signed_eve_spend = await wallet.sign(eve_spend) + + if wallet.cat_info.my_tail is None: + await wallet.save_info(CATInfo(tail.get_tree_hash(), tail)) + + return tx_record, SpendBundle.aggregate([tx_record.spend_bundle, signed_eve_spend]) + + # This should probably be much more elegant than just a dictionary with strings as identifiers # Right now this is small and experimental so it can stay like this ALL_LIMITATIONS_PROGRAMS: Dict[str, Any] = { @@ -209,6 +302,7 @@ def solve(args: List[Program], solution_dict: Dict) -> Program: "genesis_by_puzhash": GenesisByPuzhash, "everything_with_signature": EverythingWithSig, "delegated_limitations": DelegatedLimitations, + "genesis_by_id_or_singleton": GenesisByIdOrSingleton, } diff --git a/chia/wallet/singleton.py b/chia/wallet/singleton.py index 527836df9a6d..2685387697cf 100644 --- a/chia/wallet/singleton.py +++ b/chia/wallet/singleton.py @@ -1,9 +1,12 @@ from __future__ import annotations -from typing import Optional +from typing import List, Optional, Union +from chia.types.blockchain_format.coin import Coin from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.serialized_program import SerializedProgram from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend, compute_additions from chia.wallet.puzzles.load_clvm import load_clvm_maybe_recompile from chia.wallet.util.curry_and_treehash import calculate_hash_of_quoted_mod_hash, curry_and_treehash @@ -14,7 +17,7 @@ SINGLETON_LAUNCHER_PUZZLE_HASH = SINGLETON_LAUNCHER_PUZZLE.get_tree_hash() -def get_inner_puzzle_from_singleton(puzzle: Program) -> Optional[Program]: +def get_inner_puzzle_from_singleton(puzzle: Union[Program, SerializedProgram]) -> Optional[Program]: """ Extract the inner puzzle of a singleton :param puzzle: Singleton puzzle @@ -30,7 +33,23 @@ def get_inner_puzzle_from_singleton(puzzle: Program) -> Optional[Program]: return Program(INNER_PUZZLE) -def is_singleton(inner_f: Program) -> bool: +def get_singleton_id_from_puzzle(puzzle: Union[Program, SerializedProgram]) -> Optional[bytes32]: + """ + Extract the singleton ID from a singleton puzzle + :param puzzle: Singleton puzzle + :return: Inner puzzle + """ + r = puzzle.uncurry() + if r is None: + return None # pragma: no cover + inner_f, args = r + if not is_singleton(inner_f): + return None + SINGLETON_STRUCT, INNER_PUZZLE = list(args.as_iter()) + return bytes32(Program(SINGLETON_STRUCT).rest().first().as_atom()) + + +def is_singleton(inner_f: Union[Program, SerializedProgram]) -> bool: """ Check if a puzzle is a singleton mod :param inner_f: puzzle @@ -52,7 +71,7 @@ def create_singleton_puzzle_hash(innerpuz_hash: bytes32, launcher_id: bytes32) - return curry_and_treehash(SINGLETON_TOP_LAYER_MOD_HASH_QUOTED, singleton_struct.get_tree_hash(), innerpuz_hash) -def create_singleton_puzzle(innerpuz: Program, launcher_id: bytes32) -> Program: +def create_singleton_puzzle(innerpuz: Union[Program, SerializedProgram], launcher_id: bytes32) -> Program: """ Create a full Singleton puzzle :param innerpuz: Singleton inner puzzle @@ -62,3 +81,16 @@ def create_singleton_puzzle(innerpuz: Program, launcher_id: bytes32) -> Program: # singleton_struct = (MOD_HASH . (LAUNCHER_ID . LAUNCHER_PUZZLE_HASH)) singleton_struct = Program.to((SINGLETON_TOP_LAYER_MOD_HASH, (launcher_id, SINGLETON_LAUNCHER_PUZZLE_HASH))) return SINGLETON_TOP_LAYER_MOD.curry(singleton_struct, innerpuz) + + +def get_most_recent_singleton_coin_from_coin_spend(coin_sol: CoinSpend) -> Optional[Coin]: + additions: List[Coin] = compute_additions(coin_sol) + for coin in additions: + if coin.amount % 2 == 1: + return coin + return None # pragma: no cover + + +def get_singleton_struct_for_id(id: bytes32) -> Program: + singleton_struct: Program = Program.to((SINGLETON_TOP_LAYER_MOD_HASH, (id, SINGLETON_LAUNCHER_PUZZLE_HASH))) + return singleton_struct diff --git a/chia/wallet/singleton_record.py b/chia/wallet/singleton_record.py new file mode 100644 index 000000000000..77a1b6e7ecc6 --- /dev/null +++ b/chia/wallet/singleton_record.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.util.ints import uint32 +from chia.wallet.lineage_proof import LineageProof + + +@dataclass(frozen=True) +class SingletonRecord: + """ + These are values that correspond to a singleton in the WalletSingletonStore + """ + + coin: Coin + singleton_id: bytes32 + wallet_id: uint32 + parent_coinspend: CoinSpend + inner_puzzle_hash: Optional[bytes32] + pending: bool + removed_height: int + lineage_proof: LineageProof + custom_data: Optional[Any] + + def name(self) -> bytes32: # pragma: no cover + return self.coin.name() diff --git a/chia/wallet/util/wallet_types.py b/chia/wallet/util/wallet_types.py index 5dbf3e7cc731..96fd4c06c96f 100644 --- a/chia/wallet/util/wallet_types.py +++ b/chia/wallet/util/wallet_types.py @@ -26,6 +26,8 @@ class WalletType(IntEnum): DATA_LAYER = 11 DATA_LAYER_OFFER = 12 VC = 13 + DAO = 14 + DAO_CAT = 15 CRCAT = 57 diff --git a/chia/wallet/wallet_node.py b/chia/wallet/wallet_node.py index ea9537ef662a..3acc434b9da0 100644 --- a/chia/wallet/wallet_node.py +++ b/chia/wallet/wallet_node.py @@ -283,6 +283,7 @@ async def reset_sync_db(self, db_path: Union[Path, str], fingerprint: int) -> bo "trade_record_times", "tx_times", "pool_state_transitions", + "singletons", "singleton_records", "mirrors", "launchers", diff --git a/chia/wallet/wallet_singleton_store.py b/chia/wallet/wallet_singleton_store.py new file mode 100644 index 000000000000..7dd691e565da --- /dev/null +++ b/chia/wallet/wallet_singleton_store.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import json +import logging +from sqlite3 import Row +from typing import List, Optional, Type, TypeVar, Union + +from clvm.casts import int_from_bytes + +from chia.consensus.default_constants import DEFAULT_CONSTANTS +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.types.condition_opcodes import ConditionOpcode +from chia.util.condition_tools import conditions_dict_for_solution +from chia.util.db_wrapper import DBWrapper2, execute_fetchone +from chia.util.ints import uint32 +from chia.wallet import singleton +from chia.wallet.lineage_proof import LineageProof +from chia.wallet.singleton import get_inner_puzzle_from_singleton, get_singleton_id_from_puzzle +from chia.wallet.singleton_record import SingletonRecord + +log = logging.getLogger(__name__) +_T_WalletSingletonStore = TypeVar("_T_WalletSingletonStore", bound="WalletSingletonStore") + + +class WalletSingletonStore: + db_wrapper: DBWrapper2 + + @classmethod + async def create(cls: Type[_T_WalletSingletonStore], wrapper: DBWrapper2) -> _T_WalletSingletonStore: + self = cls() + self.db_wrapper = wrapper + + async with self.db_wrapper.writer_maybe_transaction() as conn: + await conn.execute( + ( + "CREATE TABLE IF NOT EXISTS singletons(" + "coin_id blob PRIMARY KEY," + " coin text," + " singleton_id blob," + " wallet_id int," + " parent_coin_spend blob," + " inner_puzzle_hash blob," + " pending tinyint," + " removed_height int," + " lineage_proof blob," + " custom_data blob)" + ) + ) + + await conn.execute("CREATE INDEX IF NOT EXISTS removed_height_index on singletons(removed_height)") + + return self + + async def save_singleton(self, record: SingletonRecord) -> None: + singleton_id = singleton.get_singleton_id_from_puzzle(record.parent_coinspend.puzzle_reveal) + if singleton_id is None: # pragma: no cover + raise RuntimeError( + "Failed to derive Singleton ID from puzzle reveal in parent spend %s", record.parent_coinspend + ) + pending_int = 0 + if record.pending: + pending_int = 1 + async with self.db_wrapper.writer_maybe_transaction() as conn: + columns = ( + "coin_id, coin, singleton_id, wallet_id, parent_coin_spend, inner_puzzle_hash, " + "pending, removed_height, lineage_proof, custom_data" + ) + await conn.execute( + f"INSERT or REPLACE INTO singletons ({columns}) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + record.coin.name().hex(), + json.dumps(record.coin.to_json_dict()), + singleton_id.hex(), + record.wallet_id, + bytes(record.parent_coinspend), + record.inner_puzzle_hash, + pending_int, + record.removed_height, + bytes(record.lineage_proof), + record.custom_data, + ), + ) + + async def add_spend( + self, + wallet_id: uint32, + coin_state: CoinSpend, + block_height: uint32 = uint32(0), + pending: bool = True, + ) -> None: + """Given a coin spend of a singleton, attempt to calculate the child coin and details + for the new singleton record. Add the new record to the store and remove the old record + if it exists + """ + # get singleton_id from puzzle_reveal + singleton_id = get_singleton_id_from_puzzle(coin_state.puzzle_reveal) + if not singleton_id: + raise RuntimeError("Coin to add is not a valid singleton") + + # get details for singleton record + conditions = conditions_dict_for_solution( + coin_state.puzzle_reveal.to_program(), + coin_state.solution.to_program(), + DEFAULT_CONSTANTS.MAX_BLOCK_COST_CLVM, + ) + + cc_cond = [cond for cond in conditions[ConditionOpcode.CREATE_COIN] if int_from_bytes(cond.vars[1]) % 2 == 1][0] + + coin = Coin(coin_state.coin.name(), cc_cond.vars[0], int_from_bytes(cc_cond.vars[1])) + inner_puz = get_inner_puzzle_from_singleton(coin_state.puzzle_reveal) + if inner_puz is None: # pragma: no cover + raise RuntimeError("Could not get inner puzzle from puzzle reveal in coin spend %s", coin_state) + + lineage_bytes = [x.as_atom() for x in coin_state.solution.to_program().first().as_iter()] + if len(lineage_bytes) == 2: + lineage_proof = LineageProof(lineage_bytes[0], None, int_from_bytes(lineage_bytes[1])) + else: + lineage_proof = LineageProof(lineage_bytes[0], lineage_bytes[1], int_from_bytes(lineage_bytes[2])) + # Create and save the new singleton record + new_record = SingletonRecord( + coin, singleton_id, wallet_id, coin_state, inner_puz.get_tree_hash(), pending, 0, lineage_proof, None + ) + await self.save_singleton(new_record) + # check if coin is in DB and mark deleted if found + current_records = await self.get_records_by_coin_id(coin_state.coin.name()) + if len(current_records) > 0: + await self.delete_singleton_by_coin_id(coin_state.coin.name(), block_height) + return + + def _to_singleton_record(self, row: Row) -> SingletonRecord: + return SingletonRecord( + coin=Coin.from_json_dict(json.loads(row[1])), + singleton_id=bytes32.from_hexstr(row[2]), + wallet_id=uint32(row[3]), + parent_coinspend=CoinSpend.from_bytes(row[4]), + inner_puzzle_hash=bytes32.from_bytes(row[5]), # inner puz hash + pending=True if row[6] == 1 else False, + removed_height=uint32(row[7]), + lineage_proof=LineageProof.from_bytes(row[8]), + custom_data=row[9], + ) + + async def delete_singleton_by_singleton_id(self, singleton_id: bytes32, height: uint32) -> bool: + """Tries to mark a given singleton as deleted at specific height + + This is due to how re-org works + Returns `True` if singleton was found and marked deleted or `False` if not.""" + async with self.db_wrapper.writer_maybe_transaction() as conn: + cursor = await conn.execute( + "UPDATE singletons SET removed_height=? WHERE singleton_id=?", (int(height), singleton_id.hex()) + ) + if cursor.rowcount > 0: + log.info("Deleted singleton with singleton id: %s", singleton_id.hex()) + return True + log.warning("Couldn't find singleton with singleton id to delete: %s", singleton_id.hex()) + return False + + async def delete_singleton_by_coin_id(self, coin_id: bytes32, height: uint32) -> bool: + """Tries to mark a given singleton as deleted at specific height + + This is due to how re-org works + Returns `True` if singleton was found and marked deleted or `False` if not.""" + async with self.db_wrapper.writer_maybe_transaction() as conn: + cursor = await conn.execute( + "UPDATE singletons SET removed_height=? WHERE coin_id=?", (int(height), coin_id.hex()) + ) + if cursor.rowcount > 0: + log.info("Deleted singleton with coin id: %s", coin_id.hex()) + return True + log.warning("Couldn't find singleton with coin id to delete: %s", coin_id.hex()) + return False + + async def delete_wallet(self, wallet_id: uint32) -> None: + async with self.db_wrapper.writer_maybe_transaction() as conn: + cursor = await conn.execute("DELETE FROM singletons WHERE wallet_id=?", (wallet_id,)) + await cursor.close() + + async def update_pending_transaction(self, coin_id: bytes32, pending: bool) -> bool: + async with self.db_wrapper.writer_maybe_transaction() as conn: + c = await conn.execute( + "UPDATE singletons SET pending=? WHERE coin_id = ?", + (pending, coin_id.hex()), + ) + return c.rowcount > 0 + + async def get_records_by_wallet_id(self, wallet_id: int) -> List[SingletonRecord]: + """ + Retrieves all entries for a wallet ID. + """ + + async with self.db_wrapper.reader_no_transaction() as conn: + rows = await conn.execute_fetchall( + "SELECT * FROM singletons WHERE wallet_id = ? ORDER BY removed_height", + (wallet_id,), + ) + return [self._to_singleton_record(row) for row in rows] + + async def get_records_by_coin_id(self, coin_id: bytes32) -> List[SingletonRecord]: + """ + Retrieves all entries for a coin ID. + """ + + async with self.db_wrapper.reader_no_transaction() as conn: + rows = await conn.execute_fetchall( + "SELECT * FROM singletons WHERE coin_id = ?", + (coin_id.hex(),), + ) + return [self._to_singleton_record(row) for row in rows] + + async def get_records_by_singleton_id(self, singleton_id: bytes32) -> List[SingletonRecord]: + """ + Retrieves all entries for a singleton ID. + """ + + async with self.db_wrapper.reader_no_transaction() as conn: + rows = await conn.execute_fetchall( + "SELECT * FROM singletons WHERE singleton_id = ? ORDER BY removed_height", + (singleton_id.hex(),), + ) + return [self._to_singleton_record(row) for row in rows] + + async def rollback(self, height: int, wallet_id_arg: int) -> None: + """ + Rollback removes all entries which have entry_height > height passed in. Note that this is not committed to the + DB until db_wrapper.commit() is called. However, it is written to the cache, so it can be fetched with + get_all_state_transitions. + """ + + async with self.db_wrapper.writer_maybe_transaction() as conn: + cursor = await conn.execute( + "DELETE FROM singletons WHERE removed_height>? AND wallet_id=?", (height, wallet_id_arg) + ) + await cursor.close() + + async def count(self, wallet_id: Optional[uint32] = None) -> int: + sql = "SELECT COUNT(singleton_id) FROM singletons WHERE removed_height=0" + params: List[uint32] = [] + if wallet_id is not None: + sql += " AND wallet_id=?" + params.append(wallet_id) + async with self.db_wrapper.reader_no_transaction() as conn: + count_row = await execute_fetchone(conn, sql, params) + if count_row: + return int(count_row[0]) + return -1 # pragma: no cover + + async def is_empty(self, wallet_id: Optional[uint32] = None) -> bool: + sql = "SELECT 1 FROM singletons WHERE removed_height=0" + params: List[Union[uint32, bytes32]] = [] + if wallet_id is not None: + sql += " AND wallet_id=?" + params.append(wallet_id) + sql += " LIMIT 1" + async with self.db_wrapper.reader_no_transaction() as conn: + count_row = await execute_fetchone(conn, sql, params) + if count_row: + return False + return True diff --git a/chia/wallet/wallet_state_manager.py b/chia/wallet/wallet_state_manager.py index 7ae21f6f4958..4c5665289a6e 100644 --- a/chia/wallet/wallet_state_manager.py +++ b/chia/wallet/wallet_state_manager.py @@ -7,7 +7,21 @@ import traceback from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Callable, + Dict, + Iterator, + List, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, +) import aiosqlite from blspy import G1Element, G2Element, PrivateKey @@ -50,7 +64,17 @@ from chia.wallet.cat_wallet.cat_info import CATCoinData, CATInfo, CRCATInfo from chia.wallet.cat_wallet.cat_utils import CAT_MOD, CAT_MOD_HASH, construct_cat_puzzle, match_cat_puzzle from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.dao_cat_wallet import DAOCATWallet from chia.wallet.conditions import Condition, ConditionValidTimes, parse_timelock_info +from chia.wallet.dao_wallet.dao_utils import ( + get_p2_singleton_puzhash, + match_dao_cat_puzzle, + match_finished_puzzle, + match_funding_puzzle, + match_proposal_puzzle, + match_treasury_puzzle, +) +from chia.wallet.dao_wallet.dao_wallet import DAOWallet from chia.wallet.db_wallet.db_wallet_puzzles import MIRROR_PUZZLE_HASH from chia.wallet.derivation_record import DerivationRecord from chia.wallet.derive_keys import ( @@ -80,7 +104,7 @@ puzzle_hash_for_synthetic_public_key, ) from chia.wallet.sign_coin_spends import sign_coin_spends -from chia.wallet.singleton import create_singleton_puzzle, get_inner_puzzle_from_singleton +from chia.wallet.singleton import create_singleton_puzzle, get_inner_puzzle_from_singleton, get_singleton_id_from_puzzle from chia.wallet.trade_manager import TradeManager from chia.wallet.trading.trade_status import TradeStatus from chia.wallet.transaction_record import TransactionRecord @@ -114,6 +138,7 @@ from chia.wallet.wallet_protocol import WalletProtocol from chia.wallet.wallet_puzzle_store import WalletPuzzleStore from chia.wallet.wallet_retry_store import WalletRetryStore +from chia.wallet.wallet_singleton_store import WalletSingletonStore from chia.wallet.wallet_transaction_store import WalletTransactionStore from chia.wallet.wallet_user_store import WalletUserStore @@ -127,6 +152,8 @@ class WalletStateManager: + interested_ph_cache: Dict[bytes32, List[int]] = {} + interested_coin_cache: Dict[bytes32, List[int]] = {} constants: ConsensusConstants config: Dict[str, Any] tx_store: WalletTransactionStore @@ -165,6 +192,7 @@ class WalletStateManager: wallet_node: WalletNode pool_store: WalletPoolStore dl_store: DataLayerStore + singleton_store: WalletSingletonStore default_cats: Dict[str, Any] asset_to_wallet_map: Dict[AssetType, Any] initial_num_public_keys: int @@ -181,6 +209,7 @@ async def create( wallet_node: WalletNode, ) -> WalletStateManager: self = WalletStateManager() + self.config = config self.constants = constants self.server = server @@ -219,6 +248,7 @@ async def create( self.dl_store = await DataLayerStore.create(self.db_wrapper) self.interested_store = await WalletInterestedStore.create(self.db_wrapper) self.retry_store = await WalletRetryStore.create(self.db_wrapper) + self.singleton_store = await WalletSingletonStore.create(self.db_wrapper) self.default_cats = DEFAULT_CATS self.wallet_node = wallet_node @@ -273,8 +303,23 @@ async def create( self.main_wallet, wallet_info, ) - elif wallet_type == WalletType.DATA_LAYER: - wallet = await DataLayerWallet.create(self, wallet_info) + elif wallet_type == WalletType.DATA_LAYER: # pragma: no cover + wallet = await DataLayerWallet.create( + self, + wallet_info, + ) + elif wallet_type == WalletType.DAO: # pragma: no cover + wallet = await DAOWallet.create( + self, + self.main_wallet, + wallet_info, + ) + elif wallet_type == WalletType.DAO_CAT: # pragma: no cover + wallet = await DAOCATWallet.create( + self, + self.main_wallet, + wallet_info, + ) elif wallet_type == WalletType.VC: # pragma: no cover wallet = await VCWallet.create( self, @@ -712,8 +757,42 @@ async def determine_coin_type( coin_spend = await fetch_coin_spend_for_coin_state(parent_coin_state, peer) puzzle = Program.from_bytes(bytes(coin_spend.puzzle_reveal)) + solution = Program.from_bytes(bytes(coin_spend.solution)) + uncurried = uncurry_puzzle(puzzle) + dao_ids = [] + wallets = self.wallets.values() + for wallet in wallets: + if wallet.type() == WalletType.DAO.value: + assert isinstance(wallet, DAOWallet) + dao_ids.append(wallet.dao_info.treasury_id) + funding_puzzle_check = match_funding_puzzle(uncurried, solution, coin_state.coin, dao_ids) + if funding_puzzle_check: + return await self.get_dao_wallet_from_coinspend_hint(coin_spend, coin_state), None + + # Check if the coin is a DAO Treasury + dao_curried_args = match_treasury_puzzle(uncurried.mod, uncurried.args) + if dao_curried_args is not None: + return await self.handle_dao_treasury(dao_curried_args, parent_coin_state, coin_state, coin_spend), None + # Check if the coin is a Proposal and that it isn't the timer coin (amount == 0) + dao_curried_args = match_proposal_puzzle(uncurried.mod, uncurried.args) + if (dao_curried_args is not None) and (coin_state.coin.amount != 0): + return await self.handle_dao_proposal(dao_curried_args, parent_coin_state, coin_state, coin_spend), None + + # Check if the coin is a finished proposal + dao_curried_args = match_finished_puzzle(uncurried.mod, uncurried.args) + if dao_curried_args is not None: + return ( + await self.handle_dao_finished_proposals(dao_curried_args, parent_coin_state, coin_state, coin_spend), + None, + ) + + # Check if the coin is a DAO CAT + dao_cat_args = match_dao_cat_puzzle(uncurried) + if dao_cat_args: + return await self.handle_dao_cat(dao_cat_args, parent_coin_state, coin_state, coin_spend), None + # Check if the coin is a CAT cat_curried_args = match_cat_puzzle(uncurried) if cat_curried_args is not None: @@ -868,6 +947,8 @@ async def spend_clawback_coins( ) coin_spend: CoinSpend = generate_clawback_spend_bundle(coin, metadata, inner_puzzle, inner_solution) coin_spends.append(coin_spend) + # Update incoming tx to prevent double spend and mark it is pending + await self.tx_store.increment_sent(incoming_tx.name, "", MempoolInclusionStatus.PENDING, None) except Exception as e: self.log.error(f"Failed to create clawback spend bundle for {coin.name().hex()}: {e}") if len(coin_spends) == 0: @@ -900,9 +981,6 @@ async def spend_clawback_coins( valid_times=parse_timelock_info(extra_conditions), ) await self.add_pending_transaction(tx_record) - # Update incoming tx to prevent double spend and mark it is pending - for coin_spend in coin_spends: - await self.tx_store.increment_sent(coin_spend.coin.name(), "", MempoolInclusionStatus.PENDING, None) return [tx_record.name] async def filter_spam(self, new_coin_state: List[CoinState]) -> List[CoinState]: @@ -939,6 +1017,25 @@ async def is_standard_wallet_tx(self, coin_state: CoinState) -> bool: wallet_identifier = await self.get_wallet_identifier_for_puzzle_hash(coin_state.coin.puzzle_hash) return wallet_identifier is not None and wallet_identifier.type == WalletType.STANDARD_WALLET + async def handle_dao_cat( + self, + curried_args: Iterator[Program], + parent_coin_state: CoinState, + coin_state: CoinState, + coin_spend: CoinSpend, + ) -> Optional[WalletIdentifier]: + """ + Handle the new coin when it is a DAO CAT + """ + mod_hash, tail_hash, inner_puzzle = curried_args + asset_id: bytes32 = bytes32(bytes(tail_hash)[1:]) + for wallet in self.wallets.values(): + if wallet.type() == WalletType.DAO_CAT: + assert isinstance(wallet, DAOCATWallet) + if wallet.dao_cat_info.limitations_program_hash == asset_id: + return WalletIdentifier.create(wallet) + return None # pragma: no cover + async def handle_cat( self, parent_data: CATCoinData, @@ -1072,6 +1169,7 @@ async def handle_did( if derivation_record is None: self.log.info(f"Received state for the coin that doesn't belong to us {coin_state}") # Check if it was owned by us + # If the puzzle inside is no longer recognised then delete the wallet associated removed_wallet_ids = [] for wallet in self.wallets.values(): if not isinstance(wallet, DIDWallet): @@ -1181,6 +1279,94 @@ async def get_minter_did(self, launcher_coin: Coin, peer: WSChiaConnection) -> O minter_did = bytes32(bytes(singleton_struct.rest().first())[1:]) return minter_did + async def handle_dao_treasury( + self, + uncurried_args: Iterator[Program], + parent_coin_state: CoinState, + coin_state: CoinState, + coin_spend: CoinSpend, + ) -> Optional[WalletIdentifier]: + self.log.info("Entering dao_treasury handling in WalletStateManager") + singleton_id = get_singleton_id_from_puzzle(coin_spend.puzzle_reveal) + for wallet in self.wallets.values(): + if wallet.type() == WalletType.DAO: + assert isinstance(wallet, DAOWallet) + if wallet.dao_info.treasury_id == singleton_id: + return WalletIdentifier.create(wallet) + + # TODO: If we can't find the wallet for this DAO but we've got here because we're subscribed, + # then create the wallet. (see early in dao-wallet commits for how to do this) + return None # pragma: no cover + + async def handle_dao_proposal( + self, + uncurried_args: Iterator[Program], + parent_coin_state: CoinState, + coin_state: CoinState, + coin_spend: CoinSpend, + ) -> Optional[WalletIdentifier]: + ( + # ; second hash + SELF_HASH, + PROPOSAL_ID, + PROPOSED_PUZ_HASH, + YES_VOTES, + TOTAL_VOTES, + # ; first hash + PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_PUZHASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_MOD_HASH, + TREASURY_MOD_HASH, + LOCKUP_SELF_HASH, + CAT_TAIL_HASH, + TREASURY_ID, + ) = uncurried_args + for wallet in self.wallets.values(): + if wallet.type() == WalletType.DAO: + assert isinstance(wallet, DAOWallet) + if wallet.dao_info.treasury_id == TREASURY_ID.as_atom(): + assert isinstance(coin_state.created_height, int) + await wallet.add_or_update_proposal_info(coin_spend, uint32(coin_state.created_height)) + return WalletIdentifier.create(wallet) + return None # pragma: no cover + + async def handle_dao_finished_proposals( + self, + uncurried_args: Iterator[Program], + parent_coin_state: CoinState, + coin_state: CoinState, + coin_spend: CoinSpend, + ) -> Optional[WalletIdentifier]: + if coin_state.created_height is None: # pragma: no cover + raise ValueError("coin_state argument to handle_dao_finished_proposals cannot have created_height of None") + ( + SINGLETON_STRUCT, # (SINGLETON_MOD_HASH, (SINGLETON_ID, LAUNCHER_PUZZLE_HASH)) + FINISHED_STATE_MOD_HASH, + ) = uncurried_args + proposal_id = SINGLETON_STRUCT.rest().first().as_atom() + for wallet in self.wallets.values(): + if wallet.type() == WalletType.DAO: + assert isinstance(wallet, DAOWallet) + for proposal_info in wallet.dao_info.proposals_list: + if proposal_info.proposal_id == proposal_id: + await wallet.add_or_update_proposal_info(coin_spend, uint32(coin_state.created_height)) + return WalletIdentifier.create(wallet) + return None + + async def get_dao_wallet_from_coinspend_hint( + self, coin_spend: CoinSpend, coin_state: CoinState + ) -> Optional[WalletIdentifier]: + hinted_coin = compute_spend_hints_and_additions(coin_spend)[coin_state.coin.name()] + if hinted_coin: + for wallet in self.wallets.values(): + if wallet.type() == WalletType.DAO.value: + assert isinstance(wallet, DAOWallet) + if get_p2_singleton_puzhash(wallet.dao_info.treasury_id) == hinted_coin.hint: + return WalletIdentifier.create(wallet) + return None + async def handle_nft( self, nft_data: NFTCoinData, @@ -1658,6 +1844,7 @@ async def _add_coin_states( if record.coin_type == CoinType.CLAWBACK: await self.interested_store.remove_interested_coin_id(coin_state.coin.name()) confirmed_tx_records: List[TransactionRecord] = [] + for tx_record in all_unconfirmed: if tx_record.type in CLAWBACK_INCOMING_TRANSACTION_TYPES: for add_coin in tx_record.additions: @@ -1678,14 +1865,17 @@ async def _add_coin_states( unconfirmed_record.name, uint32(coin_state.spent_height) ) - if record.wallet_type == WalletType.POOLING_WALLET: + if record.wallet_type in [WalletType.POOLING_WALLET, WalletType.DAO]: + wallet_type_to_class = {WalletType.POOLING_WALLET: PoolWallet, WalletType.DAO: DAOWallet} if coin_state.spent_height is not None and coin_state.coin.amount == uint64(1): - pool_wallet = self.get_wallet(id=uint32(record.wallet_id), required_type=PoolWallet) + singleton_wallet: Union[PoolWallet, DAOWallet] = self.get_wallet( + id=uint32(record.wallet_id), required_type=wallet_type_to_class[record.wallet_type] + ) curr_coin_state: CoinState = coin_state while curr_coin_state.spent_height is not None: - cs = await fetch_coin_spend_for_coin_state(curr_coin_state, peer) - success = await pool_wallet.apply_state_transition( + cs: CoinSpend = await fetch_coin_spend_for_coin_state(curr_coin_state, peer) + success = await singleton_wallet.apply_state_transition( cs, uint32(curr_coin_state.spent_height) ) if not success: @@ -1977,10 +2167,14 @@ async def coin_added( coin_record: WalletCoinRecord = WalletCoinRecord( coin, height, uint32(0), False, coinbase, wallet_type, wallet_id ) + await self.coin_store.add_coin_record(coin_record, coin_name) await self.wallets[wallet_id].coin_added(coin, height, peer, coin_data) + if wallet_type == WalletType.DAO: + return + await self.create_more_puzzle_hashes() async def add_pending_transaction(self, tx_record: TransactionRecord) -> None: @@ -2222,12 +2416,31 @@ async def new_peak(self, height: uint32) -> None: self.tx_pending_changed() async def add_interested_puzzle_hashes(self, puzzle_hashes: List[bytes32], wallet_ids: List[int]) -> None: + # TODO: It's unclear if the intended use for this is that each puzzle hash should store all + # the elements of wallet_ids. It only stores one wallet_id per puzzle hash in the interested_store + # but the coin_cache keeps all wallet_ids for each puzzle hash + for puzzle_hash in puzzle_hashes: + if puzzle_hash in self.interested_coin_cache: + wallet_ids_to_add = list( + set([w for w in wallet_ids if w not in self.interested_coin_cache[puzzle_hash]]) + ) + self.interested_coin_cache[puzzle_hash].extend(wallet_ids_to_add) + else: + self.interested_coin_cache[puzzle_hash] = list(set(wallet_ids)) for puzzle_hash, wallet_id in zip(puzzle_hashes, wallet_ids): await self.interested_store.add_interested_puzzle_hash(puzzle_hash, wallet_id) if len(puzzle_hashes) > 0: await self.wallet_node.new_peak_queue.subscribe_to_puzzle_hashes(puzzle_hashes) - async def add_interested_coin_ids(self, coin_ids: List[bytes32]) -> None: + async def add_interested_coin_ids(self, coin_ids: List[bytes32], wallet_ids: List[int] = []) -> None: + # TODO: FIX: wallet_ids is sometimes populated unexpectedly when called from add_pending_transaction + for coin_id in coin_ids: + if coin_id in self.interested_coin_cache: + # prevent repeated wallet_ids from appearing in the coin cache + wallet_ids_to_add = list(set([w for w in wallet_ids if w not in self.interested_coin_cache[coin_id]])) + self.interested_coin_cache[coin_id].extend(wallet_ids_to_add) + else: + self.interested_coin_cache[coin_id] = list(set(wallet_ids)) for coin_id in coin_ids: await self.interested_store.add_interested_coin_id(coin_id) if len(coin_ids) > 0: diff --git a/tests/cmds/wallet/test_dao.py b/tests/cmds/wallet/test_dao.py new file mode 100644 index 000000000000..4b2a5e6546c1 --- /dev/null +++ b/tests/cmds/wallet/test_dao.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import time +from pathlib import Path +from secrets import token_bytes +from typing import Any, Dict, List, Optional, Tuple, Union + +import pytest + +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.util.bech32m import encode_puzzle_hash +from chia.util.ints import uint8, uint32, uint64 +from chia.wallet.conditions import parse_timelock_info +from chia.wallet.transaction_record import TransactionRecord +from chia.wallet.util.transaction_type import TransactionType +from chia.wallet.util.tx_config import TXConfig +from chia.wallet.util.wallet_types import WalletType +from tests.cmds.cmd_test_utils import TestRpcClients, TestWalletRpcClient, run_cli_command_and_assert +from tests.cmds.wallet.test_consts import FINGERPRINT_ARG + +# DAO Commands + + +def test_dao_create(capsys: object, get_test_cli_clients: Tuple[TestRpcClients, Path]) -> None: + test_rpc_clients, root_dir = get_test_cli_clients + + # set RPC Client + class DAOCreateRpcClient(TestWalletRpcClient): + async def create_new_dao_wallet( + self, + mode: str, + tx_config: TXConfig, + dao_rules: Optional[Dict[str, uint64]] = None, + amount_of_cats: Optional[uint64] = None, + treasury_id: Optional[bytes32] = None, + filter_amount: uint64 = uint64(1), + name: Optional[str] = None, + fee: uint64 = uint64(0), + fee_for_cat: uint64 = uint64(0), + ) -> Dict[str, Union[str, int, bytes32]]: + if not treasury_id: + treasury_id = bytes32(token_bytes(32)) + return { + "success": True, + "type": "DAO", + "wallet_id": 2, + "treasury_id": treasury_id, + "cat_wallet_id": 3, + "dao_cat_wallet_id": 4, + } + + inst_rpc_client = DAOCreateRpcClient() # pylint: disable=no-value-for-parameter + test_rpc_clients.wallet_rpc_client = inst_rpc_client + command_args = [ + "dao", + "create", + FINGERPRINT_ARG, + "-n test", + "--attendance-required", + "1000", + "--cat-amount", + "100000", + "-m0.1", + "--reuse", + ] + # these are various things that should be in the output + assert_list = ["Successfully created DAO Wallet", "DAO Wallet ID: 2", "CAT Wallet ID: 3", "DAOCAT Wallet ID: 4"] + run_cli_command_and_assert(capsys, root_dir, command_args, assert_list) + + # Check command raises if proposal minimum is even + odd_pm_command_args = [ + "dao", + "create", + FINGERPRINT_ARG, + "-n test", + "--attendance-required", + "1000", + "--cat-amount", + "100000", + "--proposal-minimum", + "10", + "-m0.1", + "--reuse", + ] + extra_assert_list = [ + "Adding 1 mojo to proposal minimum amount", + ] + run_cli_command_and_assert(capsys, root_dir, odd_pm_command_args, extra_assert_list) + + # Add wallet for existing DAO + add_command_args = [ + "dao", + "add", + FINGERPRINT_ARG, + "-n test", + "-t", + bytes32(token_bytes(32)).hex(), + "--filter-amount", + "1", + ] + run_cli_command_and_assert(capsys, root_dir, add_command_args, assert_list) + + +def test_dao_treasury(capsys: object, get_test_cli_clients: Tuple[TestRpcClients, Path]) -> None: + test_rpc_clients, root_dir = get_test_cli_clients + + class DAOCreateRpcClient(TestWalletRpcClient): + async def dao_get_treasury_id( + self, + wallet_id: int, + ) -> Dict[str, str]: + return {"treasury_id": "0xCAFEF00D"} + + async def dao_get_treasury_balance(self, wallet_id: int) -> Dict[str, Union[str, bool, Dict[str, int]]]: + if wallet_id == 2: + return {"success": True, "balances": {"xch": 1000000000000, "0xCAFEF00D": 10000000}} + else: + return {"success": True, "balances": {}} + + async def dao_add_funds_to_treasury( + self, + wallet_id: int, + funding_wallet_id: int, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, bool]]: + return {"success": True, "tx_id": bytes32(b"1" * 32).hex()} + + async def dao_get_rules( + self, + wallet_id: int, + ) -> Dict[str, Dict[str, int]]: + return {"rules": {"proposal_minimum": 100}} + + async def get_transaction(self, wallet_id: int, transaction_id: bytes32) -> TransactionRecord: + return TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=bytes32(b"2" * 32), + amount=uint64(10), + fee_amount=uint64(1), + confirmed=True, + sent=uint32(10), + spend_bundle=None, + additions=[], + removals=[], + wallet_id=uint32(1), + sent_to=[("peer1", uint8(1), None)], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(token_bytes()), + memos=[], + valid_times=parse_timelock_info(tuple()), + ) + + inst_rpc_client = DAOCreateRpcClient() # pylint: disable=no-value-for-parameter + test_rpc_clients.wallet_rpc_client = inst_rpc_client + + get_id_args = ["dao", "get_id", FINGERPRINT_ARG, "-i 2"] + get_id_asserts = ["Treasury ID: 0xCAFEF00D"] + run_cli_command_and_assert(capsys, root_dir, get_id_args, get_id_asserts) + + get_balance_args = ["dao", "balance", FINGERPRINT_ARG, "-i 2"] + get_balance_asserts = ["XCH: 1.0", "0xCAFEF00D: 10000.0"] + run_cli_command_and_assert(capsys, root_dir, get_balance_args, get_balance_asserts) + + no_balance_args = ["dao", "balance", FINGERPRINT_ARG, "-i 3"] + no_balance_asserts = ["The DAO treasury currently has no funds"] + run_cli_command_and_assert(capsys, root_dir, no_balance_args, no_balance_asserts) + + add_funds_args = ["dao", "add_funds", FINGERPRINT_ARG, "-i 2", "-w 1", "-a", "10", "-m 0.1", "--reuse"] + add_funds_asserts = [ + "Transaction submitted to nodes", + ] + run_cli_command_and_assert(capsys, root_dir, add_funds_args, add_funds_asserts) + + rules_args = ["dao", "rules", FINGERPRINT_ARG, "-i 2"] + rules_asserts = "proposal_minimum: 100" + run_cli_command_and_assert(capsys, root_dir, rules_args, rules_asserts) + + +def test_dao_proposals(capsys: object, get_test_cli_clients: Tuple[TestRpcClients, Path]) -> None: + test_rpc_clients, root_dir = get_test_cli_clients + + # set RPC Client + class DAOCreateRpcClient(TestWalletRpcClient): + async def dao_get_proposals( + self, + wallet_id: int, + include_closed: bool = True, + ) -> Dict[str, Union[bool, int, List[Any]]]: + proposal = { + "proposal_id": "0xCAFEF00D", + "amount_voted": uint64(10), + "yes_votes": uint64(10), + "passed": True, + "closed": True, + } + proposal_2 = { + "proposal_id": "0xFEEDBEEF", + "amount_voted": uint64(120), + "yes_votes": uint64(100), + "passed": True, + "closed": False, + } + return { + "success": True, + "proposals": [proposal, proposal_2], + "proposal_timelock": 5, + "soft_close_length": 10, + } + + async def dao_parse_proposal( + self, + wallet_id: int, + proposal_id: str, + ) -> Dict[str, Union[bool, Dict[str, Any]]]: + if proposal_id == "0xCAFEF00D": + puzhash = bytes32(b"1" * 32).hex() + asset_id = bytes32(b"2" * 32).hex() + proposal_details: Dict[str, Any] = { + "proposal_type": "s", + "xch_conditions": [{"puzzle_hash": puzhash, "amount": 100}], + "asset_conditions": [ + {"asset_id": asset_id, "conditions": [{"puzzle_hash": puzhash, "amount": 123}]} + ], + } + elif proposal_id == "0xFEEDBEEF": + proposal_details = { + "proposal_type": "u", + "dao_rules": { + "proposal_timelock": 10, + "soft_close_length": 50, + }, + } + else: + proposal_details = { + "proposal_type": "s", + "mint_amount": 1000, + "new_cat_puzhash": bytes32(b"x" * 32).hex(), + } + proposal_state = { + "state": { + "passed": False, + "closable": False, + "closed": False, + "total_votes_needed": 10, + "yes_votes_needed": 20, + "blocks_needed": 30, + } + } + proposal_dict = {**proposal_state, **proposal_details} + return {"success": True, "proposal_dictionary": proposal_dict} + + async def dao_vote_on_proposal( + self, + wallet_id: int, + proposal_id: str, + vote_amount: int, + tx_config: TXConfig, + is_yes_vote: bool, + fee: uint64 = uint64(0), + ) -> Dict[str, Union[str, bool]]: + return {"success": True, "tx_id": bytes32(b"1" * 32).hex()} + + async def dao_close_proposal( + self, + wallet_id: int, + proposal_id: str, + tx_config: TXConfig, + fee: uint64 = uint64(0), + self_destruct: bool = False, + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, bool]]: + return {"success": True, "tx_id": bytes32(b"1" * 32).hex()} + + async def dao_create_proposal( + self, + wallet_id: int, + proposal_type: str, + tx_config: TXConfig, + additions: Optional[List[Dict[str, Any]]] = None, + amount: Optional[uint64] = None, + inner_address: Optional[str] = None, + asset_id: Optional[str] = None, + cat_target_address: Optional[str] = None, + vote_amount: Optional[int] = None, + new_dao_rules: Optional[Dict[str, uint64]] = None, + fee: uint64 = uint64(0), + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, bool]]: + return {"success": True, "proposal_id": "0xCAFEF00D"} + + async def get_wallets(self, wallet_type: Optional[WalletType] = None) -> List[Dict[str, Union[str, int]]]: + return [{"id": 1, "type": 0}, {"id": 2, "type": 14}] + + async def get_transaction(self, wallet_id: int, transaction_id: bytes32) -> TransactionRecord: + return TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=bytes32(b"2" * 32), + amount=uint64(10), + fee_amount=uint64(1), + confirmed=True, + sent=uint32(10), + spend_bundle=None, + additions=[], + removals=[], + wallet_id=uint32(1), + sent_to=[("peer1", uint8(1), None)], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(b"x" * 32), + memos=[], + valid_times=parse_timelock_info(tuple()), + ) + + # List all proposals + inst_rpc_client = DAOCreateRpcClient() # pylint: disable=no-value-for-parameter + test_rpc_clients.wallet_rpc_client = inst_rpc_client + list_args = ["dao", "list_proposals", FINGERPRINT_ARG, "-i 2"] + # these are various things that should be in the output + list_asserts = [ + "Proposal ID: 0xCAFEF00D", + "Status: OPEN", + "Votes for: 10", + "Votes against: 0", + "Proposal ID: 0xFEEDBEEF", + "Status: CLOSED", + "Votes for: 100", + "Votes against: 20", + "Proposals have 10 blocks of soft close time.", + ] + run_cli_command_and_assert(capsys, root_dir, list_args, list_asserts) + + # Show details of specific proposal + parse_spend_args = ["dao", "show_proposal", FINGERPRINT_ARG, "-i 2", "-p", "0xCAFEF00D"] + address = encode_puzzle_hash(bytes32(b"1" * 32), "xch") + asset_id = bytes32(b"2" * 32).hex() + parse_spend_asserts = [ + "Type: SPEND", + "Status: OPEN", + "Passed: False", + "Closable: False", + "Total votes needed: 10", + "Yes votes needed: 20", + "Blocks remaining: 30", + "Proposal XCH Conditions", + f"Address: {address}", + "Amount: 100", + "Proposal asset Conditions", + f"Asset ID: {asset_id}", + f"Address: {address}", + "Amount: 123", + ] + run_cli_command_and_assert(capsys, root_dir, parse_spend_args, parse_spend_asserts) + + parse_update_args = ["dao", "show_proposal", FINGERPRINT_ARG, "-i2", "-p", "0xFEEDBEEF"] + parse_update_asserts = [ + "Type: UPDATE", + "proposal_timelock: 10", + "soft_close_length: 50", + ] + run_cli_command_and_assert(capsys, root_dir, parse_update_args, parse_update_asserts) + + parse_mint_args = ["dao", "show_proposal", FINGERPRINT_ARG, "-i2", "-p", "0xDABBAD00"] + parse_mint_asserts = [ + "Type: MINT", + "Amount of CAT to mint: 1000", + "Address: {}".format(encode_puzzle_hash(bytes32(b"x" * 32), "xch")), + ] + run_cli_command_and_assert(capsys, root_dir, parse_mint_args, parse_mint_asserts) + + # Vote on a proposal + vote_args = ["dao", "vote", FINGERPRINT_ARG, "-i 2", "-p", "0xFEEDBEEF", "-a", "1000", "-n", "-m 0.1", "--reuse"] + vote_asserts = ["Transaction submitted to nodes"] + run_cli_command_and_assert(capsys, root_dir, vote_args, vote_asserts) + + # Close a proposal + close_args = ["dao", "close_proposal", FINGERPRINT_ARG, "-i 2", "-p", "0xFEEDBEEF", "-d", "-m 0.1", "--reuse"] + close_asserts = ["Transaction submitted to nodes"] + run_cli_command_and_assert(capsys, root_dir, close_args, close_asserts) + + # Create a spend proposal + address = encode_puzzle_hash(bytes32(b"x" * 32), "xch") + spend_args = [ + "dao", + "create_proposal", + "spend", + FINGERPRINT_ARG, + "-i 2", + "-t", + address, + "-a", + "10", + "-v", + "1000", + "--asset-id", + "0xFEEDBEEF", + "-m 0.1", + "--reuse", + ] + proposal_asserts = ["Successfully created proposal", "Proposal ID: 0xCAFEF00D"] + run_cli_command_and_assert(capsys, root_dir, spend_args, proposal_asserts) + + bad_spend_args = [ + "dao", + "create_proposal", + "spend", + FINGERPRINT_ARG, + "-i 2", + "-t", + address, + "-v", + "1000", + "--asset-id", + "0xFEEDBEEF", + "-m 0.1", + "--reuse", + ] + proposal_asserts = ["Successfully created proposal", "Proposal ID: 0xCAFEF00D"] + with pytest.raises(ValueError) as e_info: + run_cli_command_and_assert(capsys, root_dir, bad_spend_args, proposal_asserts) + assert e_info.value.args[0] == "Must include a json specification or an address / amount pair." + + # Create an update proposal + update_args = [ + "dao", + "create_proposal", + "update", + FINGERPRINT_ARG, + "-i 2", + "-v", + "1000", + "--proposal-timelock", + "4", + "-m 0.1", + "--reuse", + ] + run_cli_command_and_assert(capsys, root_dir, update_args, proposal_asserts) + + # Create a mint proposal + mint_args = [ + "dao", + "create_proposal", + "mint", + FINGERPRINT_ARG, + "-i 2", + "-v", + "1000", + "-a", + "100", + "-t", + address, + "-m 0.1", + "--reuse", + ] + run_cli_command_and_assert(capsys, root_dir, mint_args, proposal_asserts) + + +def test_dao_cats(capsys: object, get_test_cli_clients: Tuple[TestRpcClients, Path]) -> None: + test_rpc_clients, root_dir = get_test_cli_clients + + # set RPC Client + class DAOCreateRpcClient(TestWalletRpcClient): + async def dao_send_to_lockup( + self, + wallet_id: int, + amount: uint64, + tx_config: TXConfig, + fee: uint64 = uint64(0), + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, int]]: + return {"success": True, "tx_id": bytes32(b"x" * 32).hex()} + + async def dao_free_coins_from_finished_proposals( + self, + wallet_id: int, + tx_config: TXConfig, + fee: uint64 = uint64(0), + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, int]]: + return {"success": True, "tx_id": bytes32(b"x" * 32).hex()} + + async def dao_exit_lockup( + self, + wallet_id: int, + tx_config: TXConfig, + coins: Optional[List[Dict[str, Union[str, int]]]] = None, + fee: uint64 = uint64(0), + reuse_puzhash: Optional[bool] = None, + ) -> Dict[str, Union[str, int]]: + return {"success": True, "tx_id": bytes32(b"x" * 32).hex()} + + async def get_transaction(self, wallet_id: int, transaction_id: bytes32) -> TransactionRecord: + return TransactionRecord( + confirmed_at_height=uint32(0), + created_at_time=uint64(int(time.time())), + to_puzzle_hash=bytes32(b"2" * 32), + amount=uint64(10), + fee_amount=uint64(1), + confirmed=True, + sent=uint32(10), + spend_bundle=None, + additions=[], + removals=[], + wallet_id=uint32(1), + sent_to=[("peer1", uint8(1), None)], + trade_id=None, + type=uint32(TransactionType.INCOMING_TX.value), + name=bytes32(b"x" * 32), + memos=[], + valid_times=parse_timelock_info(tuple()), + ) + + inst_rpc_client = DAOCreateRpcClient() # pylint: disable=no-value-for-parameter + test_rpc_clients.wallet_rpc_client = inst_rpc_client + lockup_args = ["dao", "lockup_coins", FINGERPRINT_ARG, "-i 2", "-a", "1000", "-m 0.1", "--reuse"] + lockup_asserts = ["Transaction submitted to nodes"] + run_cli_command_and_assert(capsys, root_dir, lockup_args, lockup_asserts) + + release_args = ["dao", "release_coins", FINGERPRINT_ARG, "-i 2", "-m 0.1", "--reuse"] + # tx_id = bytes32(b"x" * 32).hex() + release_asserts = ["Transaction submitted to nodes"] + run_cli_command_and_assert(capsys, root_dir, release_args, release_asserts) + + exit_args = ["dao", "exit_lockup", FINGERPRINT_ARG, "-i 2", "-m 0.1", "--reuse"] + exit_asserts = ["Transaction submitted to nodes"] + run_cli_command_and_assert(capsys, root_dir, exit_args, exit_asserts) diff --git a/tests/pools/test_pool_puzzles_lifecycle.py b/tests/pools/test_pool_puzzles_lifecycle.py index 827a0e7aa7c5..579865c34afd 100644 --- a/tests/pools/test_pool_puzzles_lifecycle.py +++ b/tests/pools/test_pool_puzzles_lifecycle.py @@ -16,7 +16,6 @@ create_travel_spend, create_waiting_room_inner_puzzle, get_delayed_puz_info_from_launcher_spend, - get_most_recent_singleton_coin_from_coin_spend, get_pubkey_from_member_inner_puzzle, get_seconds_and_delayed_puzhash_from_p2_singleton_puzzle, is_pool_singleton_inner_puzzle, @@ -39,6 +38,7 @@ puzzle_for_pk, solution_for_conditions, ) +from chia.wallet.singleton import get_most_recent_singleton_coin_from_coin_spend from tests.clvm.coin_store import BadSpendBundleError, CoinStore, CoinTimestamp from tests.clvm.test_puzzles import public_key_for_index, secret_exponent_for_index from tests.util.key_tool import KeyTool diff --git a/tests/wallet/dao_wallet/config.py b/tests/wallet/dao_wallet/config.py new file mode 100644 index 000000000000..0bc843470282 --- /dev/null +++ b/tests/wallet/dao_wallet/config.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +checkout_blocks_and_plots = True diff --git a/tests/wallet/dao_wallet/test_dao_clvm.py b/tests/wallet/dao_wallet/test_dao_clvm.py new file mode 100644 index 000000000000..24714f873f32 --- /dev/null +++ b/tests/wallet/dao_wallet/test_dao_clvm.py @@ -0,0 +1,1261 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Tuple + +import pytest +from blspy import AugSchemeMPL +from clvm.casts import int_to_bytes + +from chia.clvm.spend_sim import SimClient, SpendSim, sim_and_client +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import INFINITE_COST, Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.types.condition_opcodes import ConditionOpcode +from chia.types.mempool_inclusion_status import MempoolInclusionStatus +from chia.types.spend_bundle import SpendBundle +from chia.util.condition_tools import conditions_dict_for_solution +from chia.util.errors import Err +from chia.util.hash import std_hash +from chia.util.ints import uint32, uint64 +from chia.wallet.cat_wallet.cat_utils import CAT_MOD +from chia.wallet.dao_wallet.dao_info import DAORules +from chia.wallet.dao_wallet.dao_utils import curry_singleton, get_p2_singleton_puzhash, get_treasury_puzzle +from chia.wallet.puzzles.load_clvm import load_clvm +from chia.wallet.singleton import create_singleton_puzzle_hash + +CAT_MOD_HASH: bytes32 = CAT_MOD.get_tree_hash() +SINGLETON_MOD: Program = load_clvm("singleton_top_layer_v1_1.clsp") +SINGLETON_MOD_HASH: bytes32 = SINGLETON_MOD.get_tree_hash() +SINGLETON_LAUNCHER: Program = load_clvm("singleton_launcher.clsp") +SINGLETON_LAUNCHER_HASH: bytes32 = SINGLETON_LAUNCHER.get_tree_hash() +DAO_LOCKUP_MOD: Program = load_clvm("dao_lockup.clsp") +DAO_LOCKUP_MOD_HASH: bytes32 = DAO_LOCKUP_MOD.get_tree_hash() +DAO_PROPOSAL_TIMER_MOD: Program = load_clvm("dao_proposal_timer.clsp") +DAO_PROPOSAL_TIMER_MOD_HASH: bytes32 = DAO_PROPOSAL_TIMER_MOD.get_tree_hash() +DAO_PROPOSAL_MOD: Program = load_clvm("dao_proposal.clsp") +DAO_PROPOSAL_MOD_HASH: bytes32 = DAO_PROPOSAL_MOD.get_tree_hash() +DAO_PROPOSAL_VALIDATOR_MOD: Program = load_clvm("dao_proposal_validator.clsp") +DAO_PROPOSAL_VALIDATOR_MOD_HASH: bytes32 = DAO_PROPOSAL_VALIDATOR_MOD.get_tree_hash() +DAO_TREASURY_MOD: Program = load_clvm("dao_treasury.clsp") +DAO_TREASURY_MOD_HASH: bytes32 = DAO_TREASURY_MOD.get_tree_hash() +SPEND_P2_SINGLETON_MOD: Program = load_clvm("dao_spend_p2_singleton_v2.clsp") +SPEND_P2_SINGLETON_MOD_HASH: bytes32 = SPEND_P2_SINGLETON_MOD.get_tree_hash() +DAO_FINISHED_STATE: Program = load_clvm("dao_finished_state.clsp") +DAO_FINISHED_STATE_HASH: bytes32 = DAO_FINISHED_STATE.get_tree_hash() +DAO_CAT_TAIL: Program = load_clvm( + "genesis_by_coin_id_or_singleton.clsp", package_or_requirement="chia.wallet.cat_wallet.puzzles" +) +DAO_CAT_TAIL_HASH: bytes32 = DAO_CAT_TAIL.get_tree_hash() +P2_SINGLETON_MOD: Program = load_clvm("p2_singleton_via_delegated_puzzle.clsp") +P2_SINGLETON_MOD_HASH: bytes32 = P2_SINGLETON_MOD.get_tree_hash() +P2_SINGLETON_AGGREGATOR_MOD: Program = load_clvm("p2_singleton_aggregator.clsp") +P2_SINGLETON_AGGREGATOR_MOD_HASH: bytes32 = P2_SINGLETON_AGGREGATOR_MOD.get_tree_hash() +DAO_UPDATE_MOD: Program = load_clvm("dao_update_proposal.clsp") +DAO_UPDATE_MOD_HASH: bytes32 = DAO_UPDATE_MOD.get_tree_hash() + + +def test_finished_state() -> None: + """ + Once a proposal has closed, it becomes a 'beacon' singleton which announces its proposal ID. This is referred to as the finished state and is used to confirm that a proposal has closed in order to release voting CATs from the lockup puzzle. + """ + proposal_id: Program = Program.to("proposal_id").get_tree_hash() + singleton_struct: Program = Program.to( + (SINGLETON_MOD.get_tree_hash(), (proposal_id, SINGLETON_LAUNCHER.get_tree_hash())) + ) + finished_inner_puz = DAO_FINISHED_STATE.curry(singleton_struct, DAO_FINISHED_STATE_HASH) + finished_full_puz = SINGLETON_MOD.curry(singleton_struct, finished_inner_puz) + inner_sol = Program.to([1]) + + conds = finished_inner_puz.run(inner_sol).as_python() + assert conds[0][1] == finished_full_puz.get_tree_hash() + assert conds[2][1] == finished_inner_puz.get_tree_hash() + + lineage = Program.to([proposal_id, finished_inner_puz.get_tree_hash(), 1]) + full_sol = Program.to([lineage, 1, inner_sol]) + + conds = conditions_dict_for_solution(finished_full_puz, full_sol, INFINITE_COST) + assert conds[ConditionOpcode.ASSERT_MY_PUZZLEHASH][0].vars[0] == finished_full_puz.get_tree_hash() + assert conds[ConditionOpcode.CREATE_COIN][0].vars[0] == finished_full_puz.get_tree_hash() + + +def test_proposal() -> None: + """ + This test covers the three paths for closing a proposal: + - Close a passed proposal + - Close a failed proposal + - Self-destruct a broken proposal + """ + proposal_pass_percentage: uint64 = uint64(5100) + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + treasury_id: Program = Program.to("treasury").get_tree_hash() + singleton_id: Program = Program.to("singleton_id").get_tree_hash() + singleton_struct: Program = Program.to( + (SINGLETON_MOD.get_tree_hash(), (singleton_id, SINGLETON_LAUNCHER.get_tree_hash())) + ) + self_destruct_time = 1000 # number of blocks + oracle_spend_delay = 10 + active_votes_list = [0xFADEDDAB] # are the the ids of previously voted on proposals? + acs: Program = Program.to(1) + acs_ph: bytes32 = acs.get_tree_hash() + + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + proposal_curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + dao_lockup_self.get_tree_hash(), + CAT_TAIL_HASH, + treasury_id, + ) + + # make a lockup puz for the dao cat + lockup_puz = dao_lockup_self.curry( + dao_lockup_self.get_tree_hash(), + active_votes_list, + acs, # innerpuz + ) + + dao_cat_puz: Program = CAT_MOD.curry(CAT_MOD_HASH, CAT_TAIL_HASH, lockup_puz) + dao_cat_puzhash: bytes32 = dao_cat_puz.get_tree_hash() + + # Test Voting + current_yes_votes = 20 + current_total_votes = 100 + full_proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + acs_ph, + current_yes_votes, + current_total_votes, + ) + + vote_amount = 10 + vote_type = 1 # yes vote + vote_coin_id = Program.to("vote_coin").get_tree_hash() + solution: Program = Program.to( + [ + [vote_amount], # vote amounts + vote_type, # vote type (yes) + [vote_coin_id], # vote coin ids + [active_votes_list], # previous votes + [acs_ph], # lockup inner puz hash + 0, # inner puz reveal + 0, # soft close len + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + + # Run the proposal and check its conditions + conditions = conditions_dict_for_solution(full_proposal, solution, INFINITE_COST) + + # Puzzle Announcement of vote_coin_ids + assert bytes32(conditions[ConditionOpcode.CREATE_PUZZLE_ANNOUNCEMENT][0].vars[0]) == vote_coin_id + + # Assert puzzle announcement from dao_cat of proposal_id and all vote details + apa_msg = Program.to([singleton_id, vote_amount, vote_type, vote_coin_id]).get_tree_hash() + assert conditions[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == std_hash(dao_cat_puzhash + apa_msg) + + # Check that the proposal recreates itself with updated vote amounts + next_proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + acs_ph, + current_yes_votes + vote_amount, + current_total_votes + vote_amount, + ) + assert bytes32(conditions[ConditionOpcode.CREATE_COIN][0].vars[0]) == next_proposal.get_tree_hash() + assert conditions[ConditionOpcode.CREATE_COIN][0].vars[1] == int_to_bytes(1) + + # Try to vote using multiple coin ids + vote_coin_id_1 = Program.to("vote_coin_1").get_tree_hash() + vote_coin_id_2 = Program.to("vote_coin_2").get_tree_hash() + repeat_solution_1: Program = Program.to( + [ + [vote_amount, 20], # vote amounts + vote_type, # vote type (yes) + [vote_coin_id_1, vote_coin_id_2], # vote coin ids + [active_votes_list, 0], # previous votes + [acs_ph, acs_ph], # lockup inner puz hash + 0, # inner puz reveal + 0, # soft close len + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + + conds_repeated = conditions_dict_for_solution(full_proposal, repeat_solution_1, INFINITE_COST) + assert len(conds_repeated) == 4 + + # Try to vote using repeated coin ids + repeat_solution_2: Program = Program.to( + [ + [vote_amount, vote_amount, 20], # vote amounts + vote_type, # vote type (yes) + [vote_coin_id_1, vote_coin_id_1, vote_coin_id_2], # vote coin ids + [active_votes_list], # previous votes + [acs_ph], # lockup inner puz hash + 0, # inner puz reveal + 0, # soft close len + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + + with pytest.raises(ValueError) as e_info: + conditions_dict_for_solution(full_proposal, repeat_solution_2, INFINITE_COST) + assert e_info.value.args[0] == "clvm raise" + + # Test Launch + current_yes_votes = 0 + current_total_votes = 0 + launch_proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + acs_ph, + current_yes_votes, + current_total_votes, + ) + vote_amount = 10 + vote_type = 1 # yes vote + vote_coin_id = Program.to("vote_coin").get_tree_hash() + solution = Program.to( + [ + [vote_amount], # vote amounts + vote_type, # vote type (yes) + [vote_coin_id], # vote coin ids + # TODO: Check whether previous votes should be 0 in the first spend since + # proposal looks at (f previous_votes) during loop_over_vote_coins + [0], # previous votes + [acs_ph], # lockup inner puz hash + acs, # inner puz reveal + 0, # soft close len + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + # Run the proposal and check its conditions + conditions = conditions_dict_for_solution(launch_proposal, solution, INFINITE_COST) + # check that the timer is created + timer_puz = DAO_PROPOSAL_TIMER_MOD.curry( + proposal_curry_one.get_tree_hash(), + singleton_struct, + ) + timer_puzhash = timer_puz.get_tree_hash() + assert conditions[ConditionOpcode.CREATE_COIN][1].vars[0] == timer_puzhash + + # Test exits + + # Test attempt to close a passing proposal + current_yes_votes = 200 + current_total_votes = 350 + full_proposal = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + acs_ph, + current_yes_votes, + current_total_votes, + ) + attendance_required = 200 + proposal_timelock = 20 + soft_close_length = 5 + solution = Program.to( + [ + Program.to("validator_hash").get_tree_hash(), + 0, + # Program.to("receiver_hash").get_tree_hash(), # not needed anymore? + proposal_timelock, + proposal_pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + + conds = conditions_dict_for_solution(full_proposal, solution, INFINITE_COST) + + # make a matching treasury puzzle for the APA + treasury_inner: Program = DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + Program.to("validator_hash"), + proposal_timelock, + soft_close_length, + attendance_required, + proposal_pass_percentage, + self_destruct_time, + oracle_spend_delay, + ) + treasury: Program = SINGLETON_MOD.curry( + Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))), + treasury_inner, + ) + treasury_puzhash = treasury.get_tree_hash() + apa_msg = singleton_id + + timer_apa = std_hash(timer_puzhash + singleton_id) + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == timer_apa + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][1].vars[0] == std_hash(treasury_puzhash + apa_msg) + + # close a failed proposal + full_proposal = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + acs_ph, + 20, # failing number of yes votes + current_total_votes, + ) + solution = Program.to( + [ + Program.to("validator_hash").get_tree_hash(), + 0, + # Program.to("receiver_hash").get_tree_hash(), # not needed anymore? + proposal_timelock, + proposal_pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_time, + oracle_spend_delay, + 0, + 1, + ] + ) + conds = conditions_dict_for_solution(full_proposal, solution, INFINITE_COST) + apa_msg = int_to_bytes(0) + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][1].vars[0] == std_hash(treasury_puzhash + apa_msg) + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == timer_apa + + finished_puz = DAO_FINISHED_STATE.curry(singleton_struct, DAO_FINISHED_STATE_HASH) + assert conds[ConditionOpcode.CREATE_COIN][0].vars[0] == finished_puz.get_tree_hash() + + # self destruct a proposal + attendance_required = 200 + solution = Program.to( + [ + Program.to("validator_hash").get_tree_hash(), + 0, + # Program.to("receiver_hash").get_tree_hash(), # not needed anymore? + proposal_timelock, + proposal_pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_time, + oracle_spend_delay, + 1, + 1, + ] + ) + conds = conditions_dict_for_solution(full_proposal, solution, INFINITE_COST) + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == std_hash(treasury_puzhash + apa_msg) + assert conds[ConditionOpcode.CREATE_COIN][0].vars[0] == finished_puz.get_tree_hash() + + +def test_proposal_timer() -> None: + """ + The timer puzzle is created at the same time as a proposal, and enforces a relative time condition on proposals + The closing time is passed in via the timer solution and confirmed via announcement from the proposal. + It creates/asserts announcements to pair it with the finishing spend of a proposal. + The timer puzzle only has one spend path so there is only one test case for this puzzle. + """ + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + treasury_id: Program = Program.to("treasury").get_tree_hash() + singleton_id: Program = Program.to("singleton_id").get_tree_hash() + singleton_struct: Program = Program.to( + (SINGLETON_MOD.get_tree_hash(), (singleton_id, SINGLETON_LAUNCHER.get_tree_hash())) + ) + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + proposal_curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + dao_lockup_self.get_tree_hash(), + CAT_TAIL_HASH, + treasury_id, + ) + + proposal_timer_full: Program = DAO_PROPOSAL_TIMER_MOD.curry( + proposal_curry_one.get_tree_hash(), + singleton_struct, + ) + + timelock = int_to_bytes(101) + parent_parent_id = Program.to("parent_parent").get_tree_hash() + parent_amount = 2000 + solution: Program = Program.to( + [ + 140, # yes votes + 180, # total votes + Program.to(1).get_tree_hash(), # proposal innerpuz + timelock, + parent_parent_id, + parent_amount, + ] + ) + # run the timer puzzle. + conds = conditions_dict_for_solution(proposal_timer_full, solution, INFINITE_COST) + assert len(conds) == 4 + + # Validate the output conditions + # Check the timelock is present + assert conds[ConditionOpcode.ASSERT_HEIGHT_RELATIVE][0].vars[0] == timelock + # Check the proposal id is announced by the timer puz + assert conds[ConditionOpcode.CREATE_PUZZLE_ANNOUNCEMENT][0].vars[0] == singleton_id + # Check the proposal puz announces the timelock + expected_proposal_puzhash: bytes32 = create_singleton_puzzle_hash( + proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), singleton_id, Program.to(1).get_tree_hash(), 140, 180 + ).get_tree_hash(), + singleton_id, + ) + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == std_hash( + expected_proposal_puzhash + timelock + ) + # Check the parent is a proposal + expected_parent_puzhash: bytes32 = create_singleton_puzzle_hash( + proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + singleton_id, + Program.to(1).get_tree_hash(), + 0, + 0, + ).get_tree_hash(), + singleton_id, + ) + parent_id = std_hash(parent_parent_id + expected_parent_puzhash + int_to_bytes(parent_amount)) + assert conds[ConditionOpcode.ASSERT_MY_PARENT_ID][0].vars[0] == parent_id + + +def test_validator() -> None: + """ + The proposal validator is run by the treasury when a passing proposal is closed. + Its main purpose is to check that the proposal's vote amounts adehere to the DAO rules contained in the treasury (which are passed in from the treasury as Truth values). + It creates a puzzle announcement of the proposal ID, that the proposal itself asserts. + It also spends the value held in the proposal to the excess payout puzhash. + + The test cases covered are: + - Executing a spend proposal in which the validator executes the spend of a 'spend_p2_singleton` coin. This is just a proposal that spends some the treasury + - Executing an update proposal that changes the DAO rules. + """ + # Setup the treasury + treasury_id: Program = Program.to("treasury_id").get_tree_hash() + treasury_struct: Program = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + + # Setup the proposal + proposal_id: Program = Program.to("proposal_id").get_tree_hash() + proposal_struct: Program = Program.to((SINGLETON_MOD.get_tree_hash(), (proposal_id, SINGLETON_LAUNCHER_HASH))) + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + acs: Program = Program.to(1) + acs_ph: bytes32 = acs.get_tree_hash() + + p2_singleton = P2_SINGLETON_MOD.curry(treasury_struct, P2_SINGLETON_AGGREGATOR_MOD) + p2_singleton_puzhash = p2_singleton.get_tree_hash() + parent_id = Program.to("parent").get_tree_hash() + locked_amount = 100000 + spend_amount = 1100 + conditions = [[51, 0xDABBAD00, 1000], [51, 0xCAFEF00D, 100]] + + # Setup the validator + minimum_amt = 1 + excess_puzhash = bytes32(b"1" * 32) + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + proposal_curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + dao_lockup_self.get_tree_hash(), + CAT_TAIL_HASH, + treasury_id, + ) + proposal_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + treasury_struct, + proposal_curry_one.get_tree_hash(), + minimum_amt, + excess_puzhash, + ) + + # Can now create the treasury inner puz + treasury_inner = DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + proposal_validator, + 10, # proposal len + 5, # soft close + 1000, # attendance + 5100, # pass margin + 20, # self_destruct len + 3, # oracle delay + ) + + # Setup the spend_p2_singleton (proposal inner puz) + spend_p2_singleton = SPEND_P2_SINGLETON_MOD.curry( + treasury_struct, CAT_MOD_HASH, conditions, [], p2_singleton_puzhash # tailhash conds + ) + spend_p2_singleton_puzhash = spend_p2_singleton.get_tree_hash() + + parent_amt_list = [[parent_id, locked_amount]] + cat_parent_amt_list: List[Optional[Any]] = [] + spend_p2_singleton_solution = Program.to([parent_amt_list, cat_parent_amt_list, treasury_inner.get_tree_hash()]) + + output_conds = spend_p2_singleton.run(spend_p2_singleton_solution) + + proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + proposal_id, + spend_p2_singleton_puzhash, + 950, + 1200, + ) + full_proposal = SINGLETON_MOD.curry(proposal_struct, proposal) + proposal_amt = 10 + proposal_coin_id = Coin(parent_id, full_proposal.get_tree_hash(), proposal_amt).name() + solution = Program.to( + [ + 1000, + 5100, + [proposal_coin_id, spend_p2_singleton_puzhash, 0], + [proposal_id, 1200, 950, parent_id, proposal_amt], + output_conds, + ] + ) + + conds: Program = proposal_validator.run(solution) + assert len(conds.as_python()) == 7 + len(conditions) + + # test update + proposal = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + proposal_id, + acs_ph, + 950, + 1200, + ) + full_proposal = SINGLETON_MOD.curry(proposal_struct, proposal) + proposal_coin_id = Coin(parent_id, full_proposal.get_tree_hash(), proposal_amt).name() + solution = Program.to( + [ + 1000, + 5100, + [proposal_coin_id, acs_ph, 0], + [proposal_id, 1200, 950, parent_id, proposal_amt], + [[51, 0xCAFEF00D, spend_amount]], + ] + ) + conds = proposal_validator.run(solution) + assert len(conds.as_python()) == 3 + + return + + +def test_spend_p2_singleton() -> None: + # Curried values + singleton_id: Program = Program.to("singleton_id").get_tree_hash() + singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (singleton_id, SINGLETON_LAUNCHER_HASH))) + p2_singleton_puzhash = P2_SINGLETON_MOD.curry(singleton_struct, P2_SINGLETON_AGGREGATOR_MOD).get_tree_hash() + cat_tail_1 = Program.to("cat_tail_1").get_tree_hash() + cat_tail_2 = Program.to("cat_tail_2").get_tree_hash() + conditions = [[51, 0xCAFEF00D, 100], [51, 0xFEEDBEEF, 200]] + list_of_tailhash_conds = [ + [cat_tail_1, [[51, 0x8BADF00D, 123], [51, 0xF00DF00D, 321]]], + [cat_tail_2, [[51, 0x8BADF00D, 123], [51, 0xF00DF00D, 321]]], + ] + + # Solution Values + xch_parent_amt_list = [[b"x" * 32, 10], [b"y" * 32, 100]] + cat_parent_amt_list = [ + [cat_tail_1, [["b" * 32, 100], [b"c" * 32, 400]]], + [cat_tail_2, [[b"e" * 32, 100], [b"f" * 32, 400]]], + ] + # cat_parent_amt_list = [] + treasury_inner_puzhash = Program.to("treasury_inner").get_tree_hash() + + # Puzzle + spend_p2_puz = SPEND_P2_SINGLETON_MOD.curry( + singleton_struct, CAT_MOD_HASH, conditions, list_of_tailhash_conds, p2_singleton_puzhash + ) + + # Solution + spend_p2_sol = Program.to([xch_parent_amt_list, cat_parent_amt_list, treasury_inner_puzhash]) + + conds = spend_p2_puz.run(spend_p2_sol) + assert conds + + # spend only cats + conditions = [] + list_of_tailhash_conds = [ + [cat_tail_1, [[51, b"q" * 32, 123], [51, b"w" * 32, 321]]], + [cat_tail_2, [[51, b"e" * 32, 123], [51, b"r" * 32, 321]]], + ] + xch_parent_amt_list = [] + cat_parent_amt_list = [ + [cat_tail_1, [[b"b" * 32, 100], [b"c" * 32, 400]]], + [cat_tail_2, [[b"e" * 32, 100], [b"f" * 32, 400]]], + ] + treasury_inner_puzhash = Program.to("treasury_inner").get_tree_hash() + + # Puzzle + spend_p2_puz = SPEND_P2_SINGLETON_MOD.curry( + singleton_struct, CAT_MOD_HASH, conditions, list_of_tailhash_conds, p2_singleton_puzhash + ) + + # Solution + spend_p2_sol = Program.to([xch_parent_amt_list, cat_parent_amt_list, treasury_inner_puzhash]) + conds = spend_p2_puz.run(spend_p2_sol) + assert conds + + +def test_merge_p2_singleton() -> None: + """ + The treasury funds are held by p2_singleton_via_delegated puzzles. Because a DAO can have a large number of these coins, it's possible to merge them together without requiring a treasury spend. + There are two cases tested: + - For the merge coins that do not create the single output coin, and + - For the coin that does create the output. + """ + # Setup a singleton struct + singleton_inner: Program = Program.to(1) + singleton_id: Program = Program.to("singleton_id").get_tree_hash() + singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (singleton_id, SINGLETON_LAUNCHER_HASH))) + + # Setup p2_singleton_via_delegated puz + my_id = Program.to("my_id").get_tree_hash() + p2_singleton = P2_SINGLETON_MOD.curry(singleton_struct, P2_SINGLETON_AGGREGATOR_MOD) + my_puzhash = p2_singleton.get_tree_hash() + + # Spend to delegated puz + delegated_puz = Program.to(1) + delegated_sol = Program.to([[51, 0xCAFEF00D, 300]]) + solution = Program.to([0, singleton_inner.get_tree_hash(), delegated_puz, delegated_sol, my_id]) + conds = conditions_dict_for_solution(p2_singleton, solution, INFINITE_COST) + apa = std_hash( + SINGLETON_MOD.curry(singleton_struct, singleton_inner).get_tree_hash() + + Program.to([my_id, delegated_puz.get_tree_hash()]).get_tree_hash() + ) + assert len(conds) == 4 + assert conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT][0].vars[0] == apa + assert conds[ConditionOpcode.CREATE_COIN][0].vars[1] == int_to_bytes(300) + + # Merge Spend (not output creator) + output_parent_id = Program.to("output_parent").get_tree_hash() + output_coin_amount = 100 + aggregator_sol = Program.to([my_id, my_puzhash, 300, 0, [output_parent_id, output_coin_amount]]) + merge_p2_singleton_sol = Program.to([aggregator_sol, 0, 0, 0, 0]) + conds = conditions_dict_for_solution(p2_singleton, merge_p2_singleton_sol, INFINITE_COST) + assert len(conds) == 4 + assert conds[ConditionOpcode.ASSERT_MY_PUZZLEHASH][0].vars[0] == my_puzhash + assert conds[ConditionOpcode.CREATE_COIN_ANNOUNCEMENT][0].vars[0] == int_to_bytes(0) + + # Merge Spend (output creator) + fake_parent_id = Program.to("fake_parent").get_tree_hash() + merged_coin_id = Coin(fake_parent_id, my_puzhash, 200).name() + merge_sol = Program.to([[my_id, my_puzhash, 100, [[fake_parent_id, my_puzhash, 200]], 0]]) + conds = conditions_dict_for_solution(p2_singleton, merge_sol, INFINITE_COST) + assert len(conds) == 7 + assert conds[ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT][0].vars[0] == std_hash(merged_coin_id) + assert conds[ConditionOpcode.CREATE_COIN][0].vars[1] == int_to_bytes(300) + return + + +def test_treasury() -> None: + """ + The treasury has two spend paths: + - Proposal Path: when a proposal is being closed the treasury spend runs the validator and the actual proposed code (if passed) + - Oracle Path: The treasury can make announcements about itself that are used to close invalid proposals + """ + # Setup the treasury + treasury_id: Program = Program.to("treasury_id").get_tree_hash() + treasury_struct: Program = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + + proposal_id: Program = Program.to("singleton_id").get_tree_hash() + proposal_struct: Program = Program.to((SINGLETON_MOD_HASH, (proposal_id, SINGLETON_LAUNCHER_HASH))) + p2_singleton = P2_SINGLETON_MOD.curry(treasury_struct, P2_SINGLETON_AGGREGATOR_MOD) + p2_singleton_puzhash = p2_singleton.get_tree_hash() + parent_id = Program.to("parent").get_tree_hash() + locked_amount = 100000 + oracle_spend_delay = 10 + self_destruct_time = 1000 + proposal_length = 40 + soft_close_length = 5 + attendance = 1000 + pass_margin = 5100 + conditions = [[51, 0xDABBAD00, 1000], [51, 0xCAFEF00D, 100]] + + # Setup the validator + minimum_amt = 1 + excess_puzhash = bytes32(b"1" * 32) + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + proposal_curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + dao_lockup_self.get_tree_hash(), + CAT_TAIL_HASH, + treasury_id, + ) + proposal_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + treasury_struct, + proposal_curry_one.get_tree_hash(), + minimum_amt, + excess_puzhash, + ) + + # Can now create the treasury inner puz + treasury_inner = DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + proposal_validator, + proposal_length, + soft_close_length, + attendance, + pass_margin, + self_destruct_time, + oracle_spend_delay, + ) + + # Setup the spend_p2_singleton (proposal inner puz) + spend_p2_singleton = SPEND_P2_SINGLETON_MOD.curry( + treasury_struct, CAT_MOD_HASH, conditions, [], p2_singleton_puzhash # tailhash conds + ) + spend_p2_singleton_puzhash = spend_p2_singleton.get_tree_hash() + + parent_amt_list = [[parent_id, locked_amount]] + cat_parent_amt_list: List[Optional[Any]] = [] + spend_p2_singleton_solution = Program.to([parent_amt_list, cat_parent_amt_list, treasury_inner.get_tree_hash()]) + + proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + proposal_id, + spend_p2_singleton_puzhash, + 950, + 1200, + ) + full_proposal = SINGLETON_MOD.curry(proposal_struct, proposal) + + # Oracle spend + solution: Program = Program.to([0, 0, 0, 0, 0, treasury_struct]) + conds: Program = treasury_inner.run(solution) + assert len(conds.as_python()) == 3 + + # Proposal Spend + proposal_amt = 10 + proposal_coin_id = Coin(parent_id, full_proposal.get_tree_hash(), proposal_amt).name() + solution = Program.to( + [ + [proposal_coin_id, spend_p2_singleton_puzhash, 0, "s"], + [proposal_id, 1200, 950, parent_id, proposal_amt], + spend_p2_singleton, + spend_p2_singleton_solution, + ] + ) + conds = treasury_inner.run(solution) + assert len(conds.as_python()) == 9 + len(conditions) + + +def test_lockup() -> None: + """ + The lockup puzzle tracks the voting records of DAO CATs. When a proposal is voted on the proposal ID is added to a list against which future votes are checked. + This test checks the addition of new votes to the lockup, and that you can't re-vote on a proposal twice. + """ + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + + INNERPUZ = Program.to(1) + previous_votes = [0xFADEDDAB] + + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + full_lockup_puz: Program = dao_lockup_self.curry( + dao_lockup_self.get_tree_hash(), + previous_votes, + INNERPUZ, + ) + my_id = Program.to("my_id").get_tree_hash() + lockup_coin_amount = 20 + + # Test adding vote + new_proposal = 0xBADDADAB + new_vote_list = [new_proposal, 0xFADEDDAB] + child_puzhash = dao_lockup_self.curry( + dao_lockup_self.get_tree_hash(), + new_vote_list, + INNERPUZ, + ).get_tree_hash() + message = Program.to([new_proposal, lockup_coin_amount, 1, my_id]).get_tree_hash() + generated_conditions = [[51, child_puzhash, lockup_coin_amount], [62, message]] + solution: Program = Program.to( + [ + my_id, + generated_conditions, + 20, + new_proposal, + INNERPUZ.get_tree_hash(), # fake proposal curry vals + 1, + 20, + child_puzhash, + 0, + ] + ) + conds: Program = full_lockup_puz.run(solution) + assert len(conds.as_python()) == 6 + + # Test Re-voting on same proposal fails + new_proposal = 0xBADDADAB + new_vote_list = [new_proposal, 0xBADDADAB] + child_puzhash = dao_lockup_self.curry( + dao_lockup_self.get_tree_hash(), + new_vote_list, + INNERPUZ, + ).get_tree_hash() + message = Program.to([new_proposal, lockup_coin_amount, 1, my_id]).get_tree_hash() + generated_conditions = [[51, child_puzhash, lockup_coin_amount], [62, message]] + revote_solution: Program = Program.to( + [ + my_id, + generated_conditions, + 20, + new_proposal, + INNERPUZ.get_tree_hash(), # fake proposal curry vals + 1, + 20, + child_puzhash, + 0, + ] + ) + with pytest.raises(ValueError) as e_info: + conds = full_lockup_puz.run(revote_solution) + assert e_info.value.args[0] == "clvm raise" + + # Test vote removal + solution = Program.to( + [ + 0, + generated_conditions, + 20, + [0xFADEDDAB], + INNERPUZ.get_tree_hash(), + 0, + 0, + 0, + 0, + ] + ) + conds = full_lockup_puz.run(solution) + assert len(conds.as_python()) == 3 + + new_innerpuz = Program.to("new_inner") + new_innerpuzhash = new_innerpuz.get_tree_hash() + child_lockup = dao_lockup_self.curry( + dao_lockup_self.get_tree_hash(), + previous_votes, + new_innerpuz, + ).get_tree_hash() + message = Program.to([0, 0, 0, my_id]).get_tree_hash() + spend_conds = [[51, child_lockup, lockup_coin_amount], [62, message]] + transfer_sol = Program.to( + [ + my_id, + spend_conds, + lockup_coin_amount, + 0, + INNERPUZ.get_tree_hash(), # fake proposal curry vals + 0, + 0, + INNERPUZ.get_tree_hash(), + new_innerpuzhash, + ] + ) + conds = full_lockup_puz.run(transfer_sol) + assert conds.at("rrrrfrf").as_atom() == child_lockup + + +def test_proposal_lifecycle() -> None: + """ + This test covers the whole lifecycle of a proposal and treasury. It's main function is to check that the announcement pairs between treasury and proposal are accurate. It covers the spend proposal and update proposal types. + """ + proposal_pass_percentage: uint64 = uint64(5100) + attendance_required: uint64 = uint64(1000) + proposal_timelock: uint64 = uint64(40) + soft_close_length: uint64 = uint64(5) + self_destruct_time: uint64 = uint64(1000) + oracle_spend_delay: uint64 = uint64(10) + min_amt: uint64 = uint64(1) + CAT_TAIL_HASH: Program = Program.to("tail").get_tree_hash() + + dao_rules = DAORules( + proposal_timelock=proposal_timelock, + soft_close_length=soft_close_length, + attendance_required=attendance_required, + pass_percentage=proposal_pass_percentage, + self_destruct_length=self_destruct_time, + oracle_spend_delay=oracle_spend_delay, + proposal_minimum_amount=min_amt, + ) + + # Setup the treasury + treasury_id: Program = Program.to("treasury_id").get_tree_hash() + treasury_singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (treasury_id, SINGLETON_LAUNCHER_HASH))) + treasury_amount = 1 + + # setup the p2_singleton + p2_singleton = P2_SINGLETON_MOD.curry(treasury_singleton_struct, P2_SINGLETON_AGGREGATOR_MOD) + p2_singleton_puzhash = p2_singleton.get_tree_hash() + parent_id = Program.to("parent").get_tree_hash() + locked_amount = 100000 + conditions = [[51, 0xDABBAD00, 1000], [51, 0xCAFEF00D, 100]] + + excess_puzhash = get_p2_singleton_puzhash(treasury_id) + dao_lockup_self = DAO_LOCKUP_MOD.curry( + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + DAO_FINISHED_STATE_HASH, + CAT_MOD_HASH, + CAT_TAIL_HASH, + ) + + proposal_curry_one = DAO_PROPOSAL_MOD.curry( + DAO_PROPOSAL_TIMER_MOD_HASH, + SINGLETON_MOD_HASH, + SINGLETON_LAUNCHER_HASH, + CAT_MOD_HASH, + DAO_FINISHED_STATE_HASH, + DAO_TREASURY_MOD_HASH, + dao_lockup_self.get_tree_hash(), + CAT_TAIL_HASH, + treasury_id, + ) + proposal_validator = DAO_PROPOSAL_VALIDATOR_MOD.curry( + treasury_singleton_struct, + proposal_curry_one.get_tree_hash(), + min_amt, + excess_puzhash, + ) + + treasury_inner_puz: Program = DAO_TREASURY_MOD.curry( + DAO_TREASURY_MOD_HASH, + proposal_validator, + proposal_timelock, + soft_close_length, + attendance_required, + proposal_pass_percentage, + self_destruct_time, + oracle_spend_delay, + ) + treasury_inner_puzhash = treasury_inner_puz.get_tree_hash() + + calculated_treasury_puzhash = get_treasury_puzzle(dao_rules, treasury_id, CAT_TAIL_HASH).get_tree_hash() + assert treasury_inner_puzhash == calculated_treasury_puzhash + + full_treasury_puz = SINGLETON_MOD.curry(treasury_singleton_struct, treasury_inner_puz) + full_treasury_puzhash = full_treasury_puz.get_tree_hash() + + # Setup the spend_p2_singleton (proposal inner puz) + spend_p2_singleton = SPEND_P2_SINGLETON_MOD.curry( + treasury_singleton_struct, CAT_MOD_HASH, conditions, [], p2_singleton_puzhash # tailhash conds + ) + spend_p2_singleton_puzhash = spend_p2_singleton.get_tree_hash() + + parent_amt_list = [[parent_id, locked_amount]] + cat_parent_amt_list: List[Optional[Any]] = [] + spend_p2_singleton_solution = Program.to([parent_amt_list, cat_parent_amt_list, treasury_inner_puzhash]) + + # Setup Proposal + proposal_id: Program = Program.to("proposal_id").get_tree_hash() + proposal_singleton_struct: Program = Program.to((SINGLETON_MOD_HASH, (proposal_id, SINGLETON_LAUNCHER_HASH))) + + current_votes = 1200 + yes_votes = 950 + proposal: Program = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + proposal_id, + spend_p2_singleton_puzhash, + yes_votes, + current_votes, + ) + full_proposal: Program = SINGLETON_MOD.curry(proposal_singleton_struct, proposal) + full_proposal_puzhash: bytes32 = full_proposal.get_tree_hash() + proposal_amt = 11 + proposal_coin_id = Coin(parent_id, full_proposal_puzhash, proposal_amt).name() + + treasury_solution: Program = Program.to( + [ + [proposal_coin_id, spend_p2_singleton_puzhash, 0], + [proposal_id, current_votes, yes_votes, parent_id, proposal_amt], + spend_p2_singleton, + spend_p2_singleton_solution, + ] + ) + + proposal_solution = Program.to( + [ + proposal_validator.get_tree_hash(), + 0, + proposal_timelock, + proposal_pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_time, + oracle_spend_delay, + 0, + proposal_amt, + ] + ) + + # lineage_proof my_amount inner_solution + lineage_proof = [treasury_id, treasury_inner_puzhash, treasury_amount] + full_treasury_solution = Program.to([lineage_proof, treasury_amount, treasury_solution]) + full_proposal_solution = Program.to([lineage_proof, proposal_amt, proposal_solution]) + + # Run the puzzles + treasury_conds = conditions_dict_for_solution(full_treasury_puz, full_treasury_solution, INFINITE_COST) + proposal_conds = conditions_dict_for_solution(full_proposal, full_proposal_solution, INFINITE_COST) + + # Announcements + treasury_aca = treasury_conds[ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT][0].vars[0] + proposal_cca = proposal_conds[ConditionOpcode.CREATE_COIN_ANNOUNCEMENT][0].vars[0] + assert std_hash(proposal_coin_id + proposal_cca) == treasury_aca + + treasury_cpas = [ + std_hash(full_treasury_puzhash + cond.vars[0]) + for cond in treasury_conds[ConditionOpcode.CREATE_PUZZLE_ANNOUNCEMENT] + ] + proposal_apas = [cond.vars[0] for cond in proposal_conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT]] + assert treasury_cpas[1] == proposal_apas[1] + + # Test Proposal to update treasury + # Set up new treasury params + new_proposal_pass_percentage: uint64 = uint64(2500) + new_attendance_required: uint64 = uint64(500) + new_proposal_timelock: uint64 = uint64(900) + new_soft_close_length: uint64 = uint64(10) + new_self_destruct_time: uint64 = uint64(1000) + new_oracle_spend_delay: uint64 = uint64(20) + new_minimum_amount: uint64 = uint64(10) + proposal_excess_puzhash: bytes32 = get_p2_singleton_puzhash(treasury_id) + + new_dao_rules = DAORules( + proposal_timelock=new_proposal_timelock, + soft_close_length=new_soft_close_length, + attendance_required=new_attendance_required, + pass_percentage=new_proposal_pass_percentage, + self_destruct_length=new_self_destruct_time, + oracle_spend_delay=new_oracle_spend_delay, + proposal_minimum_amount=new_minimum_amount, + ) + + update_proposal = DAO_UPDATE_MOD.curry( + DAO_TREASURY_MOD_HASH, + DAO_PROPOSAL_VALIDATOR_MOD_HASH, + treasury_singleton_struct, + proposal_curry_one.get_tree_hash(), + new_minimum_amount, + proposal_excess_puzhash, + new_proposal_timelock, + new_soft_close_length, + new_attendance_required, + new_proposal_pass_percentage, + new_self_destruct_time, + new_oracle_spend_delay, + ) + update_proposal_puzhash = update_proposal.get_tree_hash() + update_proposal_sol = Program.to([]) + + proposal = proposal_curry_one.curry( + proposal_curry_one.get_tree_hash(), + proposal_id, + update_proposal_puzhash, + yes_votes, + current_votes, + ) + full_proposal = SINGLETON_MOD.curry(proposal_singleton_struct, proposal) + full_proposal_puzhash = full_proposal.get_tree_hash() + proposal_coin_id = Coin(parent_id, full_proposal_puzhash, proposal_amt).name() + + treasury_solution = Program.to( + [ + [proposal_coin_id, update_proposal_puzhash, 0, "u"], + [proposal_id, current_votes, yes_votes, parent_id, proposal_amt], + update_proposal, + update_proposal_sol, + ] + ) + + proposal_solution = Program.to( + [ + proposal_validator.get_tree_hash(), + 0, + proposal_timelock, + proposal_pass_percentage, + attendance_required, + 0, + soft_close_length, + self_destruct_time, + oracle_spend_delay, + 0, + proposal_amt, + ] + ) + + lineage_proof = [treasury_id, treasury_inner_puzhash, treasury_amount] + full_treasury_solution = Program.to([lineage_proof, treasury_amount, treasury_solution]) + full_proposal_solution = Program.to([lineage_proof, proposal_amt, proposal_solution]) + + treasury_conds = conditions_dict_for_solution(full_treasury_puz, full_treasury_solution, INFINITE_COST) + proposal_conds = conditions_dict_for_solution(full_proposal, full_proposal_solution, INFINITE_COST) + + treasury_aca = treasury_conds[ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT][0].vars[0] + proposal_cca = proposal_conds[ConditionOpcode.CREATE_COIN_ANNOUNCEMENT][0].vars[0] + assert std_hash(proposal_coin_id + proposal_cca) == treasury_aca + + treasury_cpas = [ + std_hash(full_treasury_puzhash + cond.vars[0]) + for cond in treasury_conds[ConditionOpcode.CREATE_PUZZLE_ANNOUNCEMENT] + ] + proposal_apas = [cond.vars[0] for cond in proposal_conds[ConditionOpcode.ASSERT_PUZZLE_ANNOUNCEMENT]] + assert treasury_cpas[1] == proposal_apas[1] + + new_treasury_inner = update_proposal.run(update_proposal_sol).at("frf").as_atom() + expected_treasury_inner = get_treasury_puzzle(new_dao_rules, treasury_id, CAT_TAIL_HASH) + assert new_treasury_inner == expected_treasury_inner.get_tree_hash() + + expected_treasury_hash = curry_singleton(treasury_id, expected_treasury_inner).get_tree_hash() + assert treasury_conds[ConditionOpcode.CREATE_COIN][1].vars[0] == expected_treasury_hash + + +async def do_spend( + sim: SpendSim, + sim_client: SimClient, + coins: List[Coin], + puzzles: List[Program], + solutions: List[Program], +) -> Tuple[MempoolInclusionStatus, Optional[Err]]: + spends = [] + for coin, puzzle, solution in zip(coins, puzzles, solutions): + spends.append(CoinSpend(coin, puzzle, solution)) + spend_bundle = SpendBundle(spends, AugSchemeMPL.aggregate([])) + result = await sim_client.push_tx(spend_bundle) + await sim.farm_block() + return result + + +@pytest.mark.asyncio() +async def test_singleton_aggregator() -> None: + async with sim_and_client() as (sim, sim_client): + aggregator = P2_SINGLETON_AGGREGATOR_MOD + aggregator_hash = aggregator.get_tree_hash() + await sim.farm_block(aggregator_hash) + await sim.farm_block(aggregator_hash) + for i in range(5): + await sim.farm_block() + + coin_records = await sim_client.get_coin_records_by_puzzle_hash(aggregator_hash) + coins = [c.coin for c in coin_records] + + output_coin = coins[0] + output_sol = Program.to( + [ + output_coin.name(), + output_coin.puzzle_hash, + output_coin.amount, + [[c.parent_coin_info, c.puzzle_hash, c.amount] for c in coins[1:]], + ] + ) + merge_sols = [ + Program.to([c.name(), c.puzzle_hash, c.amount, [], [output_coin.parent_coin_info, output_coin.amount]]) + for c in coins[1:] + ] + + res = await do_spend(sim, sim_client, coins, [aggregator] * 4, [output_sol, *merge_sols]) + assert res[0] == MempoolInclusionStatus.SUCCESS + + await sim.rewind(uint32(sim.block_height - 1)) + + # Spend a merge coin with empty output details + output_sol = Program.to( + [ + output_coin.name(), + output_coin.puzzle_hash, + output_coin.amount, + [], + [], + ] + ) + res = await do_spend(sim, sim_client, [output_coin], [aggregator], [output_sol]) + assert res[0] == MempoolInclusionStatus.FAILED + + # Try to steal treasury coins with a phoney output + acs = Program.to(1) + acs_ph = acs.get_tree_hash() + await sim.farm_block(acs_ph) + bad_coin = (await sim_client.get_coin_records_by_puzzle_hash(acs_ph))[0].coin + bad_sol = Program.to( + [ + [ConditionOpcode.CREATE_COIN, acs_ph, sum(c.amount for c in coins)], + *[[ConditionOpcode.ASSERT_COIN_ANNOUNCEMENT, std_hash(c.name())] for c in coins], + [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, 0], + ] + ) + + merge_sols = [ + Program.to([c.name(), c.puzzle_hash, c.amount, [], [bad_coin.parent_coin_info, bad_coin.amount]]) + for c in coins + ] + + res = await do_spend(sim, sim_client, [bad_coin, *coins], [acs] + [aggregator] * 4, [bad_sol, *merge_sols]) + assert res[0] == MempoolInclusionStatus.FAILED diff --git a/tests/wallet/dao_wallet/test_dao_wallets.py b/tests/wallet/dao_wallet/test_dao_wallets.py new file mode 100644 index 000000000000..648ed76c1b8b --- /dev/null +++ b/tests/wallet/dao_wallet/test_dao_wallets.py @@ -0,0 +1,3649 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import pytest + +from chia.consensus.block_rewards import calculate_base_farmer_reward, calculate_pool_reward +from chia.rpc.wallet_rpc_api import WalletRpcApi +from chia.rpc.wallet_rpc_client import WalletRpcClient +from chia.simulator.setup_nodes import SimulatorsAndWallets, SimulatorsAndWalletsServices +from chia.simulator.simulator_protocol import FarmNewBlockProtocol, ReorgProtocol +from chia.simulator.time_out_assert import adjusted_timeout, time_out_assert, time_out_assert_not_none +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.peer_info import PeerInfo +from chia.types.spend_bundle import SpendBundle +from chia.util.bech32m import encode_puzzle_hash +from chia.util.ints import uint32, uint64, uint128 +from chia.wallet.cat_wallet.cat_wallet import CATWallet +from chia.wallet.cat_wallet.dao_cat_wallet import DAOCATWallet +from chia.wallet.dao_wallet.dao_info import DAORules +from chia.wallet.dao_wallet.dao_utils import ( + generate_mint_proposal_innerpuz, + generate_simple_proposal_innerpuz, + generate_update_proposal_innerpuz, +) +from chia.wallet.dao_wallet.dao_wallet import DAOWallet +from chia.wallet.transaction_record import TransactionRecord +from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG +from tests.conftest import ConsensusMode +from tests.util.rpc import validate_get_routes + + +async def get_proposal_state(wallet: DAOWallet, index: int) -> Tuple[Optional[bool], Optional[bool]]: + return wallet.dao_info.proposals_list[index].passed, wallet.dao_info.proposals_list[index].closed + + +async def rpc_state( + timeout: float, + async_function: Callable[[Any], Any], + params: List[Union[int, Dict[str, Any]]], + condition_func: Callable[[Dict[str, Any]], Any], + result: Optional[Any] = None, +) -> Union[bool, Dict[str, Any]]: # pragma: no cover + __tracebackhide__ = True + + timeout = adjusted_timeout(timeout=timeout) + + start = time.monotonic() + + while True: + resp = await async_function(*params) + assert isinstance(resp, dict) + try: + if result: + if condition_func(resp) == result: + return True + else: + if condition_func(resp): + return resp + except IndexError: + continue + + now = time.monotonic() + elapsed = now - start + if elapsed >= timeout: + raise asyncio.TimeoutError( + f"timed out while waiting for {async_function.__name__}(): {elapsed} >= {timeout}", + ) + + await asyncio.sleep(0.3) + + +puzzle_hash_0 = bytes32(32 * b"0") + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_creation( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 1 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + ph = await wallet.get_new_puzzlehash() + ph_1 = await wallet_1.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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + cat_amt = 2000 + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + + fee = uint64(10) + fee_for_cat = uint64(20) + + # Try to create a DAO with more CATs than xch balance + with pytest.raises(ValueError) as e_info: + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(funds + 1), + dao_rules, + DEFAULT_TX_CONFIG, + fee=fee, + fee_for_cat=fee_for_cat, + ) + assert e_info.value.args[0] == f"Your balance of {funds} mojos is not enough to create {funds + 1} CATs" + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(cat_amt * 2), + dao_rules, + DEFAULT_TX_CONFIG, + fee=fee, + fee_for_cat=fee_for_cat, + ) + assert dao_wallet_0 is not None + + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=tx_queue) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Check the spend was successful + treasury_id = dao_wallet_0.dao_info.treasury_id + + # check the dao wallet balances + await time_out_assert(20, dao_wallet_0.get_confirmed_balance, uint128(1)) + await time_out_assert(20, dao_wallet_0.get_unconfirmed_balance, uint128(1)) + await time_out_assert(20, dao_wallet_0.get_pending_change_balance, uint64(0)) + + # check select coins + no_coins = await dao_wallet_0.select_coins(uint64(2), DEFAULT_TX_CONFIG) + assert no_coins == set() + selected_coins = await dao_wallet_0.select_coins(uint64(1), DEFAULT_TX_CONFIG) + assert len(selected_coins) == 1 + + # get the cat wallets + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + # Some dao_cat_wallet checks for coverage + dao_cat_wallet_0.get_name() + assert (await dao_cat_wallet_0.select_coins(uint64(1), DEFAULT_TX_CONFIG)) == set() + dao_cat_puzhash = await dao_cat_wallet_0.get_new_puzzlehash() + assert dao_cat_puzhash + dao_cat_inner = await dao_cat_wallet_0.get_new_inner_puzzle(DEFAULT_TX_CONFIG) + assert dao_cat_inner + dao_cat_inner_hash = await dao_cat_wallet_0.get_new_inner_hash(DEFAULT_TX_CONFIG) + assert dao_cat_inner_hash + + cat_wallet_0_bal = await cat_wallet_0.get_confirmed_balance() + assert cat_wallet_0_bal == cat_amt * 2 + + # Create the other user's wallet from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_0.dao_info.treasury_id == dao_wallet_1.dao_info.treasury_id + + # Get the cat wallets for wallet_1 + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + assert cat_wallet_1 + + # Send some cats to the dao_cat lockup + dao_cat_amt = uint64(100) + txs = await dao_wallet_0.enter_dao_cat_voting_mode(dao_cat_amt, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet.wallet_state_manager.add_pending_transaction(tx) + sb = txs[0].spend_bundle + assert isinstance(sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, sb.name()) + await full_node_api.process_transaction_records(records=txs) + + for i in range(1, num_blocks): # pragma: no cover + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Test that we can get spendable coins from both cat and dao_cat wallet + fake_proposal_id = Program.to("proposal_id").get_tree_hash() + spendable_coins = await dao_cat_wallet_0.wallet_state_manager.get_spendable_coins_for_wallet( + dao_cat_wallet_0.id(), None + ) + + assert len(spendable_coins) > 0 + coins = await dao_cat_wallet_0.advanced_select_coins(1, fake_proposal_id) + assert len(coins) > 0 + # check that we have selected the coin from dao_cat_wallet + assert list(coins)[0].coin.amount == dao_cat_amt + + # send some cats from wallet_0 to wallet_1 so we can test voting + cat_txs = await cat_wallet_0.generate_signed_transaction([cat_amt], [ph_1], DEFAULT_TX_CONFIG) + await wallet.wallet_state_manager.add_pending_transaction(cat_txs[0]) + sb = cat_txs[0].spend_bundle + assert isinstance(sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, sb.name()) + await full_node_api.process_transaction_records(records=cat_txs) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await time_out_assert(10, cat_wallet_1.get_confirmed_balance, cat_amt) + + # Smaller tests of dao_wallet funcs for coverage + await dao_wallet_0.adjust_filter_level(uint64(10)) + assert dao_wallet_0.dao_info.filter_below_vote_amount == uint64(10) + + await dao_wallet_0.set_name("Renamed Wallet") + assert dao_wallet_0.get_name() == "Renamed Wallet" + + new_inner_puzhash = await dao_wallet_0.get_new_p2_inner_hash() + assert isinstance(new_inner_puzhash, bytes32) + + # run DAOCATwallet.create for coverage + create_dao_cat_from_info = await DAOCATWallet.create( + wallet.wallet_state_manager, wallet, dao_cat_wallet_0.wallet_info + ) + assert create_dao_cat_from_info + create_dao_wallet_from_info = await DAOWallet.create(wallet.wallet_state_manager, wallet, dao_wallet_0.wallet_info) + assert create_dao_wallet_from_info + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_funding( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 1 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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_node_2, server_2 = wallets[2] + wallet = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + wallet_2 = wallet_node_1.wallet_state_manager.main_wallet + ph = await wallet.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + ph_2 = await wallet_2.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() + } + wallet_node_2.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"] = {} + wallet_node_2.config["trusted_peers"] = {} + + await server_0.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_2.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_2)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + 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(20, wallet.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + cat_amt = 300000 + dao_rules = DAORules( + proposal_timelock=uint64(5), + soft_close_length=uint64(5), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(cat_amt), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + treasury_id = dao_wallet_0.dao_info.treasury_id + + # Get the full node sim to process the wallet creation spend + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # get the cat wallets + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, cat_amt) + + # Create funding spends for xch and cat + xch_funds = uint64(500000) + cat_funds = uint64(100000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend(xch_funds, DEFAULT_TX_CONFIG) + await wallet.wallet_state_manager.add_pending_transaction(funding_tx) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Check that the funding spend is found + await time_out_assert(20, dao_wallet_0.get_balance_by_asset_type, xch_funds) + + cat_funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend( + cat_funds, DEFAULT_TX_CONFIG, funding_wallet_id=cat_wallet_0.id() + ) + await wallet.wallet_state_manager.add_pending_transaction(cat_funding_tx) + cat_funding_sb = cat_funding_tx.spend_bundle + assert isinstance(cat_funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_funding_sb.name()) + await full_node_api.process_transaction_records(records=[cat_funding_tx]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, cat_amt - cat_funds) + + # Check that the funding spend is found + cat_id = bytes32.from_hexstr(cat_wallet_0.get_asset_id()) + await time_out_assert(20, dao_wallet_0.get_balance_by_asset_type, cat_funds, cat_id) + + # Create and close a proposal + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + dao_cat_0_bal = await dao_cat_wallet_0.get_votable_balance() + txs_0 = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dao_cat_0_bal, DEFAULT_TX_CONFIG) + for tx in txs_0: + await wallet.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs_0[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs_0) + recipient_puzzle_hash = await wallet_2.get_new_puzzlehash() + proposal_amount_1 = uint64(10000) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, + [recipient_puzzle_hash], + [proposal_amount_1], + [None], + ) + proposal_tx = await dao_wallet_0.generate_new_proposal(xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal) + await wallet.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + for _ in range(5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + prop_0 = dao_wallet_0.dao_info.proposals_list[0] + close_tx_0 = await dao_wallet_0.create_proposal_close_spend(prop_0.proposal_id, DEFAULT_TX_CONFIG) + await wallet.wallet_state_manager.add_pending_transaction(close_tx_0) + close_sb_0 = close_tx_0.spend_bundle + assert close_sb_0 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_0.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_0]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Create the other user's wallet from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_1.dao_info.treasury_id == dao_wallet_1.dao_info.treasury_id + + # Get the cat wallets for wallet_1 + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + assert cat_wallet_1 + assert cat_wallet_1.cat_info.limitations_program_hash == cat_id + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(30, dao_wallet_0.get_balance_by_asset_type, xch_funds - 10000) + await time_out_assert(30, dao_wallet_0.get_balance_by_asset_type, cat_funds, cat_id) + await time_out_assert(30, dao_wallet_1.get_balance_by_asset_type, xch_funds - 10000) + await time_out_assert(30, dao_wallet_1.get_balance_by_asset_type, cat_funds, cat_id) + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_proposals( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + """ + Test a set of proposals covering: + - the spend, update, and mint types. + - passing and failing + - force closing broken proposals + + total cats issued: 300k + each wallet holds: 100k + + The proposal types and amounts voted are: + P0 Spend => Pass + P1 Mint => Pass + P2 Update => Pass + P3 Spend => Fail + P4 Bad Spend => Force Close + + """ + num_blocks = 1 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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_node_2, server_2 = wallets[2] + wallet_0 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + wallet_2 = wallet_node_2.wallet_state_manager.main_wallet + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + ph_2 = await wallet_2.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() + } + wallet_node_2.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"] = {} + wallet_node_2.config["trusted_peers"] = {} + + await server_0.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_2.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_2)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet_0.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + # set a standard fee amount to use in all txns + base_fee = uint64(100) + + # set the cat issuance and DAO rules + cat_issuance = 300000 + proposal_min_amt = uint64(101) + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(190000), + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=proposal_min_amt, + ) + + # Create the DAO. + # This takes two steps: create the treasury singleton, wait for oracle_spend_delay and + # then complete the eve spend + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet_0, + uint64(cat_issuance), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, cat_issuance) + assert dao_cat_wallet_0 + + treasury_id = dao_wallet_0.dao_info.treasury_id + + # Create dao_wallet_1 from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_1.dao_info.treasury_id == treasury_id + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + dao_cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.dao_cat_wallet_id] + assert cat_wallet_1 + assert dao_cat_wallet_1 + + # Create dao_wallet_2 from the treasury id + dao_wallet_2 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_2.wallet_state_manager, + wallet_2, + treasury_id, + ) + assert dao_wallet_2 is not None + assert dao_wallet_2.dao_info.treasury_id == treasury_id + cat_wallet_2 = dao_wallet_2.wallet_state_manager.wallets[dao_wallet_2.dao_info.cat_wallet_id] + dao_cat_wallet_2 = dao_wallet_2.wallet_state_manager.wallets[dao_wallet_2.dao_info.dao_cat_wallet_id] + assert cat_wallet_2 + assert dao_cat_wallet_2 + + # Send 100k cats to wallet_1 and wallet_2 + cat_amt = uint64(100000) + cat_tx = await cat_wallet_0.generate_signed_transaction( + [cat_amt, cat_amt], [ph_1, ph_2], DEFAULT_TX_CONFIG, fee=base_fee + ) + cat_sb = cat_tx[0].spend_bundle + await wallet_0.wallet_state_manager.add_pending_transaction(cat_tx[0]) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_sb.name()) + await full_node_api.process_transaction_records(records=cat_tx) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Lockup voting cats for all wallets + dao_cat_0_bal = await dao_cat_wallet_0.get_votable_balance() + txs_0 = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dao_cat_0_bal, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs_0: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs_0[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs_0) + + dao_cat_1_bal = await dao_cat_wallet_1.get_votable_balance() + txs_1 = await dao_cat_wallet_1.enter_dao_cat_voting_mode(dao_cat_1_bal, DEFAULT_TX_CONFIG) + for tx in txs_1: + await wallet_1.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_1 = txs_1[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_1.name()) + await full_node_api.process_transaction_records(records=txs_1) + + dao_cat_2_bal = await dao_cat_wallet_2.get_votable_balance() + txs_2 = await dao_cat_wallet_2.enter_dao_cat_voting_mode(dao_cat_2_bal, DEFAULT_TX_CONFIG) + for tx in txs_2: + await wallet_2.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_2 = txs_2[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_2.name()) + await full_node_api.process_transaction_records(records=txs_2) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + await time_out_assert(10, dao_cat_wallet_0.get_confirmed_balance, cat_amt) + await time_out_assert(10, dao_cat_wallet_1.get_confirmed_balance, cat_amt) + await time_out_assert(10, dao_cat_wallet_2.get_confirmed_balance, cat_amt) + + # Create funding spend so the treasury holds some XCH + xch_funds = uint64(500000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend(xch_funds, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(funding_tx) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Check that the funding spend is recognized by all wallets + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + await time_out_assert(10, dao_wallet_1.get_balance_by_asset_type, xch_funds) + await time_out_assert(10, dao_wallet_2.get_balance_by_asset_type, xch_funds) + + # Create Proposals + + # Proposal 0: Spend xch to wallet_2. + recipient_puzzle_hash = await wallet_2.get_new_puzzlehash() + proposal_amount_1 = uint64(9998) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, + [recipient_puzzle_hash], + [proposal_amount_1], + [None], + ) + proposal_tx = await dao_wallet_0.generate_new_proposal( + xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, fee=base_fee + ) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + prop_0 = dao_wallet_0.dao_info.proposals_list[0] + + # Proposal 1: Mint new CATs + new_mint_amount = uint64(1000) + mint_proposal_inner = await generate_mint_proposal_innerpuz( + treasury_id, + cat_wallet_0.cat_info.limitations_program_hash, + new_mint_amount, + recipient_puzzle_hash, + ) + + proposal_tx = await dao_wallet_0.generate_new_proposal( + mint_proposal_inner, DEFAULT_TX_CONFIG, vote_amount=dao_cat_0_bal, fee=base_fee + ) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert len(dao_wallet_0.dao_info.proposals_list) == 2 + prop_1 = dao_wallet_0.dao_info.proposals_list[1] + + # Proposal 2: Update DAO Rules. + new_dao_rules = DAORules( + proposal_timelock=uint64(8), + soft_close_length=uint64(4), + attendance_required=uint64(150000), + pass_percentage=uint64(7500), + self_destruct_length=uint64(12), + oracle_spend_delay=uint64(5), + proposal_minimum_amount=uint64(1), + ) + current_innerpuz = dao_wallet_0.dao_info.current_treasury_innerpuz + assert current_innerpuz is not None + update_inner = await generate_update_proposal_innerpuz(current_innerpuz, new_dao_rules) + proposal_tx = await dao_wallet_0.generate_new_proposal(update_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, fee=base_fee) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert len(dao_wallet_0.dao_info.proposals_list) == 3 + prop_2 = dao_wallet_0.dao_info.proposals_list[2] + + # Proposal 3: Spend xch to wallet_2 (this prop will close as failed) + proposal_amount_2 = uint64(500) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, [recipient_puzzle_hash], [proposal_amount_2], [None] + ) + proposal_tx = await dao_wallet_0.generate_new_proposal( + xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, fee=base_fee + ) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert len(dao_wallet_0.dao_info.proposals_list) == 4 + prop_3 = dao_wallet_0.dao_info.proposals_list[3] + + # Proposal 4: Create a 'bad' proposal (can't be executed, must be force-closed) + xch_proposal_inner = Program.to(["x"]) + proposal_tx = await dao_wallet_0.generate_new_proposal( + xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, fee=base_fee + ) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(20, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert len(dao_wallet_0.dao_info.proposals_list) == 5 + assert len(dao_wallet_1.dao_info.proposals_list) == 5 + assert len(dao_wallet_1.dao_info.proposals_list) == 5 + prop_4 = dao_wallet_0.dao_info.proposals_list[4] + + # Proposal 0 Voting: wallet 1 votes yes, wallet 2 votes no. Proposal Passes + vote_tx_1 = await dao_wallet_1.generate_proposal_vote_spend( + prop_0.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx_1) + vote_sb_1 = vote_tx_1.spend_bundle + assert vote_sb_1 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_1.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_1]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + vote_tx_2 = await dao_wallet_2.generate_proposal_vote_spend( + prop_0.proposal_id, dao_cat_2_bal, False, DEFAULT_TX_CONFIG + ) + await wallet_2.wallet_state_manager.add_pending_transaction(vote_tx_2) + vote_sb_2 = vote_tx_2.spend_bundle + assert vote_sb_2 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_2.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_2]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + total_votes = dao_cat_0_bal + dao_cat_1_bal + dao_cat_2_bal + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == total_votes - dao_cat_2_bal + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == total_votes - dao_cat_2_bal + assert dao_wallet_2.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_2.dao_info.proposals_list[0].yes_votes == total_votes - dao_cat_2_bal + + prop_0_state = await dao_wallet_0.get_proposal_state(prop_0.proposal_id) + assert prop_0_state["passed"] + assert prop_0_state["closable"] + + # Proposal 0 is closable, but soft_close_length has not passed. + close_tx_0 = await dao_wallet_0.create_proposal_close_spend(prop_0.proposal_id, DEFAULT_TX_CONFIG) + close_sb_0 = close_tx_0.spend_bundle + assert close_sb_0 is not None + with pytest.raises(AssertionError) as e: + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_0) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_0.name()) + assert e.value.args[0] == "Timed assertion timed out" + + for _ in range(5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Proposal 0: Close + close_tx_0 = await dao_wallet_0.create_proposal_close_spend(prop_0.proposal_id, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_0) + close_sb_0 = close_tx_0.spend_bundle + assert close_sb_0 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_0.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_0]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + await time_out_assert(20, wallet_2.get_confirmed_balance, funds + proposal_amount_1) + await time_out_assert( + 20, dao_wallet_0.get_balance_by_asset_type, xch_funds - proposal_amount_1 + proposal_min_amt - 1 + ) + + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_0, 0]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_1, 0]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_2, 0]) + + # Proposal 1 vote and close + vote_tx_1 = await dao_wallet_1.generate_proposal_vote_spend( + prop_1.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx_1) + vote_sb_1 = vote_tx_1.spend_bundle + assert vote_sb_1 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_1.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_1]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + for _ in range(10): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + prop_1_state = await dao_wallet_0.get_proposal_state(prop_1.proposal_id) + assert prop_1_state["passed"] + assert prop_1_state["closable"] + + close_tx_1 = await dao_wallet_0.create_proposal_close_spend(prop_1.proposal_id, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_1) + close_sb_1 = close_tx_1.spend_bundle + assert close_sb_1 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_1.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_1]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + await time_out_assert(20, cat_wallet_2.get_confirmed_balance, new_mint_amount) + + # Proposal 2 vote and close + vote_tx_2 = await dao_wallet_1.generate_proposal_vote_spend( + prop_2.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx_2) + vote_sb_2 = vote_tx_2.spend_bundle + assert vote_sb_2 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_2.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_2]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + for _ in range(10): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + prop_2_state = await dao_wallet_0.get_proposal_state(prop_2.proposal_id) + assert prop_2_state["passed"] + assert prop_2_state["closable"] + + close_tx_2 = await dao_wallet_0.create_proposal_close_spend(prop_2.proposal_id, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_2) + close_sb_2 = close_tx_2.spend_bundle + assert close_sb_2 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_2.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_2]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + assert dao_wallet_0.dao_rules == new_dao_rules + assert dao_wallet_1.dao_rules == new_dao_rules + assert dao_wallet_2.dao_rules == new_dao_rules + + # Proposal 3 - Close as FAILED + vote_tx_3 = await dao_wallet_1.generate_proposal_vote_spend( + prop_3.proposal_id, dao_cat_1_bal, False, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx_3) + vote_sb_3 = vote_tx_3.spend_bundle + assert vote_sb_3 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_3.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_3]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + for _ in range(10): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + prop_3_state = await dao_wallet_1.get_proposal_state(prop_3.proposal_id) + assert not prop_3_state["passed"] + assert prop_3_state["closable"] + + close_tx_3 = await dao_wallet_0.create_proposal_close_spend(prop_3.proposal_id, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_3) + close_sb_3 = close_tx_3.spend_bundle + assert close_sb_3 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_3.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_3]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + await time_out_assert(20, wallet_2.get_confirmed_balance, funds + proposal_amount_1) + expected_balance = xch_funds - proposal_amount_1 + (3 * proposal_min_amt) - 3 - new_mint_amount + await time_out_assert(20, dao_wallet_0.get_balance_by_asset_type, expected_balance) + + await time_out_assert(20, get_proposal_state, (False, True), *[dao_wallet_0, 3]) + await time_out_assert(20, get_proposal_state, (False, True), *[dao_wallet_1, 3]) + await time_out_assert(20, get_proposal_state, (False, True), *[dao_wallet_2, 3]) + + # Proposal 4 - Self Destruct a broken proposal + vote_tx_4 = await dao_wallet_1.generate_proposal_vote_spend( + prop_4.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx_4) + vote_sb_4 = vote_tx_4.spend_bundle + assert vote_sb_4 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb_4.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb_4]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + for _ in range(10): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + prop_4_state = await dao_wallet_1.get_proposal_state(prop_4.proposal_id) + assert prop_4_state["passed"] + assert prop_4_state["closable"] + + with pytest.raises(Exception) as e_info: + close_tx_4 = await dao_wallet_0.create_proposal_close_spend(prop_4.proposal_id, DEFAULT_TX_CONFIG) + assert e_info.value.args[0] == "Unrecognised proposal type" + + close_tx_4 = await dao_wallet_0.create_proposal_close_spend( + prop_4.proposal_id, DEFAULT_TX_CONFIG, self_destruct=True + ) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx_4) + close_sb_4 = close_tx_4.spend_bundle + assert close_sb_4 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb_4.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb_4]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_2, timeout=30) + + # expected balance is unchanged because broken props can't release their amount + await time_out_assert(20, dao_wallet_0.get_balance_by_asset_type, expected_balance) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_0, 4]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_1, 4]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_2, 4]) + + # Remove Proposals from Memory and Free up locked coins + await time_out_assert(20, len, 5, dao_wallet_0.dao_info.proposals_list) + await dao_wallet_0.clear_finished_proposals_from_memory() + free_tx = await dao_wallet_0.free_coins_from_finished_proposals(DEFAULT_TX_CONFIG, fee=uint64(100)) + await wallet_0.wallet_state_manager.add_pending_transaction(free_tx) + free_sb = free_tx.spend_bundle + assert free_sb is not None + await time_out_assert_not_none(20, full_node_api.full_node.mempool_manager.get_spendbundle, free_sb.name()) + await full_node_api.process_spend_bundles(bundles=[free_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + await dao_wallet_0.clear_finished_proposals_from_memory() + await time_out_assert(20, len, 0, dao_wallet_0.dao_info.proposals_list) + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_proposal_partial_vote( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 1 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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_node_2, server_2 = wallets[2] + wallet = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + wallet_2 = wallet_node_2.wallet_state_manager.main_wallet + ph = await wallet.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + ph_2 = await wallet_2.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() + } + wallet_node_2.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"] = {} + wallet_node_2.config["trusted_peers"] = {} + + await server_0.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_2.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_2)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + cat_amt = 300000 + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(cat_amt), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + # Get the full node sim to process the wallet creation spend + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # get the cat wallets + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, cat_amt) + + # get the dao_cat wallet + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + + treasury_id = dao_wallet_0.dao_info.treasury_id + + # Create the other user's wallet from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_1.dao_info.treasury_id == treasury_id + + # Create funding spends for xch + xch_funds = uint64(500000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend( + xch_funds, + DEFAULT_TX_CONFIG, + ) + await wallet.wallet_state_manager.add_pending_transaction(funding_tx) + assert isinstance(funding_tx, TransactionRecord) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Check that the funding spend is recognized by both dao wallets + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + + # Send some dao_cats to wallet_1 + # Get the cat wallets for wallet_1 + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + dao_cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.dao_cat_wallet_id] + assert cat_wallet_1 + assert dao_cat_wallet_1 + + cat_tx = await cat_wallet_0.generate_signed_transaction([100000], [ph_1], DEFAULT_TX_CONFIG) + cat_sb = cat_tx[0].spend_bundle + await wallet.wallet_state_manager.add_pending_transaction(cat_tx[0]) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_sb.name()) + await full_node_api.process_transaction_records(records=cat_tx) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await time_out_assert(10, cat_wallet_1.get_spendable_balance, 100000) + + # Create dao cats for voting + dao_cat_0_bal = await dao_cat_wallet_0.get_votable_balance() + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dao_cat_0_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Create a mint proposal + recipient_puzzle_hash = await cat_wallet_1.get_new_inner_hash() + new_mint_amount = uint64(500) + mint_proposal_inner = await generate_mint_proposal_innerpuz( + treasury_id, + cat_wallet_0.cat_info.limitations_program_hash, + new_mint_amount, + recipient_puzzle_hash, + ) + + vote_amount = dao_cat_0_bal - 10 + proposal_tx = await dao_wallet_0.generate_new_proposal( + mint_proposal_inner, DEFAULT_TX_CONFIG, vote_amount=vote_amount, fee=uint64(1000) + ) + await wallet.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Check the proposal is saved + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == vote_amount + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + + # Check that wallet_1 also finds and saved the proposal + assert len(dao_wallet_1.dao_info.proposals_list) == 1 + prop = dao_wallet_1.dao_info.proposals_list[0] + + # Create votable dao cats and add a new vote + dao_cat_1_bal = await dao_cat_wallet_1.get_votable_balance() + txs = await dao_cat_wallet_1.enter_dao_cat_voting_mode(dao_cat_1_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet_1.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + vote_tx = await dao_wallet_1.generate_proposal_vote_spend( + prop.proposal_id, dao_cat_1_bal // 2, True, DEFAULT_TX_CONFIG + ) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx) + vote_sb = vote_tx.spend_bundle + assert vote_sb is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb]) + + for i in range(1, dao_rules.proposal_timelock + 1): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + total_votes = vote_amount + dao_cat_1_bal // 2 + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == total_votes + + try: + close_tx = await dao_wallet_0.create_proposal_close_spend(prop.proposal_id, DEFAULT_TX_CONFIG, fee=uint64(100)) + await wallet.wallet_state_manager.add_pending_transaction(close_tx) + close_sb = close_tx.spend_bundle + except Exception as e: # pragma: no cover + print(e) + + assert close_sb is not None + await full_node_api.process_spend_bundles(bundles=[close_sb]) + balance = await cat_wallet_1.get_spendable_balance() + + assert close_sb is not None + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, get_proposal_state, (True, True), dao_wallet_0, 0) + await time_out_assert(20, get_proposal_state, (True, True), dao_wallet_1, 0) + + await time_out_assert(20, cat_wallet_1.get_spendable_balance, balance + new_mint_amount) + # Can we spend the newly minted CATs? + old_balance = await cat_wallet_0.get_spendable_balance() + ph_0 = await cat_wallet_0.get_new_inner_hash() + cat_tx = await cat_wallet_1.generate_signed_transaction([balance + new_mint_amount], [ph_0], DEFAULT_TX_CONFIG) + cat_sb = cat_tx[0].spend_bundle + await wallet_1.wallet_state_manager.add_pending_transaction(cat_tx[0]) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_sb.name()) + await full_node_api.process_transaction_records(records=cat_tx) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, cat_wallet_1.get_spendable_balance, 0) + await time_out_assert(20, cat_wallet_0.get_spendable_balance, old_balance + balance + new_mint_amount) + # release coins + await dao_wallet_0.free_coins_from_finished_proposals(DEFAULT_TX_CONFIG) + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_rpc_api( + self_hostname: str, two_wallet_nodes: Any, trusted: Any, consensus_mode: ConsensusMode +) -> None: + num_blocks = 2 # use 2 here so the test doesn't become flaky if things get slow + full_nodes, wallets, _ = two_wallet_nodes + full_node_api = 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 + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + funds = sum( + [calculate_pool_reward(uint32(i)) + calculate_base_farmer_reward(uint32(i)) for i in range(1, num_blocks)] + ) + + await time_out_assert(30, wallet_0.get_unconfirmed_balance, funds) + await time_out_assert(30, wallet_0.get_confirmed_balance, funds) + await time_out_assert(30, wallet_node_0.wallet_state_manager.synced, True) + api_0 = WalletRpcApi(wallet_node_0) + api_1 = WalletRpcApi(wallet_node_1) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + cat_amt = 300000 + fee = 10000 + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + + # Try to create a DAO without rules + with pytest.raises(ValueError) as e_info: + dao_wallet_0 = await api_0.create_new_wallet( + dict( + wallet_type="dao_wallet", + name="DAO WALLET 1", + mode="new", + amount_of_cats=cat_amt, + filter_amount=1, + fee=fee, + ) + ) + assert e_info.value.args[0] == "DAO rules must be specified for wallet creation" + + dao_wallet_0 = await api_0.create_new_wallet( + dict( + wallet_type="dao_wallet", + name="DAO WALLET 1", + mode="new", + dao_rules=dao_rules, + amount_of_cats=cat_amt, + filter_amount=1, + fee=fee, + ) + ) + assert isinstance(dao_wallet_0, dict) + assert dao_wallet_0.get("success") + dao_wallet_0_id = dao_wallet_0["wallet_id"] + dao_cat_wallet_0_id = dao_wallet_0["cat_wallet_id"] + treasury_id = bytes32(dao_wallet_0["treasury_id"]) + spend_bundle_list = await wallet_node_0.wallet_state_manager.tx_store.get_unconfirmed_for_wallet(dao_wallet_0_id) + spend_bundle = spend_bundle_list[0].spend_bundle + await time_out_assert_not_none(30, 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(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + await time_out_assert(30, wallet_0.get_pending_change_balance, 0) + expected_xch = funds - 1 - cat_amt - fee + await time_out_assert(30, wallet_0.get_confirmed_balance, expected_xch) + + dao_wallet_1 = await api_1.create_new_wallet( + dict( + wallet_type="dao_wallet", + name="DAO WALLET 2", + mode="existing", + treasury_id=treasury_id.hex(), + filter_amount=1, + ) + ) + assert isinstance(dao_wallet_1, dict) + assert dao_wallet_1.get("success") + dao_wallet_1_id = dao_wallet_1["wallet_id"] + # Create a cat wallet and add funds to treasury + new_cat_amt = 1000000000000 + cat_wallet_0 = await api_0.create_new_wallet( + dict( + wallet_type="cat_wallet", + name="CAT WALLET 1", + test=True, + mode="new", + amount=new_cat_amt, + ) + ) + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + cat_wallet_0_id = cat_wallet_0["wallet_id"] + cat_id = bytes32.from_hexstr(cat_wallet_0["asset_id"]) + + await rpc_state( + 20, + api_0.get_wallet_balance, + [{"wallet_id": cat_wallet_0_id}], + lambda x: x["wallet_balance"]["confirmed_wallet_balance"], + new_cat_amt, + ) + + cat_funding_amt = 500000 + await api_0.dao_add_funds_to_treasury( + dict( + wallet_id=dao_wallet_0_id, + amount=cat_funding_amt, + funding_wallet_id=cat_wallet_0_id, + ) + ) + + xch_funding_amt = 200000 + await api_0.dao_add_funds_to_treasury( + dict( + wallet_id=dao_wallet_0_id, + amount=xch_funding_amt, + funding_wallet_id=1, + ) + ) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + expected_xch -= xch_funding_amt + new_cat_amt + await time_out_assert(30, wallet_0.get_confirmed_balance, expected_xch) + + await rpc_state( + 20, + api_0.get_wallet_balance, + [{"wallet_id": cat_wallet_0_id}], + lambda x: x["wallet_balance"]["confirmed_wallet_balance"], + new_cat_amt - cat_funding_amt, + ) + + balances = await api_1.dao_get_treasury_balance({"wallet_id": dao_wallet_1_id}) + assert balances["balances"]["xch"] == xch_funding_amt + assert balances["balances"][cat_id.hex()] == cat_funding_amt + + # Send some cats to wallet_1 + await api_0.cat_spend( + { + "wallet_id": dao_cat_wallet_0_id, + "amount": cat_amt // 2, + "inner_address": encode_puzzle_hash(ph_1, "xch"), + "fee": fee, + } + ) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + await rpc_state( + 20, + api_0.get_wallet_balance, + [{"wallet_id": dao_cat_wallet_0_id}], + lambda x: x["wallet_balance"]["confirmed_wallet_balance"], + cat_amt // 2, + ) + + # send cats to lockup + await api_0.dao_send_to_lockup({"wallet_id": dao_wallet_0_id, "amount": cat_amt // 2}) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + await api_1.dao_send_to_lockup({"wallet_id": dao_wallet_1_id, "amount": cat_amt // 2}) + tx_queue = await wallet_node_1.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # create a spend proposal + additions = [ + {"puzzle_hash": ph_1.hex(), "amount": 1000}, + ] + create_proposal = await api_0.dao_create_proposal( + { + "wallet_id": dao_wallet_0_id, + "proposal_type": "spend", + "additions": additions, + "vote_amount": cat_amt // 2, + "fee": fee, + } + ) + assert create_proposal["success"] + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: len(x["proposals"]), 1) + + await rpc_state(20, api_1.dao_get_proposals, [{"wallet_id": dao_wallet_1_id}], lambda x: len(x["proposals"]), 1) + + props_0 = await api_0.dao_get_proposals({"wallet_id": dao_wallet_0_id}) + prop = props_0["proposals"][0] + assert prop.amount_voted == cat_amt // 2 + assert prop.yes_votes == cat_amt // 2 + + state = await api_0.dao_get_proposal_state({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + assert state["state"]["passed"] + assert not state["state"]["closable"] + + # Add votes + await api_1.dao_vote_on_proposal( + { + "wallet_id": dao_wallet_1_id, + "vote_amount": cat_amt // 2, + "proposal_id": prop.proposal_id.hex(), + "is_yes_vote": True, + } + ) + tx_queue = await wallet_node_1.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][0].amount_voted, cat_amt + ) + + # farm blocks until we can close proposal + for _ in range(1, state["state"]["blocks_needed"]): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closable"], + True, + ) + + await api_0.dao_close_proposal({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][0].closed, True + ) + + # check that the proposal state has changed for everyone + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + await rpc_state( + 20, + api_1.dao_get_proposal_state, + [{"wallet_id": dao_wallet_1_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + # create a mint proposal + mint_proposal = await api_0.dao_create_proposal( + { + "wallet_id": dao_wallet_0_id, + "proposal_type": "mint", + "amount": uint64(10000), + "cat_target_address": encode_puzzle_hash(ph_0, "xch"), + "vote_amount": cat_amt // 2, + "fee": fee, + } + ) + assert mint_proposal["success"] + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: len(x["proposals"]), 2) + + await rpc_state(20, api_1.dao_get_proposals, [{"wallet_id": dao_wallet_1_id}], lambda x: len(x["proposals"]), 2) + + props = await api_0.dao_get_proposals({"wallet_id": dao_wallet_0_id}) + prop = props["proposals"][1] + assert prop.amount_voted == cat_amt // 2 + assert prop.yes_votes == cat_amt // 2 + + state = await api_0.dao_get_proposal_state({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + assert state["state"]["passed"] + assert not state["state"]["closable"] + + # Add votes + await api_1.dao_vote_on_proposal( + { + "wallet_id": dao_wallet_1_id, + "vote_amount": cat_amt // 2, + "proposal_id": prop.proposal_id.hex(), + "is_yes_vote": True, + } + ) + tx_queue = await wallet_node_1.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][1].amount_voted, cat_amt + ) + + # farm blocks until we can close proposal + for _ in range(1, state["state"]["blocks_needed"]): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closable"], + True, + ) + + await api_0.dao_close_proposal({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][1].closed, True + ) + + # check that the proposal state has changed for everyone + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + await rpc_state( + 20, + api_1.dao_get_proposal_state, + [{"wallet_id": dao_wallet_1_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + # Check the minted cats are received + await rpc_state( + 20, + api_0.get_wallet_balance, + [{"wallet_id": dao_cat_wallet_0_id}], + lambda x: x["wallet_balance"]["confirmed_wallet_balance"], + 10000, + ) + + # create an update proposal + new_dao_rules = {"pass_percentage": 10000} + update_proposal = await api_0.dao_create_proposal( + { + "wallet_id": dao_wallet_0_id, + "proposal_type": "update", + "new_dao_rules": new_dao_rules, + "vote_amount": cat_amt // 2, + "fee": fee, + } + ) + assert update_proposal["success"] + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: len(x["proposals"]), 3) + + await rpc_state(20, api_1.dao_get_proposals, [{"wallet_id": dao_wallet_1_id}], lambda x: len(x["proposals"]), 3) + + props = await api_0.dao_get_proposals({"wallet_id": dao_wallet_0_id}) + prop = props["proposals"][2] + assert prop.amount_voted == cat_amt // 2 + assert prop.yes_votes == cat_amt // 2 + + state = await api_0.dao_get_proposal_state({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + assert state["state"]["passed"] + assert not state["state"]["closable"] + + # Add votes + await api_1.dao_vote_on_proposal( + { + "wallet_id": dao_wallet_1_id, + "vote_amount": cat_amt // 2, + "proposal_id": prop.proposal_id.hex(), + "is_yes_vote": True, + } + ) + tx_queue = await wallet_node_1.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][2].amount_voted, cat_amt + ) + + # farm blocks until we can close proposal + for _ in range(1, state["state"]["blocks_needed"]): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closable"], + True, + ) + + open_props = await api_0.dao_get_proposals({"wallet_id": dao_wallet_0_id, "include_closed": False}) + assert len(open_props["proposals"]) == 1 + + await api_0.dao_close_proposal({"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}) + tx_queue = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + await full_node_api.process_transaction_records(records=[tx for tx in tx_queue]) + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state( + 20, api_0.dao_get_proposals, [{"wallet_id": dao_wallet_0_id}], lambda x: x["proposals"][1].closed, True + ) + + # check that the proposal state has changed for everyone + await rpc_state( + 20, + api_0.dao_get_proposal_state, + [{"wallet_id": dao_wallet_0_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + await rpc_state( + 20, + api_1.dao_get_proposal_state, + [{"wallet_id": dao_wallet_1_id, "proposal_id": prop.proposal_id.hex()}], + lambda x: x["state"]["closed"], + True, + ) + + # Check the rules have updated + dao_wallet = wallet_node_0.wallet_state_manager.wallets[dao_wallet_0_id] + assert dao_wallet.dao_rules.pass_percentage == 10000 + + # Test adjust filter level + resp = await api_0.dao_adjust_filter_level({"wallet_id": dao_wallet_1_id, "filter_level": 101}) + assert resp["success"] + assert resp["dao_info"].filter_below_vote_amount == 101 + + # Test get_treasury_id + resp = await api_0.dao_get_treasury_id({"wallet_id": dao_wallet_0_id}) + assert resp["treasury_id"] == treasury_id + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_rpc_client( + two_wallet_nodes_services: SimulatorsAndWalletsServices, + trusted: bool, + self_hostname: str, + consensus_mode: ConsensusMode, +) -> None: + num_blocks = 3 + [full_node_service], wallet_services, bt = two_wallet_nodes_services + full_node_api = full_node_service._api + full_node_server = full_node_api.full_node.server + wallet_node_0 = wallet_services[0]._node + server_0 = wallet_node_0.server + wallet_node_1 = wallet_services[1]._node + server_1 = wallet_node_1.server + wallet_0 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + + if trusted: + wallet_node_0.config["trusted_peers"] = {full_node_server.node_id.hex(): full_node_server.node_id.hex()} + wallet_node_1.config["trusted_peers"] = {full_node_server.node_id.hex(): 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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + initial_funds = sum( + [calculate_pool_reward(uint32(i)) + calculate_base_farmer_reward(uint32(i)) for i in range(1, num_blocks)] + ) + + await time_out_assert(15, wallet_0.get_confirmed_balance, initial_funds) + await time_out_assert(15, wallet_0.get_unconfirmed_balance, initial_funds) + + assert wallet_services[0].rpc_server is not None + assert wallet_services[1].rpc_server is not None + + client_0 = await WalletRpcClient.create( + self_hostname, + wallet_services[0].rpc_server.listen_port, + wallet_services[0].root_path, + wallet_services[0].config, + ) + await validate_get_routes(client_0, wallet_services[0].rpc_server.rpc_api) + client_1 = await WalletRpcClient.create( + self_hostname, + wallet_services[1].rpc_server.listen_port, + wallet_services[1].root_path, + wallet_services[1].config, + ) + await validate_get_routes(client_1, wallet_services[1].rpc_server.rpc_api) + + try: + cat_amt = uint64(150000) + amount_of_cats = uint64(cat_amt * 2) + dao_rules = DAORules( + proposal_timelock=uint64(8), + soft_close_length=uint64(4), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(4900), # 49% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + filter_amount = uint64(1) + fee = uint64(10000) + + # create new dao + dao_wallet_dict_0 = await client_0.create_new_dao_wallet( + mode="new", + tx_config=DEFAULT_TX_CONFIG, + dao_rules=dao_rules.to_json_dict(), + amount_of_cats=amount_of_cats, + filter_amount=filter_amount, + name="DAO WALLET 0", + ) + assert dao_wallet_dict_0["success"] + dao_id_0 = dao_wallet_dict_0["wallet_id"] + treasury_id_hex = dao_wallet_dict_0["treasury_id"] + cat_wallet_0 = wallet_node_0.wallet_state_manager.wallets[dao_wallet_dict_0["cat_wallet_id"]] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, amount_of_cats) + + # Create a new standard cat for treasury funds + new_cat_amt = uint64(100000) + free_coins_res = await client_0.create_new_cat_and_wallet(new_cat_amt, test=True) + new_cat_wallet_id = free_coins_res["wallet_id"] + new_cat_wallet = wallet_node_0.wallet_state_manager.wallets[new_cat_wallet_id] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # join dao + dao_wallet_dict_1 = await client_1.create_new_dao_wallet( + mode="existing", + tx_config=DEFAULT_TX_CONFIG, + treasury_id=treasury_id_hex, + filter_amount=filter_amount, + name="DAO WALLET 1", + ) + assert dao_wallet_dict_1["success"] + dao_id_1 = dao_wallet_dict_1["wallet_id"] + cat_wallet_1 = wallet_node_1.wallet_state_manager.wallets[dao_wallet_dict_1["cat_wallet_id"]] + + # fund treasury + xch_funds = uint64(10000000000) + funding_tx = await client_0.dao_add_funds_to_treasury(dao_id_0, 1, xch_funds, DEFAULT_TX_CONFIG) + cat_funding_tx = await client_0.dao_add_funds_to_treasury( + dao_id_0, new_cat_wallet_id, new_cat_amt, DEFAULT_TX_CONFIG + ) + assert funding_tx["success"] + assert cat_funding_tx["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, client_0.dao_get_treasury_balance, [dao_id_0], lambda x: x["balances"]["xch"], xch_funds) + assert isinstance(new_cat_wallet, CATWallet) + new_cat_asset_id = new_cat_wallet.cat_info.limitations_program_hash + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id.hex()], + new_cat_amt, + ) + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"]["xch"], + xch_funds, + ) + + # send cats to wallet 1 + await client_0.cat_spend( + wallet_id=dao_wallet_dict_0["cat_wallet_id"], + tx_config=DEFAULT_TX_CONFIG, + amount=cat_amt, + inner_address=encode_puzzle_hash(ph_1, "xch"), + fee=fee, + ) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, cat_amt) + await time_out_assert(20, cat_wallet_1.get_confirmed_balance, cat_amt) + + # send cats to lockup + lockup_0 = await client_0.dao_send_to_lockup(dao_id_0, cat_amt, DEFAULT_TX_CONFIG) + lockup_1 = await client_1.dao_send_to_lockup(dao_id_1, cat_amt, DEFAULT_TX_CONFIG) + assert lockup_0["success"] + assert lockup_1["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + + # create a spend proposal + additions = [ + {"puzzle_hash": ph_0.hex(), "amount": 1000}, + {"puzzle_hash": ph_0.hex(), "amount": 10000, "asset_id": new_cat_asset_id.hex()}, + ] + proposal = await client_0.dao_create_proposal( + wallet_id=dao_id_0, + proposal_type="spend", + tx_config=DEFAULT_TX_CONFIG, + additions=additions, + vote_amount=cat_amt, + fee=fee, + ) + assert proposal["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # check proposal is found by wallet 1 + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][0]["yes_votes"], cat_amt) + props = await client_1.dao_get_proposals(dao_id_1) + proposal_id_hex = props["proposals"][0]["proposal_id"] + + # create an update proposal + update_proposal = await client_1.dao_create_proposal( + wallet_id=dao_id_1, + proposal_type="update", + tx_config=DEFAULT_TX_CONFIG, + vote_amount=cat_amt, + new_dao_rules={"proposal_timelock": uint64(10)}, + fee=fee, + ) + assert update_proposal["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # create a mint proposal + mint_addr = await client_1.get_next_address(wallet_id=wallet_1.id(), new_address=False) + mint_proposal = await client_1.dao_create_proposal( + wallet_id=dao_id_1, + proposal_type="mint", + tx_config=DEFAULT_TX_CONFIG, + vote_amount=cat_amt, + amount=uint64(100), + cat_target_address=mint_addr, + fee=fee, + ) + assert mint_proposal["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # vote spend + vote = await client_1.dao_vote_on_proposal( + wallet_id=dao_id_1, + proposal_id=proposal_id_hex, + vote_amount=cat_amt, + tx_config=DEFAULT_TX_CONFIG, + is_yes_vote=True, + fee=fee, + ) + assert vote["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # check updated proposal is found by wallet 0 + await rpc_state( + 20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][0]["yes_votes"], cat_amt * 2 + ) + + # check proposal state and farm enough blocks to pass + state = await client_0.dao_get_proposal_state(wallet_id=dao_id_0, proposal_id=proposal_id_hex) + assert state["success"] + assert state["state"]["passed"] + + for _ in range(0, state["state"]["blocks_needed"]): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + state = await client_0.dao_get_proposal_state(wallet_id=dao_id_0, proposal_id=proposal_id_hex) + assert state["success"] + assert state["state"]["closable"] + + # check proposal parsing + props = await client_0.dao_get_proposals(dao_id_0) + proposal_2_hex = props["proposals"][1]["proposal_id"] + proposal_3_hex = props["proposals"][2]["proposal_id"] + parsed_1 = await client_0.dao_parse_proposal(wallet_id=dao_id_0, proposal_id=proposal_id_hex) + assert parsed_1["success"] + parsed_2 = await client_0.dao_parse_proposal(wallet_id=dao_id_0, proposal_id=proposal_2_hex) + assert parsed_2["success"] + parsed_3 = await client_0.dao_parse_proposal(wallet_id=dao_id_0, proposal_id=proposal_3_hex) + assert parsed_3["success"] + + # close the proposal + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False, fee=fee + ) + assert close["success"] + + for i in range(1, 10): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][0]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][0]["closed"], True) + # check treasury balances + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id.hex()], + new_cat_amt - 10000, + ) + await rpc_state( + 20, client_0.dao_get_treasury_balance, [dao_id_0], lambda x: x["balances"]["xch"], xch_funds - 1000 + ) + + # check wallet balances + await rpc_state( + 20, client_0.get_wallet_balance, [new_cat_wallet_id], lambda x: x["confirmed_wallet_balance"], 10000 + ) + expected_xch = initial_funds - amount_of_cats - new_cat_amt - xch_funds - (2 * fee) - 2 - 9000 + await rpc_state( + 20, client_0.get_wallet_balance, [wallet_0.id()], lambda x: x["confirmed_wallet_balance"], expected_xch + ) + + # close the mint proposal + props = await client_0.dao_get_proposals(dao_id_0) + proposal_id_hex = props["proposals"][2]["proposal_id"] + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False, fee=fee + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][2]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][2]["closed"], True) + + # check minted cats are received + await rpc_state( + 20, + client_1.get_wallet_balance, + [dao_wallet_dict_1["cat_wallet_id"]], + lambda x: x["confirmed_wallet_balance"], + 100, + ) + + open_props = await client_0.dao_get_proposals(dao_id_0, False) + assert len(open_props["proposals"]) == 1 + + # close the update proposal + proposal_id_hex = props["proposals"][1]["proposal_id"] + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False, fee=fee + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][1]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][1]["closed"], True) + + # check dao rules are updated + new_rules = await client_0.dao_get_rules(dao_id_0) + assert new_rules["rules"]["proposal_timelock"] == 10 + new_rules_1 = await client_0.dao_get_rules(dao_id_1) + assert new_rules_1["rules"]["proposal_timelock"] == 10 + + # free locked cats from finished proposal + free_coins_res = await client_0.dao_free_coins_from_finished_proposals( + wallet_id=dao_id_0, tx_config=DEFAULT_TX_CONFIG + ) + assert free_coins_res["success"] + free_coins_tx = TransactionRecord.from_json_dict(free_coins_res["tx"]) + sb = free_coins_tx.spend_bundle + assert sb is not None + 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(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + bal = await client_0.get_wallet_balance(dao_wallet_dict_0["dao_cat_wallet_id"]) + assert bal["confirmed_wallet_balance"] == cat_amt + + exit = await client_0.dao_exit_lockup(dao_id_0, tx_config=DEFAULT_TX_CONFIG) + assert exit["success"] + + # coverage tests for filter amount and get treasury id + treasury_id_resp = await client_0.dao_get_treasury_id(wallet_id=dao_id_0) + assert treasury_id_resp["treasury_id"] == treasury_id_hex + filter_amount_resp = await client_0.dao_adjust_filter_level(wallet_id=dao_id_0, filter_level=30) + assert filter_amount_resp["dao_info"]["filter_below_vote_amount"] == 30 + + finally: + client_0.close() + client_1.close() + await client_0.await_closed() + await client_1.await_closed() + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_complex_spends( + two_wallet_nodes_services: SimulatorsAndWalletsServices, + trusted: bool, + self_hostname: str, + consensus_mode: ConsensusMode, +) -> None: + num_blocks = 3 + [full_node_service], wallet_services, bt = two_wallet_nodes_services + full_node_api = full_node_service._api + full_node_server = full_node_api.full_node.server + wallet_node_0 = wallet_services[0]._node + server_0 = wallet_node_0.server + wallet_node_1 = wallet_services[1]._node + server_1 = wallet_node_1.server + wallet_0 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + + if trusted: + wallet_node_0.config["trusted_peers"] = {full_node_server.node_id.hex(): full_node_server.node_id.hex()} + wallet_node_1.config["trusted_peers"] = {full_node_server.node_id.hex(): 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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + initial_funds = sum( + [calculate_pool_reward(uint32(i)) + calculate_base_farmer_reward(uint32(i)) for i in range(1, num_blocks)] + ) + + await time_out_assert(15, wallet_0.get_confirmed_balance, initial_funds) + await time_out_assert(15, wallet_0.get_unconfirmed_balance, initial_funds) + + assert wallet_services[0].rpc_server is not None + assert wallet_services[1].rpc_server is not None + + client_0 = await WalletRpcClient.create( + self_hostname, + wallet_services[0].rpc_server.listen_port, + wallet_services[0].root_path, + wallet_services[0].config, + ) + await validate_get_routes(client_0, wallet_services[0].rpc_server.rpc_api) + client_1 = await WalletRpcClient.create( + self_hostname, + wallet_services[1].rpc_server.listen_port, + wallet_services[1].root_path, + wallet_services[1].config, + ) + await validate_get_routes(client_1, wallet_services[1].rpc_server.rpc_api) + + try: + cat_amt = uint64(300000) + dao_rules = DAORules( + proposal_timelock=uint64(2), + soft_close_length=uint64(2), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(5), + oracle_spend_delay=uint64(2), + proposal_minimum_amount=uint64(1), + ) + filter_amount = uint64(1) + + # create new dao + dao_wallet_dict_0 = await client_0.create_new_dao_wallet( + mode="new", + tx_config=DEFAULT_TX_CONFIG, + dao_rules=dao_rules.to_json_dict(), + amount_of_cats=cat_amt, + filter_amount=filter_amount, + name="DAO WALLET 0", + ) + assert dao_wallet_dict_0["success"] + dao_id_0 = dao_wallet_dict_0["wallet_id"] + treasury_id_hex = dao_wallet_dict_0["treasury_id"] + cat_wallet_0 = wallet_node_0.wallet_state_manager.wallets[dao_wallet_dict_0["cat_wallet_id"]] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, cat_amt) + + # Create a new standard cat for treasury funds + new_cat_amt = uint64(1000000) + new_cat_wallet_dict = await client_0.create_new_cat_and_wallet(new_cat_amt, test=True) + new_cat_wallet_id = new_cat_wallet_dict["wallet_id"] + new_cat_wallet = wallet_node_0.wallet_state_manager.wallets[new_cat_wallet_id] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Create a new standard cat for treasury funds + new_cat_wallet_dict_2 = await client_0.create_new_cat_and_wallet(new_cat_amt, test=True) + new_cat_wallet_id_2 = new_cat_wallet_dict_2["wallet_id"] + new_cat_wallet_2 = wallet_node_0.wallet_state_manager.wallets[new_cat_wallet_id_2] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # join dao + dao_wallet_dict_1 = await client_1.create_new_dao_wallet( + mode="existing", + tx_config=DEFAULT_TX_CONFIG, + treasury_id=treasury_id_hex, + filter_amount=filter_amount, + name="DAO WALLET 1", + ) + assert dao_wallet_dict_1["success"] + dao_id_1 = dao_wallet_dict_1["wallet_id"] + + # fund treasury so there are multiple coins for each asset + xch_funds = uint64(10000000000) + for _ in range(4): + funding_tx = await client_0.dao_add_funds_to_treasury(dao_id_0, 1, uint64(xch_funds / 4), DEFAULT_TX_CONFIG) + cat_funding_tx = await client_0.dao_add_funds_to_treasury( + dao_id_0, new_cat_wallet_id, uint64(new_cat_amt / 4), DEFAULT_TX_CONFIG + ) + cat_funding_tx_2 = await client_0.dao_add_funds_to_treasury( + dao_id_0, new_cat_wallet_id_2, uint64(new_cat_amt / 4), DEFAULT_TX_CONFIG + ) + assert funding_tx["success"] + assert cat_funding_tx["success"] + assert cat_funding_tx_2["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, client_0.dao_get_treasury_balance, [dao_id_0], lambda x: x["balances"]["xch"], xch_funds) + assert isinstance(new_cat_wallet, CATWallet) + new_cat_asset_id = new_cat_wallet.cat_info.limitations_program_hash + assert isinstance(new_cat_wallet_2, CATWallet) + new_cat_asset_id_2 = new_cat_wallet_2.cat_info.limitations_program_hash + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id.hex()], + new_cat_amt, + ) + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id_2.hex()], + new_cat_amt, + ) + + # send cats to lockup + lockup_0 = await client_0.dao_send_to_lockup(dao_id_0, cat_amt, DEFAULT_TX_CONFIG) + assert lockup_0["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + + # Test spend proposal types + + # Test proposal with multiple conditions and xch coins + additions = [ + {"puzzle_hash": ph_0.hex(), "amount": xch_funds / 4}, + {"puzzle_hash": ph_1.hex(), "amount": xch_funds / 4}, + ] + proposal = await client_0.dao_create_proposal( + wallet_id=dao_id_0, + proposal_type="spend", + tx_config=DEFAULT_TX_CONFIG, + additions=additions, + vote_amount=cat_amt, + ) + assert proposal["success"] + for i in range(1, 5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + props = await client_1.dao_get_proposals(dao_id_1) + proposal_id_hex = props["proposals"][-1]["proposal_id"] + + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][-1]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][-1]["closed"], True) + # check the xch is received + await rpc_state( + 20, + client_1.get_wallet_balance, + [wallet_1.id()], + lambda x: x["confirmed_wallet_balance"], + initial_funds + (xch_funds / 4), + ) + + # Test proposal with multiple cats and multiple coins + cat_spend_amt = 510000 + additions = [ + {"puzzle_hash": ph_0.hex(), "amount": cat_spend_amt, "asset_id": new_cat_asset_id.hex()}, + {"puzzle_hash": ph_0.hex(), "amount": cat_spend_amt, "asset_id": new_cat_asset_id_2.hex()}, + ] + proposal = await client_0.dao_create_proposal( + wallet_id=dao_id_0, + proposal_type="spend", + tx_config=DEFAULT_TX_CONFIG, + additions=additions, + vote_amount=cat_amt, + ) + assert proposal["success"] + for i in range(1, 5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + props = await client_1.dao_get_proposals(dao_id_1) + proposal_id_hex = props["proposals"][-1]["proposal_id"] + + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][-1]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][-1]["closed"], True) + + # check cat balances + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id.hex()], + new_cat_amt - cat_spend_amt, + ) + await rpc_state( + 20, + client_0.dao_get_treasury_balance, + [dao_id_0], + lambda x: x["balances"][new_cat_asset_id_2.hex()], + new_cat_amt - cat_spend_amt, + ) + + await rpc_state( + 20, client_0.get_wallet_balance, [new_cat_wallet_id], lambda x: x["confirmed_wallet_balance"], cat_spend_amt + ) + await rpc_state( + 20, + client_0.get_wallet_balance, + [new_cat_wallet_id_2], + lambda x: x["confirmed_wallet_balance"], + cat_spend_amt, + ) + + # Spend remaining balances with multiple outputs + + additions = [ + {"puzzle_hash": ph_0.hex(), "amount": 400000, "asset_id": new_cat_asset_id.hex()}, + {"puzzle_hash": ph_1.hex(), "amount": 90000, "asset_id": new_cat_asset_id.hex()}, + {"puzzle_hash": ph_0.hex(), "amount": 400000, "asset_id": new_cat_asset_id_2.hex()}, + {"puzzle_hash": ph_1.hex(), "amount": 90000, "asset_id": new_cat_asset_id_2.hex()}, + {"puzzle_hash": ph_0.hex(), "amount": xch_funds / 4}, + {"puzzle_hash": ph_1.hex(), "amount": xch_funds / 4}, + ] + proposal = await client_0.dao_create_proposal( + wallet_id=dao_id_0, + proposal_type="spend", + tx_config=DEFAULT_TX_CONFIG, + additions=additions, + vote_amount=cat_amt, + ) + assert proposal["success"] + for i in range(1, 5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + props = await client_1.dao_get_proposals(dao_id_1) + proposal_id_hex = props["proposals"][-1]["proposal_id"] + + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, + proposal_id=proposal_id_hex, + tx_config=DEFAULT_TX_CONFIG, + self_destruct=False, + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][0]["closed"], True) + await rpc_state(20, client_1.dao_get_proposals, [dao_id_1], lambda x: x["proposals"][0]["closed"], True) + # check cat balances + await rpc_state( + 20, client_0.dao_get_treasury_balance, [dao_id_0], lambda x: x["balances"][new_cat_asset_id.hex()], 0 + ) + await rpc_state( + 20, client_0.get_wallet_balance, [new_cat_wallet_id], lambda x: x["confirmed_wallet_balance"], 0 + ) + await rpc_state( + 20, client_0.get_wallet_balance, [new_cat_wallet_id_2], lambda x: x["confirmed_wallet_balance"], 0 + ) + + # check wallet balances + await rpc_state( + 20, client_0.get_wallet_balance, [new_cat_wallet_id], lambda x: x["confirmed_wallet_balance"], cat_spend_amt + ) + await rpc_state( + 20, + client_0.get_wallet_balance, + [new_cat_wallet_id_2], + lambda x: x["confirmed_wallet_balance"], + cat_spend_amt, + ) + await rpc_state( + 20, + client_1.get_wallet_balance, + [wallet_1.id()], + lambda x: x["confirmed_wallet_balance"], + initial_funds + xch_funds / 4, + ) + + finally: + client_0.close() + client_1.close() + await client_0.await_closed() + await client_1.await_closed() + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_concurrency( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 3 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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_node_2, server_2 = wallets[2] + wallet = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + wallet_2 = wallet_node_2.wallet_state_manager.main_wallet + ph = await wallet.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + ph_2 = await wallet_2.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() + } + wallet_node_2.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"] = {} + wallet_node_2.config["trusted_peers"] = {} + + await server_0.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_2.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_2)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + cat_amt = 300000 + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(101), + ) + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(cat_amt), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + # Get the full node sim to process the wallet creation spend + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # get the cat wallets + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, cat_amt) + + # get the dao_cat wallet + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + + treasury_id = dao_wallet_0.dao_info.treasury_id + + # Create the other user's wallet from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_1.dao_info.treasury_id == treasury_id + + # Create funding spends for xch + xch_funds = uint64(500000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend(xch_funds, DEFAULT_TX_CONFIG) + await wallet_1.wallet_state_manager.add_pending_transaction(funding_tx) + assert isinstance(funding_tx, TransactionRecord) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Check that the funding spend is recognized by both dao wallets + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + + # Send some dao_cats to wallet_1 + # Get the cat wallets for wallet_1 + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + dao_cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.dao_cat_wallet_id] + assert cat_wallet_1 + assert dao_cat_wallet_1 + + # Add a third wallet and check they can find proposal with accurate vote counts + dao_wallet_2 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_2.wallet_state_manager, + wallet_2, + treasury_id, + ) + assert dao_wallet_2 is not None + assert dao_wallet_2.dao_info.treasury_id == treasury_id + + dao_cat_wallet_2 = dao_wallet_2.wallet_state_manager.wallets[dao_wallet_2.dao_info.dao_cat_wallet_id] + assert dao_cat_wallet_2 + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + cat_tx = await cat_wallet_0.generate_signed_transaction([100000, 100000], [ph_1, ph_2], DEFAULT_TX_CONFIG) + cat_sb = cat_tx[0].spend_bundle + await wallet.wallet_state_manager.add_pending_transaction(cat_tx[0]) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_sb.name()) + await full_node_api.process_transaction_records(records=cat_tx) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_1.get_confirmed_balance, 100000) + cat_wallet_2 = dao_wallet_2.wallet_state_manager.wallets[dao_wallet_2.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_2.get_confirmed_balance, 100000) + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, 100000) + + # Create dao cats for voting + dao_cat_0_bal = await dao_cat_wallet_0.get_votable_balance() + assert dao_cat_0_bal == 100000 + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dao_cat_0_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Create a proposal for xch spend + recipient_puzzle_hash = await wallet_2.get_new_puzzlehash() + proposal_amount = uint64(10000) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, + [recipient_puzzle_hash], + [proposal_amount], + [None], + ) + proposal_tx = await dao_wallet_0.generate_new_proposal( + xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, uint64(1000) + ) + await wallet.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Check the proposal is saved + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + + # Check that wallet_1 also finds and saved the proposal + assert len(dao_wallet_1.dao_info.proposals_list) == 1 + prop = dao_wallet_1.dao_info.proposals_list[0] + + # Give the wallet nodes a second + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + total_votes = dao_cat_0_bal + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == total_votes + + # Create votable dao cats and add a new vote + dao_cat_1_bal = await dao_cat_wallet_1.get_votable_balance() + txs = await dao_cat_wallet_1.enter_dao_cat_voting_mode(dao_cat_1_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet_1.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + txs = await dao_cat_wallet_2.enter_dao_cat_voting_mode(dao_cat_1_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet_2.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + vote_tx = await dao_wallet_1.generate_proposal_vote_spend(prop.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx) + vote_sb = vote_tx.spend_bundle + assert vote_sb is not None + vote_tx_2 = await dao_wallet_2.generate_proposal_vote_spend( + prop.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG + ) + await wallet_2.wallet_state_manager.add_pending_transaction(vote_tx_2) + vote_2 = vote_tx_2.spend_bundle + assert vote_2 is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_2.name()) + + await time_out_assert(20, len, 1, dao_wallet_2.dao_info.proposals_list) + await time_out_assert(20, int, total_votes, dao_wallet_1.dao_info.proposals_list[0].amount_voted) + await time_out_assert(20, int, total_votes, dao_wallet_2.dao_info.proposals_list[0].amount_voted) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, int, total_votes * 2, dao_wallet_1.dao_info.proposals_list[0].amount_voted) + await time_out_assert(20, int, total_votes * 2, dao_wallet_2.dao_info.proposals_list[0].amount_voted) + dao_cat_1_bal = await dao_cat_wallet_1.get_votable_balance(prop.proposal_id) + dao_cat_2_bal = await dao_cat_wallet_2.get_votable_balance(prop.proposal_id) + + assert (dao_cat_1_bal == 100000 and dao_cat_2_bal == 0) or (dao_cat_1_bal == 0 and dao_cat_2_bal == 100000) + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_cat_exits( + two_wallet_nodes_services: SimulatorsAndWalletsServices, + trusted: bool, + self_hostname: str, + consensus_mode: ConsensusMode, +) -> None: + num_blocks = 3 # We're using the rpc client, so use 3 blocks to ensure we stay synced + [full_node_service], wallet_services, bt = two_wallet_nodes_services + full_node_api = full_node_service._api + full_node_server = full_node_api.full_node.server + wallet_node_0 = wallet_services[0]._node + server_0 = wallet_node_0.server + wallet_node_1 = wallet_services[1]._node + server_1 = wallet_node_1.server + wallet_0 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + + if trusted: + wallet_node_0.config["trusted_peers"] = {full_node_server.node_id.hex(): full_node_server.node_id.hex()} + wallet_node_1.config["trusted_peers"] = {full_node_server.node_id.hex(): 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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + initial_funds = sum( + [calculate_pool_reward(uint32(i)) + calculate_base_farmer_reward(uint32(i)) for i in range(1, num_blocks)] + ) + + await time_out_assert(15, wallet_0.get_confirmed_balance, initial_funds) + await time_out_assert(15, wallet_0.get_unconfirmed_balance, initial_funds) + + assert wallet_services[0].rpc_server is not None + assert wallet_services[1].rpc_server is not None + + client_0 = await WalletRpcClient.create( + self_hostname, + wallet_services[0].rpc_server.listen_port, + wallet_services[0].root_path, + wallet_services[0].config, + ) + await validate_get_routes(client_0, wallet_services[0].rpc_server.rpc_api) + client_1 = await WalletRpcClient.create( + self_hostname, + wallet_services[1].rpc_server.listen_port, + wallet_services[1].root_path, + wallet_services[1].config, + ) + await validate_get_routes(client_1, wallet_services[1].rpc_server.rpc_api) + + try: + cat_amt = uint64(150000) + amount_of_cats = cat_amt + dao_rules = DAORules( + proposal_timelock=uint64(8), + soft_close_length=uint64(4), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=uint64(1), + ) + filter_amount = uint64(1) + fee = uint64(10000) + + # create new dao + dao_wallet_dict_0 = await client_0.create_new_dao_wallet( + mode="new", + tx_config=DEFAULT_TX_CONFIG, + dao_rules=dao_rules.to_json_dict(), + amount_of_cats=amount_of_cats, + filter_amount=filter_amount, + name="DAO WALLET 0", + ) + assert dao_wallet_dict_0["success"] + dao_id_0 = dao_wallet_dict_0["wallet_id"] + # treasury_id_hex = dao_wallet_dict_0["treasury_id"] + cat_wallet_0 = wallet_node_0.wallet_state_manager.wallets[dao_wallet_dict_0["cat_wallet_id"]] + dao_cat_wallet_0 = wallet_node_0.wallet_state_manager.wallets[dao_wallet_dict_0["dao_cat_wallet_id"]] + + for _ in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(1) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(60, cat_wallet_0.get_confirmed_balance, amount_of_cats) + + # fund treasury + xch_funds = uint64(10000000000) + funding_tx = await client_0.dao_add_funds_to_treasury(dao_id_0, 1, xch_funds, DEFAULT_TX_CONFIG) + assert funding_tx["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await rpc_state(20, client_0.dao_get_treasury_balance, [dao_id_0], lambda x: x["balances"]["xch"], xch_funds) + + # send cats to lockup + lockup_0 = await client_0.dao_send_to_lockup(dao_id_0, cat_amt, DEFAULT_TX_CONFIG) + assert lockup_0["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await asyncio.sleep(1) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + assert isinstance(dao_cat_wallet_0, DAOCATWallet) + await time_out_assert(60, dao_cat_wallet_0.get_confirmed_balance, cat_amt) + + # create a spend proposal + additions = [ + {"puzzle_hash": ph_1.hex(), "amount": 1000}, + ] + proposal = await client_0.dao_create_proposal( + wallet_id=dao_id_0, + proposal_type="spend", + tx_config=DEFAULT_TX_CONFIG, + additions=additions, + vote_amount=cat_amt, + fee=fee, + ) + assert proposal["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert_not_none(20, client_0.dao_get_proposals, dao_id_0) + props = await client_0.dao_get_proposals(dao_id_0) + proposal_id_hex = props["proposals"][0]["proposal_id"] + + # check proposal state and farm enough blocks to pass + state = await client_0.dao_get_proposal_state(wallet_id=dao_id_0, proposal_id=proposal_id_hex) + assert state["success"] + assert state["state"]["passed"] + + for _ in range(0, state["state"]["blocks_needed"]): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + state = await client_0.dao_get_proposal_state(wallet_id=dao_id_0, proposal_id=proposal_id_hex) + assert state["success"] + assert state["state"]["closable"] + + # close the proposal + close = await client_0.dao_close_proposal( + wallet_id=dao_id_0, proposal_id=proposal_id_hex, tx_config=DEFAULT_TX_CONFIG, self_destruct=False, fee=fee + ) + assert close["success"] + + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # check proposal is closed + await rpc_state(20, client_0.dao_get_proposals, [dao_id_0], lambda x: x["proposals"][0]["closed"], True) + + # free locked cats from finished proposal + res = await client_0.dao_free_coins_from_finished_proposals(wallet_id=dao_id_0, tx_config=DEFAULT_TX_CONFIG) + assert res["success"] + tx = TransactionRecord.from_json_dict(res["tx"]) + assert tx.spend_bundle is not None + sb_name = tx.spend_bundle.name() + 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(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + assert isinstance(dao_cat_wallet_0, DAOCATWallet) + assert dao_cat_wallet_0.dao_cat_info.locked_coins[0].active_votes == [] + + exit = await client_0.dao_exit_lockup(dao_id_0, DEFAULT_TX_CONFIG) + assert exit["success"] + for i in range(1, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await asyncio.sleep(0.5) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, dao_cat_wallet_0.get_confirmed_balance, 0) + await time_out_assert(20, cat_wallet_0.get_confirmed_balance, cat_amt) + + finally: + client_0.close() + client_1.close() + await client_0.await_closed() + await client_1.await_closed() + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_reorgs( + self_hostname: str, two_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 2 + full_nodes, wallets, _ = two_wallet_nodes + full_node_api = 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 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + ph = await wallet.get_new_puzzlehash() + ph_1 = await wallet_1.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(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + cat_amt = 300000 + dao_rules = DAORules( + proposal_timelock=uint64(5), + soft_close_length=uint64(2), + attendance_required=uint64(1000), # 10% + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(5), + oracle_spend_delay=uint64(2), + proposal_minimum_amount=uint64(101), + ) + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet, + uint64(cat_amt), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + # Get the full node sim to process the wallet creation spend + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + for i in range(num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(60, dao_wallet_0.get_confirmed_balance, uint128(1)) + + # Test Reorg on creation + height = full_node_api.full_node.blockchain.get_peak_height() + if height is None: # pragma: no cover + assert False + await full_node_api.reorg_from_index_to_new_index( + ReorgProtocol(uint32(height - 2), uint32(height + 1), puzzle_hash_0, None) + ) + + assert dao_wallet_0.dao_info.current_treasury_coin + await time_out_assert(60, dao_wallet_0.get_confirmed_balance, uint128(1)) + + # get the cat wallets + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, cat_amt) + + # get the dao_cat wallet + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + + treasury_id = dao_wallet_0.dao_info.treasury_id + + # Create the other user's wallet from the treasury id + dao_wallet_1 = await DAOWallet.create_new_dao_wallet_for_existing_dao( + wallet_node_1.wallet_state_manager, + wallet_1, + treasury_id, + ) + assert dao_wallet_1 is not None + assert dao_wallet_1.dao_info.treasury_id == treasury_id + + # Create funding spends for xch + xch_funds = uint64(500000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend( + xch_funds, + DEFAULT_TX_CONFIG, + ) + await wallet.wallet_state_manager.add_pending_transaction(funding_tx) + assert isinstance(funding_tx, TransactionRecord) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Check that the funding spend is recognized by both dao wallets + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + await time_out_assert(10, dao_wallet_1.get_balance_by_asset_type, xch_funds) + + # Reorg funding spend + height = full_node_api.full_node.blockchain.get_peak_height() + if height is None: # pragma: no cover + assert False + await full_node_api.reorg_from_index_to_new_index( + ReorgProtocol(uint32(height - 1), uint32(height + 1), puzzle_hash_0, None) + ) + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + await time_out_assert(10, dao_wallet_1.get_balance_by_asset_type, xch_funds) + + # Send some dao_cats to wallet_1 + # Get the cat wallets for wallet_1 + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + dao_cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.dao_cat_wallet_id] + assert cat_wallet_1 + assert dao_cat_wallet_1 + + cat_tx = await cat_wallet_0.generate_signed_transaction( + [100000], + [ph_1], + DEFAULT_TX_CONFIG, + ) + cat_sb = cat_tx[0].spend_bundle + await wallet.wallet_state_manager.add_pending_transaction(cat_tx[0]) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, cat_sb.name()) + await full_node_api.process_transaction_records(records=cat_tx) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + cat_wallet_1 = dao_wallet_1.wallet_state_manager.wallets[dao_wallet_1.dao_info.cat_wallet_id] + await time_out_assert(10, cat_wallet_1.get_confirmed_balance, 100000) + + # Create dao cats for voting + dao_cat_0_bal = await dao_cat_wallet_0.get_votable_balance() + assert dao_cat_0_bal == 200000 + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dao_cat_0_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Create a proposal for xch spend + recipient_puzzle_hash = await wallet.get_new_puzzlehash() + proposal_amount = uint64(10000) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, + [recipient_puzzle_hash], + [proposal_amount], + [None], + ) + proposal_tx = await dao_wallet_0.generate_new_proposal( + xch_proposal_inner, DEFAULT_TX_CONFIG, dao_cat_0_bal, uint64(1000) + ) + await wallet.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + # Check the proposal is saved + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + + # Reorg proposal creation + height = full_node_api.full_node.blockchain.get_peak_height() + if height is None: # pragma: no cover + assert False + await full_node_api.reorg_from_index_to_new_index( + ReorgProtocol(uint32(height - 1), uint32(height + 1), puzzle_hash_0, None) + ) + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + + # Check that wallet_1 also finds and saved the proposal + assert len(dao_wallet_1.dao_info.proposals_list) == 1 + prop = dao_wallet_1.dao_info.proposals_list[0] + + total_votes = dao_cat_0_bal + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == total_votes + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == total_votes + + # Create votable dao cats and add a new vote + dao_cat_1_bal = await dao_cat_wallet_1.get_votable_balance() + txs = await dao_cat_wallet_1.enter_dao_cat_voting_mode(dao_cat_1_bal, DEFAULT_TX_CONFIG) + for tx in txs: + await wallet.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb.name()) + await full_node_api.process_transaction_records(records=txs) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + vote_tx = await dao_wallet_1.generate_proposal_vote_spend(prop.proposal_id, dao_cat_1_bal, True, DEFAULT_TX_CONFIG) + await wallet_1.wallet_state_manager.add_pending_transaction(vote_tx) + vote_sb = vote_tx.spend_bundle + assert vote_sb is not None + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == dao_cat_0_bal + dao_cat_1_bal + + # Reorg on vote spend + height = full_node_api.full_node.blockchain.get_peak_height() + if height is None: # pragma: no cover + assert False + await full_node_api.reorg_from_index_to_new_index( + ReorgProtocol(uint32(height - 1), uint32(height + 1), puzzle_hash_0, None) + ) + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_0.dao_info.proposals_list[0].yes_votes == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_1.dao_info.proposals_list[0].amount_voted == dao_cat_0_bal + dao_cat_1_bal + assert dao_wallet_1.dao_info.proposals_list[0].yes_votes == dao_cat_0_bal + dao_cat_1_bal + + # Close proposal + for i in range(5): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + close_tx = await dao_wallet_0.create_proposal_close_spend(prop.proposal_id, DEFAULT_TX_CONFIG, fee=uint64(100)) + await wallet.wallet_state_manager.add_pending_transaction(close_tx) + close_sb = close_tx.spend_bundle + assert close_sb is not None + await time_out_assert_not_none(20, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_0, 0]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_1, 0]) + + # Reorg closed proposal + height = full_node_api.full_node.blockchain.get_peak_height() + if height is None: # pragma: no cover + assert False + await full_node_api.reorg_from_index_to_new_index( + ReorgProtocol(uint32(height - 1), uint32(height + 1), puzzle_hash_0, None) + ) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_1, timeout=30) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_0, 0]) + await time_out_assert(20, get_proposal_state, (True, True), *[dao_wallet_1, 0]) + + +@pytest.mark.limit_consensus_modes(reason="does not depend on consensus rules") +@pytest.mark.parametrize( + "trusted", + [True, False], +) +@pytest.mark.asyncio +async def test_dao_votes( + self_hostname: str, three_wallet_nodes: SimulatorsAndWallets, trusted: bool, consensus_mode: ConsensusMode +) -> None: + num_blocks = 1 + full_nodes, wallets, _ = three_wallet_nodes + full_node_api = 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_node_2, server_2 = wallets[2] + wallet_0 = wallet_node_0.wallet_state_manager.main_wallet + wallet_1 = wallet_node_1.wallet_state_manager.main_wallet + wallet_2 = wallet_node_2.wallet_state_manager.main_wallet + ph_0 = await wallet_0.get_new_puzzlehash() + ph_1 = await wallet_1.get_new_puzzlehash() + ph_2 = await wallet_2.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() + } + wallet_node_2.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"] = {} + wallet_node_2.config["trusted_peers"] = {} + + await server_0.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_1.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + await server_2.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None) + + for i in range(0, num_blocks): + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_0)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_1)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(ph_2)) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + + 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(20, wallet_0.get_confirmed_balance, funds) + await time_out_assert(20, full_node_api.wallet_is_synced, True, wallet_node_0) + + # set a standard fee amount to use in all txns + base_fee = uint64(100) + + # set the cat issuance and DAO rules + cat_issuance = 300000 + proposal_min_amt = uint64(101) + dao_rules = DAORules( + proposal_timelock=uint64(10), + soft_close_length=uint64(5), + attendance_required=uint64(190000), + pass_percentage=uint64(5100), # 51% + self_destruct_length=uint64(20), + oracle_spend_delay=uint64(10), + proposal_minimum_amount=proposal_min_amt, + ) + + dao_wallet_0 = await DAOWallet.create_new_dao_and_wallet( + wallet_node_0.wallet_state_manager, + wallet_0, + uint64(cat_issuance), + dao_rules, + DEFAULT_TX_CONFIG, + ) + assert dao_wallet_0 is not None + + tx_queue: List[TransactionRecord] = await wallet_node_0.wallet_state_manager.tx_store.get_not_sent() + tx_record = tx_queue[0] + await full_node_api.process_transaction_records(records=[tx_record]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.cat_wallet_id] + dao_cat_wallet_0 = dao_wallet_0.wallet_state_manager.wallets[dao_wallet_0.dao_info.dao_cat_wallet_id] + await time_out_assert(10, cat_wallet_0.get_confirmed_balance, cat_issuance) + assert dao_cat_wallet_0 + + treasury_id = dao_wallet_0.dao_info.treasury_id + + dc_1 = uint64(100000) + dc_2 = uint64(50000) + dc_3 = uint64(30000) + dc_4 = uint64(20000) + dc_5 = uint64(10000) + + # Lockup voting cats for all wallets + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dc_1, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dc_2, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs) + + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dc_3, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dc_4, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + txs = await dao_cat_wallet_0.enter_dao_cat_voting_mode(dc_5, DEFAULT_TX_CONFIG, fee=base_fee) + for tx in txs: + await wallet_0.wallet_state_manager.add_pending_transaction(tx) + dao_cat_sb_0 = txs[0].spend_bundle + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, dao_cat_sb_0.name()) + await full_node_api.process_transaction_records(records=txs) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + await time_out_assert(10, dao_cat_wallet_0.get_confirmed_balance, dc_1 + dc_2 + dc_3 + dc_4 + dc_5) + + # Create funding spend so the treasury holds some XCH + xch_funds = uint64(500000) + funding_tx = await dao_wallet_0.create_add_funds_to_treasury_spend(xch_funds, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(funding_tx) + funding_sb = funding_tx.spend_bundle + assert isinstance(funding_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, funding_sb.name()) + await full_node_api.process_transaction_records(records=[funding_tx]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + # Check that the funding spend is recognized by all wallets + await time_out_assert(10, dao_wallet_0.get_balance_by_asset_type, xch_funds) + + # Create Proposals + recipient_puzzle_hash = await wallet_2.get_new_puzzlehash() + proposal_amount_1 = uint64(9998) + xch_proposal_inner = generate_simple_proposal_innerpuz( + treasury_id, + [recipient_puzzle_hash], + [proposal_amount_1], + [None], + ) + + vote_1 = uint64(120000) + vote_2 = uint64(150000) + + proposal_tx = await dao_wallet_0.generate_new_proposal(xch_proposal_inner, DEFAULT_TX_CONFIG, vote_1, fee=base_fee) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + assert len(dao_wallet_0.dao_info.proposals_list) == 1 + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == vote_1 + assert dao_wallet_0.dao_info.proposals_list[0].timer_coin is not None + prop_0 = dao_wallet_0.dao_info.proposals_list[0] + + proposal_tx = await dao_wallet_0.generate_new_proposal(xch_proposal_inner, DEFAULT_TX_CONFIG, vote_2, fee=base_fee) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + assert len(dao_wallet_0.dao_info.proposals_list) == 2 + assert dao_wallet_0.dao_info.proposals_list[1].amount_voted == vote_2 + + vote_3 = uint64(30000) + vote_tx = await dao_wallet_0.generate_proposal_vote_spend(prop_0.proposal_id, vote_3, True, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(vote_tx) + assert isinstance(vote_tx, TransactionRecord) + vote_sb = vote_tx.spend_bundle + assert isinstance(vote_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == vote_1 + vote_3 + + vote_4 = uint64(60000) + vote_tx = await dao_wallet_0.generate_proposal_vote_spend(prop_0.proposal_id, vote_4, True, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(vote_tx) + assert isinstance(vote_tx, TransactionRecord) + vote_sb = vote_tx.spend_bundle + assert isinstance(vote_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + assert dao_wallet_0.dao_info.proposals_list[0].amount_voted == vote_1 + vote_3 + vote_4 + + vote_5 = uint64(1) + proposal_tx = await dao_wallet_0.generate_new_proposal(xch_proposal_inner, DEFAULT_TX_CONFIG, vote_5, fee=base_fee) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert len(dao_wallet_0.dao_info.proposals_list) == 3 + assert dao_wallet_0.dao_info.proposals_list[2].amount_voted == vote_5 + prop_2 = dao_wallet_0.dao_info.proposals_list[2] + + vote_6 = uint64(20000) + for i in range(10): + vote_tx = await dao_wallet_0.generate_proposal_vote_spend(prop_2.proposal_id, vote_6, True, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(vote_tx) + assert isinstance(vote_tx, TransactionRecord) + vote_sb = vote_tx.spend_bundle + assert isinstance(vote_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, vote_sb.name()) + await full_node_api.process_spend_bundles(bundles=[vote_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert dao_wallet_0.dao_info.proposals_list[2].amount_voted == 200001 + + close_tx = await dao_wallet_0.create_proposal_close_spend(prop_0.proposal_id, DEFAULT_TX_CONFIG) + await wallet_0.wallet_state_manager.add_pending_transaction(close_tx) + assert isinstance(close_tx, TransactionRecord) + close_sb = close_tx.spend_bundle + assert isinstance(close_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, close_sb.name()) + await full_node_api.process_spend_bundles(bundles=[close_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + + proposal_tx = await dao_wallet_0.generate_new_proposal(xch_proposal_inner, DEFAULT_TX_CONFIG, fee=base_fee) + await wallet_0.wallet_state_manager.add_pending_transaction(proposal_tx) + assert isinstance(proposal_tx, TransactionRecord) + proposal_sb = proposal_tx.spend_bundle + assert isinstance(proposal_sb, SpendBundle) + await time_out_assert_not_none(5, full_node_api.full_node.mempool_manager.get_spendbundle, proposal_sb.name()) + await full_node_api.process_spend_bundles(bundles=[proposal_sb]) + await full_node_api.farm_new_transaction_block(FarmNewBlockProtocol(puzzle_hash_0)) + await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=30) + assert dao_wallet_0.dao_info.proposals_list[3].amount_voted == 210000 diff --git a/tests/wallet/test_singleton_store.py b/tests/wallet/test_singleton_store.py new file mode 100644 index 000000000000..9f0359eb9d83 --- /dev/null +++ b/tests/wallet/test_singleton_store.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +# import dataclasses +from secrets import token_bytes + +import pytest + +from chia.types.blockchain_format.coin import Coin +from chia.types.blockchain_format.program import Program +from chia.types.blockchain_format.sized_bytes import bytes32 +from chia.types.coin_spend import CoinSpend +from chia.util.ints import uint32, uint64 + +# from chia.wallet.dao_wallet.dao_wallet import DAOInfo, DAOWallet +from chia.wallet.lineage_proof import LineageProof +from chia.wallet.singleton import create_singleton_puzzle +from chia.wallet.singleton_record import SingletonRecord +from chia.wallet.wallet_singleton_store import WalletSingletonStore +from tests.util.db_connection import DBConnection + + +def get_record(wallet_id: uint32 = uint32(2)) -> SingletonRecord: + launcher_id = bytes32(token_bytes(32)) + inner_puz = Program.to(1) + inner_puz_hash = inner_puz.get_tree_hash() + parent_puz = create_singleton_puzzle(inner_puz, launcher_id) + parent_puz_hash = parent_puz.get_tree_hash() + parent_coin = Coin(launcher_id, parent_puz_hash, 1) + inner_sol = Program.to([[51, inner_puz_hash, 1]]) + lineage_proof = LineageProof(launcher_id, inner_puz.get_tree_hash(), uint64(1)) + parent_sol = Program.to([lineage_proof.to_program(), 1, inner_sol]) + parent_coinspend = CoinSpend(parent_coin, parent_puz, parent_sol) + pending = True + removed_height = 0 + custom_data = "{'key': 'value'}" + record = SingletonRecord( + coin=parent_coin, + singleton_id=launcher_id, + wallet_id=wallet_id, + parent_coinspend=parent_coinspend, + inner_puzzle_hash=inner_puz_hash, + pending=pending, + removed_height=removed_height, + lineage_proof=lineage_proof, + custom_data=custom_data, + ) + return record + + +class TestSingletonStore: + @pytest.mark.asyncio + async def test_singleton_insert(self) -> None: + async with DBConnection(1) as wrapper: + db = await WalletSingletonStore.create(wrapper) + record = get_record() + await db.save_singleton(record) + records_by_wallet = await db.get_records_by_wallet_id(record.wallet_id) + assert records_by_wallet[0] == record + record_by_coin_id = await db.get_records_by_coin_id(record.coin.name()) + assert record_by_coin_id[0] == record + records_by_singleton_id = await db.get_records_by_singleton_id(record.singleton_id) + assert records_by_singleton_id[0] == record + # update pending + await db.update_pending_transaction(record.coin.name(), False) + record_to_check = (await db.get_records_by_coin_id(record.coin.name()))[0] + assert record_to_check.pending is False + assert record_to_check.custom_data == "{'key': 'value'}" + + @pytest.mark.asyncio + async def test_singleton_add_spend(self) -> None: + async with DBConnection(1) as wrapper: + db = await WalletSingletonStore.create(wrapper) + record = get_record() + child_coin = Coin(record.coin.name(), record.coin.puzzle_hash, 1) + parent_coinspend = record.parent_coinspend + + # test add spend + await db.add_spend(uint32(2), parent_coinspend, uint32(10)) + record_by_id = (await db.get_records_by_coin_id(child_coin.name()))[0] + assert record_by_id + + # Test adding a non-singleton will fail + inner_puz = Program.to(1) + inner_puz_hash = inner_puz.get_tree_hash() + bad_coin = Coin(record.singleton_id, inner_puz_hash, 1) + inner_sol = Program.to([[51, inner_puz_hash, 1]]) + bad_coinspend = CoinSpend(bad_coin, inner_puz, inner_sol) + with pytest.raises(RuntimeError) as e_info: + await db.add_spend(uint32(2), bad_coinspend, uint32(10)) + assert e_info.value.args[0] == "Coin to add is not a valid singleton" + + @pytest.mark.asyncio + async def test_singleton_remove(self) -> None: + async with DBConnection(1) as wrapper: + db = await WalletSingletonStore.create(wrapper) + record_1 = get_record() + record_2 = get_record() + await db.save_singleton(record_1) + await db.save_singleton(record_2) + resp_1 = await db.delete_singleton_by_coin_id(record_1.coin.name(), uint32(1)) + assert resp_1 + resp_2 = await db.delete_singleton_by_singleton_id(record_2.singleton_id, uint32(1)) + assert resp_2 + record = (await db.get_records_by_coin_id(record_1.coin.name()))[0] + assert record.removed_height == 1 + record = (await db.get_records_by_coin_id(record_2.coin.name()))[0] + assert record.removed_height == 1 + # delete a non-existing coin id + fake_id = bytes32(b"x" * 32) + resp_3 = await db.delete_singleton_by_coin_id(fake_id, uint32(10)) + assert not resp_3 + # delete a non-existing singleton id + resp_4 = await db.delete_singleton_by_singleton_id(fake_id, uint32(10)) + assert not resp_4 + + @pytest.mark.asyncio + async def test_singleton_delete_wallet(self) -> None: + async with DBConnection(1) as wrapper: + db = await WalletSingletonStore.create(wrapper) + for i in range(1, 5): + wallet_id = uint32(i) + for _ in range(5): + record = get_record(wallet_id) + await db.save_singleton(record) + assert not (await db.is_empty(wallet_id)) + + for j in range(1, 5): + wallet_id = uint32(j) + start_count = await db.count() + await db.delete_wallet(wallet_id) + assert (await db.count(wallet_id)) == 0 + assert await db.is_empty(wallet_id) + end_count = await db.count() + assert end_count == start_count - 5 + + assert await db.is_empty() + + @pytest.mark.asyncio + async def test_singleton_reorg(self) -> None: + async with DBConnection(1) as wrapper: + db = await WalletSingletonStore.create(wrapper) + record = get_record() + # save the singleton + await db.save_singleton(record) + # delete it at block 10 + await db.delete_singleton_by_coin_id(record.coin.name(), uint32(10)) + record_by_id = (await db.get_records_by_coin_id(record.coin.name()))[0] + assert record_by_id.removed_height == 10 + # rollback + await db.rollback(5, uint32(2)) + reorged_record_by_id = await db.get_records_by_coin_id(record.coin.name()) + assert not reorged_record_by_id