-
-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathbatch-mint-agents.mjs
More file actions
347 lines (303 loc) · 11.9 KB
/
Copy pathbatch-mint-agents.mjs
File metadata and controls
347 lines (303 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
#!/usr/bin/env node
/**
* Batch-mint all three.ws agents with 3D avatars as Metaplex Core assets.
* -------------------------------------------------------------------------
* Iterates every agent_identity that has an avatar but no sol_mint_address,
* mints it into the three.ws Agent Collection on Solana, and writes the
* resulting asset address back to the DB.
*
* Custody model:
* - If the agent's user has a Solana payout wallet → mint directly to them.
* - Otherwise → mint to the collection authority as custodian.
* The authority (as owner) can transfer to the user when they connect
* their Solana wallet via the claim flow.
*
* Usage:
* # Required env vars:
* SOLANA_AGENT_COLLECTION_AUTHORITY_KEY=<bs58 secret>
* SOLANA_AGENT_COLLECTION_MAINNET=<collection address>
* DATABASE_URL=<postgres connection string>
* S3_BUCKET=<bucket>
* S3_PUBLIC_DOMAIN=<https://...>
* AWS_ACCESS_KEY_ID=<key>
* AWS_SECRET_ACCESS_KEY=<secret>
* AWS_ENDPOINT_URL_S3=<r2 endpoint>
*
* # Run (devnet safe test first):
* node scripts/batch-mint-agents.mjs --network devnet --dry-run
*
* # Real mainnet run:
* SOLANA_RPC_URL=<rpc> node scripts/batch-mint-agents.mjs --network mainnet
*
* # Limit to N agents (useful for staged rollouts):
* node scripts/batch-mint-agents.mjs --network mainnet --limit 50
*
* # Re-run is safe — already-minted agents are skipped.
*/
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import process from 'node:process';
const require = createRequire(import.meta.url);
const { Client } = require('pg');
const bs58 = require('bs58');
const { Keypair } = require('@solana/web3.js');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
import { createUmi } from '@metaplex-foundation/umi-bundle-defaults';
import { mplCore, create, ruleSet } from '@metaplex-foundation/mpl-core';
import {
generateSigner,
publicKey as umiPublicKey,
createSignerFromKeypair,
signerIdentity,
} from '@metaplex-foundation/umi';
// ── CLI args ─────────────────────────────────────────────────────────────────
function arg(name) {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function flag(name) {
return process.argv.includes(name);
}
const NETWORK = arg('--network') === 'mainnet' ? 'mainnet' : 'devnet';
const DRY_RUN = flag('--dry-run');
const LIMIT = Number(arg('--limit') || 0);
const PUBLIC_RPC = {
mainnet: 'https://api.mainnet-beta.solana.com',
devnet: 'https://api.devnet.solana.com',
};
// ── Env validation ────────────────────────────────────────────────────────────
function required(key) {
const v = process.env[key];
if (!v || !v.trim()) {
console.error(`Missing required env var: ${key}`);
process.exit(1);
}
return v.trim();
}
const AUTHORITY_KEY = required('SOLANA_AGENT_COLLECTION_AUTHORITY_KEY');
const COLLECTION_ADDR =
NETWORK === 'mainnet'
? required('SOLANA_AGENT_COLLECTION_MAINNET')
: required('SOLANA_AGENT_COLLECTION_DEVNET');
const DATABASE_URL = required('DATABASE_URL');
const S3_BUCKET = required('S3_BUCKET');
const S3_DOMAIN = required('S3_PUBLIC_DOMAIN');
// ── R2 client ─────────────────────────────────────────────────────────────────
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.AWS_ENDPOINT_URL_S3,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
function publicUrl(key) {
return `${S3_DOMAIN}/${key.split('/').map(encodeURIComponent).join('/')}`;
}
async function uploadJson(key, obj) {
const body = Buffer.from(JSON.stringify(obj), 'utf-8');
await s3.send(new PutObjectCommand({
Bucket: S3_BUCKET, Key: key, Body: body, ContentType: 'application/json',
}));
return publicUrl(key);
}
// ── Umi / Metaplex setup ──────────────────────────────────────────────────────
const rpc =
NETWORK === 'devnet'
? process.env.SOLANA_RPC_URL_DEVNET || PUBLIC_RPC.devnet
: process.env.SOLANA_RPC_URL || PUBLIC_RPC.mainnet;
const umi = createUmi(rpc).use(mplCore());
const web3Authority = Keypair.fromSecretKey(bs58.decode(AUTHORITY_KEY));
const authoritySigner = createSignerFromKeypair(
umi,
umi.eddsa.createKeypairFromSecretKey(web3Authority.secretKey),
);
umi.use(signerIdentity(authoritySigner));
const authorityPk = authoritySigner.publicKey.toString();
const collectionPk = umiPublicKey(COLLECTION_ADDR);
// ── Manifest builder ──────────────────────────────────────────────────────────
const PLATFORM = 'three.ws';
const WEBSITE = 'https://three.ws';
const X = 'https://x.com/three_ws';
const GITHUB = 'https://github.com/nirholas/three.ws';
// $THREE — the only coin on this platform
const THREE_MINT = 'FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump';
const OG_IMAGE = 'https://three.ws/og.png';
function buildManifest(agent, avatarGlbUrl, avatarThumbUrl) {
const description = agent.description?.trim() ||
`${agent.name} — an autonomous agent on ${PLATFORM}.`;
const image = avatarThumbUrl || OG_IMAGE;
return {
name: agent.name,
symbol: 'AGENT',
description,
image,
...(avatarGlbUrl ? { animation_url: avatarGlbUrl } : {}),
external_url: `${WEBSITE}/agents/${agent.id}`,
attributes: [
{ trait_type: 'Platform', value: PLATFORM },
{ trait_type: 'Standard', value: 'Metaplex Core' },
{ trait_type: 'Schema', value: 'agent-manifest/0.1' },
{ trait_type: '$THREE', value: THREE_MINT },
{ trait_type: 'Created', value: new Date(agent.created_at).toISOString() },
],
properties: {
category: avatarGlbUrl ? 'vr' : 'image',
files: [
{ uri: image, type: 'image/png' },
...(avatarGlbUrl ? [{ uri: avatarGlbUrl, type: 'model/gltf-binary' }] : []),
],
creators: [{ address: authorityPk, share: 100 }],
},
platform: { name: PLATFORM, url: WEBSITE, x: X, github: GITHUB },
token: { symbol: '$THREE', mint: THREE_MINT },
$schema: 'https://3d-agent.io/schemas/manifest/0.1.json',
spec: 'agent-manifest/0.1',
tags: ['three.ws', 'ai-agent'],
body: { uri: avatarGlbUrl || '', format: 'gltf-binary' },
...(agent.avatar_id ? { avatarId: agent.avatar_id } : {}),
};
}
function buildOnchainAttributes(agent) {
const clamp = (s, n) => s && s.length > n ? s.slice(0, n - 1) + '…' : (s || '');
return [
{ key: 'platform', value: PLATFORM },
{ key: 'url', value: WEBSITE },
{ key: 'agent', value: clamp(agent.name, 48) },
{ key: 'agent_url', value: `${WEBSITE}/agents/${agent.id}` },
{ key: 'x', value: X },
{ key: 'github', value: GITHUB },
{ key: '$THREE', value: THREE_MINT },
{ key: 'standard', value: 'metaplex-core' },
{ key: 'schema', value: 'agent-manifest/0.1' },
{ key: 'created', value: new Date(agent.created_at).toISOString() },
];
}
// ── Mint one agent ─────────────────────────────────────────────────────────────
async function mintAgent(agent, ownerAddress, metadataUri) {
const assetSigner = generateSigner(umi);
const ownerPk = umiPublicKey(ownerAddress);
const royalty = {
basisPoints: 500,
creators: [{ address: ownerPk, percentage: 100 }],
ruleSet: ruleSet('None'),
};
await create(umi, {
asset: assetSigner,
collection: collectionPk,
authority: authoritySigner,
owner: ownerPk,
name: agent.name.slice(0, 32) || 'Agent',
uri: metadataUri,
plugins: [
{
type: 'Attributes',
attributeList: buildOnchainAttributes(agent),
},
{
type: 'Royalties',
...royalty,
},
],
}).sendAndConfirm(umi, { confirm: { commitment: 'confirmed' } });
return assetSigner.publicKey.toString();
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
console.log(`\nthree.ws Agent Batch Mint`);
console.log(` network: ${NETWORK}`);
console.log(` rpc: ${rpc.replace(/api-key=[^&]+/i, 'api-key=***')}`);
console.log(` authority: ${authorityPk}`);
console.log(` collection: ${COLLECTION_ADDR}`);
console.log(` dry-run: ${DRY_RUN}`);
console.log(` limit: ${LIMIT || 'all'}`);
console.log('');
const db = new Client({ connectionString: DATABASE_URL });
await db.connect();
const { rows: agents } = await db.query(`
SELECT
ai.id, ai.name, ai.description, ai.user_id, ai.avatar_id, ai.created_at,
av.storage_key AS glb_key,
av.thumbnail_key AS thumb_key,
apw.address AS solana_wallet
FROM agent_identities ai
JOIN avatars av ON av.id = ai.avatar_id
LEFT JOIN agent_payout_wallets apw
ON apw.agent_id = ai.id AND apw.chain = 'solana'
WHERE ai.avatar_id IS NOT NULL
AND ai.meta->>'sol_mint_address' IS NULL
AND ai.deleted_at IS NULL
AND av.deleted_at IS NULL
ORDER BY ai.created_at ASC
${LIMIT ? `LIMIT ${LIMIT}` : ''}
`);
console.log(`Found ${agents.length} agents to mint.\n`);
if (agents.length === 0) { await db.end(); return; }
let minted = 0, skipped = 0, failed = 0;
for (const agent of agents) {
const ownerAddress = agent.solana_wallet || authorityPk;
const isCustody = ownerAddress === authorityPk;
const glbUrl = agent.glb_key ? publicUrl(agent.glb_key) : null;
const thumbUrl = agent.thumb_key ? publicUrl(agent.thumb_key) : null;
process.stdout.write(
`[${minted + failed + 1}/${agents.length}] ${agent.name} (${agent.id.slice(0, 8)}) ` +
`→ ${isCustody ? 'custody' : agent.solana_wallet.slice(0, 8) + '…'} … `
);
if (DRY_RUN) {
console.log('skip (dry-run)');
skipped++;
continue;
}
try {
const metaKey = `agent-manifests/batch/${agent.id}.json`;
const manifest = buildManifest(agent, glbUrl, thumbUrl);
const metadataUri = await uploadJson(metaKey, manifest);
const assetAddress = await mintAgent(agent, ownerAddress, metadataUri);
await db.query(`
UPDATE agent_identities
SET meta = jsonb_set(
jsonb_set(
coalesce(meta, '{}'),
'{sol_mint_address}', to_jsonb($2::text)
),
'{onchain}', $3::jsonb
),
updated_at = now()
WHERE id = $1
`, [
agent.id,
assetAddress,
JSON.stringify({
chain: `solana:${NETWORK === 'mainnet' ? '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' : 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1'}`,
family: 'solana',
cluster: NETWORK,
onchain_id: assetAddress,
contract_or_mint: assetAddress,
metadata_uri: metadataUri,
owner: ownerAddress,
custody: isCustody,
confirmed_at: new Date().toISOString(),
}),
]);
console.log(`✓ ${assetAddress}`);
minted++;
} catch (err) {
console.log(`✗ ${err.message}`);
failed++;
}
// ~2 mints/sec — stay well within public RPC limits
await new Promise(r => setTimeout(r, 500));
}
await db.end();
console.log(`\nDone.`);
console.log(` minted: ${minted}`);
console.log(` failed: ${failed}`);
console.log(` skipped: ${skipped}`);
if (failed > 0) {
console.log('\nRe-run is safe — already-minted agents are skipped automatically.');
}
}
main().catch((err) => {
console.error('\n❌ Fatal:', err?.message || err);
process.exit(1);
});