Monorepo for Aesthetic.Computer aesthetic.computer
at main 447 lines 16 kB view raw
1""" 2Keeps FA2 v4 - Aesthetic Computer NFT Contract (PRODUCTION) 3 4This contract extends v3 with essential production features: 5 6v4 NEW FEATURES: 7- 10% Royalty Support: Automatic royalties on secondary sales (objkt.com compatible) 8- Emergency Pause: Admin can halt minting in emergencies 9- Admin Transfer: Customer service tool for edge cases 10 11v3 features (preserved): 12- edit_metadata: Token owner/creator can edit (preserves attribution) 13- token_creators: Tracks original creator for each token 14- Pre-encoded metadata bytes (TzKT/objkt compatible) 15- Fee system with withdraw capability 16- Burn and re-mint functionality 17 18Key feature: Metadata bytes stored directly WITHOUT sp.pack(), 19ensuring compatibility with TzKT, objkt, and other Tezos indexers. 20""" 21 22import smartpy as sp 23from smartpy.templates import fa2_lib as fa2 24 25main = fa2.main 26 27 28@sp.module 29def keeps_module(): 30 import main 31 32 # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>]. 33 class KeepsFA2( 34 main.Admin, 35 main.Nft, 36 main.MintNft, 37 main.BurnNft, 38 main.OnchainviewBalanceOf, 39 ): 40 """ 41 FA2 NFT contract for aesthetic.computer Keeps (v4 - PRODUCTION). 42 43 Extends v3 with: 44 - 10% royalty support for creator monetization 45 - Emergency pause for security incidents 46 - Admin transfer for customer service 47 48 v3 features: owner/creator editable metadata, attribution tracking 49 """ 50 51 def __init__(self, admin_address, contract_metadata, ledger, token_metadata): 52 # Initialize on-chain balance view 53 main.OnchainviewBalanceOf.__init__(self) 54 55 # Initialize the NFT-specific entrypoints 56 main.BurnNft.__init__(self) 57 main.MintNft.__init__(self) 58 59 # Initialize the NFT base class 60 main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 61 62 # Initialize administrative permissions 63 main.Admin.__init__(self, admin_address) 64 65 # Additional storage for metadata locking 66 self.data.metadata_locked = sp.cast( 67 sp.big_map(), 68 sp.big_map[sp.nat, sp.bool] 69 ) 70 71 # Track content hashes to prevent duplicate mints 72 # Maps content_hash (bytes) -> token_id (nat) 73 self.data.content_hashes = sp.cast( 74 sp.big_map(), 75 sp.big_map[sp.bytes, sp.nat] 76 ) 77 78 # Track original creator for each token (v3) 79 # Maps token_id -> creator address (the first minter) 80 self.data.token_creators = sp.cast( 81 sp.big_map(), 82 sp.big_map[sp.nat, sp.address] 83 ) 84 85 # Contract-level metadata lock flag 86 self.data.contract_metadata_locked = False 87 88 # Mint fee configuration (admin-adjustable) 89 # Default: 0 tez (free minting via admin key) 90 self.data.keep_fee = sp.tez(0) 91 92 # v4: Emergency pause flag (NEW) 93 # When true, minting and metadata edits are disabled 94 self.data.paused = False 95 96 # v4: Default royalty configuration (NEW) 97 # Basis points: 1000 = 10%, 2500 = 25% (max) 98 # Applied to all new mints unless overridden 99 self.data.default_royalty_bps = 1000 # 10% default 100 101 @sp.entrypoint 102 def keep(self, params): 103 """ 104 Mint a new Keep token with full TZIP-21 metadata + royalties. 105 106 Two modes: 107 1. Admin calling: mints to specified owner (for server-side minting) 108 2. User calling: mints to sender, requires fee payment 109 - USER becomes firstMinter for proper attribution 110 111 v4: Automatically adds royalty metadata based on default_royalty_bps 112 v4: Respects pause flag (cannot mint when paused) 113 114 All bytes parameters should be raw hex-encoded UTF-8 strings. 115 """ 116 sp.cast(params, sp.record( 117 name=sp.bytes, 118 description=sp.bytes, 119 artifactUri=sp.bytes, 120 displayUri=sp.bytes, 121 thumbnailUri=sp.bytes, 122 decimals=sp.bytes, 123 symbol=sp.bytes, 124 isBooleanAmount=sp.bytes, 125 shouldPreferSymbol=sp.bytes, 126 formats=sp.bytes, 127 tags=sp.bytes, 128 attributes=sp.bytes, 129 creators=sp.bytes, 130 royalties=sp.bytes, # v4: NEW - royalty metadata 131 rights=sp.bytes, 132 content_type=sp.bytes, 133 content_hash=sp.bytes, 134 metadata_uri=sp.bytes, 135 owner=sp.address 136 )) 137 138 # v4: Check if contract is paused 139 assert not self.data.paused, "CONTRACT_PAUSED" 140 141 # Determine minting mode and owner 142 is_admin = self.is_administrator_() 143 144 # Non-admin callers must pay the fee and can only mint to themselves 145 if not is_admin: 146 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE" 147 # User must mint to themselves (ensures they are firstMinter) 148 assert params.owner == sp.sender, "MUST_MINT_TO_SELF" 149 150 # Check for duplicate content hash 151 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH" 152 153 # Get next token ID from the library's counter 154 token_id = self.data.next_token_id 155 156 # Build token_info map with all TZIP-21 fields 157 # v4: Now includes royalties field 158 token_info = sp.cast({ 159 "name": params.name, 160 "description": params.description, 161 "artifactUri": params.artifactUri, 162 "displayUri": params.displayUri, 163 "thumbnailUri": params.thumbnailUri, 164 "decimals": params.decimals, 165 "symbol": params.symbol, 166 "isBooleanAmount": params.isBooleanAmount, 167 "shouldPreferSymbol": params.shouldPreferSymbol, 168 "formats": params.formats, 169 "tags": params.tags, 170 "attributes": params.attributes, 171 "creators": params.creators, 172 "royalties": params.royalties, # v4: NEW 173 "rights": params.rights, 174 "content_type": params.content_type, 175 "content_hash": params.content_hash, 176 "": params.metadata_uri # Empty key for off-chain metadata URI 177 }, sp.map[sp.string, sp.bytes]) 178 179 # Store token metadata 180 self.data.token_metadata[token_id] = sp.record( 181 token_id=token_id, 182 token_info=token_info 183 ) 184 185 # Assign token to owner 186 self.data.ledger[token_id] = params.owner 187 188 # Initialize as not locked 189 self.data.metadata_locked[token_id] = False 190 191 # Store content hash to prevent duplicates 192 self.data.content_hashes[params.content_hash] = token_id 193 194 # Track the original creator 195 self.data.token_creators[token_id] = params.owner 196 197 # Increment token counter 198 self.data.next_token_id = token_id + 1 199 200 @sp.entrypoint 201 def edit_metadata(self, params): 202 """ 203 Update metadata for an existing token. 204 205 Authorization (v3): 206 - Admin (contract administrator) 207 - Token owner (current holder) 208 - Original creator (preserves objkt.com attribution) 209 210 v4: Respects pause flag (cannot edit when paused) 211 """ 212 sp.cast(params, sp.record( 213 token_id=sp.nat, 214 token_info=sp.map[sp.string, sp.bytes] 215 )) 216 217 # v4: Check if contract is paused 218 assert not self.data.paused, "CONTRACT_PAUSED" 219 220 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 221 222 # Check authorization: admin, owner, or original creator 223 is_admin = self.is_administrator_() 224 is_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 225 is_creator = self.data.token_creators.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 226 227 assert is_admin or is_owner or is_creator, "NOT_AUTHORIZED" 228 229 # Check if locked 230 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 231 assert not is_locked, "METADATA_LOCKED" 232 233 # Update metadata 234 self.data.token_metadata[params.token_id] = sp.record( 235 token_id=params.token_id, 236 token_info=params.token_info 237 ) 238 239 @sp.entrypoint 240 def lock_metadata(self, token_id): 241 """Permanently lock metadata for a token (admin or owner only).""" 242 sp.cast(token_id, sp.nat) 243 244 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 245 246 # Check authorization: admin or owner 247 is_admin = self.is_administrator_() 248 is_owner = self.data.ledger.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 249 250 assert is_admin or is_owner, "NOT_AUTHORIZED" 251 252 self.data.metadata_locked[token_id] = True 253 254 @sp.entrypoint 255 def set_contract_metadata(self, params): 256 """Update contract-level metadata (admin only, if not locked).""" 257 sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)]) 258 259 assert self.is_administrator_(), "FA2_NOT_ADMIN" 260 assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED" 261 262 for item in params: 263 self.data.metadata[item.key] = item.value 264 265 @sp.entrypoint 266 def lock_contract_metadata(self): 267 """Permanently lock contract-level metadata (admin only).""" 268 assert self.is_administrator_(), "FA2_NOT_ADMIN" 269 self.data.contract_metadata_locked = True 270 271 @sp.entrypoint 272 def set_keep_fee(self, new_fee): 273 """ 274 Set the keep fee required for minting. 275 Admin only. Fee is in mutez (1 tez = 1,000,000 mutez). 276 """ 277 sp.cast(new_fee, sp.mutez) 278 assert self.is_administrator_(), "FA2_NOT_ADMIN" 279 self.data.keep_fee = new_fee 280 281 @sp.entrypoint 282 def withdraw_fees(self, destination): 283 """ 284 Withdraw accumulated fees from the contract. 285 Admin only. Sends entire contract balance to destination. 286 """ 287 sp.cast(destination, sp.address) 288 assert self.is_administrator_(), "FA2_NOT_ADMIN" 289 sp.send(destination, sp.balance) 290 291 @sp.entrypoint 292 def burn_keep(self, token_id): 293 """ 294 Burn a token and remove its content_hash. 295 This allows re-minting the same piece name. 296 Admin only. 297 """ 298 sp.cast(token_id, sp.nat) 299 300 assert self.is_administrator_(), "FA2_NOT_ADMIN" 301 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 302 303 # Get content_hash before burning 304 token_info = self.data.token_metadata[token_id].token_info 305 content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 306 307 # Remove from registries 308 if self.data.content_hashes.contains(content_hash): 309 del self.data.content_hashes[content_hash] 310 311 if self.data.ledger.contains(token_id): 312 del self.data.ledger[token_id] 313 314 del self.data.token_metadata[token_id] 315 316 if self.data.metadata_locked.contains(token_id): 317 del self.data.metadata_locked[token_id] 318 319 if self.data.token_creators.contains(token_id): 320 del self.data.token_creators[token_id] 321 322 # ===================================================================== 323 # v4 NEW ENTRYPOINTS 324 # ===================================================================== 325 326 @sp.entrypoint 327 def pause(self): 328 """ 329 Emergency pause - stops minting and metadata edits. 330 Admin only. 331 332 Use cases: 333 - Security vulnerability discovered 334 - IPFS infrastructure issues 335 - Spam attack detected 336 - Contract bug found 337 338 Note: Does NOT affect transfers (preserves FA2 composability) 339 """ 340 assert self.is_administrator_(), "FA2_NOT_ADMIN" 341 self.data.paused = True 342 343 @sp.entrypoint 344 def unpause(self): 345 """ 346 Resume normal operations after emergency pause. 347 Admin only. 348 """ 349 assert self.is_administrator_(), "FA2_NOT_ADMIN" 350 self.data.paused = False 351 352 @sp.entrypoint 353 def set_default_royalty(self, bps): 354 """ 355 Set default royalty percentage for new mints. 356 Admin only. 357 358 Args: 359 bps: Basis points (100 = 1%, 1000 = 10%, 2500 = 25% max) 360 361 Example: 362 set_default_royalty(1000) # 10% royalty 363 """ 364 sp.cast(bps, sp.nat) 365 assert self.is_administrator_(), "FA2_NOT_ADMIN" 366 assert bps <= 2500, "MAX_ROYALTY_25_PERCENT" 367 self.data.default_royalty_bps = bps 368 369 @sp.entrypoint 370 def admin_transfer(self, params): 371 """Admin emergency transfer""" 372 sp.cast(params, sp.record( 373 token_id=sp.nat, 374 from_=sp.address, 375 to_=sp.address 376 )) 377 378 assert self.is_administrator_(), "FA2_NOT_ADMIN" 379 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 380 381 current_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) 382 assert current_owner == params.from_, "INVALID_CURRENT_OWNER" 383 384 self.data.ledger[params.token_id] = params.to_ 385 386 # ===================================================================== 387 # ON-CHAIN VIEWS (v4) 388 # Temporarily commented out - may have Michelson compatibility issues 389 # ===================================================================== 390 391 # @sp.onchain_view() 392 # def get_token_creator(self, token_id): 393 # """Get the original creator of a token.""" 394 # sp.cast(token_id, sp.nat) 395 # return self.data.token_creators.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) 396 397 # @sp.onchain_view() 398 # def is_paused(self): 399 # """Check if contract is paused.""" 400 # return self.data.paused 401 402 # @sp.onchain_view() 403 # def get_default_royalty(self): 404 # """Get default royalty in basis points.""" 405 # return self.data.default_royalty_bps 406 407 # Note: Views can be added via TzKT API queries instead: 408 # - paused: storage query 409 # - default_royalty_bps: storage query 410 # - token_creators: bigmap query 411 412 413def _get_balance(fa2_contract, args): 414 """Utility function to call the contract's get_balance view.""" 415 return sp.View(fa2_contract, "get_balance")(args) 416 417 418def _total_supply(fa2_contract, args): 419 """Utility function to call the contract's total_supply view.""" 420 return sp.View(fa2_contract, "total_supply")(args) 421 422 423@sp.add_test() 424def test(): 425 """Minimal test to compile v4 contract.""" 426 scenario = sp.test_scenario("KeepsFA2v4") 427 scenario.h1("Keeps FA2 v4 - Production Contract") 428 429 # Define test account 430 admin = sp.test_account("Admin") 431 432 # Create empty initial state 433 ledger = {} 434 token_metadata = [] 435 436 # Deploy contract 437 contract = keeps_module.KeepsFA2( 438 admin.address, 439 sp.big_map(), 440 ledger, 441 token_metadata 442 ) 443 444 scenario += contract 445 446 scenario.p("✅ Contract compiled successfully") 447 scenario.p("v4 features: royalties, pause, admin transfer")