Monorepo for Aesthetic.Computer aesthetic.computer
at main 349 lines 14 kB view raw
1""" 2Keeps FA2 v3 - 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 7v3 Changes from v2: 8- edit_metadata: Now allows token OWNER to edit (not just admin) 9 This preserves artist attribution on objkt.com when updating metadata. 10- Added token_creators bigmap to track original creator for each token 11 12Key feature: Metadata bytes are stored directly WITHOUT sp.pack(), 13ensuring compatibility with TzKT, objkt, and other Tezos indexers. 14""" 15 16import smartpy as sp 17from smartpy.templates import fa2_lib as fa2 18 19main = fa2.main 20 21 22@sp.module 23def keeps_module(): 24 import main 25 26 # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>]. 27 class KeepsFA2( 28 main.Admin, 29 main.Nft, 30 main.MintNft, 31 main.BurnNft, 32 main.OnchainviewBalanceOf, 33 ): 34 """ 35 FA2 NFT contract for aesthetic.computer Keeps (v3). 36 37 Extends the standard FA2 library with a custom `keep` entrypoint 38 that accepts all TZIP-21 metadata fields as pre-encoded bytes. 39 40 v3: Token owners can edit their own token metadata. 41 """ 42 43 def __init__(self, admin_address, contract_metadata, ledger, token_metadata): 44 # Initialize on-chain balance view 45 main.OnchainviewBalanceOf.__init__(self) 46 47 # Initialize the NFT-specific entrypoints 48 main.BurnNft.__init__(self) 49 main.MintNft.__init__(self) 50 51 # Initialize the NFT base class 52 main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 53 54 # Initialize administrative permissions 55 main.Admin.__init__(self, admin_address) 56 57 # Additional storage for metadata locking 58 self.data.metadata_locked = sp.cast( 59 sp.big_map(), 60 sp.big_map[sp.nat, sp.bool] 61 ) 62 63 # Track content hashes to prevent duplicate mints 64 # Maps content_hash (bytes) -> token_id (nat) 65 self.data.content_hashes = sp.cast( 66 sp.big_map(), 67 sp.big_map[sp.bytes, sp.nat] 68 ) 69 70 # Track original creator for each token (v3) 71 # Maps token_id -> creator address (the first minter) 72 self.data.token_creators = sp.cast( 73 sp.big_map(), 74 sp.big_map[sp.nat, sp.address] 75 ) 76 77 # Contract-level metadata lock flag 78 self.data.contract_metadata_locked = False 79 80 # Mint fee configuration (admin-adjustable) 81 # Default: 0 tez (free minting via admin key) 82 # Can be set to require payment when users mint via their wallets 83 self.data.keep_fee = sp.tez(0) 84 85 @sp.entrypoint 86 def keep(self, params): 87 """ 88 Mint a new Keep token with full TZIP-21 metadata. 89 90 Two modes: 91 1. Admin calling with owner param: mints to specified owner (for server-side minting) 92 2. User calling directly: mints to sender, requires fee payment 93 - This makes the USER the firstMinter for proper artist attribution 94 95 All bytes parameters should be raw hex-encoded UTF-8 strings 96 (NOT Michelson-packed with 0x05 prefix). 97 """ 98 sp.cast(params, sp.record( 99 name=sp.bytes, 100 description=sp.bytes, 101 artifactUri=sp.bytes, 102 displayUri=sp.bytes, 103 thumbnailUri=sp.bytes, 104 decimals=sp.bytes, 105 symbol=sp.bytes, 106 isBooleanAmount=sp.bytes, 107 shouldPreferSymbol=sp.bytes, 108 formats=sp.bytes, 109 tags=sp.bytes, 110 attributes=sp.bytes, 111 creators=sp.bytes, 112 rights=sp.bytes, 113 content_type=sp.bytes, 114 content_hash=sp.bytes, 115 metadata_uri=sp.bytes, 116 owner=sp.address 117 )) 118 119 # Determine minting mode and owner 120 is_admin = self.is_administrator_() 121 122 # Non-admin callers must pay the fee and can only mint to themselves 123 if not is_admin: 124 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE" 125 # User must mint to themselves (ensures they are firstMinter) 126 assert params.owner == sp.sender, "MUST_MINT_TO_SELF" 127 128 # Check for duplicate content hash 129 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH" 130 131 # Get next token ID from the library's counter 132 token_id = self.data.next_token_id 133 134 # Build token_info map with all TZIP-21 fields 135 # Values are stored as raw bytes (no packing) 136 token_info = sp.cast({ 137 "name": params.name, 138 "description": params.description, 139 "artifactUri": params.artifactUri, 140 "displayUri": params.displayUri, 141 "thumbnailUri": params.thumbnailUri, 142 "decimals": params.decimals, 143 "symbol": params.symbol, 144 "isBooleanAmount": params.isBooleanAmount, 145 "shouldPreferSymbol": params.shouldPreferSymbol, 146 "formats": params.formats, 147 "tags": params.tags, 148 "attributes": params.attributes, 149 "creators": params.creators, 150 "rights": params.rights, 151 "content_type": params.content_type, 152 "content_hash": params.content_hash, 153 "": params.metadata_uri # Empty key for off-chain metadata URI 154 }, sp.map[sp.string, sp.bytes]) 155 156 # Store token metadata using the library's mechanism 157 self.data.token_metadata[token_id] = sp.record( 158 token_id=token_id, 159 token_info=token_info 160 ) 161 162 # Assign token to owner using the library's ledger format (NFT: token_id -> owner) 163 self.data.ledger[token_id] = params.owner 164 165 # Initialize as not locked 166 self.data.metadata_locked[token_id] = False 167 168 # Store content hash to prevent future duplicates 169 self.data.content_hashes[params.content_hash] = token_id 170 171 # Track the original creator (v3) 172 # Use sender for user mints, owner param for admin mints 173 self.data.token_creators[token_id] = params.owner 174 175 # Increment token counter 176 self.data.next_token_id = token_id + 1 177 178 @sp.entrypoint 179 def edit_metadata(self, params): 180 """ 181 Update metadata for an existing token. 182 183 v3: Can be called by: 184 - Admin (contract administrator) 185 - Token owner (current holder of the token) 186 - Original creator (the address that first minted the token) 187 188 This allows artists to update their work without admin intervention, 189 preserving proper attribution on marketplaces like objkt.com. 190 """ 191 sp.cast(params, sp.record( 192 token_id=sp.nat, 193 token_info=sp.map[sp.string, sp.bytes] 194 )) 195 196 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 197 198 # Check authorization: admin, owner, or original creator 199 is_admin = self.is_administrator_() 200 is_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 201 is_creator = self.data.token_creators.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 202 203 assert is_admin or is_owner or is_creator, "NOT_AUTHORIZED" 204 205 # Check if locked 206 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 207 assert not is_locked, "METADATA_LOCKED" 208 209 # Update metadata 210 self.data.token_metadata[params.token_id] = sp.record( 211 token_id=params.token_id, 212 token_info=params.token_info 213 ) 214 215 @sp.entrypoint 216 def lock_metadata(self, token_id): 217 """Permanently lock metadata for a token (admin or owner only).""" 218 sp.cast(token_id, sp.nat) 219 220 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 221 222 # Check authorization: admin or owner 223 is_admin = self.is_administrator_() 224 is_owner = self.data.ledger.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 225 226 assert is_admin or is_owner, "NOT_AUTHORIZED" 227 228 self.data.metadata_locked[token_id] = True 229 230 @sp.entrypoint 231 def set_contract_metadata(self, params): 232 """Update contract-level metadata (admin only, if not locked). 233 Use this to set collection icon, banner, description etc. 234 params: list of (key, value) pairs to set in the metadata big_map 235 """ 236 sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)]) 237 238 assert self.is_administrator_(), "FA2_NOT_ADMIN" 239 assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED" 240 241 for item in params: 242 self.data.metadata[item.key] = item.value 243 244 @sp.entrypoint 245 def lock_contract_metadata(self): 246 """Permanently lock contract-level metadata (admin only).""" 247 assert self.is_administrator_(), "FA2_NOT_ADMIN" 248 self.data.contract_metadata_locked = True 249 250 @sp.entrypoint 251 def set_keep_fee(self, new_fee): 252 """ 253 Set the keep fee required for keeping new tokens. 254 Admin only. Fee is in mutez (1 tez = 1,000,000 mutez). 255 Set to 0 for free keeping. 256 """ 257 sp.cast(new_fee, sp.mutez) 258 assert self.is_administrator_(), "FA2_NOT_ADMIN" 259 self.data.keep_fee = new_fee 260 261 @sp.entrypoint 262 def withdraw_fees(self, destination): 263 """ 264 Withdraw accumulated fees from the contract. 265 Admin only. Sends the entire contract balance to the destination. 266 """ 267 sp.cast(destination, sp.address) 268 assert self.is_administrator_(), "FA2_NOT_ADMIN" 269 sp.send(destination, sp.balance) 270 271 @sp.entrypoint 272 def burn_keep(self, token_id): 273 """ 274 Burn a token and remove its content_hash from the registry. 275 This allows the same piece name to be minted again. 276 Admin only. 277 """ 278 sp.cast(token_id, sp.nat) 279 280 assert self.is_administrator_(), "FA2_NOT_ADMIN" 281 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 282 283 # Get the content_hash before burning so we can remove it 284 token_info = self.data.token_metadata[token_id].token_info 285 content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 286 287 # Remove from content_hashes registry (allows re-minting this piece) 288 if self.data.content_hashes.contains(content_hash): 289 del self.data.content_hashes[content_hash] 290 291 # Remove from ledger 292 if self.data.ledger.contains(token_id): 293 del self.data.ledger[token_id] 294 295 # Remove token metadata 296 del self.data.token_metadata[token_id] 297 298 # Remove metadata lock entry 299 if self.data.metadata_locked.contains(token_id): 300 del self.data.metadata_locked[token_id] 301 302 # Remove creator entry (v3) 303 if self.data.token_creators.contains(token_id): 304 del self.data.token_creators[token_id] 305 306 @sp.onchain_view() 307 def get_token_creator(self, token_id): 308 """Get the original creator of a token.""" 309 sp.cast(token_id, sp.nat) 310 return self.data.token_creators.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) 311 312 313def _get_balance(fa2_contract, args): 314 """Utility function to call the contract's get_balance view.""" 315 return sp.View(fa2_contract, "get_balance")(args) 316 317 318def _total_supply(fa2_contract, args): 319 """Utility function to call the contract's total_supply view.""" 320 return sp.View(fa2_contract, "total_supply")(args) 321 322 323@sp.add_test() 324def test(): 325 """Simple test to compile the contract.""" 326 scenario = sp.test_scenario("KeepsFA2v3") 327 scenario.h1("Keeps FA2 v3 - Compile Test") 328 329 # Define test accounts 330 admin = sp.test_account("Admin") 331 332 # Create empty initial state 333 ledger = {} 334 token_metadata = [] 335 336 # Deploy contract 337 contract = keeps_module.KeepsFA2( 338 admin.address, 339 sp.big_map(), 340 ledger, 341 token_metadata 342 ) 343 344 scenario += contract 345 346 # Just verify it compiles - actual tests done manually 347 scenario.h2("Contract compiled successfully") 348 scenario.p("v3 features: token owner/creator can edit metadata") 349