Monorepo for Aesthetic.Computer
aesthetic.computer
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};