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