Monorepo for Aesthetic.Computer aesthetic.computer
at main 461 lines 18 kB view raw
1""" 2Keeps FA2 v2 - Aesthetic Computer NFT Contract 3 4This contract extends the SmartPy FA2 library with a custom `keep` entrypoint 5that accepts pre-encoded bytes for all TZIP-21 metadata fields. 6 7Key feature: Metadata bytes are stored directly WITHOUT sp.pack(), 8ensuring compatibility with TzKT, objkt, and other Tezos indexers. 9""" 10 11import smartpy as sp 12from smartpy.templates import fa2_lib as fa2 13 14main = fa2.main 15 16 17@sp.module 18def keeps_module(): 19 import main 20 21 # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>]. 22 class KeepsFA2( 23 main.Admin, 24 main.Nft, 25 main.MintNft, 26 main.BurnNft, 27 main.OnchainviewBalanceOf, 28 ): 29 """ 30 FA2 NFT contract for aesthetic.computer Keeps. 31 32 Extends the standard FA2 library with a custom `keep` entrypoint 33 that accepts all TZIP-21 metadata fields as pre-encoded bytes. 34 """ 35 36 def __init__(self, admin_address, contract_metadata, ledger, token_metadata): 37 # Initialize on-chain balance view 38 main.OnchainviewBalanceOf.__init__(self) 39 40 # Initialize the NFT-specific entrypoints 41 main.BurnNft.__init__(self) 42 main.MintNft.__init__(self) 43 44 # Initialize the NFT base class 45 main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 46 47 # Initialize administrative permissions 48 main.Admin.__init__(self, admin_address) 49 50 # Additional storage for metadata locking 51 self.data.metadata_locked = sp.cast( 52 sp.big_map(), 53 sp.big_map[sp.nat, sp.bool] 54 ) 55 56 # Track content hashes to prevent duplicate mints 57 # Maps content_hash (bytes) -> token_id (nat) 58 self.data.content_hashes = sp.cast( 59 sp.big_map(), 60 sp.big_map[sp.bytes, sp.nat] 61 ) 62 63 # Contract-level metadata lock flag 64 self.data.contract_metadata_locked = False 65 66 # Mint fee configuration (admin-adjustable) 67 # Default: 0 tez (free minting via admin key) 68 # Can be set to require payment when users mint via their wallets 69 self.data.keep_fee = sp.tez(0) 70 71 @sp.entrypoint 72 def keep(self, params): 73 """ 74 Mint a new Keep token with full TZIP-21 metadata. 75 76 Two modes: 77 1. Admin calling with owner param: mints to specified owner (for server-side minting) 78 2. User calling directly: mints to sender, requires fee payment 79 - This makes the USER the firstMinter for proper artist attribution 80 81 All bytes parameters should be raw hex-encoded UTF-8 strings 82 (NOT Michelson-packed with 0x05 prefix). 83 """ 84 sp.cast(params, sp.record( 85 name=sp.bytes, 86 description=sp.bytes, 87 artifactUri=sp.bytes, 88 displayUri=sp.bytes, 89 thumbnailUri=sp.bytes, 90 decimals=sp.bytes, 91 symbol=sp.bytes, 92 isBooleanAmount=sp.bytes, 93 shouldPreferSymbol=sp.bytes, 94 formats=sp.bytes, 95 tags=sp.bytes, 96 attributes=sp.bytes, 97 creators=sp.bytes, 98 rights=sp.bytes, 99 content_type=sp.bytes, 100 content_hash=sp.bytes, 101 metadata_uri=sp.bytes, 102 owner=sp.address 103 )) 104 105 # Determine minting mode and owner 106 is_admin = self.is_administrator_() 107 108 # Non-admin callers must pay the fee and can only mint to themselves 109 if not is_admin: 110 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE" 111 # User must mint to themselves (ensures they are firstMinter) 112 assert params.owner == sp.sender, "MUST_MINT_TO_SELF" 113 114 # Check for duplicate content hash 115 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH" 116 117 # Get next token ID from the library's counter 118 token_id = self.data.next_token_id 119 120 # Build token_info map with all TZIP-21 fields 121 # Values are stored as raw bytes (no packing) 122 token_info = sp.cast({ 123 "name": params.name, 124 "description": params.description, 125 "artifactUri": params.artifactUri, 126 "displayUri": params.displayUri, 127 "thumbnailUri": params.thumbnailUri, 128 "decimals": params.decimals, 129 "symbol": params.symbol, 130 "isBooleanAmount": params.isBooleanAmount, 131 "shouldPreferSymbol": params.shouldPreferSymbol, 132 "formats": params.formats, 133 "tags": params.tags, 134 "attributes": params.attributes, 135 "creators": params.creators, 136 "rights": params.rights, 137 "content_type": params.content_type, 138 "content_hash": params.content_hash, 139 "": params.metadata_uri # Empty key for off-chain metadata URI 140 }, sp.map[sp.string, sp.bytes]) 141 142 # Store token metadata using the library's mechanism 143 self.data.token_metadata[token_id] = sp.record( 144 token_id=token_id, 145 token_info=token_info 146 ) 147 148 # Assign token to owner using the library's ledger format (NFT: token_id -> owner) 149 self.data.ledger[token_id] = params.owner 150 151 # Initialize as not locked 152 self.data.metadata_locked[token_id] = False 153 154 # Store content hash to prevent future duplicates 155 self.data.content_hashes[params.content_hash] = token_id 156 157 # Increment token counter 158 self.data.next_token_id = token_id + 1 159 160 @sp.entrypoint 161 def edit_metadata(self, params): 162 """Update metadata for an existing token (admin only, if not locked).""" 163 sp.cast(params, sp.record( 164 token_id=sp.nat, 165 token_info=sp.map[sp.string, sp.bytes] 166 )) 167 168 assert self.is_administrator_(), "FA2_NOT_ADMIN" 169 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 170 171 # Check if locked 172 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 173 assert not is_locked, "METADATA_LOCKED" 174 175 # Update metadata 176 self.data.token_metadata[params.token_id] = sp.record( 177 token_id=params.token_id, 178 token_info=params.token_info 179 ) 180 181 @sp.entrypoint 182 def lock_metadata(self, token_id): 183 """Permanently lock metadata for a token (admin only).""" 184 sp.cast(token_id, sp.nat) 185 186 assert self.is_administrator_(), "FA2_NOT_ADMIN" 187 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 188 189 self.data.metadata_locked[token_id] = True 190 191 @sp.entrypoint 192 def set_contract_metadata(self, params): 193 """Update contract-level metadata (admin only, if not locked). 194 Use this to set collection icon, banner, description etc. 195 params: list of (key, value) pairs to set in the metadata big_map 196 """ 197 sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)]) 198 199 assert self.is_administrator_(), "FA2_NOT_ADMIN" 200 assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED" 201 202 for item in params: 203 self.data.metadata[item.key] = item.value 204 205 @sp.entrypoint 206 def lock_contract_metadata(self): 207 """Permanently lock contract-level metadata (admin only).""" 208 assert self.is_administrator_(), "FA2_NOT_ADMIN" 209 self.data.contract_metadata_locked = True 210 211 @sp.entrypoint 212 def set_keep_fee(self, new_fee): 213 """ 214 Set the keep fee required for keeping new tokens. 215 Admin only. Fee is in mutez (1 tez = 1,000,000 mutez). 216 Set to 0 for free keeping. 217 """ 218 sp.cast(new_fee, sp.mutez) 219 assert self.is_administrator_(), "FA2_NOT_ADMIN" 220 self.data.keep_fee = new_fee 221 222 @sp.entrypoint 223 def withdraw_fees(self, destination): 224 """ 225 Withdraw accumulated fees from the contract. 226 Admin only. Sends the entire contract balance to the destination. 227 """ 228 sp.cast(destination, sp.address) 229 assert self.is_administrator_(), "FA2_NOT_ADMIN" 230 sp.send(destination, sp.balance) 231 232 @sp.entrypoint 233 def burn_keep(self, token_id): 234 """ 235 Burn a token and remove its content_hash from the registry. 236 This allows the same piece name to be minted again. 237 Admin only. 238 """ 239 sp.cast(token_id, sp.nat) 240 241 assert self.is_administrator_(), "FA2_NOT_ADMIN" 242 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 243 244 # Get the content_hash before burning so we can remove it 245 token_info = self.data.token_metadata[token_id].token_info 246 content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 247 248 # Remove from content_hashes registry (allows re-minting this piece) 249 if self.data.content_hashes.contains(content_hash): 250 del self.data.content_hashes[content_hash] 251 252 # Remove from ledger 253 if self.data.ledger.contains(token_id): 254 del self.data.ledger[token_id] 255 256 # Remove token metadata 257 del self.data.token_metadata[token_id] 258 259 # Remove metadata lock entry 260 if self.data.metadata_locked.contains(token_id): 261 del self.data.metadata_locked[token_id] 262 263 264def _get_balance(fa2_contract, args): 265 """Utility function to call the contract's get_balance view.""" 266 return sp.View(fa2_contract, "get_balance")(args) 267 268 269def _total_supply(fa2_contract, args): 270 """Utility function to call the contract's total_supply view.""" 271 return sp.View(fa2_contract, "total_supply")(args) 272 273 274@sp.add_test() 275def test(): 276 scenario = sp.test_scenario("KeepsFA2v2") 277 scenario.h1("Keeps FA2 v2 Tests") 278 279 # Define test accounts 280 admin = sp.test_account("Admin") 281 alice = sp.test_account("Alice") 282 bob = sp.test_account("Bob") 283 284 # Create empty initial state 285 ledger = {} 286 token_metadata = [] 287 288 # Deploy contract 289 contract = keeps_module.KeepsFA2( 290 admin.address, 291 sp.big_map(), # contract_metadata - will be set below 292 ledger, 293 token_metadata 294 ) 295 296 # Build contract metadata 297 contract_metadata = sp.create_tzip16_metadata( 298 name="Aesthetic.computer Keeps", 299 description="FA2 NFT contract for aesthetic.computer keeps", 300 version="2.0.0", 301 license_name="MIT", 302 interfaces=["TZIP-012", "TZIP-016", "TZIP-021"], 303 authors=["aesthetic.computer"], 304 homepage="https://aesthetic.computer", 305 source_uri=None, 306 offchain_views=contract.get_offchain_views(), 307 ) 308 309 contract_metadata["permissions"] = { 310 "operator": "owner-or-operator-transfer", 311 "receiver": "owner-no-hook", 312 "sender": "owner-no-hook", 313 } 314 315 # Placeholder metadata URI 316 metadata_uri = "ipfs://example" 317 contract.data.metadata = sp.scenario_utils.metadata_of_url(metadata_uri) 318 319 scenario += contract 320 321 if scenario.simulation_mode() is sp.SimulationMode.MOCKUP: 322 scenario.p("mockups - skipping transfer tests") 323 return 324 325 scenario.h2("Mint token using keep entrypoint") 326 327 # Helper to convert string to bytes (same as sp.scenario_utils.bytes_of_string) 328 def str_to_bytes(s): 329 return sp.scenario_utils.bytes_of_string(s) 330 331 # Mint a token 332 contract.keep( 333 name=str_to_bytes("Test Token"), 334 description=str_to_bytes("A test token for aesthetic.computer"), 335 artifactUri=str_to_bytes("ipfs://QmXYZ"), 336 displayUri=str_to_bytes("ipfs://QmXYZ"), 337 thumbnailUri=str_to_bytes("ipfs://QmXYZ"), 338 decimals=str_to_bytes("0"), 339 symbol=str_to_bytes("KEEP"), 340 isBooleanAmount=str_to_bytes("true"), 341 shouldPreferSymbol=str_to_bytes("false"), 342 formats=str_to_bytes("[]"), 343 tags=str_to_bytes("[]"), 344 attributes=str_to_bytes("[]"), 345 creators=str_to_bytes('["aesthetic.computer"]'), 346 rights=str_to_bytes("© All rights reserved."), 347 content_type=str_to_bytes("kidlisp"), 348 content_hash=str_to_bytes("QmXYZ"), 349 metadata_uri=str_to_bytes("https://example.com/metadata.json"), 350 owner=alice.address, 351 _sender=admin, 352 ) 353 354 # Verify token exists 355 scenario.verify(contract.data.next_token_id == 1) 356 scenario.verify(_get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 1) 357 scenario.verify(_total_supply(contract, sp.record(token_id=0)) == 1) 358 359 scenario.h2("Transfer token") 360 contract.transfer( 361 [ 362 sp.record( 363 from_=alice.address, 364 txs=[sp.record(to_=bob.address, amount=1, token_id=0)], 365 ), 366 ], 367 _sender=alice, 368 ) 369 370 scenario.verify(_get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 0) 371 scenario.verify(_get_balance(contract, sp.record(owner=bob.address, token_id=0)) == 1) 372 373 scenario.h2("Mint second token") 374 contract.keep( 375 name=str_to_bytes("Token Two"), 376 description=str_to_bytes("Second test token"), 377 artifactUri=str_to_bytes("ipfs://QmABC"), 378 displayUri=str_to_bytes("ipfs://QmABC"), 379 thumbnailUri=str_to_bytes("ipfs://QmABC"), 380 decimals=str_to_bytes("0"), 381 symbol=str_to_bytes("KEEP"), 382 isBooleanAmount=str_to_bytes("true"), 383 shouldPreferSymbol=str_to_bytes("false"), 384 formats=str_to_bytes("[]"), 385 tags=str_to_bytes("[]"), 386 attributes=str_to_bytes("[]"), 387 creators=str_to_bytes('["aesthetic.computer"]'), 388 rights=str_to_bytes("©"), 389 content_type=str_to_bytes("kidlisp"), 390 content_hash=str_to_bytes("QmABC"), 391 metadata_uri=str_to_bytes("https://example.com/metadata2.json"), 392 owner=admin.address, 393 _sender=admin, 394 ) 395 396 scenario.verify(contract.data.next_token_id == 2) 397 398 scenario.h2("Lock metadata") 399 contract.lock_metadata(1, _sender=admin) 400 scenario.verify(contract.data.metadata_locked[1] == True) 401 402 scenario.h2("Try to edit locked metadata - should fail") 403 contract.edit_metadata( 404 token_id=1, 405 token_info=sp.cast({ 406 "name": str_to_bytes("New Name") 407 }, sp.map[sp.string, sp.bytes]), 408 _sender=admin, 409 _valid=False, 410 ) 411 412 scenario.h2("Non-admin cannot mint to someone else") 413 contract.keep( 414 name=str_to_bytes("Unauthorized"), 415 description=str_to_bytes("Should fail"), 416 artifactUri=str_to_bytes("ipfs://Qm"), 417 displayUri=str_to_bytes("ipfs://Qm"), 418 thumbnailUri=str_to_bytes("ipfs://Qm"), 419 decimals=str_to_bytes("0"), 420 symbol=str_to_bytes("KEEP"), 421 isBooleanAmount=str_to_bytes("true"), 422 shouldPreferSymbol=str_to_bytes("false"), 423 formats=str_to_bytes("[]"), 424 tags=str_to_bytes("[]"), 425 attributes=str_to_bytes("[]"), 426 creators=str_to_bytes("[]"), 427 rights=str_to_bytes(""), 428 content_type=str_to_bytes("text"), 429 content_hash=str_to_bytes("other_piece"), 430 metadata_uri=str_to_bytes(""), 431 owner=admin.address, # Bob tries to mint to admin (not himself) 432 _sender=bob, # Bob is not admin 433 _valid=False, # Should fail: MUST_MINT_TO_SELF 434 ) 435 436 scenario.h2("Non-admin CAN mint to self (user-callable keep)") 437 contract.keep( 438 name=str_to_bytes("User Mint"), 439 description=str_to_bytes("Bob mints to himself"), 440 artifactUri=str_to_bytes("ipfs://QmUser"), 441 displayUri=str_to_bytes("ipfs://QmUser"), 442 thumbnailUri=str_to_bytes("ipfs://QmUserThumb"), 443 decimals=str_to_bytes("0"), 444 symbol=str_to_bytes("KEEP"), 445 isBooleanAmount=str_to_bytes("true"), 446 shouldPreferSymbol=str_to_bytes("false"), 447 formats=str_to_bytes("[]"), 448 tags=str_to_bytes('["user-minted"]'), 449 attributes=str_to_bytes("[]"), 450 creators=str_to_bytes('["@bob"]'), 451 rights=str_to_bytes(""), 452 content_type=str_to_bytes("KidLisp"), 453 content_hash=str_to_bytes("bob_piece"), 454 metadata_uri=str_to_bytes("ipfs://QmBobMetadata"), 455 owner=bob.address, # Bob mints to himself 456 _sender=bob, # Bob is the sender 457 _valid=True, # Should succeed - user mints to self 458 ) 459 460 # Verify Bob owns the new token 461 scenario.verify(contract.data.ledger[2] == bob.address)