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