├── ownable_singleton ├── __init__.py ├── clsp │ ├── __init__.py │ ├── p2_singleton_or_cancel.clsp.hex │ ├── ownable_singleton_v1.clsp.hex │ ├── ownable_singleton_v2.clsp.hex │ ├── ownable_singleton_v1.clsp │ ├── p2_singleton_or_cancel.clsp │ └── ownable_singleton_v2.clsp ├── drivers │ ├── __init__.py │ └── ownable_singleton_driver.py ├── tests │ ├── __init__.py │ └── test_ownable_singleton.py └── README.md ├── .gitignore ├── .github └── singleton_offers.png ├── install.ps1 ├── install.sh ├── setup.py ├── README.md ├── LICENSE └── nft.py /ownable_singleton/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ownable_singleton/clsp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ownable_singleton/drivers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ownable_singleton/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/main.sym 2 | venv 3 | .pytest_cache 4 | .idea 5 | **/__pycache__ 6 | *.egg-info 7 | build* 8 | .eggs -------------------------------------------------------------------------------- /.github/singleton_offers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mintgarden-io/nft-companion/HEAD/.github/singleton_offers.png -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | python -m venv venv 2 | ./venv/Scripts/activate 3 | python -m pip install --upgrade pip 4 | 5 | pip3 install wheel 6 | pip3 install . 7 | pip3 install chia-dev-tools --no-deps 8 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python3 -m venv venv 4 | . ./venv/bin/activate 5 | 6 | python3 -m pip install --upgrade pip 7 | 8 | pip3 install wheel && pip3 install . 9 | pip3 install chia-dev-tools --no-deps 10 | 11 | deactivate -------------------------------------------------------------------------------- /ownable_singleton/README.md: -------------------------------------------------------------------------------- 1 | # Puzzles 2 | 3 | These puzzles are used to construct singletons with an owner and paid transfer capabilities. 4 | 5 | The following image shows an overview of a singleton transfer. 6 | 7 | ![Overview over the singleton offer mechanism](../.github/singleton_offers.png) 8 | 9 | ## ownable_singleton 10 | 11 | This is an inner puzzle for a singleton_top_layer coin. It allows the singleton to be owned by a public key. 12 | 13 | Furthermore, the ownership can be transferred to a new owner, optionally by paying for it in XCH. 14 | The payment is done via a special p2_singleton_or_cancel coin. 15 | 16 | ## p2_singleton_or_cancel 17 | 18 | This coin can be spent in order to pay for a singleton ownership transfer. 19 | It also has a cancel functionality, in case an offer should be cancelled. 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | with open("README.md", "rt") as fh: 6 | long_description = fh.read() 7 | 8 | dependencies = [ 9 | "chia-blockchain@git+https://github.com/Chia-Network/chia-blockchain.git@protocol_and_cats_rebased#23d571d9bb6b5003b49dee7ee31c1799358c5349", 10 | "requests" 11 | ] 12 | 13 | dev_dependencies = [ 14 | "black", 15 | ] 16 | 17 | setup( 18 | name="singleton-utils", 19 | version="0.0.1", 20 | author="xch-gallery", 21 | setup_requires=["setuptools_scm"], 22 | install_requires=dependencies, 23 | extras_require=dict( 24 | dev=dev_dependencies, 25 | ), 26 | project_urls={ 27 | "Bug Reports": "https://github.com/xch-gallery/singleton-utils", 28 | "Source": "https://github.com/xch-gallery/singleton-utils", 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /ownable_singleton/clsp/p2_singleton_or_cancel.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff01ff02ffff03ff81bfffff01ff04ffff04ff38ffff04ffff0bffff02ff2effff04ff02ffff04ff05ffff04ff5fffff04ffff02ff3effff04ff02ffff04ffff04ff05ffff04ff0bff178080ff80808080ff808080808080ff81bf80ff808080ffff04ffff04ff3cffff04ff82017fff808080ffff04ffff04ff28ffff04ff81bfff808080ff80808080ffff01ff04ffff04ff2cffff04ff2fffff04ff5fff80808080ffff04ffff04ff10ffff04ff5fff808080ff80808080ff0180ffff04ffff01ffffff49ff463fff02ff333cffff04ff0101ffff02ff02ffff03ff05ffff01ff02ff36ffff04ff02ffff04ff0dffff04ffff0bff26ffff0bff2aff1280ffff0bff26ffff0bff26ffff0bff2aff3a80ff0980ffff0bff26ff0bffff0bff2aff8080808080ff8080808080ffff010b80ff0180ffff0bff26ffff0bff2aff1480ffff0bff26ffff0bff26ffff0bff2aff3a80ff0580ffff0bff26ffff02ff36ffff04ff02ffff04ff07ffff04ffff0bff2aff2a80ff8080808080ffff0bff2aff8080808080ff02ffff03ffff07ff0580ffff01ff0bffff0102ffff02ff3effff04ff02ffff04ff09ff80808080ffff02ff3effff04ff02ffff04ff0dff8080808080ffff01ff0bffff0101ff058080ff0180ff018080 -------------------------------------------------------------------------------- /ownable_singleton/clsp/ownable_singleton_v1.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff01ff02ffff03ff5fffff01ff02ffff03ffff15ff82017fff8080ffff01ff02ff2effff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff8201afffff04ff5fffff04ff81bfffff04ff82017fffff04ff8202ffff8080808080808080808080ffff01ff02ff36ffff04ff02ffff04ff05ffff04ff17ffff04ff8201afffff04ff5fffff04ff81bfff808080808080808080ff0180ffff01ff088080ff0180ffff04ffff01ffffff32ff3d02ff33ff3e04ffff01ff0102ffffff02ffff03ff05ffff01ff02ff26ffff04ff02ffff04ff0dffff04ffff0bff3affff0bff12ff3c80ffff0bff3affff0bff3affff0bff12ff2a80ff0980ffff0bff3aff0bffff0bff12ff8080808080ff8080808080ffff010b80ff0180ff04ffff04ff14ffff04ffff02ff3effff04ff02ffff04ff0bffff04ffff0bffff0101ff0b80ffff04ffff0bffff0101ff5f80ffff04ffff0bffff0101ff2f80ff80808080808080ffff04ff17ff80808080ffff04ffff04ff10ffff04ff2fffff04ff5fff80808080ffff04ffff04ff10ffff04ff05ffff04ff2fff80808080ff80808080ffff04ffff04ff14ffff04ffff02ff3effff04ff02ffff04ff17ffff04ffff0bffff0101ff1780ffff04ffff0bffff0101ff81bf80ffff04ffff0bffff0101ff5f80ff80808080808080ffff04ff2fff80808080ffff04ffff04ff14ffff04ff0bffff04ff82017fff80808080ffff04ffff04ff2cffff04ff8202ffff808080ffff04ffff04ff28ffff04ffff0bff8202ffff5f80ff808080ffff04ffff04ff10ffff04ff5fffff04ff81bfff80808080ffff04ffff04ff10ffff04ff05ffff04ff82017fff80808080ff80808080808080ff0bff3affff0bff12ff3880ffff0bff3affff0bff3affff0bff12ff2a80ff0580ffff0bff3affff02ff26ffff04ff02ffff04ff07ffff04ffff0bff12ff1280ff8080808080ffff0bff12ff8080808080ff018080 -------------------------------------------------------------------------------- /ownable_singleton/clsp/ownable_singleton_v2.clsp.hex: -------------------------------------------------------------------------------- 1 | ff02ffff01ff02ffff03ff5fffff01ff02ffff03ff81bfffff01ff02ff3affff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff8201afffff04ff5fffff04ff81bfff808080808080808080ffff01ff02ff2affff04ff02ffff04ff05ffff04ff0bffff04ff17ffff04ff8201afffff04ff5fff808080808080808080ff0180ffff01ff088080ff0180ffff04ffff01ffffffff323dff0233ffff3e04ff0101ffffff02ff02ffff03ff05ffff01ff02ff32ffff04ff02ffff04ff0dffff04ffff0bff22ffff0bff2cff3480ffff0bff22ffff0bff22ffff0bff2cff3c80ff0980ffff0bff22ff0bffff0bff2cff8080808080ff8080808080ffff010b80ff0180ffff04ffff04ff38ffff04ffff02ff2effff04ff02ffff04ff17ffff04ffff0bffff0101ff1780ffff04ffff02ff3effff04ff02ffff04ff0bff80808080ffff04ffff02ff3effff04ff02ffff04ff5fff80808080ff80808080808080ffff04ff2fff80808080ffff04ffff04ff20ffff04ff819fffff04ff82015fff80808080ffff04ffff04ff20ffff04ff09ffff04ff819fff80808080ff80808080ff02ff36ffff04ff02ffff04ffff02ffff03ff0bffff01ff04ffff04ff38ffff04ff15ffff04ffff02ff26ffff04ff02ffff04ffff11ff82013fffff02ffff03ff2bffff01ff05ffff14ffff12ff82013fff2b80ffff01648080ff8080ff018080ff80808080ff80808080ffff04ffff04ff38ffff04ff13ffff04ffff02ff26ffff04ff02ffff04ffff02ffff03ff2bffff01ff05ffff14ffff12ff82013fff2b80ffff01648080ff8080ff0180ff80808080ff80808080ff808080ffff01ff04ffff04ff38ffff04ff15ffff04ff82013fff80808080ff808080ff0180ffff04ffff04ffff04ff38ffff04ffff02ff2effff04ff02ffff04ff17ffff04ffff0bffff0101ff1780ffff04ffff02ff3effff04ff02ffff04ff0bff80808080ffff04ffff02ff3effff04ff02ffff04ff5fff80808080ff80808080808080ffff04ff2fff80808080ffff04ffff04ff24ffff04ff8202bfff808080ffff04ffff04ff30ffff04ffff0bff8202bfff819f80ff808080ffff04ffff04ff20ffff04ff819fffff04ff82015fff80808080ffff04ffff04ff20ffff04ff09ffff04ff82013fff80808080ff808080808080ff8080808080ffffff02ffff03ffff06ffff14ff05ffff01028080ffff01ff11ff05ffff010180ffff010580ff0180ff02ffff03ff05ffff01ff04ff09ffff02ff36ffff04ff02ffff04ff0dffff04ff0bff808080808080ffff010b80ff0180ffff0bff22ffff0bff2cff2880ffff0bff22ffff0bff22ffff0bff2cff3c80ff0580ffff0bff22ffff02ff32ffff04ff02ffff04ff07ffff04ffff0bff2cff2c80ff8080808080ffff0bff2cff8080808080ff02ffff03ffff07ff0580ffff01ff0bffff0102ffff02ff3effff04ff02ffff04ff09ff80808080ffff02ff3effff04ff02ffff04ff0dff8080808080ffff01ff0bffff0101ff058080ff0180ff018080 -------------------------------------------------------------------------------- /ownable_singleton/clsp/ownable_singleton_v1.clsp: -------------------------------------------------------------------------------- 1 | (mod (OWNER_PUBKEY 2 | OWNER_PUZZLE_HASH 3 | INNER_PUZZLE_HASH 4 | Truths 5 | new_owner_pubkey 6 | new_owner_puzhash 7 | payment_amount ; insert 0 for transfer without payment 8 | payment_id 9 | ) 10 | 11 | (include condition_codes.clib) 12 | (include singleton_truths.clib) 13 | (include curry_and_treehash.clib) 14 | 15 | (defun-inline inner_puzzle_hash_for_new_owner (INNER_PUZZLE_HASH new_owner_pubkey new_owner_puzhash) 16 | (puzzle-hash-of-curried-function INNER_PUZZLE_HASH (sha256 1 INNER_PUZZLE_HASH) (sha256 1 new_owner_puzhash) (sha256 1 new_owner_pubkey)) 17 | ) 18 | 19 | (defun change_owner (OWNER_PUBKEY INNER_PUZZLE_HASH my_amount new_owner_pubkey new_owner_puzhash) 20 | (list (list CREATE_COIN (inner_puzzle_hash_for_new_owner INNER_PUZZLE_HASH new_owner_pubkey new_owner_puzhash) my_amount) ; Transfer the singleton to the new owner 21 | (list AGG_SIG_ME new_owner_pubkey new_owner_puzhash) ; Ensure that the puzhash matches the public key 22 | (list AGG_SIG_ME OWNER_PUBKEY new_owner_pubkey) ; Owner asserts the new owner 23 | ) 24 | ) 25 | 26 | (defun change_owner_and_transfer_payment (OWNER_PUBKEY OWNER_PUZZLE_HASH INNER_PUZZLE_HASH my_amount new_owner_pubkey new_owner_puzhash payment_amount payment_id) 27 | (list (list CREATE_COIN (inner_puzzle_hash_for_new_owner INNER_PUZZLE_HASH new_owner_pubkey new_owner_puzhash) my_amount) ; Transfer the singleton to the new owner 28 | (list CREATE_COIN OWNER_PUZZLE_HASH payment_amount) ; Pay the current owner 29 | (list CREATE_PUZZLE_ANNOUNCEMENT payment_id) ; Inform the payment coin of this spend 30 | (list ASSERT_COIN_ANNOUNCEMENT (sha256 payment_id new_owner_pubkey)) ; Assert that the payment coin is being spent and validate the owner 31 | (list AGG_SIG_ME new_owner_pubkey new_owner_puzhash) ; Ensure that the puzhash matches the public key 32 | (list AGG_SIG_ME OWNER_PUBKEY payment_amount) ; Owner asserts the price 33 | ) 34 | ) 35 | 36 | ; main 37 | (if new_owner_pubkey 38 | (if (> payment_amount 0) 39 | (change_owner_and_transfer_payment OWNER_PUBKEY OWNER_PUZZLE_HASH INNER_PUZZLE_HASH (my_amount_truth Truths) new_owner_pubkey new_owner_puzhash payment_amount payment_id) 40 | (change_owner OWNER_PUBKEY INNER_PUZZLE_HASH (my_amount_truth Truths) new_owner_pubkey new_owner_puzhash) 41 | ) 42 | (x) 43 | ) 44 | ) 45 | -------------------------------------------------------------------------------- /ownable_singleton/clsp/p2_singleton_or_cancel.clsp: -------------------------------------------------------------------------------- 1 | (mod (SINGLETON_MOD_HASH LAUNCHER_ID LAUNCHER_PUZZLE_HASH CANCEL_PUZZLE_HASH p1 my_id new_owner_pubkey) 2 | 3 | ;; This puzzle has two escape conditions: the regular "claim via singleton", and the 4 | ;; cancel "claim via puzzle hash". 5 | 6 | ; SINGLETON_MOD_HASH is the mod-hash for the singleton_top_layer puzzle 7 | ; LAUNCHER_ID is the ID of the singleton we are commited to paying to 8 | ; LAUNCHER_PUZZLE_HASH is the puzzle hash of the launcher 9 | ; CANCEL_PUZZLE_HASH is the puzzle hash of the cancel puzzle 10 | ; if my_id is passed in as () then this signals that we are trying to do a cancel spend case 11 | ; p1's meaning changes depending upon which case we're using 12 | ; if we are paying to singleton then p1 is singleton_inner_puzzle_hash 13 | ; if we are running the cancel case then p1 is the amount to output 14 | 15 | (include condition_codes.clib) 16 | (include curry_and_treehash.clib) 17 | 18 | ; takes a lisp tree and returns the hash of it 19 | (defun sha256tree (TREE) 20 | (if (l TREE) 21 | (sha256 2 (sha256tree (f TREE)) (sha256tree (r TREE))) 22 | (sha256 1 TREE) 23 | ) 24 | ) 25 | 26 | (defun-inline cancel_spend (CANCEL_PUZZLE_HASH amount) 27 | (list 28 | (list CREATE_COIN CANCEL_PUZZLE_HASH amount) 29 | (list ASSERT_MY_AMOUNT amount) 30 | ) 31 | ) 32 | 33 | ;; return the full puzzlehash for a singleton with the innerpuzzle curried in 34 | ; puzzle-hash-of-curried-function is imported from curry-and-treehash.clinc 35 | (defun-inline calculate_full_puzzle_hash (SINGLETON_MOD_HASH LAUNCHER_ID LAUNCHER_PUZZLE_HASH inner_puzzle_hash) 36 | (puzzle-hash-of-curried-function SINGLETON_MOD_HASH 37 | inner_puzzle_hash 38 | (sha256tree (c SINGLETON_MOD_HASH (c LAUNCHER_ID LAUNCHER_PUZZLE_HASH))) 39 | ) 40 | ) 41 | 42 | (defun-inline claim_payment (SINGLETON_MOD_HASH LAUNCHER_ID LAUNCHER_PUZZLE_HASH singleton_inner_puzzle_hash my_id) 43 | (list 44 | (list ASSERT_PUZZLE_ANNOUNCEMENT (sha256 (calculate_full_puzzle_hash SINGLETON_MOD_HASH LAUNCHER_ID LAUNCHER_PUZZLE_HASH singleton_inner_puzzle_hash) my_id)) 45 | (list CREATE_COIN_ANNOUNCEMENT new_owner_pubkey) ; Announce the new owner of the singleton 46 | (list ASSERT_MY_COIN_ID my_id)) 47 | ) 48 | 49 | ; main 50 | (if my_id 51 | (claim_payment SINGLETON_MOD_HASH LAUNCHER_ID LAUNCHER_PUZZLE_HASH p1 my_id) 52 | (cancel_spend CANCEL_PUZZLE_HASH p1) 53 | ) 54 | ) 55 | -------------------------------------------------------------------------------- /ownable_singleton/clsp/ownable_singleton_v2.clsp: -------------------------------------------------------------------------------- 1 | (mod (OWNER 2 | ROYALTY 3 | INNER_PUZZLE_HASH 4 | Truths 5 | new_owner 6 | payment 7 | ) 8 | 9 | (defun-inline pubkey_of (owner) (f owner)) 10 | (defun-inline puzhash_of (owner) (f (r owner))) 11 | 12 | (defun-inline creator_puzhash_of (royalty) (f royalty)) 13 | (defun-inline percentage_of (royalty) (f (r royalty))) 14 | 15 | (defun-inline amount_of (payment) (f payment)) 16 | (defun-inline id_of (payment) (f (r payment))) 17 | 18 | (include condition_codes.clib) 19 | (include sha256tree.clib) 20 | (include singleton_truths.clib) 21 | (include curry_and_treehash.clib) 22 | 23 | (defun merge_list (list_a list_b) 24 | (if list_a 25 | (c (f list_a) (merge_list (r list_a) list_b)) 26 | list_b 27 | ) 28 | ) 29 | 30 | (defun make_even (amt) 31 | (if (r (divmod amt 2)) 32 | (- amt 1) 33 | amt 34 | ) 35 | ) 36 | 37 | (defun-inline creator_amt (ROYALTY amount) 38 | (if (percentage_of ROYALTY) 39 | (f (divmod (* amount (percentage_of ROYALTY)) 100)) 40 | 0 41 | ) 42 | ) 43 | 44 | (defun-inline inner_puzzle_hash_for_new_owner (INNER_PUZZLE_HASH ROYALTY new_owner) 45 | (puzzle-hash-of-curried-function INNER_PUZZLE_HASH (sha256 1 INNER_PUZZLE_HASH) (sha256tree ROYALTY) (sha256tree new_owner)) 46 | ) 47 | 48 | (defun change_owner (OWNER ROYALTY INNER_PUZZLE_HASH my_amount new_owner) 49 | (list (list CREATE_COIN (inner_puzzle_hash_for_new_owner INNER_PUZZLE_HASH ROYALTY new_owner) my_amount) ; Transfer the singleton to the new owner 50 | (list AGG_SIG_ME (pubkey_of new_owner) (puzhash_of new_owner)) ; Ensure that the puzhash matches the public key 51 | (list AGG_SIG_ME (pubkey_of OWNER) (pubkey_of new_owner)) ; Owner asserts the new owner 52 | ) 53 | ) 54 | 55 | ; TODO Allow no royalty 56 | (defun change_owner_and_transfer_payment (OWNER ROYALTY INNER_PUZZLE_HASH my_amount new_owner payment) 57 | (merge_list 58 | (if ROYALTY 59 | (list (list CREATE_COIN (puzhash_of OWNER) (make_even (- (amount_of payment) (creator_amt ROYALTY (amount_of payment))))) ; Pay the current owner 60 | (list CREATE_COIN (creator_puzhash_of ROYALTY) (make_even (creator_amt ROYALTY (amount_of payment)))) ; Pay the creator 61 | ) 62 | (list (list CREATE_COIN (puzhash_of OWNER) (amount_of payment))) ; Pay the current owner 63 | ) 64 | (list (list CREATE_COIN (inner_puzzle_hash_for_new_owner INNER_PUZZLE_HASH ROYALTY new_owner) my_amount) ; Transfer the singleton to the new owner 65 | (list CREATE_PUZZLE_ANNOUNCEMENT (id_of payment)) ; Inform the payment coin of this spend 66 | (list ASSERT_COIN_ANNOUNCEMENT (sha256 (id_of payment) (pubkey_of new_owner))) ; Assert that the payment coin is being spent and validate the owner 67 | (list AGG_SIG_ME (pubkey_of new_owner) (puzhash_of new_owner)) ; Ensure that the puzhash matches the public key 68 | (list AGG_SIG_ME (pubkey_of OWNER) (amount_of payment)) ; Owner asserts the price 69 | ) 70 | ) 71 | ) 72 | 73 | ; main 74 | (if new_owner 75 | (if payment 76 | (change_owner_and_transfer_payment OWNER ROYALTY INNER_PUZZLE_HASH (my_amount_truth Truths) new_owner payment) 77 | (change_owner OWNER ROYALTY INNER_PUZZLE_HASH (my_amount_truth Truths) new_owner) 78 | ) 79 | (x) 80 | ) 81 | ) 82 | -------------------------------------------------------------------------------- /ownable_singleton/drivers/ownable_singleton_driver.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Tuple, List, Optional 3 | 4 | import cdv.clibs as std_lib 5 | from blspy import G1Element 6 | from cdv.util.load_clvm import load_clvm 7 | from clvm.casts import int_from_bytes 8 | 9 | from chia.types.blockchain_format.coin import Coin 10 | from chia.types.blockchain_format.program import Program 11 | from chia.types.blockchain_format.sized_bytes import bytes32 12 | from chia.types.coin_spend import CoinSpend 13 | from chia.util.ints import uint64 14 | from chia.wallet.lineage_proof import LineageProof 15 | from chia.wallet.puzzles import ( 16 | p2_conditions, 17 | p2_delegated_puzzle_or_hidden_puzzle, 18 | singleton_top_layer, 19 | ) 20 | from chia.wallet.puzzles.singleton_top_layer import ( 21 | SINGLETON_MOD_HASH, 22 | SINGLETON_LAUNCHER_HASH, 23 | ) 24 | 25 | clibs_path: Path = Path(std_lib.__file__).parent 26 | 27 | OWNABLE_SINGLETON_MOD_V1: Program = load_clvm( 28 | "ownable_singleton_v1.clsp", "ownable_singleton.clsp", search_paths=[clibs_path] 29 | ) 30 | OWNABLE_SINGLETON_MOD_V2: Program = load_clvm( 31 | "ownable_singleton_v2.clsp", "ownable_singleton.clsp", search_paths=[clibs_path] 32 | ) 33 | P2_SINGLETON_OR_CANCEL_MOD: Program = load_clvm( 34 | "p2_singleton_or_cancel.clsp", "ownable_singleton.clsp", search_paths=[clibs_path] 35 | ) 36 | SINGLETON_AMOUNT: uint64 = 1023 37 | 38 | 39 | class Owner: 40 | def __init__(self, public_key: G1Element, puzzle_hash: bytes32): 41 | self.public_key = public_key 42 | self.puzzle_hash = puzzle_hash 43 | 44 | @staticmethod 45 | def from_bytes_list(owner_array: List[bytes32]): 46 | return Owner(G1Element.from_bytes(owner_array[0]), owner_array[1]) 47 | 48 | 49 | class Royalty: 50 | def __init__(self, creator_puzhash: bytes32, percentage: int): 51 | self.creator_puzhash = creator_puzhash 52 | self.percentage = percentage 53 | 54 | @staticmethod 55 | def from_bytes_list(royalty_list: List[bytes32]): 56 | return Royalty(royalty_list[0], int_from_bytes(royalty_list[1])) 57 | 58 | 59 | def pay_to_singleton_puzzle(launcher_id: bytes32, cancel_puzhash: bytes32) -> Program: 60 | return P2_SINGLETON_OR_CANCEL_MOD.curry( 61 | SINGLETON_MOD_HASH, launcher_id, SINGLETON_LAUNCHER_HASH, cancel_puzhash 62 | ) 63 | 64 | 65 | def create_inner_puzzle(version: int, owner: Owner, royalty: Optional[Royalty] = None): 66 | if version == 1: 67 | if royalty is not None: 68 | raise f"Version 1 does not support royalties" 69 | 70 | return OWNABLE_SINGLETON_MOD_V1.curry( 71 | owner.public_key, 72 | owner.puzzle_hash, 73 | OWNABLE_SINGLETON_MOD_V1.get_tree_hash(), 74 | ) 75 | elif version == 2: 76 | return OWNABLE_SINGLETON_MOD_V2.curry( 77 | [ 78 | owner.public_key, 79 | owner.puzzle_hash, 80 | ], 81 | [royalty.creator_puzhash, royalty.percentage] if royalty else [], 82 | OWNABLE_SINGLETON_MOD_V2.get_tree_hash(), 83 | ) 84 | else: 85 | raise f"Unsupported version: {version}" 86 | 87 | 88 | def create_inner_solution( 89 | version: int, new_owner: Owner, payment_amount: int, payment_id: bytes32 90 | ) -> Program: 91 | if version == 1: 92 | return Program.to( 93 | [ 94 | new_owner.public_key, 95 | new_owner.puzzle_hash, 96 | payment_amount, 97 | payment_id, 98 | ] 99 | ) 100 | elif version == 2: 101 | return Program.to( 102 | [ 103 | [new_owner.public_key, new_owner.puzzle_hash], 104 | [payment_amount, payment_id], 105 | ] 106 | ) 107 | 108 | 109 | def create_unsigned_ownable_singleton( 110 | genesis_coin: Coin, 111 | genesis_coin_puzzle: Program, 112 | creator: Owner, 113 | uri: str, 114 | name: str, 115 | version=1, 116 | royalty: Optional[Royalty] = None, 117 | ) -> Tuple[List[CoinSpend], Program]: 118 | comment = [ 119 | ("uri", uri), 120 | ("name", name), 121 | ("creator", [creator.public_key, creator.puzzle_hash] if version == 2 else creator.public_key), 122 | ("version", version), 123 | ] 124 | 125 | inner_puzzle = create_inner_puzzle(version, creator, royalty) 126 | if royalty: 127 | comment.append( 128 | ( 129 | "royalty", 130 | [royalty.creator_puzhash, royalty.percentage], 131 | ) 132 | ) 133 | 134 | assert genesis_coin.amount == SINGLETON_AMOUNT 135 | 136 | conditions, launcher_coinsol = singleton_top_layer.launch_conditions_and_coinsol( 137 | genesis_coin, inner_puzzle, comment, SINGLETON_AMOUNT 138 | ) 139 | 140 | delegated_puzzle: Program = p2_conditions.puzzle_for_conditions(conditions) 141 | full_solution: Program = ( 142 | p2_delegated_puzzle_or_hidden_puzzle.solution_for_conditions(conditions) 143 | ) 144 | 145 | starting_coinsol: CoinSpend = CoinSpend( 146 | genesis_coin, 147 | genesis_coin_puzzle, 148 | full_solution, 149 | ) 150 | 151 | return [launcher_coinsol, starting_coinsol], delegated_puzzle 152 | 153 | 154 | def create_buy_offer( 155 | p2_singleton_coin: Coin, 156 | p2_singleton_puzzle: Program, 157 | launcher_id: bytes32, 158 | lineage_proof: LineageProof, 159 | singleton_coin: Coin, 160 | current_owner: Owner, 161 | new_owner: Owner, 162 | payment_amount: uint64, 163 | version=1, 164 | royalty: Optional[Royalty] = None, 165 | ) -> List[CoinSpend]: 166 | singleton_inner_puzzle = create_inner_puzzle(version, current_owner, royalty) 167 | 168 | p2_singleton_solution = Program.to( 169 | [ 170 | singleton_inner_puzzle.get_tree_hash(), 171 | p2_singleton_coin.name(), 172 | new_owner.public_key, 173 | ] 174 | ) 175 | 176 | p2_singleton_coinsol: CoinSpend = CoinSpend( 177 | p2_singleton_coin, p2_singleton_puzzle, p2_singleton_solution 178 | ) 179 | 180 | singleton_puzzle = singleton_top_layer.puzzle_for_singleton( 181 | launcher_id, singleton_inner_puzzle 182 | ) 183 | 184 | inner_solution = create_inner_solution( 185 | version, new_owner, payment_amount, p2_singleton_coin.name() 186 | ) 187 | 188 | singleton_solution: Program = singleton_top_layer.solution_for_singleton( 189 | lineage_proof, 190 | singleton_coin.amount, 191 | inner_solution, 192 | ) 193 | 194 | singleton_coinsol: CoinSpend = CoinSpend( 195 | singleton_coin, singleton_puzzle, singleton_solution 196 | ) 197 | 198 | return [p2_singleton_coinsol, singleton_coinsol] 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | MintGarden logo 4 | 5 |

6 | 7 |

MintGarden NFT Companion

8 | 9 |
10 | Create and trade Testnet NFTs on testnet.mintgarden.io and the Chia blockchain. 11 |
12 | 13 | ## Installation 14 | 15 | 1. Clone the repository 16 | ```shell 17 | git clone https://github.com/mintgarden-io/nft-companion.git 18 | cd nft-companion 19 | ``` 20 | 21 | 2.LINUX install: Run the install script and activate the virtual environment in Linux 22 | ```shell 23 | sh install.sh 24 | . ./venv/bin/activate 25 | ``` 26 | 27 | 28 | 2.WIN10 install: Run the install script and activate the virtual environment in Win10 29 | ``` 30 | git clone https://github.com/mintgarden-io/nft-companion 31 | cd nft-companion 32 | ./install.ps1 33 | 34 | ./venv/Scripts/activate 35 | ``` 36 | 37 | ## Create a new NFT singleton 38 | 39 | The `create` command can be used to create a new NFT singleton using the Chia light wallet. 40 | It requires a running wallet on your computer. 41 | 42 | ```shell 43 | $ python3 nft.py create --help 44 | Usage: nft.py create [OPTIONS] 45 | 46 | Options: 47 | --name TEXT The name of the NFT 48 | --uri TEXT The uri of the main NFT image 49 | --royalty INTEGER The royalty percentage [default: 0] 50 | --fingerprint INTEGER The fingerprint of the key to use [optional] 51 | --fee INTEGER The XCH fee to use for this transaction [default: 0] 52 | --help Show this message and exit. 53 | ``` 54 | 55 | The following example shows the creation of an example NFT singleton. 56 | 57 | ```shell 58 | $ python3 nft.py create --name "Curly Nonchalant Marmot" --uri "https://example.com/curly-nonchalant-marmot.png" 59 | The transaction seems valid. Do you want to submit it? [y/N]: y 60 | The NFT has been submitted successfully! 61 | Please wait a few minutes for the NFT to be finalized. 62 | You can inspect your NFT using the following link: https://testnet.mintgarden.io/singletons/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 63 | 64 | 65 | Accept a offer: 66 | $python3 nft.py accept-offer --offer-id 8 --launcher-id "4e4d4bf47b26e233de96da85d132617e5aac4d8087cf61e0f17a2a7d92a1d51e" --fingerprint "3405833834" 67 | 68 | ``` 69 | 70 | ## Make a buy offer for a NFT singleton 71 | 72 | The `offer` command can be used to make an offer to buy a NFT singleton. 73 | It requires a running wallet on your computer. 74 | 75 | ```shell 76 | $ python3 nft.py offer --help 77 | Usage: nft.py offer [OPTIONS] 78 | 79 | Options: 80 | --launcher-id TEXT The ID of the NFT 81 | --price FLOAT The price (in XCH) you want to offer for this NFT singleton 82 | --fingerprint INTEGER The fingerprint of the key to use [optional] 83 | --fee INTEGER The XCH fee to use for this transaction [default: 0] 84 | --help Show this message and exit. 85 | ``` 86 | 87 | Here is an example of making a buy offer. 88 | 89 | ```shell 90 | $ python3 nft.py offer --price 0.11 --launcher-id "356eb19da1fac4490c8f83e39788d5989cc0db5a2eaf8285a58cd7f4ebe07501" 91 | You are offering 0.11 XCH for 'The fox'. Do you want to submit it? [y/N]: y 92 | Your offer has been submitted successfully! 93 | You can inspect it using the following link: https://testnet.mintgarden.io/singletons/356eb19da1fac4490c8f83e39788d5989cc0db5a2eaf8285a58cd7f4ebe07501 94 | ``` 95 | 96 | ## Accept a buy offer for a NFT singleton 97 | 98 | The `accept-offer` command can be used to accept a buy offer. 99 | It requires a running wallet on your computer. 100 | 101 | ```shell 102 | $ python3 nft.py accept-offer --help 103 | Usage: nft.py accept-offer [OPTIONS] 104 | 105 | Options: 106 | --launcher-id TEXT The ID of the NFT 107 | --offer-id TEXT The ID of the offer you want to accept 108 | --fingerprint INTEGER The fingerprint of the key to use [optional] 109 | --help Show this message and exit. 110 | ``` 111 | 112 | Here is an example of accepting a buy offer. 113 | 114 | ```shell 115 | $ python3 nft.py accept-offer --offer-id 16 --launcher-id "356eb19da1fac4490c8f83e39788d5989cc0db5a2eaf8285a58cd7f4ebe07501" 116 | You are accepting 0.11 XCH for 'The fox'. Do you want to submit it? [y/N]: y 117 | You accepted the offer! 118 | The payment is being sent to your singleton wallet address. 119 | ``` 120 | 121 | ## Cancel a buy offer for a NFT singleton 122 | 123 | The `cancel-offer` command can be used to cancel one of your buy offers. 124 | It requires a running wallet on your computer. 125 | 126 | ```shell 127 | $ python3 nft.py cancel-offer --help 128 | Usage: nft.py cancel-offer [OPTIONS] 129 | 130 | Options: 131 | --launcher-id TEXT The ID of the NFT 132 | --offer-id TEXT The ID of the offer you want to cancel 133 | --fingerprint INTEGER The fingerprint of the key to use [optional] 134 | --help Show this message and exit. 135 | ``` 136 | 137 | Here is an example of canceling a buy offer. 138 | 139 | ```shell 140 | $ python3 nft.py cancel-offer --offer-id 16 --launcher-id "356eb19da1fac4490c8f83e39788d5989cc0db5a2eaf8285a58cd7f4ebe07501" 141 | Do you want to cancel your offer of 0.11 XCH for 'The fox'? [y/N]: y 142 | You cancelled the offer. 143 | ``` 144 | 145 | ## Showing your profile 146 | 147 | The `profile` command can be used to show the singleton profile for a given wallet. 148 | It requires a running wallet on your computer. 149 | 150 | ```shell 151 | $ python3 nft.py profile --help 152 | Usage: nft.py profile [OPTIONS] 153 | 154 | Options: 155 | --fingerprint INTEGER The fingerprint of the key to use [optional] 156 | --help Show this message and exit. 157 | ``` 158 | 159 | Here is an example. 160 | 161 | ```shell 162 | $ python3 nft.py profile 163 | Choose wallet key: 164 | 1) 1105740000 165 | 2) 2244950000 166 | Enter a number to pick or q to quit: 1 167 | Your singleton profile is https://testnet.mintgarden.io/profile/991053e52414463d68cb9f8901f1bf1d7301acf2d1203b4fb28e2ea93c48f10b336a56077ac4fd9a591ce514e72beb00 168 | ``` 169 | 170 | 171 | ## Updating your profile 172 | 173 | The `update-profile` command can be used to show the singleton profile for a given wallet. 174 | It requires a running wallet on your computer. 175 | 176 | ```shell 177 | python3 nft.py update-profile --help 178 | Usage: nft.py update-profile [OPTIONS] 179 | 180 | Options: 181 | --name TEXT Your profile name 182 | --fingerprint INTEGER The fingerprint of the key to use 183 | --help Show this message and exit. 184 | ``` 185 | 186 | Here is an example. 187 | 188 | ```shell 189 | $ python3 nft.py update-profile 190 | Name: Acevail 191 | Do you want to set your profile name to Acevail? [y/N]: y 192 | Your profile has been updated! 193 | You can inspect it using the following link: https://testnet.mintgarden.io/profile/b3035d8ca2d572dec7843cc134277eec13e56c84afb2bd41ba78cb5a1b080033177433cfa8973bb5bd583ff55e96f4b4 194 | ``` 195 | 196 | ## Attribution 197 | 198 | The puzzles in this repository build on puzzles included in the [chia-blockchain](https://github.com/Chia-Network/chia-blockchain) project, which is licensed under Apache 2.0. 199 | -------------------------------------------------------------------------------- /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 2021 Andreas Greimel 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 | -------------------------------------------------------------------------------- /ownable_singleton/tests/test_ownable_singleton.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import pytest 4 | from blspy import AugSchemeMPL, G2Element, PrivateKey, G1Element 5 | from cdv.test import CoinWrapper, Wallet 6 | from cdv.test import setup as setup_test 7 | from clvm.casts import int_to_bytes 8 | 9 | from chia.consensus.default_constants import DEFAULT_CONSTANTS 10 | from chia.types.blockchain_format.coin import Coin 11 | from chia.types.condition_opcodes import ConditionOpcode 12 | from chia.types.spend_bundle import SpendBundle 13 | from chia.util.ints import uint64, uint32 14 | from chia.wallet.derive_keys import master_sk_to_singleton_owner_sk 15 | from chia.wallet.puzzles import ( 16 | singleton_top_layer, 17 | p2_delegated_puzzle_or_hidden_puzzle, 18 | ) 19 | from ownable_singleton.drivers.ownable_singleton_driver import ( 20 | create_unsigned_ownable_singleton, 21 | create_inner_puzzle, 22 | create_buy_offer, 23 | pay_to_singleton_puzzle, 24 | Owner, 25 | Royalty, 26 | ) 27 | 28 | SINGLETON_AMOUNT: uint64 = 1023 29 | 30 | 31 | def wallet_to_owner(wallet: Wallet) -> Owner: 32 | wallet_singleton_sk = master_sk_to_singleton_owner_sk(wallet.sk_, uint32(0)) 33 | singleton_wallet_puzzle = p2_delegated_puzzle_or_hidden_puzzle.puzzle_for_pk( 34 | wallet_singleton_sk.get_g1() 35 | ) 36 | 37 | return Owner(wallet_singleton_sk.get_g1(), singleton_wallet_puzzle.get_tree_hash()) 38 | 39 | 40 | async def create_singleton_spend_bundle( 41 | contribution_coin: Coin, creator_wallet, version, royalty: Royalty 42 | ): 43 | creator = wallet_to_owner(creator_wallet) 44 | 45 | genesis_create_spend = await creator_wallet.spend_coin( 46 | contribution_coin, 47 | pushtx=False, 48 | amt=SINGLETON_AMOUNT, 49 | remain=creator_wallet, 50 | custom_conditions=[ 51 | [ 52 | ConditionOpcode.CREATE_COIN, 53 | creator.puzzle_hash, 54 | SINGLETON_AMOUNT, 55 | ] 56 | ], 57 | ) 58 | genesis_coin = Coin( 59 | parent_coin_info=contribution_coin.as_coin().name(), 60 | puzzle_hash=creator.puzzle_hash, 61 | amount=SINGLETON_AMOUNT, 62 | ) 63 | genesis_coin_puzzle = p2_delegated_puzzle_or_hidden_puzzle.puzzle_for_pk( 64 | creator.public_key 65 | ) 66 | name = "Curly Nonchalant Marmot" 67 | uri = "https://example.com/curly-nonchalant-marmot.png" 68 | (coin_spends, delegated_puzzle) = create_unsigned_ownable_singleton( 69 | genesis_coin, 70 | genesis_coin_puzzle, 71 | creator, 72 | uri, 73 | name, 74 | version, 75 | royalty, 76 | ) 77 | 78 | creator_singleton_sk = master_sk_to_singleton_owner_sk( 79 | creator_wallet.sk_, uint32(0) 80 | ) 81 | synthetic_secret_key: PrivateKey = ( 82 | p2_delegated_puzzle_or_hidden_puzzle.calculate_synthetic_secret_key( 83 | creator_singleton_sk, 84 | p2_delegated_puzzle_or_hidden_puzzle.DEFAULT_HIDDEN_PUZZLE_HASH, 85 | ) 86 | ) 87 | signature = AugSchemeMPL.sign( 88 | synthetic_secret_key, 89 | ( 90 | delegated_puzzle.get_tree_hash() 91 | + genesis_coin.name() 92 | + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA 93 | ), 94 | ) 95 | singleton_spend = SpendBundle(coin_spends, signature) 96 | combined_spend = SpendBundle.aggregate([genesis_create_spend, singleton_spend]) 97 | return combined_spend, genesis_coin, singleton_spend.coin_spends[0] 98 | 99 | 100 | async def get_singleton_puzzle_owned_by_user( 101 | result, launcher_id, user, version, royalty 102 | ): 103 | owner = wallet_to_owner(user) 104 | 105 | inner_puzzle = create_inner_puzzle(version, owner, royalty) 106 | user_singleton_puzzle = singleton_top_layer.puzzle_for_singleton( 107 | launcher_id, inner_puzzle 108 | ) 109 | # singleton coin is added and owned by user 110 | filtered_result: List[Coin] = list( 111 | filter( 112 | lambda addition: (addition.amount == SINGLETON_AMOUNT) 113 | and (addition.puzzle_hash == user_singleton_puzzle.get_tree_hash()), 114 | result["additions"], 115 | ) 116 | ) 117 | assert len(filtered_result) == 1 118 | return user_singleton_puzzle 119 | 120 | 121 | async def create_buy_offer_for_user( 122 | current_owner_wallet: Wallet, 123 | buyer_wallet: Wallet, 124 | launcher_coinsol, 125 | launcher_id, 126 | payment_amount, 127 | payment_coin, 128 | singleton_coin, 129 | version, 130 | royalty, 131 | ) -> SpendBundle: 132 | current_owner = wallet_to_owner(current_owner_wallet) 133 | buyer = wallet_to_owner(buyer_wallet) 134 | 135 | buyer_singleton_sk = master_sk_to_singleton_owner_sk(buyer_wallet.sk_, uint32(0)) 136 | 137 | lineage_proof = singleton_top_layer.lineage_proof_for_coinsol(launcher_coinsol) 138 | p2_singleton_puzzle = pay_to_singleton_puzzle(launcher_id, payment_coin.puzzle_hash) 139 | payment_coin_spend = await buyer_wallet.spend_coin( 140 | payment_coin, 141 | pushtx=False, 142 | custom_conditions=[ 143 | [ 144 | ConditionOpcode.CREATE_COIN, 145 | p2_singleton_puzzle.get_tree_hash(), 146 | payment_amount, 147 | ] 148 | ], 149 | ) 150 | p2_singleton_coin = Coin( 151 | parent_coin_info=payment_coin.as_coin().name(), 152 | puzzle_hash=p2_singleton_puzzle.get_tree_hash(), 153 | amount=payment_amount, 154 | ) 155 | # Bob prepares payment bundle 156 | coin_spends = create_buy_offer( 157 | p2_singleton_coin, 158 | p2_singleton_puzzle, 159 | launcher_id, 160 | lineage_proof, 161 | singleton_coin, 162 | current_owner, 163 | buyer, 164 | payment_amount, 165 | version, 166 | royalty, 167 | ) 168 | signatures: List[G2Element] = [ 169 | AugSchemeMPL.sign( 170 | buyer_singleton_sk, 171 | buyer.puzzle_hash 172 | + singleton_coin.name() 173 | + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA, 174 | ), 175 | ] 176 | aggregated_signature = AugSchemeMPL.aggregate(signatures) 177 | assert AugSchemeMPL.aggregate_verify( 178 | [buyer.public_key], 179 | [ 180 | buyer.puzzle_hash 181 | + singleton_coin.name() 182 | + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA 183 | ], 184 | aggregated_signature, 185 | ) 186 | buy_offer = SpendBundle.aggregate( 187 | [ 188 | payment_coin_spend, 189 | SpendBundle( 190 | coin_spends, 191 | aggregated_signature, 192 | ), 193 | ] 194 | ) 195 | return buy_offer 196 | 197 | 198 | async def accept_buy_offer(singleton_coin, buy_offer, seller, payment_amount): 199 | seller_singleton_sk = master_sk_to_singleton_owner_sk(seller.sk_, uint32(0)) 200 | 201 | # ALice signs the buy offer 202 | seller_signature = AugSchemeMPL.sign( 203 | seller_singleton_sk, 204 | int_to_bytes(payment_amount) 205 | + singleton_coin.name() 206 | + DEFAULT_CONSTANTS.AGG_SIG_ME_ADDITIONAL_DATA, 207 | ) 208 | aggregated_signature = AugSchemeMPL.aggregate( 209 | [buy_offer.aggregated_signature, seller_signature] 210 | ) 211 | owner_change_spend = SpendBundle( 212 | buy_offer.coin_spends, 213 | aggregated_signature, 214 | ) 215 | return owner_change_spend 216 | 217 | 218 | testdata = [ 219 | [1, 0], 220 | [2, 0], 221 | [2, 10], 222 | ] 223 | 224 | 225 | class TestOwnableSingleton: 226 | @pytest.fixture(scope="function") 227 | async def setup(self): 228 | network, alice, bob = await setup_test() 229 | await network.farm_block() 230 | yield network, alice, bob 231 | 232 | @pytest.mark.asyncio 233 | @pytest.mark.parametrize("version,royalty_percentage", testdata) 234 | async def test_singleton_creation(self, setup, version, royalty_percentage): 235 | network, alice, bob = setup 236 | try: 237 | await network.farm_block(farmer=alice) 238 | 239 | contribution_coin: Optional[CoinWrapper] = await alice.choose_coin( 240 | SINGLETON_AMOUNT 241 | ) 242 | royalty = ( 243 | Royalty(alice.puzzle_hash, royalty_percentage) 244 | if royalty_percentage 245 | else None 246 | ) 247 | 248 | ( 249 | combined_spend, 250 | genesis_coin, 251 | launcher_coinsol, 252 | ) = await create_singleton_spend_bundle( 253 | contribution_coin, alice, version, royalty 254 | ) 255 | 256 | result = await network.push_tx(combined_spend) 257 | 258 | assert "error" not in result 259 | 260 | # Make sure there is a singleton owned by alice 261 | launcher_coin: Coin = singleton_top_layer.generate_launcher_coin( 262 | genesis_coin, 263 | SINGLETON_AMOUNT, 264 | ) 265 | launcher_id = launcher_coin.name() 266 | 267 | await get_singleton_puzzle_owned_by_user( 268 | result, launcher_id, alice, version, royalty 269 | ) 270 | 271 | finally: 272 | await network.close() 273 | 274 | @pytest.mark.asyncio 275 | @pytest.mark.parametrize("version,royalty_percentage", testdata) 276 | async def test_singleton_buy_offer(self, setup, version, royalty_percentage): 277 | network, alice, bob = setup 278 | try: 279 | await network.farm_block(farmer=alice) 280 | await network.farm_block(farmer=bob) 281 | 282 | contribution_coin: Optional[CoinWrapper] = await alice.choose_coin( 283 | SINGLETON_AMOUNT 284 | ) 285 | royalty = ( 286 | Royalty(alice.puzzle_hash, royalty_percentage) 287 | if royalty_percentage 288 | else None 289 | ) 290 | alice_initial_balance = alice.balance() 291 | 292 | ( 293 | combined_spend, 294 | genesis_coin, 295 | launcher_coinsol, 296 | ) = await create_singleton_spend_bundle( 297 | contribution_coin, alice, version, royalty 298 | ) 299 | 300 | result = await network.push_tx(combined_spend) 301 | 302 | assert "error" not in result 303 | 304 | # Make sure there is a singleton owned by alice 305 | launcher_coin: Coin = singleton_top_layer.generate_launcher_coin( 306 | genesis_coin, 307 | SINGLETON_AMOUNT, 308 | ) 309 | launcher_id = launcher_coin.name() 310 | 311 | alice_singleton_puzzle = await get_singleton_puzzle_owned_by_user( 312 | result, launcher_id, alice, version, royalty 313 | ) 314 | 315 | assert alice.balance() == alice_initial_balance - SINGLETON_AMOUNT 316 | 317 | # Eve Spend 318 | singleton_coin: Coin = next( 319 | x 320 | for x in result["additions"] 321 | if x.puzzle_hash == alice_singleton_puzzle.get_tree_hash() 322 | ) 323 | 324 | payment_amount = 10000 325 | 326 | payment_coin: Optional[CoinWrapper] = await bob.choose_coin(payment_amount) 327 | 328 | buy_offer = await create_buy_offer_for_user( 329 | alice, 330 | bob, 331 | launcher_coinsol, 332 | launcher_id, 333 | payment_amount, 334 | payment_coin, 335 | singleton_coin, 336 | version, 337 | royalty, 338 | ) 339 | 340 | accepted_buy_offer = await accept_buy_offer( 341 | singleton_coin, buy_offer, alice, payment_amount 342 | ) 343 | 344 | result = await network.push_tx(accepted_buy_offer) 345 | 346 | assert "error" not in result 347 | 348 | owner = wallet_to_owner(bob) 349 | 350 | inner_puzzle = create_inner_puzzle(version, owner, royalty) 351 | bob_singleton_puzzle = singleton_top_layer.puzzle_for_singleton( 352 | launcher_id, inner_puzzle 353 | ) 354 | # singleton coin is added and owned by user 355 | filtered_result: List[Coin] = list( 356 | filter( 357 | lambda addition: (addition.amount == SINGLETON_AMOUNT) 358 | and (addition.puzzle_hash == bob_singleton_puzzle.get_tree_hash()), 359 | result["additions"], 360 | ) 361 | ) 362 | assert len(filtered_result) == 1 363 | 364 | assert alice.balance() == alice_initial_balance - SINGLETON_AMOUNT + ( 365 | payment_amount * royalty_percentage / 100 366 | ) 367 | print(alice.usable_coins) 368 | 369 | finally: 370 | await network.close() 371 | -------------------------------------------------------------------------------- /nft.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import asyncio 3 | from typing import Optional, Tuple 4 | 5 | import aiohttp 6 | import click 7 | import requests 8 | from blspy import PrivateKey, AugSchemeMPL, G2Element 9 | from click import FLOAT, INT 10 | from clvm.casts import int_to_bytes 11 | 12 | from chia.cmds.units import units 13 | from chia.cmds.wallet_funcs import get_wallet 14 | from chia.rpc.wallet_rpc_client import WalletRpcClient 15 | from chia.types.blockchain_format.coin import Coin 16 | from chia.types.blockchain_format.program import Program 17 | from chia.types.blockchain_format.sized_bytes import bytes32 18 | from chia.types.spend_bundle import SpendBundle 19 | from chia.util.config import load_config 20 | from chia.util.default_root import DEFAULT_ROOT_PATH 21 | from chia.util.ints import uint16, uint32 22 | from chia.wallet.derive_keys import ( 23 | master_sk_to_singleton_owner_sk, 24 | master_sk_to_wallet_sk, 25 | ) 26 | from chia.wallet.puzzles import p2_delegated_puzzle_or_hidden_puzzle 27 | from chia.wallet.puzzles.singleton_top_layer import SINGLETON_LAUNCHER_HASH 28 | from chia.wallet.transaction_record import TransactionRecord 29 | from ownable_singleton.drivers.ownable_singleton_driver import ( 30 | SINGLETON_AMOUNT, 31 | create_unsigned_ownable_singleton, 32 | pay_to_singleton_puzzle, 33 | Owner, 34 | Royalty, 35 | ) 36 | 37 | AGG_SIG_ME_ADDITIONAL_DATA_TESTNET10 = bytes.fromhex( 38 | "ae83525ba8d1dd3f09b277de18ca3e43fc0af20d20c4b3e92ef2a48bd291ccb2" 39 | ) 40 | 41 | SINGLETON_GALLERY_API = "https://testnet.mintgarden.io/api" 42 | SINGLETON_GALLERY_FRONTEND = "https://testnet.mintgarden.io" 43 | 44 | 45 | # Loading the client requires the standard chia root directory configuration that all of the chia commands rely on 46 | async def get_client() -> Optional[WalletRpcClient]: 47 | config = load_config(DEFAULT_ROOT_PATH, "config.yaml") 48 | self_hostname = config["self_hostname"] 49 | wallet_rpc_port = config["wallet"]["rpc_port"] 50 | 51 | try: 52 | wallet_client = await WalletRpcClient.create( 53 | self_hostname, uint16(wallet_rpc_port), DEFAULT_ROOT_PATH, config 54 | ) 55 | return wallet_client 56 | except Exception as e: 57 | if isinstance(e, aiohttp.ClientConnectorError): 58 | print(f"Connection error. Check if wallet is running at {wallet_rpc_port}") 59 | else: 60 | print(f"Exception from 'wallets' {e}") 61 | return None 62 | 63 | 64 | def master_sk_to_wallet_puzhash(master_sk: PrivateKey) -> bytes32: 65 | wallet_sk = master_sk_to_wallet_sk(master_sk, uint32(0)) 66 | wallet_puzzle = p2_delegated_puzzle_or_hidden_puzzle.puzzle_for_pk( 67 | wallet_sk.get_g1() 68 | ) 69 | return wallet_puzzle.get_tree_hash() 70 | 71 | 72 | async def get_singleton_wallet(fingerprint: int) -> Tuple[PrivateKey, int]: 73 | try: 74 | wallet_client: WalletRpcClient = await get_client() 75 | wallet_client_f, fingerprint = await get_wallet(wallet_client, fingerprint) 76 | 77 | private_key = await wallet_client.get_private_key(fingerprint) 78 | master_sk = PrivateKey.from_bytes(bytes.fromhex(private_key["sk"])) 79 | singleton_sk = master_sk_to_singleton_owner_sk(master_sk, uint32(0)) 80 | 81 | return singleton_sk, fingerprint 82 | finally: 83 | wallet_client.close() 84 | await wallet_client.await_closed() 85 | 86 | 87 | async def create_genesis_coin( 88 | fingerprint, amt, fee 89 | ) -> [TransactionRecord, PrivateKey, bytes32]: 90 | try: 91 | wallet_client: WalletRpcClient = await get_client() 92 | wallet_client_f, fingerprint = await get_wallet(wallet_client, fingerprint) 93 | 94 | private_key = await wallet_client.get_private_key(fingerprint) 95 | master_sk = PrivateKey.from_bytes(bytes.fromhex(private_key["sk"])) 96 | singleton_sk = master_sk_to_singleton_owner_sk(master_sk, uint32(0)) 97 | 98 | singleton_wallet_puzhash = p2_delegated_puzzle_or_hidden_puzzle.puzzle_for_pk( 99 | singleton_sk.get_g1() 100 | ).get_tree_hash() 101 | 102 | signed_tx = await wallet_client.create_signed_transaction( 103 | [{"puzzle_hash": singleton_wallet_puzhash, "amount": amt}], fee=fee 104 | ) 105 | return signed_tx, singleton_sk, master_sk_to_wallet_puzhash(master_sk) 106 | finally: 107 | wallet_client.close() 108 | await wallet_client.await_closed() 109 | 110 | 111 | async def create_p2_singleton_coin( 112 | fingerprint: Optional[int], launcher_id: str, amt: int, fee: int 113 | ) -> [TransactionRecord, Program, PrivateKey, bytes32]: 114 | try: 115 | wallet_client: WalletRpcClient = await get_client() 116 | wallet_client_f, fingerprint = await get_wallet(wallet_client, fingerprint) 117 | 118 | private_key = await wallet_client.get_private_key(fingerprint) 119 | master_sk = PrivateKey.from_bytes(bytes.fromhex(private_key["sk"])) 120 | singleton_sk = master_sk_to_singleton_owner_sk(master_sk, uint32(0)) 121 | 122 | dummy_p2_singleton_puzzle = pay_to_singleton_puzzle(launcher_id, (b"0" * 32)) 123 | 124 | signed_tx = await wallet_client.create_signed_transaction( 125 | [{"puzzle_hash": dummy_p2_singleton_puzzle.get_tree_hash(), "amount": amt}], 126 | fee=fee, 127 | ) 128 | spent_coin = signed_tx.removals[0] 129 | 130 | p2_singleton_puzzle = pay_to_singleton_puzzle( 131 | bytes.fromhex(launcher_id), spent_coin.puzzle_hash 132 | ) 133 | signed_tx = await wallet_client.create_signed_transaction( 134 | [{"puzzle_hash": p2_singleton_puzzle.get_tree_hash(), "amount": amt}], 135 | fee=fee, 136 | coins=signed_tx.removals, 137 | ) 138 | 139 | return ( 140 | signed_tx, 141 | p2_singleton_puzzle, 142 | singleton_sk, 143 | master_sk_to_wallet_puzhash(master_sk), 144 | ) 145 | finally: 146 | wallet_client.close() 147 | await wallet_client.await_closed() 148 | 149 | 150 | async def sign_offer( 151 | fingerprint: Optional[int], price: int, singleton_id: str 152 | ) -> [TransactionRecord, Program, PrivateKey]: 153 | try: 154 | wallet_client: WalletRpcClient = await get_client() 155 | wallet_client_f, fingerprint = await get_wallet(wallet_client, fingerprint) 156 | 157 | private_key = await wallet_client.get_private_key(fingerprint) 158 | master_sk = PrivateKey.from_bytes(bytes.fromhex(private_key["sk"])) 159 | singleton_sk = master_sk_to_singleton_owner_sk(master_sk, uint32(0)) 160 | 161 | return AugSchemeMPL.sign( 162 | singleton_sk, 163 | int_to_bytes(price) 164 | + bytes.fromhex(singleton_id) 165 | + AGG_SIG_ME_ADDITIONAL_DATA_TESTNET10, 166 | ) 167 | finally: 168 | wallet_client.close() 169 | await wallet_client.await_closed() 170 | 171 | 172 | @click.group() 173 | def cli(): 174 | pass 175 | 176 | 177 | @cli.command() 178 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 179 | def profile(fingerprint: int): 180 | singleton_sk: PrivateKey 181 | singleton_sk, _ = asyncio.get_event_loop().run_until_complete( 182 | get_singleton_wallet(fingerprint) 183 | ) 184 | 185 | click.echo( 186 | f"Your singleton profile is {SINGLETON_GALLERY_FRONTEND}/profile/{bytes(singleton_sk.get_g1()).hex()}" 187 | ) 188 | 189 | 190 | @cli.command() 191 | @click.option("--name", prompt=True, help="Your profile name") 192 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 193 | def update_profile(name: str, fingerprint: int): 194 | singleton_sk: PrivateKey 195 | singleton_sk, _ = asyncio.get_event_loop().run_until_complete( 196 | get_singleton_wallet(fingerprint) 197 | ) 198 | 199 | public_key = singleton_sk.get_g1() 200 | signature = AugSchemeMPL.sign( 201 | singleton_sk, 202 | bytes(public_key) + bytes(name, "utf-8"), 203 | ) 204 | 205 | if click.confirm(f"Do you want to set your profile name to {name}?"): 206 | response = requests.patch( 207 | f"{SINGLETON_GALLERY_API}/profile/{public_key}", 208 | json={"signature": bytes(signature).hex(), "name": name}, 209 | ) 210 | if response.status_code != 200: 211 | click.secho("Failed to update profile:", err=True, fg="red") 212 | click.secho(response.text, err=True, fg="red") 213 | else: 214 | click.secho("Your profile has been updated!", fg="green") 215 | click.echo( 216 | f"You can inspect it using the following link: {SINGLETON_GALLERY_FRONTEND}/profile/{public_key}" 217 | ) 218 | 219 | 220 | @cli.command() 221 | @click.option("--name", prompt=True, help="The name of the NFT") 222 | @click.option("--uri", prompt=True, help="The uri of the main NFT image") 223 | @click.option( 224 | "-r", 225 | "--royalty", 226 | "royalty_percentage", 227 | type=INT, 228 | prompt=True, 229 | help="The percentage of each sale you want to receive as royalty.", 230 | default=0, 231 | show_default=True, 232 | ) 233 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 234 | @click.option( 235 | "--fee", 236 | type=FLOAT, 237 | required=True, 238 | default=0, 239 | show_default=True, 240 | help="The XCH fee to use for this transaction", 241 | ) 242 | def create(name: str, uri: str, fingerprint: int, royalty_percentage: int, fee: int): 243 | if royalty_percentage > 99 or royalty_percentage < 0: 244 | click.secho( 245 | f"Royalty percentage has to be between 1 and 99.", err=True, fg="red" 246 | ) 247 | return 248 | 249 | signed_tx: TransactionRecord 250 | owner_sk: PrivateKey 251 | wallet_puzzle_hash: bytes32 252 | ( 253 | signed_tx, 254 | owner_sk, 255 | wallet_puzzle_hash, 256 | ) = asyncio.get_event_loop().run_until_complete( 257 | create_genesis_coin(fingerprint, SINGLETON_AMOUNT, fee) 258 | ) 259 | genesis_coin: Coin = next( 260 | coin for coin in signed_tx.additions if coin.amount == SINGLETON_AMOUNT 261 | ) 262 | genesis_puzzle = p2_delegated_puzzle_or_hidden_puzzle.puzzle_for_pk( 263 | owner_sk.get_g1() 264 | ) 265 | creator = Owner(owner_sk.get_g1(), wallet_puzzle_hash) 266 | royalty = ( 267 | Royalty(creator.puzzle_hash, royalty_percentage) 268 | if royalty_percentage > 0 269 | else None 270 | ) 271 | 272 | coin_spends, delegated_puzzle = create_unsigned_ownable_singleton( 273 | genesis_coin, genesis_puzzle, creator, uri, name, version=2, royalty=royalty 274 | ) 275 | 276 | synthetic_secret_key: PrivateKey = ( 277 | p2_delegated_puzzle_or_hidden_puzzle.calculate_synthetic_secret_key( 278 | owner_sk, 279 | p2_delegated_puzzle_or_hidden_puzzle.DEFAULT_HIDDEN_PUZZLE_HASH, 280 | ) 281 | ) 282 | signature = AugSchemeMPL.sign( 283 | synthetic_secret_key, 284 | ( 285 | delegated_puzzle.get_tree_hash() 286 | + genesis_coin.name() 287 | + AGG_SIG_ME_ADDITIONAL_DATA_TESTNET10 288 | ), 289 | ) 290 | 291 | combined_spend_bundle: SpendBundle = SpendBundle.aggregate( 292 | [signed_tx.spend_bundle, SpendBundle(coin_spends, signature)] 293 | ) 294 | 295 | if click.confirm("The transaction seems valid. Do you want to submit it?"): 296 | response = requests.post( 297 | f"{SINGLETON_GALLERY_API}/singletons/submit", 298 | json=combined_spend_bundle.to_json_dict( 299 | include_legacy_keys=False, exclude_modern_keys=False 300 | ), 301 | ) 302 | if response.status_code != 200: 303 | click.secho("Failed to submit NFT:", err=True, fg="red") 304 | click.secho(response.text, err=True, fg="red") 305 | else: 306 | launcher_coin_record = next( 307 | coin 308 | for coin in combined_spend_bundle.coin_spends 309 | if coin.coin.puzzle_hash == SINGLETON_LAUNCHER_HASH 310 | ) 311 | click.secho("Your NFT has been submitted successfully!", fg="green") 312 | click.echo( 313 | "Please wait a few minutes until the NFT has been added to the blockchain." 314 | ) 315 | click.echo( 316 | f"You can inspect your NFT using the following link: {SINGLETON_GALLERY_FRONTEND}/singletons/{launcher_coin_record.coin.name()}?pending=1" 317 | ) 318 | 319 | 320 | @cli.command() 321 | @click.option("--launcher-id", prompt=True, help="The ID of the NFT") 322 | @click.option( 323 | "--price", 324 | type=float, 325 | prompt=True, 326 | help="The price (in XCH) you want to offer for this NFT singleton", 327 | ) 328 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 329 | @click.option( 330 | "--fee", 331 | required=True, 332 | default=0, 333 | show_default=True, 334 | help="The XCH fee to use for this transaction", 335 | ) 336 | def offer(launcher_id: str, price: float, fingerprint: Optional[int], fee: int): 337 | response = requests.get(f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}") 338 | if response.status_code != 200: 339 | click.secho( 340 | f"Could not find an NFT with ID '{launcher_id}'", err=True, fg="red" 341 | ) 342 | return 343 | singleton = response.json() 344 | name = singleton["name"] 345 | owner = singleton["owner"] 346 | 347 | price_in_mojo = int(price * units["chia"]) 348 | 349 | try: 350 | signed_tx: TransactionRecord 351 | p2_singleton_puzzle: Program 352 | owner_sk: PrivateKey 353 | wallet_puzzle_hash: bytes32 354 | ( 355 | signed_tx, 356 | p2_singleton_puzzle, 357 | owner_sk, 358 | wallet_puzzle_hash, 359 | ) = asyncio.get_event_loop().run_until_complete( 360 | create_p2_singleton_coin(fingerprint, launcher_id, price_in_mojo, fee) 361 | ) 362 | p2_singleton_coin: Coin = next( 363 | coin 364 | for coin in signed_tx.additions 365 | if coin.puzzle_hash == p2_singleton_puzzle.get_tree_hash() 366 | ) 367 | except TypeError: 368 | return 369 | 370 | new_owner_pubkey = owner_sk.get_g1() 371 | if owner == bytes(new_owner_pubkey).hex(): 372 | click.secho( 373 | "This is your singleton, you can't create an offer for it.", fg="yellow" 374 | ) 375 | return 376 | 377 | singleton_signature = AugSchemeMPL.sign( 378 | owner_sk, 379 | wallet_puzzle_hash 380 | + bytes.fromhex(singleton["singleton_id"]) 381 | + AGG_SIG_ME_ADDITIONAL_DATA_TESTNET10, 382 | ) 383 | payment_spend_bundle = SpendBundle.aggregate( 384 | [signed_tx.spend_bundle, SpendBundle([], singleton_signature)] 385 | ) 386 | 387 | if click.confirm( 388 | f"You are offering {price} XCH for '{name}'. Do you want to submit it?" 389 | ): 390 | response = requests.post( 391 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}/offers/submit", 392 | json={ 393 | "payment_spend_bundle": payment_spend_bundle.to_json_dict( 394 | include_legacy_keys=False, exclude_modern_keys=False 395 | ), 396 | "p2_singleton_coin": p2_singleton_coin.to_json_dict(), 397 | "p2_singleton_puzzle": bytes(p2_singleton_puzzle).hex(), 398 | "new_owner_pubkey": bytes(new_owner_pubkey).hex(), 399 | "new_owner_puzhash": wallet_puzzle_hash.hex(), 400 | "price": price_in_mojo, 401 | }, 402 | ) 403 | if response.status_code != 200: 404 | click.secho("Failed to submit offer:", err=True, fg="red") 405 | click.secho(response.text, err=True, fg="red") 406 | else: 407 | click.secho("Your offer has been submitted successfully!", fg="green") 408 | click.echo( 409 | f"You can inspect it using the following link: {SINGLETON_GALLERY_FRONTEND}/singletons/{launcher_id}" 410 | ) 411 | 412 | 413 | @cli.command() 414 | @click.option("--launcher-id", prompt=True, help="The ID of the NFT") 415 | @click.option("--offer-id", prompt=True, help="The ID of the offer you want to accept") 416 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 417 | def accept_offer(launcher_id: str, offer_id: str, fingerprint: Optional[int]): 418 | singleton_response = requests.get( 419 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}" 420 | ) 421 | if singleton_response.status_code != 200: 422 | click.secho( 423 | f"Could not find an NFT with ID '{launcher_id}'", err=True, fg="red" 424 | ) 425 | return 426 | name = singleton_response.json()["name"] 427 | royalty_percentage = singleton_response.json()["royalty_percentage"] 428 | 429 | offer_response = requests.get( 430 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}/offers/{offer_id}" 431 | ) 432 | if offer_response.status_code != 200: 433 | click.secho( 434 | f"Could not find an offer with ID '{offer_id}' for NFT '{name}'.", 435 | err=True, 436 | fg="yellow", 437 | ) 438 | return 439 | offer = offer_response.json() 440 | price = offer["price"] 441 | price_in_chia = price / units["chia"] 442 | 443 | price_signature: G2Element = asyncio.get_event_loop().run_until_complete( 444 | sign_offer(fingerprint, price, offer["singleton_id"]) 445 | ) 446 | 447 | royalty_text = ( 448 | f" A share of {royalty_percentage}% of that price is sent to its creator." 449 | if royalty_percentage > 0 450 | else "" 451 | ) 452 | if click.confirm( 453 | f"You are accepting {price_in_chia} XCH for '{name}'.{royalty_text} Do you want to submit it?" 454 | ): 455 | response = requests.post( 456 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}/offers/{offer_id}/accept", 457 | json={ 458 | "price_signature": bytes(price_signature).hex(), 459 | }, 460 | ) 461 | if response.status_code != 200: 462 | click.secho("Failed to accept offer:", err=True, fg="red") 463 | click.secho(response.text, err=True, fg="red") 464 | else: 465 | click.secho("You accepted the offer!", fg="green") 466 | click.echo(f"The payment is being sent to your wallet address.") 467 | 468 | 469 | @cli.command() 470 | @click.option("--launcher-id", prompt=True, help="The ID of the NFT") 471 | @click.option("--offer-id", prompt=True, help="The ID of the offer you want to cancel") 472 | @click.option("--fingerprint", type=int, help="The fingerprint of the key to use") 473 | def cancel_offer(launcher_id: str, offer_id: str, fingerprint: Optional[int]): 474 | singleton_response = requests.get( 475 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}" 476 | ) 477 | if singleton_response.status_code != 200: 478 | click.secho( 479 | f"Could not find an NFT with ID '{launcher_id}'", err=True, fg="red" 480 | ) 481 | return 482 | name = singleton_response.json()["name"] 483 | 484 | offer_response = requests.get( 485 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}/offers/{offer_id}" 486 | ) 487 | if offer_response.status_code != 200: 488 | click.secho( 489 | f"Could not find an offer with ID '{offer_id}' for NFT '{name}'.", 490 | err=True, 491 | fg="yellow", 492 | ) 493 | return 494 | offer = offer_response.json() 495 | price = offer["price"] 496 | price_in_chia = price / units["chia"] 497 | 498 | singleton_sk: PrivateKey 499 | (singleton_sk, fingerprint) = asyncio.get_event_loop().run_until_complete( 500 | get_singleton_wallet(fingerprint) 501 | ) 502 | if offer["new_owner_public_key"] != bytes(singleton_sk.get_g1()).hex(): 503 | click.secho(f"This is not your offer.", err=True, fg="red") 504 | return 505 | 506 | price_signature: G2Element = asyncio.get_event_loop().run_until_complete( 507 | sign_offer(fingerprint, price, offer["singleton_id"]) 508 | ) 509 | 510 | if click.confirm( 511 | f"Do you want to cancel your offer of {price_in_chia} XCH for '{name}'?" 512 | ): 513 | response = requests.delete( 514 | f"{SINGLETON_GALLERY_API}/singletons/{launcher_id}/offers/{offer_id}", 515 | json={ 516 | "price_signature": bytes(price_signature).hex(), 517 | }, 518 | ) 519 | if response.status_code != 200: 520 | click.secho("Failed to cancel offer:", err=True, fg="red") 521 | click.secho(response.text, err=True, fg="red") 522 | else: 523 | click.secho("You cancelled the offer.", fg="green") 524 | 525 | 526 | if __name__ == "__main__": 527 | cli() 528 | --------------------------------------------------------------------------------