├── cats ├── __init__.py ├── secure_the_bag.py ├── cats.py └── unwind_the_bag.py ├── .gitignore ├── .repo-content-updater.yml ├── reference_tails ├── everything_with_signature.clsp.hex ├── genesis_by_coin_id.clsp.hex ├── genesis_by_puzzle_hash_with_0.clsp.hex ├── delegated_tail.clsp.hex ├── everything_with_signature.clsp ├── genesis_by_coin_id.clsp ├── genesis_by_puzzle_hash_with_0.clsp └── delegated_tail.clsp ├── pyproject.toml ├── tests ├── cats │ ├── test.csv │ ├── test_cat_command.py │ └── test_secure_the_bag.py └── conftest.py ├── mypy.ini ├── .pre-commit-config.yaml ├── README.md ├── include ├── condition_codes.clib └── cat_truths.clib ├── pytest.ini ├── setup.py ├── .github ├── workflows │ ├── run-test-suite.yml │ ├── nightly-against-main.yml │ └── pre-commit.yml └── dependabot.yml ├── ruff.toml └── LICENSE /cats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | CAT_admin_tool.egg-info 3 | .eggs 4 | __pycache__ 5 | main.sym 6 | build 7 | .vscode 8 | -------------------------------------------------------------------------------- /.repo-content-updater.yml: -------------------------------------------------------------------------------- 1 | var_overrides: 2 | DEPENDABOT_ACTIONS_REVIEWERS: '["cmmarslender", "altendky"]' 3 | -------------------------------------------------------------------------------- /reference_tails/everything_with_signature.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff01ff04ffff04ff02ffff04ff05ffff04ff5fff80808080ff8080ffff04ffff0132ff018080 -------------------------------------------------------------------------------- /reference_tails/genesis_by_coin_id.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff03ff2fffff01ff0880ffff01ff02ffff03ffff09ff2dff0280ff80ffff01ff088080ff018080ff0180 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=80", 4 | "setuptools_scm[toml]>=8", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /reference_tails/genesis_by_puzzle_hash_with_0.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff03ff2fffff01ff0880ffff01ff02ffff03ffff09ffff0bff82013fff02ff8202bf80ff2d80ff80ffff01ff088080ff018080ff0180 -------------------------------------------------------------------------------- /tests/cats/test.csv: -------------------------------------------------------------------------------- 1 | 4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba,10000000000000000 2 | f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c,32100000000 3 | 7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4,10000000000000000 4 | -------------------------------------------------------------------------------- /reference_tails/delegated_tail.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff01ff04ffff04ff04ffff04ff05ffff04ffff02ff06ffff04ff02ffff04ff82027fff80808080ff80808080ffff02ff82027fffff04ff0bffff04ff17ffff04ff2fffff04ff5fffff04ff81bfff82057f80808080808080ffff04ffff01ff31ff02ffff03ffff07ff0580ffff01ff0bffff0102ffff02ff06ffff04ff02ffff04ff09ff80808080ffff02ff06ffff04ff02ffff04ff0dff8080808080ffff01ff0bffff0101ff058080ff0180ff018080 -------------------------------------------------------------------------------- /reference_tails/everything_with_signature.clsp: -------------------------------------------------------------------------------- 1 | ; This is a "limitations_program" for use with cat.clvm. 2 | (mod ( 3 | PUBKEY 4 | Truths 5 | parent_is_cat 6 | lineage_proof 7 | delta 8 | inner_conditions 9 | _ 10 | ) 11 | 12 | (include condition_codes.clib) 13 | 14 | (list (list AGG_SIG_ME PUBKEY delta)) ; Careful with a delta of zero, the bytecode is 80 not 00 15 | ) -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest_asyncio 4 | from chia._tests.util.setup_nodes import setup_simulators_and_wallets_service 5 | from chia.simulator.block_tools import test_constants 6 | 7 | 8 | @pytest_asyncio.fixture(scope="function") 9 | async def one_wallet_and_one_simulator_services(): # type: ignore[no-untyped-def] 10 | async with setup_simulators_and_wallets_service(1, 1, test_constants) as _: 11 | yield _ 12 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = cats,tests,*.py 3 | show_error_codes = True 4 | warn_unused_ignores = True 5 | 6 | disallow_any_generics = True 7 | disallow_subclassing_any = True 8 | disallow_untyped_calls = True 9 | disallow_untyped_defs = True 10 | disallow_incomplete_defs = True 11 | check_untyped_defs = True 12 | disallow_untyped_decorators = True 13 | no_implicit_optional = True 14 | warn_return_any = True 15 | no_implicit_reexport = True 16 | strict_equality = True 17 | warn_redundant_casts = True 18 | enable_incomplete_feature = Unpack 19 | -------------------------------------------------------------------------------- /reference_tails/genesis_by_coin_id.clsp: -------------------------------------------------------------------------------- 1 | ; This is a "genesis checker" for use with cat.clvm. 2 | ; 3 | ; This checker allows new CATs to be created if they have a particular coin id as parent 4 | ; 5 | ; The genesis_id is curried in, making this lineage_check program unique and giving the CAT it's uniqueness 6 | (mod ( 7 | GENESIS_ID 8 | Truths 9 | parent_is_cat 10 | lineage_proof 11 | delta 12 | inner_conditions 13 | _ 14 | ) 15 | 16 | (include cat_truths.clib) 17 | 18 | (if delta 19 | (x) 20 | (if (= (my_parent_cat_truth Truths) GENESIS_ID) 21 | () 22 | (x) 23 | ) 24 | ) 25 | 26 | ) 27 | -------------------------------------------------------------------------------- /reference_tails/genesis_by_puzzle_hash_with_0.clsp: -------------------------------------------------------------------------------- 1 | ; This is a "limitations_program" for use with cat.clvm. 2 | ; 3 | ; This checker allows new CATs to be created if their parent has a particular puzzle hash 4 | (mod ( 5 | GENESIS_PUZZLE_HASH 6 | Truths 7 | parent_is_cat 8 | lineage_proof 9 | delta 10 | inner_conditions 11 | (parent_parent_id parent_amount) 12 | ) 13 | 14 | (include cat_truths.clib) 15 | 16 | ; Returns nil since we don't need to add any conditions 17 | (if delta 18 | (x) 19 | (if (= (sha256 parent_parent_id GENESIS_PUZZLE_HASH parent_amount) (my_parent_cat_truth Truths)) 20 | () 21 | (x) 22 | ) 23 | ) 24 | ) 25 | -------------------------------------------------------------------------------- /reference_tails/delegated_tail.clsp: -------------------------------------------------------------------------------- 1 | ; This is a "limitations_program" for use with cat.clvm. 2 | (mod ( 3 | PUBKEY 4 | Truths 5 | parent_is_cat 6 | lineage_proof 7 | delta 8 | inner_conditions 9 | ( 10 | delegated_puzzle 11 | delegated_solution 12 | ) 13 | ) 14 | 15 | (include condition_codes.clib) 16 | 17 | (defun sha256tree1 (TREE) 18 | (if (l TREE) 19 | (sha256 2 (sha256tree1 (f TREE)) (sha256tree1 (r TREE))) 20 | (sha256 1 TREE))) 21 | 22 | (c (list AGG_SIG_UNSAFE PUBKEY (sha256tree1 delegated_puzzle)) 23 | (a delegated_puzzle (c Truths (c parent_is_cat (c lineage_proof (c delta (c inner_conditions delegated_solution)))))) 24 | ) 25 | ) -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | exclude: ".*?(.hex|.clsp|.clvm|.clib)" 8 | - id: trailing-whitespace 9 | - id: check-merge-conflict 10 | - id: check-ast 11 | - id: debug-statements 12 | - repo: local 13 | hooks: 14 | - id: ruff_format 15 | name: ruff format 16 | entry: ruff format 17 | language: system 18 | require_serial: true 19 | types_or: [python, pyi] 20 | - repo: local 21 | hooks: 22 | - id: ruff 23 | name: Ruff 24 | entry: ruff check --fix 25 | language: system 26 | types: [python] 27 | - repo: local 28 | hooks: 29 | - id: mypy 30 | name: mypy 31 | entry: mypy 32 | language: system 33 | pass_filenames: false 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CAT Admin Tool 2 | ======= 3 | 4 | Install 5 | ------- 6 | 7 | **Ubuntu/MacOSs** 8 | ``` 9 | git clone https://github.com/Chia-Network/CAT-admin-tool.git 10 | cd CAT-admin-tool 11 | python3 -m venv venv 12 | . ./venv/bin/activate 13 | python -m pip install --upgrade pip setuptools wheel 14 | pip install . 15 | pip install chia-dev-tools --no-deps 16 | pip install pytest 17 | ``` 18 | (If you're on an M1 Mac, make sure you are running an ARM64 native python virtual environment) 19 | 20 | **Windows Powershell** 21 | ``` 22 | git clone https://github.com/Chia-Network/CAT-admin-tool.git 23 | cd CAT-admin-tool 24 | py -m venv venv 25 | ./venv/Scripts/activate 26 | python -m pip install --upgrade pip setuptools wheel 27 | pip install . 28 | pip install chia-dev-tools --no-deps 29 | pip install pytest 30 | ``` 31 | 32 | Lastly this requires a synced, running light wallet 33 | 34 | Verify the installation was successful 35 | ``` 36 | cats --help 37 | cdv --help 38 | ``` 39 | 40 | Examples can be found in the [CAT Creation Tutorial](https://docs.chia.net/guides/cat-creation-tutorial/#cat-admin-tool) 41 | -------------------------------------------------------------------------------- /include/condition_codes.clib: -------------------------------------------------------------------------------- 1 | ; See chia/types/condition_opcodes.py 2 | 3 | ( 4 | (defconstant AGG_SIG_UNSAFE 49) 5 | (defconstant AGG_SIG_ME 50) 6 | 7 | ; the conditions below reserve coin amounts and have to be accounted for in output totals 8 | 9 | (defconstant CREATE_COIN 51) 10 | (defconstant RESERVE_FEE 52) 11 | 12 | ; the conditions below deal with announcements, for inter-coin communication 13 | 14 | ; coin announcements 15 | (defconstant CREATE_COIN_ANNOUNCEMENT 60) 16 | (defconstant ASSERT_COIN_ANNOUNCEMENT 61) 17 | 18 | ; puzzle announcements 19 | (defconstant CREATE_PUZZLE_ANNOUNCEMENT 62) 20 | (defconstant ASSERT_PUZZLE_ANNOUNCEMENT 63) 21 | 22 | ; the conditions below let coins inquire about themselves 23 | 24 | (defconstant ASSERT_MY_COIN_ID 70) 25 | (defconstant ASSERT_MY_PARENT_ID 71) 26 | (defconstant ASSERT_MY_PUZZLEHASH 72) 27 | (defconstant ASSERT_MY_AMOUNT 73) 28 | 29 | ; the conditions below ensure that we're "far enough" in the future 30 | 31 | ; wall-clock time 32 | (defconstant ASSERT_SECONDS_RELATIVE 80) 33 | (defconstant ASSERT_SECONDS_ABSOLUTE 81) 34 | 35 | ; block index 36 | (defconstant ASSERT_HEIGHT_RELATIVE 82) 37 | (defconstant ASSERT_HEIGHT_ABSOLUTE 83) 38 | ) 39 | -------------------------------------------------------------------------------- /include/cat_truths.clib: -------------------------------------------------------------------------------- 1 | ( 2 | (defun-inline cat_truth_data_to_truth_struct (innerpuzhash cat_struct my_id this_coin_info) 3 | (c 4 | (c 5 | innerpuzhash 6 | cat_struct 7 | ) 8 | (c 9 | my_id 10 | this_coin_info 11 | ) 12 | ) 13 | ) 14 | 15 | ; CAT Truths is: ((Inner puzzle hash . (MOD hash . (MOD hash hash . TAIL hash))) . (my_id . (my_parent_info my_puzhash my_amount))) 16 | 17 | (defun-inline my_inner_puzzle_hash_cat_truth (Truths) (f (f Truths))) 18 | (defun-inline cat_struct_truth (Truths) (r (f Truths))) 19 | (defun-inline my_id_cat_truth (Truths) (f (r Truths))) 20 | (defun-inline my_coin_info_truth (Truths) (r (r Truths))) 21 | (defun-inline my_amount_cat_truth (Truths) (f (r (r (my_coin_info_truth Truths))))) 22 | (defun-inline my_full_puzzle_hash_cat_truth (Truths) (f (r (my_coin_info_truth Truths)))) 23 | (defun-inline my_parent_cat_truth (Truths) (f (my_coin_info_truth Truths))) 24 | 25 | 26 | ; CAT mod_struct is: (MOD_HASH MOD_HASH_hash TAIL_PROGRAM TAIL_PROGRAM_hash) 27 | 28 | (defun-inline cat_mod_hash_truth (Truths) (f (cat_struct_truth Truths))) 29 | (defun-inline cat_mod_hash_hash_truth (Truths) (f (r (cat_struct_truth Truths)))) 30 | (defun-inline cat_tail_program_hash_truth (Truths) (f (r (r (cat_struct_truth Truths))))) 31 | ) 32 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | ; logging options 3 | log_cli = False 4 | addopts = --verbose --tb=short -p no:monitor 5 | log_level = WARNING 6 | console_output_style = count 7 | log_format = %(asctime)s %(name)s: %(levelname)s %(message)s 8 | asyncio_mode = strict 9 | testpaths = tests 10 | filterwarnings = 11 | error 12 | ignore:ssl_context is deprecated:DeprecationWarning 13 | ignore:Implicitly cleaning up:ResourceWarning 14 | ignore:unclosed =0.26.0", 17 | "pytest-env", 18 | "pre-commit==4.5.0; python_version >= '3.10'", 19 | "mypy==1.18.2", 20 | "types-setuptools", 21 | "ruff==0.14.6", 22 | ] 23 | 24 | setup( 25 | name="CAT_admin_tool", 26 | author="Quexington", 27 | packages=find_packages(exclude=("tests",)), 28 | entry_points={ 29 | "console_scripts": [ 30 | "cats = cats.cats:main", 31 | "secure_the_bag = cats.secure_the_bag:main", 32 | "unwind_the_bag = cats.unwind_the_bag:main", 33 | ], 34 | }, 35 | author_email="m.hauff@chia.net", 36 | setup_requires=["setuptools_scm"], 37 | install_requires=dependencies, 38 | url="https://github.com/Chia-Network", 39 | license="Apache-2.0", 40 | description="Tools to administer issuance and redemption of a Chia Asset Token or CAT", 41 | classifiers=[ 42 | "Programming Language :: Python :: 3", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: 3.11", 45 | "Programming Language :: Python :: 3.12", 46 | "License :: OSI Approved :: Apache Software License", 47 | "Topic :: Security :: Cryptography", 48 | ], 49 | extras_require=dict( 50 | dev=dev_dependencies, 51 | ), 52 | project_urls={ 53 | "Bug Reports": "https://github.com/Chia-Network/cat-admin-tool", 54 | "Source": "https://github.com/Chia-Network/cat-admin-tool", 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /.github/workflows/run-test-suite.yml: -------------------------------------------------------------------------------- 1 | name: Run Test Suite 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - "**" 10 | pull_request: 11 | branches: 12 | - "**" 13 | 14 | concurrency: 15 | group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}--${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/long_lived/')) && github.sha || '' }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | name: All tests 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 30 23 | strategy: 24 | fail-fast: false 25 | max-parallel: 4 26 | 27 | env: 28 | CHIA_ROOT: ${{ github.workspace }}/.chia/mainnet 29 | BLOCKS_AND_PLOTS_VERSION: 0.29.0 30 | 31 | steps: 32 | - name: Checkout Code 33 | uses: actions/checkout@v6 34 | with: 35 | fetch-depth: 0 36 | 37 | - name: Setup Python environment 38 | uses: Chia-Network/actions/setup-python@main 39 | with: 40 | python-version: 3.12 41 | 42 | - name: Cache test blocks and plots 43 | uses: actions/cache@v4 44 | env: 45 | SEGMENT_DOWNLOAD_TIMEOUT_MIN: 1 46 | id: test-blocks-plots 47 | with: 48 | path: | 49 | ${{ github.workspace }}/.chia/blocks 50 | ${{ github.workspace }}/.chia/test-plots 51 | key: ${{ env.BLOCKS_AND_PLOTS_VERSION }} 52 | 53 | - name: Checkout test blocks and plots 54 | if: steps.test-blocks-plots.outputs.cache-hit != 'true' 55 | env: 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | run: | 58 | gh release download -R Chia-Network/test-cache ${{ env.BLOCKS_AND_PLOTS_VERSION }} --archive=tar.gz -O - | tar xzf - 59 | mkdir "${GITHUB_WORKSPACE}/.chia" 60 | mv "${GITHUB_WORKSPACE}/test-cache-${{ env.BLOCKS_AND_PLOTS_VERSION }}/"* "${GITHUB_WORKSPACE}/.chia" 61 | 62 | - name: Test code with pytest 63 | run: | 64 | python3 -m venv venv 65 | . ./venv/bin/activate 66 | pip install .[dev] 67 | ./venv/bin/pytest tests/ -s -v --durations 0 68 | -------------------------------------------------------------------------------- /.github/workflows/nightly-against-main.yml: -------------------------------------------------------------------------------- 1 | name: Run Test Suite Nightly 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 11 * * *" 7 | 8 | concurrency: 9 | group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}--${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/long_lived/')) && github.sha || '' }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build: 14 | name: All tests 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | 18 | env: 19 | CHIA_ROOT: ${{ github.workspace }}/.chia/mainnet 20 | BLOCKS_AND_PLOTS_VERSION: 0.29.0 21 | 22 | steps: 23 | - name: Checkout Code 24 | uses: actions/checkout@v6 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Setup Python environment 29 | uses: Chia-Network/actions/setup-python@main 30 | with: 31 | python-version: "3.12" 32 | 33 | - name: Cache test blocks and plots 34 | uses: actions/cache@v4 35 | env: 36 | SEGMENT_DOWNLOAD_TIMEOUT_MIN: 1 37 | id: test-blocks-plots 38 | with: 39 | path: | 40 | ${{ github.workspace }}/.chia/blocks 41 | ${{ github.workspace }}/.chia/test-plots 42 | key: ${{ env.BLOCKS_AND_PLOTS_VERSION }} 43 | 44 | - name: Checkout test blocks and plots 45 | if: steps.test-blocks-plots.outputs.cache-hit != 'true' 46 | env: 47 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | run: | 49 | gh release download -R Chia-Network/test-cache ${{ env.BLOCKS_AND_PLOTS_VERSION }} --archive=tar.gz -O - | tar xzf - 50 | mkdir "${GITHUB_WORKSPACE}/.chia" 51 | mv "${GITHUB_WORKSPACE}/test-cache-${{ env.BLOCKS_AND_PLOTS_VERSION }}/"* "${GITHUB_WORKSPACE}/.chia" 52 | 53 | - name: Test code with pytest against chia-blockchain main nightly 54 | run: | 55 | python3 -m venv venv 56 | . ./venv/bin/activate 57 | sed -i 's/chia-blockchain.*/chia-blockchain @ git+https:\/\/github.com\/Chia-Network\/chia-blockchain.git@main\",/g' setup.py 58 | pip install .[dev] 59 | ./venv/bin/pytest tests/ -s -v --durations 0 60 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # This file is managed by the repo-content-updater project. Manual changes here will result in a PR to bring back 2 | # inline with the upstream template, unless you remove the dependabot managed file property from the repo 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "gomod" 7 | directory: / 8 | schedule: 9 | interval: "weekly" 10 | day: "tuesday" 11 | cooldown: 12 | default-days: 7 13 | open-pull-requests-limit: 10 14 | rebase-strategy: auto 15 | labels: 16 | - dependencies 17 | - go 18 | - "Changed" 19 | reviewers: ["cmmarslender", "Starttoaster"] 20 | groups: 21 | global: 22 | patterns: 23 | - "*" 24 | 25 | - package-ecosystem: "pip" 26 | directory: / 27 | schedule: 28 | interval: "weekly" 29 | day: "tuesday" 30 | cooldown: 31 | default-days: 7 32 | open-pull-requests-limit: 10 33 | rebase-strategy: auto 34 | labels: 35 | - dependencies 36 | - python 37 | - "Changed" 38 | reviewers: ["emlowe", "altendky"] 39 | 40 | - package-ecosystem: "github-actions" 41 | directories: ["/", ".github/actions/*"] 42 | schedule: 43 | interval: "weekly" 44 | day: "tuesday" 45 | cooldown: 46 | default-days: 7 47 | open-pull-requests-limit: 10 48 | rebase-strategy: auto 49 | labels: 50 | - dependencies 51 | - github_actions 52 | - "Changed" 53 | reviewers: ["cmmarslender", "altendky"] 54 | 55 | - package-ecosystem: "npm" 56 | directory: / 57 | schedule: 58 | interval: "weekly" 59 | day: "tuesday" 60 | cooldown: 61 | default-days: 7 62 | open-pull-requests-limit: 10 63 | rebase-strategy: auto 64 | labels: 65 | - dependencies 66 | - javascript 67 | - "Changed" 68 | reviewers: ["cmmarslender", "ChiaMineJP"] 69 | 70 | - package-ecosystem: cargo 71 | directory: / 72 | schedule: 73 | interval: "weekly" 74 | day: "tuesday" 75 | cooldown: 76 | default-days: 7 77 | open-pull-requests-limit: 10 78 | rebase-strategy: auto 79 | labels: 80 | - dependencies 81 | - rust 82 | - "Changed" 83 | 84 | - package-ecosystem: swift 85 | directory: / 86 | schedule: 87 | interval: "weekly" 88 | day: "tuesday" 89 | cooldown: 90 | default-days: 7 91 | open-pull-requests-limit: 10 92 | rebase-strategy: auto 93 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: 🚨 pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | # SHA is added to the end if on `main` to let all main workflows run 11 | group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/long_lived/')) && github.sha || '' }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | pre-commit: 16 | name: ${{ matrix.os.name }} ${{ matrix.arch.name }} ${{ matrix.python.major_dot_minor }} 17 | runs-on: ${{ matrix.os.runs-on[matrix.arch.matrix] }} 18 | timeout-minutes: 20 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: 23 | - name: Linux 24 | matrix: linux 25 | runs-on: 26 | intel: ubuntu-latest 27 | arm: [linux, arm64] 28 | - name: macOS 29 | matrix: macos 30 | runs-on: 31 | intel: macos-15-intel 32 | arm: macos-13-arm64 33 | - name: Windows 34 | matrix: windows 35 | runs-on: 36 | intel: windows-latest 37 | arch: 38 | - name: ARM64 39 | matrix: arm 40 | - name: Intel 41 | matrix: intel 42 | python: 43 | - major_dot_minor: "3.10" 44 | - major_dot_minor: "3.11" 45 | - major_dot_minor: "3.12" 46 | exclude: 47 | - os: 48 | matrix: windows 49 | arch: 50 | matrix: arm 51 | 52 | steps: 53 | - name: Clean workspace 54 | uses: Chia-Network/actions/clean-workspace@main 55 | 56 | - uses: Chia-Network/actions/git-mark-workspace-safe@main 57 | 58 | - name: disable git autocrlf 59 | run: | 60 | git config --global core.autocrlf false 61 | 62 | - uses: actions/checkout@v6 63 | with: 64 | fetch-depth: 0 65 | 66 | - uses: Chia-Network/actions/setup-python@main 67 | with: 68 | python-version: ${{ matrix.python.major_dot_minor }} 69 | 70 | - name: Create virtual environment 71 | uses: Chia-Network/actions/create-venv@main 72 | id: create-venv 73 | 74 | - name: Activate virtual environment 75 | uses: Chia-Network/actions/activate-venv@main 76 | with: 77 | directories: ${{ steps.create-venv.outputs.activate-venv-directories }} 78 | 79 | - shell: bash 80 | run: | 81 | python -m pip install --extra-index https://pypi.chia.net/simple/ --editable .[dev] 82 | pre-commit run --all-files --verbose 83 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | 3 | [lint] 4 | preview = true 5 | select = [ 6 | "PL", # Pylint 7 | "I", # Isort 8 | "FA", # Flake8: future-annotations 9 | "UP", # Pyupgrade 10 | "RUF", # Ruff specific 11 | "F", # Flake8 core 12 | "ASYNC", # Flake8 async 13 | "ISC", # flake8-implicit-str-concat 14 | "TID", # flake8-tidy-imports 15 | "E", 16 | "W", 17 | ] 18 | explicit-preview-rules = false 19 | ignore = [ 20 | # Pylint convention 21 | "PLC0415", # import-outside-top-level 22 | "PLC1901", # compare-to-empty-string 23 | # Should probably fix these 24 | "PLC2801", # unnecessary-dunder-call 25 | "PLC2701", # import-private-name 26 | 27 | # Pylint refactor 28 | "PLR0915", # too-many-statements 29 | "PLR0914", # too-many-locals 30 | "PLR0913", # too-many-arguments 31 | "PLR0912", # too-many-branches 32 | "PLR1702", # too-many-nested-blocks 33 | "PLR0904", # too-many-public-methods 34 | "PLR0917", # too-many-positional-arguments 35 | "PLR0916", # too-many-boolean-expressions 36 | "PLR0911", # too-many-return-statements 37 | # Should probably fix these 38 | "PLR6301", # no-self-use 39 | "PLR2004", # magic-value-comparison 40 | "PLR1704", # redefined-argument-from-local 41 | "PLR5501", # collapsible-else-if 42 | 43 | # Pylint warning 44 | "PLW1641", # eq-without-hash 45 | # Should probably fix these 46 | "PLW2901", # redefined-loop-name 47 | "PLW1514", # unspecified-encoding 48 | "PLW0603", # global-statement 49 | 50 | # Flake8 async 51 | # Should probably fix these 52 | "ASYNC230", # blocking-open-call-in-async-function 53 | "ASYNC250", # blocking-input-call-in-async-function 54 | # Should probably fix these after dealing with shielding for anyio 55 | "ASYNC109", # async-function-with-timeout 56 | 57 | # flake8-implicit-str-concat 58 | "ISC003", # explicit-string-concatenation 59 | 60 | # Ruff Specific 61 | 62 | # This code is problematic because using instantiated types as defaults is so common across the codebase. 63 | # Its purpose is to prevent accidenatally assigning mutable defaults. However, this is a bit overkill for that. 64 | # That being said, it would be nice to add some way to actually guard against that specific mutable default behavior. 65 | "RUF009", # function-call-in-dataclass-default-argument 66 | # Should probably fix this 67 | "RUF029", 68 | ] 69 | 70 | 71 | [lint.flake8-implicit-str-concat] 72 | # Found 3279 errors. 73 | # allow-multiline = false 74 | 75 | [lint.flake8-tidy-imports] 76 | ban-relative-imports = "all" 77 | 78 | [lint.flake8-tidy-imports.banned-api] 79 | # for use with another pr 80 | # "asyncio.create_task".msg = "Use `from chia.util.pit import pit` and `pit.create_task()`" 81 | 82 | [lint.isort] 83 | required-imports = ["from __future__ import annotations"] 84 | 85 | [lint.pylint] 86 | max-args = 5 87 | max-locals = 15 88 | max-returns = 6 89 | max-branches = 12 90 | max-statements = 50 91 | max-nested-blocks = 5 92 | max-public-methods = 20 93 | max-bool-expr = 5 94 | 95 | [lint.pyupgrade] 96 | keep-runtime-typing = true 97 | -------------------------------------------------------------------------------- /tests/cats/test_cat_command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import io 5 | 6 | import pytest 7 | from chia._tests.util.setup_nodes import SimulatorsAndWalletsServices 8 | from chia.types.blockchain_format.coin import Coin 9 | from chia.types.peer_info import PeerInfo 10 | from chia.util.bech32m import encode_puzzle_hash 11 | from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG 12 | from chia_rs.sized_bytes import bytes32 13 | from chia_rs.sized_ints import uint16, uint64 14 | 15 | from cats.cats import cmd_func 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_cat_mint( 20 | one_wallet_and_one_simulator_services: SimulatorsAndWalletsServices, 21 | ) -> None: 22 | # Wallet environment setup 23 | num_blocks = 1 24 | full_nodes, wallets, _bt = one_wallet_and_one_simulator_services 25 | full_node_api = full_nodes[0]._api 26 | full_node_server = full_node_api.full_node.server 27 | wallet_service_0 = wallets[0] 28 | wallet_node_0 = wallet_service_0._node 29 | wallet_0 = wallet_node_0.wallet_state_manager.main_wallet 30 | assert wallet_service_0.rpc_server is not None 31 | assert wallet_service_0.rpc_server.webserver is not None 32 | 33 | wallet_node_0.config["automatically_add_unknown_cats"] = True 34 | wallet_node_0.config["trusted_peers"] = { 35 | full_node_api.full_node.server.node_id.hex(): full_node_api.full_node.server.node_id.hex() 36 | } 37 | 38 | assert full_node_server._port is not None 39 | await wallet_node_0.server.start_client(PeerInfo("127.0.0.1", uint16(full_node_server._port)), None) 40 | await full_node_api.farm_blocks_to_wallet(count=num_blocks, wallet=wallet_0) 41 | await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=20) 42 | 43 | async with wallet_0.wallet_state_manager.new_action_scope(DEFAULT_TX_CONFIG, push=True) as action_scope: 44 | ph = await action_scope.get_puzzle_hash(wallet_0.wallet_state_manager) 45 | self_address = encode_puzzle_hash(ph, "xch") 46 | fingerprint = wallet_0.wallet_state_manager.get_master_private_key().get_g1().get_fingerprint() 47 | root_path = str(wallet_service_0.root_path) 48 | 49 | # Issuance parameters 50 | tail_as_hex = "80" 51 | args_to_curry = ("80",) 52 | expected_tail = "ff02ffff0180ffff04ffff0150ff018080" # opc "(a (q) (c (q . 80) 1))" 53 | solution = "9a7461696c20736f6c7574696f6e20666f72206361742074657374" # opc "'tail solution for cat test'" 54 | amount = 13 55 | fee = 100 # mojos 56 | 57 | f = io.StringIO() 58 | with contextlib.redirect_stdout(f): 59 | await cmd_func( 60 | tail_as_hex, 61 | args_to_curry, 62 | solution, 63 | self_address, 64 | amount, 65 | fee, 66 | [], 67 | None, 68 | [], 69 | fingerprint, 70 | signature=[], 71 | spend=[], 72 | as_bytes=False, 73 | select_coin=True, 74 | quiet=False, 75 | push=False, 76 | root_path=root_path, 77 | wallet_rpc_port=wallet_service_0.rpc_server.webserver.listen_port, 78 | ) 79 | 80 | expected_str_value = ( 81 | '{\n "amount": 250000000000,\n ' 82 | '"parent_coin_info": "0x27ae41e4649b934ca495991b7852b85500000000000000000000000000000002",\n ' 83 | '"puzzle_hash": "0x3ecfd2611925541707c96e689bd415f1991f018a5179d0a7072226d81453d377"\n}\n' 84 | "Name: 1ef743aa7bd56cec3a65115eb37b6e2b969377eca8c9099337381471efe26e78\n" 85 | ) 86 | 87 | assert f.getvalue() == expected_str_value 88 | f.truncate(0) 89 | 90 | with contextlib.redirect_stdout(f): 91 | await cmd_func( 92 | tail_as_hex, 93 | args_to_curry, 94 | solution, 95 | self_address, 96 | amount, 97 | fee, 98 | [], 99 | None, 100 | [], 101 | fingerprint, 102 | signature=[], 103 | spend=[], 104 | as_bytes=True, 105 | select_coin=False, 106 | quiet=True, 107 | push=False, 108 | root_path=root_path, 109 | wallet_rpc_port=wallet_service_0.rpc_server.webserver.listen_port, 110 | ) 111 | assert expected_tail in f.getvalue() 112 | assert solution in f.getvalue() 113 | 114 | f.truncate(0) 115 | 116 | with contextlib.redirect_stdout(f): 117 | await cmd_func( 118 | tail_as_hex, 119 | args_to_curry, 120 | solution, 121 | self_address, 122 | amount, 123 | fee, 124 | [], 125 | None, 126 | [], 127 | fingerprint, 128 | signature=[], 129 | spend=[], 130 | as_bytes=True, 131 | select_coin=False, 132 | quiet=True, 133 | push=True, 134 | root_path=root_path, 135 | wallet_rpc_port=wallet_service_0.rpc_server.webserver.listen_port, 136 | ) 137 | 138 | await full_node_api.process_coin_spends( 139 | coins={ 140 | Coin( 141 | bytes32.from_hexstr("1ef743aa7bd56cec3a65115eb37b6e2b969377eca8c9099337381471efe26e78"), 142 | bytes32.from_hexstr("bebd0c1c65d72e260ff7bef6edc93154568f699c18ced593b585d4a6d5c28ed2"), 143 | uint64(13), 144 | ) 145 | } 146 | ) 147 | await full_node_api.wait_for_wallet_synced(wallet_node=wallet_node_0, timeout=20) 148 | assert len(wallet_node_0.wallet_state_manager.wallets) == 2 149 | -------------------------------------------------------------------------------- /cats/secure_the_bag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import csv 4 | import re 5 | from collections.abc import Iterable 6 | from typing import Any 7 | 8 | import click 9 | from chia.types.blockchain_format.coin import Coin 10 | from chia.types.blockchain_format.program import Program 11 | from chia.types.coin_spend import make_spend 12 | from chia.types.condition_opcodes import ConditionOpcode 13 | from chia.util.bech32m import encode_puzzle_hash 14 | from chia.util.byte_types import hexstr_to_bytes 15 | from chia.wallet.cat_wallet.cat_utils import CAT_MOD, construct_cat_puzzle 16 | from chia_rs import CoinSpend 17 | from chia_rs.sized_bytes import bytes32 18 | from chia_rs.sized_ints import uint64 19 | from clvm_tools.binutils import assemble 20 | from clvm_tools.clvmc import compile_clvm_text 21 | 22 | # Fees spend asserts this. Message not required as inner puzzle contains hardcoded coin spends 23 | # and doesn't accept a solution. 24 | EMPTY_COIN_ANNOUNCEMENT = [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"] 25 | 26 | 27 | # The clvm loaders in this library automatically search for includable files in the directory './include' 28 | def append_include(search_paths: Iterable[str]) -> list[str]: 29 | if search_paths: 30 | search_list = list(search_paths) 31 | search_list.append("./include") 32 | return search_list 33 | else: 34 | return ["./include"] 35 | 36 | 37 | def parse_program(program: str | Program, include: Iterable[str] = []) -> Program: 38 | prog: Program 39 | if isinstance(program, Program): 40 | return program 41 | else: 42 | if "(" in program: # If it's raw clvm 43 | prog = Program.to(assemble(program)) 44 | elif "." not in program: # If it's a byte string 45 | prog = Program.from_bytes(hexstr_to_bytes(program)) 46 | else: # If it's a file 47 | with open(program) as file: 48 | filestring: str = file.read() 49 | if "(" in filestring: # If it's not compiled 50 | # TODO: This should probably be more robust 51 | if re.compile(r"\(mod\s").search(filestring): # If it's Chialisp 52 | prog = Program.to( 53 | compile_clvm_text(filestring, append_include(include)) # type: ignore[no-untyped-call] 54 | ) 55 | else: # If it's CLVM 56 | prog = Program.to(assemble(filestring)) 57 | else: # If it's serialized CLVM 58 | prog = Program.from_bytes(hexstr_to_bytes(filestring)) 59 | return prog 60 | 61 | 62 | class Target: 63 | puzzle_hash: bytes32 64 | amount: uint64 65 | 66 | def __init__(self, puzzle_hash: bytes32, amount: uint64) -> None: 67 | self.puzzle_hash = puzzle_hash 68 | self.amount = amount 69 | 70 | def create_coin_condition(self) -> list[Any]: 71 | return [ 72 | ConditionOpcode.CREATE_COIN, 73 | self.puzzle_hash, 74 | self.amount, 75 | [self.puzzle_hash], 76 | ] 77 | 78 | 79 | class TargetCoin: 80 | target: Target 81 | puzzle: Program 82 | puzzle_hash: bytes32 83 | amount: uint64 84 | 85 | def __init__(self, target: Target, puzzle: Program, amount: uint64) -> None: 86 | self.target = target 87 | self.puzzle = puzzle 88 | self.puzzle_hash = puzzle.get_tree_hash() 89 | self.amount = amount 90 | 91 | 92 | def batch_the_bag(targets: list[Target], leaf_width: int) -> list[list[Target]]: 93 | """ 94 | Batches the bag by leaf width. 95 | """ 96 | results = [] 97 | current_batch = [] 98 | 99 | for index, target in enumerate(targets): 100 | current_batch.append(target) 101 | 102 | if len(current_batch) == leaf_width or index == len(targets) - 1: 103 | results.append(current_batch) 104 | current_batch = [] 105 | 106 | return results 107 | 108 | 109 | def secure_the_bag( 110 | targets: list[Target], 111 | leaf_width: int, 112 | asset_id: bytes32 | None = None, 113 | parent_puzzle_lookup: dict[str, TargetCoin] = {}, 114 | ) -> tuple[bytes32, dict[str, TargetCoin]]: 115 | """ 116 | Calculates secure the bag root puzzle hash and provides parent puzzle reveal lookup table for spending. 117 | 118 | Secures bag of CATs if optional asset id is passed. 119 | """ 120 | 121 | if len(targets) == 1: 122 | return targets[0].puzzle_hash, parent_puzzle_lookup 123 | 124 | results: list[Target] = [] 125 | 126 | batched_targets = batch_the_bag(targets, leaf_width) 127 | batch_count = len(batched_targets) 128 | 129 | print(f"Batched the bag into {batch_count} batches") 130 | 131 | processed = 0 132 | 133 | for batch_targets in batched_targets: 134 | print(f"{round((processed / batch_count) * 100, 2)}% of the way through batches") 135 | 136 | list_of_conditions = [EMPTY_COIN_ANNOUNCEMENT] 137 | total_amount = 0 138 | 139 | print(f"Creating coin with {len(batch_targets)} targets") 140 | 141 | for target in batch_targets: 142 | list_of_conditions.append(target.create_coin_condition()) 143 | total_amount += target.amount 144 | 145 | puzzle = Program.to((1, list_of_conditions)) 146 | puzzle_hash = puzzle.get_tree_hash() 147 | amount = total_amount 148 | 149 | results.append(Target(puzzle_hash, uint64(amount))) 150 | 151 | if asset_id is not None: 152 | outer_puzzle = construct_cat_puzzle(CAT_MOD, asset_id, puzzle) 153 | 154 | for target in batch_targets: 155 | if asset_id is not None: 156 | target_outer_puzzle_hash = construct_cat_puzzle( 157 | CAT_MOD, asset_id, Program.to(target.puzzle_hash) 158 | ).get_tree_hash_precalc(target.puzzle_hash) 159 | parent_puzzle_lookup[target_outer_puzzle_hash.hex()] = TargetCoin(target, outer_puzzle, uint64(amount)) 160 | else: 161 | parent_puzzle_lookup[target.puzzle_hash.hex()] = TargetCoin(target, puzzle, uint64(amount)) 162 | 163 | processed += 1 164 | 165 | return secure_the_bag(results, leaf_width, asset_id, parent_puzzle_lookup) 166 | 167 | 168 | def parent_of_puzzle_hash( 169 | genesis_coin_name: bytes32, 170 | puzzle_hash: bytes32, 171 | parent_puzzle_lookup: dict[str, TargetCoin], 172 | ) -> tuple[CoinSpend | None, bytes32]: 173 | parent: TargetCoin | None = parent_puzzle_lookup.get(puzzle_hash.hex()) 174 | 175 | if parent is None: 176 | return None, genesis_coin_name 177 | 178 | # We need the parent of the parent in order to calculate the coin name 179 | _, parent_coin_info = parent_of_puzzle_hash(genesis_coin_name, parent.puzzle_hash, parent_puzzle_lookup) 180 | 181 | coin = Coin(parent_coin_info, parent.puzzle_hash, parent.amount) 182 | 183 | return make_spend(coin, parent.puzzle, Program.to([])), coin.name() 184 | 185 | 186 | def read_secure_the_bag_targets(secure_the_bag_targets_path: str, target_amount: int | None) -> list[Target]: 187 | """ 188 | Reads secure the bag targets file. Validates the net amount sent to targets is equal to the target amount. 189 | """ 190 | targets: list[Target] = [] 191 | 192 | with open(secure_the_bag_targets_path, newline="") as csvfile: 193 | reader = csv.reader(csvfile) 194 | for [ph, amount] in list(reader): 195 | targets.append(Target(bytes32.fromhex(ph), uint64(amount))) 196 | 197 | net_amount = sum([target.amount for target in targets]) 198 | 199 | if target_amount: 200 | if net_amount != target_amount: 201 | raise Exception(f"Net amount of targets not expected amount. Expected {target_amount} but got {net_amount}") 202 | 203 | return targets 204 | 205 | 206 | @click.command() 207 | @click.pass_context 208 | @click.option( 209 | "-l", 210 | "--tail", 211 | required=True, 212 | help="The TAIL program to launch this CAT with", 213 | ) 214 | @click.option( 215 | "-c", 216 | "--curry", 217 | multiple=True, 218 | help="An argument to curry into the TAIL", 219 | ) 220 | @click.option( 221 | "-a", 222 | "--amount", 223 | required=True, 224 | type=int, 225 | help="The amount to issue in mojos (regular XCH will be used to fund this)", 226 | ) 227 | @click.option( 228 | "-stbtp", 229 | "--secure-the-bag-targets-path", 230 | help="Path to CSV file containing targets of secure the bag (inner puzzle hash + amount)", 231 | ) 232 | @click.option( 233 | "-lw", 234 | "--leaf-width", 235 | required=True, 236 | default=100, 237 | show_default=True, 238 | help="Secure the bag leaf width", 239 | ) 240 | @click.option( 241 | "-pr", 242 | "--prefix", 243 | required=True, 244 | default="xch", 245 | show_default=True, 246 | help="Address prefix", 247 | ) 248 | def cli( 249 | ctx: click.Context, 250 | tail: str, 251 | curry: tuple[str], 252 | amount: int, 253 | secure_the_bag_targets_path: str, 254 | leaf_width: int, 255 | prefix: str, 256 | ) -> None: 257 | ctx.ensure_object(dict) 258 | 259 | parsed_tail: Program = parse_program(tail) 260 | curried_args = [assemble(arg) for arg in curry] 261 | 262 | # Construct the TAIL 263 | if len(curried_args) > 0: 264 | curried_tail = parsed_tail.curry(*curried_args) 265 | else: 266 | curried_tail = parsed_tail 267 | 268 | targets = read_secure_the_bag_targets(secure_the_bag_targets_path, amount) 269 | root_puzzle_hash, _ = secure_the_bag(targets, leaf_width, None) 270 | outer_root_puzzle_hash = construct_cat_puzzle( 271 | CAT_MOD, curried_tail.get_tree_hash(), Program.to(root_puzzle_hash) 272 | ).get_tree_hash_precalc(root_puzzle_hash) 273 | 274 | print(f"Secure the bag root puzzle hash: {outer_root_puzzle_hash}") 275 | 276 | address = encode_puzzle_hash(root_puzzle_hash, prefix) 277 | 278 | print(f"Secure the bag root address: {address}") 279 | 280 | 281 | def main() -> None: 282 | cli() 283 | 284 | 285 | if __name__ == "__main__": 286 | main() 287 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Chia Network Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /cats/cats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import json 5 | import re 6 | from collections.abc import AsyncIterator, Iterable 7 | from contextlib import asynccontextmanager 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | import click 12 | from chia.cmds.cmds_util import get_wallet_client 13 | from chia.types.blockchain_format.program import Program 14 | from chia.util.bech32m import decode_puzzle_hash 15 | from chia.util.byte_types import hexstr_to_bytes 16 | from chia.util.config import load_config 17 | from chia.util.default_root import DEFAULT_ROOT_PATH 18 | from chia.wallet.cat_wallet.cat_utils import ( 19 | CAT_MOD, 20 | SpendableCAT, 21 | construct_cat_puzzle, 22 | unsigned_spend_bundle_for_spendable_cats, 23 | ) 24 | from chia.wallet.transaction_record import TransactionRecord 25 | from chia.wallet.util.tx_config import DEFAULT_TX_CONFIG 26 | from chia.wallet.vc_wallet.cr_cat_drivers import ProofsChecker, construct_cr_layer 27 | from chia.wallet.wallet_request_types import PushTX 28 | from chia.wallet.wallet_rpc_client import WalletRpcClient 29 | from chia.wallet.wallet_spend_bundle import WalletSpendBundle 30 | from chia_rs import AugSchemeMPL, G2Element 31 | from chia_rs.sized_bytes import bytes32 32 | from chia_rs.sized_ints import uint64 33 | from clvm_tools.binutils import assemble 34 | from clvm_tools.clvmc import compile_clvm_text 35 | 36 | 37 | # Loading the client requires the standard chia root directory configuration that all of the chia commands rely on 38 | @asynccontextmanager 39 | async def get_context_manager( 40 | wallet_rpc_port: int | None, fingerprint: int, root_path: Path 41 | ) -> AsyncIterator[tuple[WalletRpcClient, int, dict[str, Any]]]: 42 | config = load_config(root_path, "config.yaml") 43 | wallet_rpc_port = config["wallet"]["rpc_port"] if wallet_rpc_port is None else wallet_rpc_port 44 | async with get_wallet_client(root_path=root_path, wallet_rpc_port=wallet_rpc_port, fingerprint=fingerprint) as args: 45 | yield args 46 | 47 | 48 | async def get_signed_tx( 49 | wallet_rpc_port: int | None, 50 | fingerprint: int, 51 | ph: bytes32, 52 | amt: uint64, 53 | fee: uint64, 54 | root_path: Path, 55 | ) -> TransactionRecord: 56 | async with get_context_manager(wallet_rpc_port, fingerprint, root_path) as client_etc: 57 | wallet_client, _, _ = client_etc 58 | if wallet_client is None: 59 | raise ValueError("Error getting wallet client. Make sure wallet is running.") 60 | signed_tx = await wallet_client.create_signed_transactions( 61 | [{"puzzle_hash": ph, "amount": amt}], 62 | DEFAULT_TX_CONFIG, 63 | fee=fee, # TODO: no default tx config 64 | ) 65 | return signed_tx.signed_tx 66 | 67 | 68 | async def push_tx( 69 | wallet_rpc_port: int | None, 70 | fingerprint: int, 71 | bundle: WalletSpendBundle, 72 | root_path: Path, 73 | ) -> Any: 74 | async with get_context_manager(wallet_rpc_port, fingerprint, root_path) as client_etc: 75 | wallet_client, _, _ = client_etc 76 | if wallet_client is None: 77 | raise ValueError("Error getting wallet client. Make sure wallet is running.") 78 | return await wallet_client.push_tx(PushTX(bundle)) 79 | 80 | 81 | # The clvm loaders in this library automatically search for includable files in the directory './include' 82 | def append_include(search_paths: Iterable[str]) -> list[str]: 83 | if search_paths: 84 | search_list = list(search_paths) 85 | search_list.append("./include") 86 | return search_list 87 | else: 88 | return ["./include"] 89 | 90 | 91 | def parse_program(program: str | Program, include: Iterable[str] = []) -> Program: 92 | prog: Program 93 | if isinstance(program, Program): 94 | return program 95 | else: 96 | if "(" in program: # If it's raw clvm 97 | prog = Program.to(assemble(program)) 98 | elif "." not in program: # If it's a byte string 99 | prog = Program.from_bytes(hexstr_to_bytes(program)) 100 | else: # If it's a file 101 | with open(program) as file: 102 | filestring: str = file.read() 103 | if "(" in filestring: # If it's not compiled 104 | # TODO: This should probably be more robust 105 | if re.compile(r"\(mod\s").search(filestring): # If it's Chialisp 106 | prog = Program.to( 107 | compile_clvm_text(filestring, append_include(include)) # type: ignore[no-untyped-call] 108 | ) 109 | else: # If it's CLVM 110 | prog = Program.to(assemble(filestring)) 111 | else: # If it's serialized CLVM 112 | prog = Program.from_bytes(hexstr_to_bytes(filestring)) 113 | return prog 114 | 115 | 116 | CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) 117 | 118 | 119 | @click.command() 120 | @click.pass_context 121 | @click.option( 122 | "-l", 123 | "--tail", 124 | required=True, 125 | help="The TAIL program to launch this CAT with", 126 | ) 127 | @click.option( 128 | "-c", 129 | "--curry", 130 | multiple=True, 131 | help="An argument to curry into the TAIL", 132 | ) 133 | @click.option( 134 | "-s", 135 | "--solution", 136 | required=True, 137 | default="()", 138 | show_default=True, 139 | help="The solution to the TAIL program", 140 | ) 141 | @click.option( 142 | "-t", 143 | "--send-to", 144 | required=True, 145 | help="The address these CATs will appear at once they are issued", 146 | ) 147 | @click.option( 148 | "-a", 149 | "--amount", 150 | required=True, 151 | type=int, 152 | help="The amount to issue in mojos (regular XCH will be used to fund this)", 153 | ) 154 | @click.option( 155 | "-m", 156 | "--fee", 157 | required=True, 158 | default=0, 159 | show_default=True, 160 | help="The fees for the transaction, in mojos", 161 | ) 162 | @click.option( 163 | "-d", 164 | "--authorized-provider", 165 | type=str, 166 | multiple=True, 167 | help=( 168 | "A trusted DID that can issue VCs that are allowed to trade the CAT. Specifying this option will make the CAT " 169 | "a CR (credential restricted) CAT." 170 | ), 171 | ) 172 | @click.option( 173 | "-r", 174 | "--proofs-checker", 175 | type=str, 176 | default=None, 177 | show_default=False, 178 | help=( 179 | "The program that checks the proofs of a VC for a CR-CAT. " 180 | "Specifying this option requires a value for --authorized-providers." 181 | ), 182 | ) 183 | @click.option( 184 | "-v", 185 | "--cr-flag", 186 | type=str, 187 | multiple=True, 188 | help=( 189 | "Specify a list of flags to check a VC for in order to authorize this CR-CAT. " 190 | "Specifying this option requires a value for --authorized-providers. " 191 | "Cannot be used if a custom --proofs-checker is specified." 192 | ), 193 | ) 194 | @click.option( 195 | "-f", 196 | "--fingerprint", 197 | type=int, 198 | help="The wallet fingerprint to use as funds", 199 | ) 200 | @click.option( 201 | "-sig", 202 | "--signature", 203 | multiple=True, 204 | help="A signature to aggregate with the transaction", 205 | ) 206 | @click.option( 207 | "-as", 208 | "--spend", 209 | multiple=True, 210 | help="An additional spend to aggregate with the transaction", 211 | ) 212 | @click.option( 213 | "-b", 214 | "--as-bytes", 215 | is_flag=True, 216 | help="Output the spend bundle as a sequence of bytes instead of JSON", 217 | ) 218 | @click.option( 219 | "-sc", 220 | "--select-coin", 221 | is_flag=True, 222 | help="Stop the process once a coin from the wallet has been selected and return the coin", 223 | ) 224 | @click.option( 225 | "-q", 226 | "--quiet", 227 | is_flag=True, 228 | help="Quiet mode will not ask to push transaction to the network", 229 | ) 230 | @click.option( 231 | "-p", 232 | "--push", 233 | is_flag=True, 234 | help="Automatically push transaction to the network in quiet mode", 235 | ) 236 | @click.option( 237 | "--root-path", 238 | default=DEFAULT_ROOT_PATH, 239 | help="The root folder where the config lies", 240 | type=click.Path(), 241 | show_default=True, 242 | ) 243 | @click.option( 244 | "--wallet-rpc-port", 245 | default=None, 246 | help="The RPC port the wallet service is running on", 247 | type=int, 248 | ) 249 | def cli( 250 | ctx: click.Context, 251 | tail: str, 252 | curry: tuple[str, ...], 253 | solution: str, 254 | send_to: str, 255 | amount: int, 256 | fee: int, 257 | authorized_provider: list[str], 258 | proofs_checker: str | None, 259 | cr_flag: list[str], 260 | fingerprint: int, 261 | signature: list[str], 262 | spend: list[str], 263 | as_bytes: bool, 264 | select_coin: bool, 265 | quiet: bool, 266 | push: bool, 267 | root_path: str, 268 | wallet_rpc_port: int | None, 269 | ) -> None: 270 | ctx.ensure_object(dict) 271 | 272 | asyncio.run( 273 | cmd_func( 274 | tail, 275 | curry, 276 | solution, 277 | send_to, 278 | amount, 279 | fee, 280 | authorized_provider, 281 | proofs_checker, 282 | cr_flag, 283 | fingerprint, 284 | signature, 285 | spend, 286 | as_bytes, 287 | select_coin, 288 | quiet, 289 | push, 290 | root_path, 291 | wallet_rpc_port, 292 | ) 293 | ) 294 | 295 | 296 | async def cmd_func( 297 | tail: str, 298 | curry: tuple[str, ...], 299 | solution: str, 300 | send_to: str, 301 | amount: int, 302 | fee: int, 303 | authorized_provider: list[str], 304 | proofs_checker: str | None, 305 | cr_flag: list[str], 306 | fingerprint: int, 307 | signature: list[str], 308 | spend: list[str], 309 | as_bytes: bool, 310 | select_coin: bool, 311 | quiet: bool, 312 | push: bool, 313 | root_path: str, 314 | wallet_rpc_port: int | None, 315 | ) -> None: 316 | parsed_tail: Program = parse_program(tail) 317 | curried_args = [assemble(arg) for arg in curry] 318 | parsed_solution: Program = parse_program(solution) 319 | inner_address = decode_puzzle_hash(send_to) 320 | address = inner_address 321 | 322 | # Potentially wrap address in CR layer 323 | extra_conditions: list[Program] = [] 324 | if len(authorized_provider) > 0: 325 | ap_bytes = [bytes32(decode_puzzle_hash(ap)) for ap in authorized_provider] 326 | # proofs_checker: Program 327 | if proofs_checker is not None: 328 | if len(cr_flag) > 0: 329 | print("Cannot specify values for both --proofs-checker and --cr-flag") 330 | return 331 | parsed_proofs_checker = parse_program(proofs_checker) 332 | elif len(cr_flag) > 0: 333 | parsed_proofs_checker = ProofsChecker(list(cr_flag)).as_program() 334 | else: 335 | print("Must specify either --proofs-checker or --cr-flag if specifying --authorized-provider") 336 | return 337 | extra_conditions.append(Program.to([1, inner_address, ap_bytes, parsed_proofs_checker])) 338 | address = construct_cr_layer( 339 | ap_bytes, 340 | parsed_proofs_checker, 341 | inner_address, # type: ignore 342 | ).get_tree_hash_precalc(inner_address) 343 | 344 | elif proofs_checker is not None or len(cr_flag) > 0: 345 | print("Cannot specify --proofs-checker or --cr-flag without values for --authorized-provider") 346 | return 347 | 348 | aggregated_signature = G2Element() 349 | for sig in signature: 350 | aggregated_signature = AugSchemeMPL.aggregate( 351 | [aggregated_signature, G2Element.from_bytes(hexstr_to_bytes(sig))] 352 | ) 353 | 354 | aggregated_spend = WalletSpendBundle([], G2Element()) 355 | for bundle in spend: 356 | aggregated_spend = WalletSpendBundle.aggregate( 357 | [aggregated_spend, WalletSpendBundle.from_bytes(hexstr_to_bytes(bundle))] 358 | ) 359 | 360 | # Construct the TAIL 361 | if len(curried_args) > 0: 362 | curried_tail = parsed_tail.curry(*curried_args) 363 | else: 364 | curried_tail = parsed_tail 365 | 366 | # Construct the intermediate puzzle 367 | p2_puzzle = Program.to( 368 | ( 369 | 1, 370 | [ 371 | [51, 0, -113, curried_tail, parsed_solution], 372 | [51, address, amount, [inner_address]], 373 | *extra_conditions, 374 | ], 375 | ) 376 | ) 377 | 378 | # Wrap the intermediate puzzle in a CAT wrapper 379 | cat_puzzle = construct_cat_puzzle(CAT_MOD, curried_tail.get_tree_hash(), p2_puzzle) 380 | cat_ph = cat_puzzle.get_tree_hash() 381 | 382 | # Get a signed transaction from the wallet 383 | signed_tx = await get_signed_tx( 384 | wallet_rpc_port, 385 | fingerprint, 386 | cat_ph, 387 | uint64(amount), 388 | uint64(fee), 389 | Path(root_path), 390 | ) 391 | if signed_tx.spend_bundle is None: 392 | raise ValueError("Error creating signed transaction") 393 | eve_coin = next(filter(lambda c: c.puzzle_hash == cat_ph, signed_tx.spend_bundle.additions())) 394 | 395 | # This is where we exit if we're only looking for the selected coin 396 | if select_coin: 397 | primary_coin = next( 398 | filter( 399 | lambda c: c.name() == eve_coin.parent_coin_info, 400 | signed_tx.spend_bundle.removals(), 401 | ) 402 | ) 403 | print(json.dumps(primary_coin.to_json_dict(), sort_keys=True, indent=4)) 404 | print(f"Name: {primary_coin.name().hex()}") 405 | return 406 | 407 | # Create the CAT spend 408 | spendable_eve = SpendableCAT( 409 | eve_coin, 410 | curried_tail.get_tree_hash(), 411 | p2_puzzle, 412 | Program.to([]), 413 | limitations_solution=parsed_solution, 414 | limitations_program_reveal=curried_tail, 415 | ) 416 | eve_spend = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, [spendable_eve]) 417 | 418 | # Aggregate everything together 419 | final_bundle = WalletSpendBundle.aggregate( 420 | [ 421 | signed_tx.spend_bundle, 422 | eve_spend, 423 | aggregated_spend, 424 | WalletSpendBundle([], aggregated_signature), 425 | ] 426 | ) 427 | 428 | if as_bytes: 429 | final_bundle_dump = bytes(final_bundle).hex() 430 | else: 431 | final_bundle_dump = json.dumps(final_bundle.to_json_dict(), sort_keys=True, indent=4) 432 | 433 | confirmation = push 434 | 435 | if not quiet: 436 | confirmation = input("The transaction has been created, would you like to push it to the network? (Y/N)") in { 437 | "y", 438 | "Y", 439 | "yes", 440 | "Yes", 441 | } 442 | if confirmation: 443 | try: 444 | await push_tx(wallet_rpc_port, fingerprint, final_bundle, Path(root_path)) 445 | except Exception as e: 446 | print(f"Error pushing transaction: {e}") 447 | return 448 | 449 | print("Successfully pushed the transaction to the network") 450 | 451 | print(f"Asset ID: {curried_tail.get_tree_hash().hex()}") 452 | print(f"Eve Coin ID: {eve_coin.name().hex()}") 453 | if not confirmation: 454 | print(f"Spend Bundle: {final_bundle_dump}") 455 | 456 | 457 | def main() -> None: 458 | cli() 459 | 460 | 461 | if __name__ == "__main__": 462 | main() 463 | -------------------------------------------------------------------------------- /tests/cats/test_secure_the_bag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from chia.types.blockchain_format.program import Program 5 | from chia.types.condition_opcodes import ConditionOpcode 6 | from chia.util.hash import std_hash 7 | from chia.wallet.cat_wallet.cat_utils import CAT_MOD, construct_cat_puzzle 8 | from chia_rs.sized_bytes import bytes32 9 | from chia_rs.sized_ints import uint64 10 | from clvm.casts import int_to_bytes 11 | 12 | from cats.secure_the_bag import ( 13 | Target, 14 | batch_the_bag, 15 | parent_of_puzzle_hash, 16 | read_secure_the_bag_targets, 17 | secure_the_bag, 18 | ) 19 | 20 | 21 | def test_batch_the_bag() -> None: 22 | targets = [ 23 | Target( 24 | bytes32.fromhex("4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba"), 25 | uint64(10000000000000000), 26 | ), 27 | Target( 28 | bytes32.fromhex("f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c"), 29 | uint64(32100000000), 30 | ), 31 | Target( 32 | bytes32.fromhex("7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4"), 33 | uint64(10000000000000000), 34 | ), 35 | ] 36 | results = batch_the_bag(targets, 2) 37 | 38 | assert len(results) == 2 39 | assert len(results[0]) == 2 40 | assert len(results[1]) == 1 41 | 42 | assert results[0][0].puzzle_hash == bytes32.fromhex( 43 | "4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba" 44 | ) 45 | assert results[0][0].amount == uint64(10000000000000000) 46 | 47 | assert results[0][1].puzzle_hash == bytes32.fromhex( 48 | "f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c" 49 | ) 50 | assert results[0][1].amount == uint64(32100000000) 51 | 52 | assert results[1][0].puzzle_hash == bytes32.fromhex( 53 | "7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4" 54 | ) 55 | assert results[1][0].amount == uint64(10000000000000000) 56 | 57 | 58 | def test_secure_the_bag() -> None: 59 | target_1_puzzle_hash = bytes32.fromhex("4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba") 60 | target_1_amount = uint64(10000000000000000) 61 | target_2_puzzle_hash = bytes32.fromhex("f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c") 62 | target_2_amount = uint64(32100000000) 63 | target_3_puzzle_hash = bytes32.fromhex("7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4") 64 | target_3_amount = uint64(10000000000000000) 65 | 66 | targets = [ 67 | Target(target_1_puzzle_hash, target_1_amount), 68 | Target(target_2_puzzle_hash, target_2_amount), 69 | Target(target_3_puzzle_hash, target_3_amount), 70 | ] 71 | root_hash, parent_puzzle_lookup = secure_the_bag(targets, 2) 72 | 73 | # Calculates correct root hash 74 | assert root_hash.hex() == "2a21783e7b1f5ab453e45315a35c1e02c4dd7234f3f41d2d64541819431d049d" 75 | 76 | node_1_puzzle_hash = bytes32.fromhex("f2cff3b95ddbaa61a214220d67a20901c584ff16df12ec769844f391d513835c") 77 | node_2_puzzle_hash = bytes32.fromhex("f45579725598a28c5572d8c534be3edf095830de0f984f0eb3d9bb251c71134b") 78 | 79 | root_puzzle = Program.to( 80 | ( 81 | 1, 82 | [ 83 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 84 | [ 85 | ConditionOpcode.CREATE_COIN, 86 | node_1_puzzle_hash, 87 | uint64(10000032100000000), 88 | [node_1_puzzle_hash], 89 | ], 90 | [ 91 | ConditionOpcode.CREATE_COIN, 92 | node_2_puzzle_hash, 93 | uint64(10000000000000000), 94 | [node_2_puzzle_hash], 95 | ], 96 | ], 97 | ) 98 | ) 99 | 100 | # Puzzle reveal for root hash is correct 101 | assert root_puzzle.get_tree_hash().hex() == root_hash.hex() 102 | 103 | r = root_puzzle.run(0) 104 | 105 | expected_result = Program.to( 106 | [ 107 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 108 | [ 109 | ConditionOpcode.CREATE_COIN, 110 | node_1_puzzle_hash, 111 | uint64(10000032100000000), 112 | [node_1_puzzle_hash], 113 | ], 114 | [ 115 | ConditionOpcode.CREATE_COIN, 116 | node_2_puzzle_hash, 117 | uint64(10000000000000000), 118 | [node_2_puzzle_hash], 119 | ], 120 | ] 121 | ) 122 | 123 | # Result of running root is correct 124 | assert r.get_tree_hash().hex() == expected_result.get_tree_hash().hex() 125 | 126 | node_1_puzzle = Program.to( 127 | ( 128 | 1, 129 | [ 130 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 131 | [ 132 | ConditionOpcode.CREATE_COIN, 133 | target_1_puzzle_hash, 134 | target_1_amount, 135 | [target_1_puzzle_hash], 136 | ], 137 | [ 138 | ConditionOpcode.CREATE_COIN, 139 | target_2_puzzle_hash, 140 | target_2_amount, 141 | [target_2_puzzle_hash], 142 | ], 143 | ], 144 | ) 145 | ) 146 | 147 | # Puzzle reveal for node 1 is correct 148 | assert node_1_puzzle.get_tree_hash().hex() == node_1_puzzle_hash.hex() 149 | 150 | r = node_1_puzzle.run(0) 151 | 152 | expected_result = Program.to( 153 | [ 154 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 155 | [ 156 | ConditionOpcode.CREATE_COIN, 157 | target_1_puzzle_hash, 158 | target_1_amount, 159 | [target_1_puzzle_hash], 160 | ], 161 | [ 162 | ConditionOpcode.CREATE_COIN, 163 | target_2_puzzle_hash, 164 | target_2_amount, 165 | [target_2_puzzle_hash], 166 | ], 167 | ] 168 | ) 169 | 170 | # Result of running node 1 is correct 171 | assert r.get_tree_hash().hex() == expected_result.get_tree_hash().hex() 172 | 173 | node_2_puzzle = Program.to( 174 | ( 175 | 1, 176 | [ 177 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 178 | [ 179 | ConditionOpcode.CREATE_COIN, 180 | target_3_puzzle_hash, 181 | target_3_amount, 182 | [target_3_puzzle_hash], 183 | ], 184 | ], 185 | ) 186 | ) 187 | 188 | # Puzzle reveal for node 2 is correct 189 | assert node_2_puzzle.get_tree_hash().hex() == node_2_puzzle_hash.hex() 190 | 191 | r = node_2_puzzle.run(0) 192 | 193 | expected_result = Program.to( 194 | [ 195 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 196 | [ 197 | ConditionOpcode.CREATE_COIN, 198 | target_3_puzzle_hash, 199 | target_3_amount, 200 | [target_3_puzzle_hash], 201 | ], 202 | ] 203 | ) 204 | 205 | # Result of running node 2 is correct 206 | assert r.get_tree_hash().hex() == expected_result.get_tree_hash().hex() 207 | 208 | # Parent puzzle lookup (used for puzzle reveals) 209 | 210 | puzzle_create_target_1 = parent_puzzle_lookup.get(target_1_puzzle_hash.hex()) 211 | puzzle_create_target_2 = parent_puzzle_lookup.get(target_2_puzzle_hash.hex()) 212 | puzzle_create_target_3 = parent_puzzle_lookup.get(target_3_puzzle_hash.hex()) 213 | 214 | assert puzzle_create_target_1 is not None 215 | assert puzzle_create_target_2 is not None 216 | assert puzzle_create_target_3 is not None 217 | 218 | # Targets 1 & 2 are created by spending node 1 219 | assert puzzle_create_target_1.puzzle.get_tree_hash().hex() == node_1_puzzle_hash.hex() 220 | assert puzzle_create_target_2.puzzle.get_tree_hash().hex() == node_1_puzzle_hash.hex() 221 | 222 | # Target 3 is created by spending node 2 223 | assert puzzle_create_target_3.puzzle.get_tree_hash().hex() == node_2_puzzle_hash.hex() 224 | 225 | puzzle_create_node_1 = parent_puzzle_lookup.get(node_1_puzzle_hash.hex()) 226 | puzzle_create_node_2 = parent_puzzle_lookup.get(node_2_puzzle_hash.hex()) 227 | 228 | assert puzzle_create_node_1 is not None 229 | assert puzzle_create_node_2 is not None 230 | 231 | # Nodes 1 & 2 are created by spending root 232 | assert puzzle_create_node_1.puzzle.get_tree_hash().hex() == root_hash.hex() 233 | assert puzzle_create_node_2.puzzle.get_tree_hash().hex() == root_hash.hex() 234 | 235 | 236 | def test_secure_bag_of_cats() -> None: 237 | asset_id = bytes32.fromhex("6d95dae356e32a71db5ddcb42224754a02524c615c5fc35f568c2af04774e589") 238 | target_1_inner_puzzle_hash = bytes32.fromhex("4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba") 239 | target_1_outer_puzzle_hash = construct_cat_puzzle( 240 | CAT_MOD, asset_id, Program.to(target_1_inner_puzzle_hash) 241 | ).get_tree_hash_precalc(target_1_inner_puzzle_hash) 242 | target_1_amount = uint64(10000000000000000) 243 | target_2_inner_puzzle_hash = bytes32.fromhex("f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c") 244 | target_2_outer_puzzle_hash = construct_cat_puzzle( 245 | CAT_MOD, asset_id, Program.to(target_2_inner_puzzle_hash) 246 | ).get_tree_hash_precalc(target_2_inner_puzzle_hash) 247 | target_2_amount = uint64(32100000000) 248 | target_3_inner_puzzle_hash = bytes32.fromhex("7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4") 249 | target_3_outer_puzzle_hash = construct_cat_puzzle( 250 | CAT_MOD, asset_id, Program.to(target_3_inner_puzzle_hash) 251 | ).get_tree_hash_precalc(target_3_inner_puzzle_hash) 252 | target_3_amount = uint64(10000000000000000) 253 | 254 | targets = [ 255 | Target(target_1_inner_puzzle_hash, target_1_amount), 256 | Target(target_2_inner_puzzle_hash, target_2_amount), 257 | Target(target_3_inner_puzzle_hash, target_3_amount), 258 | ] 259 | root_hash, parent_puzzle_lookup = secure_the_bag(targets, 2, asset_id) 260 | 261 | # Calculates correct root hash 262 | assert root_hash.hex() == "2a21783e7b1f5ab453e45315a35c1e02c4dd7234f3f41d2d64541819431d049d" 263 | 264 | node_1_inner_puzzle_hash = bytes32.fromhex("f2cff3b95ddbaa61a214220d67a20901c584ff16df12ec769844f391d513835c") 265 | node_1_outer_puzzle_hash = construct_cat_puzzle( 266 | CAT_MOD, asset_id, Program.to(node_1_inner_puzzle_hash) 267 | ).get_tree_hash_precalc(node_1_inner_puzzle_hash) 268 | node_2_inner_puzzle_hash = bytes32.fromhex("f45579725598a28c5572d8c534be3edf095830de0f984f0eb3d9bb251c71134b") 269 | node_2_outer_puzzle_hash = construct_cat_puzzle( 270 | CAT_MOD, asset_id, Program.to(node_2_inner_puzzle_hash) 271 | ).get_tree_hash_precalc(node_2_inner_puzzle_hash) 272 | 273 | root_puzzle = Program.to( 274 | ( 275 | 1, 276 | [ 277 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 278 | [ 279 | ConditionOpcode.CREATE_COIN, 280 | node_1_inner_puzzle_hash, 281 | uint64(10000032100000000), 282 | [node_1_inner_puzzle_hash], 283 | ], 284 | [ 285 | ConditionOpcode.CREATE_COIN, 286 | node_2_inner_puzzle_hash, 287 | uint64(10000000000000000), 288 | [node_2_inner_puzzle_hash], 289 | ], 290 | ], 291 | ) 292 | ) 293 | 294 | # Puzzle reveal for root hash is correct 295 | assert root_puzzle.get_tree_hash().hex() == root_hash.hex() 296 | 297 | node_1_puzzle = Program.to( 298 | ( 299 | 1, 300 | [ 301 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 302 | [ 303 | ConditionOpcode.CREATE_COIN, 304 | target_1_inner_puzzle_hash, 305 | target_1_amount, 306 | [target_1_inner_puzzle_hash], 307 | ], 308 | [ 309 | ConditionOpcode.CREATE_COIN, 310 | target_2_inner_puzzle_hash, 311 | target_2_amount, 312 | [target_2_inner_puzzle_hash], 313 | ], 314 | ], 315 | ) 316 | ) 317 | 318 | # Puzzle reveal for node 1 is correct 319 | assert node_1_puzzle.get_tree_hash().hex() == node_1_inner_puzzle_hash.hex() 320 | 321 | node_2_puzzle = Program.to( 322 | ( 323 | 1, 324 | [ 325 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 326 | [ 327 | ConditionOpcode.CREATE_COIN, 328 | target_3_inner_puzzle_hash, 329 | target_3_amount, 330 | [target_3_inner_puzzle_hash], 331 | ], 332 | ], 333 | ) 334 | ) 335 | 336 | # Puzzle reveal for node 2 is correct 337 | assert node_2_puzzle.get_tree_hash().hex() == node_2_inner_puzzle_hash.hex() 338 | 339 | # Parent puzzle lookup (used for puzzle reveals) 340 | puzzle_create_target_1 = parent_puzzle_lookup.get(target_1_outer_puzzle_hash.hex()) 341 | puzzle_create_target_2 = parent_puzzle_lookup.get(target_2_outer_puzzle_hash.hex()) 342 | puzzle_create_target_3 = parent_puzzle_lookup.get(target_3_outer_puzzle_hash.hex()) 343 | 344 | assert puzzle_create_target_1 is not None 345 | assert puzzle_create_target_2 is not None 346 | assert puzzle_create_target_3 is not None 347 | 348 | # Targets 1 & 2 are created by spending node 1 349 | assert puzzle_create_target_1.puzzle.get_tree_hash().hex() == node_1_outer_puzzle_hash.hex() 350 | assert puzzle_create_target_2.puzzle.get_tree_hash().hex() == node_1_outer_puzzle_hash.hex() 351 | 352 | # Target 3 is created by spending node 2 353 | assert puzzle_create_target_3.puzzle.get_tree_hash().hex() == node_2_outer_puzzle_hash.hex() 354 | 355 | puzzle_create_node_1 = parent_puzzle_lookup.get(node_1_outer_puzzle_hash.hex()) 356 | puzzle_create_node_2 = parent_puzzle_lookup.get(node_2_outer_puzzle_hash.hex()) 357 | 358 | assert puzzle_create_node_1 is not None 359 | assert puzzle_create_node_2 is not None 360 | 361 | # Nodes 1 & 2 are created by spending root 362 | assert ( 363 | puzzle_create_node_1.puzzle.get_tree_hash().hex() 364 | == construct_cat_puzzle(CAT_MOD, asset_id, root_puzzle).get_tree_hash().hex() 365 | ) 366 | assert ( 367 | puzzle_create_node_2.puzzle.get_tree_hash().hex() 368 | == construct_cat_puzzle(CAT_MOD, asset_id, root_puzzle).get_tree_hash().hex() 369 | ) 370 | 371 | 372 | def test_parent_of_puzzle_hash() -> None: 373 | target_1_puzzle_hash = bytes32.fromhex("4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba") 374 | target_1_amount = uint64(10000000000000000) 375 | target_2_puzzle_hash = bytes32.fromhex("f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c") 376 | target_2_amount = uint64(32100000000) 377 | target_3_puzzle_hash = bytes32.fromhex("7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4") 378 | target_3_amount = uint64(10000000000000000) 379 | 380 | targets = [ 381 | Target(target_1_puzzle_hash, target_1_amount), 382 | Target(target_2_puzzle_hash, target_2_amount), 383 | Target(target_3_puzzle_hash, target_3_amount), 384 | ] 385 | _, parent_puzzle_lookup = secure_the_bag(targets, 2) 386 | 387 | genesis_coin_name = bytes32.fromhex("2676b64fab1f562cc4788cb2a9dbbe31da09da9cc23118dfccf6ad741d652328") 388 | expected_node_1_coin_name = bytes32.fromhex("32d17491d882934307218e5581b1de2d4eb27905c654afed0f0c3f8b44aa84eb") 389 | expected_root_coin_name = bytes32.fromhex("f3153d27c1d14581971203f10082fa2db2fbc0fd786a9b210e43f227eca499b5") 390 | 391 | coin_spend, coin_name = parent_of_puzzle_hash(genesis_coin_name, target_1_puzzle_hash, parent_puzzle_lookup) 392 | 393 | # Coin name of node 1 394 | assert coin_spend is not None 395 | assert coin_spend.coin.name() == expected_node_1_coin_name 396 | assert coin_name == expected_node_1_coin_name 397 | 398 | pp = parent_puzzle_lookup.get(target_1_puzzle_hash.hex()) 399 | assert pp is not None 400 | node_1_puzzle_hash = pp.puzzle_hash 401 | 402 | coin_spend, coin_name = parent_of_puzzle_hash(genesis_coin_name, node_1_puzzle_hash, parent_puzzle_lookup) 403 | 404 | # Coin name of root 405 | assert coin_spend is not None 406 | assert coin_spend.coin.name() == expected_root_coin_name 407 | assert coin_name == expected_root_coin_name 408 | pp = parent_puzzle_lookup.get(node_1_puzzle_hash.hex()) 409 | assert pp is not None 410 | root_puzzle_hash = pp.puzzle_hash 411 | 412 | coin_spend, puzzle_hash = parent_of_puzzle_hash(genesis_coin_name, root_puzzle_hash, parent_puzzle_lookup) 413 | 414 | # Genesis 415 | assert coin_spend is None 416 | assert puzzle_hash == bytes32.fromhex("2676b64fab1f562cc4788cb2a9dbbe31da09da9cc23118dfccf6ad741d652328") 417 | 418 | # Confirm expected root coin name is correct 419 | root_coin_name = std_hash( 420 | genesis_coin_name + root_puzzle_hash + int_to_bytes(target_1_amount + target_2_amount + target_3_amount) 421 | ) 422 | 423 | assert root_coin_name == expected_root_coin_name 424 | 425 | node_1_puzzle = Program.to( 426 | ( 427 | 1, 428 | [ 429 | [ConditionOpcode.CREATE_COIN_ANNOUNCEMENT, b"$"], 430 | [ 431 | ConditionOpcode.CREATE_COIN, 432 | target_1_puzzle_hash, 433 | target_1_amount, 434 | [target_1_puzzle_hash], 435 | ], 436 | [ 437 | ConditionOpcode.CREATE_COIN, 438 | target_2_puzzle_hash, 439 | target_2_amount, 440 | [target_2_puzzle_hash], 441 | ], 442 | ], 443 | ) 444 | ) 445 | 446 | # Confirm expected node 1 coin name is correct 447 | node_1_coin_name = std_hash( 448 | root_coin_name + node_1_puzzle.get_tree_hash() + int_to_bytes(target_1_amount + target_2_amount) 449 | ) 450 | 451 | assert node_1_coin_name == expected_node_1_coin_name 452 | 453 | 454 | def test_read_secure_the_bag_targets() -> None: 455 | targets = read_secure_the_bag_targets("./tests/cats/test.csv", 20000032100000000) 456 | 457 | assert len(targets) == 3 458 | 459 | assert targets[0].puzzle_hash == bytes32.fromhex("4bc6435b409bcbabe53870dae0f03755f6aabb4594c5915ec983acf12a5d1fba") 460 | assert targets[0].amount == uint64(10000000000000000) 461 | 462 | assert targets[1].puzzle_hash == bytes32.fromhex("f3d5162330c4d6c8b9a0aba5eed999178dd2bf466a7a0289739acc8209122e2c") 463 | assert targets[1].amount == uint64(32100000000) 464 | 465 | assert targets[2].puzzle_hash == bytes32.fromhex("7ffdeca4f997bde55d249b4a3adb8077782bc4134109698e95b10ea306a138b4") 466 | assert targets[2].amount == uint64(10000000000000000) 467 | 468 | 469 | def test_read_secure_the_bag_targets_invalid_net_amount() -> None: 470 | with pytest.raises(Exception): 471 | read_secure_the_bag_targets("test.csv", 5000000) 472 | -------------------------------------------------------------------------------- /cats/unwind_the_bag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | from collections import defaultdict 6 | from collections.abc import Coroutine 7 | from pathlib import Path 8 | from typing import Any 9 | 10 | import click 11 | from chia.cmds.cmds_util import get_wallet 12 | from chia.full_node.full_node_rpc_client import FullNodeRpcClient 13 | from chia.types.blockchain_format.program import Program, run_with_cost 14 | from chia.util.bech32m import decode_puzzle_hash 15 | from chia.util.config import load_config 16 | from chia.wallet.cat_wallet.cat_utils import ( 17 | CAT_MOD, 18 | SpendableCAT, 19 | construct_cat_puzzle, 20 | match_cat_puzzle, 21 | unsigned_spend_bundle_for_spendable_cats, 22 | ) 23 | from chia.wallet.conditions import AssertAnnouncement 24 | from chia.wallet.lineage_proof import LineageProof 25 | from chia.wallet.uncurried_puzzle import uncurry_puzzle 26 | from chia.wallet.util.tx_config import DEFAULT_COIN_SELECTION_CONFIG, DEFAULT_TX_CONFIG 27 | from chia.wallet.wallet_request_types import GetNextAddress, LogIn, PushTX, SelectCoins 28 | from chia.wallet.wallet_rpc_client import WalletRpcClient 29 | from chia.wallet.wallet_spend_bundle import WalletSpendBundle 30 | from chia_rs import CoinSpend, G2Element 31 | from chia_rs.sized_bytes import bytes32 32 | from chia_rs.sized_ints import uint32, uint64 33 | 34 | from cats.secure_the_bag import ( 35 | TargetCoin, 36 | batch_the_bag, 37 | parent_of_puzzle_hash, 38 | read_secure_the_bag_targets, 39 | secure_the_bag, 40 | ) 41 | 42 | NULL_SIGNATURE = G2Element() 43 | 44 | 45 | async def unspent_coin_exists(full_node_client: FullNodeRpcClient, coin_name: bytes32) -> bool: 46 | """ 47 | Checks if an unspent coin exists. 48 | 49 | Raises an exception if coin has already been spent. 50 | """ 51 | coin_record = await full_node_client.get_coin_record_by_name(coin_name) 52 | 53 | if coin_record is None: 54 | return False 55 | 56 | if coin_record.spent_block_index > 0: 57 | raise Exception(f"Coin {coin_name} has already been spent") 58 | 59 | return True 60 | 61 | 62 | async def wait_for_unspent_coin(full_node_client: FullNodeRpcClient, coin_name: bytes32) -> None: 63 | """ 64 | Repeatedly poll full node until unspent coin is created. 65 | 66 | Raises an exception if coin has already been spent. 67 | """ 68 | while True: 69 | print(f"Waiting for unspent coin {coin_name.hex()}") 70 | 71 | exists = await unspent_coin_exists(full_node_client, coin_name) 72 | 73 | if exists: 74 | print(f"Coin {coin_name.hex()} exists and is unspent") 75 | 76 | break 77 | 78 | print(f"Unspent coin {coin_name.hex()} does not exist") 79 | 80 | await asyncio.sleep(3) 81 | 82 | 83 | async def wait_for_coin_spend(full_node_client: FullNodeRpcClient, coin_name: bytes32) -> None: 84 | """ 85 | Repeatedly poll full node until coin is spent. 86 | 87 | This is used to wait for coins spend before spending children. 88 | """ 89 | while True: 90 | print(f"Waiting for coin spend {coin_name.hex()}") 91 | 92 | coin_record = await full_node_client.get_coin_record_by_name(coin_name) 93 | 94 | if coin_record is None: 95 | print(f"Coin {coin_name.hex()} does not exist") 96 | 97 | continue 98 | 99 | if coin_record.spent_block_index > 0: 100 | print(f"Coin {coin_name.hex()} has been spent") 101 | 102 | break 103 | 104 | print(f"Coin {coin_name.hex()} has not been spent") 105 | 106 | await asyncio.sleep(3) 107 | 108 | 109 | async def get_unwind( 110 | full_node_client: FullNodeRpcClient, 111 | genesis_coin_id: bytes32, 112 | tail_hash_bytes: bytes32, 113 | parent_puzzle_lookup: dict[str, TargetCoin], 114 | target_puzzle_hash: bytes32, 115 | ) -> list[CoinSpend]: 116 | required_coin_spends: list[CoinSpend] = [] 117 | 118 | current_puzzle_hash = target_puzzle_hash 119 | 120 | while True: 121 | if current_puzzle_hash is None: 122 | break 123 | 124 | coin_spend, _ = parent_of_puzzle_hash(genesis_coin_id, current_puzzle_hash, parent_puzzle_lookup) 125 | 126 | if coin_spend is None: 127 | break 128 | 129 | response = await full_node_client.get_coin_record_by_name(coin_spend.coin.name()) 130 | 131 | if response is None: 132 | # Coin doesn't exist yet so we add to list of required spends and check the parent 133 | required_coin_spends.append(coin_spend) 134 | current_puzzle_hash = coin_spend.coin.puzzle_hash 135 | continue 136 | 137 | if response.spent_block_index == 0: 138 | # We have reached the lowest unspent coin 139 | required_coin_spends.append(coin_spend) 140 | else: 141 | # This situation is only expected if the bag has already been unwound (possibly by somebody else) 142 | print("WARNING: Lowest coin is spent. Secured bag already unwound.") 143 | 144 | break 145 | 146 | return required_coin_spends 147 | 148 | 149 | async def unwind_coin_spend( 150 | full_node_client: FullNodeRpcClient, tail_hash_bytes: bytes32, coin_spend: CoinSpend 151 | ) -> WalletSpendBundle: 152 | # Wait for unspent coin to exist before trying to spend it 153 | await wait_for_unspent_coin(full_node_client, coin_spend.coin.name()) 154 | 155 | curried_args = match_cat_puzzle(uncurry_puzzle(coin_spend.puzzle_reveal)) 156 | 157 | if curried_args is None: 158 | raise Exception("Expected CAT") 159 | 160 | _, _, inner_puzzle = curried_args 161 | 162 | # Get parent coin info as required for lineage proof when spending this CAT coin 163 | parent_r = await full_node_client.get_coin_record_by_name(coin_spend.coin.parent_coin_info) 164 | if parent_r is None: 165 | raise Exception("Parent coin does not exist") 166 | parent = await full_node_client.get_puzzle_and_solution( 167 | coin_spend.coin.parent_coin_info, parent_r.spent_block_index 168 | ) 169 | if parent is None: 170 | raise Exception("Parent coin does not exist") 171 | 172 | parent_curried_args = match_cat_puzzle(uncurry_puzzle(parent.puzzle_reveal)) 173 | 174 | if parent_curried_args is None: 175 | raise Exception("Expected parent to be CAT") 176 | 177 | _, _, parent_inner_puzzle = parent_curried_args 178 | 179 | spendable_cat = SpendableCAT( 180 | coin_spend.coin, 181 | tail_hash_bytes, 182 | inner_puzzle, 183 | Program.to([]), 184 | lineage_proof=LineageProof( 185 | parent_r.coin.parent_coin_info, 186 | parent_inner_puzzle.get_tree_hash(), 187 | uint64(parent.coin.amount), 188 | ), 189 | ) 190 | cat_spend = unsigned_spend_bundle_for_spendable_cats(CAT_MOD, [spendable_cat]) 191 | 192 | # Throw an error before pushing to full node if spend is invalid 193 | _ = run_with_cost(cat_spend.coin_spends[0].puzzle_reveal, 0, cat_spend.coin_spends[0].solution) 194 | 195 | return cat_spend 196 | 197 | 198 | async def unwind_the_bag( 199 | full_node_client: FullNodeRpcClient, 200 | wallet_client: WalletRpcClient, 201 | unwind_target_puzzle_hash_bytes: bytes32, 202 | tail_hash_bytes: bytes32, 203 | genesis_coin_id: bytes32, 204 | parent_puzzle_lookup: dict[str, TargetCoin], 205 | ) -> list[CoinSpend]: 206 | current_puzzle_hash = construct_cat_puzzle( 207 | CAT_MOD, tail_hash_bytes, Program.to(unwind_target_puzzle_hash_bytes) 208 | ).get_tree_hash_precalc(unwind_target_puzzle_hash_bytes) 209 | 210 | print(f"Getting unwind for {current_puzzle_hash}") 211 | 212 | required_coin_spends: list[CoinSpend] = await get_unwind( 213 | full_node_client, 214 | genesis_coin_id, 215 | tail_hash_bytes, 216 | parent_puzzle_lookup, 217 | current_puzzle_hash, 218 | ) 219 | 220 | print(f"{len(required_coin_spends)} spends required to unwind the bag to {unwind_target_puzzle_hash_bytes}") 221 | 222 | return required_coin_spends[::-1] 223 | 224 | 225 | async def app( 226 | chia_config: dict[str, Any], 227 | chia_root: Path, 228 | secure_the_bag_targets_path: str, 229 | leaf_width: int, 230 | tail_hash_bytes: bytes32, 231 | unwind_target_puzzle_hash_bytes: bytes32 | None, 232 | genesis_coin_id: bytes32, 233 | fingerprint: int, 234 | wallet_id: int, 235 | unwind_fee: int, 236 | ) -> None: 237 | full_node_client = await FullNodeRpcClient.create( 238 | chia_config["self_hostname"], 239 | chia_config["full_node"]["rpc_port"], 240 | chia_root, 241 | load_config(chia_root, "config.yaml"), 242 | ) 243 | wallet_client = await WalletRpcClient.create( 244 | chia_config["self_hostname"], 245 | chia_config["wallet"]["rpc_port"], 246 | chia_root, 247 | load_config(chia_root, "config.yaml"), 248 | ) 249 | if fingerprint is not None: 250 | print(f"Setting fingerprint: {fingerprint}") 251 | await wallet_client.log_in(LogIn(uint32(fingerprint))) 252 | 253 | targets = read_secure_the_bag_targets(secure_the_bag_targets_path, None) 254 | _, parent_puzzle_lookup = secure_the_bag(targets, leaf_width, tail_hash_bytes) 255 | 256 | if unwind_target_puzzle_hash_bytes is not None: 257 | # Unwinding to a single target has to be done sequentially as each spend is dependant on the parent being spent 258 | print(f"Unwinding secured bag to {unwind_target_puzzle_hash_bytes}") 259 | 260 | coin_spends = await unwind_the_bag( 261 | full_node_client, 262 | wallet_client, 263 | unwind_target_puzzle_hash_bytes, 264 | tail_hash_bytes, 265 | genesis_coin_id, 266 | parent_puzzle_lookup, 267 | ) 268 | 269 | for coin_spend in coin_spends: 270 | cat_spend = await unwind_coin_spend(full_node_client, tail_hash_bytes, coin_spend) 271 | await get_wallet( 272 | root_path=chia_root, 273 | wallet_client=wallet_client, 274 | fingerprint=fingerprint, 275 | ) 276 | 277 | if unwind_fee > 0: 278 | fee_coins_response = await wallet_client.select_coins( 279 | request=SelectCoins.from_coin_selection_config( 280 | amount=uint64(unwind_fee), 281 | wallet_id=uint32(wallet_id), 282 | coin_selection_config=DEFAULT_COIN_SELECTION_CONFIG, 283 | ) 284 | ) 285 | 286 | fee_coins = fee_coins_response.coins 287 | change_amount = sum([c.amount for c in fee_coins]) - unwind_fee 288 | change_address = await wallet_client.get_next_address( 289 | request=GetNextAddress(wallet_id=uint32(wallet_id), new_address=False) 290 | ) 291 | change_ph = decode_puzzle_hash(change_address.address) 292 | 293 | # Fees depend on announcements made by secure the bag CATs to ensure they can't be seperated 294 | cat_announcements: list[AssertAnnouncement] = [] 295 | for coin_spend in cat_spend.coin_spends: 296 | cat_announcements.append( 297 | AssertAnnouncement( 298 | coin_not_puzzle=True, 299 | asserted_origin_id=coin_spend.coin.name(), 300 | asserted_msg=b"$", 301 | ) 302 | ) 303 | 304 | # Create signed coin spends and change for fees 305 | fees_tx = await wallet_client.create_signed_transactions( 306 | [{"amount": change_amount, "puzzle_hash": change_ph}], 307 | coins=fee_coins, 308 | fee=uint64(unwind_fee), 309 | extra_conditions=(*cat_announcements,), 310 | tx_config=DEFAULT_TX_CONFIG, 311 | ) 312 | 313 | if fees_tx.signed_tx.spend_bundle is None: 314 | raise Exception("No spend bundle created") 315 | 316 | await wallet_client.push_tx( 317 | PushTX( 318 | WalletSpendBundle( 319 | cat_spend.coin_spends + fees_tx.signed_tx.spend_bundle.coin_spends, 320 | fees_tx.signed_tx.spend_bundle.aggregated_signature, 321 | ) 322 | ) 323 | ) 324 | else: 325 | await wallet_client.push_tx( 326 | PushTX(WalletSpendBundle(cat_spend.coin_spends, cat_spend.aggregated_signature)) 327 | ) 328 | 329 | print("Transaction pushed to full node") 330 | 331 | # Wait for parent coin to be spent before attempting to spend children 332 | await wait_for_coin_spend(full_node_client, coin_spend.coin.name()) 333 | else: 334 | # Unwinding the entire secured bag can involve batching spends together for speed 335 | # Care must be taken to only batch together spends where the parent has been spent 336 | # otherwise one invalid spend could invalidate the entire spend bundle 337 | print("Unwinding entire secured bag") 338 | 339 | batched_targets = batch_the_bag(targets, leaf_width) 340 | 341 | # Dictionary of spends at each level of the tree so they can be batched 342 | # based on parents that have already been spent 343 | level_coin_spends: dict[int, dict[str, CoinSpend]] = defaultdict(dict) 344 | max_depth = 0 345 | total_spends = 0 346 | 347 | # Unwind to the first target coin in each batch 348 | for batch_targets in batched_targets: 349 | unwound_spends = await unwind_the_bag( 350 | full_node_client, 351 | wallet_client, 352 | batch_targets[0].puzzle_hash, 353 | tail_hash_bytes, 354 | genesis_coin_id, 355 | parent_puzzle_lookup, 356 | ) 357 | total_spends += len(unwound_spends) 358 | 359 | print(f"{len(unwound_spends)} spends to {batch_targets[0].puzzle_hash}") 360 | 361 | for index, coin_spend in enumerate(unwound_spends): 362 | level_coin_spends[index][coin_spend.coin.puzzle_hash.hex()] = coin_spend 363 | max_depth = max(index, max_depth) 364 | 365 | total_fees = total_spends * unwind_fee 366 | 367 | print(f"{total_spends} total spends required with {total_fees} fees") 368 | 369 | for depth in range(0, max_depth + 1): 370 | level = level_coin_spends[depth] 371 | 372 | # Larger batch_size e.g. 25 can result in COST_EXCEEDS_MAX 373 | batch_size = 10 374 | spent_coin_names: list[bytes32] = [] 375 | bundle_spends: list[CoinSpend] = [] 376 | 377 | print(f"About to iterate {len(level.values())} times for depth {depth}") 378 | 379 | i = 0 380 | for coin_spend in level.values(): 381 | i += 1 382 | cat_spend = await unwind_coin_spend(full_node_client, tail_hash_bytes, coin_spend) 383 | await get_wallet( 384 | root_path=chia_root, 385 | wallet_client=wallet_client, 386 | fingerprint=fingerprint, 387 | ) 388 | 389 | bundle_spends += cat_spend.coin_spends 390 | spent_coin_names.append(coin_spend.coin.name()) 391 | 392 | if len(bundle_spends) >= batch_size or i == len(level.values()): 393 | if unwind_fee > 0: 394 | spend_bundle_fee = len(bundle_spends) * unwind_fee 395 | 396 | fee_coins_response = await wallet_client.select_coins( 397 | request=SelectCoins.from_coin_selection_config( 398 | amount=uint64(spend_bundle_fee), 399 | wallet_id=uint32(wallet_id), 400 | coin_selection_config=DEFAULT_COIN_SELECTION_CONFIG, 401 | ) 402 | ) 403 | fee_coins = fee_coins_response.coins 404 | change_amount = sum([c.amount for c in fee_coins]) - spend_bundle_fee 405 | change_address = await wallet_client.get_next_address( 406 | request=GetNextAddress(wallet_id=uint32(wallet_id), new_address=False) 407 | ) 408 | change_ph = decode_puzzle_hash(change_address.address) 409 | 410 | # Fees depend on announcements made by secure the bag CATs to ensure they can't be seperated 411 | cat_announcements = [] 412 | for coin_spend in bundle_spends: 413 | cat_announcements.append( 414 | AssertAnnouncement( 415 | coin_not_puzzle=True, 416 | asserted_origin_id=coin_spend.coin.name(), 417 | asserted_msg=b"$", 418 | ) 419 | ) 420 | 421 | # Create signed coin spends and change for fees 422 | fees_tx = await wallet_client.create_signed_transactions( 423 | [{"amount": change_amount, "puzzle_hash": change_ph}], 424 | coins=fee_coins, 425 | fee=uint64(spend_bundle_fee), 426 | extra_conditions=(*cat_announcements,), 427 | tx_config=DEFAULT_TX_CONFIG, 428 | ) 429 | if fees_tx.signed_tx.spend_bundle is None: 430 | raise Exception("No spend bundle created") 431 | 432 | await wallet_client.push_tx( 433 | PushTX( 434 | WalletSpendBundle( 435 | bundle_spends + fees_tx.signed_tx.spend_bundle.coin_spends, 436 | fees_tx.signed_tx.spend_bundle.aggregated_signature, 437 | ) 438 | ) 439 | ) 440 | else: 441 | await wallet_client.push_tx( 442 | PushTX(WalletSpendBundle(bundle_spends, cat_spend.aggregated_signature)) 443 | ) 444 | 445 | print( 446 | f"Transaction containing {len(bundle_spends)} coin spends " 447 | f"at tree depth {depth} pushed to full node" 448 | ) 449 | 450 | bundle_spends = [] 451 | 452 | # Wait for this batch to be spent before attempting next spends 453 | # Important for spending children of coins we just created 454 | coin_spend_waits: list[Coroutine[Any, Any, None]] = [] 455 | 456 | for coin_name in spent_coin_names: 457 | coin_spend_waits.append(wait_for_coin_spend(full_node_client, coin_name)) 458 | 459 | await asyncio.gather(*coin_spend_waits) 460 | 461 | spent_coin_names = [] 462 | 463 | full_node_client.close() 464 | wallet_client.close() 465 | 466 | 467 | @click.command() 468 | @click.pass_context 469 | @click.option( 470 | "-ecid", 471 | "--eve-coin-id", 472 | required=True, 473 | help="ID of coin that was spent to create secured bag", 474 | ) 475 | @click.option( 476 | "-th", 477 | "--tail-hash", 478 | required=True, 479 | help="TAIL hash / Asset ID of CAT to unwind from secured bag of CATs", 480 | ) 481 | @click.option( 482 | "-stbtp", 483 | "--secure-the-bag-targets-path", 484 | required=True, 485 | help="Path to CSV file containing targets of secure the bag (inner puzzle hash + amount)", 486 | ) 487 | @click.option( 488 | "-utph", 489 | "--unwind-target-puzzle-hash", 490 | required=False, 491 | help="Puzzle hash of target to unwind from secured bag", 492 | ) 493 | @click.option( 494 | "-wi", 495 | "--wallet-id", 496 | type=int, 497 | help="The wallet id to use", 498 | ) 499 | @click.option( 500 | "-f", 501 | "--fingerprint", 502 | type=int, 503 | default=None, 504 | help="The wallet fingerprint to use as funds", 505 | ) 506 | @click.option( 507 | "-uf", 508 | "--unwind-fee", 509 | required=True, 510 | default=500000, 511 | show_default=True, 512 | help="Fee paid for each unwind spend. Enough mojos must be available to cover all spends.", 513 | ) 514 | @click.option( 515 | "-lw", 516 | "--leaf-width", 517 | required=True, 518 | default=100, 519 | show_default=True, 520 | help="Secure the bag leaf width", 521 | ) 522 | def cli( 523 | ctx: click.Context, 524 | eve_coin_id: str, 525 | tail_hash: str, 526 | secure_the_bag_targets_path: str, 527 | unwind_target_puzzle_hash: str, 528 | fingerprint: int, 529 | wallet_id: int, 530 | unwind_fee: int, 531 | leaf_width: int, 532 | ) -> None: 533 | ctx.ensure_object(dict) 534 | 535 | eve_coin_id_bytes = bytes32.fromhex(eve_coin_id) 536 | tail_hash_bytes = bytes32.fromhex(tail_hash) 537 | unwind_target_puzzle_hash_bytes = None 538 | if unwind_target_puzzle_hash: 539 | unwind_target_puzzle_hash_bytes = bytes32.fromhex(unwind_target_puzzle_hash) 540 | 541 | chia_root: Path = Path(os.path.expanduser(os.getenv("CHIA_ROOT", "~/.chia/mainnet"))).resolve() 542 | chia_config = load_config(chia_root, "config.yaml") 543 | 544 | asyncio.get_event_loop().run_until_complete( 545 | app( 546 | chia_config, 547 | chia_root, 548 | secure_the_bag_targets_path, 549 | leaf_width, 550 | tail_hash_bytes, 551 | unwind_target_puzzle_hash_bytes, 552 | eve_coin_id_bytes, 553 | fingerprint, 554 | wallet_id, 555 | unwind_fee, 556 | ) 557 | ) 558 | 559 | 560 | def main() -> None: 561 | cli() 562 | 563 | 564 | if __name__ == "__main__": 565 | main() 566 | --------------------------------------------------------------------------------