Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

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