Monorepo for Aesthetic.Computer
aesthetic.computer
1"""
2Keeps FA2 v3 - 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
7v3 Changes from v2:
8- edit_metadata: Now allows token OWNER to edit (not just admin)
9 This preserves artist attribution on objkt.com when updating metadata.
10- Added token_creators bigmap to track original creator for each token
11
12Key feature: Metadata bytes are stored directly WITHOUT sp.pack(),
13ensuring compatibility with TzKT, objkt, and other Tezos indexers.
14"""
15
16import smartpy as sp
17from smartpy.templates import fa2_lib as fa2
18
19main = fa2.main
20
21
22@sp.module
23def keeps_module():
24 import main
25
26 # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>].
27 class KeepsFA2(
28 main.Admin,
29 main.Nft,
30 main.MintNft,
31 main.BurnNft,
32 main.OnchainviewBalanceOf,
33 ):
34 """
35 FA2 NFT contract for aesthetic.computer Keeps (v3).
36
37 Extends the standard FA2 library with a custom `keep` entrypoint
38 that accepts all TZIP-21 metadata fields as pre-encoded bytes.
39
40 v3: Token owners can edit their own token metadata.
41 """
42
43 def __init__(self, admin_address, contract_metadata, ledger, token_metadata):
44 # Initialize on-chain balance view
45 main.OnchainviewBalanceOf.__init__(self)
46
47 # Initialize the NFT-specific entrypoints
48 main.BurnNft.__init__(self)
49 main.MintNft.__init__(self)
50
51 # Initialize the NFT base class
52 main.Nft.__init__(self, contract_metadata, ledger, token_metadata)
53
54 # Initialize administrative permissions
55 main.Admin.__init__(self, admin_address)
56
57 # Additional storage for metadata locking
58 self.data.metadata_locked = sp.cast(
59 sp.big_map(),
60 sp.big_map[sp.nat, sp.bool]
61 )
62
63 # Track content hashes to prevent duplicate mints
64 # Maps content_hash (bytes) -> token_id (nat)
65 self.data.content_hashes = sp.cast(
66 sp.big_map(),
67 sp.big_map[sp.bytes, sp.nat]
68 )
69
70 # Track original creator for each token (v3)
71 # Maps token_id -> creator address (the first minter)
72 self.data.token_creators = sp.cast(
73 sp.big_map(),
74 sp.big_map[sp.nat, sp.address]
75 )
76
77 # Contract-level metadata lock flag
78 self.data.contract_metadata_locked = False
79
80 # Mint fee configuration (admin-adjustable)
81 # Default: 0 tez (free minting via admin key)
82 # Can be set to require payment when users mint via their wallets
83 self.data.keep_fee = sp.tez(0)
84
85 @sp.entrypoint
86 def keep(self, params):
87 """
88 Mint a new Keep token with full TZIP-21 metadata.
89
90 Two modes:
91 1. Admin calling with owner param: mints to specified owner (for server-side minting)
92 2. User calling directly: mints to sender, requires fee payment
93 - This makes the USER the firstMinter for proper artist attribution
94
95 All bytes parameters should be raw hex-encoded UTF-8 strings
96 (NOT Michelson-packed with 0x05 prefix).
97 """
98 sp.cast(params, sp.record(
99 name=sp.bytes,
100 description=sp.bytes,
101 artifactUri=sp.bytes,
102 displayUri=sp.bytes,
103 thumbnailUri=sp.bytes,
104 decimals=sp.bytes,
105 symbol=sp.bytes,
106 isBooleanAmount=sp.bytes,
107 shouldPreferSymbol=sp.bytes,
108 formats=sp.bytes,
109 tags=sp.bytes,
110 attributes=sp.bytes,
111 creators=sp.bytes,
112 rights=sp.bytes,
113 content_type=sp.bytes,
114 content_hash=sp.bytes,
115 metadata_uri=sp.bytes,
116 owner=sp.address
117 ))
118
119 # Determine minting mode and owner
120 is_admin = self.is_administrator_()
121
122 # Non-admin callers must pay the fee and can only mint to themselves
123 if not is_admin:
124 assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE"
125 # User must mint to themselves (ensures they are firstMinter)
126 assert params.owner == sp.sender, "MUST_MINT_TO_SELF"
127
128 # Check for duplicate content hash
129 assert not self.data.content_hashes.contains(params.content_hash), "DUPLICATE_CONTENT_HASH"
130
131 # Get next token ID from the library's counter
132 token_id = self.data.next_token_id
133
134 # Build token_info map with all TZIP-21 fields
135 # Values are stored as raw bytes (no packing)
136 token_info = sp.cast({
137 "name": params.name,
138 "description": params.description,
139 "artifactUri": params.artifactUri,
140 "displayUri": params.displayUri,
141 "thumbnailUri": params.thumbnailUri,
142 "decimals": params.decimals,
143 "symbol": params.symbol,
144 "isBooleanAmount": params.isBooleanAmount,
145 "shouldPreferSymbol": params.shouldPreferSymbol,
146 "formats": params.formats,
147 "tags": params.tags,
148 "attributes": params.attributes,
149 "creators": params.creators,
150 "rights": params.rights,
151 "content_type": params.content_type,
152 "content_hash": params.content_hash,
153 "": params.metadata_uri # Empty key for off-chain metadata URI
154 }, sp.map[sp.string, sp.bytes])
155
156 # Store token metadata using the library's mechanism
157 self.data.token_metadata[token_id] = sp.record(
158 token_id=token_id,
159 token_info=token_info
160 )
161
162 # Assign token to owner using the library's ledger format (NFT: token_id -> owner)
163 self.data.ledger[token_id] = params.owner
164
165 # Initialize as not locked
166 self.data.metadata_locked[token_id] = False
167
168 # Store content hash to prevent future duplicates
169 self.data.content_hashes[params.content_hash] = token_id
170
171 # Track the original creator (v3)
172 # Use sender for user mints, owner param for admin mints
173 self.data.token_creators[token_id] = params.owner
174
175 # Increment token counter
176 self.data.next_token_id = token_id + 1
177
178 @sp.entrypoint
179 def edit_metadata(self, params):
180 """
181 Update metadata for an existing token.
182
183 v3: Can be called by:
184 - Admin (contract administrator)
185 - Token owner (current holder of the token)
186 - Original creator (the address that first minted the token)
187
188 This allows artists to update their work without admin intervention,
189 preserving proper attribution on marketplaces like objkt.com.
190 """
191 sp.cast(params, sp.record(
192 token_id=sp.nat,
193 token_info=sp.map[sp.string, sp.bytes]
194 ))
195
196 assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED"
197
198 # Check authorization: admin, owner, or original creator
199 is_admin = self.is_administrator_()
200 is_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender
201 is_creator = self.data.token_creators.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender
202
203 assert is_admin or is_owner or is_creator, "NOT_AUTHORIZED"
204
205 # Check if locked
206 is_locked = self.data.metadata_locked.get(params.token_id, default=False)
207 assert not is_locked, "METADATA_LOCKED"
208
209 # Update metadata
210 self.data.token_metadata[params.token_id] = sp.record(
211 token_id=params.token_id,
212 token_info=params.token_info
213 )
214
215 @sp.entrypoint
216 def lock_metadata(self, token_id):
217 """Permanently lock metadata for a token (admin or owner only)."""
218 sp.cast(token_id, sp.nat)
219
220 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED"
221
222 # Check authorization: admin or owner
223 is_admin = self.is_administrator_()
224 is_owner = self.data.ledger.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender
225
226 assert is_admin or is_owner, "NOT_AUTHORIZED"
227
228 self.data.metadata_locked[token_id] = True
229
230 @sp.entrypoint
231 def set_contract_metadata(self, params):
232 """Update contract-level metadata (admin only, if not locked).
233 Use this to set collection icon, banner, description etc.
234 params: list of (key, value) pairs to set in the metadata big_map
235 """
236 sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)])
237
238 assert self.is_administrator_(), "FA2_NOT_ADMIN"
239 assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED"
240
241 for item in params:
242 self.data.metadata[item.key] = item.value
243
244 @sp.entrypoint
245 def lock_contract_metadata(self):
246 """Permanently lock contract-level metadata (admin only)."""
247 assert self.is_administrator_(), "FA2_NOT_ADMIN"
248 self.data.contract_metadata_locked = True
249
250 @sp.entrypoint
251 def set_keep_fee(self, new_fee):
252 """
253 Set the keep fee required for keeping new tokens.
254 Admin only. Fee is in mutez (1 tez = 1,000,000 mutez).
255 Set to 0 for free keeping.
256 """
257 sp.cast(new_fee, sp.mutez)
258 assert self.is_administrator_(), "FA2_NOT_ADMIN"
259 self.data.keep_fee = new_fee
260
261 @sp.entrypoint
262 def withdraw_fees(self, destination):
263 """
264 Withdraw accumulated fees from the contract.
265 Admin only. Sends the entire contract balance to the destination.
266 """
267 sp.cast(destination, sp.address)
268 assert self.is_administrator_(), "FA2_NOT_ADMIN"
269 sp.send(destination, sp.balance)
270
271 @sp.entrypoint
272 def burn_keep(self, token_id):
273 """
274 Burn a token and remove its content_hash from the registry.
275 This allows the same piece name to be minted again.
276 Admin only.
277 """
278 sp.cast(token_id, sp.nat)
279
280 assert self.is_administrator_(), "FA2_NOT_ADMIN"
281 assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED"
282
283 # Get the content_hash before burning so we can remove it
284 token_info = self.data.token_metadata[token_id].token_info
285 content_hash = token_info.get("content_hash", default=sp.bytes("0x"))
286
287 # Remove from content_hashes registry (allows re-minting this piece)
288 if self.data.content_hashes.contains(content_hash):
289 del self.data.content_hashes[content_hash]
290
291 # Remove from ledger
292 if self.data.ledger.contains(token_id):
293 del self.data.ledger[token_id]
294
295 # Remove token metadata
296 del self.data.token_metadata[token_id]
297
298 # Remove metadata lock entry
299 if self.data.metadata_locked.contains(token_id):
300 del self.data.metadata_locked[token_id]
301
302 # Remove creator entry (v3)
303 if self.data.token_creators.contains(token_id):
304 del self.data.token_creators[token_id]
305
306 @sp.onchain_view()
307 def get_token_creator(self, token_id):
308 """Get the original creator of a token."""
309 sp.cast(token_id, sp.nat)
310 return self.data.token_creators.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX"))
311
312
313def _get_balance(fa2_contract, args):
314 """Utility function to call the contract's get_balance view."""
315 return sp.View(fa2_contract, "get_balance")(args)
316
317
318def _total_supply(fa2_contract, args):
319 """Utility function to call the contract's total_supply view."""
320 return sp.View(fa2_contract, "total_supply")(args)
321
322
323@sp.add_test()
324def test():
325 """Simple test to compile the contract."""
326 scenario = sp.test_scenario("KeepsFA2v3")
327 scenario.h1("Keeps FA2 v3 - Compile Test")
328
329 # Define test accounts
330 admin = sp.test_account("Admin")
331
332 # Create empty initial state
333 ledger = {}
334 token_metadata = []
335
336 # Deploy contract
337 contract = keeps_module.KeepsFA2(
338 admin.address,
339 sp.big_map(),
340 ledger,
341 token_metadata
342 )
343
344 scenario += contract
345
346 # Just verify it compiles - actual tests done manually
347 scenario.h2("Contract compiled successfully")
348 scenario.p("v3 features: token owner/creator can edit metadata")
349