Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 428 lines 17 kB view raw
1""" 2KidLisp Keeps FA2 v10 - Aesthetic Computer NFT Contract 3 4v10 CHANGES from v9: 5- admin_transfer removed — admin cannot move tokens on behalf of owners 6- lock_metadata restricted to owner-only — admin can no longer freeze 7 a token's metadata without owner consent 8- withdraw_fees kept — all fees accumulate in-contract and are pulled 9 to treasury via withdraw_fees (no in-flight send at mint) 10 11Inherited from v9: 12- keep requires backend-issued signature (author permit) 13- keep signature binds: contract + owner + content_hash + deadline 14- owner-only burn_keep 15- creator refresh metadata policy (v7) 16- immutable fields: content_hash + royalties 17- emergency pause/unpause (does NOT affect transfers) 18- default fee: 2.5 XTZ 19""" 20 21import smartpy as sp 22from smartpy.templates import fa2_lib as fa2 23 24main = fa2.main 25 26 27@sp.module 28def keeps_module(): 29 import main 30 31 KEEP_PERMIT_SIGNER = sp.key("edpktwf7pNMMfRcMoxHANoFtJLgGhJLwTsiSqaEMB2CnnSDTeLKoF6") 32 33 class KidLispKeepsFA2v10( 34 main.Admin, 35 main.Nft, 36 main.OnchainviewBalanceOf, 37 ): 38 """ 39 FA2 NFT contract for KidLisp Keeps (v10). 40 41 v10 changes from v9: 42 - admin_transfer removed: admin has no power over token ownership 43 - lock_metadata is owner-only: admin cannot freeze someone else's metadata 44 - fees accumulate in-contract; use withdraw_fees to pull to treasury 45 """ 46 47 def __init__(self, admin_address, treasury_address, contract_metadata, ledger, token_metadata): 48 main.OnchainviewBalanceOf.__init__(self) 49 main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 50 main.Admin.__init__(self, admin_address) 51 52 # Fee destination — used by withdraw_fees, not sent at mint 53 self.data.treasury_address = sp.cast(treasury_address, sp.address) 54 55 # Metadata locking per token 56 self.data.metadata_locked = sp.cast( 57 sp.big_map(), 58 sp.big_map[sp.nat, sp.bool] 59 ) 60 61 # Content hash registry — prevents 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 # Original creator per token — used for creator refresh path 69 # Maps token_id -> creator address 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 76 self.data.contract_metadata_locked = False 77 78 # Mint fee — accumulates in-contract, pulled via withdraw_fees 79 self.data.keep_fee = sp.mutez(2500000) # 2.5 XTZ 80 81 # Emergency pause — stops minting and metadata edits only 82 # Transfers are always unaffected 83 self.data.paused = False 84 85 # Royalty split — informational, read by backend at mint time. 86 # Not enforced on-chain; royalties are passed as bytes in keep params. 87 # Backend builds objkt-standard royalties JSON: 88 # { "decimals": 4, "shares": { artist: 900, platform: 100 } } 89 # Total: 10% (9% artist + 1% platform). Decimals: 4 => out of 10000. 90 self.data.artist_royalty_bps = 900 # 9% 91 self.data.platform_royalty_bps = 100 # 1% — goes to treasury_address 92 93 @sp.entrypoint 94 def keep(self, params): 95 """ 96 Mint a new Keep token. 97 98 Two modes: 99 1. Admin calling: mints to specified owner (server-side path) 100 2. User calling: mints to sender, fee forwarded to treasury immediately 101 102 Requires a valid backend-issued keep permit (signed by KEEP_PERMIT_SIGNER). 103 Permit binds: contract + owner + content_hash + deadline. 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 permit_deadline=sp.timestamp, 119 keep_permit=sp.signature 120 )) 121 122 assert not self.data.paused, "MINTING_PAUSED" 123 124 is_admin = self.is_administrator_() 125 126 if not is_admin: 127 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE" 128 assert params.owner == sp.sender, "MUST_MINT_TO_SELF" 129 130 # Verify signed keep permit 131 permit_payload = sp.record( 132 contract=sp.self_address, 133 owner=params.owner, 134 content_hash=params.content_hash, 135 permit_deadline=params.permit_deadline, 136 ) 137 sp.cast( 138 permit_payload, 139 sp.record( 140 contract=sp.address, 141 owner=sp.address, 142 content_hash=sp.bytes, 143 permit_deadline=sp.timestamp, 144 ).layout(("contract", ("owner", ("content_hash", "permit_deadline")))) 145 ) 146 assert sp.now <= params.permit_deadline, "PERMIT_EXPIRED" 147 assert sp.check_signature( 148 KEEP_PERMIT_SIGNER, 149 params.keep_permit, 150 sp.pack(permit_payload) 151 ), "INVALID_KEEP_PERMIT" 152 153 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH" 154 155 token_id = self.data.next_token_id 156 157 token_info = sp.cast({ 158 "name": params.name, 159 "symbol": params.symbol, 160 "description": params.description, 161 "artifactUri": params.artifactUri, 162 "displayUri": params.displayUri, 163 "thumbnailUri": params.thumbnailUri, 164 "decimals": params.decimals, 165 "creators": params.creators, 166 "royalties": params.royalties, 167 "content_hash": params.content_hash, 168 "metadata_uri": params.metadata_uri, 169 "": params.metadata_uri 170 }, sp.map[sp.string, sp.bytes]) 171 172 self.data.token_metadata[token_id] = sp.record( 173 token_id=token_id, 174 token_info=token_info 175 ) 176 177 self.data.ledger[token_id] = params.owner 178 self.data.metadata_locked[token_id] = False 179 self.data.content_hashes[params.content_hash] = token_id 180 self.data.token_creators[token_id] = params.owner 181 self.data.next_token_id = token_id + 1 182 # Fee accumulates in-contract — use withdraw_fees to pull to treasury 183 184 @sp.entrypoint 185 def mint(self, batch): 186 """Disabled — use keep.""" 187 sp.cast( 188 batch, 189 sp.list[ 190 sp.record( 191 to_=sp.address, 192 metadata=sp.map[sp.string, sp.bytes], 193 ).layout(("to_", "metadata")) 194 ], 195 ) 196 assert False, "MINT_DISABLED_USE_KEEP" 197 198 @sp.entrypoint 199 def burn(self, batch): 200 """Disabled — use burn_keep.""" 201 sp.cast( 202 batch, 203 sp.list[ 204 sp.record( 205 from_=sp.address, 206 token_id=sp.nat, 207 amount=sp.nat, 208 ).layout(("from_", ("token_id", "amount"))) 209 ], 210 ) 211 assert False, "BURN_DISABLED_USE_BURN_KEEP" 212 213 @sp.entrypoint 214 def edit_metadata(self, params): 215 """ 216 Update metadata for an existing token. 217 218 Authorization: 219 - Current token owner: full metadata edit 220 - Original creator: refresh-only (URI/presentation fields only) 221 222 content_hash and royalties are always preserved from original mint. 223 """ 224 sp.cast(params, sp.record( 225 token_id=sp.nat, 226 token_info=sp.map[sp.string, sp.bytes] 227 )) 228 229 assert not self.data.paused, "EDITING_PAUSED" 230 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 231 232 is_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 233 is_creator = self.data.token_creators.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 234 235 assert is_owner or is_creator, "NOT_AUTHORIZED" 236 237 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 238 assert not is_locked, "METADATA_LOCKED" 239 240 existing_info = self.data.token_metadata[params.token_id].token_info 241 original_hash = existing_info.get("content_hash", default=sp.bytes("0x")) 242 original_royalties = existing_info.get("royalties", default=sp.bytes("0x")) 243 244 if is_owner: 245 self.data.token_metadata[params.token_id] = sp.record( 246 token_id=params.token_id, 247 token_info=params.token_info 248 ) 249 else: 250 # Creator refresh path: URI/presentation fields only 251 refreshed_info = existing_info 252 mutable_refresh_fields = [ 253 "", 254 "metadata_uri", 255 "artifactUri", 256 "displayUri", 257 "thumbnailUri", 258 "formats", 259 "tags", 260 "attributes", 261 "rights", 262 "content_type", 263 "isBooleanAmount", 264 "shouldPreferSymbol", 265 ] 266 for field in mutable_refresh_fields: 267 if params.token_info.contains(field): 268 refreshed_info[field] = params.token_info[field] 269 270 # Keep "" and metadata_uri aligned 271 if params.token_info.contains("metadata_uri"): 272 refreshed_info[""] = params.token_info["metadata_uri"] 273 if params.token_info.contains(""): 274 refreshed_info["metadata_uri"] = params.token_info[""] 275 276 self.data.token_metadata[params.token_id] = sp.record( 277 token_id=params.token_id, 278 token_info=refreshed_info 279 ) 280 281 # Always re-inject immutable fields 282 self.data.token_metadata[params.token_id].token_info["content_hash"] = original_hash 283 self.data.token_metadata[params.token_id].token_info["royalties"] = original_royalties 284 285 @sp.entrypoint 286 def lock_metadata(self, token_id): 287 """ 288 Permanently lock token metadata. Owner only. Irreversible. 289 Admin intentionally excluded — only the token owner can freeze their own metadata. 290 """ 291 sp.cast(token_id, sp.nat) 292 293 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 294 295 is_owner = self.data.ledger.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 296 assert is_owner, "NOT_TOKEN_OWNER" 297 298 self.data.metadata_locked[token_id] = True 299 300 @sp.entrypoint 301 def burn_keep(self, token_id): 302 """ 303 Burn a token and free its content_hash for re-minting. 304 Owner only. 305 """ 306 sp.cast(token_id, sp.nat) 307 308 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 309 current_owner = self.data.ledger.get( 310 token_id, 311 default=sp.address("tz1burnburnburnburnburnburnburjAYjjX") 312 ) 313 assert current_owner == sp.sender, "NOT_TOKEN_OWNER" 314 315 token_info = self.data.token_metadata[token_id].token_info 316 content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 317 318 if self.data.content_hashes.contains(content_hash): 319 del self.data.content_hashes[content_hash] 320 321 if self.data.ledger.contains(token_id): 322 del self.data.ledger[token_id] 323 324 del self.data.token_metadata[token_id] 325 326 if self.data.metadata_locked.contains(token_id): 327 del self.data.metadata_locked[token_id] 328 329 if self.data.token_creators.contains(token_id): 330 del self.data.token_creators[token_id] 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 withdraw_fees(self, destination): 351 """ 352 Withdraw all accumulated keep fees to destination. 353 Covers both user-path and admin-path mints. 354 Admin only. 355 """ 356 sp.cast(destination, sp.address) 357 assert self.is_administrator_(), "FA2_NOT_ADMIN" 358 sp.send(destination, sp.balance) 359 360 @sp.entrypoint 361 def set_keep_fee(self, new_fee): 362 """Update the keep fee. Admin only. Fee in mutez.""" 363 sp.cast(new_fee, sp.mutez) 364 assert self.is_administrator_(), "FA2_NOT_ADMIN" 365 self.data.keep_fee = new_fee 366 367 @sp.entrypoint 368 def set_treasury(self, new_treasury): 369 """Update the fee treasury address. Admin only.""" 370 sp.cast(new_treasury, sp.address) 371 assert self.is_administrator_(), "FA2_NOT_ADMIN" 372 self.data.treasury_address = new_treasury 373 374 @sp.entrypoint 375 def pause(self): 376 """ 377 Emergency pause — stops minting and metadata edits. 378 Does NOT affect FA2 transfers. 379 Admin only. 380 """ 381 assert self.is_administrator_(), "FA2_NOT_ADMIN" 382 self.data.paused = True 383 384 @sp.entrypoint 385 def unpause(self): 386 """Resume normal operations. Admin only.""" 387 assert self.is_administrator_(), "FA2_NOT_ADMIN" 388 self.data.paused = False 389 390 @sp.entrypoint 391 def set_royalty_split(self, params): 392 """ 393 Update the artist/platform royalty split for new mints. 394 Admin only. Total must not exceed 2500 bps (25%). 395 Backend reads these values when building keep params. 396 """ 397 sp.cast(params, sp.record(artist_bps=sp.nat, platform_bps=sp.nat)) 398 assert self.is_administrator_(), "FA2_NOT_ADMIN" 399 assert params.artist_bps + params.platform_bps <= 2500, "MAX_ROYALTY_25_PERCENT" 400 self.data.artist_royalty_bps = params.artist_bps 401 self.data.platform_royalty_bps = params.platform_bps 402 403 404@sp.add_test() 405def test(): 406 scenario = sp.test_scenario("KeepsFA2v10") 407 scenario.h1("KidLisp Keeps FA2 v10") 408 409 admin = sp.test_account("Admin") 410 treasury = sp.test_account("Treasury") 411 412 ledger = {} 413 token_metadata = [] 414 415 contract = keeps_module.KidLispKeepsFA2v10( 416 admin.address, 417 treasury.address, 418 sp.big_map(), 419 ledger, 420 token_metadata 421 ) 422 423 scenario += contract 424 425 scenario.p("v10: admin_transfer removed, lock_metadata owner-only, fees accumulate via withdraw_fees") 426 scenario.p("v9: signed keep permits + owner self-mint enforcement") 427 scenario.p("v7: owner full edit + creator refresh-only metadata updates") 428 scenario.p("v6: owner-only burn_keep + royalties immutable after keep")