Monorepo for Aesthetic.Computer aesthetic.computer
at main 4811 lines 195 kB view raw
1#!/usr/bin/env node 2process.noDeprecation = true; 3/** 4 * 🔮 Keeps - Tezos FA2 Contract Management with Taquito 5 * 6 * A comprehensive Node.js module for deploying and managing 7 * the Aesthetic Computer "Keeps" FA2 contract on Tezos. 8 * 9 * Usage: 10 * node keeps.mjs deploy [network] - Deploy contract (supports --contract profile) 11 * node keeps.mjs keep <piece> - Keep (preserve) a KidLisp piece 12 * node keeps.mjs status - Check contract status 13 * node keeps.mjs balance - Check wallet balance 14 * node keeps.mjs tokens - List wallet tokens for active keeps contract 15 * node keeps.mjs market - Show Objkt market snapshot 16 * node keeps.mjs sell <token> <xtz> - List a token on Objkt marketplace 17 * node keeps.mjs accept <offer_id> - Accept a specific Objkt offer 18 * node keeps.mjs accept:auto ... - Accept best offers above thresholds 19 * node keeps.mjs buy <ask_id> - Buy a listed token (fulfill_ask) 20 * node keeps.mjs upload <piece> - Upload bundle to IPFS 21 */ 22 23import { TezosToolkit, MichelsonMap } from '@taquito/taquito'; 24import { InMemorySigner } from '@taquito/signer'; 25import { Parser, packDataBytes } from '@taquito/michel-codec'; 26import { MongoClient } from 'mongodb'; 27import fs from 'fs'; 28import path from 'path'; 29import { fileURLToPath } from 'url'; 30import crypto from 'crypto'; 31import readline from 'readline'; 32 33const __dirname = path.dirname(fileURLToPath(import.meta.url)); 34const KEEPS_SECRET_ID = process.env.KEEPS_SECRET_ID || 'tezos-kidlisp'; 35const KEEP_PERMIT_TTL_MS = Number.parseInt(process.env.KEEP_PERMIT_TTL_MS || '1200000', 10); // 20 minutes 36const OBJKT_DATA_API = 'https://data.objkt.com/v3/graphql'; 37const OBJKT_MARKETPLACE_FALLBACK = { 38 mainnet: 'KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X', // objktcom marketplace v6.2 39}; 40 41// ============================================================================ 42// Configuration 43// ============================================================================ 44 45const CONFIG = { 46 // Network settings 47 ghostnet: { 48 rpc: 'https://rpc.ghostnet.teztnets.com', // Changed from ecadinfra 49 name: 'Ghostnet (Testnet)', 50 explorer: 'https://ghostnet.tzkt.io' 51 }, 52 mainnet: { 53 rpc: 'https://mainnet.api.tez.ie', // Changed from ecadinfra for better deployment support 54 name: 'Mainnet', 55 explorer: 'https://tzkt.io' 56 }, 57 58 // IPFS settings 59 pinata: { 60 apiUrl: 'https://api.pinata.cloud', 61 gateway: 'https://ipfs.aesthetic.computer' 62 }, 63 64 // Oven service for thumbnails 65 oven: { 66 url: process.env.OVEN_URL || 'https://oven.aesthetic.computer' 67 }, 68 69 // Contract paths 70 paths: { 71 // Compiled contract artifacts by generation 72 compiled: { 73 v11: path.join(__dirname, 'KeepsFA2v11/step_002_cont_0_contract.tz'), 74 v10: path.join(__dirname, 'KeepsFA2v10/step_002_cont_0_contract.tz'), 75 v9: path.join(__dirname, 'KeepsFA2v9/step_002_cont_0_contract.tz'), 76 v8: path.join(__dirname, 'KeepsFA2v8/step_002_cont_0_contract.tz'), 77 v7: path.join(__dirname, 'KeepsFA2v7/step_002_cont_0_contract.tz'), 78 v6: path.join(__dirname, 'KeepsFA2v6/step_002_cont_0_contract.tz'), 79 v2: path.join(__dirname, 'KeepsFA2v2/step_002_cont_0_contract.tz'), 80 v3: path.join(__dirname, 'KeepsFA2v3/step_002_cont_0_contract.tz'), 81 v4: path.join(__dirname, 'KeepsFA2v4/step_002_cont_0_contract.tz'), 82 v5: path.join(__dirname, 'KeepsFA2v5/step_002_cont_0_contract.tz'), 83 }, 84 // Backward-compatible defaults 85 contract: path.join(__dirname, 'KeepsFA2v9/step_002_cont_0_contract.tz'), 86 storage: path.join(__dirname, 'KeepsFA2v9/step_002_cont_0_storage.tz'), 87 // Legacy direct paths 88 v3Contract: path.join(__dirname, 'KeepsFA2v3/step_002_cont_0_contract.tz'), 89 v2Contract: path.join(__dirname, 'KeepsFA2v2/step_002_cont_0_contract.tz'), 90 // Legacy contract path 91 legacyContract: path.join(__dirname, 'michelson-lib/keeps-fa2-complete.tz'), 92 // Network-specific contract addresses 93 contractAddresses: { 94 ghostnet: path.join(__dirname, 'contract-address-ghostnet.txt'), 95 mainnet: path.join(__dirname, 'contract-address-mainnet.txt'), 96 }, 97 // Legacy single file (deprecated) 98 contractAddress: path.join(__dirname, 'contract-address.txt'), 99 vault: path.join(__dirname, '../aesthetic-computer-vault') 100 } 101}; 102 103const CONTRACT_PROFILES = { 104 v11: { 105 key: 'v11', 106 label: 'KidLisp v11 — user-only minting, no admin path', 107 artifactKey: 'v11', 108 metadata: { 109 name: 'KidLisp', 110 version: '11.0.0', 111 description: 'https://keep.kidlisp.com', 112 homepage: 'https://keep.kidlisp.com', 113 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 114 authors: ['aesthetic.computer'], 115 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 116 }, 117 keepFeeMutez: 2_500_000, 118 artistRoyaltyBps: 900, 119 platformRoyaltyBps: 100, 120 paused: false, 121 }, 122 v10: { 123 key: 'v10', 124 label: 'KidLisp v10 — no admin_transfer, split royalties', 125 artifactKey: 'v10', 126 metadata: { 127 name: 'KidLisp', 128 version: '10.0.0', 129 description: 'https://keep.kidlisp.com', 130 homepage: 'https://keep.kidlisp.com', 131 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 132 authors: ['aesthetic.computer'], 133 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 134 }, 135 keepFeeMutez: 2_500_000, 136 artistRoyaltyBps: 900, 137 platformRoyaltyBps: 100, 138 paused: false, 139 }, 140 v9: { 141 key: 'v9', 142 label: 'KidLisp v9 final production', 143 artifactKey: 'v9', 144 metadata: { 145 name: 'KidLisp', 146 version: '9.0.0', 147 description: 'https://keep.kidlisp.com', 148 homepage: 'https://keep.kidlisp.com', 149 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 150 authors: ['aesthetic.computer'], 151 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 152 }, 153 keepFeeMutez: 2_500_000, 154 defaultRoyaltyBps: 1000, 155 paused: false, 156 }, 157 v8: { 158 key: 'v8', 159 label: 'KidLisp v8 signed-permit production', 160 artifactKey: 'v8', 161 metadata: { 162 name: 'KidLisp', 163 version: '8.0.0', 164 description: 'https://keep.kidlisp.com', 165 homepage: 'https://keep.kidlisp.com', 166 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 167 authors: ['aesthetic.computer'], 168 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 169 }, 170 keepFeeMutez: 2_500_000, 171 defaultRoyaltyBps: 1000, 172 paused: false, 173 }, 174 v7: { 175 key: 'v7', 176 label: 'KidLisp v7 final production', 177 artifactKey: 'v7', 178 metadata: { 179 name: 'KidLisp', 180 version: '7.0.0', 181 description: 'https://keep.kidlisp.com', 182 homepage: 'https://kidlisp.com', 183 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 184 authors: ['aesthetic.computer'], 185 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 186 }, 187 keepFeeMutez: 2_500_000, 188 defaultRoyaltyBps: 1000, 189 paused: false, 190 }, 191 v6: { 192 key: 'v6', 193 label: 'KidLisp v6 production (legacy)', 194 artifactKey: 'v6', 195 metadata: { 196 name: 'KidLisp', 197 version: '6.0.0', 198 description: 'https://keep.kidlisp.com', 199 homepage: 'https://kidlisp.com', 200 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 201 authors: ['aesthetic.computer'], 202 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 203 }, 204 keepFeeMutez: 2_500_000, 205 defaultRoyaltyBps: 1000, 206 paused: false, 207 }, 208 v5rc: { 209 key: 'v5rc', 210 label: 'KidLisp v5 release candidate', 211 artifactKey: 'v5', 212 metadata: { 213 name: 'KidLisp Keeps RC', 214 version: '5.0.0-rc', 215 description: 'https://keep.kidlisp.com/rc', 216 homepage: 'https://kidlisp.com', 217 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 218 authors: ['aesthetic.computer'], 219 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 220 }, 221 keepFeeMutez: 2_500_000, 222 defaultRoyaltyBps: 1000, 223 paused: false, 224 }, 225 v4: { 226 key: 'v4', 227 label: 'Keeps v4 legacy', 228 artifactKey: 'v4', 229 metadata: { 230 name: 'KidLisp Keeps Beta', 231 version: '4.0.0', 232 interfaces: ['TZIP-012', 'TZIP-016', 'TZIP-021'], 233 authors: ['aesthetic.computer'], 234 homepage: 'https://aesthetic.computer', 235 imageUri: 'https://oven.aesthetic.computer/keeps/latest', 236 }, 237 keepFeeMutez: 0, 238 defaultRoyaltyBps: 1000, 239 paused: false, 240 }, 241}; 242 243function resolveContractProfile(rawProfile = 'v9') { 244 const normalized = String(rawProfile || 'v9').trim().toLowerCase(); 245 const aliasMap = { 246 rc: 'v5rc', 247 v5: 'v5rc', 248 production: 'v11', 249 latest: 'v11', 250 }; 251 const key = aliasMap[normalized] || normalized; 252 const profile = CONTRACT_PROFILES[key]; 253 if (!profile) { 254 const supported = Object.keys(CONTRACT_PROFILES).join(', '); 255 throw new Error(`❌ Unknown contract profile: ${rawProfile}. Use one of: ${supported}`); 256 } 257 return profile; 258} 259 260function getMongoSecretsConfig() { 261 return { 262 connectionString: process.env.MONGODB_CONNECTION_STRING, 263 dbName: process.env.MONGODB_NAME, 264 }; 265} 266 267async function syncActiveKeepsSecret({ network = 'mainnet', contractAddress, profile }) { 268 const { connectionString, dbName } = getMongoSecretsConfig(); 269 if (!connectionString || !dbName) { 270 console.log(' ⚠️ Mongo secrets sync skipped (set MONGODB_CONNECTION_STRING + MONGODB_NAME to enable).'); 271 return { synced: false, reason: 'missing-mongo-env' }; 272 } 273 274 const client = new MongoClient(connectionString, { 275 serverSelectionTimeoutMS: 10000, 276 connectTimeoutMS: 10000, 277 }); 278 279 try { 280 await client.connect(); 281 const secrets = client.db(dbName).collection('secrets'); 282 const now = new Date().toISOString(); 283 284 const update = await secrets.updateOne( 285 { _id: KEEPS_SECRET_ID }, 286 { 287 $set: { 288 [`keepsContract.${network}`]: contractAddress, 289 currentKeepsContract: contractAddress, 290 currentKeepsNetwork: network, 291 currentKeepsProfile: profile.key, 292 currentKeepsVersion: profile.metadata?.version || null, 293 currentKeepsUpdatedAt: now, 294 }, 295 } 296 ); 297 298 if (!update.matchedCount) { 299 console.log(` ⚠️ Mongo secrets sync skipped (no secrets.${KEEPS_SECRET_ID} document found).`); 300 return { synced: false, reason: 'secret-not-found' }; 301 } 302 303 console.log(` 🗄️ Synced secrets.${KEEPS_SECRET_ID} -> ${contractAddress} (${profile.key} on ${network})`); 304 return { synced: true }; 305 } catch (error) { 306 console.log(` ⚠️ Mongo secrets sync failed: ${error.message}`); 307 return { synced: false, reason: 'sync-error', error: error.message }; 308 } finally { 309 await client.close().catch(() => {}); 310 } 311} 312 313async function syncCurrentContractToSecrets(network = 'mainnet', options = {}) { 314 const profile = resolveContractProfile(options.contractProfile || options.profile || 'v9'); 315 const addressPath = getContractAddressPath(network); 316 if (!fs.existsSync(addressPath)) { 317 throw new Error(`❌ No saved contract address for ${network} at ${addressPath}`); 318 } 319 320 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 321 if (!isKt1Address(contractAddress)) { 322 throw new Error(`❌ Invalid contract address in ${addressPath}: ${contractAddress}`); 323 } 324 325 console.log(`\n🗄️ Syncing secrets from ${addressPath}`); 326 console.log(` Contract: ${contractAddress}`); 327 console.log(` Profile: ${profile.key}`); 328 console.log(` Network: ${network}`); 329 330 return syncActiveKeepsSecret({ network, contractAddress, profile }); 331} 332 333// ============================================================================ 334// Credential Loading 335// ============================================================================ 336 337// Current wallet selection (can be changed via --wallet flag) 338let currentWallet = 'staging'; // default for mainnet staging contract 339 340function setWallet(wallet) { 341 currentWallet = wallet; 342} 343 344function loadCredentials() { 345 const credentials = {}; 346 347 // Load Tezos wallet credentials based on current wallet selection 348 // Note: aesthetic wallet keys are stored in kidlisp/.env for convenience 349 const walletPaths = { 350 keeps: { path: 'tezos/kidlisp/.env', addressKey: 'KEEPS_ADDRESS', secretKey: 'KEEPS_KEY' }, 351 kidlisp: { path: 'tezos/kidlisp/.env', addressKey: 'KIDLISP_ADDRESS', secretKey: 'KIDLISP_KEY' }, 352 aesthetic: { path: 'tezos/kidlisp/.env', addressKey: 'AESTHETIC_ADDRESS', secretKey: 'AESTHETIC_KEY' }, 353 staging: { path: 'tezos/staging/.env', addressKey: 'STAGING_ADDRESS', secretKey: 'STAGING_KEY' } 354 }; 355 356 const walletConfig = walletPaths[currentWallet] || walletPaths.kidlisp; 357 const tezosEnvPath = path.join(CONFIG.paths.vault, walletConfig.path); 358 359 if (fs.existsSync(tezosEnvPath)) { 360 const content = fs.readFileSync(tezosEnvPath, 'utf8'); 361 for (const line of content.split('\n')) { 362 // Try both specific keys and generic ADDRESS/KEY patterns 363 if (line.startsWith(walletConfig.addressKey + '=') || line.startsWith('ADDRESS=')) { 364 credentials.address = line.split('=')[1].trim().replace(/"/g, ''); 365 } else if (line.startsWith(walletConfig.secretKey + '=') || line.startsWith('KEY=') || line.startsWith('SECRET_KEY=')) { 366 credentials.secretKey = line.split('=')[1].trim().replace(/"/g, ''); 367 } 368 } 369 } 370 371 // Load Pinata credentials 372 const pinataEnvPath = path.join(CONFIG.paths.vault, '.env.pinata'); 373 if (fs.existsSync(pinataEnvPath)) { 374 const content = fs.readFileSync(pinataEnvPath, 'utf8'); 375 for (const line of content.split('\n')) { 376 if (line.startsWith('PINATA_API_KEY=')) { 377 credentials.pinataKey = line.split('=')[1].trim().replace(/"/g, ''); 378 } else if (line.startsWith('PINATA_API_SECRET=')) { 379 credentials.pinataSecret = line.split('=')[1].trim().replace(/"/g, ''); 380 } 381 } 382 } 383 384 credentials.wallet = currentWallet; 385 return credentials; 386} 387 388// Get contract address file path for a network 389function getContractAddressPath(network = 'mainnet') { 390 // Use network-specific paths if available 391 if (CONFIG.paths.contractAddresses[network]) { 392 return CONFIG.paths.contractAddresses[network]; 393 } 394 // Fall back to legacy single file 395 return CONFIG.paths.contractAddress; 396} 397 398function isKt1Address(value) { 399 return typeof value === 'string' && /^KT1[1-9A-HJ-NP-Za-km-z]{33}$/.test(value.trim()); 400} 401 402function formatExtendedError(error) { 403 if (!error || typeof error !== 'object') return ''; 404 405 const payload = error.body ?? error.errors ?? error.data ?? null; 406 if (!payload) return ''; 407 408 try { 409 return JSON.stringify(payload, null, 2); 410 } catch { 411 return String(payload); 412 } 413} 414 415function tzktApiBase(network = 'mainnet') { 416 return network === 'mainnet' ? 'https://api.tzkt.io' : `https://api.${network}.tzkt.io`; 417} 418 419async function objktGraphQL(query, variables = {}) { 420 const response = await fetch(OBJKT_DATA_API, { 421 method: 'POST', 422 headers: { 'content-type': 'application/json' }, 423 body: JSON.stringify({ query, variables }), 424 }); 425 426 if (!response.ok) { 427 throw new Error(`Objkt data API returned ${response.status}`); 428 } 429 430 const payload = await response.json(); 431 if (Array.isArray(payload.errors) && payload.errors.length > 0) { 432 throw new Error(`Objkt data API error: ${payload.errors[0].message || 'Unknown error'}`); 433 } 434 435 return payload.data || {}; 436} 437 438function parseVersionFromLabel(label = '') { 439 const match = String(label).match(/v(\d+(?:\.\d+)*)/i); 440 if (!match) return []; 441 return match[1].split('.').map((part) => Number.parseInt(part, 10)).filter(Number.isFinite); 442} 443 444function compareVersionArrays(a = [], b = []) { 445 const maxLength = Math.max(a.length, b.length); 446 for (let i = 0; i < maxLength; i += 1) { 447 const av = a[i] ?? 0; 448 const bv = b[i] ?? 0; 449 if (av !== bv) return av - bv; 450 } 451 return 0; 452} 453 454async function resolveObjktMarketplaceContract({ 455 network = 'mainnet', 456 keepsContract, 457 explicitContract = null, 458}) { 459 if (explicitContract) return explicitContract; 460 461 if (network !== 'mainnet') { 462 throw new Error( 463 `Objkt marketplace auto-discovery only supports mainnet. Pass --marketplace=<KT1...> for ${network}.` 464 ); 465 } 466 467 try { 468 const existingData = await objktGraphQL( 469 ` 470 query($contract:String!) { 471 listing_active( 472 where:{fa_contract:{_eq:$contract}} 473 order_by:{timestamp:desc} 474 limit:1 475 ) { 476 marketplace_contract 477 marketplace { name } 478 } 479 } 480 `, 481 { contract: keepsContract } 482 ); 483 484 const existing = existingData?.listing_active?.[0]; 485 if (isKt1Address(existing?.marketplace_contract)) { 486 return existing.marketplace_contract; 487 } 488 } catch { 489 // Fallback to registry lookup below. 490 } 491 492 const registryData = await objktGraphQL(` 493 query { 494 marketplace_contract( 495 where:{ 496 group:{_eq:"objktcom"}, 497 subgroup:{_eq:"marketplace"}, 498 name:{_ilike:"objktcom marketplace v%"} 499 } 500 ) { 501 contract 502 name 503 } 504 } 505 `); 506 507 const rows = Array.isArray(registryData?.marketplace_contract) 508 ? registryData.marketplace_contract 509 : []; 510 511 const ranked = rows 512 .filter((row) => isKt1Address(row?.contract)) 513 .map((row) => ({ 514 ...row, 515 parsedVersion: parseVersionFromLabel(row?.name), 516 })) 517 .sort((a, b) => compareVersionArrays(b.parsedVersion, a.parsedVersion)); 518 519 if (ranked.length > 0) { 520 return ranked[0].contract; 521 } 522 523 const fallback = OBJKT_MARKETPLACE_FALLBACK[network]; 524 if (isKt1Address(fallback)) return fallback; 525 526 throw new Error('Could not resolve Objkt marketplace contract.'); 527} 528 529function loadContractAddress(network = 'mainnet') { 530 const addressPath = getContractAddressPath(network); 531 if (!fs.existsSync(addressPath)) { 532 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 533 } 534 return fs.readFileSync(addressPath, 'utf8').trim(); 535} 536 537function parsePriceToMutez(priceInput) { 538 const asNumber = Number.parseFloat(String(priceInput)); 539 if (!Number.isFinite(asNumber) || asNumber <= 0) { 540 throw new Error(`Invalid price "${priceInput}". Expected a positive XTZ value.`); 541 } 542 return Math.round(asNumber * 1_000_000); 543} 544 545function parseOptionalIsoTimestamp(value, label) { 546 if (value == null || value === '') return null; 547 const date = new Date(value); 548 if (Number.isNaN(date.getTime())) { 549 throw new Error(`Invalid ${label} timestamp "${value}". Use ISO-8601 format.`); 550 } 551 return date.toISOString(); 552} 553 554async function resolveTokenIdFromReference(tokenReference, { contractAddress, network = 'mainnet' }) { 555 const raw = String(tokenReference || '').trim(); 556 if (!raw) { 557 throw new Error('Token reference is required (token id or piece code like $bip).'); 558 } 559 560 if (/^\d+$/.test(raw)) { 561 return Number.parseInt(raw, 10); 562 } 563 564 const pieceName = raw.replace(/^\$/, ''); 565 const duplicate = await checkDuplicatePiece(pieceName, contractAddress, network); 566 if (duplicate.exists) { 567 return Number.parseInt(duplicate.tokenId, 10); 568 } 569 570 throw new Error(`Could not resolve token reference "${tokenReference}" to a token id.`); 571} 572 573function normalizeShareMap(shares = {}) { 574 const normalized = {}; 575 for (const [address, amount] of Object.entries(shares || {})) { 576 const nat = Number.parseInt(String(amount), 10); 577 if (nat > 0) { 578 normalized[address] = nat.toString(); 579 } 580 } 581 return normalized; 582} 583 584async function fetchTokenFromTzkt(contractAddress, tokenId, network = 'mainnet') { 585 const apiBase = tzktApiBase(network); 586 const url = `${apiBase}/v1/tokens?contract=${contractAddress}&tokenId=${tokenId}`; 587 const response = await fetch(url); 588 if (!response.ok) { 589 throw new Error(`Failed to fetch token #${tokenId} from TzKT (${response.status})`); 590 } 591 const rows = await response.json(); 592 return rows?.[0] || null; 593} 594 595async function assertWalletOwnsToken(contractAddress, tokenId, ownerAddress, network = 'mainnet') { 596 const apiBase = tzktApiBase(network); 597 const url = `${apiBase}/v1/tokens/balances?token.contract=${contractAddress}&token.tokenId=${tokenId}&account=${ownerAddress}&balance.gt=0&limit=1`; 598 const response = await fetch(url); 599 if (!response.ok) { 600 throw new Error(`Failed to verify ownership for token #${tokenId} (${response.status})`); 601 } 602 const rows = await response.json(); 603 if (!Array.isArray(rows) || rows.length === 0) { 604 throw new Error(`Wallet ${ownerAddress} does not currently hold token #${tokenId}.`); 605 } 606} 607 608async function loadActiveListingForToken(contractAddress, tokenId, sellerAddress) { 609 const data = await objktGraphQL( 610 ` 611 query($contract:String!, $tokenId:String!, $seller:String!) { 612 listing_active( 613 where:{ 614 fa_contract:{_eq:$contract}, 615 seller_address:{_eq:$seller}, 616 token:{token_id:{_eq:$tokenId}} 617 } 618 order_by:{timestamp:desc} 619 limit:1 620 ) { 621 id 622 bigmap_key 623 price_xtz 624 marketplace_contract 625 marketplace { name } 626 } 627 } 628 `, 629 { contract: contractAddress, tokenId: String(tokenId), seller: sellerAddress } 630 ); 631 return data?.listing_active?.[0] || null; 632} 633 634async function loadBestActiveOfferForToken(contractAddress, tokenId) { 635 const data = await objktGraphQL( 636 ` 637 query($contract:String!, $tokenId:String!) { 638 offer_active( 639 where:{ 640 fa_contract:{_eq:$contract}, 641 token:{token_id:{_eq:$tokenId}} 642 } 643 order_by:{price_xtz:desc} 644 limit:1 645 ) { 646 id 647 bigmap_key 648 price_xtz 649 buyer_address 650 marketplace_contract 651 amount_left 652 timestamp 653 token { token_id name fa_contract } 654 } 655 } 656 `, 657 { contract: contractAddress, tokenId: String(tokenId) } 658 ); 659 return data?.offer_active?.[0] || null; 660} 661 662async function loadActiveOfferById(offerId) { 663 const numericId = Number.parseInt(String(offerId), 10); 664 if (!Number.isInteger(numericId) || numericId < 0) { 665 throw new Error(`Invalid offer id "${offerId}".`); 666 } 667 668 const data = await objktGraphQL( 669 ` 670 query { 671 offer_active( 672 where:{ 673 _or:[ 674 {id:{_eq:${numericId}}}, 675 {bigmap_key:{_eq:${numericId}}} 676 ] 677 } 678 order_by:{timestamp:desc} 679 limit:5 680 ) { 681 id 682 bigmap_key 683 price_xtz 684 buyer_address 685 marketplace_contract 686 amount_left 687 timestamp 688 token { token_id name fa_contract } 689 } 690 } 691 ` 692 ); 693 694 const rows = Array.isArray(data?.offer_active) ? data.offer_active : []; 695 if (rows.length === 0) return null; 696 697 // Prefer direct row-id match first, then on-chain offer-id (bigmap key). 698 return rows.find((row) => Number.parseInt(String(row?.id), 10) === numericId) 699 || rows.find((row) => Number.parseInt(String(row?.bigmap_key), 10) === numericId) 700 || rows[0]; 701} 702 703// ============================================================================ 704// Tezos Client Setup 705// ============================================================================ 706 707async function createTezosClient(network = 'mainnet') { 708 const credentials = loadCredentials(); 709 710 if (!credentials.address || !credentials.secretKey) { 711 throw new Error('❌ Tezos credentials not found in vault'); 712 } 713 714 const config = CONFIG[network]; 715 const tezos = new TezosToolkit(config.rpc); 716 717 // Set up signer 718 tezos.setProvider({ 719 signer: new InMemorySigner(credentials.secretKey) 720 }); 721 722 return { tezos, credentials, config }; 723} 724 725// ============================================================================ 726// Contract Deployment 727// ============================================================================ 728 729async function deployContract(network = 'mainnet', options = {}) { 730 const profile = resolveContractProfile(options.contractProfile || options.profile || 'v9'); 731 const contractPath = CONFIG.paths.compiled[profile.artifactKey] || CONFIG.paths.contract; 732 const feeXTZ = (profile.keepFeeMutez / 1_000_000).toFixed(6); 733 const pausedMichelson = profile.paused ? 'True' : 'False'; 734 735 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 736 console.log(`║ 🚀 Deploying ${profile.label.padEnd(48)}`); 737 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 738 739 const { tezos, credentials, config } = await createTezosClient(network); 740 741 console.log(`📡 Network: ${config.name}`); 742 console.log(`📍 RPC: ${config.rpc}`); 743 console.log(`👤 Administrator: ${credentials.address}`); 744 console.log(`🧱 Profile: ${profile.key}\n`); 745 746 // Check balance 747 console.log('💰 Checking balance...'); 748 const balance = await tezos.tz.getBalance(credentials.address); 749 const balanceXTZ = balance.toNumber() / 1_000_000; 750 console.log(` Balance: ${balanceXTZ.toFixed(2)} XTZ`); 751 752 if (balanceXTZ < 1) { 753 throw new Error('❌ Insufficient balance. Need at least 1 XTZ for deployment.'); 754 } 755 756 // Load contract code (SmartPy compiled Michelson) 757 console.log('\n📄 Loading contract...'); 758 if (!fs.existsSync(contractPath)) { 759 throw new Error(`❌ Contract file not found: ${contractPath}\n Compile the selected artifact before deploy.`); 760 } 761 762 const contractSource = fs.readFileSync(contractPath, 'utf8'); 763 console.log(` ✓ Contract loaded: ${path.relative(__dirname, contractPath)}`); 764 765 // Parse the contract using michel-codec 766 const parser = new Parser(); 767 const parsedContract = parser.parseScript(contractSource); 768 769 console.log('\n💾 Creating initial storage...'); 770 771 const contractMetadataJson = JSON.stringify(profile.metadata); 772 const contractMetadataBytes = stringToBytes(contractMetadataJson); 773 const tezosStoragePointer = stringToBytes('tezos-storage:content'); 774 775 // Storage layout varies by version (SmartPy sorts fields alphabetically). 776 // v10 adds: artist_royalty_bps, platform_royalty_bps, treasury_address 777 // removes: default_royalty_bps 778 // v10 order: administrator, artist_royalty_bps, content_hashes, 779 // contract_metadata_locked, keep_fee, ledger, metadata, 780 // metadata_locked, next_token_id, operators, paused, 781 // platform_royalty_bps, token_creators, token_metadata, treasury_address 782 const isV10 = profile.key === 'v10' || profile.key === 'v11'; 783 const treasuryAddress = credentials.treasuryAddress || credentials.address; 784 785 let initialStorageMichelson; 786 if (isV10) { 787 initialStorageMichelson = `(Pair "${credentials.address}" (Pair ${profile.artistRoyaltyBps} (Pair {} (Pair False (Pair ${profile.keepFeeMutez} (Pair {} (Pair {Elt "" 0x${tezosStoragePointer}; Elt "content" 0x${contractMetadataBytes}} (Pair {} (Pair 0 (Pair {} (Pair ${pausedMichelson} (Pair ${profile.platformRoyaltyBps} (Pair {} (Pair {} "${treasuryAddress}")))))))))))))))`; 788 } else { 789 // v9 and earlier: administrator, content_hashes, contract_metadata_locked, 790 // default_royalty_bps, keep_fee, ledger, metadata, metadata_locked, 791 // next_token_id, operators, paused, token_creators, token_metadata 792 initialStorageMichelson = `(Pair "${credentials.address}" (Pair {} (Pair False (Pair ${profile.defaultRoyaltyBps} (Pair ${profile.keepFeeMutez} (Pair {} (Pair {Elt "" 0x${tezosStoragePointer}; Elt "content" 0x${contractMetadataBytes}} (Pair {} (Pair 0 (Pair {} (Pair ${pausedMichelson} (Pair {} {}))))))))))))`; 793 } 794 const parsedStorage = parser.parseMichelineExpression(initialStorageMichelson); 795 796 console.log(` ✓ Name: ${profile.metadata.name}`); 797 console.log(` ✓ Version: ${profile.metadata.version}`); 798 if (profile.metadata.description) { 799 console.log(` ✓ Description: ${profile.metadata.description}`); 800 } 801 console.log(` ✓ Homepage: ${profile.metadata.homepage}`); 802 console.log(` ✓ Initial token ID: 0`); 803 console.log(` ✓ Keep fee: ${profile.keepFeeMutez} mutez (${feeXTZ} XTZ)`); 804 if (profile.artistRoyaltyBps !== undefined) { 805 console.log(` ✓ Artist royalty: ${profile.artistRoyaltyBps} bps / Platform: ${profile.platformRoyaltyBps} bps`); 806 console.log(` ✓ Treasury: ${treasuryAddress}`); 807 } else { 808 console.log(` ✓ Default royalty: ${profile.defaultRoyaltyBps} bps`); 809 } 810 console.log(` ✓ Paused: ${profile.paused}`); 811 812 console.log('\n📤 Deploying contract...'); 813 console.log(' (This may take 1-2 minutes...)\n'); 814 815 try { 816 const originationOp = await tezos.contract.originate({ 817 code: parsedContract, 818 init: parsedStorage 819 }); 820 821 console.log(` ⏳ Operation hash: ${originationOp.hash}`); 822 console.log(' ⏳ Waiting for confirmation...'); 823 824 await originationOp.confirmation(1); 825 826 const contractAddress = originationOp.contractAddress; 827 828 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 829 console.log('║ ✅ Contract Deployed Successfully! ║'); 830 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 831 832 console.log(` 📍 Contract Address: ${contractAddress}`); 833 console.log(` 🔗 Explorer: ${config.explorer}/${contractAddress}`); 834 console.log(` 🖼️ Objkt: https://${network === 'mainnet' ? '' : 'ghostnet.'}objkt.com/collection/${contractAddress}`); 835 console.log(` 📝 Operation: ${config.explorer}/${originationOp.hash}\n`); 836 837 // Save contract address (network-specific file) 838 const addressPath = getContractAddressPath(network); 839 fs.writeFileSync(addressPath, contractAddress); 840 console.log(` 💾 Saved address to: ${addressPath}\n`); 841 842 await syncActiveKeepsSecret({ network, contractAddress, profile }); 843 console.log(''); 844 845 return { address: contractAddress, hash: originationOp.hash, profile: profile.key }; 846 847 } catch (error) { 848 console.error('\n❌ Deployment failed!'); 849 console.error(` Error: ${error.message}`); 850 if (error.message.includes('bad_stack')) { 851 console.error('\n 💡 This usually means storage format mismatch.'); 852 console.error(' Check that the storage matches the selected contract artifact.'); 853 } 854 throw error; 855 } 856} 857 858// ============================================================================ 859// Contract Status 860// ============================================================================ 861 862async function getContractStatus(network = 'mainnet') { 863 const { tezos, config } = await createTezosClient(network); 864 865 // Load contract address (network-specific) 866 const addressPath = getContractAddressPath(network); 867 if (!fs.existsSync(addressPath)) { 868 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 869 } 870 871 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 872 873 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 874 console.log('║ 📊 Contract Status ║'); 875 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 876 877 console.log(`📡 Network: ${config.name}`); 878 console.log(`📍 Contract: ${contractAddress}`); 879 console.log(`🔗 Explorer: ${config.explorer}/${contractAddress}\n`); 880 881 try { 882 const contract = await tezos.contract.at(contractAddress); 883 const storage = await contract.storage(); 884 885 console.log('📦 Storage:'); 886 console.log(` Administrator: ${storage.administrator}`); 887 console.log(` Next Token ID: ${storage.next_token_id.toString()}`); 888 console.log(` Total Keeps: ${storage.next_token_id.toString()}`); 889 890 const totalTokens = storage.next_token_id.toNumber(); 891 892 // For large collections, use TzKT API with pagination 893 // Only show recent tokens to avoid O(n) RPC calls 894 const MAX_DISPLAY = 10; 895 if (totalTokens > 0) { 896 console.log(`\n🎨 Recent Tokens (showing last ${Math.min(MAX_DISPLAY, totalTokens)} of ${totalTokens}):`); 897 898 // Fetch recent tokens via TzKT (efficient, paginated) 899 const tzktUrl = `https://api.${network}.tzkt.io/v1/contracts/${contractAddress}/bigmaps/token_metadata/keys?limit=${MAX_DISPLAY}&sort.desc=id`; 900 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 901 try { 902 const response = await fetch(tzktUrl); 903 if (response.ok) { 904 const tokens = await response.json(); 905 for (const token of tokens.reverse()) { 906 const tokenId = token.key; 907 const tokenInfo = token.value?.token_info || {}; 908 const name = tokenInfo.name ? Buffer.from(tokenInfo.name, 'hex').toString() : `#${tokenId}`; 909 // Check if metadata is locked (bigmap entry must exist AND be true) 910 const lockValue = await storage.metadata_locked?.get?.(parseInt(tokenId)); 911 const locked = lockValue === true ? ' 🔒' : ''; 912 const objktUrl = `${objktBase}/asset/${contractAddress}/${tokenId}`; 913 console.log(` [${tokenId}] ${name}${locked}`); 914 console.log(` 🔗 ${objktUrl}`); 915 } 916 } else { 917 console.log(' (Use TzKT explorer to view all tokens)'); 918 } 919 } catch (e) { 920 console.log(' (Could not fetch token list from TzKT)'); 921 } 922 923 if (totalTokens > MAX_DISPLAY) { 924 console.log(` ... and ${totalTokens - MAX_DISPLAY} more`); 925 } 926 } 927 928 return { address: contractAddress, storage }; 929 930 } catch (error) { 931 console.error(`\n❌ Failed to get contract status: ${error.message}`); 932 throw error; 933 } 934} 935 936// ============================================================================ 937// Multi-Chain Wallet Balances 938// ============================================================================ 939 940async function getAllWalletBalances() { 941 const walletsPath = path.join(CONFIG.paths.vault, 'wallets/wallets.json'); 942 if (!fs.existsSync(walletsPath)) { 943 console.error('❌ wallets.json not found in vault'); 944 process.exit(1); 945 } 946 const { wallets } = JSON.parse(fs.readFileSync(walletsPath, 'utf8')); 947 948 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 949 console.log('║ 🌐 All Wallet Balances ║'); 950 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 951 952 // Group wallets by chain 953 const chains = {}; 954 for (const [key, w] of Object.entries(wallets)) { 955 (chains[w.chain] ||= []).push({ key, ...w }); 956 } 957 958 // --- Tezos --- 959 if (chains.tezos) { 960 console.log('── Tezos ──────────────────────────────────────────────────────'); 961 const tezos = new TezosToolkit(CONFIG.mainnet.rpc); 962 for (const w of chains.tezos) { 963 try { 964 const bal = await tezos.tz.getBalance(w.address); 965 const xtz = (bal.toNumber() / 1_000_000).toFixed(6); 966 const label = w.domain || w.name; 967 console.log(` ${label.padEnd(22)} ${xtz.padStart(14)} XTZ ${w.address}`); 968 } catch (e) { 969 console.log(` ${(w.domain || w.name).padEnd(22)} ${'error'.padStart(14)} ${w.address} (${e.message})`); 970 } 971 } 972 // Also show keeps contract balance 973 try { 974 const contractAddr = fs.readFileSync( 975 CONFIG.paths.contractAddresses.mainnet, 'utf8' 976 ).trim(); 977 const bal = await tezos.tz.getBalance(contractAddr); 978 const xtz = (bal.toNumber() / 1_000_000).toFixed(6); 979 console.log(` ${'keeps contract'.padEnd(22)} ${xtz.padStart(14)} XTZ ${contractAddr}`); 980 } catch (e) { /* skip */ } 981 console.log(); 982 } 983 984 // --- Ethereum --- 985 if (chains.ethereum) { 986 console.log('── Ethereum ───────────────────────────────────────────────────'); 987 for (const w of chains.ethereum) { 988 try { 989 const resp = await fetch('https://ethereum-rpc.publicnode.com', { 990 method: 'POST', 991 headers: { 'Content-Type': 'application/json' }, 992 body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_getBalance', params: [w.address, 'latest'], id: 1 }), 993 }); 994 const data = await resp.json(); 995 const eth = (Number(BigInt(data.result)) / 1e18).toFixed(6); 996 const label = w.domain || w.name; 997 console.log(` ${label.padEnd(22)} ${eth.padStart(14)} ETH ${w.address}`); 998 } catch (e) { 999 console.log(` ${(w.domain || w.name).padEnd(22)} ${'error'.padStart(14)} ${w.address}`); 1000 } 1001 } 1002 console.log(); 1003 } 1004 1005 // --- Solana --- 1006 if (chains.solana) { 1007 console.log('── Solana ─────────────────────────────────────────────────────'); 1008 for (const w of chains.solana) { 1009 try { 1010 const resp = await fetch('https://solana-rpc.publicnode.com', { 1011 method: 'POST', 1012 headers: { 'Content-Type': 'application/json' }, 1013 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [w.address] }), 1014 }); 1015 const data = await resp.json(); 1016 const sol = (data.result.value / 1e9).toFixed(6); 1017 const label = w.name; 1018 console.log(` ${label.padEnd(22)} ${sol.padStart(14)} SOL ${w.address}`); 1019 } catch (e) { 1020 console.log(` ${(w.domain || w.name).padEnd(22)} ${'error'.padStart(14)} ${w.address}`); 1021 } 1022 } 1023 console.log(); 1024 } 1025 1026 // --- Bitcoin --- 1027 if (chains.bitcoin) { 1028 console.log('── Bitcoin ────────────────────────────────────────────────────'); 1029 for (const w of chains.bitcoin) { 1030 // Check both taproot and segwit addresses 1031 const addr = w.address_taproot || w.address_segwit || w.address; 1032 if (!addr) { 1033 console.log(` ${w.name.padEnd(22)} ${'no address'.padStart(14)} (needs derivation)`); 1034 continue; 1035 } 1036 try { 1037 const resp = await fetch(`https://blockstream.info/api/address/${addr}`); 1038 const data = await resp.json(); 1039 // funded = total received, spent = total sent; chain_stats for confirmed 1040 const confirmed = data.chain_stats || {}; 1041 const satoshis = (confirmed.funded_txo_sum || 0) - (confirmed.spent_txo_sum || 0); 1042 const btc = (satoshis / 1e8).toFixed(8); 1043 const label = w.name; 1044 console.log(` ${label.padEnd(22)} ${btc.padStart(14)} BTC ${addr}`); 1045 } catch (e) { 1046 console.log(` ${w.name.padEnd(22)} ${'error'.padStart(14)} ${addr}`); 1047 } 1048 } 1049 console.log(); 1050 } 1051 1052 // --- Cardano --- 1053 if (chains.cardano) { 1054 console.log('── Cardano ────────────────────────────────────────────────────'); 1055 for (const w of chains.cardano) { 1056 if (!w.address) { 1057 console.log(` ${w.name.padEnd(22)} ${'no address'.padStart(14)} (derive from mnemonic with cardano-serialization-lib)`); 1058 continue; 1059 } 1060 try { 1061 const resp = await fetch('https://api.koios.rest/api/v1/address_info', { 1062 method: 'POST', 1063 headers: { 'Content-Type': 'application/json' }, 1064 body: JSON.stringify({ _addresses: [w.address] }), 1065 }); 1066 const data = await resp.json(); 1067 const lovelace = data[0]?.balance || '0'; 1068 const ada = (Number(lovelace) / 1e6).toFixed(6); 1069 console.log(` ${w.name.padEnd(22)} ${ada.padStart(14)} ADA ${w.address}`); 1070 } catch (e) { 1071 console.log(` ${w.name.padEnd(22)} ${'error'.padStart(14)} ${w.address}`); 1072 } 1073 } 1074 console.log(); 1075 } 1076 1077 console.log('🔗 Explorers: tzkt.io | etherscan.io | solscan.io | blockstream.info | cardanoscan.io\n'); 1078} 1079 1080// ============================================================================ 1081// Wallet Balance 1082// ============================================================================ 1083 1084async function getBalance(network = 'mainnet') { 1085 const { tezos, credentials, config } = await createTezosClient(network); 1086 1087 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 1088 console.log('║ 💰 Wallet Balance ║'); 1089 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 1090 1091 console.log(`📡 Network: ${config.name}`); 1092 console.log(`👤 Address: ${credentials.address}\n`); 1093 1094 const balance = await tezos.tz.getBalance(credentials.address); 1095 const balanceXTZ = balance.toNumber() / 1_000_000; 1096 1097 console.log(`💵 Balance: ${balanceXTZ.toFixed(6)} XTZ`); 1098 console.log(`🔗 Explorer: ${config.explorer}/${credentials.address}\n`); 1099 1100 return { address: credentials.address, balance: balanceXTZ }; 1101} 1102 1103// ============================================================================ 1104// Content Type Detection 1105// ============================================================================ 1106 1107/** 1108 * Detect if a piece is kidlisp or JavaScript mjs 1109 * KidLisp pieces start with $ and are stored via the store-kidlisp API 1110 * JavaScript pieces exist as .mjs files in disks/ 1111 */ 1112async function detectContentType(piece) { 1113 const pieceName = piece.replace(/^\$/, ''); 1114 1115 // Check if it's a kidlisp piece (codes like 39j, abc, etc.) 1116 // KidLisp codes are 2-4 alphanumeric characters 1117 if (/^[a-z0-9]{2,4}$/i.test(pieceName)) { 1118 // Try to fetch from kidlisp API to confirm 1119 try { 1120 const response = await fetch(`https://aesthetic.computer/api/store-kidlisp?code=${pieceName}`); 1121 const data = await response.json(); 1122 if (data.source && !data.error) { 1123 return { type: 'kidlisp', source: data.source }; 1124 } 1125 } catch (e) { 1126 // Fall through to check mjs 1127 } 1128 } 1129 1130 // Check if it exists as an mjs piece 1131 try { 1132 const response = await fetch(`https://aesthetic.computer/aesthetic.computer/disks/${pieceName}.mjs`, { 1133 method: 'HEAD' 1134 }); 1135 if (response.ok) { 1136 return { type: 'mjs', source: null }; 1137 } 1138 } catch (e) { 1139 // Continue 1140 } 1141 1142 // Default to assuming it's a kidlisp code 1143 return { type: 'kidlisp', source: null }; 1144} 1145 1146// ============================================================================ 1147// Bundle Generation via Netlify Endpoint 1148// ============================================================================ 1149 1150/** 1151 * Fetch a proper self-contained HTML bundle from the Netlify endpoint 1152 * This bundles all JS, CSS, and assets into a single file that works offline 1153 */ 1154// Check if a piece name already exists in the contract 1155async function checkDuplicatePiece(pieceName, contractAddress, network = 'mainnet') { 1156 if (!contractAddress) { 1157 // Load contract address (network-specific) if not provided 1158 const addressPath = getContractAddressPath(network); 1159 if (!fs.existsSync(addressPath)) { 1160 return { exists: false }; // No contract deployed yet 1161 } 1162 contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 1163 } 1164 1165 // Query the content_hashes big_map via TzKT 1166 // Key is the piece name as hex bytes 1167 const keyBytes = stringToBytes(pieceName); 1168 // Use network-appropriate API endpoint 1169 const apiBase = network === 'mainnet' ? 'https://api.tzkt.io' : `https://api.${network}.tzkt.io`; 1170 const url = `${apiBase}/v1/contracts/${contractAddress}/bigmaps/content_hashes/keys/${keyBytes}`; 1171 1172 try { 1173 const response = await fetch(url); 1174 if (response.status === 200) { 1175 const data = await response.json(); 1176 // Check if the key is still active (not burned) 1177 if (data.active) { 1178 return { exists: true, tokenId: data.value }; 1179 } 1180 } 1181 return { exists: false }; 1182 } catch (error) { 1183 // If query fails, assume not duplicate (will fail at contract level if it is) 1184 return { exists: false }; 1185 } 1186} 1187 1188async function fetchBundleFromNetlify(piece, contentType) { 1189 const pieceName = piece.replace(/^\$/, ''); 1190 1191 console.log(`📦 Fetching bundle from Netlify (${contentType})...`); 1192 1193 // Use local dev server if --local flag is set or LOCAL_BUNDLE env var 1194 const useLocal = process.env.LOCAL_BUNDLE === '1' || process.argv.includes('--local'); 1195 const baseUrl = useLocal 1196 ? 'https://localhost:8888/api/bundle-html' 1197 : 'https://aesthetic.computer/api/bundle-html'; 1198 1199 // Build the correct endpoint URL based on content type 1200 let url; 1201 if (contentType === 'kidlisp') { 1202 url = `${baseUrl}?code=${pieceName}&format=json`; 1203 } else { 1204 url = `${baseUrl}?piece=${pieceName}&format=json`; 1205 } 1206 1207 console.log(` URL: ${url}${useLocal ? ' (local)' : ''}`); 1208 1209 let response; 1210 if (useLocal) { 1211 // For local dev server with self-signed cert, use https module directly 1212 const https = await import('https'); 1213 response = await new Promise((resolve, reject) => { 1214 const req = https.get(url, { rejectUnauthorized: false }, (res) => { 1215 let data = ''; 1216 res.on('data', chunk => data += chunk); 1217 res.on('end', () => { 1218 resolve({ 1219 ok: res.statusCode >= 200 && res.statusCode < 300, 1220 status: res.statusCode, 1221 json: () => Promise.resolve(JSON.parse(data)), 1222 text: () => Promise.resolve(data) 1223 }); 1224 }); 1225 }); 1226 req.on('error', reject); 1227 }); 1228 } else { 1229 response = await fetch(url); 1230 } 1231 1232 if (!response.ok) { 1233 const errorText = await response.text(); 1234 throw new Error(`Bundle generation failed: ${errorText}`); 1235 } 1236 1237 const data = await response.json(); 1238 1239 if (data.error) { 1240 throw new Error(`Bundle error: ${data.error}`); 1241 } 1242 1243 // Decode base64 content 1244 const html = Buffer.from(data.content, 'base64').toString('utf8'); 1245 1246 console.log(` ✓ Bundle received: ${data.sizeKB} KB`); 1247 console.log(` ✓ Filename: ${data.filename}`); 1248 if (data.sourceCode) { 1249 console.log(` ✓ Source lines: ${data.sourceCode.split('\n').length}`); 1250 } 1251 if (data.authorHandle) { 1252 console.log(` ✓ Author: ${data.authorHandle}`); 1253 } 1254 if (data.userCode) { 1255 console.log(` ✓ User: ${data.userCode}`); 1256 } 1257 if (data.depCount > 0) { 1258 console.log(` ✓ Dependencies: ${data.depCount}`); 1259 } 1260 1261 return { 1262 html, 1263 filename: data.filename, 1264 sizeKB: data.sizeKB, 1265 sourceCode: data.sourceCode, 1266 authorHandle: data.authorHandle, 1267 userCode: data.userCode, 1268 packDate: data.packDate, 1269 depCount: data.depCount, 1270 }; 1271} 1272 1273// ============================================================================ 1274// IPFS Upload 1275// ============================================================================ 1276 1277/** 1278 * Upload a JSON object to IPFS via Pinata 1279 */ 1280async function uploadJsonToIPFS(jsonData, name, credentials) { 1281 const jsonString = JSON.stringify(jsonData, null, 2); 1282 const blob = new Blob([jsonString], { type: 'application/json' }); 1283 1284 const formData = new FormData(); 1285 formData.append('file', blob, 'metadata.json'); 1286 formData.append('pinataMetadata', JSON.stringify({ name })); 1287 1288 const response = await fetch(`${CONFIG.pinata.apiUrl}/pinning/pinFileToIPFS`, { 1289 method: 'POST', 1290 headers: { 1291 'pinata_api_key': credentials.pinataKey, 1292 'pinata_secret_api_key': credentials.pinataSecret 1293 }, 1294 body: formData 1295 }); 1296 1297 if (!response.ok) { 1298 const error = await response.text(); 1299 throw new Error(`Pinata JSON upload failed: ${error}`); 1300 } 1301 1302 const result = await response.json(); 1303 return `ipfs://${result.IpfsHash}`; 1304} 1305 1306/** 1307 * Generate and upload thumbnail to IPFS via oven's grab-ipfs endpoint 1308 */ 1309async function generateThumbnail(piece, credentials, options = {}) { 1310 const { 1311 format = 'webp', 1312 width = 96, // Small thumbnail (was 512) 1313 height = 96, 1314 duration = 8000, // 8 seconds 1315 fps = 10, // 10fps capture 1316 playbackFps = 20, // 20fps playback = 2x speed 1317 density = 2, // 2x density for crisp pixels 1318 quality = 70, // Lower quality for smaller files 1319 keepId = null, // Tezos keep token ID for tracking 1320 } = options; 1321 1322 console.log('\n📸 Generating thumbnail...'); 1323 console.log(` Oven: ${CONFIG.oven.url}`); 1324 1325 // For local dev with self-signed certs, we need to disable cert verification 1326 const fetchOptions = { 1327 method: 'POST', 1328 headers: { 1329 'Content-Type': 'application/json', 1330 }, 1331 body: JSON.stringify({ 1332 piece, 1333 format, 1334 width, 1335 height, 1336 duration, 1337 fps, 1338 playbackFps, 1339 density, 1340 quality, 1341 pinataKey: credentials.pinataKey, 1342 pinataSecret: credentials.pinataSecret, 1343 source: 'keep', 1344 keepId, 1345 }), 1346 }; 1347 1348 // Disable SSL verification for localhost (self-signed certs) 1349 if (CONFIG.oven.url.includes('localhost')) { 1350 const https = await import('https'); 1351 fetchOptions.agent = new https.Agent({ rejectUnauthorized: false }); 1352 } 1353 1354 const response = await fetch(`${CONFIG.oven.url}/grab-ipfs`, fetchOptions); 1355 1356 if (!response.ok) { 1357 const error = await response.text(); 1358 throw new Error(`Thumbnail generation failed: ${error}`); 1359 } 1360 1361 const result = await response.json(); 1362 1363 if (!result.success) { 1364 throw new Error(`Thumbnail generation failed: ${result.error}`); 1365 } 1366 1367 console.log(` ✅ Thumbnail uploaded: ${result.ipfsUri}`); 1368 console.log(` Size: ${(result.size / 1024).toFixed(2)} KB`); 1369 1370 return { 1371 ipfsUri: result.ipfsUri, 1372 mimeType: result.mimeType, 1373 size: result.size, 1374 }; 1375} 1376 1377async function uploadToIPFS(piece, options = {}) { 1378 const credentials = loadCredentials(); 1379 1380 if (!credentials.pinataKey || !credentials.pinataSecret) { 1381 throw new Error('❌ Pinata credentials not found in vault'); 1382 } 1383 1384 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 1385 console.log('║ 📤 Uploading to IPFS via Pinata ║'); 1386 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 1387 1388 const pieceName = piece.replace(/^\$/, ''); 1389 console.log(`📦 Piece: ${pieceName}`); 1390 1391 // Detect content type if not provided 1392 let contentType = options.contentType; 1393 if (!contentType) { 1394 console.log('🔍 Detecting content type...'); 1395 const detection = await detectContentType(piece); 1396 contentType = detection.type; 1397 console.log(` ✓ Detected: ${contentType}`); 1398 } 1399 1400 // Get bundle from Netlify endpoint (proper self-contained bundle) 1401 let bundleHtml; 1402 let bundleMeta = {}; 1403 if (options.bundleHtml) { 1404 bundleHtml = options.bundleHtml; 1405 console.log(' Using provided bundle HTML'); 1406 } else { 1407 const bundle = await fetchBundleFromNetlify(piece, contentType); 1408 bundleHtml = bundle.html; 1409 bundleMeta = { 1410 sourceCode: bundle.sourceCode, 1411 authorHandle: bundle.authorHandle, 1412 userCode: bundle.userCode, 1413 packDate: bundle.packDate, 1414 depCount: bundle.depCount, 1415 }; 1416 } 1417 1418 // Use piece name as the unique identifier (simple and deterministic) 1419 console.log(`🔐 Piece ID: ${pieceName}`); 1420 1421 // Upload to Pinata 1422 const formData = new FormData(); 1423 const blob = new Blob([bundleHtml], { type: 'text/html' }); 1424 formData.append('file', blob, 'index.html'); 1425 1426 formData.append('pinataMetadata', JSON.stringify({ 1427 name: `aesthetic.computer-keep-${pieceName}` 1428 })); 1429 1430 formData.append('pinataOptions', JSON.stringify({ 1431 wrapWithDirectory: true 1432 })); 1433 1434 console.log('📤 Uploading to IPFS...'); 1435 1436 const response = await fetch(`${CONFIG.pinata.apiUrl}/pinning/pinFileToIPFS`, { 1437 method: 'POST', 1438 headers: { 1439 'pinata_api_key': credentials.pinataKey, 1440 'pinata_secret_api_key': credentials.pinataSecret 1441 }, 1442 body: formData 1443 }); 1444 1445 if (!response.ok) { 1446 const error = await response.text(); 1447 throw new Error(`Pinata upload failed: ${error}`); 1448 } 1449 1450 const result = await response.json(); 1451 const cid = result.IpfsHash; 1452 1453 console.log(`\n✅ Uploaded to IPFS!`); 1454 console.log(` CID: ${cid}`); 1455 console.log(` Gateway: ${CONFIG.pinata.gateway}/ipfs/${cid}`); 1456 console.log(` IPFS URI: ipfs://${cid}\n`); 1457 1458 return { 1459 cid, 1460 contentHash: pieceName, // Use piece name as unique key 1461 contentType, 1462 gatewayUrl: `${CONFIG.pinata.gateway}/ipfs/${cid}`, 1463 ipfsUri: `ipfs://${cid}`, 1464 // Bundle metadata for KidLisp pieces 1465 ...bundleMeta, 1466 }; 1467} 1468 1469// ============================================================================ 1470// Mint Token 1471// ============================================================================ 1472 1473// Helper to convert string to hex bytes (TZIP-21 format - raw UTF-8 hex, no pack prefix) 1474function stringToBytes(str) { 1475 return Buffer.from(str, 'utf8').toString('hex'); 1476} 1477 1478const KEEP_PERMIT_PAYLOAD_TYPE = { 1479 prim: 'pair', 1480 args: [ 1481 { prim: 'address', annots: ['%contract'] }, 1482 { 1483 prim: 'pair', 1484 args: [ 1485 { prim: 'address', annots: ['%owner'] }, 1486 { 1487 prim: 'pair', 1488 args: [ 1489 { prim: 'bytes', annots: ['%content_hash'] }, 1490 { prim: 'timestamp', annots: ['%permit_deadline'] }, 1491 ], 1492 }, 1493 ], 1494 }, 1495 ], 1496}; 1497 1498async function loadPermitSigner() { 1499 const connectionString = process.env.MONGODB_CONNECTION_STRING; 1500 const dbName = process.env.MONGODB_NAME; 1501 if (!connectionString || !dbName) return null; 1502 const client = new MongoClient(connectionString, { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000 }); 1503 try { 1504 await client.connect(); 1505 const secrets = await client.db(dbName).collection('secrets').findOne({ _id: KEEPS_SECRET_ID }); 1506 const privateKey = secrets?.keepPermitSignerPrivateKey || secrets?.keepPermitPrivateKey || secrets?.privateKey; 1507 if (!privateKey) return null; 1508 return new InMemorySigner(privateKey); 1509 } catch { 1510 return null; 1511 } finally { 1512 await client.close().catch(() => {}); 1513 } 1514} 1515 1516async function buildKeepPermit({ signer, contractAddress, owner, contentHashBytes, deadlineIso = null }) { 1517 if (!contractAddress || !owner || !contentHashBytes) { 1518 throw new Error('Missing keep permit fields (contractAddress, owner, contentHashBytes)'); 1519 } 1520 1521 const permitDeadline = deadlineIso || new Date(Date.now() + KEEP_PERMIT_TTL_MS).toISOString(); 1522 const payloadData = { 1523 prim: 'Pair', 1524 args: [ 1525 { string: contractAddress }, 1526 { 1527 prim: 'Pair', 1528 args: [ 1529 { string: owner }, 1530 { 1531 prim: 'Pair', 1532 args: [ 1533 { bytes: contentHashBytes }, 1534 { string: permitDeadline }, 1535 ], 1536 }, 1537 ], 1538 }, 1539 ], 1540 }; 1541 1542 const packed = packDataBytes(payloadData, KEEP_PERMIT_PAYLOAD_TYPE).bytes; 1543 const signature = await signer.sign(packed); 1544 1545 return { 1546 permit_deadline: permitDeadline, 1547 keep_permit: signature.prefixSig, 1548 }; 1549} 1550 1551async function mintToken(piece, options = {}) { 1552 const { network = 'mainnet', generateThumbnail: shouldGenerateThumbnail = false, recipient = null, skipConfirm = false } = options; 1553 1554 const { tezos, credentials, config } = await createTezosClient(network); 1555 1556 // Determine owner: recipient if specified, otherwise the server wallet 1557 const ownerAddress = recipient || credentials.address; 1558 const allCredentials = loadCredentials(); // For Pinata access 1559 1560 // Load contract address (network-specific) 1561 const addressPath = getContractAddressPath(network); 1562 if (!fs.existsSync(addressPath)) { 1563 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 1564 } 1565 1566 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 1567 const pieceName = piece.replace(/^\$/, ''); 1568 1569 // Fetch contract storage for status info 1570 const contract = await tezos.contract.at(contractAddress); 1571 const storage = await contract.storage(); 1572 const nextTokenId = storage.next_token_id.toNumber(); 1573 const keepFee = storage.keep_fee ? storage.keep_fee.toNumber() / 1_000_000 : 0; 1574 1575 // Fetch contract balance 1576 const contractBalance = await tezos.tz.getBalance(contractAddress); 1577 const contractBalanceXTZ = contractBalance.toNumber() / 1_000_000; 1578 1579 // Get objkt base URL 1580 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 1581 1582 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 1583 console.log('║ 📜 Keeping a KidLisp Piece ║'); 1584 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 1585 1586 console.log('Keep your KidLisp as a unique digital token.\n'); 1587 1588 console.log('─────────────────── Piece ───────────────────'); 1589 console.log(` Code: $${pieceName}`); 1590 console.log(` Preview: https://aesthetic.computer/$${pieceName}`); 1591 if (ownerAddress !== credentials.address) { 1592 console.log(` Recipient: ${ownerAddress}`); 1593 } 1594 console.log(` Thumbnail: ${shouldGenerateThumbnail ? 'Animated WebP (via Oven)' : 'Static PNG (HTTP grab)'}`); 1595 1596 console.log('\n─────────────────── Contract ───────────────────'); 1597 console.log(` Address: ${contractAddress}`); 1598 console.log(` Network: ${config.name}`); 1599 console.log(` Explorer: ${config.explorer}/${contractAddress}`); 1600 console.log(` Collection: ${objktBase}/collection/${contractAddress}`); 1601 console.log(` Admin: ${storage.administrator}`); 1602 console.log(` Balance: ${contractBalanceXTZ.toFixed(6)} XTZ`); 1603 console.log(` Keeps: ${nextTokenId} total`); 1604 console.log(` Next ID: #${nextTokenId}`); 1605 if (keepFee > 0) { 1606 console.log(` Keep Fee: ${keepFee} XTZ`); 1607 } 1608 1609 console.log('\n─────────────────── Wallet ───────────────────'); 1610 console.log(` Address: ${credentials.address}`); 1611 console.log(` Wallet: ${credentials.wallet || 'default'}`); 1612 const walletBalance = await tezos.tz.getBalance(credentials.address); 1613 console.log(` Balance: ${(walletBalance.toNumber() / 1_000_000).toFixed(6)} XTZ\n`); 1614 1615 // Confirmation prompt (unless skipped) 1616 if (!skipConfirm) { 1617 const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 1618 const answer = await new Promise(resolve => { 1619 rl.question('Keep this piece? (y/N): ', resolve); 1620 }); 1621 rl.close(); 1622 1623 if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { 1624 console.log('\n❌ Cancelled.\n'); 1625 process.exit(0); 1626 } 1627 console.log(''); 1628 } 1629 1630 // Check for duplicate BEFORE uploading to IPFS 1631 console.log('🔍 Checking for duplicates on-chain...'); 1632 const duplicate = await checkDuplicatePiece(pieceName, contractAddress, network); 1633 if (duplicate.exists) { 1634 throw new Error(`Duplicate! $${pieceName} was already kept as token #${duplicate.tokenId}`); 1635 } 1636 console.log(' ✓ No duplicate found'); 1637 1638 // Detect content type 1639 let contentType = options.contentType; 1640 if (!contentType) { 1641 console.log('🔍 Detecting content type...'); 1642 const detection = await detectContentType(piece); 1643 contentType = detection.type; 1644 console.log(` ✓ Detected: ${contentType}`); 1645 } 1646 console.log(`📁 Content Type: ${contentType}`); 1647 1648 // Upload HTML bundle to IPFS if not provided 1649 let artifactUri = options.ipfsUri; 1650 let artifactCid = options.contentHash; 1651 let contentHash = null; // Source-based hash for duplicate prevention 1652 let sourceCode = null; 1653 let authorHandle = null; 1654 let userCode = null; 1655 let packDate = null; 1656 let depCount = 0; 1657 1658 if (!artifactUri) { 1659 console.log('\n📤 Uploading HTML bundle to IPFS...'); 1660 const upload = await uploadToIPFS(piece, { contentType }); 1661 artifactUri = `ipfs://${upload.cid}`; // Use ipfs:// URI for artifact 1662 artifactCid = upload.cid; 1663 contentHash = upload.contentHash; // Source-based hash for uniqueness 1664 contentType = upload.contentType; 1665 // Capture bundle metadata for KidLisp pieces 1666 sourceCode = upload.sourceCode; 1667 authorHandle = upload.authorHandle; 1668 userCode = upload.userCode; 1669 packDate = upload.packDate; 1670 depCount = upload.depCount || 0; 1671 } 1672 1673 console.log(`🔗 Artifact URI: ${artifactUri}`); 1674 console.log(`💾 Artifact CID: ${artifactCid}`); 1675 console.log(`🔐 Content Hash: ${contentHash}`); 1676 1677 // Build TZIP-21 compliant JSON metadata for objkt 1678 // Token name is just the code (e.g., "$roz") 1679 const tokenName = `$${pieceName}`; 1680 const acUrl = `https://aesthetic.computer/$${pieceName}`; 1681 1682 // Build author display name for attributes 1683 let authorDisplayName = null; 1684 if (authorHandle && authorHandle !== '@anon') { 1685 authorDisplayName = authorHandle; 1686 } 1687 1688 // Description is ONLY the KidLisp source code (clean and simple) 1689 const description = sourceCode || `A KidLisp piece preserved on Tezos`; 1690 1691 // v9 metadata policy: single canonical tag only 1692 const tags = ['KidLisp']; 1693 1694 // Generate and upload thumbnail to IPFS if requested 1695 let thumbnailUri = `https://grab.aesthetic.computer/preview/400x400/$${pieceName}.png`; // HTTP fallback 1696 let thumbnailMimeType = 'image/png'; 1697 1698 if (shouldGenerateThumbnail) { 1699 try { 1700 const thumbnail = await generateThumbnail(piece, allCredentials, { 1701 format: 'webp', 1702 width: 512, 1703 height: 512, 1704 duration: 12000, 1705 fps: 7.5, 1706 playbackFps: 15, 1707 quality: 90, 1708 }); 1709 thumbnailUri = thumbnail.ipfsUri; 1710 thumbnailMimeType = thumbnail.mimeType; 1711 } catch (error) { 1712 console.warn(` ⚠️ Thumbnail generation failed: ${error.message}`); 1713 console.warn(` Using HTTP fallback: ${thumbnailUri}`); 1714 } 1715 } 1716 1717 // creators array contains just the wallet address for on-chain attribution 1718 // objkt.com uses firstMinter for artist display 1719 const creatorsArray = [credentials.address]; 1720 1721 const metadataJson = { 1722 name: tokenName, 1723 description: description, 1724 artifactUri: artifactUri, 1725 displayUri: artifactUri, 1726 thumbnailUri: thumbnailUri, 1727 decimals: 0, 1728 symbol: 'KEEP', 1729 isBooleanAmount: true, 1730 shouldPreferSymbol: false, 1731 minter: authorHandle || credentials.address, 1732 creators: creatorsArray, 1733 rights: '© All rights reserved', 1734 mintingTool: 'https://kidlisp.com', 1735 formats: [{ 1736 uri: artifactUri, 1737 mimeType: 'text/html', 1738 dimensions: { value: 'responsive', unit: 'viewport' } 1739 }], 1740 tags: tags, 1741 attributes: [ 1742 { name: 'Language', value: 'KidLisp' }, 1743 { name: 'Code', value: `$${pieceName}` }, 1744 ...(authorDisplayName ? [{ name: 'Author', value: authorDisplayName }] : []), 1745 ...(userCode ? [{ name: 'User', value: userCode }] : []), 1746 ...(sourceCode ? [{ name: 'Lines of Code', value: String(sourceCode.split('\n').length) }] : []), 1747 ...(packDate ? [{ name: 'Packed on', value: packDate }] : []), 1748 { name: 'Interactive', value: 'Yes' }, 1749 { name: 'Platform', value: 'Aesthetic Computer' }, 1750 ] 1751 }; 1752 1753 // Upload JSON metadata to IPFS 1754 console.log('\n📤 Uploading JSON metadata to IPFS...'); 1755 const metadataUri = await uploadJsonToIPFS( 1756 metadataJson, 1757 `aesthetic.computer-keep-${pieceName}-metadata`, 1758 allCredentials 1759 ); 1760 console.log(`📋 Metadata URI: ${metadataUri}`); 1761 1762 // For the contract, we only need to store the metadata URI in the "" key 1763 // All other fields will be fetched by indexers from the JSON 1764 const onChainMetadata = { 1765 name: stringToBytes(tokenName), 1766 description: stringToBytes(description), 1767 artifactUri: stringToBytes(artifactUri), 1768 displayUri: stringToBytes(artifactUri), 1769 thumbnailUri: stringToBytes(thumbnailUri), 1770 decimals: stringToBytes('0'), 1771 symbol: stringToBytes('KEEP'), 1772 isBooleanAmount: stringToBytes('true'), 1773 shouldPreferSymbol: stringToBytes('false'), 1774 formats: stringToBytes(JSON.stringify(metadataJson.formats)), 1775 tags: stringToBytes(JSON.stringify(metadataJson.tags)), 1776 attributes: stringToBytes(JSON.stringify(metadataJson.attributes)), 1777 creators: stringToBytes(JSON.stringify(creatorsArray)), 1778 rights: stringToBytes('© All rights reserved'), 1779 content_type: stringToBytes('KidLisp'), 1780 content_hash: stringToBytes(contentHash), // Source-based hash for uniqueness 1781 // IMPORTANT: This is the off-chain metadata URI that objkt will fetch 1782 metadata_uri: stringToBytes(metadataUri), 1783 }; 1784 1785 // Call keep entrypoint 1786 console.log('\n📤 Preserving on Tezos blockchain...'); 1787 1788 try { 1789 // Use the backend permit signer key (from MongoDB), not the wallet key 1790 const permitSigner = await loadPermitSigner() || tezos.signer; 1791 const keepPermit = await buildKeepPermit({ 1792 signer: permitSigner, 1793 contractAddress, 1794 owner: ownerAddress, 1795 contentHashBytes: onChainMetadata.content_hash, 1796 }); 1797 1798 // Build royalties JSON (objkt standard: decimals 4, shares in bps) 1799 const storage = await contract.storage(); 1800 const artistBps = storage.artist_royalty_bps ? storage.artist_royalty_bps.toNumber() : (storage.default_royalty_bps ? storage.default_royalty_bps.toNumber() : 1000); 1801 const platformBps = storage.platform_royalty_bps ? storage.platform_royalty_bps.toNumber() : 0; 1802 const treasuryAddr = storage.treasury_address || null; 1803 const royaltiesObj = { decimals: 4, shares: { [ownerAddress]: String(artistBps) } }; 1804 if (platformBps > 0 && treasuryAddr) royaltiesObj.shares[treasuryAddr] = String(platformBps); 1805 const royaltiesBytes = stringToBytes(JSON.stringify(royaltiesObj)); 1806 1807 const op = await contract.methodsObject.keep({ 1808 artifactUri: onChainMetadata.artifactUri, 1809 content_hash: onChainMetadata.content_hash, 1810 creators: onChainMetadata.creators, 1811 decimals: onChainMetadata.decimals, 1812 description: onChainMetadata.description, 1813 displayUri: onChainMetadata.displayUri, 1814 metadata_uri: onChainMetadata.metadata_uri, 1815 name: onChainMetadata.name, 1816 owner: ownerAddress, 1817 royalties: royaltiesBytes, 1818 symbol: onChainMetadata.symbol, 1819 thumbnailUri: onChainMetadata.thumbnailUri, 1820 permit_deadline: keepPermit.permit_deadline, 1821 keep_permit: keepPermit.keep_permit, 1822 }).send(); 1823 1824 console.log(` ⏳ Operation hash: ${op.hash}`); 1825 console.log(' ⏳ Waiting for confirmation...'); 1826 1827 await op.confirmation(1); 1828 1829 // Get the token ID from contract storage (next_token_id - 1) 1830 // This is O(1) and scales to millions of tokens 1831 const updatedStorage = await contract.storage(); 1832 const tokenId = updatedStorage.next_token_id.toNumber() - 1; 1833 1834 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 1835 console.log('║ ✅ Piece Kept Successfully! ║'); 1836 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 1837 1838 console.log(` 🎨 Token ID: #${tokenId}`); 1839 console.log(` 📦 Piece: $${pieceName}`); 1840 console.log(` 🔗 Artifact: ${artifactUri}`); 1841 console.log(` 📝 Operation: ${config.explorer}/${op.hash}`); 1842 console.log(` 🖼️ View on Objkt: ${objktBase}/asset/${contractAddress}/${tokenId}\n`); 1843 1844 return { tokenId, hash: op.hash, artifactUri }; 1845 1846 } catch (error) { 1847 console.error('\n❌ Keep failed!'); 1848 console.error(` Error: ${error.message}`); 1849 throw error; 1850 } 1851} 1852 1853// ============================================================================ 1854// Update Metadata 1855// ============================================================================ 1856 1857async function updateMetadata(tokenId, piece, options = {}) { 1858 const { network = 'mainnet', generateThumbnail: shouldGenerateThumbnail = false } = options; 1859 1860 const { tezos, credentials, config } = await createTezosClient(network); 1861 const allCredentials = loadCredentials(); // For Pinata access 1862 1863 // Load contract address (network-specific) 1864 const addressPath = getContractAddressPath(network); 1865 if (!fs.existsSync(addressPath)) { 1866 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 1867 } 1868 1869 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 1870 const pieceName = piece.replace(/^\$/, ''); 1871 const acUrl = `https://aesthetic.computer/$${pieceName}`; 1872 1873 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 1874 console.log('║ 🔄 Updating Token Metadata ║'); 1875 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 1876 1877 console.log(`📡 Network: ${config.name}`); 1878 console.log(`📍 Contract: ${contractAddress}`); 1879 console.log(`🎨 Token ID: ${tokenId}`); 1880 console.log(`📦 Piece: ${pieceName}`); 1881 1882 // Detect content type 1883 console.log('🔍 Detecting content type...'); 1884 const detection = await detectContentType(piece); 1885 const contentType = detection.type; 1886 console.log(` ✓ Detected: ${contentType}`); 1887 1888 // Upload new bundle to IPFS (skip duplicate check since we're updating) 1889 console.log('\n📤 Uploading new bundle to IPFS...'); 1890 const upload = await uploadToIPFS(piece, { contentType, skipDuplicateCheck: true }); 1891 const artifactUri = `ipfs://${upload.cid}`; 1892 const artifactCid = upload.cid; 1893 1894 // Get bundle metadata 1895 const sourceCode = upload.sourceCode; 1896 const authorHandle = upload.authorHandle; 1897 const userCode = upload.userCode; 1898 const packDate = upload.packDate; 1899 const depCount = upload.depCount || 0; 1900 1901 console.log(`🔗 New Artifact URI: ${artifactUri}`); 1902 1903 // Build author display name for attributes 1904 let authorDisplayName = null; 1905 if (authorHandle && authorHandle !== '@anon') { 1906 authorDisplayName = authorHandle; 1907 } 1908 1909 // Description is ONLY the KidLisp source code (clean and simple) 1910 const description = sourceCode || `A KidLisp piece preserved on Tezos`; 1911 1912 // v9 metadata policy: single canonical tag only 1913 const tags = ['KidLisp']; 1914 1915 // Build improved attributes 1916 const attributes = [ 1917 { name: 'Language', value: 'KidLisp' }, 1918 { name: 'Code', value: `$${pieceName}` }, 1919 ...(authorDisplayName ? [{ name: 'Author', value: authorDisplayName }] : []), 1920 ...(userCode ? [{ name: 'User', value: userCode }] : []), 1921 ...(sourceCode ? [{ name: 'Lines of Code', value: String(sourceCode.split('\n').length) }] : []), 1922 ...(packDate ? [{ name: 'Packed on', value: packDate }] : []), 1923 { name: 'Interactive', value: 'Yes' }, 1924 { name: 'Platform', value: 'Aesthetic Computer' }, 1925 ]; 1926 1927 // Preserve the ORIGINAL creator from firstMinter on TzKT 1928 // This ensures artist attribution is maintained on updates 1929 let originalCreator = credentials.address; // fallback to current wallet 1930 try { 1931 const tzktBase = network === 'mainnet' ? 'https://api.tzkt.io' : `https://api.${network}.tzkt.io`; 1932 const tokenUrl = `${tzktBase}/v1/tokens?contract=${contractAddress}&tokenId=${tokenId}`; 1933 const tokenResponse = await fetch(tokenUrl); 1934 if (tokenResponse.ok) { 1935 const tokens = await tokenResponse.json(); 1936 if (tokens[0]?.firstMinter?.address) { 1937 originalCreator = tokens[0].firstMinter.address; 1938 console.log(` ✓ Preserving original creator: ${originalCreator}`); 1939 } 1940 } 1941 } catch (e) { 1942 console.warn(` ⚠ Could not fetch original creator, using current wallet`); 1943 } 1944 1945 const creatorsArray = [originalCreator]; 1946 1947 // Generate thumbnail via oven if requested, otherwise use HTTP fallback 1948 let thumbnailUri = `https://grab.aesthetic.computer/preview/400x400/$${pieceName}.png`; 1949 1950 if (shouldGenerateThumbnail) { 1951 try { 1952 const thumbnail = await generateThumbnail(pieceName, allCredentials, { 1953 width: 256, 1954 height: 256, 1955 duration: 8000, 1956 fps: 10, 1957 playbackFps: 20, 1958 density: 2, 1959 quality: 70, 1960 }); 1961 thumbnailUri = thumbnail.ipfsUri; 1962 console.log(` 🖼️ Thumbnail: ${thumbnailUri}`); 1963 } catch (err) { 1964 console.warn(` ⚠ Thumbnail generation failed, using HTTP fallback: ${err.message}`); 1965 } 1966 } 1967 1968 // Build metadata JSON for IPFS 1969 const tokenName = `$${pieceName}`; 1970 const metadataJson = { 1971 name: tokenName, 1972 description: description, 1973 artifactUri: artifactUri, 1974 displayUri: artifactUri, 1975 thumbnailUri: thumbnailUri, 1976 decimals: 0, 1977 symbol: 'KEEP', 1978 isBooleanAmount: true, 1979 shouldPreferSymbol: false, 1980 minter: authorHandle || credentials.address, 1981 creators: creatorsArray, 1982 rights: '© All rights reserved', 1983 mintingTool: 'https://kidlisp.com', 1984 formats: [{ 1985 uri: artifactUri, 1986 mimeType: 'text/html', 1987 dimensions: { value: 'responsive', unit: 'viewport' } 1988 }], 1989 tags: tags, 1990 attributes: attributes 1991 }; 1992 1993 // Upload JSON metadata to IPFS 1994 console.log('\n📤 Uploading JSON metadata to IPFS...'); 1995 const metadataUri = await uploadJsonToIPFS( 1996 metadataJson, 1997 `aesthetic.computer-keep-${pieceName}-metadata-updated`, 1998 allCredentials 1999 ); 2000 console.log(`📋 Metadata URI: ${metadataUri}`); 2001 2002 // Build on-chain token_info 2003 const tokenInfo = { 2004 name: stringToBytes(tokenName), 2005 description: stringToBytes(description), 2006 artifactUri: stringToBytes(artifactUri), 2007 displayUri: stringToBytes(artifactUri), 2008 thumbnailUri: stringToBytes(metadataJson.thumbnailUri), 2009 decimals: stringToBytes('0'), 2010 symbol: stringToBytes('KEEP'), 2011 isBooleanAmount: stringToBytes('true'), 2012 shouldPreferSymbol: stringToBytes('false'), 2013 formats: stringToBytes(JSON.stringify(metadataJson.formats)), 2014 tags: stringToBytes(JSON.stringify(tags)), 2015 attributes: stringToBytes(JSON.stringify(attributes)), 2016 creators: stringToBytes(JSON.stringify(creatorsArray)), 2017 rights: stringToBytes('© All rights reserved'), 2018 content_type: stringToBytes('KidLisp'), 2019 content_hash: stringToBytes(pieceName), 2020 '': stringToBytes(metadataUri) 2021 }; 2022 2023 // Call edit_metadata entrypoint 2024 console.log('\n📤 Calling edit_metadata entrypoint...'); 2025 2026 try { 2027 const contract = await tezos.contract.at(contractAddress); 2028 2029 const op = await contract.methodsObject.edit_metadata({ 2030 token_id: tokenId, 2031 token_info: tokenInfo 2032 }).send(); 2033 2034 console.log(` ⏳ Operation hash: ${op.hash}`); 2035 console.log(' ⏳ Waiting for confirmation...'); 2036 2037 await op.confirmation(1); 2038 2039 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2040 console.log('║ ✅ Metadata Updated Successfully! ║'); 2041 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2042 2043 console.log(` 🎨 Token ID: ${tokenId}`); 2044 console.log(` 🔗 New Artifact: ${artifactUri}`); 2045 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2046 2047 return { tokenId, hash: op.hash, artifactUri }; 2048 2049 } catch (error) { 2050 console.error('\n❌ Update failed!'); 2051 console.error(` Error: ${error.message}`); 2052 if (error.message.includes('METADATA_LOCKED')) { 2053 console.error('\n 💡 This token\'s metadata has been locked and cannot be updated.'); 2054 } 2055 throw error; 2056 } 2057} 2058 2059// ============================================================================ 2060// Redact Token (Censor) 2061// ============================================================================ 2062 2063async function redactToken(tokenId, options = {}) { 2064 const { network = 'mainnet', reason = 'Content has been redacted.' } = options; 2065 2066 const { tezos, credentials, config } = await createTezosClient(network); 2067 const allCredentials = loadCredentials(); 2068 2069 // Load contract address (network-specific) 2070 const addressPath = getContractAddressPath(network); 2071 if (!fs.existsSync(addressPath)) { 2072 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2073 } 2074 2075 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2076 2077 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2078 console.log('║ 🚫 Redacting Token ║'); 2079 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2080 2081 console.log(`📡 Network: ${config.name}`); 2082 console.log(`📍 Contract: ${contractAddress}`); 2083 console.log(`🎨 Token ID: ${tokenId}`); 2084 console.log(`📝 Reason: ${reason}`); 2085 console.log('\n⚠️ This will replace all content with a redacted placeholder.\n'); 2086 2087 // Generate a red "REDACTED" image 2088 console.log('🖼️ Generating redacted thumbnail...'); 2089 2090 // Create a simple red HTML page for the artifact 2091 const redactedHtml = `<!DOCTYPE html> 2092<html> 2093<head> 2094 <meta charset="utf-8"> 2095 <meta name="viewport" content="width=device-width, initial-scale=1"> 2096 <title>REDACTED</title> 2097 <style> 2098 * { margin: 0; padding: 0; box-sizing: border-box; } 2099 body { 2100 background: #1a0000; 2101 color: #ff0000; 2102 font-family: monospace; 2103 display: flex; 2104 align-items: center; 2105 justify-content: center; 2106 min-height: 100vh; 2107 text-align: center; 2108 } 2109 .container { 2110 padding: 2rem; 2111 } 2112 h1 { 2113 font-size: 3rem; 2114 letter-spacing: 0.5em; 2115 margin-bottom: 1rem; 2116 text-shadow: 0 0 20px #ff0000; 2117 } 2118 p { 2119 font-size: 1rem; 2120 opacity: 0.7; 2121 } 2122 .bars { 2123 display: flex; 2124 flex-direction: column; 2125 gap: 8px; 2126 margin-top: 2rem; 2127 } 2128 .bar { 2129 height: 20px; 2130 background: #ff0000; 2131 opacity: 0.3; 2132 } 2133 .bar:nth-child(1) { width: 80%; } 2134 .bar:nth-child(2) { width: 60%; } 2135 .bar:nth-child(3) { width: 90%; } 2136 .bar:nth-child(4) { width: 45%; } 2137 </style> 2138</head> 2139<body> 2140 <div class="container"> 2141 <h1>REDACTED</h1> 2142 <p>${reason}</p> 2143 <div class="bars"> 2144 <div class="bar"></div> 2145 <div class="bar"></div> 2146 <div class="bar"></div> 2147 <div class="bar"></div> 2148 </div> 2149 </div> 2150</body> 2151</html>`; 2152 2153 // Upload redacted HTML to IPFS 2154 console.log('📤 Uploading redacted artifact to IPFS...'); 2155 2156 const formData = new FormData(); 2157 const blob = new Blob([redactedHtml], { type: 'text/html' }); 2158 formData.append('file', blob, 'index.html'); 2159 formData.append('pinataMetadata', JSON.stringify({ 2160 name: `aesthetic.computer-redacted-${tokenId}` 2161 })); 2162 formData.append('pinataOptions', JSON.stringify({ 2163 wrapWithDirectory: true 2164 })); 2165 2166 const uploadResponse = await fetch(`${CONFIG.pinata.apiUrl}/pinning/pinFileToIPFS`, { 2167 method: 'POST', 2168 headers: { 2169 'pinata_api_key': allCredentials.pinataKey, 2170 'pinata_secret_api_key': allCredentials.pinataSecret 2171 }, 2172 body: formData 2173 }); 2174 2175 if (!uploadResponse.ok) { 2176 throw new Error(`Failed to upload redacted artifact: ${await uploadResponse.text()}`); 2177 } 2178 2179 const uploadResult = await uploadResponse.json(); 2180 const artifactCid = uploadResult.IpfsHash; 2181 const artifactUri = `ipfs://${artifactCid}`; 2182 console.log(` ✓ Artifact: ${artifactUri}`); 2183 2184 // Generate red thumbnail via Oven 2185 console.log('📸 Generating redacted thumbnail via Oven...'); 2186 let thumbnailUri = 'https://grab.aesthetic.computer/preview/400x400/redacted.png'; 2187 2188 try { 2189 // Use oven to capture the redacted page 2190 const ovenResponse = await fetch(`${CONFIG.oven.url}/grab-ipfs`, { 2191 method: 'POST', 2192 headers: { 'Content-Type': 'application/json' }, 2193 body: JSON.stringify({ 2194 url: `${CONFIG.pinata.gateway}/ipfs/${artifactCid}`, 2195 format: 'webp', 2196 width: 96, 2197 height: 96, 2198 density: 2, 2199 duration: 1000, 2200 fps: 1, 2201 quality: 80, 2202 pinataKey: allCredentials.pinataKey, 2203 pinataSecret: allCredentials.pinataSecret, 2204 }), 2205 }); 2206 2207 if (ovenResponse.ok) { 2208 const ovenResult = await ovenResponse.json(); 2209 if (ovenResult.success) { 2210 thumbnailUri = ovenResult.ipfsUri; 2211 console.log(` ✓ Thumbnail: ${thumbnailUri}`); 2212 } 2213 } 2214 } catch (e) { 2215 console.log(` ⚠️ Thumbnail generation failed, using fallback`); 2216 } 2217 2218 // Build redacted metadata 2219 const tokenName = '[REDACTED]'; 2220 const description = `[REDACTED]\n\n${reason}`; 2221 2222 const tags = ['REDACTED', 'censored']; 2223 const attributes = [ 2224 { name: 'Status', value: 'REDACTED' }, 2225 { name: 'Reason', value: reason }, 2226 { name: 'Platform', value: 'Aesthetic Computer' }, 2227 ]; 2228 2229 // For redacted content, use admin wallet as creator (censorship action) 2230 const creatorsArray = [credentials.address]; 2231 2232 // Upload metadata JSON 2233 const metadataJson = { 2234 name: tokenName, 2235 description: description, 2236 artifactUri: artifactUri, 2237 displayUri: artifactUri, 2238 thumbnailUri: thumbnailUri, 2239 decimals: 0, 2240 symbol: 'KEEP', 2241 isBooleanAmount: true, 2242 shouldPreferSymbol: false, 2243 minter: '@aesthetic', 2244 creators: creatorsArray, 2245 rights: '© All rights reserved', 2246 mintingTool: 'https://kidlisp.com', 2247 formats: [{ 2248 uri: artifactUri, 2249 mimeType: 'text/html', 2250 dimensions: { value: 'responsive', unit: 'viewport' } 2251 }], 2252 tags: tags, 2253 attributes: attributes 2254 }; 2255 2256 console.log('📤 Uploading redacted metadata to IPFS...'); 2257 const metadataUri = await uploadJsonToIPFS( 2258 metadataJson, 2259 `aesthetic.computer-redacted-${tokenId}-metadata`, 2260 allCredentials 2261 ); 2262 console.log(` ✓ Metadata: ${metadataUri}`); 2263 2264 // Build on-chain token_info 2265 const tokenInfo = { 2266 name: stringToBytes(tokenName), 2267 description: stringToBytes(description), 2268 artifactUri: stringToBytes(artifactUri), 2269 displayUri: stringToBytes(artifactUri), 2270 thumbnailUri: stringToBytes(thumbnailUri), 2271 decimals: stringToBytes('0'), 2272 symbol: stringToBytes('KEEP'), 2273 isBooleanAmount: stringToBytes('true'), 2274 shouldPreferSymbol: stringToBytes('false'), 2275 formats: stringToBytes(JSON.stringify(metadataJson.formats)), 2276 tags: stringToBytes(JSON.stringify(tags)), 2277 attributes: stringToBytes(JSON.stringify(attributes)), 2278 creators: stringToBytes(JSON.stringify(creatorsArray)), 2279 rights: stringToBytes('© All rights reserved'), 2280 content_type: stringToBytes('REDACTED'), 2281 content_hash: stringToBytes('REDACTED'), 2282 '': stringToBytes(metadataUri) 2283 }; 2284 2285 // Call edit_metadata entrypoint 2286 console.log('\n📤 Calling edit_metadata entrypoint...'); 2287 2288 try { 2289 const contract = await tezos.contract.at(contractAddress); 2290 2291 const op = await contract.methodsObject.edit_metadata({ 2292 token_id: tokenId, 2293 token_info: tokenInfo 2294 }).send(); 2295 2296 console.log(` ⏳ Operation hash: ${op.hash}`); 2297 console.log(' ⏳ Waiting for confirmation...'); 2298 2299 await op.confirmation(1); 2300 2301 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2302 console.log('║ 🚫 Token Redacted Successfully! ║'); 2303 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2304 2305 console.log(` 🎨 Token ID: ${tokenId}`); 2306 console.log(` 🚫 Status: REDACTED`); 2307 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2308 2309 return { tokenId, hash: op.hash, redacted: true }; 2310 2311 } catch (error) { 2312 console.error('\n❌ Redaction failed!'); 2313 console.error(` Error: ${error.message}`); 2314 if (error.message.includes('METADATA_LOCKED')) { 2315 console.error('\n 💡 This token\'s metadata has been locked and cannot be redacted.'); 2316 } 2317 throw error; 2318 } 2319} 2320 2321// ============================================================================ 2322// Set Collection Media (Contract-level Metadata) 2323// ============================================================================ 2324 2325async function setCollectionMedia(options = {}) { 2326 const { 2327 network = 'mainnet', 2328 name, // Collection name 2329 imageUri, // Collection icon/logo (IPFS URI or URL) 2330 homepage, // Collection homepage URL 2331 description, // Collection description 2332 raw = {} // Raw key-value pairs to set 2333 } = options; 2334 2335 const { tezos, credentials, config } = await createTezosClient(network); 2336 2337 // Load contract address (network-specific) 2338 const addressPath = getContractAddressPath(network); 2339 if (!fs.existsSync(addressPath)) { 2340 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2341 } 2342 2343 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2344 2345 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2346 console.log('║ 🎨 Setting Collection Media ║'); 2347 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2348 2349 console.log(`📡 Network: ${config.name}`); 2350 console.log(`📍 Contract: ${contractAddress}\n`); 2351 2352 // Build the metadata updates 2353 const updates = []; 2354 const contract = await tezos.contract.at(contractAddress); 2355 2356 // Load existing contract metadata so partial updates don't wipe fields. 2357 let existingMetadata = {}; 2358 try { 2359 const storage = await contract.storage(); 2360 const existingContent = await storage.metadata.get('content'); 2361 const decoded = decodeContractMetadataBytes(existingContent); 2362 if (decoded && typeof decoded === 'object') { 2363 existingMetadata = decoded; 2364 } 2365 } catch (error) { 2366 console.warn(` ⚠️ Could not read existing metadata, using defaults: ${error.message}`); 2367 } 2368 2369 // Build new contract metadata JSON (merge existing + updates) 2370 const currentMetadata = { 2371 ...existingMetadata, 2372 name: name || existingMetadata.name || "KidLisp Keeps", 2373 version: existingMetadata.version || "2.0.0", 2374 interfaces: existingMetadata.interfaces || ["TZIP-012", "TZIP-016", "TZIP-021"], 2375 authors: existingMetadata.authors || ["aesthetic.computer"], 2376 homepage: homepage || existingMetadata.homepage || "https://keep.kidlisp.com" 2377 }; 2378 2379 if (options.name) { 2380 console.log(` 📛 Name: ${options.name}`); 2381 } 2382 2383 if (imageUri) { 2384 currentMetadata.imageUri = imageUri; 2385 currentMetadata.thumbnailUri = imageUri; 2386 console.log(` 🖼️ Image URI: ${imageUri}`); 2387 } 2388 2389 if (description) { 2390 currentMetadata.description = description; 2391 console.log(` 📝 Description: ${description.substring(0, 80)}...`); 2392 } 2393 2394 if (homepage) { 2395 console.log(` 🏠 Homepage: ${homepage}`); 2396 } 2397 2398 // Add any raw fields 2399 for (const [key, value] of Object.entries(raw)) { 2400 currentMetadata[key] = value; 2401 console.log(` 📦 ${key}: ${String(value).substring(0, 50)}`); 2402 } 2403 2404 // Update the "content" key with new metadata JSON 2405 const metadataJson = JSON.stringify(currentMetadata); 2406 const metadataBytes = stringToBytes(metadataJson); 2407 2408 updates.push({ key: 'content', value: metadataBytes }); 2409 2410 console.log(`\n📤 Updating contract metadata...`); 2411 2412 try { 2413 // Format for set_contract_metadata: list of { key: string, value: bytes } 2414 // Bytes must be hex string prefixed with 0x for Taquito 2415 const params = updates.map(u => ({ 2416 key: u.key, 2417 value: '0x' + u.value 2418 })); 2419 2420 const op = await contract.methods.set_contract_metadata(params).send(); 2421 2422 console.log(` ⏳ Operation hash: ${op.hash}`); 2423 console.log(' ⏳ Waiting for confirmation...'); 2424 2425 await op.confirmation(1); 2426 2427 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2428 console.log('║ ✅ Collection Media Updated! ║'); 2429 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2430 2431 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2432 2433 return { hash: op.hash, metadata: currentMetadata }; 2434 2435 } catch (error) { 2436 console.error('\n❌ Update failed!'); 2437 console.error(` Error: ${error.message}`); 2438 throw error; 2439 } 2440} 2441 2442// ============================================================================ 2443// Lock Collection Metadata 2444// ============================================================================ 2445 2446async function lockCollectionMetadata(options = {}) { 2447 const { network = 'mainnet' } = options; 2448 2449 const { tezos, credentials, config } = await createTezosClient(network); 2450 2451 // Load contract address (network-specific) 2452 const addressPath = getContractAddressPath(network); 2453 if (!fs.existsSync(addressPath)) { 2454 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2455 } 2456 2457 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2458 2459 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2460 console.log('║ 🔒 Locking Collection Metadata ║'); 2461 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2462 2463 console.log(`📡 Network: ${config.name}`); 2464 console.log(`📍 Contract: ${contractAddress}`); 2465 console.log('\n⚠️ WARNING: This action is PERMANENT.'); 2466 console.log(' Collection metadata (name, description, image) cannot be updated after locking.\n'); 2467 2468 try { 2469 const contract = await tezos.contract.at(contractAddress); 2470 2471 const op = await contract.methods.lock_contract_metadata().send(); 2472 2473 console.log(` ⏳ Operation hash: ${op.hash}`); 2474 console.log(' ⏳ Waiting for confirmation...'); 2475 2476 await op.confirmation(1); 2477 2478 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2479 console.log('║ ✅ Collection Metadata Locked! ║'); 2480 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2481 2482 console.log(` 🔒 Status: PERMANENTLY LOCKED`); 2483 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2484 2485 return { hash: op.hash, locked: true }; 2486 2487 } catch (error) { 2488 console.error('\n❌ Lock failed!'); 2489 console.error(` Error: ${error.message}`); 2490 throw error; 2491 } 2492} 2493 2494// ============================================================================ 2495// Lock Metadata 2496// ============================================================================ 2497 2498async function lockMetadata(tokenId, options = {}) { 2499 const { network = 'mainnet' } = options; 2500 2501 const { tezos, credentials, config } = await createTezosClient(network); 2502 2503 // Load contract address (network-specific) 2504 const addressPath = getContractAddressPath(network); 2505 if (!fs.existsSync(addressPath)) { 2506 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2507 } 2508 2509 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2510 2511 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2512 console.log('║ 🔒 Locking Token Metadata ║'); 2513 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2514 2515 console.log(`📡 Network: ${config.name}`); 2516 console.log(`📍 Contract: ${contractAddress}`); 2517 console.log(`🎨 Token ID: ${tokenId}`); 2518 console.log('\n⚠️ WARNING: This action is PERMANENT. Metadata cannot be updated after locking.\n'); 2519 2520 try { 2521 const contract = await tezos.contract.at(contractAddress); 2522 2523 const op = await contract.methods.lock_metadata(tokenId).send(); 2524 2525 console.log(` ⏳ Operation hash: ${op.hash}`); 2526 console.log(' ⏳ Waiting for confirmation...'); 2527 2528 await op.confirmation(1); 2529 2530 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2531 console.log('║ ✅ Metadata Locked Successfully! ║'); 2532 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2533 2534 console.log(` 🎨 Token ID: ${tokenId}`); 2535 console.log(` 🔒 Status: PERMANENTLY LOCKED`); 2536 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2537 2538 return { tokenId, hash: op.hash, locked: true }; 2539 2540 } catch (error) { 2541 console.error('\n❌ Lock failed!'); 2542 console.error(` Error: ${error.message}`); 2543 throw error; 2544 } 2545} 2546 2547// ============================================================================ 2548// Burn Token 2549// ============================================================================ 2550 2551async function burnToken(tokenId, options = {}) { 2552 const { network = 'mainnet' } = options; 2553 2554 const { tezos, credentials, config } = await createTezosClient(network); 2555 2556 // Load contract address (network-specific) 2557 const addressPath = getContractAddressPath(network); 2558 if (!fs.existsSync(addressPath)) { 2559 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2560 } 2561 2562 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2563 2564 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2565 console.log('║ 🔥 Burning Token ║'); 2566 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2567 2568 console.log(`📡 Network: ${config.name}`); 2569 console.log(`📍 Contract: ${contractAddress}`); 2570 console.log(`🎨 Token ID: ${tokenId}`); 2571 console.log('\n⚠️ WARNING: This action is PERMANENT. The token will be destroyed.\n'); 2572 console.log(' The piece name will become available for re-keeping.\n'); 2573 2574 try { 2575 const contract = await tezos.contract.at(contractAddress); 2576 2577 const op = await contract.methods.burn_keep(tokenId).send(); 2578 2579 console.log(` ⏳ Operation hash: ${op.hash}`); 2580 console.log(' ⏳ Waiting for confirmation...'); 2581 2582 await op.confirmation(1); 2583 2584 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2585 console.log('║ ✅ Token Burned Successfully! ║'); 2586 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2587 2588 console.log(` 🔥 Token ID: ${tokenId} - DESTROYED`); 2589 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2590 2591 return { tokenId, hash: op.hash, burned: true }; 2592 2593 } catch (error) { 2594 console.error('\n❌ Burn failed!'); 2595 console.error(` Error: ${error.message}`); 2596 throw error; 2597 } 2598} 2599 2600// ============================================================================ 2601// Fee Management 2602// ============================================================================ 2603 2604async function getKeepFee(network = 'mainnet') { 2605 const { tezos, config } = await createTezosClient(network); 2606 2607 const addressPath = getContractAddressPath(network); 2608 if (!fs.existsSync(addressPath)) { 2609 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2610 } 2611 2612 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2613 const contract = await tezos.contract.at(contractAddress); 2614 const storage = await contract.storage(); 2615 2616 // keep_fee is stored in mutez 2617 const feeInMutez = storage.keep_fee?.toNumber?.() ?? storage.keep_fee ?? 0; 2618 const feeInTez = feeInMutez / 1_000_000; 2619 2620 return { feeInMutez, feeInTez, contractAddress }; 2621} 2622 2623async function setKeepFee(feeInTez, options = {}) { 2624 const { network = 'mainnet' } = options; 2625 2626 const { tezos, config } = await createTezosClient(network); 2627 2628 const addressPath = getContractAddressPath(network); 2629 if (!fs.existsSync(addressPath)) { 2630 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2631 } 2632 2633 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2634 const feeInMutez = Math.floor(feeInTez * 1_000_000); 2635 2636 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2637 console.log('║ 💰 Setting Keep Fee ║'); 2638 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2639 2640 console.log(`📡 Network: ${config.name}`); 2641 console.log(`📍 Contract: ${contractAddress}`); 2642 console.log(`💵 New Fee: ${feeInTez} XTZ (${feeInMutez} mutez)\n`); 2643 2644 try { 2645 const contract = await tezos.contract.at(contractAddress); 2646 2647 const op = await contract.methods.set_keep_fee(feeInMutez).send(); 2648 2649 console.log(` ⏳ Operation hash: ${op.hash}`); 2650 console.log(' ⏳ Waiting for confirmation...'); 2651 2652 await op.confirmation(1); 2653 2654 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2655 console.log('║ ✅ Keep Fee Updated Successfully! ║'); 2656 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2657 2658 console.log(` 💵 New keep fee: ${feeInTez} XTZ`); 2659 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2660 2661 return { feeInTez, feeInMutez, hash: op.hash }; 2662 2663 } catch (error) { 2664 console.error('\n❌ Set fee failed!'); 2665 console.error(` Error: ${error.message}`); 2666 throw error; 2667 } 2668} 2669 2670async function setAdministrator(newAdmin, options = {}) { 2671 const { network = 'mainnet' } = options; 2672 2673 const { tezos, config } = await createTezosClient(network); 2674 2675 const addressPath = getContractAddressPath(network); 2676 if (!fs.existsSync(addressPath)) { 2677 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2678 } 2679 2680 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2681 2682 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2683 console.log('║ 👑 Setting Contract Administrator ║'); 2684 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2685 2686 console.log(`📡 Network: ${config.name}`); 2687 console.log(`📍 Contract: ${contractAddress}`); 2688 console.log(`👤 New Admin: ${newAdmin}\n`); 2689 2690 try { 2691 const contract = await tezos.contract.at(contractAddress); 2692 2693 const op = await contract.methods.set_administrator(newAdmin).send(); 2694 2695 console.log(` ⏳ Operation hash: ${op.hash}`); 2696 console.log(' ⏳ Waiting for confirmation...'); 2697 2698 await op.confirmation(1); 2699 2700 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2701 console.log('║ ✅ Administrator Changed Successfully! ║'); 2702 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2703 2704 console.log(` 👤 New admin: ${newAdmin}`); 2705 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2706 console.log(' ⚠️ WARNING: Only the new admin can call admin functions now!\n'); 2707 2708 return { newAdmin, hash: op.hash }; 2709 2710 } catch (error) { 2711 console.error('\n❌ Set administrator failed!'); 2712 console.error(` Error: ${error.message}`); 2713 throw error; 2714 } 2715} 2716 2717async function withdrawFees(destination, options = {}) { 2718 const { network = 'mainnet' } = options; 2719 2720 const { tezos, credentials, config } = await createTezosClient(network); 2721 2722 const addressPath = getContractAddressPath(network); 2723 if (!fs.existsSync(addressPath)) { 2724 throw new Error(`❌ No contract deployed on ${network}. Run: node keeps.mjs deploy ${network}`); 2725 } 2726 2727 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2728 const dest = destination || credentials.address; // Default to admin address 2729 2730 // Check contract balance first 2731 const contractBalance = await tezos.tz.getBalance(contractAddress); 2732 const balanceXTZ = contractBalance.toNumber() / 1_000_000; 2733 2734 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2735 console.log('║ 💸 Withdrawing Fees ║'); 2736 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2737 2738 console.log(`📡 Network: ${config.name}`); 2739 console.log(`📍 Contract: ${contractAddress}`); 2740 console.log(`💰 Contract Balance: ${balanceXTZ.toFixed(6)} XTZ`); 2741 console.log(`📤 Destination: ${dest}\n`); 2742 2743 if (balanceXTZ === 0) { 2744 console.log(' ℹ️ No fees to withdraw (balance is 0)\n'); 2745 return { withdrawn: 0, hash: null }; 2746 } 2747 2748 try { 2749 const contract = await tezos.contract.at(contractAddress); 2750 2751 const op = await contract.methods.withdraw_fees(dest).send(); 2752 2753 console.log(` ⏳ Operation hash: ${op.hash}`); 2754 console.log(' ⏳ Waiting for confirmation...'); 2755 2756 await op.confirmation(1); 2757 2758 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2759 console.log('║ ✅ Fees Withdrawn Successfully! ║'); 2760 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2761 2762 console.log(` 💸 Withdrawn: ${balanceXTZ.toFixed(6)} XTZ`); 2763 console.log(` 📤 To: ${dest}`); 2764 console.log(` 📝 Operation: ${config.explorer}/${op.hash}\n`); 2765 2766 return { withdrawn: balanceXTZ, destination: dest, hash: op.hash }; 2767 2768 } catch (error) { 2769 console.error('\n❌ Withdrawal failed!'); 2770 console.error(` Error: ${error.message}`); 2771 throw error; 2772 } 2773} 2774 2775// ============================================================================ 2776// v4 NEW FEATURES - Royalty, Pause, Admin Transfer 2777// ============================================================================ 2778 2779async function getRoyalty(network = 'mainnet') { 2780 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2781 console.log('║ 🎨 Current Royalty Configuration ║'); 2782 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2783 2784 const { tezos, config } = await createTezosClient(network); 2785 const addressPath = getContractAddressPath(network); 2786 2787 if (!fs.existsSync(addressPath)) { 2788 throw new Error(`No contract address found for ${network}. Deploy contract first.`); 2789 } 2790 2791 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2792 const contract = await tezos.contract.at(contractAddress); 2793 const storage = await contract.storage(); 2794 2795 const royaltyBps = storage.default_royalty_bps ? storage.default_royalty_bps.toNumber() : 1000; 2796 const royaltyPercent = (royaltyBps / 100).toFixed(1); 2797 2798 console.log(` Contract: ${contractAddress}`); 2799 console.log(` Network: ${config.name}`); 2800 console.log(` Royalty: ${royaltyPercent}% (${royaltyBps} basis points)`); 2801 console.log(` Explorer: ${config.explorer}/${contractAddress}\n`); 2802 2803 return { contractAddress, royaltyBps, royaltyPercent }; 2804} 2805 2806async function setRoyalty(percentage, options = {}) { 2807 const network = options.network || 'mainnet'; 2808 2809 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2810 console.log('║ 🎨 Setting Default Royalty ║'); 2811 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2812 2813 if (percentage < 0 || percentage > 25) { 2814 throw new Error('Royalty must be between 0% and 25%'); 2815 } 2816 2817 const bps = Math.round(percentage * 100); // Convert to basis points 2818 2819 const { tezos, credentials, config } = await createTezosClient(network); 2820 const addressPath = getContractAddressPath(network); 2821 2822 if (!fs.existsSync(addressPath)) { 2823 throw new Error(`No contract address found for ${network}. Deploy contract first.`); 2824 } 2825 2826 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2827 2828 console.log(` Setting royalty to ${percentage}% (${bps} basis points)...`); 2829 console.log(` Contract: ${contractAddress}`); 2830 console.log(` Admin: ${credentials.address}\n`); 2831 2832 const contract = await tezos.contract.at(contractAddress); 2833 const op = await contract.methodsObject.set_default_royalty(bps).send(); 2834 2835 console.log(` Transaction: ${op.hash}`); 2836 console.log(` Waiting for confirmation...`); 2837 2838 await op.confirmation(1); 2839 2840 console.log(`\n✅ Royalty set to ${percentage}%`); 2841 console.log(`🔗 ${config.explorer}/${op.hash}\n`); 2842} 2843 2844async function pauseContract(options = {}) { 2845 const network = options.network || 'mainnet'; 2846 2847 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2848 console.log('║ 🚨 EMERGENCY PAUSE ║'); 2849 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2850 2851 const { tezos, credentials, config } = await createTezosClient(network); 2852 const addressPath = getContractAddressPath(network); 2853 2854 if (!fs.existsSync(addressPath)) { 2855 throw new Error(`No contract address found for ${network}. Deploy contract first.`); 2856 } 2857 2858 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2859 2860 console.log(` ⚠️ This will stop all minting and metadata edits`); 2861 console.log(` Contract: ${contractAddress}`); 2862 console.log(` Admin: ${credentials.address}\n`); 2863 2864 // Confirmation prompt 2865 const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 2866 const answer = await new Promise(resolve => { 2867 rl.question('Pause contract? (y/N): ', resolve); 2868 }); 2869 rl.close(); 2870 2871 if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { 2872 console.log('\n❌ Cancelled.\n'); 2873 return; 2874 } 2875 2876 const contract = await tezos.contract.at(contractAddress); 2877 const op = await contract.methodsObject.pause().send(); 2878 2879 console.log(`\n Transaction: ${op.hash}`); 2880 console.log(` Waiting for confirmation...`); 2881 2882 await op.confirmation(1); 2883 2884 console.log(`\n✅ Contract PAUSED`); 2885 console.log(`🔗 ${config.explorer}/${op.hash}\n`); 2886 console.log(`⚠️ Minting and metadata edits are now disabled`); 2887 console.log(` Use "node keeps.mjs unpause" to resume operations\n`); 2888} 2889 2890async function unpauseContract(options = {}) { 2891 const network = options.network || 'mainnet'; 2892 2893 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2894 console.log('║ ✅ UNPAUSE CONTRACT ║'); 2895 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2896 2897 const { tezos, credentials, config } = await createTezosClient(network); 2898 const addressPath = getContractAddressPath(network); 2899 2900 if (!fs.existsSync(addressPath)) { 2901 throw new Error(`No contract address found for ${network}. Deploy contract first.`); 2902 } 2903 2904 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2905 2906 console.log(` Resuming normal operations...`); 2907 console.log(` Contract: ${contractAddress}`); 2908 console.log(` Admin: ${credentials.address}\n`); 2909 2910 const contract = await tezos.contract.at(contractAddress); 2911 const op = await contract.methodsObject.unpause().send(); 2912 2913 console.log(` Transaction: ${op.hash}`); 2914 console.log(` Waiting for confirmation...`); 2915 2916 await op.confirmation(1); 2917 2918 console.log(`\n✅ Contract UNPAUSED`); 2919 console.log(`🔗 ${config.explorer}/${op.hash}\n`); 2920 console.log(` Minting and metadata edits are now enabled\n`); 2921} 2922 2923async function adminTransfer(tokenId, fromAddress, toAddress, options = {}) { 2924 const network = options.network || 'mainnet'; 2925 2926 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2927 console.log('║ 🔄 Admin Emergency Transfer ║'); 2928 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2929 2930 const { tezos, credentials, config } = await createTezosClient(network); 2931 const addressPath = getContractAddressPath(network); 2932 2933 if (!fs.existsSync(addressPath)) { 2934 throw new Error(`No contract address found for ${network}. Deploy contract first.`); 2935 } 2936 2937 const contractAddress = fs.readFileSync(addressPath, 'utf8').trim(); 2938 2939 console.log(` Token ID: #${tokenId}`); 2940 console.log(` From: ${fromAddress}`); 2941 console.log(` To: ${toAddress}`); 2942 console.log(` Contract: ${contractAddress}`); 2943 console.log(` Admin: ${credentials.address}\n`); 2944 2945 // Confirmation prompt 2946 const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 2947 const answer = await new Promise(resolve => { 2948 rl.question('Transfer token? (y/N): ', resolve); 2949 }); 2950 rl.close(); 2951 2952 if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') { 2953 console.log('\n❌ Cancelled.\n'); 2954 return; 2955 } 2956 2957 const contract = await tezos.contract.at(contractAddress); 2958 const op = await contract.methodsObject.admin_transfer({ 2959 token_id: tokenId, 2960 from_: fromAddress, 2961 to_: toAddress 2962 }).send(); 2963 2964 console.log(`\n Transaction: ${op.hash}`); 2965 console.log(` Waiting for confirmation...`); 2966 2967 await op.confirmation(1); 2968 2969 console.log(`\n✅ Token transferred`); 2970 console.log(`🔗 ${config.explorer}/${op.hash}`); 2971 console.log(`📊 ${config.explorer}/${contractAddress}/tokens/${tokenId}\n`); 2972} 2973 2974// ============================================================================ 2975// Send XTZ 2976// ============================================================================ 2977 2978async function sendTez(toAddress, amount, network = 'mainnet') { 2979 const { tezos, credentials, config } = await createTezosClient(network); 2980 2981 let resolvedAddress = toAddress; 2982 if (toAddress.endsWith('.tez')) { 2983 console.log(`\n🔍 Resolving ${toAddress}...`); 2984 resolvedAddress = await resolveTezDomain(toAddress); 2985 console.log(`${resolvedAddress}`); 2986 } 2987 2988 const mutez = Math.floor(amount * 1_000_000); 2989 2990 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 2991 console.log('║ 💸 Send XTZ ║'); 2992 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 2993 2994 console.log(`📡 Network: ${config.name}`); 2995 console.log(`📤 From: ${credentials.address}`); 2996 console.log(`📥 To: ${resolvedAddress}${toAddress !== resolvedAddress ? ` (${toAddress})` : ''}`); 2997 console.log(`💵 Amount: ${amount} XTZ\n`); 2998 2999 const op = await tezos.contract.transfer({ to: resolvedAddress, amount: mutez, mutez: true }); 3000 3001 console.log(` ⏳ Operation: ${op.hash}`); 3002 console.log(' ⏳ Waiting for confirmation...'); 3003 await op.confirmation(1); 3004 3005 console.log('\n✅ Sent!'); 3006 console.log(` 🔗 ${config.explorer}/${op.hash}\n`); 3007 return { hash: op.hash }; 3008} 3009 3010// ============================================================================ 3011// FA2 Transfer 3012// ============================================================================ 3013 3014async function resolveTezDomain(name) { 3015 const resp = await fetch(`https://api.tzkt.io/v1/domains?name=${encodeURIComponent(name)}`); 3016 if (!resp.ok) throw new Error(`Tezos Domains lookup failed: ${resp.status}`); 3017 const data = await resp.json(); 3018 const entry = data?.[0]; 3019 if (!entry?.owner?.address) throw new Error(`Could not resolve ${name}`); 3020 return entry.owner.address; 3021} 3022 3023async function transferToken(tokenId, toAddress, network = 'mainnet') { 3024 const { tezos, credentials, config } = await createTezosClient(network); 3025 const contractAddress = loadContractAddress(network); 3026 3027 let resolvedAddress = toAddress; 3028 if (toAddress.endsWith('.tez')) { 3029 console.log(`\n🔍 Resolving ${toAddress}...`); 3030 resolvedAddress = await resolveTezDomain(toAddress); 3031 console.log(`${resolvedAddress}`); 3032 } 3033 3034 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3035 console.log('║ 🎁 Transfer Token ║'); 3036 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3037 3038 console.log(`📡 Network: ${config.name}`); 3039 console.log(`📍 Contract: ${contractAddress}`); 3040 console.log(`🎨 Token: #${tokenId}`); 3041 console.log(`📤 From: ${credentials.address}`); 3042 console.log(`📥 To: ${resolvedAddress}${toAddress !== resolvedAddress ? ` (${toAddress})` : ''}\n`); 3043 3044 // Check for active listing and retract if needed 3045 const listing = await loadActiveListingForToken(contractAddress, tokenId, credentials.address); 3046 const contract = await tezos.contract.at(contractAddress); 3047 3048 if (listing) { 3049 const askId = Number.parseInt(String(listing.bigmap_key), 10) || listing.id; 3050 const askPrice = (Number(listing.price_xtz || 0) / 1_000_000).toFixed(6); 3051 console.log(`♻️ Retracting active listing #${askId} (${askPrice} XTZ)...`); 3052 3053 const marketplaceContract = listing.marketplace_contract || OBJKT_MARKETPLACE_FALLBACK[network]; 3054 const marketContract = await tezos.contract.at(marketplaceContract); 3055 3056 const op = await tezos.contract.batch() 3057 .withContractCall(marketContract.methods.retract_ask(Number(askId))) 3058 .withContractCall(contract.methods.transfer([{ 3059 from_: credentials.address, 3060 txs: [{ to_: resolvedAddress, token_id: tokenId, amount: 1 }] 3061 }])) 3062 .send(); 3063 3064 console.log(` ⏳ Operation: ${op.hash}`); 3065 console.log(' ⏳ Waiting for confirmation...'); 3066 await op.confirmation(1); 3067 3068 console.log('\n✅ Listing retracted + token transferred!'); 3069 console.log(` 🔗 ${config.explorer}/${op.hash}\n`); 3070 return { hash: op.hash, retracted: askId }; 3071 } 3072 3073 const op = await contract.methods.transfer([{ 3074 from_: credentials.address, 3075 txs: [{ to_: resolvedAddress, token_id: tokenId, amount: 1 }] 3076 }]).send(); 3077 3078 console.log(` ⏳ Operation: ${op.hash}`); 3079 console.log(' ⏳ Waiting for confirmation...'); 3080 await op.confirmation(1); 3081 3082 console.log('\n✅ Token transferred!'); 3083 console.log(` 🔗 ${config.explorer}/${op.hash}\n`); 3084 return { hash: op.hash }; 3085} 3086 3087// ============================================================================ 3088// Marketplace Commands (Objkt) 3089// ============================================================================ 3090 3091async function listOwnedTokens(network = 'mainnet', options = {}) { 3092 const { credentials, config } = await createTezosClient(network); 3093 const contractAddress = loadContractAddress(network); 3094 const limit = Number.isFinite(Number(options.limit)) ? Math.max(1, Number(options.limit)) : 200; 3095 3096 const apiBase = tzktApiBase(network); 3097 const url = `${apiBase}/v1/tokens/balances?token.contract=${contractAddress}&account=${credentials.address}&balance.gt=0&limit=${limit}`; 3098 const response = await fetch(url); 3099 if (!response.ok) { 3100 throw new Error(`Failed to load owned tokens (${response.status})`); 3101 } 3102 3103 const rows = await response.json(); 3104 3105 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3106 console.log('║ 🧾 Wallet Token Inventory ║'); 3107 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3108 3109 console.log(`📡 Network: ${config.name}`); 3110 console.log(`📍 Contract: ${contractAddress}`); 3111 console.log(`👤 Wallet: ${credentials.address}`); 3112 console.log(`📦 Tokens: ${rows.length}\n`); 3113 3114 if (!rows.length) { 3115 console.log(' (No tokens in this wallet for current keeps contract)\n'); 3116 return []; 3117 } 3118 3119 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 3120 const normalized = rows 3121 .map((row) => ({ 3122 tokenId: Number.parseInt(row?.token?.tokenId ?? row?.token?.token_id, 10), 3123 name: row?.token?.metadata?.name || row?.token?.metadata?.symbol || `#${row?.token?.tokenId}`, 3124 balance: Number.parseInt(row?.balance ?? '0', 10), 3125 lastTime: row?.lastTime || null, 3126 })) 3127 .filter((row) => Number.isInteger(row.tokenId)) 3128 .sort((a, b) => a.tokenId - b.tokenId); 3129 3130 for (const token of normalized) { 3131 console.log(` [${token.tokenId}] ${token.name} (balance: ${token.balance})`); 3132 console.log(` 🔗 ${objktBase}/tokens/${contractAddress}/${token.tokenId}`); 3133 } 3134 console.log(''); 3135 3136 return normalized; 3137} 3138 3139async function listTokenForSale(tokenReference, priceInXTZ, options = {}) { 3140 const network = options.network || 'mainnet'; 3141 const apply = options.apply === true; 3142 const referralBonusBpsRaw = Number.parseInt(options.referralBonusBps ?? 500, 10); 3143 const referralBonusBps = Number.isFinite(referralBonusBpsRaw) 3144 ? Math.max(0, Math.min(10000, referralBonusBpsRaw)) 3145 : 500; 3146 const startTime = options.startTime || null; 3147 const expiryTime = options.expiryTime || null; 3148 const replaceExisting = options.replaceExisting === true; 3149 3150 const { tezos, credentials, config } = await createTezosClient(network); 3151 const contractAddress = loadContractAddress(network); 3152 const tokenId = await resolveTokenIdFromReference(tokenReference, { contractAddress, network }); 3153 const priceMutez = parsePriceToMutez(priceInXTZ); 3154 const priceXTZ = (priceMutez / 1_000_000).toFixed(6); 3155 3156 await assertWalletOwnsToken(contractAddress, tokenId, credentials.address, network); 3157 3158 const token = await fetchTokenFromTzkt(contractAddress, tokenId, network); 3159 if (!token) { 3160 throw new Error(`Token #${tokenId} not found on ${network}.`); 3161 } 3162 3163 const tokenName = token?.metadata?.name || token?.metadata?.symbol || `#${tokenId}`; 3164 const shares = normalizeShareMap(token?.metadata?.royalties?.shares); 3165 if (Object.keys(shares).length === 0) { 3166 shares[credentials.address] = '1000'; 3167 } 3168 3169 const marketplaceContract = await resolveObjktMarketplaceContract({ 3170 network, 3171 keepsContract: contractAddress, 3172 explicitContract: options.marketplaceContract || null, 3173 }); 3174 3175 const existingListing = await loadActiveListingForToken(contractAddress, tokenId, credentials.address) 3176 .catch(() => null); 3177 3178 if (existingListing && !replaceExisting) { 3179 const existingPrice = Number(existingListing.price_xtz || 0) / 1_000_000; 3180 throw new Error( 3181 `Token #${tokenId} already has an active listing (${existingPrice} XTZ). Use --replace to update it.` 3182 ); 3183 } 3184 3185 const askPayload = { 3186 token: { 3187 address: contractAddress, 3188 token_id: tokenId.toString(), 3189 }, 3190 currency: { 3191 tez: {}, 3192 }, 3193 amount: priceMutez.toString(), 3194 editions: '1', 3195 shares, 3196 start_time: startTime, 3197 expiry_time: expiryTime, 3198 referral_bonus: referralBonusBps.toString(), 3199 condition: null, 3200 }; 3201 3202 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 3203 const tokenUrl = `${objktBase}/tokens/${contractAddress}/${tokenId}`; 3204 const existingAskOnChainId = Number.parseInt(String(existingListing?.bigmap_key), 10); 3205 const existingAskDisplayId = Number.isInteger(existingAskOnChainId) 3206 ? existingAskOnChainId 3207 : existingListing?.id; 3208 3209 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3210 console.log('║ 🏷️ List Token For Sale ║'); 3211 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3212 3213 console.log(`📡 Network: ${config.name}`); 3214 console.log(`📍 Keeps: ${contractAddress}`); 3215 console.log(`🛒 Marketplace: ${marketplaceContract}`); 3216 console.log(`🎨 Token: [${tokenId}] ${tokenName}`); 3217 console.log(`💵 Ask Price: ${priceXTZ} XTZ (${priceMutez} mutez)`); 3218 console.log(`👤 Seller: ${credentials.address}`); 3219 console.log(`🔗 View: ${tokenUrl}`); 3220 console.log(`📈 Shares: ${JSON.stringify(shares)}`); 3221 if (existingListing) { 3222 console.log( 3223 `♻️ Existing ask: #${existingAskDisplayId} (${(Number(existingListing.price_xtz || 0) / 1_000_000).toFixed(6)} XTZ)` 3224 ); 3225 } 3226 console.log(''); 3227 3228 if (!apply) { 3229 console.log('⚠️ DRY RUN: no transaction sent. Add --yes to list on chain.\n'); 3230 return { 3231 tokenId, 3232 tokenName, 3233 priceMutez, 3234 priceXTZ: Number(priceXTZ), 3235 contractAddress, 3236 marketplaceContract, 3237 dryRun: true, 3238 replaced: Boolean(existingListing), 3239 }; 3240 } 3241 3242 const tokenContract = await tezos.contract.at(contractAddress); 3243 const marketContract = await tezos.contract.at(marketplaceContract); 3244 3245 const askMethod = marketContract.methodsObject.ask(askPayload); 3246 const retractMethod = existingListing 3247 ? marketContract.methods.retract_ask(Number(existingAskDisplayId)) 3248 : null; 3249 3250 let op; 3251 let mode = 'ask-only'; 3252 3253 if (retractMethod) { 3254 mode = 'retract+ask'; 3255 op = await tezos.contract 3256 .batch() 3257 .withContractCall(retractMethod) 3258 .withContractCall(askMethod) 3259 .send(); 3260 } else { 3261 const addOperatorMethod = tokenContract.methods.update_operators([ 3262 { 3263 add_operator: { 3264 owner: credentials.address, 3265 operator: marketplaceContract, 3266 token_id: tokenId, 3267 }, 3268 }, 3269 ]); 3270 3271 try { 3272 mode = 'add_operator+ask'; 3273 op = await tezos.contract 3274 .batch() 3275 .withContractCall(addOperatorMethod) 3276 .withContractCall(askMethod) 3277 .send(); 3278 } catch (error) { 3279 const message = String(error?.message || error); 3280 if (/FA2_OPERATOR_ALREADY_EXISTS|operator/i.test(message)) { 3281 mode = 'ask-only'; 3282 op = await tezos.contract 3283 .batch() 3284 .withContractCall(askMethod) 3285 .send(); 3286 } else { 3287 throw error; 3288 } 3289 } 3290 } 3291 3292 console.log(` Transaction: ${op.hash}`); 3293 console.log(' Waiting for confirmation...'); 3294 await op.confirmation(1); 3295 3296 console.log('\n✅ Listed successfully'); 3297 console.log(` Mode: ${mode}`); 3298 console.log(` Explorer: ${config.explorer}/${op.hash}`); 3299 console.log(` Objkt: ${tokenUrl}\n`); 3300 3301 return { 3302 tokenId, 3303 tokenName, 3304 priceMutez, 3305 priceXTZ: Number(priceXTZ), 3306 contractAddress, 3307 marketplaceContract, 3308 hash: op.hash, 3309 mode, 3310 replaced: Boolean(existingListing), 3311 }; 3312} 3313 3314async function listBatchForSale(items = [], options = {}) { 3315 if (!Array.isArray(items) || items.length === 0) { 3316 throw new Error('No items to list. Use format: <token|piece>=<priceXTZ>'); 3317 } 3318 3319 const results = []; 3320 for (const item of items) { 3321 const [tokenReference, priceInXTZ] = item.split('='); 3322 if (!tokenReference || !priceInXTZ) { 3323 throw new Error(`Invalid batch item "${item}". Expected "<token|piece>=<priceXTZ>".`); 3324 } 3325 3326 const result = await listTokenForSale(tokenReference, priceInXTZ, options); 3327 results.push(result); 3328 } 3329 3330 return results; 3331} 3332 3333async function acceptOffer(offerIdInput, options = {}) { 3334 const network = options.network || 'mainnet'; 3335 const apply = options.apply === true; 3336 const minPriceMutez = options.minPriceMutez != null 3337 ? Number.parseInt(String(options.minPriceMutez), 10) 3338 : null; 3339 3340 const { tezos, credentials, config } = await createTezosClient(network); 3341 const contractAddress = loadContractAddress(network); 3342 const offerId = Number.parseInt(String(offerIdInput), 10); 3343 3344 if (!Number.isInteger(offerId) || offerId < 0) { 3345 throw new Error(`Invalid offer id "${offerIdInput}".`); 3346 } 3347 3348 const offer = await loadActiveOfferById(offerId); 3349 if (!offer) { 3350 throw new Error(`Offer #${offerId} is not active.`); 3351 } 3352 3353 const tokenId = Number.parseInt(String(offer?.token?.token_id), 10); 3354 const tokenName = offer?.token?.name || `#${offer?.token?.token_id ?? '?'}`; 3355 const offerRowId = Number.parseInt(String(offer?.id), 10); 3356 const offerOnChainId = Number.parseInt(String(offer?.bigmap_key), 10); 3357 const chainOfferId = Number.isInteger(offerOnChainId) ? offerOnChainId : offerId; 3358 const faContract = offer?.token?.fa_contract; 3359 if (!isKt1Address(faContract) || faContract !== contractAddress) { 3360 throw new Error( 3361 `Offer #${offerId} belongs to ${faContract || 'unknown contract'}, not current keeps contract ${contractAddress}.` 3362 ); 3363 } 3364 3365 const bidMutez = Number.parseInt(String(offer?.price_xtz || 0), 10); 3366 if (!Number.isInteger(bidMutez) || bidMutez <= 0) { 3367 throw new Error(`Offer #${offerId} has invalid bid amount.`); 3368 } 3369 3370 if (Number.isInteger(minPriceMutez) && bidMutez <= minPriceMutez) { 3371 throw new Error( 3372 `Offer #${offerId} bid ${(bidMutez / 1_000_000).toFixed(6)} XTZ is not above threshold ${(minPriceMutez / 1_000_000).toFixed(6)} XTZ.` 3373 ); 3374 } 3375 3376 await assertWalletOwnsToken(contractAddress, tokenId, credentials.address, network); 3377 3378 const marketplaceContract = options.marketplaceContract 3379 || offer?.marketplace_contract 3380 || await resolveObjktMarketplaceContract({ 3381 network, 3382 keepsContract: contractAddress, 3383 explicitContract: null, 3384 }); 3385 3386 const bidXTZ = (bidMutez / 1_000_000).toFixed(6); 3387 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 3388 const tokenUrl = `${objktBase}/tokens/${contractAddress}/${tokenId}`; 3389 3390 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3391 console.log('║ ✅ Accept Offer ║'); 3392 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3393 console.log(`📡 Network: ${config.name}`); 3394 console.log(`📍 Keeps: ${contractAddress}`); 3395 console.log(`🛒 Marketplace: ${marketplaceContract}`); 3396 console.log(`🎨 Token: [${tokenId}] ${tokenName}`); 3397 console.log(`🧾 Offer ID: ${chainOfferId}`); 3398 if (Number.isInteger(offerRowId) && offerRowId !== chainOfferId) { 3399 console.log(`🗂️ Offer Row ID: ${offerRowId}`); 3400 } 3401 console.log(`💵 Bid: ${bidXTZ} XTZ (${bidMutez} mutez)`); 3402 console.log(`👤 Buyer: ${offer?.buyer_address || 'unknown'}`); 3403 console.log(`👤 Seller: ${credentials.address}`); 3404 console.log(`🔗 View: ${tokenUrl}\n`); 3405 3406 if (!apply) { 3407 console.log('⚠️ DRY RUN: no transaction sent. Add --yes to accept on chain.\n'); 3408 return { 3409 offerId: chainOfferId, 3410 offerRowId: Number.isInteger(offerRowId) ? offerRowId : null, 3411 tokenId, 3412 tokenName, 3413 bidMutez, 3414 bidXTZ: Number(bidXTZ), 3415 buyerAddress: offer?.buyer_address || null, 3416 marketplaceContract, 3417 contractAddress, 3418 dryRun: true, 3419 }; 3420 } 3421 3422 const tokenContract = await tezos.contract.at(contractAddress); 3423 const marketContract = await tezos.contract.at(marketplaceContract); 3424 3425 const addOperatorMethod = tokenContract.methods.update_operators([ 3426 { 3427 add_operator: { 3428 owner: credentials.address, 3429 operator: marketplaceContract, 3430 token_id: tokenId, 3431 }, 3432 }, 3433 ]); 3434 3435 const fulfillMethod = marketContract.methodsObject.fulfill_offer({ 3436 offer_id: chainOfferId, 3437 token_id: tokenId, 3438 condition_extra: null, 3439 }); 3440 3441 let op; 3442 let mode = 'add_operator+fulfill_offer'; 3443 3444 try { 3445 op = await tezos.contract 3446 .batch() 3447 .withContractCall(addOperatorMethod) 3448 .withContractCall(fulfillMethod) 3449 .send(); 3450 } catch (error) { 3451 const message = String(error?.message || error); 3452 if (/FA2_OPERATOR_ALREADY_EXISTS|operator/i.test(message)) { 3453 mode = 'fulfill_offer-only'; 3454 op = await tezos.contract 3455 .batch() 3456 .withContractCall(fulfillMethod) 3457 .send(); 3458 } else { 3459 throw error; 3460 } 3461 } 3462 3463 console.log(` Transaction: ${op.hash}`); 3464 console.log(' Waiting for confirmation...'); 3465 await op.confirmation(1); 3466 3467 console.log('\n✅ Offer accepted'); 3468 console.log(` Mode: ${mode}`); 3469 console.log(` Explorer: ${config.explorer}/${op.hash}`); 3470 console.log(` Objkt: ${tokenUrl}\n`); 3471 3472 return { 3473 offerId: chainOfferId, 3474 offerRowId: Number.isInteger(offerRowId) ? offerRowId : null, 3475 tokenId, 3476 tokenName, 3477 bidMutez, 3478 bidXTZ: Number(bidXTZ), 3479 buyerAddress: offer?.buyer_address || null, 3480 marketplaceContract, 3481 contractAddress, 3482 hash: op.hash, 3483 mode, 3484 }; 3485} 3486 3487async function acceptOffersAboveThreshold(items = [], options = {}) { 3488 if (!Array.isArray(items) || items.length === 0) { 3489 throw new Error('No items provided. Use format: <token|piece>=<minimumXTZ>'); 3490 } 3491 3492 const network = options.network || 'mainnet'; 3493 const apply = options.apply === true; 3494 const contractAddress = loadContractAddress(network); 3495 3496 const checks = []; 3497 for (const item of items) { 3498 const [tokenReference, minXTZ] = item.split('='); 3499 if (!tokenReference || !minXTZ) { 3500 throw new Error(`Invalid item "${item}". Expected "<token|piece>=<minimumXTZ>".`); 3501 } 3502 3503 const tokenId = await resolveTokenIdFromReference(tokenReference, { contractAddress, network }); 3504 const minPriceMutez = parsePriceToMutez(minXTZ); 3505 const bestOffer = await loadBestActiveOfferForToken(contractAddress, tokenId); 3506 3507 checks.push({ 3508 tokenReference, 3509 tokenId, 3510 minPriceMutez, 3511 minXTZ: minPriceMutez / 1_000_000, 3512 offer: bestOffer, 3513 qualifies: Number(bestOffer?.price_xtz || 0) > minPriceMutez, 3514 }); 3515 } 3516 3517 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3518 console.log('║ 🤝 Accept Offers Above Threshold ║'); 3519 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3520 console.log(`📡 Network: ${network}`); 3521 console.log(`📍 Keeps: ${contractAddress}\n`); 3522 3523 for (const row of checks) { 3524 if (!row.offer) { 3525 console.log(` [${row.tokenId}] no active offer (threshold: ${row.minXTZ.toFixed(6)} XTZ)`); 3526 continue; 3527 } 3528 3529 const bidMutez = Number(row.offer.price_xtz || 0); 3530 const bidXTZ = bidMutez / 1_000_000; 3531 const pass = row.qualifies ? '✅' : '❌'; 3532 const onChainOfferId = Number.parseInt(String(row.offer.bigmap_key), 10); 3533 const displayOfferId = Number.isInteger(onChainOfferId) ? onChainOfferId : row.offer.id; 3534 console.log( 3535 ` [${row.tokenId}] ${row.offer?.token?.name || '#'} offer #${displayOfferId} bid ${bidXTZ.toFixed(6)} XTZ vs threshold ${row.minXTZ.toFixed(6)} XTZ ${pass}` 3536 ); 3537 } 3538 console.log(''); 3539 3540 const qualifying = checks.filter((row) => row.offer && row.qualifies); 3541 if (qualifying.length === 0) { 3542 console.log('No qualifying offers above thresholds.\n'); 3543 return []; 3544 } 3545 3546 if (!apply) { 3547 console.log('⚠️ DRY RUN: no transaction sent. Add --yes to accept qualifying offers.\n'); 3548 return qualifying.map((row) => ({ 3549 tokenId: row.tokenId, 3550 tokenName: row.offer?.token?.name || null, 3551 offerId: Number.parseInt(String(row.offer?.bigmap_key), 10) || Number(row.offer.id), 3552 offerRowId: Number(row.offer.id), 3553 bidMutez: Number(row.offer.price_xtz || 0), 3554 bidXTZ: Number(row.offer.price_xtz || 0) / 1_000_000, 3555 thresholdXTZ: row.minXTZ, 3556 buyerAddress: row.offer?.buyer_address || null, 3557 dryRun: true, 3558 })); 3559 } 3560 3561 const results = []; 3562 for (const row of qualifying) { 3563 const onChainOfferId = Number.parseInt(String(row.offer?.bigmap_key), 10); 3564 const offerIdentifier = Number.isInteger(onChainOfferId) ? onChainOfferId : row.offer.id; 3565 const result = await acceptOffer(offerIdentifier, { 3566 network, 3567 apply: true, 3568 minPriceMutez: row.minPriceMutez, 3569 marketplaceContract: options.marketplaceContract || row.offer?.marketplace_contract || null, 3570 }); 3571 results.push(result); 3572 } 3573 3574 return results; 3575} 3576 3577// ============================================================================ 3578// Buy (fulfill_ask) 3579// ============================================================================ 3580 3581async function loadActiveAskById(askId) { 3582 const numericId = Number.parseInt(String(askId), 10); 3583 if (!Number.isInteger(numericId) || numericId < 0) { 3584 throw new Error(`Invalid ask id "${askId}".`); 3585 } 3586 3587 const data = await objktGraphQL( 3588 ` 3589 query { 3590 listing_active( 3591 where:{ 3592 _or:[ 3593 {id:{_eq:${numericId}}}, 3594 {bigmap_key:{_eq:${numericId}}} 3595 ] 3596 } 3597 order_by:{timestamp:desc} 3598 limit:5 3599 ) { 3600 id 3601 bigmap_key 3602 price_xtz 3603 seller_address 3604 marketplace_contract 3605 marketplace { name } 3606 amount_left 3607 timestamp 3608 token { token_id name fa_contract } 3609 } 3610 } 3611 ` 3612 ); 3613 3614 const rows = Array.isArray(data?.listing_active) ? data.listing_active : []; 3615 if (rows.length === 0) return null; 3616 3617 return rows.find((row) => Number.parseInt(String(row?.id), 10) === numericId) 3618 || rows.find((row) => Number.parseInt(String(row?.bigmap_key), 10) === numericId) 3619 || rows[0]; 3620} 3621 3622async function buyToken(askIdInput, options = {}) { 3623 const network = options.network || 'mainnet'; 3624 const apply = options.apply === true; 3625 3626 const { tezos, credentials, config } = await createTezosClient(network); 3627 const contractAddress = loadContractAddress(network); 3628 const askId = Number.parseInt(String(askIdInput), 10); 3629 3630 if (!Number.isInteger(askId) || askId < 0) { 3631 throw new Error(`Invalid ask id "${askIdInput}".`); 3632 } 3633 3634 // Try objkt GraphQL first, fall back to TzKT bigmap lookup 3635 let listing = await loadActiveAskById(askId); 3636 let priceMutez, tokenId, tokenName, sellerAddress, marketplaceContract, faContract; 3637 3638 if (listing) { 3639 priceMutez = Number.parseInt(String(listing.price_xtz || 0), 10); 3640 tokenId = Number.parseInt(String(listing.token?.token_id), 10); 3641 tokenName = listing.token?.name || `#${tokenId}`; 3642 sellerAddress = listing.seller_address; 3643 marketplaceContract = listing.marketplace_contract; 3644 faContract = listing.token?.fa_contract; 3645 } else { 3646 // Fallback: read directly from TzKT bigmap 3647 const bigmapId = 684371; // objktcom marketplace v6.2 asks bigmap 3648 const resp = await fetch(`https://api.tzkt.io/v1/bigmaps/${bigmapId}/keys/${askId}`); 3649 if (!resp.ok) throw new Error(`Ask #${askId} not found (TzKT ${resp.status}).`); 3650 const entry = await resp.json(); 3651 if (!entry?.active) throw new Error(`Ask #${askId} is no longer active.`); 3652 const val = entry.value; 3653 priceMutez = Number.parseInt(String(val?.amount || 0), 10); 3654 tokenId = Number.parseInt(String(val?.token?.token_id), 10); 3655 tokenName = `#${tokenId}`; 3656 sellerAddress = val?.creator; 3657 faContract = val?.token?.address; 3658 marketplaceContract = 'KT1SwbTqhSKF6Pdokiu1K4Fpi17ahPPzmt1X'; 3659 } 3660 3661 if (!Number.isInteger(priceMutez) || priceMutez <= 0) { 3662 throw new Error(`Ask #${askId} has invalid price.`); 3663 } 3664 3665 if (faContract && faContract !== contractAddress) { 3666 // Allow buying from any FA2, but warn if not the current keeps contract 3667 console.log(`⚠️ Token is from ${faContract}, not current keeps contract ${contractAddress}.`); 3668 } 3669 3670 const priceXTZ = (priceMutez / 1_000_000).toFixed(6); 3671 const balance = await tezos.tz.getBalance(credentials.address); 3672 const balanceXTZ = balance.toNumber() / 1_000_000; 3673 3674 if (balanceXTZ < priceMutez / 1_000_000) { 3675 throw new Error( 3676 `Insufficient balance: ${balanceXTZ.toFixed(6)} XTZ available, need ${priceXTZ} XTZ.` 3677 ); 3678 } 3679 3680 const objktBase = network === 'mainnet' ? 'https://objkt.com' : 'https://ghostnet.objkt.com'; 3681 const tokenUrl = faContract 3682 ? `${objktBase}/tokens/${faContract}/${tokenId}` 3683 : `${objktBase}/tokens/${contractAddress}/${tokenId}`; 3684 3685 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3686 console.log('║ 🛒 Buy Token (fulfill_ask) ║'); 3687 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3688 console.log(`📡 Network: ${config.name}`); 3689 console.log(`📍 Contract: ${faContract || contractAddress}`); 3690 console.log(`🛒 Marketplace: ${marketplaceContract}`); 3691 console.log(`🎨 Token: [${tokenId}] ${tokenName}`); 3692 console.log(`🧾 Ask ID: ${askId}`); 3693 console.log(`💵 Price: ${priceXTZ} XTZ (${priceMutez} mutez)`); 3694 console.log(`👤 Seller: ${sellerAddress || 'unknown'}`); 3695 console.log(`👤 Buyer: ${credentials.address}`); 3696 console.log(`💰 Balance: ${balanceXTZ.toFixed(6)} XTZ`); 3697 console.log(`🔗 View: ${tokenUrl}\n`); 3698 3699 if (!apply) { 3700 console.log('⚠️ DRY RUN: no transaction sent. Add --yes to buy on chain.\n'); 3701 return { 3702 askId, 3703 tokenId, 3704 tokenName, 3705 priceMutez, 3706 priceXTZ: Number(priceXTZ), 3707 sellerAddress, 3708 marketplaceContract, 3709 dryRun: true, 3710 }; 3711 } 3712 3713 const marketContract = await tezos.contract.at(marketplaceContract); 3714 3715 const op = await marketContract.methodsObject.fulfill_ask({ 3716 ask_id: askId, 3717 amount: 1, // editions to buy 3718 proxy_for: null, 3719 condition_extra: null, 3720 referrers: new MichelsonMap(), 3721 }).send({ amount: priceMutez, mutez: true }); 3722 3723 console.log(` ⏳ Transaction: ${op.hash}`); 3724 console.log(' ⏳ Waiting for confirmation...'); 3725 await op.confirmation(1); 3726 3727 console.log('\n✅ Purchase complete!'); 3728 console.log(` 🎨 Token: [${tokenId}] ${tokenName}`); 3729 console.log(` 💵 Paid: ${priceXTZ} XTZ`); 3730 console.log(` 🔗 Explorer: ${config.explorer}/${op.hash}`); 3731 console.log(` 🔗 Objkt: ${tokenUrl}\n`); 3732 3733 return { 3734 askId, 3735 tokenId, 3736 tokenName, 3737 priceMutez, 3738 priceXTZ: Number(priceXTZ), 3739 sellerAddress, 3740 marketplaceContract, 3741 hash: op.hash, 3742 }; 3743} 3744 3745async function showMarketSnapshot(network = 'mainnet', options = {}) { 3746 if (network !== 'mainnet') { 3747 throw new Error('Market snapshot currently supports mainnet only.'); 3748 } 3749 3750 const contractAddress = loadContractAddress(network); 3751 const listingsLimit = Number.isFinite(Number(options.listingsLimit)) ? Math.max(1, Number(options.listingsLimit)) : 20; 3752 const salesLimit = Number.isFinite(Number(options.salesLimit)) ? Math.max(1, Number(options.salesLimit)) : 10; 3753 3754 const collectionData = await objktGraphQL( 3755 ` 3756 query($contract:String!, $limit:Int!) { 3757 fa(where:{contract:{_eq:$contract}}) { 3758 contract 3759 name 3760 items 3761 owners 3762 floor_price 3763 volume_24h 3764 volume_total 3765 } 3766 listing_active( 3767 where:{fa_contract:{_eq:$contract}} 3768 order_by:{price_xtz:asc} 3769 limit:$limit 3770 ) { 3771 id 3772 bigmap_key 3773 price_xtz 3774 seller_address 3775 token { token_id name } 3776 marketplace { name } 3777 timestamp 3778 } 3779 offer_active( 3780 where:{fa_contract:{_eq:$contract}} 3781 order_by:{price_xtz:desc} 3782 limit:$limit 3783 ) { 3784 id 3785 bigmap_key 3786 price_xtz 3787 buyer_address 3788 token { token_id name } 3789 marketplace { name } 3790 timestamp 3791 } 3792 } 3793 `, 3794 { contract: contractAddress, limit: listingsLimit } 3795 ); 3796 3797 let salesData = { listing_sale: [] }; 3798 let salesLoadError = null; 3799 try { 3800 salesData = await objktGraphQL( 3801 ` 3802 query($contract:String!, $limit:Int!) { 3803 listing_sale( 3804 where:{token:{fa_contract:{_eq:$contract}}} 3805 order_by:{timestamp:desc} 3806 limit:$limit 3807 ) { 3808 timestamp 3809 price_xtz 3810 buyer_address 3811 seller_address 3812 token { token_id name } 3813 marketplace { name } 3814 } 3815 } 3816 `, 3817 { contract: contractAddress, limit: salesLimit } 3818 ); 3819 } catch (error) { 3820 salesLoadError = error; 3821 } 3822 3823 const collection = collectionData?.fa?.[0] || null; 3824 const listings = Array.isArray(collectionData?.listing_active) ? collectionData.listing_active : []; 3825 const offers = Array.isArray(collectionData?.offer_active) ? collectionData.offer_active : []; 3826 const sales = Array.isArray(salesData?.listing_sale) ? salesData.listing_sale : []; 3827 3828 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 3829 console.log('║ 📈 Objkt Market Snapshot ║'); 3830 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 3831 3832 console.log(`📍 Contract: ${contractAddress}`); 3833 console.log(`🎨 Collection: ${collection?.name || 'Unknown'}`); 3834 console.log(`🧱 Items: ${collection?.items ?? 'n/a'} | 👥 Owners: ${collection?.owners ?? 'n/a'}`); 3835 console.log(`🏷️ Floor: ${collection?.floor_price != null ? (Number(collection.floor_price) / 1_000_000).toFixed(6) : 'n/a'} XTZ`); 3836 console.log(`📊 Volume 24h: ${collection?.volume_24h != null ? (Number(collection.volume_24h) / 1_000_000).toFixed(6) : 'n/a'} XTZ`); 3837 console.log(`📚 Volume total: ${collection?.volume_total != null ? (Number(collection.volume_total) / 1_000_000).toFixed(6) : 'n/a'} XTZ\n`); 3838 3839 console.log(`🛒 Active Listings (${listings.length}):`); 3840 if (listings.length === 0) { 3841 console.log(' (none)'); 3842 } else { 3843 for (const row of listings) { 3844 const askId = Number.parseInt(String(row?.bigmap_key), 10); 3845 const displayAskId = Number.isInteger(askId) ? askId : row?.id; 3846 console.log( 3847 ` [${row?.token?.token_id}] ${row?.token?.name || '#'} ask #${displayAskId} @ ${(Number(row?.price_xtz || 0) / 1_000_000).toFixed(6)} XTZ (${row?.seller_address})` 3848 ); 3849 } 3850 } 3851 console.log(''); 3852 3853 console.log(`🤝 Active Offers (${offers.length}):`); 3854 if (offers.length === 0) { 3855 console.log(' (none)'); 3856 } else { 3857 for (const row of offers) { 3858 const offerId = Number.parseInt(String(row?.bigmap_key), 10); 3859 const displayOfferId = Number.isInteger(offerId) ? offerId : row?.id; 3860 console.log( 3861 ` [${row?.token?.token_id}] ${row?.token?.name || '#'} offer #${displayOfferId} bid ${(Number(row?.price_xtz || 0) / 1_000_000).toFixed(6)} XTZ (${row?.buyer_address})` 3862 ); 3863 } 3864 } 3865 console.log(''); 3866 3867 console.log(`💸 Recent Sales (${sales.length}):`); 3868 if (sales.length === 0) { 3869 if (salesLoadError) { 3870 console.log(` (unavailable: ${salesLoadError.message})`); 3871 } else { 3872 console.log(' (none)'); 3873 } 3874 } else { 3875 for (const row of sales) { 3876 console.log( 3877 ` [${row?.token?.token_id}] ${row?.token?.name || '#'} sold ${(Number(row?.price_xtz || 0) / 1_000_000).toFixed(6)} XTZ @ ${row?.timestamp}` 3878 ); 3879 } 3880 } 3881 console.log(''); 3882 3883 return { 3884 contractAddress, 3885 collection, 3886 listings, 3887 offers, 3888 sales, 3889 }; 3890} 3891 3892async function discoverContractsByManager(managerAddress, network = 'mainnet') { 3893 const apiBase = tzktApiBase(network); 3894 const url = `${apiBase}/v1/accounts/${managerAddress}/contracts?limit=200&sort.desc=creationLevel`; 3895 const response = await fetch(url); 3896 if (!response.ok) { 3897 throw new Error(`Failed to load contracts for ${managerAddress}: ${response.status}`); 3898 } 3899 const contracts = await response.json(); 3900 return contracts 3901 .map((entry) => entry?.address) 3902 .filter((address) => isKt1Address(address)); 3903} 3904 3905function decodeContractMetadataBytes(contentBytes) { 3906 if (!contentBytes) return null; 3907 3908 const raw = typeof contentBytes === 'string' 3909 ? contentBytes 3910 : (typeof contentBytes?.toString === 'function' ? contentBytes.toString() : ''); 3911 const normalized = raw.startsWith('0x') ? raw.slice(2) : raw; 3912 if (!normalized) return null; 3913 3914 try { 3915 return JSON.parse(Buffer.from(normalized, 'hex').toString('utf8')); 3916 } catch { 3917 return null; 3918 } 3919} 3920 3921function buildDeprecatedCollectionMetadata(existing = {}, options = {}) { 3922 const replacementContract = options.replacementContract; 3923 const nowIso = new Date().toISOString(); 3924 const nowDate = nowIso.slice(0, 10); 3925 const priorDescription = typeof existing.description === 'string' ? existing.description.trim() : ''; 3926 const replacementText = replacementContract 3927 ? `Replacement contract: ${replacementContract}` 3928 : 'Replacement contract: not set'; 3929 const notice = options.notice || `Deprecated staging contract as of ${nowDate}. ${replacementText}`; 3930 3931 const deprecatedName = typeof existing.name === 'string' && existing.name.trim() 3932 ? (existing.name.includes('[DEPRECATED]') ? existing.name : `${existing.name} [DEPRECATED]`) 3933 : 'KidLisp Keeps [DEPRECATED]'; 3934 3935 return { 3936 ...existing, 3937 name: deprecatedName, 3938 description: [priorDescription, notice].filter(Boolean).join('\n\n'), 3939 deprecated: true, 3940 deprecatedAt: nowIso, 3941 deprecatedReplacement: replacementContract || null, 3942 status: 'deprecated-staging', 3943 }; 3944} 3945 3946async function fetchActiveTokenIds(contractAddress, network = 'mainnet') { 3947 const apiBase = tzktApiBase(network); 3948 const limit = 1000; 3949 let offset = 0; 3950 const tokenIds = []; 3951 3952 while (true) { 3953 const url = `${apiBase}/v1/contracts/${contractAddress}/bigmaps/token_metadata/keys?active=true&select=key&limit=${limit}&offset=${offset}`; 3954 const response = await fetch(url); 3955 if (!response.ok) { 3956 throw new Error(`Failed to fetch token list for ${contractAddress}: ${response.status}`); 3957 } 3958 3959 const keys = await response.json(); 3960 if (!Array.isArray(keys) || keys.length === 0) break; 3961 3962 for (const key of keys) { 3963 const tokenId = Number(key); 3964 if (Number.isInteger(tokenId) && tokenId >= 0) { 3965 tokenIds.push(tokenId); 3966 } 3967 } 3968 3969 if (keys.length < limit) break; 3970 offset += keys.length; 3971 } 3972 3973 return [...new Set(tokenIds)].sort((a, b) => a - b); 3974} 3975 3976async function deprecateStagingContracts(options = {}) { 3977 const network = options.network || 'mainnet'; 3978 const apply = options.apply === true; 3979 const providedAddresses = Array.isArray(options.addresses) 3980 ? options.addresses.filter((address) => isKt1Address(address)) 3981 : []; 3982 const replacementContract = isKt1Address(options.replacementContract) 3983 ? options.replacementContract 3984 : null; 3985 const burnLimitRaw = Number(options.burnLimit); 3986 const burnLimit = Number.isFinite(burnLimitRaw) && burnLimitRaw > 0 3987 ? Math.floor(burnLimitRaw) 3988 : Number.POSITIVE_INFINITY; 3989 3990 const { tezos, credentials, config } = await createTezosClient(network); 3991 3992 let contractAddresses = providedAddresses; 3993 if (contractAddresses.length === 0) { 3994 contractAddresses = await discoverContractsByManager(credentials.address, network); 3995 } 3996 3997 if (contractAddresses.length === 0) { 3998 throw new Error(`No contracts found for ${credentials.address} on ${network}`); 3999 } 4000 4001 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 4002 console.log('║ 🧹 Deprecating Staging Contracts ║'); 4003 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 4004 4005 console.log(`📡 Network: ${config.name}`); 4006 console.log(`👤 Wallet: ${credentials.address}`); 4007 console.log(`📦 Contracts: ${contractAddresses.length}`); 4008 if (!apply) { 4009 console.log('⚠️ DRY RUN (no transactions will be sent). Use --yes to apply.\n'); 4010 } else { 4011 console.log('⚠️ APPLY MODE (transactions will be sent).\n'); 4012 } 4013 4014 const summary = []; 4015 4016 for (const contractAddress of contractAddresses) { 4017 const row = { 4018 contractAddress, 4019 paused: null, 4020 metadataLocked: null, 4021 activeTokenCount: 0, 4022 burned: 0, 4023 operations: [], 4024 skipped: false, 4025 errors: [], 4026 }; 4027 4028 summary.push(row); 4029 console.log(`\n──────────────────────────────────────────────────────────────`); 4030 console.log(`📍 ${contractAddress}`); 4031 console.log(`🔗 ${config.explorer}/${contractAddress}`); 4032 4033 try { 4034 const contract = await tezos.contract.at(contractAddress); 4035 const storage = await contract.storage(); 4036 4037 const looksLikeKeeps = storage 4038 && storage.metadata 4039 && storage.token_metadata 4040 && storage.keep_fee !== undefined 4041 && storage.default_royalty_bps !== undefined; 4042 4043 if (!looksLikeKeeps) { 4044 row.skipped = true; 4045 console.log(' ⏭️ Skipping (does not match Keeps storage shape)'); 4046 continue; 4047 } 4048 4049 row.paused = storage.paused === true; 4050 row.metadataLocked = storage.contract_metadata_locked === true; 4051 const nextTokenId = Number(storage.next_token_id?.toNumber?.() ?? storage.next_token_id ?? 0); 4052 const tokenIds = await fetchActiveTokenIds(contractAddress, network); 4053 row.activeTokenCount = tokenIds.length; 4054 4055 console.log(` • Paused: ${row.paused}`); 4056 console.log(` • Metadata locked: ${row.metadataLocked}`); 4057 console.log(` • Next token id: ${nextTokenId}`); 4058 console.log(` • Active tokens: ${row.activeTokenCount}`); 4059 4060 if (!apply) { 4061 continue; 4062 } 4063 4064 if (!row.metadataLocked) { 4065 try { 4066 const existingContent = await storage.metadata.get('content'); 4067 const existingMetadata = decodeContractMetadataBytes(existingContent) || {}; 4068 const deprecatedMetadata = buildDeprecatedCollectionMetadata(existingMetadata, { 4069 replacementContract, 4070 }); 4071 const metadataBytes = stringToBytes(JSON.stringify(deprecatedMetadata)); 4072 const metadataOp = await contract.methods.set_contract_metadata([ 4073 { key: 'content', value: `0x${metadataBytes}` }, 4074 ]).send(); 4075 console.log(` ⏳ Deprecation metadata op: ${metadataOp.hash}`); 4076 await metadataOp.confirmation(1); 4077 row.operations.push({ step: 'set_contract_metadata', hash: metadataOp.hash }); 4078 console.log(' ✅ Collection metadata marked deprecated'); 4079 } catch (error) { 4080 row.errors.push(`set_contract_metadata: ${error.message}`); 4081 console.log(` ⚠️ Could not set deprecation metadata: ${error.message}`); 4082 } 4083 } else { 4084 console.log(' ℹ️ Metadata already locked; cannot write deprecation notice'); 4085 } 4086 4087 if (!row.paused) { 4088 try { 4089 const pauseOp = await contract.methodsObject.pause().send(); 4090 console.log(` ⏳ Pause op: ${pauseOp.hash}`); 4091 await pauseOp.confirmation(1); 4092 row.operations.push({ step: 'pause', hash: pauseOp.hash }); 4093 row.paused = true; 4094 console.log(' ✅ Contract paused'); 4095 } catch (error) { 4096 row.errors.push(`pause: ${error.message}`); 4097 console.log(` ⚠️ Could not pause: ${error.message}`); 4098 } 4099 } else { 4100 console.log(' ℹ️ Already paused'); 4101 } 4102 4103 const burnQueue = Number.isFinite(burnLimit) 4104 ? tokenIds.slice(0, burnLimit) 4105 : tokenIds; 4106 4107 if (burnQueue.length > 0) { 4108 console.log(` 🔥 Burning ${burnQueue.length} token(s)...`); 4109 } 4110 4111 for (const tokenId of burnQueue) { 4112 try { 4113 const burnOp = await contract.methods.burn_keep(tokenId).send(); 4114 console.log(` ⏳ burn #${tokenId}: ${burnOp.hash}`); 4115 await burnOp.confirmation(1); 4116 row.operations.push({ step: 'burn_keep', tokenId, hash: burnOp.hash }); 4117 row.burned += 1; 4118 } catch (error) { 4119 row.errors.push(`burn_keep(${tokenId}): ${error.message}`); 4120 console.log(` ⚠️ burn #${tokenId} failed: ${error.message}`); 4121 } 4122 } 4123 4124 if (!row.metadataLocked) { 4125 try { 4126 const lockOp = await contract.methods.lock_contract_metadata().send(); 4127 console.log(` ⏳ Lock metadata op: ${lockOp.hash}`); 4128 await lockOp.confirmation(1); 4129 row.operations.push({ step: 'lock_contract_metadata', hash: lockOp.hash }); 4130 row.metadataLocked = true; 4131 console.log(' ✅ Collection metadata locked'); 4132 } catch (error) { 4133 row.errors.push(`lock_contract_metadata: ${error.message}`); 4134 console.log(` ⚠️ Could not lock metadata: ${error.message}`); 4135 } 4136 } else { 4137 console.log(' ℹ️ Metadata already locked'); 4138 } 4139 } catch (error) { 4140 row.errors.push(error.message); 4141 console.log(` ❌ Contract processing failed: ${error.message}`); 4142 } 4143 } 4144 4145 console.log('\n══════════════════════════════════════════════════════════════'); 4146 console.log('📋 Staging Deprecation Summary'); 4147 for (const row of summary) { 4148 console.log(` ${row.contractAddress}`); 4149 if (row.skipped) { 4150 console.log(' - skipped'); 4151 continue; 4152 } 4153 console.log(` - paused: ${row.paused}`); 4154 console.log(` - metadataLocked: ${row.metadataLocked}`); 4155 console.log(` - activeTokens(before): ${row.activeTokenCount}`); 4156 console.log(` - burned: ${row.burned}`); 4157 console.log(` - ops: ${row.operations.length}`); 4158 if (row.errors.length > 0) { 4159 console.log(` - errors: ${row.errors.length}`); 4160 row.errors.forEach((message) => console.log(`${message}`)); 4161 } 4162 } 4163 console.log(''); 4164 4165 return { 4166 network, 4167 wallet: credentials.address, 4168 apply, 4169 contracts: summary, 4170 }; 4171} 4172 4173// ============================================================================ 4174// CLI Interface 4175// ============================================================================ 4176 4177async function main() { 4178 const rawArgs = process.argv.slice(2); 4179 4180 // Separate flags from positional arguments 4181 const flags = rawArgs.filter(a => a.startsWith('--')); 4182 const args = rawArgs.filter(a => !a.startsWith('--')); 4183 const command = args[0]; 4184 const contractFlag = flags.find(f => f.startsWith('--contract=') || f.startsWith('--profile=')); 4185 const contractProfile = contractFlag ? contractFlag.split('=').slice(1).join('=').trim() : 'v9'; 4186 4187 // Parse --wallet flag 4188 const walletFlag = flags.find(f => f.startsWith('--wallet=')); 4189 if (walletFlag) { 4190 const wallet = walletFlag.split('=')[1]; 4191 if (['keeps', 'kidlisp', 'aesthetic', 'staging'].includes(wallet)) { 4192 setWallet(wallet); 4193 console.log(`🔑 Using wallet: ${wallet}\n`); 4194 } else { 4195 console.error(`❌ Unknown wallet: ${wallet}. Use: keeps, kidlisp, aesthetic, or staging`); 4196 process.exit(1); 4197 } 4198 } 4199 4200 // Helper to get network from args (defaults to mainnet) 4201 const getNetwork = (argIndex) => { 4202 const val = args[argIndex]; 4203 if (!val || val.startsWith('--')) return 'mainnet'; 4204 return val; 4205 }; 4206 4207 try { 4208 switch (command) { 4209 case 'deploy': 4210 await deployContract(getNetwork(1), { contractProfile }); 4211 break; 4212 4213 case 'sync-secrets': 4214 await syncCurrentContractToSecrets(getNetwork(1), { contractProfile }); 4215 break; 4216 4217 case 'status': 4218 await getContractStatus(getNetwork(1)); 4219 break; 4220 4221 case 'balance': 4222 await getBalance(getNetwork(1)); 4223 break; 4224 4225 case 'wallets': 4226 await getAllWalletBalances(); 4227 break; 4228 4229 case 'tokens': { 4230 const limitFlag = flags.find(f => f.startsWith('--limit=')); 4231 const limit = limitFlag ? Number.parseInt(limitFlag.split('=')[1], 10) : undefined; 4232 await listOwnedTokens(getNetwork(1), { limit }); 4233 break; 4234 } 4235 4236 case 'market': { 4237 const listingsLimitFlag = flags.find(f => f.startsWith('--listings=')); 4238 const salesLimitFlag = flags.find(f => f.startsWith('--sales=')); 4239 const listingsLimit = listingsLimitFlag ? Number.parseInt(listingsLimitFlag.split('=')[1], 10) : undefined; 4240 const salesLimit = salesLimitFlag ? Number.parseInt(salesLimitFlag.split('=')[1], 10) : undefined; 4241 await showMarketSnapshot(getNetwork(1), { listingsLimit, salesLimit }); 4242 break; 4243 } 4244 4245 case 'sell': { 4246 if (!args[1] || !args[2]) { 4247 console.error('Usage: node keeps.mjs sell <token_id|$piece> <price_xtz> [network] [--marketplace=<KT1...>] [--replace] [--yes]'); 4248 process.exit(1); 4249 } 4250 4251 const marketplaceFlag = flags.find(f => f.startsWith('--marketplace=')); 4252 const referralFlag = flags.find(f => f.startsWith('--referral-bps=')); 4253 const startFlag = flags.find(f => f.startsWith('--start=')); 4254 const expiryFlag = flags.find(f => f.startsWith('--expiry=')); 4255 const marketplaceContract = marketplaceFlag ? marketplaceFlag.split('=').slice(1).join('=').trim() : null; 4256 const referralBonusBps = referralFlag ? Number.parseInt(referralFlag.split('=')[1], 10) : undefined; 4257 const startTime = parseOptionalIsoTimestamp(startFlag ? startFlag.split('=').slice(1).join('=') : null, 'start'); 4258 const expiryTime = parseOptionalIsoTimestamp(expiryFlag ? expiryFlag.split('=').slice(1).join('=') : null, 'expiry'); 4259 4260 await listTokenForSale(args[1], args[2], { 4261 network: getNetwork(3), 4262 marketplaceContract, 4263 referralBonusBps, 4264 startTime, 4265 expiryTime, 4266 replaceExisting: flags.includes('--replace'), 4267 apply: flags.includes('--yes') || flags.includes('--apply'), 4268 }); 4269 break; 4270 } 4271 4272 case 'sell:batch': { 4273 const inputItems = args.slice(1); 4274 if (inputItems.length === 0) { 4275 console.error('Usage: node keeps.mjs sell:batch <token|piece=price_xtz> [...] [network] [--marketplace=<KT1...>] [--replace] [--yes]'); 4276 process.exit(1); 4277 } 4278 4279 let network = 'mainnet'; 4280 if (['mainnet', 'ghostnet'].includes(inputItems[inputItems.length - 1])) { 4281 network = inputItems.pop(); 4282 } 4283 4284 if (inputItems.length === 0) { 4285 console.error('❌ No batch items supplied. Example: node keeps.mjs sell:batch \'$faim=5.5\' \'$tezz=5.8\' \'$bip=6\' --yes'); 4286 process.exit(1); 4287 } 4288 4289 const marketplaceFlag = flags.find(f => f.startsWith('--marketplace=')); 4290 const referralFlag = flags.find(f => f.startsWith('--referral-bps=')); 4291 const startFlag = flags.find(f => f.startsWith('--start=')); 4292 const expiryFlag = flags.find(f => f.startsWith('--expiry=')); 4293 const marketplaceContract = marketplaceFlag ? marketplaceFlag.split('=').slice(1).join('=').trim() : null; 4294 const referralBonusBps = referralFlag ? Number.parseInt(referralFlag.split('=')[1], 10) : undefined; 4295 const startTime = parseOptionalIsoTimestamp(startFlag ? startFlag.split('=').slice(1).join('=') : null, 'start'); 4296 const expiryTime = parseOptionalIsoTimestamp(expiryFlag ? expiryFlag.split('=').slice(1).join('=') : null, 'expiry'); 4297 4298 await listBatchForSale(inputItems, { 4299 network, 4300 marketplaceContract, 4301 referralBonusBps, 4302 startTime, 4303 expiryTime, 4304 replaceExisting: flags.includes('--replace'), 4305 apply: flags.includes('--yes') || flags.includes('--apply'), 4306 }); 4307 break; 4308 } 4309 4310 case 'accept': { 4311 if (!args[1]) { 4312 console.error('Usage: node keeps.mjs accept <offer_id> [network] [--marketplace=<KT1...>] [--min=<xtz>] [--yes]'); 4313 process.exit(1); 4314 } 4315 4316 const marketplaceFlag = flags.find(f => f.startsWith('--marketplace=')); 4317 const minFlag = flags.find(f => f.startsWith('--min=')); 4318 const marketplaceContract = marketplaceFlag ? marketplaceFlag.split('=').slice(1).join('=').trim() : null; 4319 4320 let minPriceMutez = null; 4321 if (minFlag) { 4322 const raw = minFlag.split('=').slice(1).join('=').trim(); 4323 const minXTZ = Number.parseFloat(raw); 4324 if (!Number.isFinite(minXTZ) || minXTZ < 0) { 4325 console.error(`❌ Invalid --min value "${raw}". Expected a non-negative XTZ amount.`); 4326 process.exit(1); 4327 } 4328 minPriceMutez = Math.round(minXTZ * 1_000_000); 4329 } 4330 4331 await acceptOffer(args[1], { 4332 network: getNetwork(2), 4333 marketplaceContract, 4334 minPriceMutez, 4335 apply: flags.includes('--yes') || flags.includes('--apply'), 4336 }); 4337 break; 4338 } 4339 4340 case 'accept:auto': { 4341 const inputItems = args.slice(1); 4342 if (inputItems.length === 0) { 4343 console.error('Usage: node keeps.mjs accept:auto <token|piece=min_xtz> [...] [network] [--marketplace=<KT1...>] [--yes]'); 4344 process.exit(1); 4345 } 4346 4347 let network = 'mainnet'; 4348 if (['mainnet', 'ghostnet'].includes(inputItems[inputItems.length - 1])) { 4349 network = inputItems.pop(); 4350 } 4351 4352 if (inputItems.length === 0) { 4353 console.error('❌ No accept:auto items supplied. Example: node keeps.mjs accept:auto \'$faim=8\' \'$tezz=9.5\' --yes'); 4354 process.exit(1); 4355 } 4356 4357 const marketplaceFlag = flags.find(f => f.startsWith('--marketplace=')); 4358 const marketplaceContract = marketplaceFlag ? marketplaceFlag.split('=').slice(1).join('=').trim() : null; 4359 4360 await acceptOffersAboveThreshold(inputItems, { 4361 network, 4362 marketplaceContract, 4363 apply: flags.includes('--yes') || flags.includes('--apply'), 4364 }); 4365 break; 4366 } 4367 4368 case 'buy': { 4369 if (!args[1]) { 4370 console.error('Usage: node keeps.mjs buy <ask_id> [network] [--marketplace=<KT1...>] [--yes]'); 4371 console.error(''); 4372 console.error('Fulfill an active Objkt marketplace listing (ask) to buy a token.'); 4373 console.error(''); 4374 console.error('Examples:'); 4375 console.error(' node keeps.mjs buy 12589569 --wallet=aesthetic # Dry run'); 4376 console.error(' node keeps.mjs buy 12589569 --wallet=aesthetic --yes # Live purchase'); 4377 process.exit(1); 4378 } 4379 4380 const buyMarketFlag = flags.find(f => f.startsWith('--marketplace=')); 4381 const buyMarketplace = buyMarketFlag ? buyMarketFlag.split('=').slice(1).join('=').trim() : null; 4382 4383 await buyToken(args[1], { 4384 network: getNetwork(2), 4385 marketplaceContract: buyMarketplace, 4386 apply: flags.includes('--yes') || flags.includes('--apply'), 4387 }); 4388 break; 4389 } 4390 4391 case 'upload': 4392 if (!args[1]) { 4393 console.error('Usage: node keeps.mjs upload <piece>'); 4394 process.exit(1); 4395 } 4396 await uploadToIPFS(args[1]); 4397 break; 4398 4399 case 'mint': 4400 case 'keep': { 4401 if (!args[1]) { 4402 console.error('Usage: node keeps.mjs keep <piece> [network] [--thumbnail] [--to=<address>] [--yes]'); 4403 process.exit(1); 4404 } 4405 const toFlag = flags.find(f => f.startsWith('--to=')); 4406 const recipientAddr = toFlag ? toFlag.split('=')[1] : null; 4407 await mintToken(args[1], { 4408 network: getNetwork(2), 4409 generateThumbnail: flags.includes('--thumbnail'), 4410 recipient: recipientAddr, 4411 skipConfirm: flags.includes('--yes') || flags.includes('-y') 4412 }); 4413 break; 4414 } 4415 4416 case 'update': 4417 if (!args[1] || !args[2]) { 4418 console.error('Usage: node keeps.mjs update <token_id> <piece> [--thumbnail]'); 4419 process.exit(1); 4420 } 4421 await updateMetadata(parseInt(args[1]), args[2], { 4422 network: getNetwork(3), 4423 generateThumbnail: flags.includes('--thumbnail') 4424 }); 4425 break; 4426 4427 case 'lock': 4428 if (!args[1]) { 4429 console.error('Usage: node keeps.mjs lock <token_id>'); 4430 process.exit(1); 4431 } 4432 await lockMetadata(parseInt(args[1]), { network: getNetwork(2) }); 4433 break; 4434 4435 case 'burn': 4436 if (!args[1]) { 4437 console.error('Usage: node keeps.mjs burn <token_id>'); 4438 process.exit(1); 4439 } 4440 await burnToken(parseInt(args[1]), { network: getNetwork(2) }); 4441 break; 4442 4443 case 'redact': { 4444 if (!args[1]) { 4445 console.error('Usage: node keeps.mjs redact <token_id> [--reason="..."]'); 4446 process.exit(1); 4447 } 4448 const reasonFlag = flags.find(f => f.startsWith('--reason=')); 4449 const reason = reasonFlag ? reasonFlag.split('=').slice(1).join('=') : 'Content has been redacted.'; 4450 await redactToken(parseInt(args[1]), { network: getNetwork(2), reason }); 4451 break; 4452 } 4453 4454 case 'set-collection-media': { 4455 // Parse --name=<text>, --image=<uri>, --homepage=<url> and --description=<text> flags 4456 const nameFlag = flags.find(f => f.startsWith('--name=')); 4457 const imageFlag = flags.find(f => f.startsWith('--image=')); 4458 const homepageFlag = flags.find(f => f.startsWith('--homepage=')); 4459 const descFlag = flags.find(f => f.startsWith('--description=')); 4460 4461 const name = nameFlag ? nameFlag.split('=').slice(1).join('=') : undefined; 4462 const imageUri = imageFlag ? imageFlag.split('=').slice(1).join('=') : undefined; 4463 const homepage = homepageFlag ? homepageFlag.split('=').slice(1).join('=') : undefined; 4464 const description = descFlag ? descFlag.split('=').slice(1).join('=') : undefined; 4465 4466 if (!name && !imageUri && !homepage && !description) { 4467 console.error('Usage: node keeps.mjs set-collection-media [--name=<text>] [--image=<ipfs-uri>] [--homepage=<url>] [--description=<text>]'); 4468 console.error(''); 4469 console.error('Examples:'); 4470 console.error(' node keeps.mjs set-collection-media --name="KidLisp Keeps (Staging)"'); 4471 console.error(' node keeps.mjs set-collection-media --image=ipfs://Qm...'); 4472 console.error(' node keeps.mjs set-collection-media --image=https://oven.aesthetic.computer/keeps/latest'); 4473 console.error(' node keeps.mjs set-collection-media --homepage=https://keep.kidlisp.com'); 4474 console.error(' node keeps.mjs set-collection-media --description="KidLisp generative art collection"'); 4475 process.exit(1); 4476 } 4477 4478 await setCollectionMedia({ 4479 network: getNetwork(1), 4480 name, 4481 imageUri, 4482 homepage, 4483 description 4484 }); 4485 break; 4486 } 4487 4488 case 'lock-collection': 4489 await lockCollectionMetadata({ network: getNetwork(1) }); 4490 break; 4491 4492 case 'deprecate-staging': { 4493 const addressesFlag = flags.find(f => f.startsWith('--addresses=')); 4494 const addresses = addressesFlag 4495 ? addressesFlag.split('=').slice(1).join('=').split(',').map(v => v.trim()).filter(Boolean) 4496 : []; 4497 const replacementFlag = flags.find(f => f.startsWith('--replacement=')); 4498 const replacementContract = replacementFlag ? replacementFlag.split('=').slice(1).join('=').trim() : null; 4499 const burnLimitFlag = flags.find(f => f.startsWith('--burn-limit=')); 4500 const burnLimit = burnLimitFlag ? Number.parseInt(burnLimitFlag.split('=')[1], 10) : undefined; 4501 const apply = flags.includes('--yes') || flags.includes('--apply'); 4502 4503 await deprecateStagingContracts({ 4504 network: getNetwork(1), 4505 addresses, 4506 replacementContract, 4507 burnLimit, 4508 apply, 4509 }); 4510 break; 4511 } 4512 4513 case 'fee': 4514 // Show current keep fee 4515 const feeInfo = await getKeepFee(getNetwork(1)); 4516 console.log('\n╔══════════════════════════════════════════════════════════════╗'); 4517 console.log('║ 💰 Current Keep Fee ║'); 4518 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 4519 console.log(` Contract: ${feeInfo.contractAddress}`); 4520 console.log(` Keep Fee: ${feeInfo.feeInTez} XTZ (${feeInfo.feeInMutez} mutez)\n`); 4521 break; 4522 4523 case 'set-fee': { 4524 if (!args[1]) { 4525 console.error('Usage: node keeps.mjs set-fee <amount_in_tez>'); 4526 console.error(''); 4527 console.error('Examples:'); 4528 console.error(' node keeps.mjs set-fee 5 # Set fee to 5 XTZ'); 4529 console.error(' node keeps.mjs set-fee 0 # Free keeping'); 4530 console.error(' node keeps.mjs set-fee 0.5 # Set fee to 0.5 XTZ'); 4531 process.exit(1); 4532 } 4533 const feeAmount = parseFloat(args[1]); 4534 if (isNaN(feeAmount) || feeAmount < 0) { 4535 console.error('❌ Invalid fee amount. Must be a non-negative number.'); 4536 process.exit(1); 4537 } 4538 await setKeepFee(feeAmount, { network: getNetwork(2) }); 4539 break; 4540 } 4541 4542 case 'withdraw': { 4543 const dest = args[1]; // Optional destination address 4544 await withdrawFees(dest, { network: getNetwork(dest ? 2 : 1) }); 4545 break; 4546 } 4547 4548 case 'set-admin': { 4549 if (!args[1]) { 4550 console.error('Usage: node keeps.mjs set-admin <new_admin_address>'); 4551 console.error(''); 4552 console.error('This changes the contract administrator. Only the current admin can call this.'); 4553 console.error(''); 4554 console.error('Examples:'); 4555 console.error(' node keeps.mjs set-admin tz1abc... # Set new admin'); 4556 process.exit(1); 4557 } 4558 const newAdmin = args[1]; 4559 if (!newAdmin.startsWith('tz1') && !newAdmin.startsWith('tz2') && !newAdmin.startsWith('tz3')) { 4560 console.error('❌ Invalid Tezos address. Must start with tz1, tz2, or tz3.'); 4561 process.exit(1); 4562 } 4563 await setAdministrator(newAdmin, { network: getNetwork(2) }); 4564 break; 4565 } 4566 4567 // v4 NEW COMMANDS 4568 case 'royalty': 4569 case 'royalty:get': 4570 await getRoyalty(getNetwork(1)); 4571 break; 4572 4573 case 'royalty:set': { 4574 if (!args[1]) { 4575 console.error('Usage: node keeps.mjs royalty:set <percentage> [network]'); 4576 console.error(''); 4577 console.error('Examples:'); 4578 console.error(' node keeps.mjs royalty:set 10 # Set royalty to 10%'); 4579 console.error(' node keeps.mjs royalty:set 15 # Set royalty to 15%'); 4580 console.error(' node keeps.mjs royalty:set 0 # No royalties'); 4581 console.error(''); 4582 console.error('Maximum: 25%'); 4583 process.exit(1); 4584 } 4585 const percentage = parseFloat(args[1]); 4586 if (isNaN(percentage)) { 4587 console.error('❌ Invalid percentage. Must be a number.'); 4588 process.exit(1); 4589 } 4590 await setRoyalty(percentage, { network: getNetwork(2) }); 4591 break; 4592 } 4593 4594 case 'pause': 4595 await pauseContract({ network: getNetwork(1) }); 4596 break; 4597 4598 case 'unpause': 4599 await unpauseContract({ network: getNetwork(1) }); 4600 break; 4601 4602 case 'send': { 4603 if (!args[1] || !args[2]) { 4604 console.error('Usage: node keeps.mjs send <to_address> <amount_xtz> [network]'); 4605 console.error(''); 4606 console.error('Examples:'); 4607 console.error(' node keeps.mjs send aesthetic.tez 3 --wallet=staging --yes'); 4608 process.exit(1); 4609 } 4610 const sendTo = args[1]; 4611 const sendAmt = parseFloat(args[2]); 4612 if (isNaN(sendAmt) || sendAmt <= 0) { 4613 console.error('❌ Invalid amount.'); 4614 process.exit(1); 4615 } 4616 await sendTez(sendTo, sendAmt, getNetwork(3)); 4617 break; 4618 } 4619 4620 case 'transfer': { 4621 if (!args[1] || !args[2]) { 4622 console.error('Usage: node keeps.mjs transfer <token_id> <to_address> [network]'); 4623 console.error(''); 4624 console.error('Examples:'); 4625 console.error(' node keeps.mjs transfer 53 reas.tez --wallet=aesthetic --yes'); 4626 console.error(''); 4627 console.error('Automatically retracts active Objkt listing if one exists.'); 4628 process.exit(1); 4629 } 4630 const xferTokenId = parseInt(args[1]); 4631 const xferTo = args[2]; 4632 if (!xferTo.startsWith('tz') && !xferTo.endsWith('.tez')) { 4633 console.error('❌ Invalid Tezos address.'); 4634 process.exit(1); 4635 } 4636 await transferToken(xferTokenId, xferTo, getNetwork(3)); 4637 break; 4638 } 4639 4640 case 'transfer:admin': { 4641 console.error('⚠️ transfer:admin is deprecated (v10 contract has no admin_transfer).'); 4642 console.error(' Use: node keeps.mjs transfer <token_id> <to_address> --wallet=aesthetic'); 4643 process.exit(1); 4644 } 4645 4646 case 'help': 4647 default: 4648 console.log(` 4649╔══════════════════════════════════════════════════════════════╗ 4650║ 🔮 Keeps - Tezos FA2 Contract Manager ║ 4651╚══════════════════════════════════════════════════════════════╝ 4652 4653Usage: node keeps.mjs <command> [options] 4654 4655Commands: 4656 deploy [network] Deploy contract (default profile: v10) 4657 sync-secrets [network] Sync active contract/profile to Mongo secrets 4658 status [network] Show contract status 4659 balance [network] Check wallet balance 4660 wallets Show all wallet balances (XTZ, ETH, SOL, BTC, ADA) 4661 tokens [network] List tokens held by current wallet 4662 market [network] Show Objkt listings/offers/sales snapshot 4663 upload <piece> Upload bundle to IPFS 4664 mint <piece> [network] Mint a new keep 4665 sell <token> <price> [network] List token on Objkt (dry-run unless --yes) 4666 sell:batch <ref=price>... Batch-list multiple tokens on Objkt 4667 accept <offer_id> [network] Accept one active Objkt offer (dry-run unless --yes) 4668 accept:auto <ref=min>... Accept best offers above per-token thresholds 4669 buy <ask_id> [network] Buy a listed token (fulfill_ask, dry-run unless --yes) 4670 update <token_id> <piece> Update token metadata (re-upload bundle) 4671 lock <token_id> Permanently lock token metadata 4672 burn <token_id> Burn token (allows re-keeping piece) 4673 redact <token_id> Censor token (replace with redacted content) 4674 set-collection-media Set collection icon/description 4675 lock-collection Permanently lock collection metadata 4676 deprecate-staging [network] Pause + deprecate + burn staging contracts 4677 fee [network] Show current keep fee 4678 set-fee <tez> [network] Set keep fee (admin only) 4679 set-admin <address> Change contract administrator (admin only) 4680 withdraw [dest] [network] Withdraw accumulated fees to address 4681 4682v4 Commands (Royalties, Pause, Admin Transfer): 4683 royalty [network] Show current default royalty percentage 4684 royalty:set <pct> [network] Set default royalty (0-25%, admin only) 4685 pause [network] Emergency pause (stops minting, admin only) 4686 unpause [network] Resume operations (admin only) 4687 transfer <id> <to> Transfer token (auto-unlists, supports .tez domains) 4688 4689 help Show this help 4690 4691Networks: 4692 mainnet Tezos mainnet (default) 4693 ghostnet Tezos ghostnet (testnet) 4694 4695Flags: 4696 --contract=<profile> Deploy profile: v10 | v9 | v8 | v7 | v6 | v5rc | v4 4697 --thumbnail Generate animated WebP thumbnail via Oven 4698 and upload to IPFS (requires Oven service) 4699 --to=<address> Recipient wallet address (default: server wallet) 4700 --limit=<n> Max rows for tokens command 4701 --listings=<n> Max rows for market listings/offers 4702 --sales=<n> Max rows for market sales 4703 --marketplace=<KT1...> Override marketplace contract for sell/accept 4704 --replace Retract existing ask before listing new price 4705 --min=<xtz> Minimum bid filter for accept <offer_id> 4706 --referral-bps=<n> Referral bonus bps for marketplace ask (default: 500) 4707 --start=<iso8601> Scheduled listing start time 4708 --expiry=<iso8601> Listing expiry time 4709 --image=<uri> Collection image URI (IPFS or URL) 4710 --homepage=<url> Collection homepage URL 4711 --description=<text> Collection description 4712 --addresses=<KT1,KT1,...> Explicit contract list for deprecate-staging 4713 --replacement=<KT1...> Replacement contract for deprecation notice 4714 --burn-limit=<n> Limit number of burns per contract 4715 --yes / --apply Send live transactions for destructive commands 4716 4717Examples: 4718 node keeps.mjs deploy mainnet --wallet=kidlisp --contract=v9 4719 node keeps.mjs sync-secrets mainnet --contract=v9 4720 node keeps.mjs deploy mainnet --wallet=kidlisp --contract=v8 4721 node keeps.mjs deploy mainnet --wallet=kidlisp --contract=v7 4722 node keeps.mjs deploy mainnet --wallet=kidlisp --contract=v6 4723 node keeps.mjs deploy mainnet --wallet=staging --contract=v5rc 4724 node keeps.mjs deploy ghostnet --wallet=aesthetic --contract=v4 4725 node keeps.mjs balance 4726 node keeps.mjs tokens --wallet=aesthetic 4727 node keeps.mjs market 4728 node keeps.mjs mint wand --thumbnail # With IPFS thumbnail 4729 node keeps.mjs mint wand --to=tz1abc... # Mint to specific wallet 4730 node keeps.mjs sell '$bip' 6 --wallet=aesthetic --yes 4731 node keeps.mjs sell:batch '$faim=5.5' '$tezz=5.8' '$bip=6' --wallet=aesthetic --yes 4732 node keeps.mjs accept 12179750 --wallet=aesthetic --min=13 --yes 4733 node keeps.mjs accept:auto '19=8.5' '20=9' '21=10' '22=10.5' '23=12' '24=13' --wallet=aesthetic --yes 4734 node keeps.mjs buy 12589569 --wallet=aesthetic --yes 4735 node keeps.mjs update 0 wand # Re-upload bundle & update metadata 4736 node keeps.mjs lock 0 # Permanently lock token 0 4737 node keeps.mjs burn 0 # Burn token 0 (allows re-mint) 4738 4739v4 Examples: 4740 node keeps.mjs royalty:set 10 # Set royalty to 10% 4741 node keeps.mjs royalty # View current royalty 4742 node keeps.mjs pause # Emergency pause 4743 node keeps.mjs unpause # Resume operations 4744 node keeps.mjs transfer:admin 5 tz1... tz1... # Emergency transfer 4745 4746 # Fee management 4747 node keeps.mjs fee # Show current keep fee 4748 node keeps.mjs set-fee 5 # Set keep fee to 5 XTZ 4749 node keeps.mjs set-fee 0 # Make keeping free 4750 node keeps.mjs withdraw # Withdraw fees to admin wallet 4751 node keeps.mjs withdraw tz1abc... # Withdraw fees to specific address 4752 4753 # Collection media (use live endpoint for dynamic thumbnail) 4754 node keeps.mjs set-collection-media --image=https://oven.aesthetic.computer/keeps/latest 4755 node keeps.mjs set-collection-media --homepage=https://keep.kidlisp.com 4756 node keeps.mjs set-collection-media --image=ipfs://QmXxx --description="KidLisp art" 4757 node keeps.mjs lock-collection # Lock collection metadata forever 4758 node keeps.mjs deprecate-staging --wallet=staging # Dry run 4759 node keeps.mjs deprecate-staging --wallet=staging --yes --replacement=KT1... 4760 4761Environment: 4762 OVEN_URL Oven service URL (default: https://oven.aesthetic.computer) 4763`); 4764 } 4765 } catch (error) { 4766 console.error(`\n❌ Error: ${error.message}\n`); 4767 const details = formatExtendedError(error); 4768 if (details) { 4769 console.error(`${details}\n`); 4770 } 4771 process.exit(1); 4772 } 4773} 4774 4775// Run CLI if executed directly 4776if (process.argv[1] === fileURLToPath(import.meta.url)) { 4777 main(); 4778} 4779 4780// Export for use as module 4781export { 4782 createTezosClient, 4783 deployContract, 4784 syncCurrentContractToSecrets, 4785 getContractStatus, 4786 getBalance, 4787 getAllWalletBalances, 4788 listOwnedTokens, 4789 showMarketSnapshot, 4790 listTokenForSale, 4791 listBatchForSale, 4792 acceptOffer, 4793 acceptOffersAboveThreshold, 4794 buyToken, 4795 uploadToIPFS, 4796 mintToken, 4797 updateMetadata, 4798 lockMetadata, 4799 burnToken, 4800 redactToken, 4801 setCollectionMedia, 4802 lockCollectionMetadata, 4803 getKeepFee, 4804 setKeepFee, 4805 setAdministrator, 4806 withdrawFees, 4807 deprecateStagingContracts, 4808 detectContentType, 4809 loadCredentials, 4810 CONFIG 4811};