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