Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(keeps): add v6 contract + deploy script, clean up token metadata

- Create keeps_fa2_v6.py: production contract for keeps.tez
Contract name: "KidLisp", description: "https://keeps.kidlisp.com",
homepage: "https://kidlisp.com"
- Create deploy-v6.mjs: production deploy script with --dry-run support
- Rename token attributes: "Author Handle" -> "Handle",
"Author Code" -> "User", "User Code" -> "User", "Packed" -> "Packed on"
- Remove "Analyzer Version" attribute from all mint/update paths
- Remove unused ANALYZER_VERSION import from keep-update.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+663 -10
+1 -1
SCORE.md
··· 10 10 11 11 <!-- stats:start --> 12 12 351 built-in pieces (333 JS + 18 KidLisp), ~78 API endpoints.<br> 13 - 2798 registered handles, 265 user-published pieces, 4392 paintings, 16174 KidLisp programs, 18016 chat messages, 20 prints ordered.<br> 13 + 2799 registered handles, 265 user-published pieces, 4392 paintings, 16174 KidLisp programs, 18016 chat messages, 20 prints ordered.<br> 14 14 *Last refreshed: Mar 2, 2026* 15 15 <!-- stats:end --> 16 16
+2 -3
system/netlify/functions/keep-mint.mjs
··· 931 931 const attributes = [ 932 932 ...analysis.traits, 933 933 ...(packDate ? [{ name: "Packed on", value: packDate }] : []), 934 - ...(authorHandle && authorHandle !== "@anon" ? [{ name: "Author Handle", value: `@${authorHandle.replace(/^@/, "")}` }] : []), 935 - ...(userCode ? [{ name: "Author Code", value: userCode }] : []), 934 + ...(authorHandle && authorHandle !== "@anon" ? [{ name: "Handle", value: `@${authorHandle.replace(/^@/, "")}` }] : []), 935 + ...(userCode ? [{ name: "User", value: userCode }] : []), 936 936 ...(depCount > 0 ? [{ name: "Dependencies", value: String(depCount) }] : []), 937 - { name: "Analyzer Version", value: ANALYZER_VERSION }, 938 937 ]; 939 938 940 939 // Creator identity for metadata
+1 -2
system/netlify/functions/keep-update.mjs
··· 8 8 9 9 import { authorize, handleFor, hasAdmin } from "../../backend/authorization.mjs"; 10 10 import { connect } from "../../backend/database.mjs"; 11 - import { analyzeKidLisp, ANALYZER_VERSION } from "../../backend/kidlisp-analyzer.mjs"; 11 + import { analyzeKidLisp } from "../../backend/kidlisp-analyzer.mjs"; 12 12 import { stream } from "@netlify/functions"; 13 13 import { TezosToolkit, MichelsonMap } from "@taquito/taquito"; 14 14 import { InMemorySigner } from "@taquito/signer"; ··· 280 280 ...analysis.traits, 281 281 { name: "Updated", value: new Date().toISOString().split('T')[0] }, 282 282 ...(authorHandle && authorHandle !== "@anon" ? [{ name: "Handle", value: authorHandle }] : []), 283 - { name: "Analyzer Version", value: ANALYZER_VERSION }, 284 283 ]; 285 284 286 285 await send("progress", { stage: "metadata", message: "✓ Metadata ready" });
+224
tezos/deploy-v6.mjs
··· 1 + #!/usr/bin/env node 2 + /** 3 + * deploy-v6.mjs - Deploy production KidLisp Keeps v6 contract 4 + * 5 + * Deploys to mainnet using the kidlisp wallet (keeps.tez). 6 + * 7 + * Contract metadata: 8 + * name: "KidLisp" 9 + * version: "6.0.0" 10 + * description: "https://keeps.kidlisp.com" 11 + * homepage: "https://kidlisp.com" 12 + * 13 + * Usage: 14 + * # Set env vars from tezos/kidlisp/.env first: 15 + * export TEZOS_KIDLISP_KEY=edsk... 16 + * export TEZOS_KIDLISP_ADDRESS=tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC 17 + * 18 + * node deploy-v6.mjs # Deploy with kidlisp wallet 19 + * node deploy-v6.mjs --wallet=staging # Deploy with staging wallet (for testing) 20 + * node deploy-v6.mjs --dry-run # Show what would happen without deploying 21 + */ 22 + 23 + import { TezosToolkit } from '@taquito/taquito'; 24 + import { InMemorySigner } from '@taquito/signer'; 25 + import { Parser } from '@taquito/michel-codec'; 26 + import fs from 'fs'; 27 + import path from 'path'; 28 + import { fileURLToPath } from 'url'; 29 + 30 + const __filename = fileURLToPath(import.meta.url); 31 + const __dirname = path.dirname(__filename); 32 + 33 + // Parse CLI args 34 + const args = process.argv.slice(2); 35 + const walletArg = args.find(a => a.startsWith('--wallet=')); 36 + const walletName = walletArg ? walletArg.split('=')[1] : 'kidlisp'; 37 + const dryRun = args.includes('--dry-run'); 38 + 39 + // Wallet configurations 40 + const WALLETS = { 41 + kidlisp: { 42 + envFile: path.join(__dirname, 'kidlisp/.env'), 43 + addressKey: 'KIDLISP_ADDRESS', 44 + keyKey: 'KIDLISP_KEY', 45 + fallbackAddress: 'tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC', 46 + }, 47 + staging: { 48 + envFile: path.join(__dirname, 'staging/.env'), 49 + addressKey: 'STAGING_ADDRESS', 50 + keyKey: 'STAGING_KEY', 51 + fallbackAddress: 'tz1dfoQDuxjwSgxdqJnisyKUxDHweade4Gzt', 52 + }, 53 + }; 54 + 55 + const walletConfig = WALLETS[walletName]; 56 + if (!walletConfig) { 57 + console.error(`Unknown wallet: ${walletName}. Use: kidlisp or staging`); 58 + process.exit(1); 59 + } 60 + 61 + // Load credentials from .env file 62 + function loadEnv(envPath, addressKey, keyKey, fallbackAddress) { 63 + if (!fs.existsSync(envPath)) { 64 + // Fall back to environment variables 65 + const address = process.env[`TEZOS_${addressKey}`] || process.env[addressKey] || fallbackAddress; 66 + const secretKey = process.env[`TEZOS_${keyKey}`] || process.env[keyKey]; 67 + if (!secretKey) { 68 + throw new Error(`No credentials found. Set ${keyKey} env var or create ${envPath}`); 69 + } 70 + return { address, secretKey }; 71 + } 72 + 73 + const content = fs.readFileSync(envPath, 'utf8'); 74 + const vars = {}; 75 + for (const line of content.split('\n')) { 76 + const trimmed = line.trim(); 77 + if (!trimmed || trimmed.startsWith('#')) continue; 78 + const [key, ...rest] = trimmed.split('='); 79 + if (key && rest.length > 0) { 80 + vars[key.trim()] = rest.join('=').trim().replace(/^["']|["']$/g, ''); 81 + } 82 + } 83 + 84 + const address = vars[addressKey] || vars.ADDRESS || fallbackAddress; 85 + const secretKey = vars[keyKey] || vars.KEY || vars.SECRET_KEY; 86 + if (!secretKey) throw new Error(`No secret key found in ${envPath}`); 87 + return { address, secretKey }; 88 + } 89 + 90 + function stringToBytes(str) { 91 + return Buffer.from(str, 'utf8').toString('hex'); 92 + } 93 + 94 + async function deploy() { 95 + console.log('\n╔══════════════════════════════════════════════════════════════╗'); 96 + console.log('║ 🚀 Deploying KidLisp Keeps v6 — Production Contract ║'); 97 + console.log('╚══════════════════════════════════════════════════════════════╝\n'); 98 + 99 + const { address, secretKey } = loadEnv( 100 + walletConfig.envFile, 101 + walletConfig.addressKey, 102 + walletConfig.keyKey, 103 + walletConfig.fallbackAddress 104 + ); 105 + 106 + console.log(`📡 Network: Mainnet`); 107 + console.log(`👤 Wallet: ${walletName} (${address})`); 108 + if (dryRun) console.log(`🔒 DRY RUN — no transaction will be sent\n`); 109 + 110 + const tezos = new TezosToolkit('https://mainnet.api.tez.ie'); 111 + tezos.setSignerProvider(new InMemorySigner(secretKey)); 112 + 113 + // Check balance 114 + const balance = await tezos.tz.getBalance(address); 115 + const balanceXTZ = balance.toNumber() / 1_000_000; 116 + console.log(`💰 Balance: ${balanceXTZ.toFixed(6)} XTZ\n`); 117 + 118 + if (balanceXTZ < 3) { 119 + throw new Error(`Need at least 3 XTZ for deployment. Have ${balanceXTZ.toFixed(6)} XTZ.`); 120 + } 121 + 122 + // Contract-level metadata (TZIP-016) 123 + const contractMetadata = { 124 + name: "KidLisp", 125 + version: "6.0.0", 126 + description: "https://keeps.kidlisp.com", 127 + homepage: "https://kidlisp.com", 128 + authors: ["aesthetic.computer"], 129 + interfaces: ["TZIP-012", "TZIP-016", "TZIP-021"], 130 + imageUri: "https://oven.aesthetic.computer/keeps/latest", 131 + }; 132 + 133 + console.log('📋 Contract metadata:'); 134 + console.log(` name: "${contractMetadata.name}"`); 135 + console.log(` version: "${contractMetadata.version}"`); 136 + console.log(` description: "${contractMetadata.description}"`); 137 + console.log(` homepage: "${contractMetadata.homepage}"`); 138 + console.log(` authors: ${JSON.stringify(contractMetadata.authors)}`); 139 + console.log(` imageUri: "${contractMetadata.imageUri}"`); 140 + console.log(''); 141 + 142 + // Load compiled contract (use v5 compiled output — same bytecode) 143 + // v6 Python source is identical logic, just different docstrings 144 + const contractPath = path.join(__dirname, 'KeepsFA2v5/step_002_cont_0_contract.tz'); 145 + if (!fs.existsSync(contractPath)) { 146 + throw new Error(`Compiled contract not found: ${contractPath}\nRun SmartPy compilation first, or use KeepsFA2v5 compiled output.`); 147 + } 148 + 149 + const contractSource = fs.readFileSync(contractPath, 'utf8'); 150 + console.log('📄 Contract loaded: KeepsFA2v5 (same bytecode as v6)'); 151 + 152 + const parser = new Parser(); 153 + const parsedContract = parser.parseScript(contractSource); 154 + 155 + // Build storage with contract metadata embedded 156 + const contractMetadataBytes = stringToBytes(JSON.stringify(contractMetadata)); 157 + const tezosStoragePointer = stringToBytes('tezos-storage:content'); 158 + 159 + // Initial storage: admin, content_hashes, contract_metadata_locked, default_royalty_bps, 160 + // keep_fee, ledger, metadata, metadata_locked, next_token_id, 161 + // operators, paused, token_creators, token_metadata 162 + // keep_fee = 2500000 mutez = 2.5 XTZ 163 + const initialStorageMichelson = `(Pair "${address}" (Pair {} (Pair False (Pair 1000 (Pair 2500000 (Pair {} (Pair {Elt "" 0x${tezosStoragePointer}; Elt "content" 0x${contractMetadataBytes}} (Pair {} (Pair 0 (Pair {} (Pair False (Pair {} {}))))))))))))`; 164 + 165 + console.log(`\n💰 Keep fee: 2.5 XTZ (2500000 mutez)`); 166 + console.log(`🎨 Royalties: 10% (1000 bps)`); 167 + console.log(`⏸️ Paused: false`); 168 + console.log(`🔓 Metadata locked: false\n`); 169 + 170 + if (dryRun) { 171 + console.log('╔══════════════════════════════════════════════════════════════╗'); 172 + console.log('║ 🔒 DRY RUN COMPLETE — No transaction sent ║'); 173 + console.log('╚══════════════════════════════════════════════════════════════╝\n'); 174 + console.log('Storage Michelson (first 200 chars):'); 175 + console.log(' ', initialStorageMichelson.substring(0, 200), '...\n'); 176 + return; 177 + } 178 + 179 + const parsedStorage = parser.parseMichelineExpression(initialStorageMichelson); 180 + 181 + console.log('📤 Deploying contract...'); 182 + console.log(' (This may take 1-2 minutes...)\n'); 183 + 184 + const originationOp = await tezos.contract.originate({ 185 + code: parsedContract, 186 + init: parsedStorage, 187 + }); 188 + 189 + console.log(`⏳ Operation: ${originationOp.hash}`); 190 + console.log('⏳ Waiting for confirmation...\n'); 191 + 192 + await originationOp.confirmation(1); 193 + 194 + const contractAddress = originationOp.contractAddress; 195 + 196 + console.log('╔══════════════════════════════════════════════════════════════╗'); 197 + console.log('║ ✅ KidLisp v6 Production Contract Deployed! ║'); 198 + console.log('╚══════════════════════════════════════════════════════════════╝\n'); 199 + console.log(`📍 Contract: ${contractAddress}`); 200 + console.log(`🔗 Explorer: https://tzkt.io/${contractAddress}`); 201 + console.log(`🎨 objkt: https://objkt.com/collection/${contractAddress}`); 202 + console.log(`\n📝 Next steps:`); 203 + console.log(` 1. Update constants.mjs: contract = "${contractAddress}"`); 204 + console.log(` 2. Set KEEPS_STAGING = false`); 205 + console.log(` 3. Update env var fallbacks in keep-mint.mjs, keep-update.mjs, etc.`); 206 + console.log(` 4. Deploy to Netlify`); 207 + console.log(` 5. Smoke test: first production mint\n`); 208 + 209 + // Save address 210 + const outputFile = path.join(__dirname, 'v6-contract-address.txt'); 211 + fs.writeFileSync(outputFile, contractAddress); 212 + console.log(`💾 Saved to: ${outputFile}`); 213 + 214 + // Also check new balance 215 + const newBalance = await tezos.tz.getBalance(address); 216 + const newBalanceXTZ = newBalance.toNumber() / 1_000_000; 217 + console.log(`💰 New balance: ${newBalanceXTZ.toFixed(6)} XTZ\n`); 218 + } 219 + 220 + deploy().catch(err => { 221 + console.error('\n❌ Deployment failed!'); 222 + console.error(` Error: ${err.message}\n`); 223 + process.exit(1); 224 + });
+4 -4
tezos/keeps.mjs
··· 956 956 { name: 'Language', value: 'KidLisp' }, 957 957 { name: 'Code', value: `$${pieceName}` }, 958 958 ...(authorDisplayName ? [{ name: 'Author', value: authorDisplayName }] : []), 959 - ...(userCode ? [{ name: 'User Code', value: userCode }] : []), 959 + ...(userCode ? [{ name: 'User', value: userCode }] : []), 960 960 ...(sourceCode ? [{ name: 'Lines of Code', value: String(sourceCode.split('\n').length) }] : []), 961 961 ...(depCount > 0 ? [{ name: 'Dependencies', value: String(depCount) }] : []), 962 - ...(packDate ? [{ name: 'Packed', value: packDate }] : []), 962 + ...(packDate ? [{ name: 'Packed on', value: packDate }] : []), 963 963 { name: 'Interactive', value: 'Yes' }, 964 964 { name: 'Platform', value: 'Aesthetic Computer' }, 965 965 ] ··· 1126 1126 { name: 'Language', value: 'KidLisp' }, 1127 1127 { name: 'Code', value: `$${pieceName}` }, 1128 1128 ...(authorDisplayName ? [{ name: 'Author', value: authorDisplayName }] : []), 1129 - ...(userCode ? [{ name: 'User Code', value: userCode }] : []), 1129 + ...(userCode ? [{ name: 'User', value: userCode }] : []), 1130 1130 ...(sourceCode ? [{ name: 'Lines of Code', value: String(sourceCode.split('\n').length) }] : []), 1131 1131 ...(depCount > 0 ? [{ name: 'Dependencies', value: String(depCount) }] : []), 1132 - ...(packDate ? [{ name: 'Packed', value: packDate }] : []), 1132 + ...(packDate ? [{ name: 'Packed on', value: packDate }] : []), 1133 1133 { name: 'Interactive', value: 'Yes' }, 1134 1134 { name: 'Platform', value: 'Aesthetic Computer' }, 1135 1135 ];
+431
tezos/keeps_fa2_v6.py
··· 1 + """ 2 + Keeps FA2 v6 - KidLisp Production Contract 3 + 4 + Production deployment on keeps.tez. Contract-level metadata: 5 + name: "KidLisp" 6 + description: "https://keeps.kidlisp.com" 7 + homepage: "https://kidlisp.com" 8 + version: "6.0.0" 9 + 10 + Identical contract logic to v5. v6 is a fresh deploy with: 11 + - Admin: keeps.tez (tz1Lc2DzTjDPyWFj1iuAVGGZWNjK67Wun2dC) 12 + - Clean token IDs starting from 0 13 + - Production contract metadata 14 + - 2.5 XTZ mint fee (revenue enabled) 15 + 16 + v5 features (preserved): 17 + - Default fee: 2.5 XTZ (revenue enabled by default) 18 + - Immutable content_hash: edit_metadata preserves content_hash 19 + - Improved error messages with context 20 + 21 + v4 features (preserved): 22 + - 10% Royalty Support: Automatic royalties on secondary sales (objkt.com compatible) 23 + - Emergency Pause: Admin can halt minting in emergencies 24 + - Admin Transfer: Customer service tool for edge cases 25 + 26 + v3 features (preserved): 27 + - edit_metadata: Token owner/creator can edit (preserves attribution) 28 + - token_creators: Tracks original creator for each token 29 + - Pre-encoded metadata bytes (TzKT/objkt compatible) 30 + - Fee system with withdraw capability 31 + - Burn and re-mint functionality 32 + 33 + Key feature: Metadata bytes stored directly WITHOUT sp.pack(), 34 + ensuring compatibility with TzKT, objkt, and other Tezos indexers. 35 + """ 36 + 37 + import smartpy as sp 38 + from smartpy.templates import fa2_lib as fa2 39 + 40 + main = fa2.main 41 + 42 + 43 + @sp.module 44 + def keeps_module(): 45 + import main 46 + 47 + # Order of inheritance: [Admin], [<policy>], <base class>, [<other mixins>]. 48 + class KeepsFA2( 49 + main.Admin, 50 + main.Nft, 51 + main.MintNft, 52 + main.BurnNft, 53 + main.OnchainviewBalanceOf, 54 + ): 55 + """ 56 + FA2 NFT contract for KidLisp Keeps (v6 - Production on keeps.tez). 57 + 58 + Contract-level metadata: 59 + name: "KidLisp" 60 + description: "https://keeps.kidlisp.com" 61 + homepage: "https://kidlisp.com" 62 + 63 + v5 features: 2.5 XTZ fee, immutable content_hash, improved errors 64 + v4 features: royalties, emergency pause, admin transfer 65 + v3 features: owner/creator editable metadata, attribution tracking 66 + """ 67 + 68 + def __init__(self, admin_address, contract_metadata, ledger, token_metadata): 69 + # Initialize on-chain balance view 70 + main.OnchainviewBalanceOf.__init__(self) 71 + 72 + # Initialize the NFT-specific entrypoints 73 + main.BurnNft.__init__(self) 74 + main.MintNft.__init__(self) 75 + 76 + # Initialize the NFT base class 77 + main.Nft.__init__(self, contract_metadata, ledger, token_metadata) 78 + 79 + # Initialize administrative permissions 80 + main.Admin.__init__(self, admin_address) 81 + 82 + # Additional storage for metadata locking 83 + self.data.metadata_locked = sp.cast( 84 + sp.big_map(), 85 + sp.big_map[sp.nat, sp.bool] 86 + ) 87 + 88 + # Track content hashes to prevent duplicate mints 89 + # Maps content_hash (bytes) -> token_id (nat) 90 + self.data.content_hashes = sp.cast( 91 + sp.big_map(), 92 + sp.big_map[sp.bytes, sp.nat] 93 + ) 94 + 95 + # Track original creator for each token (v3) 96 + # Maps token_id -> creator address (the first minter) 97 + self.data.token_creators = sp.cast( 98 + sp.big_map(), 99 + sp.big_map[sp.nat, sp.address] 100 + ) 101 + 102 + # Contract-level metadata lock flag 103 + self.data.contract_metadata_locked = False 104 + 105 + # Mint fee configuration (admin-adjustable) 106 + # 2.5 XTZ default fee 107 + self.data.keep_fee = sp.mutez(2500000) 108 + 109 + # Emergency pause flag 110 + # When true, minting and metadata edits are disabled 111 + self.data.paused = False 112 + 113 + # Default royalty configuration 114 + # Basis points: 1000 = 10%, 2500 = 25% (max) 115 + self.data.default_royalty_bps = 1000 # 10% default 116 + 117 + @sp.entrypoint 118 + def keep(self, params): 119 + """ 120 + Mint a new Keep token with minimal on-chain metadata. 121 + 122 + Full metadata lives in IPFS (via metadata_uri). On-chain stores only 123 + the essential fields needed for display and deduplication. 124 + 125 + Two modes: 126 + 1. Admin calling: mints to specified owner (for server-side minting) 127 + 2. User calling: mints to sender, requires fee payment (default 2.5 XTZ) 128 + 129 + All bytes parameters should be raw hex-encoded UTF-8 strings. 130 + """ 131 + sp.cast(params, sp.record( 132 + name=sp.bytes, 133 + symbol=sp.bytes, 134 + description=sp.bytes, 135 + artifactUri=sp.bytes, 136 + displayUri=sp.bytes, 137 + thumbnailUri=sp.bytes, 138 + decimals=sp.bytes, 139 + creators=sp.bytes, 140 + royalties=sp.bytes, 141 + content_hash=sp.bytes, 142 + metadata_uri=sp.bytes, 143 + owner=sp.address 144 + )) 145 + 146 + # Check if contract is paused 147 + assert not self.data.paused, "MINTING_PAUSED" 148 + 149 + # Determine minting mode and owner 150 + is_admin = self.is_administrator_() 151 + 152 + # Non-admin callers must pay the fee and can only mint to themselves 153 + if not is_admin: 154 + assert sp.amount >= self.data.keep_fee, "INSUFFICIENT_FEE" 155 + # User must mint to themselves (ensures they are firstMinter) 156 + assert params.owner == sp.sender, "MUST_MINT_TO_SELF" 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 + "": params.metadata_uri 177 + }, sp.map[sp.string, sp.bytes]) 178 + 179 + # Store token metadata 180 + self.data.token_metadata[token_id] = sp.record( 181 + token_id=token_id, 182 + token_info=token_info 183 + ) 184 + 185 + # Assign token to owner 186 + self.data.ledger[token_id] = params.owner 187 + 188 + # Initialize as not locked 189 + self.data.metadata_locked[token_id] = False 190 + 191 + # Store content hash to prevent duplicates 192 + self.data.content_hashes[params.content_hash] = token_id 193 + 194 + # Track the original creator 195 + self.data.token_creators[token_id] = params.owner 196 + 197 + # Increment token counter 198 + self.data.next_token_id = token_id + 1 199 + 200 + @sp.entrypoint 201 + def edit_metadata(self, params): 202 + """ 203 + Update metadata for an existing token. 204 + 205 + Authorization: 206 + - Admin (contract administrator) 207 + - Token owner (current holder) 208 + - Original creator (preserves objkt.com attribution) 209 + 210 + Respects pause flag (cannot edit when paused). 211 + content_hash is immutable — always preserved from original mint. 212 + """ 213 + sp.cast(params, sp.record( 214 + token_id=sp.nat, 215 + token_info=sp.map[sp.string, sp.bytes] 216 + )) 217 + 218 + # Check if contract is paused 219 + assert not self.data.paused, "EDITING_PAUSED" 220 + 221 + assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 222 + 223 + # Check authorization: admin, owner, or original creator 224 + is_admin = self.is_administrator_() 225 + is_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 226 + is_creator = self.data.token_creators.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 227 + 228 + assert is_admin or is_owner or is_creator, "NOT_AUTHORIZED" 229 + 230 + # Check if locked 231 + is_locked = self.data.metadata_locked.get(params.token_id, default=False) 232 + assert not is_locked, "METADATA_LOCKED" 233 + 234 + # Preserve immutable content_hash from original metadata 235 + existing_info = self.data.token_metadata[params.token_id].token_info 236 + original_hash = existing_info.get("content_hash", default=sp.bytes("0x")) 237 + 238 + # Update metadata 239 + self.data.token_metadata[params.token_id] = sp.record( 240 + token_id=params.token_id, 241 + token_info=params.token_info 242 + ) 243 + 244 + # Re-inject content_hash (immutable — cannot be changed or removed via edit) 245 + self.data.token_metadata[params.token_id].token_info["content_hash"] = original_hash 246 + 247 + @sp.entrypoint 248 + def lock_metadata(self, token_id): 249 + """Permanently lock metadata for a token (admin or owner only).""" 250 + sp.cast(token_id, sp.nat) 251 + 252 + assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 253 + 254 + # Check authorization: admin or owner 255 + is_admin = self.is_administrator_() 256 + is_owner = self.data.ledger.get(token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) == sp.sender 257 + 258 + assert is_admin or is_owner, "NOT_AUTHORIZED" 259 + 260 + self.data.metadata_locked[token_id] = True 261 + 262 + @sp.entrypoint 263 + def set_contract_metadata(self, params): 264 + """Update contract-level metadata (admin only, if not locked).""" 265 + sp.cast(params, sp.list[sp.record(key=sp.string, value=sp.bytes)]) 266 + 267 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 268 + assert not self.data.contract_metadata_locked, "CONTRACT_METADATA_LOCKED" 269 + 270 + for item in params: 271 + self.data.metadata[item.key] = item.value 272 + 273 + @sp.entrypoint 274 + def lock_contract_metadata(self): 275 + """Permanently lock contract-level metadata (admin only).""" 276 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 277 + self.data.contract_metadata_locked = True 278 + 279 + @sp.entrypoint 280 + def set_keep_fee(self, new_fee): 281 + """ 282 + Set the keep fee required for minting. 283 + Admin only. Fee is in mutez (1 tez = 1,000,000 mutez). 284 + """ 285 + sp.cast(new_fee, sp.mutez) 286 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 287 + self.data.keep_fee = new_fee 288 + 289 + @sp.entrypoint 290 + def withdraw_fees(self, destination): 291 + """ 292 + Withdraw accumulated fees from the contract. 293 + Admin only. Sends entire contract balance to destination. 294 + """ 295 + sp.cast(destination, sp.address) 296 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 297 + sp.send(destination, sp.balance) 298 + 299 + @sp.entrypoint 300 + def burn_keep(self, token_id): 301 + """ 302 + Burn a token and remove its content_hash. 303 + This allows re-minting the same piece name. 304 + Admin only. 305 + """ 306 + sp.cast(token_id, sp.nat) 307 + 308 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 309 + assert self.data.token_metadata.contains(token_id), "FA2_TOKEN_UNDEFINED" 310 + 311 + # Get content_hash before burning 312 + token_info = self.data.token_metadata[token_id].token_info 313 + content_hash = token_info.get("content_hash", default=sp.bytes("0x")) 314 + 315 + # Remove from registries 316 + if self.data.content_hashes.contains(content_hash): 317 + del self.data.content_hashes[content_hash] 318 + 319 + if self.data.ledger.contains(token_id): 320 + del self.data.ledger[token_id] 321 + 322 + del self.data.token_metadata[token_id] 323 + 324 + if self.data.metadata_locked.contains(token_id): 325 + del self.data.metadata_locked[token_id] 326 + 327 + if self.data.token_creators.contains(token_id): 328 + del self.data.token_creators[token_id] 329 + 330 + # ===================================================================== 331 + # v4 ENTRYPOINTS (preserved) 332 + # ===================================================================== 333 + 334 + @sp.entrypoint 335 + def pause(self): 336 + """ 337 + Emergency pause - stops minting and metadata edits. 338 + Admin only. 339 + 340 + Use cases: 341 + - Security vulnerability discovered 342 + - IPFS infrastructure issues 343 + - Spam attack detected 344 + - Contract bug found 345 + 346 + Note: Does NOT affect transfers (preserves FA2 composability) 347 + """ 348 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 349 + self.data.paused = True 350 + 351 + @sp.entrypoint 352 + def unpause(self): 353 + """ 354 + Resume normal operations after emergency pause. 355 + Admin only. 356 + """ 357 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 358 + self.data.paused = False 359 + 360 + @sp.entrypoint 361 + def set_default_royalty(self, bps): 362 + """ 363 + Set default royalty percentage for new mints. 364 + Admin only. 365 + 366 + Args: 367 + bps: Basis points (100 = 1%, 1000 = 10%, 2500 = 25% max) 368 + 369 + Example: 370 + set_default_royalty(1000) # 10% royalty 371 + """ 372 + sp.cast(bps, sp.nat) 373 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 374 + assert bps <= 2500, "MAX_ROYALTY_25_PERCENT" 375 + self.data.default_royalty_bps = bps 376 + 377 + @sp.entrypoint 378 + def admin_transfer(self, params): 379 + """Admin emergency transfer""" 380 + sp.cast(params, sp.record( 381 + token_id=sp.nat, 382 + from_=sp.address, 383 + to_=sp.address 384 + )) 385 + 386 + assert self.is_administrator_(), "FA2_NOT_ADMIN" 387 + assert self.data.token_metadata.contains(params.token_id), "FA2_TOKEN_UNDEFINED" 388 + 389 + current_owner = self.data.ledger.get(params.token_id, default=sp.address("tz1burnburnburnburnburnburnburjAYjjX")) 390 + assert current_owner == params.from_, "INVALID_CURRENT_OWNER" 391 + 392 + self.data.ledger[params.token_id] = params.to_ 393 + 394 + 395 + def _get_balance(fa2_contract, args): 396 + """Utility function to call the contract's get_balance view.""" 397 + return sp.View(fa2_contract, "get_balance")(args) 398 + 399 + 400 + def _total_supply(fa2_contract, args): 401 + """Utility function to call the contract's total_supply view.""" 402 + return sp.View(fa2_contract, "total_supply")(args) 403 + 404 + 405 + @sp.add_test() 406 + def test(): 407 + """Minimal test to compile v6 contract.""" 408 + scenario = sp.test_scenario("KeepsFA2v6") 409 + scenario.h1("Keeps FA2 v6 - KidLisp Production Contract") 410 + 411 + # Define test account 412 + admin = sp.test_account("Admin") 413 + 414 + # Create empty initial state 415 + ledger = {} 416 + token_metadata = [] 417 + 418 + # Deploy contract 419 + contract = keeps_module.KeepsFA2( 420 + admin.address, 421 + sp.big_map(), 422 + ledger, 423 + token_metadata 424 + ) 425 + 426 + scenario += contract 427 + 428 + scenario.p("v6: Production deploy on keeps.tez") 429 + scenario.p("Contract name: KidLisp") 430 + scenario.p("Fee: 2.5 XTZ, 10% royalties") 431 + scenario.p("All v3-v5 features preserved")