Monorepo for Aesthetic.Computer
aesthetic.computer
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 )