Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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