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