|
| 1 | +import smartpy as sp |
| 2 | +from smartpy.templates import fa2_lib as fa2 |
| 3 | + |
| 4 | +# Main template for FA2 contracts |
| 5 | +main = fa2.main |
| 6 | + |
| 7 | + |
| 8 | +@sp.module |
| 9 | +def my_module(): |
| 10 | + import main |
| 11 | + |
| 12 | + # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>]. |
| 13 | + class MyNFTContract( |
| 14 | + main.Admin, |
| 15 | + main.Nft, |
| 16 | + main.MintNft, |
| 17 | + main.BurnNft, |
| 18 | + main.OnchainviewBalanceOf, |
| 19 | + ): |
| 20 | + def __init__(self, admin_address, contract_metadata, ledger, token_metadata): |
| 21 | + """Initializes the contract with administrative permissions and NFT functionalities. |
| 22 | + The base class is required; all mixins are optional. |
| 23 | + The initialization must follow this order: |
| 24 | +
|
| 25 | + - Other mixins such as OnchainviewBalanceOf, MintNFT, and BurnNFT |
| 26 | + - Base class: NFT |
| 27 | + - Transfer policy |
| 28 | + - Admin |
| 29 | + """ |
| 30 | + |
| 31 | + # Initialize on-chain balance view |
| 32 | + main.OnchainviewBalanceOf.__init__(self) |
| 33 | + |
| 34 | + # Initialize the NFT-specific entrypoints |
| 35 | + main.BurnNft.__init__(self) |
| 36 | + main.MintNft.__init__(self) |
| 37 | + |
| 38 | + # Initialize the NFT base class |
| 39 | + main.Nft.__init__(self, contract_metadata, ledger, token_metadata) |
| 40 | + |
| 41 | + # Initialize administrative permissions |
| 42 | + main.Admin.__init__(self, admin_address) |
| 43 | + |
| 44 | +# Create token metadata |
| 45 | +# Adapted from fa2.make_metadata |
| 46 | +def create_metadata(symbol, name, decimals, displayUri, artifactUri, description, thumbnailUri): |
| 47 | + return sp.map( |
| 48 | + l={ |
| 49 | + "name": sp.scenario_utils.bytes_of_string(name), |
| 50 | + "decimals": sp.scenario_utils.bytes_of_string("%d" % decimals), |
| 51 | + "symbol": sp.scenario_utils.bytes_of_string(symbol), |
| 52 | + "displayUri": sp.scenario_utils.bytes_of_string(displayUri), |
| 53 | + "artifactUri": sp.scenario_utils.bytes_of_string(artifactUri), |
| 54 | + "description": sp.scenario_utils.bytes_of_string(description), |
| 55 | + "thumbnailUri": sp.scenario_utils.bytes_of_string(thumbnailUri), |
| 56 | + } |
| 57 | + ) |
| 58 | + |
| 59 | +def _get_balance(fa2_contract, args): |
| 60 | + """Utility function to call the contract's get_balance view to get an account's token balance.""" |
| 61 | + return sp.View(fa2_contract, "get_balance")(args) |
| 62 | + |
| 63 | + |
| 64 | +def _total_supply(fa2_contract, args): |
| 65 | + """Utility function to call the contract's total_supply view to get the total amount of tokens.""" |
| 66 | + return sp.View(fa2_contract, "total_supply")(args) |
| 67 | + |
| 68 | + |
| 69 | +@sp.add_test() |
| 70 | +def test(): |
| 71 | + # Create and configure the test scenario |
| 72 | + # Import the types from the FA2 library, the library itself, and the contract module, in that order. |
| 73 | + scenario = sp.test_scenario("fa2_lib_nft", my_module) |
| 74 | + scenario.h1("FA2 NFT contract test") |
| 75 | + |
| 76 | + # Define test accounts |
| 77 | + # admin = sp.record( |
| 78 | + # address=sp.address("tz1QCVQinE8iVj1H2fckqx6oiM85CNJSK9Sx"), |
| 79 | + # public_key_hash=sp.key_hash("tz1QCVQinE8iVj1H2fckqx6oiM85CNJSK9Sx"), |
| 80 | + # public_key=sp.key("edpktnVRg5YeqR6yWXUFuDVTLMzDQrre1QD3FXmYxRzTVinSsJLpnk"), |
| 81 | + # private_key="edskRypCD2G7B1ym3MWuJig8LvpmG32soDsmXVSs7QzZKAH7ehWfZx5buxZ8vaHP2FHqu7yj2jrdUQyBd72EaTJ5iDTbDD9bvJ" |
| 82 | + # ) |
| 83 | + admin = sp.address("tz1QCVQinE8iVj1H2fckqx6oiM85CNJSK9Sx") |
| 84 | + alice = sp.test_account("Alice") |
| 85 | + bob = sp.test_account("Bob") |
| 86 | + |
| 87 | + # Precreated image on IPFS |
| 88 | + token_thumb_uri = "https://gateway.pinata.cloud/ipfs/QmRCp4Qc8afPrEqtM1YdRvNagWCsFGXHgGjbBYrmNsBkcE" |
| 89 | + |
| 90 | + # Define initial token metadata and ownership |
| 91 | + tok0_md = create_metadata( |
| 92 | + "Tok0", |
| 93 | + "Token Zero", |
| 94 | + 0, |
| 95 | + token_thumb_uri, |
| 96 | + token_thumb_uri, |
| 97 | + "My first token", |
| 98 | + token_thumb_uri, |
| 99 | + ) |
| 100 | + tok1_md = create_metadata( |
| 101 | + "Tok1", |
| 102 | + "Token One", |
| 103 | + 0, |
| 104 | + token_thumb_uri, |
| 105 | + token_thumb_uri, |
| 106 | + "My second token", |
| 107 | + token_thumb_uri, |
| 108 | + ) |
| 109 | + tok2_md = create_metadata( |
| 110 | + "Tok2", |
| 111 | + "Token Two", |
| 112 | + 0, |
| 113 | + token_thumb_uri, |
| 114 | + token_thumb_uri, |
| 115 | + "My third token", |
| 116 | + token_thumb_uri, |
| 117 | + ) |
| 118 | + token_metadata = [tok0_md, tok1_md, tok2_md] |
| 119 | + ledger = {0: alice.address, 1: alice.address, 2: bob.address} |
| 120 | + |
| 121 | + # Instantiate the FA2 NFT contract |
| 122 | + contract = my_module.MyNFTContract( |
| 123 | + admin, sp.big_map(), ledger, token_metadata |
| 124 | + ) |
| 125 | + |
| 126 | + # Build contract metadata content |
| 127 | + contract_metadata = sp.create_tzip16_metadata( |
| 128 | + name="My FA2 NFT contract", |
| 129 | + description="This is an FA2 NFT contract using SmartPy.", |
| 130 | + version="1.0.0", |
| 131 | + license_name="CC-BY-SA", |
| 132 | + license_details="Creative Commons Attribution Share Alike license 4.0 https://creativecommons.org/licenses/by/4.0/", |
| 133 | + interfaces=["TZIP-012", "TZIP-016"], |
| 134 | + authors=["SmartPy <https://smartpy.io/#contact>"], |
| 135 | + homepage="https://smartpy.io/ide?template=fa2_lib_nft.py", |
| 136 | + # Optionally, upload the source code to IPFS and add the URI here |
| 137 | + source_uri=None, |
| 138 | + offchain_views=contract.get_offchain_views(), |
| 139 | + ) |
| 140 | + |
| 141 | + # Add the info specific to FA2 permissions |
| 142 | + contract_metadata["permissions"] = { |
| 143 | + # The operator policy chosen: |
| 144 | + # owner-or-operator-transfer is the default. |
| 145 | + "operator": "owner-or-operator-transfer", |
| 146 | + # Those two options should always have these values. |
| 147 | + # It means that the contract doesn't use the hook mechanism. |
| 148 | + "receiver": "owner-no-hook", |
| 149 | + "sender": "owner-no-hook", |
| 150 | + } |
| 151 | + |
| 152 | + # You must upload the contract metadata to IPFS and get its URI. |
| 153 | + # You can write the contract_metadata object to a JSON file with json.dumps() and upload it manually. |
| 154 | + # You can also use sp.pin_on_ipfs() to upload the object via pinata.cloud and get the IPFS URI: |
| 155 | + # metadata_uri = sp.pin_on_ipfs(contract_metadata, api_key=None, secret_key=None, name = "Metadata for my FA2 contract") |
| 156 | + |
| 157 | + # This is a placeholder value. In production, replace it with your metadata URI. |
| 158 | + metadata_uri = "ipfs://example" |
| 159 | + |
| 160 | + # Create the metadata big map based on the IPFS URI |
| 161 | + contract_metadata = sp.scenario_utils.metadata_of_url(metadata_uri) |
| 162 | + |
| 163 | + # Update the scenario instance with the new metadata |
| 164 | + contract.data.metadata = contract_metadata |
| 165 | + |
| 166 | + # Originate the contract in the test scenario |
| 167 | + scenario += contract |
| 168 | + |
| 169 | + if scenario.simulation_mode() is sp.SimulationMode.MOCKUP: |
| 170 | + scenario.p("mockups - fix transfer based testing") |
| 171 | + return |
| 172 | + |
| 173 | + # Run tests |
| 174 | + |
| 175 | + scenario.h2("Verify the initial owners of the tokens") |
| 176 | + scenario.verify( |
| 177 | + _get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 1 |
| 178 | + ) |
| 179 | + scenario.verify( |
| 180 | + _get_balance(contract, sp.record(owner=bob.address, token_id=0)) == 0 |
| 181 | + ) |
| 182 | + scenario.verify( |
| 183 | + _get_balance(contract, sp.record(owner=alice.address, token_id=1)) == 1 |
| 184 | + ) |
| 185 | + scenario.verify( |
| 186 | + _get_balance(contract, sp.record(owner=bob.address, token_id=1)) == 0 |
| 187 | + ) |
| 188 | + scenario.verify( |
| 189 | + _get_balance(contract, sp.record(owner=alice.address, token_id=2)) == 0 |
| 190 | + ) |
| 191 | + scenario.verify( |
| 192 | + _get_balance(contract, sp.record(owner=bob.address, token_id=2)) == 1 |
| 193 | + ) |
| 194 | + |
| 195 | + # Verify the token supply |
| 196 | + scenario.verify(_total_supply(contract, sp.record(token_id=0)) == 1) |
| 197 | + scenario.verify(_total_supply(contract, sp.record(token_id=1)) == 1) |
| 198 | + scenario.verify(_total_supply(contract, sp.record(token_id=2)) == 1) |
| 199 | + |
| 200 | + scenario.h2("Transfer a token") |
| 201 | + contract.transfer( |
| 202 | + [ |
| 203 | + sp.record( |
| 204 | + from_=alice.address, |
| 205 | + txs=[sp.record(to_=bob.address, amount=1, token_id=0)], |
| 206 | + ), |
| 207 | + ], |
| 208 | + _sender=alice, |
| 209 | + ) |
| 210 | + # Verify the result |
| 211 | + scenario.verify( |
| 212 | + _get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 0 |
| 213 | + ) |
| 214 | + scenario.verify( |
| 215 | + _get_balance(contract, sp.record(owner=bob.address, token_id=0)) == 1 |
| 216 | + ) |
| 217 | + # Transfer it back |
| 218 | + contract.transfer( |
| 219 | + [ |
| 220 | + sp.record( |
| 221 | + from_=bob.address, |
| 222 | + txs=[sp.record(to_=alice.address, amount=1, token_id=0)], |
| 223 | + ), |
| 224 | + ], |
| 225 | + _sender=bob, |
| 226 | + ) |
| 227 | + |
| 228 | + scenario.h2("Mint a token") |
| 229 | + nft3_md = fa2.make_metadata(name="Token Three", decimals=1, symbol="Tok3") |
| 230 | + # Verify that only the admin can mint a token |
| 231 | + contract.mint( |
| 232 | + [ |
| 233 | + sp.record(metadata=nft3_md, to_=bob.address), |
| 234 | + ], |
| 235 | + _sender=bob, |
| 236 | + _valid=False, |
| 237 | + ) |
| 238 | + # Mint a token |
| 239 | + contract.mint( |
| 240 | + [ |
| 241 | + sp.record(metadata=nft3_md, to_=bob.address), |
| 242 | + ], |
| 243 | + _sender=admin, |
| 244 | + ) |
| 245 | + # Verify the result |
| 246 | + scenario.verify(_total_supply(contract, sp.record(token_id=3)) == 1) |
| 247 | + scenario.verify( |
| 248 | + _get_balance(contract, sp.record(owner=alice.address, token_id=3)) == 0 |
| 249 | + ) |
| 250 | + scenario.verify( |
| 251 | + _get_balance(contract, sp.record(owner=bob.address, token_id=3)) == 1 |
| 252 | + ) |
| 253 | + |
| 254 | + scenario.h2("Burn a token") |
| 255 | + # Verify that you can't burn someone else's token |
| 256 | + contract.burn( |
| 257 | + [sp.record(token_id=3, from_=bob.address, amount=1)], |
| 258 | + _sender=alice, |
| 259 | + _valid=False, |
| 260 | + ) |
| 261 | + |
| 262 | + # Verify that you can burn your own token |
| 263 | + contract.burn([sp.record(token_id=3, from_=bob.address, amount=1)], _sender=bob) |
0 commit comments