Monorepo for Aesthetic.Computer
aesthetic.computer
1"""
2Keeps FA2 v2 - Aesthetic Computer NFT Contract
3
4This contract extends the SmartPy FA2 library with a custom `keep` entrypoint
5that accepts pre-encoded bytes for all TZIP-21 metadata fields.
6
7Key feature: Metadata bytes are stored directly WITHOUT sp.pack(),
8ensuring compatibility with TzKT, objkt, and other Tezos indexers.
9"""
10
11import smartpy as sp
12from smartpy.templates import fa2_lib as fa2
13
14main = fa2.main
15
16
17@sp.module
18def keeps_module():
19 import main
20
21 # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>].
22 class KeepsFA2(
23 main.Admin,
24 main.Nft,
25 main.MintNft,
26 main.BurnNft,
27 main.OnchainviewBalanceOf,
28 ):
29 """
30 FA2 NFT contract for aesthetic.computer Keeps.
31
32 Extends the standard FA2 library with a custom `keep` entrypoint
33 that accepts all TZIP-21 metadata fields as pre-encoded bytes.
34 """
35
36 def __init__(self, admin_address, contract_metadata, ledger, token_metadata):
37 # Initialize on-chain balance view
38 main.OnchainviewBalanceOf.__init__(self)
39
40 # Initialize the NFT-specific entrypoints
41 main.BurnNft.__init__(self)
42 main.MintNft.__init__(self)
43
44 # Initialize the NFT base class
45 main.Nft.__init__(self, contract_metadata, ledger, token_metadata)
46
47 # Initialize administrative permissions
48 main.Admin.__init__(self, admin_address)
49
50 # Additional storage for metadata locking
51 self.data.metadata_locked = sp.cast(
52 sp.big_map(),
53 sp.big_map[sp.nat, sp.bool]
54 )
55
56 # Track content hashes to prevent duplicate mints
57 # Maps content_hash (bytes) -> token_id (nat)
58 self.data.content_hashes = sp.cast(
59 sp.big_map(),
60 sp.big_map[sp.bytes, sp.nat]
61 )
62
63 # Contract-level metadata lock flag
64 self.data.contract_metadata_locked = False
65
66 # Mint fee configuration (admin-adjustable)
67 # Default: 0 tez (free minting via admin key)
68 # Can be set to require payment when users mint via their wallets
69 self.data.keep_fee = sp.tez(0)
70
71 @sp.entrypoint
72 def keep(self, params):
73 """
74 Mint a new Keep token with full TZIP-21 metadata.
75
76 Two modes:
77 1. Admin calling with owner param: mints to specified owner (for server-side minting)
78 2. User calling directly: mints to sender, requires fee payment
79 - This makes the USER the firstMinter for proper artist attribution
80
81 All bytes parameters should be raw hex-encoded UTF-8 strings
82 (NOT Michelson-packed with 0x05 prefix).
83 """
84 sp.cast(params, sp.record(
85 name=sp.bytes,
86 description=sp.bytes,
87 artifactUri=sp.bytes,
88 displayUri=sp.bytes,
89 thumbnailUri=sp.bytes,
90 decimals=sp.bytes,
91 symbol=sp.bytes,
92 isBooleanAmount=sp.bytes,
93 shouldPreferSymbol=sp.bytes,
94 formats=sp.bytes,
95 tags=sp.bytes,
96 attributes=sp.bytes,
97 creators=sp.bytes,
98 rights=sp.bytes,
99 content_type=sp.bytes,
100 content_hash=sp.bytes,
101 metadata_uri=sp.bytes,
102 owner=sp.address
103 ))
104
105 # Determine minting mode and owner
106 is_admin = self.is_administrator_()
107
108 # Non-admin callers must pay the fee and can only mint to themselves
109 if not is_admin:
110 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE"
111 # User must mint to themselves (ensures they are firstMinter)
112 assert params.owner == sp.sender, "MUST_MINT_TO_SELF"
113
114 # Check for duplicate content hash
115 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH"
116
117 # Get next token ID from the library's counter
118 token_id = self.data.next_token_id
119
120 # Build token_info map with all TZIP-21 fields
121 # Values are stored as raw bytes (no packing)
122 token_info = sp.cast({
123 "name": params.name,
124 "description": params.description,
125 "artifactUri": params.artifactUri,
126 "displayUri": params.displayUri,
127 "thumbnailUri": params.thumbnailUri,
128 "decimals": params.decimals,
129 "symbol": params.symbol,
130 "isBooleanAmount": params.isBooleanAmount,
131 "shouldPreferSymbol": params.shouldPreferSymbol,
132 "formats": params.formats,
133 "tags": params.tags,
134 "attributes": params.attributes,
135 "creators": params.creators,
136 "rights": params.rights,
137 "content_type": params.content_type,
138 "content_hash": params.content_hash,
139 "": params.metadata_uri # Empty key for off-chain metadata URI
140 }, sp.map[sp.string, sp.bytes])
141
142 # Store token metadata using the library's mechanism
143 self.data.token_metadata[token_id] = sp.record(
144 token_id=token_id,
145 token_info=token_info
146 )
147
148 # Assign token to owner using the library's ledger format (NFT: token_id -> owner)
149 self.data.ledger[token_id] = params.owner
150
151 # Initialize as not locked
152 self.data.metadata_locked[token_id] = False
153
154 # Store content hash to prevent future duplicates
155 self.data.content_hashes[params.content_hash] = token_id
156
157 # Increment token counter
158 self.data.next_token_id = token_id + 1
159
160 @sp.entrypoint
161 def edit_metadata(self, params):
162 """Update metadata for an existing token (admin only, if not locked)."""
163 sp.cast(params, sp.record(
164 token_id=sp.nat,
165 token_info=sp.map[sp.string, sp.bytes]
166 ))
167
168 assert self.is_administrator_(), "FA2_NOT_ADMIN"
169 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED"
170
171 # Check if locked
172 is_locked = self.data.metadata_locked.get(params.token_id, default=False)
173 assert not is_locked, "METADATA_LOCKED"
174
175 # Update metadata
176 self.data.token_metadata[params.token_id] = sp.record(
177 token_id=params.token_id,
178 token_info=params.token_info
179 )
180
181 @sp.entrypoint
182 def lock_metadata(self, token_id):
183 """Permanently lock metadata for a token (admin only)."""
184 sp.cast(token_id, sp.nat)
185
186 assert self.is_administrator_(), "FA2_NOT_ADMIN"
187 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED"
188
189 self.data.metadata_locked[token_id] = True
190
191 @sp.entrypoint
192 def set_contract_metadata(self, params):
193 """Update contract-level metadata (admin only, if not locked).
194 Use this to set collection icon, banner, description etc.
195 params: list of (key, value) pairs to set in the metadata big_map
196 """
197 sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)])
198
199 assert self.is_administrator_(), "FA2_NOT_ADMIN"
200 assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED"
201
202 for item in params:
203 self.data.metadata[item.key] = item.value
204
205 @sp.entrypoint
206 def lock_contract_metadata(self):
207 """Permanently lock contract-level metadata (admin only)."""
208 assert self.is_administrator_(), "FA2_NOT_ADMIN"
209 self.data.contract_metadata_locked = True
210
211 @sp.entrypoint
212 def set_keep_fee(self, new_fee):
213 """
214 Set the keep fee required for keeping new tokens.
215 Admin only. Fee is in mutez (1 tez = 1,000,000 mutez).
216 Set to 0 for free keeping.
217 """
218 sp.cast(new_fee, sp.mutez)
219 assert self.is_administrator_(), "FA2_NOT_ADMIN"
220 self.data.keep_fee = new_fee
221
222 @sp.entrypoint
223 def withdraw_fees(self, destination):
224 """
225 Withdraw accumulated fees from the contract.
226 Admin only. Sends the entire contract balance to the destination.
227 """
228 sp.cast(destination, sp.address)
229 assert self.is_administrator_(), "FA2_NOT_ADMIN"
230 sp.send(destination, sp.balance)
231
232 @sp.entrypoint
233 def burn_keep(self, token_id):
234 """
235 Burn a token and remove its content_hash from the registry.
236 This allows the same piece name to be minted again.
237 Admin only.
238 """
239 sp.cast(token_id, sp.nat)
240
241 assert self.is_administrator_(), "FA2_NOT_ADMIN"
242 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED"
243
244 # Get the content_hash before burning so we can remove it
245 token_info = self.data.token_metadata[token_id].token_info
246 content_hash = token_info.get("content_hash", default=sp.bytes("0x"))
247
248 # Remove from content_hashes registry (allows re-minting this piece)
249 if self.data.content_hashes.contains(content_hash):
250 del self.data.content_hashes[content_hash]
251
252 # Remove from ledger
253 if self.data.ledger.contains(token_id):
254 del self.data.ledger[token_id]
255
256 # Remove token metadata
257 del self.data.token_metadata[token_id]
258
259 # Remove metadata lock entry
260 if self.data.metadata_locked.contains(token_id):
261 del self.data.metadata_locked[token_id]
262
263
264def _get_balance(fa2_contract, args):
265 """Utility function to call the contract's get_balance view."""
266 return sp.View(fa2_contract, "get_balance")(args)
267
268
269def _total_supply(fa2_contract, args):
270 """Utility function to call the contract's total_supply view."""
271 return sp.View(fa2_contract, "total_supply")(args)
272
273
274@sp.add_test()
275def test():
276 scenario = sp.test_scenario("KeepsFA2v2")
277 scenario.h1("Keeps FA2 v2 Tests")
278
279 # Define test accounts
280 admin = sp.test_account("Admin")
281 alice = sp.test_account("Alice")
282 bob = sp.test_account("Bob")
283
284 # Create empty initial state
285 ledger = {}
286 token_metadata = []
287
288 # Deploy contract
289 contract = keeps_module.KeepsFA2(
290 admin.address,
291 sp.big_map(), # contract_metadata - will be set below
292 ledger,
293 token_metadata
294 )
295
296 # Build contract metadata
297 contract_metadata = sp.create_tzip16_metadata(
298 name="Aesthetic.computer Keeps",
299 description="FA2 NFT contract for aesthetic.computer keeps",
300 version="2.0.0",
301 license_name="MIT",
302 interfaces=["TZIP-012", "TZIP-016", "TZIP-021"],
303 authors=["aesthetic.computer"],
304 homepage="https://aesthetic.computer",
305 source_uri=None,
306 offchain_views=contract.get_offchain_views(),
307 )
308
309 contract_metadata["permissions"] = {
310 "operator": "owner-or-operator-transfer",
311 "receiver": "owner-no-hook",
312 "sender": "owner-no-hook",
313 }
314
315 # Placeholder metadata URI
316 metadata_uri = "ipfs://example"
317 contract.data.metadata = sp.scenario_utils.metadata_of_url(metadata_uri)
318
319 scenario += contract
320
321 if scenario.simulation_mode() is sp.SimulationMode.MOCKUP:
322 scenario.p("mockups - skipping transfer tests")
323 return
324
325 scenario.h2("Mint token using keep entrypoint")
326
327 # Helper to convert string to bytes (same as sp.scenario_utils.bytes_of_string)
328 def str_to_bytes(s):
329 return sp.scenario_utils.bytes_of_string(s)
330
331 # Mint a token
332 contract.keep(
333 name=str_to_bytes("Test Token"),
334 description=str_to_bytes("A test token for aesthetic.computer"),
335 artifactUri=str_to_bytes("ipfs://QmXYZ"),
336 displayUri=str_to_bytes("ipfs://QmXYZ"),
337 thumbnailUri=str_to_bytes("ipfs://QmXYZ"),
338 decimals=str_to_bytes("0"),
339 symbol=str_to_bytes("KEEP"),
340 isBooleanAmount=str_to_bytes("true"),
341 shouldPreferSymbol=str_to_bytes("false"),
342 formats=str_to_bytes("[]"),
343 tags=str_to_bytes("[]"),
344 attributes=str_to_bytes("[]"),
345 creators=str_to_bytes('["aesthetic.computer"]'),
346 rights=str_to_bytes("© All rights reserved."),
347 content_type=str_to_bytes("kidlisp"),
348 content_hash=str_to_bytes("QmXYZ"),
349 metadata_uri=str_to_bytes("https://example.com/metadata.json"),
350 owner=alice.address,
351 _sender=admin,
352 )
353
354 # Verify token exists
355 scenario.verify(contract.data.next_token_id == 1)
356 scenario.verify(_get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 1)
357 scenario.verify(_total_supply(contract, sp.record(token_id=0)) == 1)
358
359 scenario.h2("Transfer token")
360 contract.transfer(
361 [
362 sp.record(
363 from_=alice.address,
364 txs=[sp.record(to_=bob.address, amount=1, token_id=0)],
365 ),
366 ],
367 _sender=alice,
368 )
369
370 scenario.verify(_get_balance(contract, sp.record(owner=alice.address, token_id=0)) == 0)
371 scenario.verify(_get_balance(contract, sp.record(owner=bob.address, token_id=0)) == 1)
372
373 scenario.h2("Mint second token")
374 contract.keep(
375 name=str_to_bytes("Token Two"),
376 description=str_to_bytes("Second test token"),
377 artifactUri=str_to_bytes("ipfs://QmABC"),
378 displayUri=str_to_bytes("ipfs://QmABC"),
379 thumbnailUri=str_to_bytes("ipfs://QmABC"),
380 decimals=str_to_bytes("0"),
381 symbol=str_to_bytes("KEEP"),
382 isBooleanAmount=str_to_bytes("true"),
383 shouldPreferSymbol=str_to_bytes("false"),
384 formats=str_to_bytes("[]"),
385 tags=str_to_bytes("[]"),
386 attributes=str_to_bytes("[]"),
387 creators=str_to_bytes('["aesthetic.computer"]'),
388 rights=str_to_bytes("©"),
389 content_type=str_to_bytes("kidlisp"),
390 content_hash=str_to_bytes("QmABC"),
391 metadata_uri=str_to_bytes("https://example.com/metadata2.json"),
392 owner=admin.address,
393 _sender=admin,
394 )
395
396 scenario.verify(contract.data.next_token_id == 2)
397
398 scenario.h2("Lock metadata")
399 contract.lock_metadata(1, _sender=admin)
400 scenario.verify(contract.data.metadata_locked[1] == True)
401
402 scenario.h2("Try to edit locked metadata - should fail")
403 contract.edit_metadata(
404 token_id=1,
405 token_info=sp.cast({
406 "name": str_to_bytes("New Name")
407 }, sp.map[sp.string, sp.bytes]),
408 _sender=admin,
409 _valid=False,
410 )
411
412 scenario.h2("Non-admin cannot mint to someone else")
413 contract.keep(
414 name=str_to_bytes("Unauthorized"),
415 description=str_to_bytes("Should fail"),
416 artifactUri=str_to_bytes("ipfs://Qm"),
417 displayUri=str_to_bytes("ipfs://Qm"),
418 thumbnailUri=str_to_bytes("ipfs://Qm"),
419 decimals=str_to_bytes("0"),
420 symbol=str_to_bytes("KEEP"),
421 isBooleanAmount=str_to_bytes("true"),
422 shouldPreferSymbol=str_to_bytes("false"),
423 formats=str_to_bytes("[]"),
424 tags=str_to_bytes("[]"),
425 attributes=str_to_bytes("[]"),
426 creators=str_to_bytes("[]"),
427 rights=str_to_bytes(""),
428 content_type=str_to_bytes("text"),
429 content_hash=str_to_bytes("other_piece"),
430 metadata_uri=str_to_bytes(""),
431 owner=admin.address, # Bob tries to mint to admin (not himself)
432 _sender=bob, # Bob is not admin
433 _valid=False, # Should fail: MUST_MINT_TO_SELF
434 )
435
436 scenario.h2("Non-admin CAN mint to self (user-callable keep)")
437 contract.keep(
438 name=str_to_bytes("User Mint"),
439 description=str_to_bytes("Bob mints to himself"),
440 artifactUri=str_to_bytes("ipfs://QmUser"),
441 displayUri=str_to_bytes("ipfs://QmUser"),
442 thumbnailUri=str_to_bytes("ipfs://QmUserThumb"),
443 decimals=str_to_bytes("0"),
444 symbol=str_to_bytes("KEEP"),
445 isBooleanAmount=str_to_bytes("true"),
446 shouldPreferSymbol=str_to_bytes("false"),
447 formats=str_to_bytes("[]"),
448 tags=str_to_bytes('["user-minted"]'),
449 attributes=str_to_bytes("[]"),
450 creators=str_to_bytes('["@bob"]'),
451 rights=str_to_bytes(""),
452 content_type=str_to_bytes("KidLisp"),
453 content_hash=str_to_bytes("bob_piece"),
454 metadata_uri=str_to_bytes("ipfs://QmBobMetadata"),
455 owner=bob.address, # Bob mints to himself
456 _sender=bob, # Bob is the sender
457 _valid=True, # Should succeed - user mints to self
458 )
459
460 # Verify Bob owns the new token
461 scenario.verify(contract.data.ledger[2] == bob.address)