Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 904 lines 39 kB view raw
1""" 2KidLisp Keeps FA2 v12 (draft) - trust-minimized design 3 4Design goals: 5- No admin role in-contract (no mutable privileged entrypoints). 6- User-only minting via commit-reveal (no backend permit signer). 7- Owner-only burn_keep. 8- content_hash + royalties remain immutable after mint. 9- Metadata refresh remains possible with per-token policy controls. 10- Contract metadata upgrades/deprecation governed by token holders (no admin key). 11- Optional trustless v11->v12 claim migration path. 12 13Important notes: 14- This is a draft for design review and testing. 15- Royalty bytes are still client-supplied metadata, but become immutable post-mint. 16- Commit-reveal is used to reduce mempool front-running risk for content_hash claims. 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 t_commitment_key: type = sp.record(owner=sp.address, commitment=sp.bytes).layout( 30 ("owner", "commitment") 31 ) 32 t_contract_metadata_update: type = sp.record(key=sp.string, value=sp.bytes).layout( 33 ("key", "value") 34 ) 35 t_contract_upgrade_vote_key: type = sp.record( 36 proposal_id=sp.nat, token_id=sp.nat 37 ).layout(("proposal_id", "token_id")) 38 t_fa2_balance_of_request: type = sp.record(owner=sp.address, token_id=sp.nat).layout( 39 ("owner", "token_id") 40 ) 41 t_fa2_balance_of_response: type = sp.record( 42 request=t_fa2_balance_of_request, 43 balance=sp.nat, 44 ).layout(("request", "balance")) 45 t_contract_upgrade_proposal: type = sp.record( 46 proposer=sp.address, 47 created_at=sp.timestamp, 48 voting_deadline=sp.timestamp, 49 metadata_updates=sp.list[t_contract_metadata_update], 50 metadata_updates_hash=sp.bytes, 51 successor=sp.option[sp.address], 52 deprecate=sp.bool, 53 yes_votes=sp.nat, 54 no_votes=sp.nat, 55 executed=sp.bool, 56 ).layout( 57 ( 58 "proposer", 59 ( 60 "created_at", 61 ( 62 "voting_deadline", 63 ( 64 "metadata_updates", 65 ( 66 "metadata_updates_hash", 67 ("successor", ("deprecate", ("yes_votes", ("no_votes", "executed")))), 68 ), 69 ), 70 ), 71 ), 72 ) 73 ) 74 75 # Metadata refresh policy constants 76 POLICY_OWNER_ONLY = 0 77 POLICY_CREATOR_ONLY = 1 78 POLICY_OWNER_OR_CREATOR = 2 79 POLICY_OWNER_AND_CREATOR = 3 80 81 # Contract lifecycle constants 82 CONTRACT_STATE_ACTIVE = 0 83 CONTRACT_STATE_DEPRECATED = 1 84 85 BURN_ADDRESS = sp.address("tz1burnburnburnburnburnburnburjAYjjX") 86 87 class KidLispKeepsFA2v12( 88 main.Nft, 89 main.OnchainviewBalanceOf, 90 ): 91 """ 92 v12 draft: 93 - No admin role. 94 - No permit signer. 95 - Commit-reveal keep authorization. 96 - Fee forwarded immediately to treasury (no withdraw path). 97 - Per-token metadata refresh policy. 98 - Holder-governed contract metadata upgrades and deprecation. 99 - Trustless v11 owner-claim migration path. 100 """ 101 102 def __init__( 103 self, 104 treasury_address, 105 migration_source_contract, 106 contract_metadata, 107 ledger, 108 token_metadata, 109 keep_fee, 110 commitment_min_delay_seconds, 111 artist_royalty_bps, 112 platform_royalty_bps, 113 governance_voting_period_seconds, 114 governance_quorum_bps, 115 governance_approval_bps, 116 ): 117 main.OnchainviewBalanceOf.__init__(self) 118 main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 119 120 # Immutable-at-deploy policy fields (no admin setters in v12). 121 self.data.treasury_address = sp.cast(treasury_address, sp.address) 122 self.data.migration_source_contract = sp.cast( 123 migration_source_contract, sp.address 124 ) 125 self.data.keep_fee = sp.cast(keep_fee, sp.mutez) 126 self.data.commitment_min_delay_seconds = sp.cast( 127 commitment_min_delay_seconds, sp.nat 128 ) 129 self.data.artist_royalty_bps = sp.cast(artist_royalty_bps, sp.nat) 130 self.data.platform_royalty_bps = sp.cast(platform_royalty_bps, sp.nat) 131 self.data.governance_voting_period_seconds = sp.cast( 132 governance_voting_period_seconds, sp.nat 133 ) 134 self.data.governance_quorum_bps = sp.cast(governance_quorum_bps, sp.nat) 135 self.data.governance_approval_bps = sp.cast(governance_approval_bps, sp.nat) 136 assert self.data.governance_voting_period_seconds > 0, "INVALID_VOTING_PERIOD" 137 assert self.data.governance_quorum_bps <= 10000, "INVALID_QUORUM_BPS" 138 assert self.data.governance_approval_bps <= 10000, "INVALID_APPROVAL_BPS" 139 140 # Content hash registry (dedupe) 141 self.data.content_hashes = sp.cast( 142 sp.big_map(), sp.big_map[sp.bytes, sp.nat] 143 ) 144 145 # Token creator address + key (key needed for creator co-sign refresh mode) 146 self.data.token_creators = sp.cast( 147 sp.big_map(), sp.big_map[sp.nat, sp.address] 148 ) 149 self.data.token_creator_keys = sp.cast( 150 sp.big_map(), sp.big_map[sp.nat, sp.key] 151 ) 152 153 # Owner-controlled metadata lock 154 self.data.metadata_locked = sp.cast( 155 sp.big_map(), sp.big_map[sp.nat, sp.bool] 156 ) 157 158 # Per-token refresh policy + nonce for creator co-sign anti-replay 159 self.data.refresh_policies = sp.cast( 160 sp.big_map(), sp.big_map[sp.nat, sp.nat] 161 ) 162 self.data.refresh_nonces = sp.cast( 163 sp.big_map(), sp.big_map[sp.nat, sp.nat] 164 ) 165 166 # Commit-reveal registry: (owner, commitment) -> registered_at 167 self.data.keep_commitments = sp.cast( 168 sp.big_map(), sp.big_map[t_commitment_key, sp.timestamp] 169 ) 170 171 # Token count used for trustless governance denominator. 172 self.data.active_token_count = sp.cast(len(ledger), sp.nat) 173 174 # Contract lifecycle state. 175 self.data.contract_state = sp.cast(CONTRACT_STATE_ACTIVE, sp.nat) 176 self.data.deprecated_successor = sp.cast(None, sp.option[sp.address]) 177 self.data.deprecated_at = sp.cast(None, sp.option[sp.timestamp]) 178 179 # Holder-governed contract metadata/deprecation proposals. 180 self.data.contract_upgrade_proposals = sp.cast( 181 sp.big_map(), sp.big_map[sp.nat, t_contract_upgrade_proposal] 182 ) 183 self.data.contract_upgrade_votes = sp.cast( 184 sp.big_map(), sp.big_map[t_contract_upgrade_vote_key, sp.bool] 185 ) 186 self.data.next_contract_upgrade_proposal_id = sp.cast(0, sp.nat) 187 188 # v11 -> v12 migration claims: old token id -> new token id 189 self.data.migration_claims = sp.cast( 190 sp.big_map(), sp.big_map[sp.nat, sp.nat] 191 ) 192 193 @sp.entrypoint 194 def register_keep_commitment(self, commitment): 195 """ 196 Register a pre-commitment for a future keep. 197 Users submit commitment = blake2b(pack(contract, owner, content_hash, salt)). 198 """ 199 sp.cast(commitment, sp.bytes) 200 assert self.data.contract_state == CONTRACT_STATE_ACTIVE, "CONTRACT_DEPRECATED" 201 key = sp.record(owner=sp.sender, commitment=commitment) 202 sp.cast(key, t_commitment_key) 203 assert not self.data.keep_commitments.contains(key), "COMMITMENT_EXISTS" 204 self.data.keep_commitments[key] = sp.now 205 206 @sp.entrypoint 207 def cancel_keep_commitment(self, commitment): 208 """Remove a previously registered commitment for the sender.""" 209 sp.cast(commitment, sp.bytes) 210 key = sp.record(owner=sp.sender, commitment=commitment) 211 sp.cast(key, t_commitment_key) 212 assert self.data.keep_commitments.contains(key), "COMMITMENT_NOT_FOUND" 213 del self.data.keep_commitments[key] 214 215 @sp.entrypoint 216 def keep(self, params): 217 """ 218 Mint a new keep. 219 220 Requirements: 221 - Exact keep fee payment. 222 - Valid matured commitment for (sender, content_hash, salt). 223 - Unique content_hash. 224 - creator_pubkey must correspond to sender. 225 """ 226 sp.cast( 227 params, 228 sp.record( 229 name=sp.bytes, 230 symbol=sp.bytes, 231 description=sp.bytes, 232 artifactUri=sp.bytes, 233 displayUri=sp.bytes, 234 thumbnailUri=sp.bytes, 235 decimals=sp.bytes, 236 creators=sp.bytes, 237 royalties=sp.bytes, 238 content_hash=sp.bytes, 239 metadata_uri=sp.bytes, 240 salt=sp.bytes, 241 creator_pubkey=sp.key, 242 initial_refresh_policy=sp.nat, 243 ), 244 ) 245 246 assert self.data.contract_state == CONTRACT_STATE_ACTIVE, "CONTRACT_DEPRECATED" 247 assert sp.amount == self.data.keep_fee, "INVALID_FEE_AMOUNT" 248 assert params.initial_refresh_policy <= POLICY_OWNER_AND_CREATOR, "INVALID_REFRESH_POLICY" 249 250 derived_creator = sp.to_address(sp.implicit_account(sp.hash_key(params.creator_pubkey))) 251 assert derived_creator == sp.sender, "CREATOR_KEY_SENDER_MISMATCH" 252 253 commitment_payload = sp.record( 254 contract=sp.self_address, 255 owner=sp.sender, 256 content_hash=params.content_hash, 257 salt=params.salt, 258 ) 259 sp.cast( 260 commitment_payload, 261 sp.record( 262 contract=sp.address, 263 owner=sp.address, 264 content_hash=sp.bytes, 265 salt=sp.bytes, 266 ).layout(("contract", ("owner", ("content_hash", "salt")))), 267 ) 268 commitment_key = sp.record( 269 owner=sp.sender, 270 commitment=sp.blake2b(sp.pack(commitment_payload)), 271 ) 272 sp.cast(commitment_key, t_commitment_key) 273 assert self.data.keep_commitments.contains(commitment_key), "COMMITMENT_NOT_FOUND" 274 registered_at = self.data.keep_commitments[commitment_key] 275 maturity = sp.add_seconds( 276 registered_at, sp.to_int(self.data.commitment_min_delay_seconds) 277 ) 278 assert sp.now >= maturity, "COMMITMENT_TOO_FRESH" 279 del self.data.keep_commitments[commitment_key] 280 281 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH" 282 283 token_id = self.data.next_token_id 284 token_info = sp.cast( 285 { 286 "name": params.name, 287 "symbol": params.symbol, 288 "description": params.description, 289 "artifactUri": params.artifactUri, 290 "displayUri": params.displayUri, 291 "thumbnailUri": params.thumbnailUri, 292 "decimals": params.decimals, 293 "creators": params.creators, 294 "royalties": params.royalties, 295 "content_hash": params.content_hash, 296 "metadata_uri": params.metadata_uri, 297 "": params.metadata_uri, 298 }, 299 sp.map[sp.string, sp.bytes], 300 ) 301 302 self.data.token_metadata[token_id] = sp.record( 303 token_id=token_id, 304 token_info=token_info, 305 ) 306 self.data.ledger[token_id] = sp.sender 307 self.data.metadata_locked[token_id] = False 308 self.data.content_hashes[params.content_hash] = token_id 309 self.data.token_creators[token_id] = sp.sender 310 self.data.token_creator_keys[token_id] = params.creator_pubkey 311 self.data.refresh_policies[token_id] = params.initial_refresh_policy 312 self.data.refresh_nonces[token_id] = 0 313 self.data.next_token_id = token_id + 1 314 self.data.active_token_count += 1 315 316 # Non-custodial fee handling: forward fee immediately. 317 if sp.amount > sp.mutez(0): 318 sp.send(self.data.treasury_address, sp.amount) 319 320 @sp.entrypoint 321 def claim_from_v11(self, params): 322 """ 323 Trustless one-time claim migration from v11. 324 Verifies current ownership on the source contract via get_balance_of view. 325 """ 326 sp.cast( 327 params, 328 sp.record( 329 old_token_id=sp.nat, 330 token_info=sp.map[sp.string, sp.bytes], 331 creator_pubkey=sp.key, 332 ), 333 ) 334 335 assert self.data.contract_state == CONTRACT_STATE_ACTIVE, "CONTRACT_DEPRECATED" 336 assert sp.amount == sp.mutez(0), "MIGRATION_NO_FEE_REQUIRED" 337 assert not self.data.migration_claims.contains(params.old_token_id), "OLD_TOKEN_ALREADY_CLAIMED" 338 339 derived_creator = sp.to_address( 340 sp.implicit_account(sp.hash_key(params.creator_pubkey)) 341 ) 342 assert derived_creator == sp.sender, "CREATOR_KEY_SENDER_MISMATCH" 343 344 requests = [ 345 sp.record( 346 owner=sp.sender, 347 token_id=params.old_token_id, 348 ) 349 ] 350 sp.cast(requests, sp.list[t_fa2_balance_of_request]) 351 352 source_balances = sp.view( 353 "get_balance_of", 354 self.data.migration_source_contract, 355 requests, 356 sp.list[t_fa2_balance_of_response], 357 ).unwrap_some(error="MIGRATION_VIEW_FAILED") 358 359 assert sp.len(source_balances) == 1, "MIGRATION_BAD_VIEW_RESPONSE_COUNT" 360 for response in source_balances: 361 assert response.request.owner == sp.sender, "MIGRATION_BAD_VIEW_RESPONSE" 362 assert response.request.token_id == params.old_token_id, "MIGRATION_BAD_VIEW_RESPONSE" 363 assert response.balance == 1, "NOT_SOURCE_TOKEN_OWNER" 364 365 content_hash = params.token_info.get("content_hash", default=sp.bytes("0x")) 366 royalties = params.token_info.get("royalties", default=sp.bytes("0x")) 367 assert content_hash != sp.bytes("0x"), "MISSING_CONTENT_HASH" 368 assert royalties != sp.bytes("0x"), "MISSING_ROYALTIES" 369 assert not self.data.content_hashes.contains(content_hash), "DUPLICATE_CONTENT_HASH" 370 371 token_id = self.data.next_token_id 372 token_info = params.token_info 373 374 # Keep metadata URI aliases aligned. 375 if token_info.contains("metadata_uri"): 376 token_info[""] = token_info["metadata_uri"] 377 if token_info.contains(""): 378 token_info["metadata_uri"] = token_info[""] 379 380 # Provenance tags for indexers/UIs. 381 token_info["upgraded_from_contract"] = sp.pack(self.data.migration_source_contract) 382 token_info["upgraded_from_token_id"] = sp.pack(params.old_token_id) 383 token_info["migration_kind"] = sp.bytes("0x7631315f636c61696d") # "v11_claim" 384 385 # Force immutable fields to canonical values before mint. 386 token_info["content_hash"] = content_hash 387 token_info["royalties"] = royalties 388 389 self.data.token_metadata[token_id] = sp.record( 390 token_id=token_id, 391 token_info=token_info, 392 ) 393 self.data.ledger[token_id] = sp.sender 394 self.data.metadata_locked[token_id] = False 395 self.data.content_hashes[content_hash] = token_id 396 self.data.token_creators[token_id] = sp.sender 397 self.data.token_creator_keys[token_id] = params.creator_pubkey 398 self.data.refresh_policies[token_id] = POLICY_OWNER_ONLY 399 self.data.refresh_nonces[token_id] = 0 400 self.data.next_token_id = token_id + 1 401 self.data.active_token_count += 1 402 self.data.migration_claims[params.old_token_id] = token_id 403 404 @sp.entrypoint 405 def mint(self, batch): 406 """Disabled — use keep.""" 407 sp.cast( 408 batch, 409 sp.list[ 410 sp.record( 411 to_=sp.address, 412 metadata=sp.map[sp.string, sp.bytes], 413 ).layout(("to_", "metadata")) 414 ], 415 ) 416 assert False, "MINT_DISABLED_USE_KEEP" 417 418 @sp.entrypoint 419 def burn(self, batch): 420 """Disabled — use burn_keep.""" 421 sp.cast( 422 batch, 423 sp.list[ 424 sp.record( 425 from_=sp.address, 426 token_id=sp.nat, 427 amount=sp.nat, 428 ).layout(("from_", ("token_id", "amount"))) 429 ], 430 ) 431 assert False, "BURN_DISABLED_USE_BURN_KEEP" 432 433 @sp.entrypoint 434 def propose_contract_upgrade(self, params): 435 """ 436 Create a holder-governed proposal for contract-level metadata updates. 437 Optional irreversible deprecation can be bundled in the same proposal. 438 """ 439 sp.cast( 440 params, 441 sp.record( 442 metadata_updates=sp.list[t_contract_metadata_update], 443 metadata_updates_hash=sp.bytes, 444 successor=sp.option[sp.address], 445 deprecate=sp.bool, 446 ), 447 ) 448 449 assert self.data.active_token_count > 0, "NO_ACTIVE_TOKENS" 450 assert sp.len(params.metadata_updates) > 0, "EMPTY_METADATA_UPDATES" 451 assert ( 452 sp.blake2b(sp.pack(params.metadata_updates)) 453 == params.metadata_updates_hash 454 ), "METADATA_HASH_MISMATCH" 455 if params.deprecate: 456 assert params.successor.is_some(), "SUCCESSOR_REQUIRED" 457 assert self.data.contract_state == CONTRACT_STATE_ACTIVE, "ALREADY_DEPRECATED" 458 459 proposal_id = self.data.next_contract_upgrade_proposal_id 460 voting_deadline = sp.add_seconds( 461 sp.now, 462 sp.to_int(self.data.governance_voting_period_seconds) 463 ) 464 proposal = sp.record( 465 proposer=sp.sender, 466 created_at=sp.now, 467 voting_deadline=voting_deadline, 468 metadata_updates=params.metadata_updates, 469 metadata_updates_hash=params.metadata_updates_hash, 470 successor=params.successor, 471 deprecate=params.deprecate, 472 yes_votes=0, 473 no_votes=0, 474 executed=False, 475 ) 476 sp.cast(proposal, t_contract_upgrade_proposal) 477 self.data.contract_upgrade_proposals[proposal_id] = proposal 478 self.data.next_contract_upgrade_proposal_id = proposal_id + 1 479 480 @sp.entrypoint 481 def vote_contract_upgrade(self, params): 482 """ 483 Vote a proposal with token IDs owned by sender. 484 One vote per token_id per proposal. 485 """ 486 sp.cast( 487 params, 488 sp.record( 489 proposal_id=sp.nat, 490 token_ids=sp.list[sp.nat], 491 support=sp.bool, 492 ), 493 ) 494 495 assert self.data.contract_upgrade_proposals.contains( 496 params.proposal_id 497 ), "PROPOSAL_NOT_FOUND" 498 proposal = self.data.contract_upgrade_proposals[params.proposal_id] 499 assert not proposal.executed, "PROPOSAL_ALREADY_EXECUTED" 500 assert sp.now <= proposal.voting_deadline, "VOTING_CLOSED" 501 assert sp.len(params.token_ids) > 0, "EMPTY_TOKEN_LIST" 502 503 for token_id in params.token_ids: 504 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 505 owner = self.data.ledger.get(token_id, default=BURN_ADDRESS) 506 assert owner == sp.sender, "NOT_TOKEN_OWNER" 507 508 vote_key = sp.record(proposal_id=params.proposal_id, token_id=token_id) 509 sp.cast(vote_key, t_contract_upgrade_vote_key) 510 assert not self.data.contract_upgrade_votes.contains(vote_key), "TOKEN_ALREADY_VOTED" 511 self.data.contract_upgrade_votes[vote_key] = params.support 512 513 if params.support: 514 proposal.yes_votes += 1 515 else: 516 proposal.no_votes += 1 517 self.data.contract_upgrade_proposals[params.proposal_id] = proposal 518 519 @sp.entrypoint 520 def execute_contract_upgrade(self, proposal_id): 521 """ 522 Execute a passed proposal after voting closes. 523 Trustless pass conditions: 524 - quorum of active tokens participated 525 - yes ratio meets approval threshold 526 """ 527 sp.cast(proposal_id, sp.nat) 528 assert self.data.contract_upgrade_proposals.contains(proposal_id), "PROPOSAL_NOT_FOUND" 529 530 proposal = self.data.contract_upgrade_proposals[proposal_id] 531 assert not proposal.executed, "PROPOSAL_ALREADY_EXECUTED" 532 assert sp.now > proposal.voting_deadline, "VOTING_STILL_OPEN" 533 assert self.data.active_token_count > 0, "NO_ACTIVE_TOKENS" 534 535 cast_votes = proposal.yes_votes + proposal.no_votes 536 assert cast_votes > 0, "NO_VOTES_CAST" 537 assert ( 538 cast_votes * 10000 539 >= self.data.active_token_count * self.data.governance_quorum_bps 540 ), "QUORUM_NOT_MET" 541 assert ( 542 proposal.yes_votes * 10000 543 >= cast_votes * self.data.governance_approval_bps 544 ), "APPROVAL_NOT_MET" 545 546 for item in proposal.metadata_updates: 547 self.data.metadata[item.key] = item.value 548 549 if proposal.deprecate: 550 assert self.data.contract_state == CONTRACT_STATE_ACTIVE, "ALREADY_DEPRECATED" 551 assert proposal.successor.is_some(), "SUCCESSOR_REQUIRED" 552 self.data.contract_state = CONTRACT_STATE_DEPRECATED 553 self.data.deprecated_successor = proposal.successor 554 self.data.deprecated_at = sp.Some(sp.now) 555 556 proposal.executed = True 557 self.data.contract_upgrade_proposals[proposal_id] = proposal 558 559 @sp.offchain_view() 560 def contract_upgrade_status(self): 561 """Expose governance/deprecation status for UIs and tooling.""" 562 return sp.record( 563 contract_state=self.data.contract_state, 564 deprecated_successor=self.data.deprecated_successor, 565 deprecated_at=self.data.deprecated_at, 566 active_token_count=self.data.active_token_count, 567 migration_source_contract=self.data.migration_source_contract, 568 governance_voting_period_seconds=self.data.governance_voting_period_seconds, 569 governance_quorum_bps=self.data.governance_quorum_bps, 570 governance_approval_bps=self.data.governance_approval_bps, 571 next_proposal_id=self.data.next_contract_upgrade_proposal_id, 572 ) 573 574 @sp.entrypoint 575 def set_refresh_policy(self, params): 576 """ 577 Owner-controlled metadata refresh policy. 578 """ 579 sp.cast(params, sp.record(token_id=sp.nat, policy=sp.nat)) 580 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 581 is_owner = self.data.ledger.get( 582 params.token_id, 583 default=BURN_ADDRESS, 584 ) == sp.sender 585 assert is_owner, "NOT_TOKEN_OWNER" 586 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 587 assert not is_locked, "METADATA_LOCKED" 588 assert params.policy <= POLICY_OWNER_AND_CREATOR, "INVALID_REFRESH_POLICY" 589 self.data.refresh_policies[params.token_id] = params.policy 590 591 @sp.entrypoint 592 def edit_metadata(self, params): 593 """ 594 Update metadata with per-token refresh policy authorization. 595 596 Policy modes: 597 - owner_only: owner full edit. 598 - creator_only: creator refresh-only fields. 599 - owner_or_creator: owner full edit OR creator refresh-only. 600 - owner_and_creator: owner full edit + creator signature consent 601 (unless owner == creator). 602 603 In all cases, content_hash and royalties remain immutable. 604 """ 605 sp.cast( 606 params, 607 sp.record( 608 token_id=sp.nat, 609 token_info=sp.map[sp.string, sp.bytes], 610 creator_sig=sp.option[sp.signature], 611 creator_sig_deadline=sp.option[sp.timestamp], 612 ), 613 ) 614 615 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 616 is_locked = self.data.metadata_locked.get(params.token_id, default=False) 617 assert not is_locked, "METADATA_LOCKED" 618 619 owner = self.data.ledger.get( 620 params.token_id, 621 default=BURN_ADDRESS, 622 ) 623 creator = self.data.token_creators.get( 624 params.token_id, 625 default=BURN_ADDRESS, 626 ) 627 is_owner = owner == sp.sender 628 is_creator = creator == sp.sender 629 630 existing_info = self.data.token_metadata[params.token_id].token_info 631 original_hash = existing_info.get("content_hash", default=sp.bytes("0x")) 632 original_royalties = existing_info.get("royalties", default=sp.bytes("0x")) 633 634 policy = self.data.refresh_policies.get( 635 params.token_id, default=POLICY_OWNER_OR_CREATOR 636 ) 637 assert policy <= POLICY_OWNER_AND_CREATOR, "INVALID_REFRESH_POLICY" 638 639 if policy == POLICY_OWNER_ONLY: 640 assert is_owner, "OWNER_REQUIRED" 641 self.data.token_metadata[params.token_id] = sp.record( 642 token_id=params.token_id, 643 token_info=params.token_info, 644 ) 645 else: 646 if policy == POLICY_CREATOR_ONLY: 647 assert is_creator, "CREATOR_REQUIRED" 648 refreshed_info = self.data.token_metadata[params.token_id].token_info 649 mutable_refresh_fields = [ 650 "", 651 "metadata_uri", 652 "artifactUri", 653 "displayUri", 654 "thumbnailUri", 655 "formats", 656 "tags", 657 "attributes", 658 "rights", 659 "content_type", 660 "isBooleanAmount", 661 "shouldPreferSymbol", 662 ] 663 for field in mutable_refresh_fields: 664 if params.token_info.contains(field): 665 refreshed_info[field] = params.token_info[field] 666 if params.token_info.contains("metadata_uri"): 667 refreshed_info[""] = params.token_info["metadata_uri"] 668 if params.token_info.contains(""): 669 refreshed_info["metadata_uri"] = params.token_info[""] 670 self.data.token_metadata[params.token_id] = sp.record( 671 token_id=params.token_id, 672 token_info=refreshed_info, 673 ) 674 else: 675 if policy == POLICY_OWNER_OR_CREATOR: 676 if is_owner: 677 self.data.token_metadata[params.token_id] = sp.record( 678 token_id=params.token_id, 679 token_info=params.token_info, 680 ) 681 else: 682 assert is_creator, "NOT_AUTHORIZED" 683 refreshed_info = self.data.token_metadata[params.token_id].token_info 684 mutable_refresh_fields = [ 685 "", 686 "metadata_uri", 687 "artifactUri", 688 "displayUri", 689 "thumbnailUri", 690 "formats", 691 "tags", 692 "attributes", 693 "rights", 694 "content_type", 695 "isBooleanAmount", 696 "shouldPreferSymbol", 697 ] 698 for field in mutable_refresh_fields: 699 if params.token_info.contains(field): 700 refreshed_info[field] = params.token_info[field] 701 if params.token_info.contains("metadata_uri"): 702 refreshed_info[""] = params.token_info["metadata_uri"] 703 if params.token_info.contains(""): 704 refreshed_info["metadata_uri"] = params.token_info[""] 705 self.data.token_metadata[params.token_id] = sp.record( 706 token_id=params.token_id, 707 token_info=refreshed_info, 708 ) 709 else: 710 if policy == POLICY_OWNER_AND_CREATOR: 711 if is_owner and is_creator: 712 # Same wallet is both roles. 713 self.data.token_metadata[params.token_id] = sp.record( 714 token_id=params.token_id, 715 token_info=params.token_info, 716 ) 717 else: 718 # Owner submits edit + creator co-signs exact payload. 719 assert is_owner, "OWNER_REQUIRED" 720 assert self.data.token_creator_keys.contains(params.token_id), "MISSING_CREATOR_KEY" 721 assert params.creator_sig.is_some(), "CREATOR_SIG_REQUIRED" 722 assert params.creator_sig_deadline.is_some(), "CREATOR_SIG_DEADLINE_REQUIRED" 723 724 deadline = params.creator_sig_deadline.unwrap_some() 725 assert sp.now <= deadline, "CREATOR_SIG_EXPIRED" 726 727 nonce = self.data.refresh_nonces.get(params.token_id, default=0) 728 consent_payload = sp.record( 729 contract=sp.self_address, 730 token_id=params.token_id, 731 token_info_hash=sp.blake2b(sp.pack(params.token_info)), 732 nonce=nonce, 733 deadline=deadline, 734 ) 735 sp.cast( 736 consent_payload, 737 sp.record( 738 contract=sp.address, 739 token_id=sp.nat, 740 token_info_hash=sp.bytes, 741 nonce=sp.nat, 742 deadline=sp.timestamp, 743 ).layout(("contract", ("token_id", ("token_info_hash", ("nonce", "deadline"))))), 744 ) 745 creator_key = self.data.token_creator_keys[params.token_id] 746 assert sp.check_signature( 747 creator_key, 748 params.creator_sig.unwrap_some(), 749 sp.pack(consent_payload), 750 ), "INVALID_CREATOR_SIG" 751 752 self.data.refresh_nonces[params.token_id] = nonce + 1 753 self.data.token_metadata[params.token_id] = sp.record( 754 token_id=params.token_id, 755 token_info=params.token_info, 756 ) 757 else: 758 assert False, "INVALID_REFRESH_POLICY" 759 760 self.data.token_metadata[params.token_id].token_info["content_hash"] = original_hash 761 self.data.token_metadata[params.token_id].token_info["royalties"] = original_royalties 762 763 # Keep metadata URI aliases aligned post-update. 764 updated_info = self.data.token_metadata[params.token_id].token_info 765 if updated_info.contains("metadata_uri"): 766 updated_info[""] = updated_info["metadata_uri"] 767 if updated_info.contains(""): 768 updated_info["metadata_uri"] = updated_info[""] 769 self.data.token_metadata[params.token_id].token_info = updated_info 770 771 @sp.entrypoint 772 def lock_metadata(self, token_id): 773 """Owner-only irreversible metadata lock.""" 774 sp.cast(token_id, sp.nat) 775 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 776 is_owner = self.data.ledger.get( 777 token_id, default=BURN_ADDRESS 778 ) == sp.sender 779 assert is_owner, "NOT_TOKEN_OWNER" 780 self.data.metadata_locked[token_id] = True 781 782 @sp.entrypoint 783 def burn_keep(self, token_id): 784 """Owner-only burn; frees content_hash for re-keep.""" 785 sp.cast(token_id, sp.nat) 786 787 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 788 current_owner = self.data.ledger.get( 789 token_id, default=BURN_ADDRESS 790 ) 791 assert current_owner == sp.sender, "NOT_TOKEN_OWNER" 792 793 token_info = self.data.token_metadata[token_id].token_info 794 content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 795 796 if self.data.content_hashes.contains(content_hash): 797 del self.data.content_hashes[content_hash] 798 if self.data.ledger.contains(token_id): 799 del self.data.ledger[token_id] 800 if self.data.metadata_locked.contains(token_id): 801 del self.data.metadata_locked[token_id] 802 if self.data.token_creators.contains(token_id): 803 del self.data.token_creators[token_id] 804 if self.data.token_creator_keys.contains(token_id): 805 del self.data.token_creator_keys[token_id] 806 if self.data.refresh_policies.contains(token_id): 807 del self.data.refresh_policies[token_id] 808 if self.data.refresh_nonces.contains(token_id): 809 del self.data.refresh_nonces[token_id] 810 811 del self.data.token_metadata[token_id] 812 self.data.active_token_count = sp.as_nat(self.data.active_token_count - 1) 813 814 815@sp.add_test() 816def test(): 817 scenario = sp.test_scenario("KeepsFA2v12 draft") 818 scenario.h1("KidLisp Keeps FA2 v12 (draft)") 819 820 alice = sp.test_account("Alice") 821 bob = sp.test_account("Bob") 822 treasury = sp.test_account("Treasury") 823 824 token0_metadata = sp.cast( 825 { 826 "name": sp.bytes("0x2464656d6f"), # "$demo" 827 "symbol": sp.bytes("0x64656d6f"), # "demo" 828 "metadata_uri": sp.bytes("0x697066733a2f2f6b69646c6973702d7631312d6d657461"), 829 "": sp.bytes("0x697066733a2f2f6b69646c6973702d7631312d6d657461"), 830 "content_hash": sp.bytes("0x746573742d68617368"), 831 "royalties": sp.bytes("0x7b7d"), 832 }, 833 sp.map[sp.string, sp.bytes], 834 ) 835 836 contract = keeps_module.KidLispKeepsFA2v12( 837 treasury.address, 838 treasury.address, 839 sp.big_map(), 840 {0: alice.address}, 841 [token0_metadata], 842 sp.mutez(0), 843 0, 844 900, 845 100, 846 1, 847 2000, 848 6667, 849 ) 850 scenario += contract 851 852 metadata_updates = [ 853 sp.record( 854 key="", 855 value=sp.bytes("0x697066733a2f2f6b69646c6973702d7631322d636f6c6c656374696f6e2d6d657461"), 856 ), 857 sp.record( 858 key="content", 859 value=sp.bytes("0x697066733a2f2f6b69646c6973702d7631322d75706772616465"), 860 ), 861 ] 862 metadata_updates_hash = sp.blake2b(sp.pack(metadata_updates)) 863 864 contract.propose_contract_upgrade( 865 metadata_updates=metadata_updates, 866 metadata_updates_hash=metadata_updates_hash, 867 successor=sp.Some(bob.address), 868 deprecate=True, 869 _sender=bob, 870 _now=sp.timestamp(5), 871 ) 872 873 contract.vote_contract_upgrade( 874 proposal_id=0, 875 token_ids=[0], 876 support=True, 877 _sender=alice, 878 _now=sp.timestamp(5), 879 ) 880 881 contract.execute_contract_upgrade( 882 0, 883 _sender=bob, 884 _now=sp.timestamp(10), 885 ) 886 887 scenario.verify(contract.data.contract_state == keeps_module.CONTRACT_STATE_DEPRECATED) 888 scenario.verify(contract.data.deprecated_successor.unwrap_some() == bob.address) 889 scenario.verify( 890 contract.data.metadata[""] 891 == sp.bytes("0x697066733a2f2f6b69646c6973702d7631322d636f6c6c656374696f6e2d6d657461") 892 ) 893 894 contract.register_keep_commitment( 895 sp.bytes("0x00"), 896 _sender=alice, 897 _now=sp.timestamp(11), 898 _valid=False, 899 _exception="CONTRACT_DEPRECATED", 900 ) 901 902 scenario.p( 903 "v12 draft: no admin, commit-reveal keep, owner burn, refresh policies, holder-governed upgrades/deprecation" 904 )