├── .gitignore ├── LICENSE ├── README.md ├── compile.py ├── contracts └── NEP11 │ └── NEP11-Template.py └── tests └── test_nep11.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | __pycache__ 4 | *.nef 5 | *.manifest.json 6 | *.nefdbgnfo 7 | dist 8 | build 9 | _build 10 | neo3_boa.egg-info 11 | /neo-devpack-dotnet/ 12 | /Neo.TestEngine/ 13 | test-engine-test.json 14 | autopep8 15 | .vscode 16 | n3 17 | .DS_Store 18 | n3-boa 19 | -------------------------------------------------------------------------------- /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 | Copyright 2023 OnBlockIO, LDA 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NEP11-Template.py 2 | Fully featured NEP-11 python contract to allow to easily deploy your own NFT minting contract, and make it seamless to integrate it on GhostMarket. 3 | 4 | 5 | ## Contract overview 6 | 7 | The contract features a generic mint() method, a burn() method, has builtin support for royalties (both GhostMarket and NEO official standard), locked content and is pausable and updatable (restricted to owner). On top of that its possible to add or remove authorized addresses, allowed to interact with the contract admin functions (pause / mint / etc.) 8 | 9 | 10 | ## What to replace 11 | 12 | `manifest_metadata` section : fill out author/description/email accordingly 13 | 14 | `TOKEN_SYMBOL` : replace with your desired SYMBOL 15 | 16 | `mint` : either leave as is, to allow anyone to mint on the contract, or uncomment `verify()` to restrict minting to one of the authorized addresses 17 | 18 | 19 | ## Royalties 20 | 21 | This template features royalties for NFT through two standard: GhostMarket standard and NEO official standard. 22 | For each sale happening on GhostMarket trading contract, a configurable percentage will be sent to the original creator (minter) if configured (or multiple ones). 23 | For convenience sake, both are supported on this contract, and any NFT minted support both. 24 | 25 | The details have to be passed as an array during minting, and follow a json structure. 26 | 27 | Note that the value is in BPS (ie 10% is 1000). We support multiple royalties, up to a maximum combined of 50% royalties. Note that if a NFT has royalties, our current implementation prevent it to be traded against indivisible currencies (like NEO), but if it does not have royalties it's allowed. 28 | 29 | `[{"address":"NNau7VyBMQno89H8aAyirVJTdyLVeRxHGy","value":"1000"}]` 30 | or 31 | `[{"address":"NNau7VyBMQno89H8aAyirVJTdyLVeRxHGy","value":1000}]` 32 | 33 | where `NNau7VyBMQno89H8aAyirVJTdyLVeRxHGy` would be getting 10% of all sales as royalties. 34 | 35 | 36 | ## Locked Content 37 | 38 | This template features the built-in feature of GhostMarket related to lock content. When a NFT is minted directly from GhostMarket UI with this template, you can decide to store content (which is encrypted before storing it on smart contract), which can then only be retrieved by the current NFT owner (through a custom proprietary GhostMarket API). 39 | This is totally optional, and is currently only supported for NFT minted directly from GhostMarket (for the ones using this template), as it requires encryption before minting. 40 | 41 | ## Metadata 42 | 43 | This contract features two methods to handle properties: 44 | 45 | `properties` and `propertiesJson` 46 | 47 | `properties` returns a MAP of all the NFT metadata, and is what follows NEP-11 standard (even though currently the standard is inconsistent as the signature shows it should be a MAP, while the explanation tied to it shows it should be a serialized NVM object). 48 | 49 | `propertiesJson` returns a serialized JSON string of all the NFT metadata, and is what makes more sense for us to handle metadata. 50 | 51 | This contract supports both methods for convenience purposes. 52 | 53 | ### Compiling contract 54 | Currently tested and working with neo3-boa 1.2.1 55 | 56 | ``` 57 | ./compile.py 58 | or 59 | neo3-boa compile NEP11-Template.py 60 | ``` 61 | 62 | ### Deploying from neo-cli 63 | ``` 64 | open wallet 65 | deploy 66 | ``` 67 | 68 | ### Upgrading from neo-cli 69 | ``` 70 | open wallet 71 | update 72 | ``` 73 | 74 | ## Testing 75 | 76 | Dependencies required to be installed for testing: 77 | 78 | ``` 79 | pip install "neo3-boa[test]" 80 | pip3 install typing-extensions 81 | ``` 82 | 83 | tests can be run with: 84 | 85 | ``` 86 | python3 -m unittest test_nep11 87 | ``` 88 | 89 | individual test can be run witn 90 | ``` 91 | python3 -m unittest test_nep11.TestNEP11.test_decimals 92 | ``` 93 | 94 | -------------------------------------------------------------------------------- /compile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from boa3.boa3 import Boa3 6 | 7 | from contextlib import contextmanager 8 | import sys, os 9 | 10 | @contextmanager 11 | def suppress_stdout(): 12 | with open(os.devnull, "w") as devnull: 13 | old_stdout = sys.stdout 14 | sys.stdout = devnull 15 | try: 16 | yield 17 | finally: 18 | sys.stdout = old_stdout 19 | 20 | def cleanup(cleaned=False): 21 | if not cleaned: 22 | if os.path.exists(CONTRACT_PATH_NEF): 23 | os.remove(CONTRACT_PATH_NEF) 24 | if os.path.exists(CONTRACT_PATH_NEFDBG): 25 | os.remove(CONTRACT_PATH_NEFDBG) 26 | if os.path.exists(CONTRACT_PATH_JSON): 27 | os.remove(CONTRACT_PATH_JSON) 28 | else: 29 | if os.path.exists(CONTRACT_PATH_PY_CLEANED): 30 | os.remove(CONTRACT_PATH_PY_CLEANED) 31 | 32 | def preprocess_contract(to_remove, path, path_cleaned, base_path): 33 | with open(path) as oldfile, open(path_cleaned, 'w') as newfile: 34 | debug_block = False 35 | for line in oldfile: 36 | 37 | if any(dbg_block in line for dbg_block in list(debug_block_start)): 38 | print("found start") 39 | debug_block = True 40 | 41 | if any(dbg_block in line for dbg_block in list(debug_block_end)): 42 | print("found end") 43 | debug_block = False 44 | continue 45 | 46 | if debug_block: 47 | continue 48 | 49 | if not any(to_remove in line for to_remove in to_remove): 50 | newfile.write(line) 51 | os.rename(path, base_path + "/temp.py") 52 | os.rename(path_cleaned, path) 53 | 54 | def build_contract(path): 55 | Boa3.compile_and_save(path) 56 | 57 | GHOST_ROOT = str(os.getcwd()) 58 | to_remove = ['debug('] 59 | debug_block_start = ['# DEBUG_START'] 60 | debug_block_end = ['# DEBUG_END'] 61 | 62 | CONTRACT_DIR = GHOST_ROOT + '/contracts/NEP11/' 63 | CONTRACT_PATH_PY = GHOST_ROOT + '/contracts/NEP11/NEP11-Template.py' 64 | CONTRACT_PATH_JSON = GHOST_ROOT + '/contracts/NEP11/NEP11-Template.manifest.json' 65 | CONTRACT_PATH_NEFDBG = GHOST_ROOT + '/contracts/NEP11/NEP11-Template.nefdbgnfo' 66 | CONTRACT_PATH_NEF = GHOST_ROOT + '/contracts/NEP11/NEP11-Template.nef' 67 | 68 | CONTRACT_PATH_PY_CLEANED = GHOST_ROOT + '/contracts/NEP11/NEP11-Template_cleaned.py' 69 | 70 | cleanup() 71 | preprocess_contract(to_remove, CONTRACT_PATH_PY, CONTRACT_PATH_PY_CLEANED, CONTRACT_DIR) 72 | with suppress_stdout(): 73 | build_contract(CONTRACT_PATH_PY) 74 | os.rename(CONTRACT_DIR + "/temp.py", CONTRACT_PATH_PY) 75 | 76 | cleanup(True) 77 | -------------------------------------------------------------------------------- /contracts/NEP11/NEP11-Template.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Union, cast 2 | 3 | from boa3.builtin.compile_time import CreateNewEvent, NeoMetadata, public 4 | from boa3.builtin.type.helper import to_bytes 5 | from boa3.builtin.interop.blockchain import get_contract, Transaction 6 | from boa3.builtin.interop.contract import CallFlags, call_contract, destroy_contract, get_call_flags, update_contract 7 | from boa3.builtin.interop.runtime import check_witness, script_container 8 | from boa3.builtin.interop.stdlib import serialize, deserialize, atoi 9 | from boa3.builtin.interop.storage import delete, get, get_int, get_bool, get_uint160, put, put_bool, put_int, put_uint160, put_str, find, get_read_only_context 10 | from boa3.builtin.interop.storage.findoptions import FindOptions 11 | from boa3.builtin.interop.iterator import Iterator 12 | from boa3.builtin.contract import abort 13 | from boa3.builtin.type import UInt160 14 | from boa3.builtin.interop.json import json_deserialize 15 | from boa3.builtin.interop.runtime import get_network 16 | from boa3.builtin.contract import to_script_hash 17 | 18 | 19 | # ------------------------------------------- 20 | # METADATA 21 | # ------------------------------------------- 22 | def manifest_metadata() -> NeoMetadata: 23 | """ 24 | Defines this smart contract's metadata information 25 | """ 26 | meta = NeoMetadata() 27 | meta.author = "Template Author" # TODO_TEMPLATE 28 | meta.description = "Some Description" # TODO_TEMPLATE 29 | meta.email = "hello@example.com" # TODO_TEMPLATE 30 | meta.supported_standards = ["NEP-11", "NEP-24"] 31 | meta.source = "https://github.com/" # TODO_TEMPLATE 32 | # meta.add_permission(contract='*', methods=['*']) 33 | return meta 34 | 35 | # ------------------------------------------- 36 | # TOKEN SETTINGS 37 | # ------------------------------------------- 38 | 39 | # Symbol of the Token 40 | TOKEN_SYMBOL = 'EXMP' # TODO_TEMPLATE 41 | 42 | # Number of decimal places 43 | TOKEN_DECIMALS = 0 44 | 45 | # Whether the smart contract was deployed or not 46 | DEPLOYED = b'deployed' 47 | 48 | # Whether the smart contract is paused or not 49 | PAUSED = b'paused' 50 | 51 | 52 | # ------------------------------------------- 53 | # PREFIXES 54 | # ------------------------------------------- 55 | 56 | ACCOUNT_PREFIX = b'ACC' 57 | TOKEN_PREFIX = b'TPF' 58 | TOKEN_DATA_PREFIX = b'TDP' 59 | LOCKED_PREFIX = b'LCP' 60 | BALANCE_PREFIX = b'BLP' 61 | SUPPLY_PREFIX = b'SPP' 62 | META_PREFIX = b'MDP' 63 | LOCKED_VIEW_COUNT_PREFIX = b'LVCP' 64 | ROYALTIES_PREFIX = b'RYP' 65 | 66 | 67 | # ------------------------------------------- 68 | # KEYS 69 | # ------------------------------------------- 70 | 71 | TOKEN_COUNT = b'TOKEN_COUNT' 72 | AUTH_ADDRESSES = b'AUTH_ADDRESSES' 73 | 74 | 75 | # ------------------------------------------- 76 | # EVENTS 77 | # ------------------------------------------- 78 | 79 | on_transfer = CreateNewEvent( 80 | # trigger when tokens are transferred, including zero value transfers. 81 | [ 82 | ('from_addr', Union[UInt160, None]), 83 | ('to_addr', Union[UInt160, None]), 84 | ('amount', int), 85 | ('tokenId', bytes) 86 | ], 87 | 'Transfer' 88 | ) 89 | 90 | on_auth = CreateNewEvent( 91 | # trigger when an address has been authorized/whitelisted. 92 | [ 93 | ('authorized', UInt160), 94 | ('type', int), 95 | ('add', bool), 96 | ], 97 | 'Authorized' 98 | ) 99 | 100 | on_unlock = CreateNewEvent( 101 | [ 102 | ('tokenId', bytes), 103 | ('counter', int) 104 | ], 105 | 'UnlockIncremented' 106 | ) 107 | 108 | # DEBUG_START 109 | # ------------------------------------------- 110 | # DEBUG 111 | # ------------------------------------------- 112 | 113 | on_debug = CreateNewEvent( 114 | [ 115 | ('params', list), 116 | ], 117 | 'Debug' 118 | ) 119 | 120 | def debug(params: list): 121 | allow_notify = get_call_flags() & CallFlags.ALLOW_NOTIFY 122 | if allow_notify == CallFlags.ALLOW_NOTIFY: 123 | on_debug(params) 124 | 125 | # DEBUG_END 126 | 127 | # ------------------------------------------- 128 | # NEP-11 METHODS 129 | # ------------------------------------------- 130 | 131 | @public(safe=True) 132 | def symbol() -> str: 133 | """ 134 | Gets the symbols of the token. 135 | 136 | This string must be valid ASCII, must not contain whitespace or control characters, should be limited to uppercase 137 | Latin alphabet (i.e. the 26 letters used in English) and should be short (3-8 characters is recommended). 138 | This method must always return the same value every time it is invoked. 139 | 140 | :return: a short string representing symbol of the token managed in this contract. 141 | """ 142 | debug(['symbol: ', TOKEN_SYMBOL]) 143 | return TOKEN_SYMBOL 144 | 145 | @public(safe=True) 146 | def decimals() -> int: 147 | """ 148 | Gets the amount of decimals used by the token. 149 | 150 | E.g. 8, means to divide the token amount by 100,000,000 (10 ^ 8) to get its user representation. 151 | This method must always return the same value every time it is invoked. 152 | 153 | :return: the number of decimals used by the token. 154 | """ 155 | debug(['decimals: ', TOKEN_DECIMALS]) 156 | return TOKEN_DECIMALS 157 | 158 | @public(safe=True) 159 | def totalSupply() -> int: 160 | """ 161 | Gets the total token supply deployed in the system. 162 | 163 | This number must not be in its user representation. E.g. if the total supply is 10,000,000 tokens, this method 164 | must return 10,000,000 * 10 ^ decimals. 165 | 166 | :return: the total token supply deployed in the system. 167 | """ 168 | debug(['totalSupply: ', get_int(SUPPLY_PREFIX)]) 169 | return get_int(SUPPLY_PREFIX, get_read_only_context()) 170 | 171 | @public(safe=True) 172 | def balanceOf(owner: UInt160) -> int: 173 | """ 174 | Get the current balance of an address 175 | 176 | The parameter owner must be a 20-byte address represented by a UInt160. 177 | 178 | :param owner: the owner address to retrieve the balance for 179 | :type owner: UInt160 180 | :return: the total amount of tokens owned by the specified address. 181 | :raise AssertionError: raised if `owner` length is not 20. 182 | """ 183 | expect(validateAddress(owner), "balanceOf - not a valid address") 184 | debug(['balanceOf: ', get_int(mk_balance_key(owner), get_read_only_context())]) 185 | return get_int(mk_balance_key(owner), get_read_only_context()) 186 | 187 | @public(safe=True) 188 | def tokensOf(owner: UInt160) -> Iterator: 189 | """ 190 | Get all of the token ids owned by the specified address 191 | 192 | The parameter owner must be a 20-byte address represented by a UInt160. 193 | 194 | :param owner: the owner address to retrieve the tokens for 195 | :type owner: UInt160 196 | :return: an iterator that contains all of the token ids owned by the specified address. 197 | :raise AssertionError: raised if `owner` length is not 20. 198 | """ 199 | expect(validateAddress(owner), "tokensOf - not a valid address") 200 | flags = FindOptions.REMOVE_PREFIX | FindOptions.KEYS_ONLY 201 | return find(mk_account_key(owner), get_read_only_context(), flags) 202 | 203 | @public(name='onNEP11Payment') 204 | def on_nep11_payment(from_address: UInt160, amount: int, token_id: bytes, data: Any): 205 | """ 206 | This contract will not receive another NEP-11 token. 207 | 208 | :param from_address: the address of the one who is trying to send cryptocurrency to this smart contract 209 | :type from_address: UInt160 210 | :param amount: the amount of cryptocurrency that is being sent to the this smart contract 211 | :type amount: int 212 | :param token_id: the id of the token that is being sent 213 | :type token_id: bytes 214 | :param data: any pertinent data that might validate the transaction 215 | :type data: Any 216 | """ 217 | abort() 218 | 219 | @public 220 | def transfer(to: UInt160, tokenId: bytes, data: Any) -> bool: 221 | """ 222 | Transfers the token with id tokenId to address to 223 | 224 | The parameter to SHOULD be a 20-byte address. If not, this method SHOULD throw an exception. 225 | The parameter tokenId SHOULD be a valid NFT. If not, this method SHOULD throw an exception. 226 | If the method succeeds, it MUST fire the Transfer event, and MUST return true, even if the token is sent to the owner. 227 | If the receiver is a deployed contract, the function MUST call onNEP11Payment method on receiver contract with the 228 | data parameter from transfer AFTER firing the Transfer event. 229 | 230 | The function SHOULD check whether the owner address equals the caller contract hash. If so, the transfer SHOULD be 231 | processed; If not, the function SHOULD use the SYSCALL Neo.Runtime.CheckWitness to verify the transfer. 232 | 233 | If the transfer is not processed, the function SHOULD return false. 234 | 235 | :param to: the address to transfer to 236 | :type to: UInt160 237 | :param tokenId: the token to transfer 238 | :type tokenId: bytes 239 | :param data: whatever data is pertinent to the onPayment method 240 | :type data: Any 241 | :return: whether the transfer was successful 242 | :raise AssertionError: raised if `to` length is not 20 or if `tokenId` is not a valid NFT or if the contract is paused. 243 | """ 244 | expect(validateAddress(to), "transfer - not a valid address") 245 | expect(not isPaused(), "transfer - contract paused") 246 | token_owner = get_owner_of(tokenId) 247 | expect(token_owner != UInt160.zero, "Token not found") 248 | 249 | if not check_witness(token_owner): 250 | return False 251 | 252 | if (token_owner != to): 253 | set_balance(token_owner, -1) 254 | remove_token_account(token_owner, tokenId) 255 | 256 | set_balance(to, 1) 257 | 258 | set_owner_of(tokenId, to) 259 | add_token_account(to, tokenId) 260 | post_transfer(token_owner, to, tokenId, data) 261 | return True 262 | 263 | def post_transfer(token_owner: Union[UInt160, None], to: Union[UInt160, None], tokenId: bytes, data: Any): 264 | """ 265 | Checks if the one receiving NEP-11 tokens is a smart contract and if it's one the onPayment method will be called - internal 266 | 267 | :param token_owner: the address of the sender 268 | :type token_owner: UInt160 269 | :param to: the address of the receiver 270 | :type to: UInt160 271 | :param tokenId: the token hash as bytes 272 | :type tokenId: bytes 273 | :param data: any pertinent data that might validate the transaction 274 | :type data: Any 275 | """ 276 | on_transfer(token_owner, to, 1, tokenId) 277 | if to is not None: 278 | contract = get_contract(to) 279 | if contract is not None: 280 | call_contract(to, 'onNEP11Payment', [token_owner, 1, tokenId, data]) 281 | pass 282 | 283 | @public(safe=True) 284 | def ownerOf(tokenId: bytes) -> UInt160: 285 | """ 286 | Get the owner of the specified token. 287 | 288 | The parameter tokenId SHOULD be a valid NFT. If not, this method SHOULD throw an exception. 289 | 290 | :param tokenId: the token for which to check the ownership 291 | :type tokenId: bytes 292 | :return: the owner of the specified token. 293 | :raise AssertionError: raised if `tokenId` is not a valid NFT. 294 | """ 295 | owner = get_owner_of(tokenId) 296 | debug(['ownerOf: ', owner]) 297 | return owner 298 | 299 | @public(safe=True) 300 | def tokens() -> Iterator: 301 | """ 302 | Get all tokens minted by the contract 303 | 304 | :return: an iterator that contains all of the tokens minted by the contract. 305 | """ 306 | flags = FindOptions.REMOVE_PREFIX | FindOptions.KEYS_ONLY 307 | context = get_read_only_context() 308 | return find(TOKEN_PREFIX, context, flags) 309 | 310 | @public(safe=True) 311 | def properties(tokenId: bytes) -> Dict[Any, Any]: 312 | """ 313 | Get the properties of a token. 314 | 315 | The parameter tokenId SHOULD be a valid NFT. If no metadata is found (invalid tokenId), an exception is thrown. 316 | 317 | :param tokenId: the token for which to check the properties 318 | :type tokenId: bytes 319 | :return: a serialized NVM object containing the properties for the given NFT. 320 | :raise AssertionError: raised if `tokenId` is not a valid NFT, or if no metadata available. 321 | """ 322 | metaBytes = cast(str, get_meta(tokenId)) 323 | expect(len(metaBytes) != 0, 'properties - no metadata available for token') 324 | metaObject = cast(Dict[str, str], json_deserialize(metaBytes)) 325 | debug(['properties: ', metaObject]) 326 | return metaObject 327 | 328 | @public(safe=True) 329 | def propertiesJson(tokenId: bytes) -> bytes: 330 | """ 331 | Get the properties of a token. 332 | 333 | The parameter tokenId SHOULD be a valid NFT. If no metadata is found (invalid tokenId), an exception is thrown. 334 | 335 | :param tokenId: the token for which to check the properties 336 | :type tokenId: bytes 337 | :return: a serialized NVM object containing the properties for the given NFT. 338 | :raise AssertionError: raised if `tokenId` is not a valid NFT, or if no metadata available. 339 | """ 340 | meta = get_meta(tokenId) 341 | expect(len(meta) != 0, 'propertiesJson - no metadata available for token') 342 | debug(['properties: ', meta]) 343 | return meta 344 | 345 | @public 346 | def _deploy(data: Any, upgrade: bool): 347 | """ 348 | The contracts initial entry point, on deployment. 349 | """ 350 | debug(["deploy now"]) 351 | if upgrade: 352 | return 353 | 354 | if get_bool(DEPLOYED, get_read_only_context()): 355 | return 356 | 357 | tx = cast(Transaction, script_container) 358 | debug(["tx.sender: ", tx.sender, get_network()]) 359 | owner: UInt160 = tx.sender 360 | network = get_network() 361 | # DEBUG_START 362 | # custom owner for tests, ugly hack, because TestEnginge sets an unkown tx.sender... 363 | if data is not None and network == 860833102: 364 | newOwner = cast(UInt160, data) 365 | debug(["check", newOwner]) 366 | internal_deploy(newOwner) 367 | return 368 | 369 | if data is None and network == 860833102: 370 | return 371 | # DEBUG_END 372 | debug(["owner: ", owner]) 373 | internal_deploy(owner) 374 | 375 | def internal_deploy(owner: UInt160): 376 | 377 | debug(["internal: ", owner]) 378 | put_bool(DEPLOYED, True) 379 | put_bool(PAUSED, False) 380 | put_int(TOKEN_COUNT, 0) 381 | 382 | auth: List[UInt160] = [] 383 | auth.append(owner) 384 | serialized = serialize(auth) 385 | put(AUTH_ADDRESSES, serialized) 386 | 387 | # ------------------------------------------- 388 | # METHODS 389 | # ------------------------------------------- 390 | 391 | @public 392 | def burn(tokenId: bytes) -> bool: 393 | """ 394 | Burn a token. 395 | 396 | :param tokenId: the token to burn 397 | :type tokenId: bytes 398 | :return: whether the burn was successful. 399 | :raise AssertionError: raised if the contract is paused. 400 | """ 401 | expect(not isPaused(), "burn - contract paused") 402 | return internal_burn(tokenId) 403 | 404 | @public 405 | def mint(account: UInt160, meta: bytes, lockedContent: bytes, royalties: bytes) -> bytes: 406 | """ 407 | Mint new token. 408 | 409 | :param account: the address of the account that is minting token 410 | :type account: UInt160 411 | :param meta: the metadata to use for this token 412 | :type meta: bytes 413 | :param lockedContent: the lock content to use for this token 414 | :type lockedContent: bytes 415 | :param royalties: the royalties to use for this token 416 | :type royalties: bytes 417 | :return: tokenId of the token minted 418 | :raise AssertionError: raised if the contract is paused or if check witness fails. 419 | """ 420 | expect(validateAddress(account), "mint - not a valid address") # not really necessary because check_witness would catch an invalid address 421 | expect(not isPaused(), "mint - contract paused") 422 | 423 | # TODO_TEMPLATE: add own logic if necessary, or uncomment below to restrict minting to contract authorized addresses 424 | # verified: bool = verify() 425 | # expect(verified, '`account` is not allowed for mint') 426 | expect(check_witness(account), "mint - invalid witness") 427 | 428 | return internal_mint(account, meta, lockedContent, royalties) 429 | 430 | @public(safe=True) 431 | def getRoyalties(tokenId: bytes) -> bytes: 432 | """ 433 | Get a token royalties values - ghostmarket standard. 434 | 435 | :param tokenId: the token to get royalties values 436 | :type tokenId: bytes 437 | :return: bytes of addresses and values for this token royalties. 438 | :raise AssertionError: raised if any `tokenId` is not a valid NFT. 439 | """ 440 | royalties = get_royalties(tokenId) 441 | debug(['getRoyalties: ', royalties]) 442 | return royalties 443 | 444 | @public(safe=True) 445 | def royaltyInfo(tokenId: bytes, royaltyToken: UInt160, salePrice: int) -> List[List[Any]]: 446 | """ 447 | Get a token royalties values - official standard. 448 | 449 | :param tokenId: the token used to calculate royalties values 450 | :type tokenId: bytes 451 | :param royaltyToken: the currency used to calculate royalties values 452 | :type royaltyToken: UInt160 453 | :param salePrice: the sale amount used to calculate royalties values 454 | :type salePrice: int 455 | :return: Returns a NeoVM Array stack item with single or multi array, each array still has two elements 456 | :raise AssertionError: raised if any `tokenId` is not a valid NFT or if royaltyToken is not a valid UInt160 or salePrice incorrect 457 | """ 458 | royalties = get_royalties_info(tokenId, salePrice) 459 | return royalties 460 | 461 | @public(safe=True) 462 | def getLockedContentViewCount(tokenId: bytes) -> int: 463 | """ 464 | Get lock content view count of a token. 465 | 466 | :param tokenId: the token to query 467 | :type tokenId: bytes 468 | :return: number of times the lock content of this token was accessed. 469 | """ 470 | debug(['getLockedContentViewCount: ', get_locked_view_counter(tokenId)]) 471 | return get_locked_view_counter(tokenId) 472 | 473 | @public 474 | def getLockedContent(tokenId: bytes) -> bytes: 475 | """ 476 | Get lock content of a token. 477 | 478 | :param tokenId: the token to query 479 | :type tokenId: bytes 480 | :return: the lock content of this token. 481 | :raise AssertionError: raised if witness is not owner 482 | :emits UnlockIncremented 483 | """ 484 | owner = get_owner_of(tokenId) 485 | 486 | expect(check_witness(owner), "getLockedContent - prohibited access to locked content!") 487 | set_locked_view_counter(tokenId) 488 | 489 | debug(['getLockedContent: ', get_locked_content(tokenId)]) 490 | content = get_locked_content(tokenId) 491 | counter = get_locked_view_counter(tokenId) 492 | on_unlock(tokenId, counter) 493 | return content 494 | 495 | @public(safe=True) 496 | def getAuthorizedAddress() -> list[UInt160]: 497 | """ 498 | Configure authorized addresses. 499 | 500 | When this contract address is included in the transaction signature, 501 | this method will be triggered as a VerificationTrigger to verify that the signature is correct. 502 | For example, this method needs to be called when withdrawing token from the contract. 503 | 504 | :param address: the address of the account that is being authorized 505 | :type address: UInt160 506 | :param authorized: authorization status of this address 507 | :type authorized: bool 508 | :return: whether the transaction signature is correct 509 | :raise AssertionError: raised if witness is not verified. 510 | """ 511 | serialized = get(AUTH_ADDRESSES, get_read_only_context()) 512 | auth = cast(list[UInt160], deserialize(serialized)) 513 | 514 | return auth 515 | 516 | @public 517 | def setAuthorizedAddress(address: UInt160, authorized: bool): 518 | """ 519 | Configure authorized addresses. 520 | 521 | When this contract address is included in the transaction signature, 522 | this method will be triggered as a VerificationTrigger to verify that the signature is correct. 523 | For example, this method needs to be called when withdrawing token from the contract. 524 | 525 | :param address: the address of the account that is being authorized 526 | :type address: UInt160 527 | :param authorized: authorization status of this address 528 | :type authorized: bool 529 | :return: whether the transaction signature is correct 530 | :raise AssertionError: raised if witness is not verified. 531 | """ 532 | verified: bool = verify() 533 | expect(verified, 'setAuthorizedAddress - `account` is not allowed for setAuthorizedAddress') 534 | expect(validateAddress(address), "setAuthorizedAddress - not a valid address") 535 | expect(isinstance(authorized, bool), "setAuthorizedAddress - authorized has to be of type bool") 536 | serialized = get(AUTH_ADDRESSES, get_read_only_context()) 537 | auth = cast(list[UInt160], deserialize(serialized)) 538 | expect(len(auth) <= 10, "setAuthorizedAddress - authorized addresses count has to be <= 10") 539 | 540 | if authorized: 541 | found = False 542 | for i in auth: 543 | if i == address: 544 | found = True 545 | break 546 | 547 | if not found: 548 | auth.append(address) 549 | 550 | put(AUTH_ADDRESSES, serialize(auth)) 551 | on_auth(address, 0, True) 552 | else: 553 | auth.remove(address) 554 | put(AUTH_ADDRESSES, serialize(auth)) 555 | on_auth(address, 0, False) 556 | 557 | @public 558 | def updatePause(status: bool) -> bool: 559 | """ 560 | Set contract pause status. 561 | 562 | :param status: the status of the contract pause 563 | :type status: bool 564 | :return: the contract pause status 565 | :raise AssertionError: raised if witness is not verified. 566 | """ 567 | verified: bool = verify() 568 | expect(verified, 'updatePause - `account` is not allowed for updatePause') 569 | expect(isinstance(status, bool), "updatePause - status has to be of type bool") 570 | put_bool(PAUSED, status) 571 | debug(['updatePause: ', get_bool(PAUSED, get_read_only_context())]) 572 | return get_bool(PAUSED, get_read_only_context()) 573 | 574 | @public(safe=True) 575 | def isPaused() -> bool: 576 | """ 577 | Get the contract pause status. 578 | 579 | If the contract is paused, some operations are restricted. 580 | 581 | :return: whether the contract is paused 582 | """ 583 | debug(['isPaused: ', get_bool(PAUSED)]) 584 | if get_bool(PAUSED, get_read_only_context()): 585 | return True 586 | return False 587 | 588 | @public 589 | def verify() -> bool: 590 | """ 591 | Check if the address is allowed. 592 | 593 | When this contract address is included in the transaction signature, 594 | this method will be triggered as a VerificationTrigger to verify that the signature is correct. 595 | For example, this method needs to be called when withdrawing token from the contract. 596 | 597 | :return: whether the transaction signature is correct 598 | """ 599 | serialized = get(AUTH_ADDRESSES, get_read_only_context()) 600 | auth = cast(list[UInt160], deserialize(serialized)) 601 | tx = cast(Transaction, script_container) 602 | for addr in auth: 603 | if check_witness(addr): 604 | debug(["Verification successful", addr, tx.sender]) 605 | return True 606 | 607 | debug(["Verification failed", addr]) 608 | return False 609 | 610 | @public 611 | def update(script: bytes, manifest: bytes): 612 | """ 613 | Upgrade the contract. 614 | 615 | :param script: the contract script 616 | :type script: bytes 617 | :param manifest: the contract manifest 618 | :type manifest: bytes 619 | :raise AssertionError: raised if witness is not verified 620 | """ 621 | verified: bool = verify() 622 | expect(verified, 'update - `account` is not allowed for update') 623 | update_contract(script, manifest) 624 | debug(['update called and done']) 625 | 626 | @public 627 | def destroy(): 628 | """ 629 | Destroy the contract. 630 | 631 | :raise AssertionError: raised if witness is not verified 632 | """ 633 | verified: bool = verify() 634 | expect(verified, 'destroy - `account` is not allowed for destroy') 635 | debug(['destroy called and done']) 636 | destroy_contract() 637 | 638 | def internal_burn(tokenId: bytes) -> bool: 639 | """ 640 | Burn a token - internal 641 | 642 | :param tokenId: the token to burn 643 | :type tokenId: bytes 644 | :return: whether the burn was successful. 645 | :raise AssertionError: raised if `tokenId` is not a valid NFT. 646 | """ 647 | owner = get_owner_of(tokenId) 648 | 649 | if not check_witness(owner): 650 | return False 651 | 652 | remove_owner_of(tokenId) 653 | set_balance(owner, -1) 654 | add_to_supply(-1) 655 | remove_meta(tokenId) 656 | remove_locked_content(tokenId) 657 | remove_royalties(tokenId) 658 | remove_token_account(owner, tokenId) 659 | 660 | post_transfer(owner, None, tokenId, None) 661 | return True 662 | 663 | def internal_mint(account: UInt160, meta: bytes, lockedContent: bytes, royalties: bytes) -> bytes: 664 | """ 665 | Mint new token - internal 666 | 667 | :param account: the address of the account that is minting token 668 | :type account: UInt160 669 | :param meta: the metadata to use for this token 670 | :type meta: bytes 671 | :param lockedContent: the lock content to use for this token 672 | :type lockedContent: bytes 673 | :param royalties: the royalties to use for this token 674 | :type royalties: bytes 675 | :return: tokenId of the token minted 676 | :raise AssertionError: raised if meta is empty, or if contract is paused. 677 | """ 678 | expect(len(meta) != 0, 'internal_mint - `meta` can not be empty') 679 | 680 | tokenId = get_int(TOKEN_COUNT, get_read_only_context()) + 1 681 | put_int(TOKEN_COUNT, tokenId) 682 | tokenIdBytes = to_bytes(tokenId) 683 | 684 | set_owner_of(tokenIdBytes, account) 685 | set_balance(account, 1) 686 | add_to_supply(1) 687 | 688 | add_meta(tokenIdBytes, meta) 689 | debug(['metadata: ', meta]) 690 | 691 | if len(lockedContent) != 0: 692 | add_locked_content(tokenIdBytes, lockedContent) 693 | debug(['locked: ', lockedContent]) 694 | 695 | if len(royalties) != 0: 696 | expect(validateRoyalties(royalties), "internal_mint - not a valid royalties format") 697 | add_royalties(tokenIdBytes, cast(str, royalties)) 698 | debug(['royalties: ', royalties]) 699 | 700 | add_token_account(account, tokenIdBytes) 701 | post_transfer(None, account, tokenIdBytes, None) 702 | return tokenIdBytes 703 | 704 | def validateRoyalties(bytes: bytes) -> bool: 705 | 706 | strRoyalties: str = cast(str, bytes) 707 | deserialized = cast(List[Dict[str, str]], json_deserialize(strRoyalties)) 708 | 709 | for royalty in deserialized: 710 | if "address" not in royalty or "value" not in royalty: 711 | return False 712 | return True 713 | 714 | def remove_token_account(holder: UInt160, tokenId: bytes): 715 | key = mk_account_key(holder) + tokenId 716 | debug(['add_token_account: ', key, tokenId]) 717 | delete(key) 718 | 719 | def add_token_account(holder: UInt160, tokenId: bytes): 720 | key = mk_account_key(holder) + tokenId 721 | debug(['add_token_account: ', key, tokenId]) 722 | put(key, tokenId) 723 | 724 | def get_owner_of(tokenId: bytes) -> UInt160: 725 | key = mk_token_key(tokenId) 726 | debug(['get_owner_of: ', key, tokenId]) 727 | owner = get_uint160(key, get_read_only_context()) 728 | return owner 729 | 730 | def remove_owner_of(tokenId: bytes): 731 | key = mk_token_key(tokenId) 732 | debug(['remove_owner_of: ', key, tokenId]) 733 | delete(key) 734 | 735 | def set_owner_of(tokenId: bytes, owner: UInt160): 736 | key = mk_token_key(tokenId) 737 | debug(['set_owner_of: ', key, tokenId]) 738 | put_uint160(key, owner) 739 | 740 | def add_to_supply(amount: int): 741 | total = totalSupply() + (amount) 742 | debug(['add_to_supply: ', amount]) 743 | put_int(SUPPLY_PREFIX, total) 744 | 745 | def set_balance(owner: UInt160, amount: int): 746 | old = balanceOf(owner) 747 | new = old + (amount) 748 | debug(['set_balance: ', amount]) 749 | 750 | key = mk_balance_key(owner) 751 | if (new > 0): 752 | put_int(key, new) 753 | else: 754 | delete(key) 755 | 756 | def get_meta(tokenId: bytes) -> bytes: 757 | key = mk_meta_key(tokenId) 758 | debug(['get_meta: ', key, tokenId]) 759 | val = get(key, get_read_only_context()) 760 | return val 761 | 762 | def remove_meta(tokenId: bytes): 763 | key = mk_meta_key(tokenId) 764 | debug(['remove_meta: ', key, tokenId]) 765 | delete(key) 766 | 767 | def add_meta(tokenId: bytes, meta: bytes): 768 | key = mk_meta_key(tokenId) 769 | debug(['add_meta: ', key, tokenId]) 770 | put(key, meta) 771 | 772 | def get_locked_content(tokenId: bytes) -> bytes: 773 | key = mk_locked_key(tokenId) 774 | debug(['get_locked_content: ', key, tokenId]) 775 | val = get(key, get_read_only_context()) 776 | return val 777 | 778 | def remove_locked_content(tokenId: bytes): 779 | key = mk_locked_key(tokenId) 780 | debug(['remove_locked_content: ', key, tokenId]) 781 | delete(key) 782 | 783 | def add_locked_content(tokenId: bytes, content: bytes): 784 | key = mk_locked_key(tokenId) 785 | debug(['add_locked_content: ', key, tokenId]) 786 | put(key, content) 787 | 788 | def get_royalties(tokenId: bytes) -> bytes: 789 | key = mk_royalties_key(tokenId) 790 | debug(['get_royalties: ', key, tokenId]) 791 | val = get(key, get_read_only_context()) 792 | return val 793 | 794 | def get_royalties_info(tokenId: bytes, salePrice: int) -> List[List[Any]]: 795 | key = mk_royalties_key(tokenId) 796 | val = get(key, get_read_only_context()) 797 | 798 | result: List[List[Any]] = [] 799 | 800 | if len(val) == 0: 801 | return result 802 | 803 | strRoyalties: str = cast(str, val) 804 | deserialized = cast(List[Dict[str, str]], json_deserialize(strRoyalties)) 805 | 806 | for royalty in deserialized: 807 | royalties: List[Any] = [] 808 | 809 | val: int = 0 810 | if isinstance(royalty["value"], str): 811 | val = atoi(royalty["value"], 10) 812 | else: 813 | val = royalty["value"] 814 | amount: int = salePrice * val // 10000 815 | 816 | recipient: UInt160 = to_script_hash(cast(UInt160,(royalty["address"]))) 817 | royalties.append(recipient) 818 | royalties.append(amount) 819 | result.append(royalties) 820 | 821 | return result 822 | 823 | def add_royalties(tokenId: bytes, royalties: str): 824 | key = mk_royalties_key(tokenId) 825 | debug(['add_royalties: ', key, tokenId]) 826 | put_str(key, royalties) 827 | 828 | def remove_royalties(tokenId: bytes): 829 | key = mk_royalties_key(tokenId) 830 | debug(['remove_royalties: ', key, tokenId]) 831 | delete(key) 832 | 833 | def get_locked_view_counter(tokenId: bytes) -> int: 834 | key = mk_lv_key(tokenId) 835 | debug(['get_locked_view_counter: ', key, tokenId]) 836 | return get_int(key, get_read_only_context()) 837 | 838 | def remove_locked_view_counter(tokenId: bytes): 839 | key = mk_lv_key(tokenId) 840 | debug(['remove_locked_view_counter: ', key, tokenId]) 841 | delete(key) 842 | 843 | def set_locked_view_counter(tokenId: bytes): 844 | key = mk_lv_key(tokenId) 845 | debug(['set_locked_view_counter: ', key, tokenId]) 846 | count = get_int(key, get_read_only_context()) + 1 847 | put_int(key, count) 848 | 849 | 850 | # ------------------------------------------- 851 | # HELPERS 852 | # ------------------------------------------- 853 | 854 | def expect(condition: bool, message: str): 855 | assert condition, message 856 | 857 | def validateAddress(address: UInt160) -> bool: 858 | if not isinstance(address, UInt160): 859 | return False 860 | if address == 0: 861 | return False 862 | return True 863 | 864 | def mk_account_key(address: UInt160) -> bytes: 865 | return ACCOUNT_PREFIX + address 866 | 867 | def mk_balance_key(address: UInt160) -> bytes: 868 | return BALANCE_PREFIX + address 869 | 870 | def mk_token_key(tokenId: bytes) -> bytes: 871 | return TOKEN_PREFIX + tokenId 872 | 873 | def mk_token_data_key(tokenId: bytes) -> bytes: 874 | return TOKEN_DATA_PREFIX + tokenId 875 | 876 | def mk_meta_key(tokenId: bytes) -> bytes: 877 | return META_PREFIX + tokenId 878 | 879 | def mk_locked_key(tokenId: bytes) -> bytes: 880 | return LOCKED_PREFIX + tokenId 881 | 882 | def mk_royalties_key(tokenId: bytes) -> bytes: 883 | return ROYALTIES_PREFIX + tokenId 884 | 885 | def mk_lv_key(tokenId: bytes) -> bytes: 886 | return LOCKED_VIEW_COUNT_PREFIX + tokenId 887 | -------------------------------------------------------------------------------- /tests/test_nep11.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Self 3 | 4 | from neo3.api import StackItemType 5 | from neo3.contracts.contract import CONTRACT_HASHES 6 | from neo3.core import types 7 | from neo3.wallet import account 8 | 9 | from boa3.internal.neo.vm.type.String import String 10 | from boa3_test.tests import boatestcase, event 11 | 12 | 13 | class TestNEP11(boatestcase.BoaTestCase): 14 | DECIMALS = 0 15 | OWNER_BALANCE = 0 16 | TOTAL_SUPPLY = 0 17 | 18 | owner: account.Account 19 | account1: account.Account 20 | account2: account.Account 21 | 22 | TOKEN_ID_TRANSFER_TEST: bytes 23 | TEST_TOKEN_ID: bytes 24 | 25 | TOKEN_META = bytes( 26 | '{ "name": "NEP11", "description": "Some description", "image": "{some image URI}", "tokenURI": "{some URI}" }', 27 | 'utf-8') 28 | TOKEN_LOCKED = bytes('lockedContent', 'utf-8') 29 | ROYALTIES = bytes( 30 | '[{"address": "NZcuGiwRu1QscpmCyxj5XwQBUf6sk7dJJN", "value": 2000}, ' 31 | '{"address": "NiNmXL8FjEUEs1nfX9uHFBNaenxDHJtmuB", "value": 3000}]', 32 | 'utf-8') 33 | 34 | ACCOUNT_PREFIX = b'ACC' 35 | 36 | @classmethod 37 | def setupTestCase(cls): 38 | cls.owner = cls.node.wallet.account_new(label='owner', password='123') 39 | cls.account1 = cls.node.wallet.account_new(label='test1', password='123') 40 | cls.account2 = cls.node.wallet.account_new(label='test2', password='123') 41 | 42 | super().setupTestCase() 43 | 44 | @classmethod 45 | async def asyncSetupClass(cls) -> None: 46 | await super().asyncSetupClass() 47 | 48 | await cls.transfer(CONTRACT_HASHES.GAS_TOKEN, cls.genesis.script_hash, cls.owner.script_hash, 100) 49 | await cls.transfer(CONTRACT_HASHES.GAS_TOKEN, cls.genesis.script_hash, cls.account1.script_hash, 100) 50 | await cls.transfer(CONTRACT_HASHES.GAS_TOKEN, cls.genesis.script_hash, cls.account2.script_hash, 100) 51 | 52 | await cls.set_up_contract('..', 'contracts/NEP11', 'NEP11-Template.py', signing_account=cls.owner) 53 | 54 | mint_args = [cls.TOKEN_META, cls.TOKEN_LOCKED, cls.ROYALTIES] 55 | mint_amount = 5 56 | account_balance = 2 57 | for _ in range(mint_amount): 58 | await cls.call( 59 | 'mint', 60 | [cls.owner.script_hash, *mint_args], 61 | return_type=bytes, 62 | signing_accounts=[cls.owner] 63 | ) 64 | 65 | account_tokens: list[bytes] = [] 66 | for _ in range(account_balance): 67 | result, _ = await cls.call( 68 | 'mint', 69 | [cls.account1.script_hash, *mint_args], 70 | return_type=bytes, 71 | signing_accounts=[cls.account1] 72 | ) 73 | account_tokens.append(result) 74 | 75 | cls.TOKEN_ID_TRANSFER_TEST, cls.TEST_TOKEN_ID = account_tokens 76 | cls.OWNER_BALANCE = mint_amount 77 | cls.TOTAL_SUPPLY = mint_amount + account_balance 78 | 79 | def test_compile(self): 80 | path = self.get_contract_path('..', 'contracts/NEP11', 'NEP11-Template.py') 81 | _, manifest = self.assertCompile(path, get_manifest=True) 82 | 83 | self.assertIn('supportedstandards', manifest) 84 | self.assertIsInstance(manifest['supportedstandards'], list) 85 | self.assertGreater(len(manifest['supportedstandards']), 0) 86 | self.assertIn('NEP-11', manifest['supportedstandards']) 87 | self.assertIn('NEP-24', manifest['supportedstandards']) 88 | 89 | async def test_symbol(self): 90 | expected = 'EXMP' 91 | result, _ = await self.call('symbol', return_type=str) 92 | self.assertEqual(expected, result) 93 | 94 | async def test_decimals(self): 95 | expected = self.DECIMALS 96 | result, _ = await self.call('decimals', return_type=int) 97 | self.assertEqual(expected, result) 98 | 99 | async def test_before_mint_total_supply(self): 100 | total_supply = self.TOTAL_SUPPLY 101 | result, _ = await self.call('totalSupply', return_type=int) 102 | self.assertEqual(total_supply, result) 103 | 104 | async def test_balance_of(self): 105 | expected = self.OWNER_BALANCE 106 | owner_account = self.owner.script_hash 107 | result, _ = await self.call('balanceOf', [owner_account], return_type=int) 108 | self.assertEqual(expected, result) 109 | 110 | bad_account = bytes(10) 111 | with self.assertRaises(boatestcase.AssertException) as context: 112 | await self.call("balanceOf", [bad_account], return_type=int) 113 | self.assertEqual(str(context.exception), 'balanceOf - not a valid address') 114 | 115 | bad_account = bytes(30) 116 | with self.assertRaises(boatestcase.AssertException) as context: 117 | await self.call("balanceOf", [bad_account], return_type=int) 118 | self.assertEqual(str(context.exception), 'balanceOf - not a valid address') 119 | 120 | async def test_tokens_of(self): 121 | no_balance_account = types.UInt160.zero() 122 | # TODO: #86drqwhx0 neo-go in the current version of boa-test-constructor is not configured to return Iterators 123 | with self.assertRaises(ValueError) as context: 124 | result, _ = await self.call('tokensOf', [no_balance_account], return_type=list) 125 | self.assertEqual([], result) 126 | 127 | self.assertRegex(str(context.exception), 'Interop stack item only supports iterators') 128 | 129 | tokens_of_storage = await self.get_storage( 130 | self.ACCOUNT_PREFIX + no_balance_account.to_array(), 131 | remove_prefix=True 132 | ) 133 | self.assertEqual(0, len(tokens_of_storage)) 134 | 135 | # TODO: #86drqwhx0 neo-go in the current version of boa-test-constructor is not configured to return Iterators 136 | with self.assertRaises(ValueError) as context: 137 | result, _ = await self.call('tokensOf', [self.owner.script_hash], return_type=list) 138 | self.assertEqual(self.OWNER_BALANCE, len(result)) 139 | 140 | self.assertRegex( 141 | str(context.exception), 142 | fr"item is not of type 'StackItemType.\w+' but of type '{StackItemType.INTEROP_INTERFACE}'" 143 | ) 144 | 145 | tokens_of_storage = await self.get_storage( 146 | self.ACCOUNT_PREFIX + self.owner.script_hash.to_array(), 147 | remove_prefix=True 148 | ) 149 | self.assertEqual(self.OWNER_BALANCE, len(tokens_of_storage)) 150 | 151 | async def test_transfer_success(self): 152 | token = self.TOKEN_ID_TRANSFER_TEST 153 | from_account = self.account1.script_hash 154 | to_account = self.account2.script_hash 155 | 156 | total_supply, _ = await self.call('totalSupply', [], return_type=int) 157 | 158 | # check owner before 159 | result, _ = await self.call('ownerOf', [token], return_type=types.UInt160) 160 | self.assertEqual(from_account, result) 161 | 162 | # transfer 163 | result, notifications = await self.call( 164 | 'transfer', 165 | [to_account, token, None], 166 | return_type=bool, 167 | signing_accounts=[self.account1] 168 | ) 169 | self.assertEqual(True, result) 170 | transfer_events = self.filter_events( 171 | notifications, 172 | origin=[self.contract_hash], 173 | event_name='Transfer', 174 | notification_type=boatestcase.Nep11TransferEvent 175 | ) 176 | self.assertEqual(len(transfer_events), 1) 177 | self.assertEqual(from_account, transfer_events[0].source) 178 | self.assertEqual(to_account, transfer_events[0].destination) 179 | self.assertEqual(1, transfer_events[0].amount) 180 | self.assertEqual(token.decode('utf-8'), transfer_events[0].token_id) 181 | 182 | # check owner after 183 | result, _ = await self.call('ownerOf', [token], return_type=types.UInt160) 184 | self.assertEqual(self.account2.script_hash, result) 185 | 186 | # check balances after 187 | result, _ = await self.call('balanceOf', [from_account], return_type=int) 188 | self.assertEqual(1, result) 189 | result, _ = await self.call('totalSupply', [], return_type=int) 190 | self.assertEqual(total_supply, result) 191 | 192 | async def test_transfer_fail_no_sign(self): 193 | token = self.TEST_TOKEN_ID 194 | from_account = self.account1.script_hash 195 | to_account = self.account2.script_hash 196 | 197 | # check owner before 198 | result, _ = await self.call('ownerOf', [token], return_type=types.UInt160) 199 | self.assertEqual(from_account, result) 200 | 201 | # transfer 202 | result, notifications = await self.call( 203 | 'transfer', 204 | [to_account, token, None], 205 | return_type=bool 206 | ) 207 | self.assertEqual(False, result) 208 | 209 | transfers = self.filter_events( 210 | notifications, 211 | origin=[self.contract_hash], 212 | event_name='Transfer', 213 | notification_type=boatestcase.Nep11TransferEvent 214 | ) 215 | self.assertEqual(0, len(transfers)) 216 | 217 | async def test_transfer_fail_wrong_token_owner(self): 218 | token = self.TEST_TOKEN_ID 219 | from_account = self.account2.script_hash 220 | to_account = self.account1.script_hash 221 | 222 | # check if owner is incorrect 223 | result, _ = await self.call('ownerOf', [token], return_type=types.UInt160) 224 | self.assertNotEqual(from_account, result) 225 | 226 | # transfer 227 | result, notifications = await self.call( 228 | 'transfer', 229 | [to_account, token, None], 230 | return_type=bool, 231 | signing_accounts=[self.account2] 232 | ) 233 | self.assertEqual(False, result) 234 | 235 | transfers = self.filter_events( 236 | notifications, 237 | origin=[self.contract_hash], 238 | event_name='Transfer', 239 | notification_type=boatestcase.Nep11TransferEvent 240 | ) 241 | self.assertEqual(0, len(transfers)) 242 | 243 | async def test_transfer_fail_non_existing_token(self): 244 | token = b'thisisanonexistingtoken' 245 | 246 | with self.assertRaises(boatestcase.AssertException) as context: 247 | await self.call( 248 | 'transfer', 249 | [self.account2.script_hash, token, None], 250 | return_type=bool 251 | ) 252 | self.assertEqual(str(context.exception), 'Token not found') 253 | 254 | async def test_transfer_fail_bad_account(self): 255 | token = self.TEST_TOKEN_ID 256 | to_account = bytes(10) 257 | 258 | with self.assertRaises(boatestcase.AssertException) as context: 259 | await self.call( 260 | 'transfer', 261 | [to_account, token, None], 262 | return_type=bool, 263 | signing_accounts=[self.account1] 264 | ) 265 | self.assertEqual(str(context.exception), 'transfer - not a valid address') 266 | 267 | async def test_on_nep11_payment_call(self): 268 | # trying to call onNEP11Payment() will result in an abort if the one calling it is not NEO or GAS contracts 269 | with self.assertRaises(boatestcase.AbortException): 270 | await self.call( 271 | 'onNEP11Payment', 272 | [self.owner.script_hash, 1, self.TEST_TOKEN_ID, None], 273 | return_type=None, 274 | signing_accounts=[self.owner] 275 | ) 276 | 277 | async def test_update(self): 278 | path = self.get_contract_path('..', 'contracts/NEP11', 'NEP11-Template.py') 279 | 280 | new_nef, new_manifest = self.get_serialized_output(path) 281 | arg_manifest = String(json.dumps(new_manifest, separators=(',', ':'))).to_bytes() 282 | 283 | with self.assertRaises(boatestcase.AssertException) as context: 284 | # missing signature 285 | await self.call( 286 | 'update', 287 | [new_nef, arg_manifest], 288 | return_type=None 289 | ) 290 | self.assertEqual(str(context.exception), 'update - `account` is not allowed for update') 291 | 292 | result, notifications = await self.call( 293 | 'update', 294 | [new_nef, arg_manifest], 295 | return_type=None, 296 | signing_accounts=[self.owner] 297 | ) 298 | self.assertIsNone(result) 299 | 300 | update_events = self.filter_events( 301 | notifications, 302 | event_name='Update', 303 | notification_type=event.UpdateEvent 304 | ) 305 | self.assertEqual(1, len(update_events)) 306 | self.assertEqual(self.contract_hash, update_events[0].updated_contract) 307 | 308 | async def test_destroy(self): 309 | owner_test_destroy = self.account2 310 | 311 | contract_hash = await self.compile_and_deploy( 312 | '..', 'contracts/NEP11', 'NEP11-Template.py', 313 | signing_account=owner_test_destroy 314 | ) 315 | 316 | with self.assertRaises(boatestcase.AssertException) as context: 317 | # missing signature 318 | await self.call( 319 | 'destroy', [], 320 | return_type=None, 321 | target_contract=contract_hash 322 | ) 323 | self.assertEqual(str(context.exception), 'destroy - `account` is not allowed for destroy') 324 | 325 | result, notifications = await self.call( 326 | 'destroy', [], 327 | return_type=None, 328 | target_contract=contract_hash, 329 | signing_accounts=[owner_test_destroy] 330 | ) 331 | self.assertIsNone(result) 332 | 333 | destroy_events = self.filter_events( 334 | notifications, 335 | event_name='Destroy', 336 | notification_type=event.DestroyEvent 337 | ) 338 | self.assertEqual(1, len(destroy_events)) 339 | self.assertEqual(contract_hash, destroy_events[0].destroyed_contract) 340 | 341 | # should not exist anymore 342 | with self.assertRaises(boatestcase.FaultException) as context: 343 | await self.call('symbol', [], return_type=str, target_contract=contract_hash) 344 | self.assertRegex(str(context.exception), f'called contract {contract_hash} not found') 345 | 346 | async def test_verify(self): 347 | result, _ = await self.call('getAuthorizedAddress', [], return_type=list[types.UInt160]) 348 | self.assertEqual([self.owner.script_hash], result) 349 | 350 | result, _ = await self.call('verify', [], return_type=bool, signing_accounts=[self.owner]) 351 | self.assertEqual(True, result) 352 | 353 | result, _ = await self.call('verify', [], return_type=bool, signing_accounts=[self.account1]) 354 | self.assertEqual(False, result) 355 | 356 | result, _ = await self.call('verify', [], return_type=bool, signing_accounts=[self.account2]) 357 | self.assertEqual(False, result) 358 | 359 | async def test_authorize(self): 360 | from dataclasses import dataclass 361 | from neo3.api import noderpc 362 | 363 | @dataclass 364 | class AuthorizedEvent(boatestcase.BoaTestEvent): 365 | authorized: types.UInt160 366 | type: int 367 | add: bool 368 | 369 | @classmethod 370 | def from_untyped_notification(cls, n: noderpc.Notification) -> Self: 371 | inner_args_types = tuple(cls.__annotations__.values()) 372 | e = super().from_notification(n, *inner_args_types) 373 | return cls(e.contract, e.name, e.state, *e.state) 374 | 375 | account = self.account1.script_hash 376 | 377 | result, notifications = await self.call( 378 | 'setAuthorizedAddress', 379 | [account, True], 380 | return_type=None, 381 | signing_accounts=[self.owner] 382 | ) 383 | self.assertIsNone(result) 384 | 385 | authorized = self.filter_events( 386 | notifications, 387 | event_name='Authorized', 388 | notification_type=AuthorizedEvent 389 | ) 390 | self.assertEqual(1, len(authorized)) 391 | self.assertEqual(account, authorized[0].authorized) 392 | self.assertEqual(0, authorized[0].type) 393 | self.assertEqual(True, authorized[0].add) 394 | 395 | # now deauthorize the address 396 | result, notifications = await self.call( 397 | 'setAuthorizedAddress', 398 | [account, False], 399 | return_type=None, 400 | signing_accounts=[self.owner] 401 | ) 402 | self.assertIsNone(result) 403 | 404 | authorized = self.filter_events( 405 | notifications, 406 | event_name='Authorized', 407 | notification_type=AuthorizedEvent 408 | ) 409 | self.assertEqual(1, len(authorized)) 410 | self.assertEqual(account, authorized[0].authorized) 411 | self.assertEqual(0, authorized[0].type) 412 | self.assertEqual(False, authorized[0].add) 413 | 414 | async def test_pause(self): 415 | # missing owner signing transaction 416 | with self.assertRaises(boatestcase.AssertException) as context: 417 | await self.call('updatePause', [True], return_type=bool) 418 | self.assertEqual(str(context.exception), 'updatePause - `account` is not allowed for updatePause') 419 | 420 | test_account = self.account2.script_hash 421 | # pause contract 422 | result, _ = await self.call('updatePause', [True], return_type=bool, signing_accounts=[self.owner]) 423 | self.assertEqual(True, result) 424 | 425 | # should fail because contract is paused 426 | with self.assertRaises(boatestcase.AssertException) as context: 427 | await self.call( 428 | 'mint', 429 | [test_account, self.TOKEN_META, self.TOKEN_LOCKED, self.ROYALTIES], 430 | return_type=str, 431 | signing_accounts=[self.account2] 432 | ) 433 | self.assertEqual(str(context.exception), 'mint - contract paused') 434 | 435 | # unpause contract 436 | result, _ = await self.call('updatePause', [False], return_type=bool, signing_accounts=[self.owner]) 437 | self.assertEqual(False, result) 438 | 439 | _, notifications = await self.call( 440 | 'mint', 441 | [test_account, self.TOKEN_META, self.TOKEN_LOCKED, self.ROYALTIES], 442 | return_type=str, 443 | signing_accounts=[self.account2] 444 | ) 445 | 446 | mint_events = self.filter_events( 447 | notifications, 448 | origin=[self.contract_hash], 449 | event_name='Transfer', 450 | notification_type=boatestcase.Nep11TransferEvent 451 | ) 452 | self.assertEqual(len(mint_events), 1) 453 | 454 | async def test_mint(self): 455 | test_account = self.account2 456 | 457 | balance, _ = await self.call('balanceOf', [test_account.script_hash], return_type=int) 458 | total_supply, _ = await self.call('totalSupply', [], return_type=int) 459 | 460 | with self.assertRaises(boatestcase.AssertException) as context: 461 | await self.call( 462 | 'mint', 463 | [test_account.script_hash, self.TOKEN_META, self.TOKEN_LOCKED, self.ROYALTIES], 464 | return_type=str 465 | ) 466 | self.assertEqual(str(context.exception), 'mint - invalid witness') 467 | 468 | token, notifications = await self.call( 469 | 'mint', 470 | [test_account.script_hash, self.TOKEN_META, self.TOKEN_LOCKED, self.ROYALTIES], 471 | return_type=str, 472 | signing_accounts=[test_account] 473 | ) 474 | 475 | mint_events = self.filter_events( 476 | notifications, 477 | origin=[self.contract_hash], 478 | event_name='Transfer', 479 | notification_type=boatestcase.Nep11TransferEvent 480 | ) 481 | self.assertEqual(len(mint_events), 1) 482 | self.assertEqual(None, mint_events[0].source) 483 | self.assertEqual(test_account.script_hash, mint_events[0].destination) 484 | self.assertEqual(1, mint_events[0].amount) 485 | self.assertEqual(token, mint_events[0].token_id) 486 | 487 | result, _ = await self.call('properties', [token], return_type=dict[str, str]) 488 | token_property = json.loads(self.TOKEN_META.decode('utf-8').replace("'", "\"")) 489 | self.assertEqual(token_property, result) 490 | 491 | token_royalties = self.ROYALTIES.decode('utf-8') 492 | result, _ = await self.call('getRoyalties', [token], return_type=str) 493 | self.assertEqual(token_royalties, result) 494 | 495 | # check balances after 496 | result, _ = await self.call('balanceOf', [test_account.script_hash], return_type=int) 497 | self.assertEqual(balance + 1, result) 498 | 499 | result, _ = await self.call('totalSupply', [], return_type=int) 500 | self.assertEqual(total_supply + 1, result) 501 | 502 | async def test_properties_success(self): 503 | token = self.TEST_TOKEN_ID 504 | expected = json.loads(self.TOKEN_META.decode('utf-8').replace("'", "\"")) 505 | 506 | result, _ = await self.call('properties', [token], return_type=dict[str, str]) 507 | self.assertEqual(expected, result) 508 | 509 | async def test_properties_fail_non_existent_token(self): 510 | token = b'thisisanonexistingtoken' 511 | 512 | with self.assertRaises(boatestcase.AssertException) as context: 513 | await self.call('properties', [token], return_type=dict[str, str]) 514 | 515 | self.assertEqual(str(context.exception), 'properties - no metadata available for token') 516 | 517 | async def test_burn(self): 518 | test_account = self.account2 519 | 520 | token, _ = await self.call( 521 | 'mint', 522 | [test_account.script_hash, self.TOKEN_META, self.TOKEN_LOCKED, self.ROYALTIES], 523 | return_type=str, 524 | signing_accounts=[test_account] 525 | ) 526 | 527 | balance, _ = await self.call('balanceOf', [test_account.script_hash], return_type=int) 528 | total_supply, _ = await self.call('totalSupply', [], return_type=int) 529 | 530 | result, _ = await self.call( 531 | 'burn', 532 | [token], 533 | return_type=bool 534 | ) 535 | self.assertEqual(False, result) 536 | 537 | result, notifications = await self.call( 538 | 'burn', 539 | [token], 540 | return_type=bool, 541 | signing_accounts=[test_account] 542 | ) 543 | self.assertEqual(True, result) 544 | 545 | burn_events = self.filter_events( 546 | notifications, 547 | origin=[self.contract_hash], 548 | event_name='Transfer', 549 | notification_type=boatestcase.Nep11TransferEvent 550 | ) 551 | self.assertEqual(len(burn_events), 1) 552 | self.assertEqual(test_account.script_hash, burn_events[0].source) 553 | self.assertEqual(None, burn_events[0].destination) 554 | self.assertEqual(1, burn_events[0].amount) 555 | self.assertEqual(token, burn_events[0].token_id) 556 | 557 | # check balances after 558 | result, _ = await self.call('balanceOf', [test_account.script_hash], return_type=int) 559 | self.assertEqual(balance - 1, result) 560 | 561 | result, _ = await self.call('totalSupply', [], return_type=int) 562 | self.assertEqual(total_supply - 1, result) 563 | --------------------------------------------------------------------------------