@xpr-agents/openclaw 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +51 -10
  2. package/openclaw.plugin.json +15 -1
  3. package/package.json +7 -4
  4. package/skills/code-sandbox/SKILL.md +30 -0
  5. package/skills/code-sandbox/dist/index.js +188 -0
  6. package/skills/code-sandbox/skill.json +13 -0
  7. package/skills/code-sandbox/src/index.ts +212 -0
  8. package/skills/creative/SKILL.md +32 -0
  9. package/skills/creative/dist/index.js +667 -0
  10. package/skills/creative/skill.json +13 -0
  11. package/skills/creative/src/index.ts +679 -0
  12. package/skills/defi/SKILL.md +123 -0
  13. package/skills/defi/dist/index.js +1745 -0
  14. package/skills/defi/skill.json +44 -0
  15. package/skills/defi/src/index.ts +1788 -0
  16. package/skills/defi/test-read.mjs +281 -0
  17. package/skills/governance/SKILL.md +69 -0
  18. package/skills/governance/dist/index.js +632 -0
  19. package/skills/governance/skill.json +21 -0
  20. package/skills/governance/src/index.ts +656 -0
  21. package/skills/governance/test-read.mjs +176 -0
  22. package/skills/lending/SKILL.md +63 -0
  23. package/skills/lending/dist/index.js +1039 -0
  24. package/skills/lending/skill.json +29 -0
  25. package/skills/lending/src/index.ts +1105 -0
  26. package/skills/lending/test-read.mjs +156 -0
  27. package/skills/nft/SKILL.md +95 -0
  28. package/skills/nft/dist/index.js +1520 -0
  29. package/skills/nft/skill.json +37 -0
  30. package/skills/nft/src/index.ts +1539 -0
  31. package/skills/shellbook/SKILL.md +59 -0
  32. package/skills/shellbook/dist/index.js +381 -0
  33. package/skills/shellbook/skill.json +29 -0
  34. package/skills/shellbook/src/index.ts +391 -0
  35. package/skills/shellbook/tsconfig.json +14 -0
  36. package/skills/smart-contracts/SKILL.md +128 -0
  37. package/skills/smart-contracts/dist/index.js +1225 -0
  38. package/skills/smart-contracts/skill.json +25 -0
  39. package/skills/smart-contracts/src/index.ts +1327 -0
  40. package/skills/smart-contracts/tsconfig.json +14 -0
  41. package/skills/structured-data/SKILL.md +36 -0
  42. package/skills/structured-data/dist/index.js +501 -0
  43. package/skills/structured-data/skill.json +13 -0
  44. package/skills/structured-data/src/index.ts +597 -0
  45. package/skills/tax/SKILL.md +109 -0
  46. package/skills/tax/dist/index.js +1749 -0
  47. package/skills/tax/skill.json +20 -0
  48. package/skills/tax/src/index.ts +1985 -0
  49. package/skills/web-scraping/SKILL.md +29 -0
  50. package/skills/web-scraping/dist/index.js +311 -0
  51. package/skills/web-scraping/skill.json +13 -0
  52. package/skills/web-scraping/src/index.ts +371 -0
  53. package/skills/xmd/SKILL.md +52 -0
  54. package/skills/xmd/dist/index.js +596 -0
  55. package/skills/xmd/skill.json +22 -0
  56. package/skills/xmd/src/index.ts +635 -0
  57. package/skills/xmd/test-read.mjs +178 -0
@@ -0,0 +1,1539 @@
1
+ /**
2
+ * NFT Skill — Full AtomicAssets/AtomicMarket Integration
3
+ *
4
+ * Read-only tools use the Saltant AtomicAssets REST API (rich joined data).
5
+ * Write tools create sessions from env vars for signing transactions.
6
+ */
7
+
8
+ // ── Types ────────────────────────────────────────
9
+
10
+ interface ToolDef {
11
+ name: string;
12
+ description: string;
13
+ parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
14
+ handler: (params: any) => Promise<unknown>;
15
+ }
16
+
17
+ interface SkillApi {
18
+ registerTool(tool: ToolDef): void;
19
+ getConfig(): Record<string, unknown>;
20
+ }
21
+
22
+ // ── Session Factory ──────────────────────────────
23
+ // Backed by the proton CLI — agent process never holds a private key.
24
+
25
+ let cachedSession: { api: any; account: string; permission: string } | null = null;
26
+
27
+ async function getNftSession(): Promise<{ api: any; account: string; permission: string }> {
28
+ if (cachedSession) return cachedSession;
29
+
30
+ const account = process.env.XPR_ACCOUNT;
31
+ const permission = process.env.XPR_PERMISSION || 'active';
32
+ const rpcEndpoint = process.env.XPR_RPC_ENDPOINT;
33
+
34
+ if (!account) throw new Error('XPR_ACCOUNT is required for NFT write operations');
35
+
36
+ // @ts-ignore — provided by host at runtime; not resolvable when building skills inside the openclaw package
37
+
38
+ const { createCliApi } = await import('@xpr-agents/openclaw');
39
+ cachedSession = createCliApi({ account, permission, rpcEndpoint });
40
+ return cachedSession;
41
+ }
42
+
43
+ // ── AtomicAssets API Helpers ─────────────────────
44
+
45
+ const API_TIMEOUT = 15000;
46
+
47
+ function getAtomicApiEndpoints(network: string): string[] {
48
+ if (network === 'mainnet') {
49
+ return [
50
+ 'https://aa-xprnetwork-main.saltant.io',
51
+ 'https://xpr-mainnet-atm-api.bloxprod.io',
52
+ ];
53
+ }
54
+ // BloxProd first — Saltant testnet indexer is unreliable / often behind
55
+ return [
56
+ 'https://xpr-testnet-atm-api.bloxprod.io',
57
+ 'https://aa-xprnetwork-test.saltant.io',
58
+ ];
59
+ }
60
+
61
+ async function atomicGetSingle(base: string, path: string, params?: Record<string, string>): Promise<any> {
62
+ const url = new URL(path, base);
63
+ if (params) {
64
+ Object.entries(params).forEach(([k, v]) => {
65
+ if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
66
+ });
67
+ }
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT);
70
+ try {
71
+ const resp = await fetch(url.toString(), { signal: controller.signal });
72
+ if (!resp.ok) {
73
+ const text = await resp.text().catch(() => '');
74
+ throw new Error(`AtomicAssets API ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
75
+ }
76
+ const json = await resp.json();
77
+ if (!json.success) throw new Error(`AtomicAssets API error: ${JSON.stringify(json).slice(0, 200)}`);
78
+ return json.data;
79
+ } finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+
84
+ async function atomicGet(endpoints: string[], path: string, params?: Record<string, string>): Promise<any> {
85
+ let lastError: Error | null = null;
86
+ for (const base of endpoints) {
87
+ try {
88
+ return await atomicGetSingle(base, path, params);
89
+ } catch (err: any) {
90
+ lastError = err;
91
+ // Try next endpoint
92
+ }
93
+ }
94
+ throw lastError || new Error(`All AtomicAssets API endpoints failed for ${path}`);
95
+ }
96
+
97
+ // ── RPC Fallback Helper ─────────────────────────
98
+
99
+ async function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT);
102
+ try {
103
+ const resp = await fetch(`${endpoint}${path}`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify(body),
107
+ signal: controller.signal,
108
+ });
109
+ if (!resp.ok) {
110
+ const text = await resp.text().catch(() => '');
111
+ throw new Error(`RPC ${path} failed (${resp.status}): ${text.slice(0, 200)}`);
112
+ }
113
+ return await resp.json();
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ async function getTableRows(endpoint: string, opts: {
120
+ code: string; scope: string; table: string;
121
+ lower_bound?: string | number; upper_bound?: string | number;
122
+ limit?: number; key_type?: string; index_position?: string;
123
+ }): Promise<any[]> {
124
+ const result = await rpcPost(endpoint, '/v1/chain/get_table_rows', {
125
+ json: true,
126
+ code: opts.code,
127
+ scope: opts.scope,
128
+ table: opts.table,
129
+ lower_bound: opts.lower_bound,
130
+ upper_bound: opts.upper_bound,
131
+ limit: opts.limit || 100,
132
+ key_type: opts.key_type,
133
+ index_position: opts.index_position,
134
+ });
135
+ return result.rows || [];
136
+ }
137
+
138
+ // ── Auto RAM Management ─────────────────────────
139
+
140
+ const MIN_RAM_FREE_BYTES = 32768; // 32 KB threshold
141
+ const RAM_BUY_AMOUNT = '50.0000 XPR'; // Buy 50 XPR worth of RAM (~500KB)
142
+
143
+ async function ensureRam(session: { api: any; account: string; permission: string }): Promise<void> {
144
+ const rpcEndpoint = process.env.XPR_RPC_ENDPOINT;
145
+ if (!rpcEndpoint) return; // Can't check without RPC
146
+
147
+ try {
148
+ const acctInfo = await rpcPost(rpcEndpoint, '/v1/chain/get_account', { account_name: session.account });
149
+ const free = (acctInfo.ram_quota || 0) - (acctInfo.ram_usage || 0);
150
+
151
+ if (free < MIN_RAM_FREE_BYTES) {
152
+ console.log(`[nft] Low RAM: ${free} bytes free (threshold: ${MIN_RAM_FREE_BYTES}). Buying more...`);
153
+ await session.api.transact({
154
+ actions: [{
155
+ account: 'eosio',
156
+ name: 'buyram',
157
+ authorization: [{ actor: session.account, permission: session.permission }],
158
+ data: {
159
+ payer: session.account,
160
+ receiver: session.account,
161
+ quant: RAM_BUY_AMOUNT,
162
+ },
163
+ }],
164
+ }, { blocksBehind: 3, expireSeconds: 30 });
165
+ console.log(`[nft] Bought ${RAM_BUY_AMOUNT} worth of RAM for ${session.account}`);
166
+ }
167
+ } catch (err: any) {
168
+ // Non-fatal — log and continue, the actual NFT tx will fail with a clearer error if RAM is truly out
169
+ console.warn(`[nft] RAM check failed (non-fatal): ${err.message}`);
170
+ }
171
+ }
172
+
173
+ // ── Schema Format Fetcher (AA API + RPC fallback) ──
174
+
175
+ async function fetchSchemaFormat(
176
+ atomicEndpoints: string[],
177
+ rpcEndpoint: string,
178
+ collection_name: string,
179
+ schema_name: string,
180
+ ): Promise<Array<{ name: string; type: string }>> {
181
+ // Try AA API first
182
+ try {
183
+ const data = await atomicGet(atomicEndpoints, `/atomicassets/v1/schemas/${encodeURIComponent(collection_name)}/${encodeURIComponent(schema_name)}`);
184
+ if (data.format && data.format.length > 0) return data.format;
185
+ } catch { /* fall through to RPC */ }
186
+
187
+ // Fallback: read directly from chain (schemas table scoped by collection)
188
+ const rows = await getTableRows(rpcEndpoint, {
189
+ code: 'atomicassets',
190
+ scope: collection_name,
191
+ table: 'schemas',
192
+ lower_bound: schema_name,
193
+ upper_bound: schema_name,
194
+ limit: 1,
195
+ });
196
+ if (rows.length === 0) throw new Error(`Schema "${schema_name}" not found in collection "${collection_name}"`);
197
+ const format = rows[0].format;
198
+ if (!Array.isArray(format) || format.length === 0) {
199
+ throw new Error(`Schema "${schema_name}" has no attributes defined`);
200
+ }
201
+ return format;
202
+ }
203
+
204
+ // ── ATTRIBUTE_MAP Builder ────────────────────────
205
+
206
+ function buildAttributeMap(
207
+ data: Record<string, any>,
208
+ schemaFormat: Array<{ name: string; type: string }>,
209
+ ): Array<{ key: string; value: [string, any] }> {
210
+ const typeMap = new Map(schemaFormat.map(f => [f.name, f.type]));
211
+ return Object.entries(data).map(([key, val]) => {
212
+ const type = typeMap.get(key);
213
+ if (!type) throw new Error(`Attribute "${key}" not found in schema`);
214
+ if (['string', 'image', 'ipfs'].includes(type)) return { key, value: ['string', String(val)] };
215
+ if (type.startsWith('uint')) return { key, value: [type, Number(val)] };
216
+ if (type.startsWith('int')) return { key, value: [type, Number(val)] };
217
+ if (['float', 'double'].includes(type)) return { key, value: ['double', Number(val)] };
218
+ if (type === 'bool') return { key, value: ['uint8', val ? 1 : 0] };
219
+ return { key, value: ['string', String(val)] };
220
+ });
221
+ }
222
+
223
+ // ── Token Contract Resolution ────────────────────
224
+
225
+ const TOKEN_CONTRACTS: Record<string, string> = {
226
+ XPR: 'eosio.token',
227
+ XUSDC: 'xtokens',
228
+ XBTC: 'xtokens',
229
+ XETH: 'xtokens',
230
+ METAL: 'xtokens',
231
+ FOOBAR: 'xtokens',
232
+ XDOGE: 'xtokens',
233
+ XLTC: 'xtokens',
234
+ };
235
+
236
+ function getTokenContract(symbol: string): string {
237
+ return TOKEN_CONTRACTS[symbol.toUpperCase()] || 'eosio.token';
238
+ }
239
+
240
+ function parsePrice(price: string): { amount: string; symbol: string; precision: number; contract: string } {
241
+ const parts = price.trim().split(/\s+/);
242
+ if (parts.length !== 2) throw new Error(`Invalid price format "${price}". Expected "100.0000 XPR"`);
243
+ const amount = parts[0];
244
+ const symbol = parts[1].toUpperCase();
245
+ const dotIdx = amount.indexOf('.');
246
+ const precision = dotIdx >= 0 ? amount.length - dotIdx - 1 : 0;
247
+ const contract = getTokenContract(symbol);
248
+ return { amount, symbol, precision, contract };
249
+ }
250
+
251
+ // ── Validation Helpers ───────────────────────────
252
+
253
+ function isValidEosioName(name: string): boolean {
254
+ if (!name || name.length > 12) return false;
255
+ return /^[a-z1-5.]+$/.test(name);
256
+ }
257
+
258
+ // ── Skill Entry Point ────────────────────────────
259
+
260
+ export default function nftSkill(api: SkillApi): void {
261
+ const config = api.getConfig();
262
+ const rpcEndpoint = (config.rpcEndpoint as string) || process.env.XPR_RPC_ENDPOINT || '';
263
+ const network = (config.network as string) || process.env.XPR_NETWORK || 'testnet';
264
+ const atomicEndpoints = getAtomicApiEndpoints(network);
265
+
266
+ // ════════════════════════════════════════════════
267
+ // READ-ONLY TOOLS (11)
268
+ // ════════════════════════════════════════════════
269
+
270
+ // ── 1. nft_get_collection ──
271
+ api.registerTool({
272
+ name: 'nft_get_collection',
273
+ description: 'Get details of an AtomicAssets collection by name. Returns author, authorized accounts, market fee, and collection data.',
274
+ parameters: {
275
+ type: 'object',
276
+ required: ['collection_name'],
277
+ properties: {
278
+ collection_name: { type: 'string', description: 'Collection name (1-12 chars)' },
279
+ },
280
+ },
281
+ handler: async ({ collection_name }: { collection_name: string }) => {
282
+ if (!collection_name) return { error: 'collection_name is required' };
283
+ try {
284
+ const data = await atomicGet(atomicEndpoints, `/atomicassets/v1/collections/${encodeURIComponent(collection_name)}`);
285
+ return {
286
+ collection_name: data.collection_name,
287
+ author: data.author,
288
+ authorized_accounts: data.authorized_accounts,
289
+ notify_accounts: data.notify_accounts,
290
+ market_fee: data.market_fee,
291
+ data: data.data,
292
+ created_at_block: data.created_at_block,
293
+ created_at_time: data.created_at_time,
294
+ };
295
+ } catch (err: any) {
296
+ // Fallback to RPC
297
+ try {
298
+ const rows = await getTableRows(rpcEndpoint, {
299
+ code: 'atomicassets', scope: 'atomicassets', table: 'collections',
300
+ lower_bound: collection_name, upper_bound: collection_name, limit: 1,
301
+ });
302
+ if (rows.length === 0) return { error: `Collection "${collection_name}" not found` };
303
+ return rows[0];
304
+ } catch {
305
+ return { error: `Failed to get collection: ${err.message}` };
306
+ }
307
+ }
308
+ },
309
+ });
310
+
311
+ // ── 2. nft_list_collections ──
312
+ api.registerTool({
313
+ name: 'nft_list_collections',
314
+ description: 'Search or list AtomicAssets collections. Filter by author or search term.',
315
+ parameters: {
316
+ type: 'object',
317
+ properties: {
318
+ author: { type: 'string', description: 'Filter by collection author account' },
319
+ match: { type: 'string', description: 'Search term to match collection names' },
320
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
321
+ page: { type: 'number', description: 'Page number (default 1)' },
322
+ },
323
+ },
324
+ handler: async ({ author, match, limit, page }: {
325
+ author?: string; match?: string; limit?: number; page?: number;
326
+ }) => {
327
+ const params: Record<string, string> = {
328
+ limit: String(Math.min(Math.max(limit || 20, 1), 100)),
329
+ page: String(Math.max(page || 1, 1)),
330
+ order: 'desc',
331
+ sort: 'created',
332
+ };
333
+ if (author) params.author = author;
334
+ if (match) params.match = match;
335
+
336
+ try {
337
+ const data = await atomicGet(atomicEndpoints, '/atomicassets/v1/collections', params);
338
+ const collections = Array.isArray(data) ? data : [];
339
+ return {
340
+ collections: collections.map((c: any) => ({
341
+ collection_name: c.collection_name,
342
+ author: c.author,
343
+ market_fee: c.market_fee,
344
+ data: c.data,
345
+ created_at_time: c.created_at_time,
346
+ })),
347
+ total: collections.length,
348
+ };
349
+ } catch (err: any) {
350
+ return { error: `Failed to list collections: ${err.message}` };
351
+ }
352
+ },
353
+ });
354
+
355
+ // ── 3. nft_get_schema ──
356
+ api.registerTool({
357
+ name: 'nft_get_schema',
358
+ description: 'Get schema attribute definitions (name/type pairs) for a collection schema.',
359
+ parameters: {
360
+ type: 'object',
361
+ required: ['collection_name', 'schema_name'],
362
+ properties: {
363
+ collection_name: { type: 'string', description: 'Collection name' },
364
+ schema_name: { type: 'string', description: 'Schema name' },
365
+ },
366
+ },
367
+ handler: async ({ collection_name, schema_name }: { collection_name: string; schema_name: string }) => {
368
+ if (!collection_name || !schema_name) return { error: 'collection_name and schema_name are required' };
369
+ try {
370
+ const data = await atomicGet(atomicEndpoints, `/atomicassets/v1/schemas/${encodeURIComponent(collection_name)}/${encodeURIComponent(schema_name)}`);
371
+ return {
372
+ schema_name: data.schema_name,
373
+ collection_name: data.collection?.collection_name || collection_name,
374
+ format: data.format,
375
+ created_at_time: data.created_at_time,
376
+ };
377
+ } catch (err: any) {
378
+ return { error: `Failed to get schema: ${err.message}` };
379
+ }
380
+ },
381
+ });
382
+
383
+ // ── 4. nft_get_template ──
384
+ api.registerTool({
385
+ name: 'nft_get_template',
386
+ description: 'Get template details including immutable data, supply count, and transferable/burnable flags.',
387
+ parameters: {
388
+ type: 'object',
389
+ required: ['collection_name', 'template_id'],
390
+ properties: {
391
+ collection_name: { type: 'string', description: 'Collection name' },
392
+ template_id: { type: 'string', description: 'Template ID' },
393
+ },
394
+ },
395
+ handler: async ({ collection_name, template_id }: { collection_name: string; template_id: string }) => {
396
+ if (!collection_name || !template_id) return { error: 'collection_name and template_id are required' };
397
+ try {
398
+ const data = await atomicGet(atomicEndpoints, `/atomicassets/v1/templates/${encodeURIComponent(collection_name)}/${encodeURIComponent(template_id)}`);
399
+ return {
400
+ template_id: data.template_id,
401
+ collection_name: data.collection?.collection_name || collection_name,
402
+ schema_name: data.schema?.schema_name,
403
+ immutable_data: data.immutable_data,
404
+ max_supply: data.max_supply,
405
+ issued_supply: data.issued_supply,
406
+ is_transferable: data.is_transferable,
407
+ is_burnable: data.is_burnable,
408
+ created_at_time: data.created_at_time,
409
+ };
410
+ } catch {
411
+ // Fallback: read directly from chain
412
+ try {
413
+ const rows = await getTableRows(rpcEndpoint, {
414
+ code: 'atomicassets', scope: collection_name, table: 'templates',
415
+ lower_bound: template_id, upper_bound: template_id, limit: 1,
416
+ });
417
+ if (rows.length === 0) return { error: `Template "${template_id}" not found in collection "${collection_name}"` };
418
+ const t = rows[0];
419
+ return {
420
+ template_id: t.template_id,
421
+ collection_name,
422
+ schema_name: t.schema_name,
423
+ max_supply: t.max_supply,
424
+ issued_supply: t.issued_supply,
425
+ is_transferable: t.transferable === 1,
426
+ is_burnable: t.burnable === 1,
427
+ source: 'rpc',
428
+ note: 'Data from RPC (immutable_data is serialized binary)',
429
+ };
430
+ } catch (rpcErr: any) {
431
+ return { error: `Failed to get template: ${rpcErr.message}` };
432
+ }
433
+ }
434
+ },
435
+ });
436
+
437
+ // ── 5. nft_list_templates ──
438
+ api.registerTool({
439
+ name: 'nft_list_templates',
440
+ description: 'List templates in a collection, optionally filtered by schema.',
441
+ parameters: {
442
+ type: 'object',
443
+ required: ['collection_name'],
444
+ properties: {
445
+ collection_name: { type: 'string', description: 'Collection name' },
446
+ schema_name: { type: 'string', description: 'Filter by schema name' },
447
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
448
+ page: { type: 'number', description: 'Page number (default 1)' },
449
+ },
450
+ },
451
+ handler: async ({ collection_name, schema_name, limit, page }: {
452
+ collection_name: string; schema_name?: string; limit?: number; page?: number;
453
+ }) => {
454
+ if (!collection_name) return { error: 'collection_name is required' };
455
+ const params: Record<string, string> = {
456
+ collection_name,
457
+ limit: String(Math.min(Math.max(limit || 20, 1), 100)),
458
+ page: String(Math.max(page || 1, 1)),
459
+ order: 'desc',
460
+ sort: 'created',
461
+ };
462
+ if (schema_name) params.schema_name = schema_name;
463
+
464
+ try {
465
+ const data = await atomicGet(atomicEndpoints, '/atomicassets/v1/templates', params);
466
+ const templates = Array.isArray(data) ? data : [];
467
+ if (templates.length > 0) {
468
+ return {
469
+ templates: templates.map((t: any) => ({
470
+ template_id: t.template_id,
471
+ schema_name: t.schema?.schema_name,
472
+ immutable_data: t.immutable_data,
473
+ max_supply: t.max_supply,
474
+ issued_supply: t.issued_supply,
475
+ is_transferable: t.is_transferable,
476
+ is_burnable: t.is_burnable,
477
+ })),
478
+ total: templates.length,
479
+ };
480
+ }
481
+ // AA API returned empty — fall through to RPC
482
+ throw new Error('AA API returned no templates, trying RPC');
483
+ } catch {
484
+ // Fallback: read directly from chain
485
+ try {
486
+ const rows = await getTableRows(rpcEndpoint, {
487
+ code: 'atomicassets', scope: collection_name, table: 'templates',
488
+ limit: Math.min(Math.max(limit || 20, 1), 100),
489
+ });
490
+ return {
491
+ templates: rows.map((t: any) => ({
492
+ template_id: t.template_id,
493
+ schema_name: t.schema_name,
494
+ max_supply: t.max_supply === '0' ? '0' : t.max_supply,
495
+ issued_supply: t.issued_supply,
496
+ is_transferable: t.transferable === 1,
497
+ is_burnable: t.burnable === 1,
498
+ note: 'Data from RPC (immutable_data is serialized binary)',
499
+ })),
500
+ total: rows.length,
501
+ source: 'rpc',
502
+ };
503
+ } catch (rpcErr: any) {
504
+ return { error: `Failed to list templates: ${rpcErr.message}` };
505
+ }
506
+ }
507
+ },
508
+ });
509
+
510
+ // ── 6. nft_get_asset ──
511
+ api.registerTool({
512
+ name: 'nft_get_asset',
513
+ description: 'Get full details of a specific NFT asset by its ID, including owner, collection, template data, and mutable data.',
514
+ parameters: {
515
+ type: 'object',
516
+ required: ['asset_id'],
517
+ properties: {
518
+ asset_id: { type: 'string', description: 'Asset ID' },
519
+ },
520
+ },
521
+ handler: async ({ asset_id }: { asset_id: string }) => {
522
+ if (!asset_id) return { error: 'asset_id is required' };
523
+ try {
524
+ const data = await atomicGet(atomicEndpoints, `/atomicassets/v1/assets/${encodeURIComponent(asset_id)}`);
525
+ return {
526
+ asset_id: data.asset_id,
527
+ owner: data.owner,
528
+ collection: data.collection ? {
529
+ collection_name: data.collection.collection_name,
530
+ author: data.collection.author,
531
+ } : null,
532
+ schema: data.schema ? { schema_name: data.schema.schema_name } : null,
533
+ template: data.template ? {
534
+ template_id: data.template.template_id,
535
+ immutable_data: data.template.immutable_data,
536
+ } : null,
537
+ immutable_data: data.immutable_data,
538
+ mutable_data: data.mutable_data,
539
+ is_transferable: data.is_transferable,
540
+ is_burnable: data.is_burnable,
541
+ burned_at_time: data.burned_at_time,
542
+ };
543
+ } catch (err: any) {
544
+ return { error: `Failed to get asset: ${err.message}` };
545
+ }
546
+ },
547
+ });
548
+
549
+ // ── 7. nft_list_assets ──
550
+ api.registerTool({
551
+ name: 'nft_list_assets',
552
+ description: 'List/search NFT assets. Filter by owner, collection, template, or schema.',
553
+ parameters: {
554
+ type: 'object',
555
+ properties: {
556
+ owner: { type: 'string', description: 'Filter by owner account' },
557
+ collection_name: { type: 'string', description: 'Filter by collection name' },
558
+ template_id: { type: 'string', description: 'Filter by template ID' },
559
+ schema_name: { type: 'string', description: 'Filter by schema name' },
560
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
561
+ page: { type: 'number', description: 'Page number (default 1)' },
562
+ },
563
+ },
564
+ handler: async ({ owner, collection_name, template_id, schema_name, limit, page }: {
565
+ owner?: string; collection_name?: string; template_id?: string;
566
+ schema_name?: string; limit?: number; page?: number;
567
+ }) => {
568
+ const params: Record<string, string> = {
569
+ limit: String(Math.min(Math.max(limit || 20, 1), 100)),
570
+ page: String(Math.max(page || 1, 1)),
571
+ order: 'desc',
572
+ sort: 'asset_id',
573
+ };
574
+ if (owner) params.owner = owner;
575
+ if (collection_name) params.collection_name = collection_name;
576
+ if (template_id) params.template_id = template_id;
577
+ if (schema_name) params.schema_name = schema_name;
578
+
579
+ try {
580
+ const data = await atomicGet(atomicEndpoints, '/atomicassets/v1/assets', params);
581
+ const assets = Array.isArray(data) ? data : [];
582
+ return {
583
+ assets: assets.map((a: any) => ({
584
+ asset_id: a.asset_id,
585
+ owner: a.owner,
586
+ collection_name: a.collection?.collection_name,
587
+ schema_name: a.schema?.schema_name,
588
+ template_id: a.template?.template_id,
589
+ name: a.immutable_data?.name || a.data?.name || a.template?.immutable_data?.name,
590
+ immutable_data: a.immutable_data,
591
+ mutable_data: a.mutable_data,
592
+ })),
593
+ total: assets.length,
594
+ };
595
+ } catch (err: any) {
596
+ return { error: `Failed to list assets: ${err.message}` };
597
+ }
598
+ },
599
+ });
600
+
601
+ // ── 8. nft_get_sale ──
602
+ api.registerTool({
603
+ name: 'nft_get_sale',
604
+ description: 'Get details of a specific AtomicMarket sale listing by sale ID. Returns full asset metadata, price, seller, buyer, and state.',
605
+ parameters: {
606
+ type: 'object',
607
+ required: ['sale_id'],
608
+ properties: {
609
+ sale_id: { type: 'string', description: 'Sale ID' },
610
+ },
611
+ },
612
+ handler: async ({ sale_id }: { sale_id: string }) => {
613
+ if (!sale_id) return { error: 'sale_id is required' };
614
+ try {
615
+ const data = await atomicGet(atomicEndpoints, `/atomicmarket/v1/sales/${encodeURIComponent(sale_id)}`);
616
+ return {
617
+ sale_id: data.sale_id,
618
+ seller: data.seller,
619
+ buyer: data.buyer,
620
+ listing_price: data.listing_price,
621
+ listing_symbol: data.listing_symbol,
622
+ price: data.price,
623
+ collection_name: data.collection_name || data.collection?.collection_name,
624
+ assets: (data.assets || []).map((a: any) => ({
625
+ asset_id: a.asset_id,
626
+ name: a.name || a.data?.name || a.immutable_data?.name,
627
+ template_id: a.template?.template_id,
628
+ data: a.data || a.immutable_data,
629
+ })),
630
+ state: data.state,
631
+ offer_id: data.offer_id,
632
+ created_at_time: data.created_at_time,
633
+ updated_at_time: data.updated_at_time,
634
+ };
635
+ } catch (err: any) {
636
+ // Fallback to RPC
637
+ try {
638
+ const rows = await getTableRows(rpcEndpoint, {
639
+ code: 'atomicmarket', scope: 'atomicmarket', table: 'sales',
640
+ lower_bound: sale_id, upper_bound: sale_id, limit: 1,
641
+ });
642
+ if (rows.length === 0) return { error: `Sale #${sale_id} not found` };
643
+ return rows[0];
644
+ } catch {
645
+ return { error: `Failed to get sale: ${err.message}` };
646
+ }
647
+ }
648
+ },
649
+ });
650
+
651
+ // ── 9. nft_search_sales ──
652
+ api.registerTool({
653
+ name: 'nft_search_sales',
654
+ description: 'Search AtomicMarket sales. Filter by collection, seller, price range, state, symbol. Returns rich asset metadata.',
655
+ parameters: {
656
+ type: 'object',
657
+ properties: {
658
+ collection_name: { type: 'string', description: 'Filter by collection name' },
659
+ seller: { type: 'string', description: 'Filter by seller account' },
660
+ buyer: { type: 'string', description: 'Filter by buyer account' },
661
+ min_price: { type: 'string', description: 'Minimum price filter (e.g. "10.0000")' },
662
+ max_price: { type: 'string', description: 'Maximum price filter (e.g. "1000.0000")' },
663
+ symbol: { type: 'string', description: 'Token symbol filter (e.g. "XPR")' },
664
+ state: { type: 'string', description: 'Sale state: 0=waiting, 1=listed, 2=canceled, 3=sold (default: 1)' },
665
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
666
+ },
667
+ },
668
+ handler: async ({ collection_name, seller, buyer, min_price, max_price, symbol, state, limit }: {
669
+ collection_name?: string; seller?: string; buyer?: string;
670
+ min_price?: string; max_price?: string; symbol?: string; state?: string; limit?: number;
671
+ }) => {
672
+ const params: Record<string, string> = {
673
+ state: state || '1',
674
+ limit: String(Math.min(Math.max(limit || 20, 1), 100)),
675
+ order: 'desc',
676
+ sort: 'created',
677
+ };
678
+ if (collection_name) params.collection_name = collection_name;
679
+ if (seller) params.seller = seller;
680
+ if (buyer) params.buyer = buyer;
681
+ if (symbol) params.symbol = symbol.toUpperCase();
682
+ if (min_price) params.min_price = min_price;
683
+ if (max_price) params.max_price = max_price;
684
+
685
+ try {
686
+ const data = await atomicGet(atomicEndpoints, '/atomicmarket/v1/sales', params);
687
+ const sales = Array.isArray(data) ? data : [];
688
+ return {
689
+ sales: sales.map((s: any) => ({
690
+ sale_id: s.sale_id,
691
+ seller: s.seller,
692
+ buyer: s.buyer,
693
+ listing_price: s.listing_price,
694
+ price: s.price,
695
+ collection_name: s.collection_name || s.collection?.collection_name,
696
+ assets: (s.assets || []).map((a: any) => ({
697
+ asset_id: a.asset_id,
698
+ name: a.name || a.data?.name || a.immutable_data?.name,
699
+ template_id: a.template?.template_id,
700
+ })),
701
+ state: s.state,
702
+ created_at_time: s.created_at_time,
703
+ })),
704
+ total: sales.length,
705
+ };
706
+ } catch (err: any) {
707
+ return { error: `Failed to search sales: ${err.message}` };
708
+ }
709
+ },
710
+ });
711
+
712
+ // ── 10. nft_list_auctions ──
713
+ api.registerTool({
714
+ name: 'nft_list_auctions',
715
+ description: 'List AtomicMarket auctions. Filter by collection, seller, or state.',
716
+ parameters: {
717
+ type: 'object',
718
+ properties: {
719
+ collection_name: { type: 'string', description: 'Filter by collection name' },
720
+ seller: { type: 'string', description: 'Filter by seller account' },
721
+ state: { type: 'string', description: 'Auction state: 0=waiting, 1=active, 2=canceled, 3=sold, 4=invalid (default: 1)' },
722
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
723
+ },
724
+ },
725
+ handler: async ({ collection_name, seller, state, limit }: {
726
+ collection_name?: string; seller?: string; state?: string; limit?: number;
727
+ }) => {
728
+ const params: Record<string, string> = {
729
+ state: state || '1',
730
+ limit: String(Math.min(Math.max(limit || 20, 1), 100)),
731
+ order: 'desc',
732
+ sort: 'created',
733
+ };
734
+ if (collection_name) params.collection_name = collection_name;
735
+ if (seller) params.seller = seller;
736
+
737
+ try {
738
+ const data = await atomicGet(atomicEndpoints, '/atomicmarket/v1/auctions', params);
739
+ const auctions = Array.isArray(data) ? data : [];
740
+ return {
741
+ auctions: auctions.map((a: any) => ({
742
+ auction_id: a.auction_id,
743
+ seller: a.seller,
744
+ buyer: a.buyer,
745
+ price: a.price,
746
+ collection_name: a.collection_name || a.collection?.collection_name,
747
+ assets: (a.assets || []).map((asset: any) => ({
748
+ asset_id: asset.asset_id,
749
+ name: asset.name || asset.data?.name || asset.immutable_data?.name,
750
+ })),
751
+ bids: a.bids || [],
752
+ state: a.state,
753
+ end_time: a.end_time,
754
+ created_at_time: a.created_at_time,
755
+ })),
756
+ total: auctions.length,
757
+ };
758
+ } catch (err: any) {
759
+ return { error: `Failed to list auctions: ${err.message}` };
760
+ }
761
+ },
762
+ });
763
+
764
+ // ── 11. nft_get_auction ──
765
+ api.registerTool({
766
+ name: 'nft_get_auction',
767
+ description: 'Get details of a specific AtomicMarket auction including bids, assets, and timing.',
768
+ parameters: {
769
+ type: 'object',
770
+ required: ['auction_id'],
771
+ properties: {
772
+ auction_id: { type: 'string', description: 'Auction ID' },
773
+ },
774
+ },
775
+ handler: async ({ auction_id }: { auction_id: string }) => {
776
+ if (!auction_id) return { error: 'auction_id is required' };
777
+ try {
778
+ const data = await atomicGet(atomicEndpoints, `/atomicmarket/v1/auctions/${encodeURIComponent(auction_id)}`);
779
+ return {
780
+ auction_id: data.auction_id,
781
+ seller: data.seller,
782
+ buyer: data.buyer,
783
+ price: data.price,
784
+ collection_name: data.collection_name || data.collection?.collection_name,
785
+ assets: (data.assets || []).map((a: any) => ({
786
+ asset_id: a.asset_id,
787
+ name: a.name || a.data?.name || a.immutable_data?.name,
788
+ template_id: a.template?.template_id,
789
+ data: a.data || a.immutable_data,
790
+ })),
791
+ bids: data.bids || [],
792
+ state: data.state,
793
+ end_time: data.end_time,
794
+ created_at_time: data.created_at_time,
795
+ updated_at_time: data.updated_at_time,
796
+ };
797
+ } catch (err: any) {
798
+ return { error: `Failed to get auction: ${err.message}` };
799
+ }
800
+ },
801
+ });
802
+
803
+ // ════════════════════════════════════════════════
804
+ // WRITE TOOLS (12)
805
+ // ════════════════════════════════════════════════
806
+
807
+ // ── 12. nft_create_collection ──
808
+ api.registerTool({
809
+ name: 'nft_create_collection',
810
+ description: 'Create a new AtomicAssets collection. The agent account becomes the author and authorized account. Collection names are permanent (1-12 chars, a-z1-5).',
811
+ parameters: {
812
+ type: 'object',
813
+ required: ['collection_name', 'confirmed'],
814
+ properties: {
815
+ collection_name: { type: 'string', description: 'Unique collection name (1-12 chars, a-z and 1-5 only, permanent)' },
816
+ display_name: { type: 'string', description: 'Human-readable display name' },
817
+ description: { type: 'string', description: 'Collection description' },
818
+ image: { type: 'string', description: 'Collection image (IPFS CID or URL)' },
819
+ market_fee: { type: 'number', description: 'Market fee as decimal (e.g. 0.05 = 5%, default 0.05)' },
820
+ allow_notify: { type: 'boolean', description: 'Allow contract notifications (default true)' },
821
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
822
+ },
823
+ },
824
+ handler: async ({ collection_name, display_name, description, image, market_fee, allow_notify, confirmed }: {
825
+ collection_name: string; display_name?: string; description?: string; image?: string;
826
+ market_fee?: number; allow_notify?: boolean; confirmed?: boolean;
827
+ }) => {
828
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to create this collection.' };
829
+ if (!isValidEosioName(collection_name)) return { error: 'Invalid collection_name. Must be 1-12 characters, a-z and 1-5 only.' };
830
+
831
+ try {
832
+ const session = await getNftSession();
833
+ await ensureRam(session);
834
+ const data: Array<{ key: string; value: [string, any] }> = [];
835
+ if (display_name) data.push({ key: 'name', value: ['string', display_name] });
836
+ if (image) data.push({ key: 'image', value: ['string', image] });
837
+ if (description) data.push({ key: 'description', value: ['string', description] });
838
+
839
+ const result = await session.api.transact({
840
+ actions: [{
841
+ account: 'atomicassets',
842
+ name: 'createcol',
843
+ authorization: [{ actor: session.account, permission: session.permission }],
844
+ data: {
845
+ author: session.account,
846
+ collection_name,
847
+ allow_notify: allow_notify !== false,
848
+ authorized_accounts: [session.account],
849
+ notify_accounts: [],
850
+ market_fee: market_fee ?? 0.05,
851
+ data,
852
+ },
853
+ }],
854
+ }, { blocksBehind: 3, expireSeconds: 30 });
855
+
856
+ return { transaction_id: result.transaction_id || result.processed?.id, collection_name, author: session.account };
857
+ } catch (err: any) {
858
+ return { error: `Failed to create collection: ${err.message}` };
859
+ }
860
+ },
861
+ });
862
+
863
+ // ── 13. nft_create_schema ──
864
+ api.registerTool({
865
+ name: 'nft_create_schema',
866
+ description: 'Create a schema within a collection. Defines attribute names and types for templates/assets. Common types: string, image, ipfs, uint64, uint32, double, bool.',
867
+ parameters: {
868
+ type: 'object',
869
+ required: ['collection_name', 'schema_name', 'schema_format', 'confirmed'],
870
+ properties: {
871
+ collection_name: { type: 'string', description: 'Collection to add schema to' },
872
+ schema_name: { type: 'string', description: 'Schema name (1-12 chars, a-z1-5)' },
873
+ schema_format: {
874
+ type: 'array',
875
+ description: 'Array of {name, type} attribute definitions. E.g. [{"name":"name","type":"string"},{"name":"image","type":"image"},{"name":"rarity","type":"string"}]',
876
+ },
877
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
878
+ },
879
+ },
880
+ handler: async ({ collection_name, schema_name, schema_format, confirmed }: {
881
+ collection_name: string; schema_name: string;
882
+ schema_format: Array<{ name: string; type: string }>; confirmed?: boolean;
883
+ }) => {
884
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to create this schema.' };
885
+ if (!isValidEosioName(collection_name)) return { error: 'Invalid collection_name' };
886
+ if (!isValidEosioName(schema_name)) return { error: 'Invalid schema_name. Must be 1-12 characters, a-z and 1-5 only.' };
887
+ if (!Array.isArray(schema_format) || schema_format.length === 0) {
888
+ return { error: 'schema_format must be a non-empty array of {name, type} objects' };
889
+ }
890
+
891
+ try {
892
+ const session = await getNftSession();
893
+ await ensureRam(session);
894
+ const result = await session.api.transact({
895
+ actions: [{
896
+ account: 'atomicassets',
897
+ name: 'createschema',
898
+ authorization: [{ actor: session.account, permission: session.permission }],
899
+ data: {
900
+ authorized_creator: session.account,
901
+ collection_name,
902
+ schema_name,
903
+ schema_format,
904
+ },
905
+ }],
906
+ }, { blocksBehind: 3, expireSeconds: 30 });
907
+
908
+ return { transaction_id: result.transaction_id || result.processed?.id, collection_name, schema_name, attributes: schema_format.length };
909
+ } catch (err: any) {
910
+ return { error: `Failed to create schema: ${err.message}` };
911
+ }
912
+ },
913
+ });
914
+
915
+ // ── 14. nft_create_template ──
916
+ api.registerTool({
917
+ name: 'nft_create_template',
918
+ description: 'Create a template with immutable data. Pass a plain data object — types are auto-mapped from the schema. E.g. {name: "My NFT", img: "QmHash", rarity: "legendary"}.',
919
+ parameters: {
920
+ type: 'object',
921
+ required: ['collection_name', 'schema_name', 'immutable_data', 'confirmed'],
922
+ properties: {
923
+ collection_name: { type: 'string', description: 'Collection name' },
924
+ schema_name: { type: 'string', description: 'Schema name within the collection' },
925
+ immutable_data: { type: 'object', description: 'Key-value pairs matching schema attributes. E.g. {"name":"Cool NFT","image":"QmHash"}' },
926
+ max_supply: { type: 'number', description: 'Maximum supply (0 = unlimited, default 0)' },
927
+ transferable: { type: 'boolean', description: 'Can assets be transferred (default true)' },
928
+ burnable: { type: 'boolean', description: 'Can assets be burned (default true)' },
929
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
930
+ },
931
+ },
932
+ handler: async ({ collection_name, schema_name, immutable_data, max_supply, transferable, burnable, confirmed }: {
933
+ collection_name: string; schema_name: string; immutable_data: Record<string, any>;
934
+ max_supply?: number; transferable?: boolean; burnable?: boolean; confirmed?: boolean;
935
+ }) => {
936
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to create this template.' };
937
+ if (!collection_name || !schema_name) return { error: 'collection_name and schema_name are required' };
938
+ if (!immutable_data || typeof immutable_data !== 'object') return { error: 'immutable_data must be an object' };
939
+
940
+ try {
941
+ // Fetch schema to get attribute types (AA API with RPC fallback)
942
+ const schemaFormat = await fetchSchemaFormat(atomicEndpoints, rpcEndpoint, collection_name, schema_name);
943
+
944
+ const attributeMap = buildAttributeMap(immutable_data, schemaFormat);
945
+
946
+ const session = await getNftSession();
947
+ await ensureRam(session);
948
+ const result = await session.api.transact({
949
+ actions: [{
950
+ account: 'atomicassets',
951
+ name: 'createtempl',
952
+ authorization: [{ actor: session.account, permission: session.permission }],
953
+ data: {
954
+ authorized_creator: session.account,
955
+ collection_name,
956
+ schema_name,
957
+ transferable: transferable !== false,
958
+ burnable: burnable !== false,
959
+ max_supply: max_supply || 0,
960
+ immutable_data: attributeMap,
961
+ },
962
+ }],
963
+ }, { blocksBehind: 3, expireSeconds: 30 });
964
+
965
+ const txId = result.transaction_id || result.processed?.id;
966
+
967
+ // Read template_id from on-chain table (AA API is too slow for newly created templates)
968
+ let template_id: number | undefined;
969
+ try {
970
+ const rows = await getTableRows(rpcEndpoint, {
971
+ code: 'atomicassets', scope: collection_name, table: 'templates',
972
+ limit: 1, key_type: undefined, index_position: undefined,
973
+ });
974
+ // Templates table uses template_id as primary key — get the highest one (most recent)
975
+ if (rows.length > 0) {
976
+ // Read in reverse to get highest template_id
977
+ const allRows = await getTableRows(rpcEndpoint, {
978
+ code: 'atomicassets', scope: collection_name, table: 'templates',
979
+ limit: 100, key_type: undefined, index_position: undefined,
980
+ });
981
+ if (allRows.length > 0) {
982
+ template_id = allRows[allRows.length - 1].template_id;
983
+ }
984
+ }
985
+ } catch { /* non-critical — template_id is a convenience */ }
986
+
987
+ return {
988
+ transaction_id: txId,
989
+ template_id,
990
+ collection_name, schema_name,
991
+ immutable_data,
992
+ max_supply: max_supply || 0,
993
+ note: template_id
994
+ ? `Template created with ID ${template_id}. Use this ID for nft_mint.`
995
+ : 'Template created. Read the templates table to get the template_id.',
996
+ };
997
+ } catch (err: any) {
998
+ return { error: `Failed to create template: ${err.message}` };
999
+ }
1000
+ },
1001
+ });
1002
+
1003
+ // ── 15. nft_mint ──
1004
+ api.registerTool({
1005
+ name: 'nft_mint',
1006
+ description: 'Mint a new NFT from an existing template. Optionally include mutable data and specify a recipient (defaults to self).',
1007
+ parameters: {
1008
+ type: 'object',
1009
+ required: ['collection_name', 'schema_name', 'template_id', 'confirmed'],
1010
+ properties: {
1011
+ collection_name: { type: 'string', description: 'Collection name' },
1012
+ schema_name: { type: 'string', description: 'Schema name' },
1013
+ template_id: { type: 'number', description: 'Template ID to mint from' },
1014
+ new_asset_owner: { type: 'string', description: 'Recipient account (default: self)' },
1015
+ mutable_data: { type: 'object', description: 'Optional mutable data key-value pairs' },
1016
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1017
+ },
1018
+ },
1019
+ handler: async ({ collection_name, schema_name, template_id, new_asset_owner, mutable_data, confirmed }: {
1020
+ collection_name: string; schema_name: string; template_id: number;
1021
+ new_asset_owner?: string; mutable_data?: Record<string, any>; confirmed?: boolean;
1022
+ }) => {
1023
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to mint this NFT.' };
1024
+ if (!collection_name || !schema_name || template_id == null) {
1025
+ return { error: 'collection_name, schema_name, and template_id are required' };
1026
+ }
1027
+
1028
+ try {
1029
+ const session = await getNftSession();
1030
+ await ensureRam(session);
1031
+ const owner = new_asset_owner || session.account;
1032
+
1033
+ // Build mutable data attribute map if provided
1034
+ let immutable_data: Array<{ key: string; value: [string, any] }> = [];
1035
+ let mutable_data_map: Array<{ key: string; value: [string, any] }> = [];
1036
+
1037
+ if (mutable_data && Object.keys(mutable_data).length > 0) {
1038
+ // Fetch schema to map types (AA API with RPC fallback)
1039
+ const schemaFormat = await fetchSchemaFormat(atomicEndpoints, rpcEndpoint, collection_name, schema_name);
1040
+ mutable_data_map = buildAttributeMap(mutable_data, schemaFormat);
1041
+ }
1042
+
1043
+ const result = await session.api.transact({
1044
+ actions: [{
1045
+ account: 'atomicassets',
1046
+ name: 'mintasset',
1047
+ authorization: [{ actor: session.account, permission: session.permission }],
1048
+ data: {
1049
+ authorized_minter: session.account,
1050
+ collection_name,
1051
+ schema_name,
1052
+ template_id,
1053
+ new_asset_owner: owner,
1054
+ immutable_data,
1055
+ mutable_data: mutable_data_map,
1056
+ tokens_to_back: [],
1057
+ },
1058
+ }],
1059
+ }, { blocksBehind: 3, expireSeconds: 30 });
1060
+
1061
+ const txId = result.transaction_id || result.processed?.id;
1062
+
1063
+ // Extract new asset_id from the transaction traces (logmint inline action)
1064
+ let asset_id: string | undefined;
1065
+ try {
1066
+ const traces = result.processed?.action_traces || [];
1067
+ for (const trace of traces) {
1068
+ const inlines = trace.inline_traces || [];
1069
+ for (const inl of inlines) {
1070
+ if (inl.act?.name === 'logmint' && inl.act?.data?.asset_id) {
1071
+ asset_id = String(inl.act.data.asset_id);
1072
+ break;
1073
+ }
1074
+ }
1075
+ if (asset_id) break;
1076
+ }
1077
+ } catch { /* non-critical */ }
1078
+
1079
+ // Fallback: read the assets table for this owner scoped by collection
1080
+ if (!asset_id) {
1081
+ try {
1082
+ // The global config table holds the next asset_id counter
1083
+ const configRows = await getTableRows(rpcEndpoint, {
1084
+ code: 'atomicassets', scope: 'atomicassets', table: 'config', limit: 1,
1085
+ });
1086
+ if (configRows.length > 0 && configRows[0].asset_counter) {
1087
+ // The asset just minted has ID = counter - 1
1088
+ asset_id = String(Number(configRows[0].asset_counter) - 1);
1089
+ }
1090
+ } catch { /* non-critical */ }
1091
+ }
1092
+
1093
+ return {
1094
+ transaction_id: txId,
1095
+ asset_id,
1096
+ collection_name, schema_name, template_id,
1097
+ new_asset_owner: owner,
1098
+ note: asset_id
1099
+ ? `NFT minted with asset ID ${asset_id}. Use this ID for transfers, sales, or delivery.`
1100
+ : 'NFT minted successfully. Check your assets to find the new asset ID.',
1101
+ };
1102
+ } catch (err: any) {
1103
+ return { error: `Failed to mint NFT: ${err.message}` };
1104
+ }
1105
+ },
1106
+ });
1107
+
1108
+ // ── 16. nft_transfer ──
1109
+ api.registerTool({
1110
+ name: 'nft_transfer',
1111
+ description: 'Transfer one or more NFT assets to another account.',
1112
+ parameters: {
1113
+ type: 'object',
1114
+ required: ['to', 'asset_ids', 'confirmed'],
1115
+ properties: {
1116
+ to: { type: 'string', description: 'Recipient account' },
1117
+ asset_ids: { type: 'array', description: 'Array of asset IDs to transfer', items: { type: 'string' } },
1118
+ memo: { type: 'string', description: 'Transfer memo (default empty)' },
1119
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1120
+ },
1121
+ },
1122
+ handler: async ({ to, asset_ids, memo, confirmed }: {
1123
+ to: string; asset_ids: string[]; memo?: string; confirmed?: boolean;
1124
+ }) => {
1125
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to transfer these NFTs.' };
1126
+ if (!to || !isValidEosioName(to)) return { error: 'Invalid recipient account' };
1127
+ if (!Array.isArray(asset_ids) || asset_ids.length === 0) return { error: 'asset_ids must be a non-empty array' };
1128
+
1129
+ try {
1130
+ const session = await getNftSession();
1131
+ await ensureRam(session);
1132
+ const result = await session.api.transact({
1133
+ actions: [{
1134
+ account: 'atomicassets',
1135
+ name: 'transfer',
1136
+ authorization: [{ actor: session.account, permission: session.permission }],
1137
+ data: {
1138
+ from: session.account,
1139
+ to,
1140
+ asset_ids: asset_ids.map(id => Number(id)),
1141
+ memo: memo || '',
1142
+ },
1143
+ }],
1144
+ }, { blocksBehind: 3, expireSeconds: 30 });
1145
+
1146
+ return { transaction_id: result.transaction_id || result.processed?.id, from: session.account, to, asset_ids };
1147
+ } catch (err: any) {
1148
+ return { error: `Failed to transfer NFTs: ${err.message}` };
1149
+ }
1150
+ },
1151
+ });
1152
+
1153
+ // ── 17. nft_burn ──
1154
+ api.registerTool({
1155
+ name: 'nft_burn',
1156
+ description: 'Permanently destroy (burn) an NFT asset you own. This cannot be undone.',
1157
+ parameters: {
1158
+ type: 'object',
1159
+ required: ['asset_id', 'confirmed'],
1160
+ properties: {
1161
+ asset_id: { type: 'string', description: 'Asset ID to burn' },
1162
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1163
+ },
1164
+ },
1165
+ handler: async ({ asset_id, confirmed }: { asset_id: string; confirmed?: boolean }) => {
1166
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to burn this NFT. This action is PERMANENT.' };
1167
+ if (!asset_id) return { error: 'asset_id is required' };
1168
+
1169
+ try {
1170
+ const session = await getNftSession();
1171
+ await ensureRam(session);
1172
+ const result = await session.api.transact({
1173
+ actions: [{
1174
+ account: 'atomicassets',
1175
+ name: 'burnasset',
1176
+ authorization: [{ actor: session.account, permission: session.permission }],
1177
+ data: {
1178
+ asset_owner: session.account,
1179
+ asset_id: Number(asset_id),
1180
+ },
1181
+ }],
1182
+ }, { blocksBehind: 3, expireSeconds: 30 });
1183
+
1184
+ return { transaction_id: result.transaction_id || result.processed?.id, burned_asset_id: asset_id };
1185
+ } catch (err: any) {
1186
+ return { error: `Failed to burn NFT: ${err.message}` };
1187
+ }
1188
+ },
1189
+ });
1190
+
1191
+ // ── 18. nft_list_for_sale ──
1192
+ api.registerTool({
1193
+ name: 'nft_list_for_sale',
1194
+ description: 'List NFT(s) for sale at a fixed price on AtomicMarket. Combines createoffer + announcesale in one transaction. Price format: "100.0000 XPR".',
1195
+ parameters: {
1196
+ type: 'object',
1197
+ required: ['asset_ids', 'price', 'confirmed'],
1198
+ properties: {
1199
+ asset_ids: { type: 'array', description: 'Array of asset IDs to sell', items: { type: 'string' } },
1200
+ price: { type: 'string', description: 'Listing price with full precision, e.g. "100.0000 XPR" or "50.000000 XUSDC"' },
1201
+ marketplace: { type: 'string', description: 'Maker marketplace account (optional)' },
1202
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1203
+ },
1204
+ },
1205
+ handler: async ({ asset_ids, price, marketplace, confirmed }: {
1206
+ asset_ids: string[]; price: string; marketplace?: string; confirmed?: boolean;
1207
+ }) => {
1208
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to list these NFTs for sale.' };
1209
+ if (!Array.isArray(asset_ids) || asset_ids.length === 0) return { error: 'asset_ids must be a non-empty array' };
1210
+ if (!price) return { error: 'price is required (e.g. "100.0000 XPR")' };
1211
+
1212
+ try {
1213
+ const parsed = parsePrice(price);
1214
+ const session = await getNftSession();
1215
+ await ensureRam(session);
1216
+ const numericAssetIds = asset_ids.map(id => Number(id));
1217
+
1218
+ // announcesale MUST come before createoffer — when createoffer notifies
1219
+ // atomicmarket, it checks that a sale was already announced for these assets.
1220
+ const result = await session.api.transact({
1221
+ actions: [
1222
+ {
1223
+ account: 'atomicmarket',
1224
+ name: 'announcesale',
1225
+ authorization: [{ actor: session.account, permission: session.permission }],
1226
+ data: {
1227
+ seller: session.account,
1228
+ asset_ids: numericAssetIds,
1229
+ listing_price: `${parsed.amount} ${parsed.symbol}`,
1230
+ settlement_symbol: `${parsed.precision},${parsed.symbol}`,
1231
+ maker_marketplace: marketplace || '',
1232
+ },
1233
+ },
1234
+ {
1235
+ account: 'atomicassets',
1236
+ name: 'createoffer',
1237
+ authorization: [{ actor: session.account, permission: session.permission }],
1238
+ data: {
1239
+ sender: session.account,
1240
+ recipient: 'atomicmarket',
1241
+ sender_asset_ids: numericAssetIds,
1242
+ recipient_asset_ids: [],
1243
+ memo: 'sale',
1244
+ },
1245
+ },
1246
+ ],
1247
+ }, { blocksBehind: 3, expireSeconds: 30 });
1248
+
1249
+ return { transaction_id: result.transaction_id || result.processed?.id, asset_ids, price };
1250
+ } catch (err: any) {
1251
+ return { error: `Failed to list for sale: ${err.message}` };
1252
+ }
1253
+ },
1254
+ });
1255
+
1256
+ // ── 19. nft_cancel_sale ──
1257
+ api.registerTool({
1258
+ name: 'nft_cancel_sale',
1259
+ description: 'Cancel a sale listing on AtomicMarket. Only the seller can cancel.',
1260
+ parameters: {
1261
+ type: 'object',
1262
+ required: ['sale_id', 'confirmed'],
1263
+ properties: {
1264
+ sale_id: { type: 'string', description: 'Sale ID to cancel' },
1265
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1266
+ },
1267
+ },
1268
+ handler: async ({ sale_id, confirmed }: { sale_id: string; confirmed?: boolean }) => {
1269
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to cancel this sale.' };
1270
+ if (!sale_id) return { error: 'sale_id is required' };
1271
+
1272
+ try {
1273
+ const session = await getNftSession();
1274
+ await ensureRam(session);
1275
+ const result = await session.api.transact({
1276
+ actions: [{
1277
+ account: 'atomicmarket',
1278
+ name: 'cancelsale',
1279
+ authorization: [{ actor: session.account, permission: session.permission }],
1280
+ data: {
1281
+ sale_id: Number(sale_id),
1282
+ },
1283
+ }],
1284
+ }, { blocksBehind: 3, expireSeconds: 30 });
1285
+
1286
+ return { transaction_id: result.transaction_id || result.processed?.id, cancelled_sale_id: sale_id };
1287
+ } catch (err: any) {
1288
+ return { error: `Failed to cancel sale: ${err.message}` };
1289
+ }
1290
+ },
1291
+ });
1292
+
1293
+ // ── 20. nft_purchase ──
1294
+ api.registerTool({
1295
+ name: 'nft_purchase',
1296
+ description: 'Purchase an NFT from an AtomicMarket sale. Combines token deposit + purchasesale in one transaction.',
1297
+ parameters: {
1298
+ type: 'object',
1299
+ required: ['sale_id', 'price', 'confirmed'],
1300
+ properties: {
1301
+ sale_id: { type: 'string', description: 'Sale ID to purchase' },
1302
+ price: { type: 'string', description: 'Exact listing price (must match), e.g. "100.0000 XPR"' },
1303
+ taker_marketplace: { type: 'string', description: 'Taker marketplace account (optional)' },
1304
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1305
+ },
1306
+ },
1307
+ handler: async ({ sale_id, price, taker_marketplace, confirmed }: {
1308
+ sale_id: string; price: string; taker_marketplace?: string; confirmed?: boolean;
1309
+ }) => {
1310
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to purchase this NFT.' };
1311
+ if (!sale_id) return { error: 'sale_id is required' };
1312
+ if (!price) return { error: 'price is required (must match listing price exactly)' };
1313
+
1314
+ try {
1315
+ const parsed = parsePrice(price);
1316
+ const session = await getNftSession();
1317
+ await ensureRam(session);
1318
+
1319
+ const result = await session.api.transact({
1320
+ actions: [
1321
+ {
1322
+ account: parsed.contract,
1323
+ name: 'transfer',
1324
+ authorization: [{ actor: session.account, permission: session.permission }],
1325
+ data: {
1326
+ from: session.account,
1327
+ to: 'atomicmarket',
1328
+ quantity: `${parsed.amount} ${parsed.symbol}`,
1329
+ memo: 'deposit',
1330
+ },
1331
+ },
1332
+ {
1333
+ account: 'atomicmarket',
1334
+ name: 'purchasesale',
1335
+ authorization: [{ actor: session.account, permission: session.permission }],
1336
+ data: {
1337
+ buyer: session.account,
1338
+ sale_id: Number(sale_id),
1339
+ intended_delphi_median: 0,
1340
+ taker_marketplace: taker_marketplace || '',
1341
+ },
1342
+ },
1343
+ ],
1344
+ }, { blocksBehind: 3, expireSeconds: 30 });
1345
+
1346
+ return { transaction_id: result.transaction_id || result.processed?.id, sale_id, price, buyer: session.account };
1347
+ } catch (err: any) {
1348
+ return { error: `Failed to purchase NFT: ${err.message}` };
1349
+ }
1350
+ },
1351
+ });
1352
+
1353
+ // ── 21. nft_create_auction ──
1354
+ api.registerTool({
1355
+ name: 'nft_create_auction',
1356
+ description: 'Start a timed auction on AtomicMarket. Transfers assets to atomicmarket and announces the auction.',
1357
+ parameters: {
1358
+ type: 'object',
1359
+ required: ['asset_ids', 'starting_bid', 'duration_seconds', 'confirmed'],
1360
+ properties: {
1361
+ asset_ids: { type: 'array', description: 'Array of asset IDs to auction', items: { type: 'string' } },
1362
+ starting_bid: { type: 'string', description: 'Starting bid price, e.g. "10.0000 XPR"' },
1363
+ duration_seconds: { type: 'number', description: 'Auction duration in seconds (e.g. 86400 = 24 hours)' },
1364
+ marketplace: { type: 'string', description: 'Maker marketplace account (optional)' },
1365
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1366
+ },
1367
+ },
1368
+ handler: async ({ asset_ids, starting_bid, duration_seconds, marketplace, confirmed }: {
1369
+ asset_ids: string[]; starting_bid: string; duration_seconds: number;
1370
+ marketplace?: string; confirmed?: boolean;
1371
+ }) => {
1372
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to create this auction.' };
1373
+ if (!Array.isArray(asset_ids) || asset_ids.length === 0) return { error: 'asset_ids must be a non-empty array' };
1374
+ if (!starting_bid) return { error: 'starting_bid is required (e.g. "10.0000 XPR")' };
1375
+ if (!duration_seconds || duration_seconds <= 0) return { error: 'duration_seconds must be a positive number' };
1376
+
1377
+ try {
1378
+ const parsed = parsePrice(starting_bid);
1379
+ const session = await getNftSession();
1380
+ await ensureRam(session);
1381
+ const numericAssetIds = asset_ids.map(id => Number(id));
1382
+
1383
+ // announceauct MUST come before transfer — when atomicmarket receives the
1384
+ // assets via transfer notification, it checks for a previously announced auction.
1385
+ const result = await session.api.transact({
1386
+ actions: [
1387
+ {
1388
+ account: 'atomicmarket',
1389
+ name: 'announceauct',
1390
+ authorization: [{ actor: session.account, permission: session.permission }],
1391
+ data: {
1392
+ seller: session.account,
1393
+ asset_ids: numericAssetIds,
1394
+ starting_bid: `${parsed.amount} ${parsed.symbol}`,
1395
+ duration: duration_seconds,
1396
+ maker_marketplace: marketplace || '',
1397
+ },
1398
+ },
1399
+ {
1400
+ account: 'atomicassets',
1401
+ name: 'transfer',
1402
+ authorization: [{ actor: session.account, permission: session.permission }],
1403
+ data: {
1404
+ from: session.account,
1405
+ to: 'atomicmarket',
1406
+ asset_ids: numericAssetIds,
1407
+ memo: 'auction',
1408
+ },
1409
+ },
1410
+ ],
1411
+ }, { blocksBehind: 3, expireSeconds: 30 });
1412
+
1413
+ return {
1414
+ transaction_id: result.transaction_id || result.processed?.id,
1415
+ asset_ids, starting_bid, duration_seconds,
1416
+ };
1417
+ } catch (err: any) {
1418
+ return { error: `Failed to create auction: ${err.message}` };
1419
+ }
1420
+ },
1421
+ });
1422
+
1423
+ // ── 22. nft_bid ──
1424
+ api.registerTool({
1425
+ name: 'nft_bid',
1426
+ description: 'Place a bid on an AtomicMarket auction. Combines token deposit + auctionbid in one transaction. Bid must be higher than current highest bid.',
1427
+ parameters: {
1428
+ type: 'object',
1429
+ required: ['auction_id', 'bid_amount', 'confirmed'],
1430
+ properties: {
1431
+ auction_id: { type: 'string', description: 'Auction ID to bid on' },
1432
+ bid_amount: { type: 'string', description: 'Bid amount, e.g. "50.0000 XPR" (must exceed current highest bid)' },
1433
+ taker_marketplace: { type: 'string', description: 'Taker marketplace account (optional)' },
1434
+ confirmed: { type: 'boolean', description: 'Must be true to proceed' },
1435
+ },
1436
+ },
1437
+ handler: async ({ auction_id, bid_amount, taker_marketplace, confirmed }: {
1438
+ auction_id: string; bid_amount: string; taker_marketplace?: string; confirmed?: boolean;
1439
+ }) => {
1440
+ if (!confirmed) return { error: 'Confirmation required. Set confirmed=true to place this bid.' };
1441
+ if (!auction_id) return { error: 'auction_id is required' };
1442
+ if (!bid_amount) return { error: 'bid_amount is required (e.g. "50.0000 XPR")' };
1443
+
1444
+ try {
1445
+ const parsed = parsePrice(bid_amount);
1446
+ const session = await getNftSession();
1447
+ await ensureRam(session);
1448
+
1449
+ const result = await session.api.transact({
1450
+ actions: [
1451
+ {
1452
+ account: parsed.contract,
1453
+ name: 'transfer',
1454
+ authorization: [{ actor: session.account, permission: session.permission }],
1455
+ data: {
1456
+ from: session.account,
1457
+ to: 'atomicmarket',
1458
+ quantity: `${parsed.amount} ${parsed.symbol}`,
1459
+ memo: 'deposit',
1460
+ },
1461
+ },
1462
+ {
1463
+ account: 'atomicmarket',
1464
+ name: 'auctionbid',
1465
+ authorization: [{ actor: session.account, permission: session.permission }],
1466
+ data: {
1467
+ bidder: session.account,
1468
+ auction_id: Number(auction_id),
1469
+ bid: `${parsed.amount} ${parsed.symbol}`,
1470
+ taker_marketplace: taker_marketplace || '',
1471
+ },
1472
+ },
1473
+ ],
1474
+ }, { blocksBehind: 3, expireSeconds: 30 });
1475
+
1476
+ return { transaction_id: result.transaction_id || result.processed?.id, auction_id, bid_amount, bidder: session.account };
1477
+ } catch (err: any) {
1478
+ return { error: `Failed to place bid: ${err.message}` };
1479
+ }
1480
+ },
1481
+ });
1482
+
1483
+ // ── 23. nft_claim_auction ──
1484
+ api.registerTool({
1485
+ name: 'nft_claim_auction',
1486
+ description: 'Claim won assets (buyer) or sale proceeds (seller) from a completed auction. No risk — just claims what is rightfully yours.',
1487
+ parameters: {
1488
+ type: 'object',
1489
+ required: ['auction_id'],
1490
+ properties: {
1491
+ auction_id: { type: 'string', description: 'Auction ID to claim' },
1492
+ },
1493
+ },
1494
+ handler: async ({ auction_id }: { auction_id: string }) => {
1495
+ if (!auction_id) return { error: 'auction_id is required' };
1496
+
1497
+ try {
1498
+ const session = await getNftSession();
1499
+ await ensureRam(session);
1500
+
1501
+ // auctclaimbuy claims assets for the buyer, auctclaimsell claims proceeds for the seller
1502
+ // Try both — only the relevant one will succeed
1503
+ const actions = [
1504
+ {
1505
+ account: 'atomicmarket',
1506
+ name: 'auctclaimbuy',
1507
+ authorization: [{ actor: session.account, permission: session.permission }],
1508
+ data: { auction_id: Number(auction_id) },
1509
+ },
1510
+ {
1511
+ account: 'atomicmarket',
1512
+ name: 'auctclaimsell',
1513
+ authorization: [{ actor: session.account, permission: session.permission }],
1514
+ data: { auction_id: Number(auction_id) },
1515
+ },
1516
+ ];
1517
+
1518
+ // Try buyer claim first
1519
+ try {
1520
+ const result = await session.api.transact({ actions: [actions[0]] }, { blocksBehind: 3, expireSeconds: 30 });
1521
+ return { transaction_id: result.transaction_id || result.processed?.id, auction_id, claim_type: 'buyer' };
1522
+ } catch {
1523
+ // Not the buyer — try seller claim
1524
+ }
1525
+
1526
+ try {
1527
+ const result = await session.api.transact({ actions: [actions[1]] }, { blocksBehind: 3, expireSeconds: 30 });
1528
+ return { transaction_id: result.transaction_id || result.processed?.id, auction_id, claim_type: 'seller' };
1529
+ } catch {
1530
+ // Neither buyer nor seller
1531
+ }
1532
+
1533
+ return { error: `Could not claim auction #${auction_id} — you may not be the buyer or seller, or the auction may not be completed yet.` };
1534
+ } catch (err: any) {
1535
+ return { error: `Failed to claim auction: ${err.message}` };
1536
+ }
1537
+ },
1538
+ });
1539
+ }