@three-ws/avatar-agent 1.0.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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +74 -0
- package/server.json +75 -0
- package/src/config.js +31 -0
- package/src/index.js +123 -0
- package/src/lib/atomic-collect.js +141 -0
- package/src/lib/atomic-launch.js +184 -0
- package/src/lib/avatars.js +94 -0
- package/src/lib/ens-sns.js +105 -0
- package/src/lib/glb-io.js +59 -0
- package/src/lib/jito.js +72 -0
- package/src/lib/jupiter-buy.js +158 -0
- package/src/lib/pumpfun.js +175 -0
- package/src/lib/render.js +76 -0
- package/src/lib/solana.js +190 -0
- package/src/tools/dress-avatar.js +61 -0
- package/src/tools/ens-sns-resolve.js +21 -0
- package/src/tools/generate-avatar.js +165 -0
- package/src/tools/inspect-glb.js +219 -0
- package/src/tools/list-animations.js +37 -0
- package/src/tools/list-avatars.js +20 -0
- package/src/tools/optimize-glb.js +114 -0
- package/src/tools/pump-buy.js +69 -0
- package/src/tools/pump-collect.js +48 -0
- package/src/tools/pump-launch.js +77 -0
- package/src/tools/pump-snapshot.js +38 -0
- package/src/tools/render-avatar.js +60 -0
- package/src/tools/spawn-avatar.js +60 -0
- package/src/tools/speak.js +92 -0
- package/src/tools/thumbnail-glb.js +35 -0
- package/src/tools/validate-glb.js +90 -0
- package/src/tools/viewer-url.js +124 -0
- package/src/tools/wallet-balance.js +36 -0
- package/src/tools/wallet-create.js +67 -0
- package/src/tools/wallet-send.js +35 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Atomic pump.fun token launch via a Jito bundle.
|
|
2
|
+
//
|
|
3
|
+
// Ported from nirholas/atomic's fire-jito.js. Two-tx bundle:
|
|
4
|
+
// Tx 1 (funder pays fee + tip): transfer rent SOL to creator + Jito tip
|
|
5
|
+
// Tx 2 (creator pays fee): pump.fun createV2 instruction
|
|
6
|
+
//
|
|
7
|
+
// Both txs share the same recent blockhash and are submitted as a bundle to
|
|
8
|
+
// Jito's mainnet block engine. Either both land or neither does — so no
|
|
9
|
+
// MEV searcher can interleave, and the creator wallet doesn't need to hold
|
|
10
|
+
// SOL before the launch.
|
|
11
|
+
//
|
|
12
|
+
// This is the "real launch" path: the on-chain `creator` field on the mint
|
|
13
|
+
// is the creator wallet, not the funder. That matters because pump.fun's
|
|
14
|
+
// creator-fee accrual follows the creator key.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Keypair,
|
|
18
|
+
PublicKey,
|
|
19
|
+
SystemProgram,
|
|
20
|
+
TransactionMessage,
|
|
21
|
+
VersionedTransaction,
|
|
22
|
+
ComputeBudgetProgram,
|
|
23
|
+
LAMPORTS_PER_SOL,
|
|
24
|
+
} from '@solana/web3.js';
|
|
25
|
+
import FormData from 'form-data';
|
|
26
|
+
|
|
27
|
+
import { bs58encode, bs58decode, getConnection, keypairFromSecret } from './solana.js';
|
|
28
|
+
import { JITO_TIP_ACCOUNTS, randomTipAccount, submitBundle, waitForSignatures } from './jito.js';
|
|
29
|
+
|
|
30
|
+
const PUMP_IPFS_URL = 'https://pump.fun/api/ipfs';
|
|
31
|
+
|
|
32
|
+
// Upload token metadata + image to pump.fun's IPFS endpoint. Returns the
|
|
33
|
+
// metadata URI you feed into createV2. If no imageUrl is provided we use a
|
|
34
|
+
// 1x1 transparent PNG placeholder so the form is valid; pass an imageUrl
|
|
35
|
+
// to use a real image fetched at upload time.
|
|
36
|
+
export async function uploadPumpMetadata({ name, symbol, description = '', twitter = '', telegram = '', website = '', imageUrl, showName = true }) {
|
|
37
|
+
if (!name) throw new Error('uploadPumpMetadata: name is required');
|
|
38
|
+
if (!symbol) throw new Error('uploadPumpMetadata: symbol is required');
|
|
39
|
+
const form = new FormData();
|
|
40
|
+
let imageBuf;
|
|
41
|
+
let imageName = 'placeholder.png';
|
|
42
|
+
let imageType = 'image/png';
|
|
43
|
+
if (imageUrl) {
|
|
44
|
+
const r = await fetch(imageUrl);
|
|
45
|
+
if (!r.ok) throw new Error(`Failed to fetch imageUrl: HTTP ${r.status}`);
|
|
46
|
+
imageBuf = Buffer.from(await r.arrayBuffer());
|
|
47
|
+
const ext = (imageUrl.split('?')[0].split('.').pop() || 'png').toLowerCase();
|
|
48
|
+
imageName = `image.${ext}`;
|
|
49
|
+
imageType = r.headers.get('content-type') || imageType;
|
|
50
|
+
} else {
|
|
51
|
+
imageBuf = Buffer.from(
|
|
52
|
+
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154' +
|
|
53
|
+
'78da63fcffff3f0305000601018a0c1d990000000049454e44ae426082',
|
|
54
|
+
'hex',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
form.append('file', imageBuf, { filename: imageName, contentType: imageType });
|
|
58
|
+
form.append('name', name);
|
|
59
|
+
form.append('symbol', symbol);
|
|
60
|
+
form.append('description', description);
|
|
61
|
+
form.append('twitter', twitter);
|
|
62
|
+
form.append('telegram', telegram);
|
|
63
|
+
form.append('website', website);
|
|
64
|
+
form.append('showName', String(showName));
|
|
65
|
+
|
|
66
|
+
const res = await fetch(PUMP_IPFS_URL, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
body: form,
|
|
69
|
+
headers: form.getHeaders(),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const txt = await res.text().catch(() => '');
|
|
73
|
+
throw new Error(`pump.fun IPFS upload failed (${res.status}): ${txt.slice(0, 300)}`);
|
|
74
|
+
}
|
|
75
|
+
const json = await res.json();
|
|
76
|
+
return {
|
|
77
|
+
uri: json.metadataUri || json.metadata_uri || null,
|
|
78
|
+
raw: json,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function atomicLaunch({
|
|
83
|
+
name,
|
|
84
|
+
symbol,
|
|
85
|
+
uri,
|
|
86
|
+
funderSecret,
|
|
87
|
+
creatorSecret,
|
|
88
|
+
mintSecret,
|
|
89
|
+
rentSol = 0.035,
|
|
90
|
+
jitoTipSol = 0.005,
|
|
91
|
+
priorityMicroLamports = 2_000_000,
|
|
92
|
+
mayhemMode = false,
|
|
93
|
+
cashback = false,
|
|
94
|
+
}) {
|
|
95
|
+
if (!name) throw new Error('atomicLaunch: name is required');
|
|
96
|
+
if (!symbol) throw new Error('atomicLaunch: symbol is required');
|
|
97
|
+
if (!uri) throw new Error('atomicLaunch: uri is required (call uploadPumpMetadata first or pass an existing one)');
|
|
98
|
+
|
|
99
|
+
const funder = keypairFromSecret(funderSecret);
|
|
100
|
+
const creator = keypairFromSecret(creatorSecret);
|
|
101
|
+
const mint = mintSecret
|
|
102
|
+
? Keypair.fromSecretKey(bs58decode(mintSecret))
|
|
103
|
+
: Keypair.generate();
|
|
104
|
+
|
|
105
|
+
// pump-sdk is a CJS module — import dynamically so this file can stay ESM.
|
|
106
|
+
const pumpSdkPkg = await import('@nirholas/pump-sdk');
|
|
107
|
+
const PUMP_SDK = pumpSdkPkg.PUMP_SDK || pumpSdkPkg.default?.PUMP_SDK;
|
|
108
|
+
if (!PUMP_SDK || typeof PUMP_SDK.createV2Instruction !== 'function') {
|
|
109
|
+
throw new Error('@nirholas/pump-sdk: PUMP_SDK.createV2Instruction not found in installed version');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const conn = getConnection();
|
|
113
|
+
const funderBal = await conn.getBalance(funder.publicKey, 'confirmed');
|
|
114
|
+
const needed = (rentSol + jitoTipSol + 0.002) * LAMPORTS_PER_SOL;
|
|
115
|
+
if (funderBal < needed) {
|
|
116
|
+
const err = new Error(
|
|
117
|
+
`Funder needs >= ${(needed / LAMPORTS_PER_SOL).toFixed(4)} SOL; has ${(funderBal / LAMPORTS_PER_SOL).toFixed(4)} SOL.`,
|
|
118
|
+
);
|
|
119
|
+
err.code = 'insufficient_funds';
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const tipAccount = randomTipAccount();
|
|
124
|
+
const { blockhash } = await conn.getLatestBlockhash('confirmed');
|
|
125
|
+
|
|
126
|
+
const tx1Msg = new TransactionMessage({
|
|
127
|
+
payerKey: funder.publicKey,
|
|
128
|
+
recentBlockhash: blockhash,
|
|
129
|
+
instructions: [
|
|
130
|
+
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityMicroLamports }),
|
|
131
|
+
ComputeBudgetProgram.setComputeUnitLimit({ units: 1000 }),
|
|
132
|
+
SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: creator.publicKey, lamports: Math.floor(rentSol * LAMPORTS_PER_SOL) }),
|
|
133
|
+
SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: tipAccount, lamports: Math.floor(jitoTipSol * LAMPORTS_PER_SOL) }),
|
|
134
|
+
],
|
|
135
|
+
}).compileToV0Message();
|
|
136
|
+
const tx1 = new VersionedTransaction(tx1Msg);
|
|
137
|
+
tx1.sign([funder]);
|
|
138
|
+
|
|
139
|
+
const createIx = await PUMP_SDK.createV2Instruction({
|
|
140
|
+
mint: mint.publicKey,
|
|
141
|
+
name,
|
|
142
|
+
symbol,
|
|
143
|
+
uri,
|
|
144
|
+
creator: creator.publicKey,
|
|
145
|
+
user: creator.publicKey,
|
|
146
|
+
mayhemMode,
|
|
147
|
+
cashback,
|
|
148
|
+
});
|
|
149
|
+
const tx2Msg = new TransactionMessage({
|
|
150
|
+
payerKey: creator.publicKey,
|
|
151
|
+
recentBlockhash: blockhash,
|
|
152
|
+
instructions: [
|
|
153
|
+
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityMicroLamports }),
|
|
154
|
+
ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }),
|
|
155
|
+
createIx,
|
|
156
|
+
],
|
|
157
|
+
}).compileToV0Message();
|
|
158
|
+
const tx2 = new VersionedTransaction(tx2Msg);
|
|
159
|
+
tx2.sign([creator, mint]);
|
|
160
|
+
|
|
161
|
+
const bundle = [bs58encode(tx1.serialize()), bs58encode(tx2.serialize())];
|
|
162
|
+
const { bundleId, explorer } = await submitBundle(bundle);
|
|
163
|
+
|
|
164
|
+
const sig1 = bs58encode(tx1.signatures[0]);
|
|
165
|
+
const sig2 = bs58encode(tx2.signatures[0]);
|
|
166
|
+
const wait = await waitForSignatures(conn, [sig1, sig2], { timeoutMs: 60_000, intervalMs: 2_000 });
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
ok: wait.ok,
|
|
170
|
+
bundleId,
|
|
171
|
+
bundleExplorer: explorer,
|
|
172
|
+
mint: mint.publicKey.toBase58(),
|
|
173
|
+
mintSecret: bs58encode(mint.secretKey),
|
|
174
|
+
creator: creator.publicKey.toBase58(),
|
|
175
|
+
funder: funder.publicKey.toBase58(),
|
|
176
|
+
tx1Signature: sig1,
|
|
177
|
+
tx2Signature: sig2,
|
|
178
|
+
statuses: wait.statuses,
|
|
179
|
+
err: wait.err,
|
|
180
|
+
pumpUrl: `https://pump.fun/coin/${mint.publicKey.toBase58()}`,
|
|
181
|
+
fundingTxExplorer: `https://solscan.io/tx/${sig1}`,
|
|
182
|
+
createTxExplorer: `https://solscan.io/tx/${sig2}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Curated catalog of default avatars + accessories served from the
|
|
2
|
+
// three.ws public CDN. All URLs are public and cacheable — no auth needed.
|
|
3
|
+
//
|
|
4
|
+
// The MCP keeps avatar sessions in-process: spawning an avatar returns a
|
|
5
|
+
// session id that other tools (dress_avatar, viewer_url, render_clip) can
|
|
6
|
+
// reference. Sessions don't persist across MCP restarts; for production
|
|
7
|
+
// flows the client should re-call spawn_avatar.
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
import { THREE_WS_BASE, VIEWER_BASE } from '../config.js';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_AVATARS = [
|
|
14
|
+
{
|
|
15
|
+
id: 'default',
|
|
16
|
+
name: 'three.ws default',
|
|
17
|
+
description: 'Stylized humanoid base mesh, rigged with the three.ws standard skeleton. Good starting point for any persona.',
|
|
18
|
+
glb: `${THREE_WS_BASE}/avatars/default.glb`,
|
|
19
|
+
thumbnail: null,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'cz',
|
|
23
|
+
name: 'CZ',
|
|
24
|
+
description: 'Suited exchange-founder type with a clean rig. Drops well-known crypto vibes into your demo.',
|
|
25
|
+
glb: `${THREE_WS_BASE}/avatars/cz.glb`,
|
|
26
|
+
thumbnail: null,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const ACCESSORIES = [
|
|
31
|
+
{ id: 'hat-baseball', slot: 'head', glb: `${THREE_WS_BASE}/accessories/hat-baseball.glb`, name: 'Baseball cap' },
|
|
32
|
+
{ id: 'hat-beanie', slot: 'head', glb: `${THREE_WS_BASE}/accessories/hat-beanie.glb`, name: 'Beanie' },
|
|
33
|
+
{ id: 'hat-cowboy', slot: 'head', glb: `${THREE_WS_BASE}/accessories/hat-cowboy.glb`, name: 'Cowboy hat' },
|
|
34
|
+
{ id: 'glasses-round', slot: 'face', glb: `${THREE_WS_BASE}/accessories/glasses-round.glb`, name: 'Round glasses' },
|
|
35
|
+
{ id: 'glasses-shades', slot: 'face', glb: `${THREE_WS_BASE}/accessories/glasses-shades.glb`, name: 'Shades' },
|
|
36
|
+
{ id: 'earrings-hoops', slot: 'ears', glb: `${THREE_WS_BASE}/accessories/earrings-hoops.glb`, name: 'Hoop earrings' },
|
|
37
|
+
{ id: 'earrings-studs', slot: 'ears', glb: `${THREE_WS_BASE}/accessories/earrings-studs.glb`, name: 'Stud earrings' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export const POSE_PRESETS = [
|
|
41
|
+
'idle', 'wave', 'thumbs_up', 'dab', 't_pose', 'sit', 'point', 'salute', 'cheer',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export function findAvatar(id) {
|
|
45
|
+
return DEFAULT_AVATARS.find((a) => a.id === id) || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findAccessory(id) {
|
|
49
|
+
return ACCESSORIES.find((a) => a.id === id) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// In-memory session store. Maps sessionId → { avatar, accessories[], pose,
|
|
53
|
+
// voice, createdAt, lastUpdated }.
|
|
54
|
+
const sessions = new Map();
|
|
55
|
+
|
|
56
|
+
export function createSession({ glb, name, voice, persona, accessories = [], pose = 'idle' }) {
|
|
57
|
+
const id = randomUUID();
|
|
58
|
+
const session = {
|
|
59
|
+
id,
|
|
60
|
+
name: name || null,
|
|
61
|
+
persona: persona || null,
|
|
62
|
+
voice: voice || 'nova',
|
|
63
|
+
avatar: { glb, source: glb.startsWith(THREE_WS_BASE) ? 'three.ws' : 'external' },
|
|
64
|
+
accessories: accessories.slice(),
|
|
65
|
+
pose,
|
|
66
|
+
wallet: null,
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
lastUpdated: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
sessions.set(id, session);
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getSession(id) {
|
|
75
|
+
return sessions.get(id) || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function updateSession(id, patch) {
|
|
79
|
+
const s = sessions.get(id);
|
|
80
|
+
if (!s) return null;
|
|
81
|
+
Object.assign(s, patch, { lastUpdated: new Date().toISOString() });
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function listSessions() {
|
|
86
|
+
return [...sessions.values()];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function viewerUrlFor(session) {
|
|
90
|
+
const params = new URLSearchParams({ src: session.avatar.glb });
|
|
91
|
+
if (session.accessories.length) params.set('accessories', session.accessories.map((a) => a.id).join(','));
|
|
92
|
+
if (session.pose && session.pose !== 'idle') params.set('pose', session.pose);
|
|
93
|
+
return `${VIEWER_BASE}?${params.toString()}`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ENS (Ethereum) + SNS (Solana) name resolution.
|
|
2
|
+
//
|
|
3
|
+
// ENS: ethers JsonRpcProvider against the configured ETH_RPC_URL, falling
|
|
4
|
+
// back to ethers' default public provider rotation.
|
|
5
|
+
// SNS: Bonfida's sns-api (the same service three.ws uses on the web side).
|
|
6
|
+
|
|
7
|
+
import { ethers } from 'ethers';
|
|
8
|
+
|
|
9
|
+
import { ETH_RPC_URL } from '../config.js';
|
|
10
|
+
|
|
11
|
+
const ENS_RE = /^(?:[a-z0-9-]+\.)*[a-z0-9-]+\.eth$/i;
|
|
12
|
+
const SOL_RE = /^[a-z0-9-]{1,63}(?:\.sol)?$/i;
|
|
13
|
+
const SNS_API = 'https://sns-api.bonfida.com';
|
|
14
|
+
|
|
15
|
+
async function withTimeout(promise, ms, label) {
|
|
16
|
+
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms));
|
|
17
|
+
return Promise.race([promise, timeout]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function resolveEns(name) {
|
|
21
|
+
const provider = ETH_RPC_URL
|
|
22
|
+
? new ethers.JsonRpcProvider(ETH_RPC_URL)
|
|
23
|
+
: ethers.getDefaultProvider('mainnet');
|
|
24
|
+
const address = await withTimeout(provider.resolveName(name), 4000, 'ens');
|
|
25
|
+
if (!address) return null;
|
|
26
|
+
let reverseName = null;
|
|
27
|
+
try {
|
|
28
|
+
reverseName = await withTimeout(provider.lookupAddress(address), 3000, 'ens-reverse');
|
|
29
|
+
} catch {
|
|
30
|
+
// best effort
|
|
31
|
+
}
|
|
32
|
+
return { network: 'ethereum', name, address, reverseName, rpc: ETH_RPC_URL || 'ethers-default' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolveSns(name) {
|
|
36
|
+
const bare = name.toLowerCase().replace(/\.sol$/, '');
|
|
37
|
+
if (!/^[a-z0-9-]{1,63}$/.test(bare)) return null;
|
|
38
|
+
const lookup = await fetch(`${SNS_API}/v2/domain/lookup/${bare}.sol`).catch(() => null);
|
|
39
|
+
if (!lookup || !lookup.ok) return null;
|
|
40
|
+
const data = await lookup.json().catch(() => null);
|
|
41
|
+
const owner = data?.owner || data?.[bare + '.sol']?.owner || data?.data?.owner || null;
|
|
42
|
+
if (!owner) return null;
|
|
43
|
+
|
|
44
|
+
let allDomains = [];
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(`${SNS_API}/v2/user/domains/${owner}`);
|
|
47
|
+
if (r.ok) {
|
|
48
|
+
const body = await r.json();
|
|
49
|
+
const list = body?.[owner] || body?.data?.[owner] || [];
|
|
50
|
+
if (Array.isArray(list)) {
|
|
51
|
+
allDomains = list
|
|
52
|
+
.map((d) => (typeof d === 'string' ? d : d?.domain || d?.name))
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let favoriteDomain = null;
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetch(`${SNS_API}/v2/user/fav-domains/${owner}`);
|
|
63
|
+
if (r.ok) {
|
|
64
|
+
const body = await r.json();
|
|
65
|
+
favoriteDomain = body?.[owner] || body?.data?.[owner] || null;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// best effort
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
network: 'solana',
|
|
73
|
+
name: `${bare}.sol`,
|
|
74
|
+
address: owner,
|
|
75
|
+
favoriteDomain,
|
|
76
|
+
allDomains,
|
|
77
|
+
source: `${SNS_API}/v2/domain/lookup/${bare}.sol`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function resolveName(name) {
|
|
82
|
+
const trimmed = String(name || '').trim().toLowerCase();
|
|
83
|
+
const isEns = ENS_RE.test(trimmed);
|
|
84
|
+
const isSol = /\.sol$/.test(trimmed) || (!isEns && SOL_RE.test(trimmed));
|
|
85
|
+
|
|
86
|
+
const tasks = [];
|
|
87
|
+
if (isEns) tasks.push(['ens', resolveEns(trimmed).catch((e) => ({ error: e?.message || 'ens failed' }))]);
|
|
88
|
+
if (isSol) tasks.push(['sns', resolveSns(trimmed).catch((e) => ({ error: e?.message || 'sns failed' }))]);
|
|
89
|
+
if (!isEns && !isSol) {
|
|
90
|
+
return { ok: false, error: 'invalid_name', message: 'name does not look like a .eth, .sol, or bare label' };
|
|
91
|
+
}
|
|
92
|
+
const results = await Promise.all(tasks.map((t) => t[1]));
|
|
93
|
+
const out = { ok: false, input: trimmed, ens: null, sns: null };
|
|
94
|
+
tasks.forEach(([key], i) => {
|
|
95
|
+
out[key] = results[i] || null;
|
|
96
|
+
});
|
|
97
|
+
if (out.ens && !out.ens.error) out.ok = true;
|
|
98
|
+
if (out.sns && !out.sns.error) out.ok = true;
|
|
99
|
+
if (!out.ok) {
|
|
100
|
+
out.error = 'not_found';
|
|
101
|
+
out.message = 'name did not resolve in either ENS or SNS';
|
|
102
|
+
}
|
|
103
|
+
out.fetchedAt = new Date().toISOString();
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// glTF / GLB I/O helpers — load a GLB from a URL or a base64 data URL into
|
|
2
|
+
// a @gltf-transform/core Document, and serialize back to bytes when needed.
|
|
3
|
+
//
|
|
4
|
+
// The @gltf-transform NodeIO reader does the binary parsing (BIN chunk +
|
|
5
|
+
// JSON chunk per the glTF 2.0 spec). We attach the Draco extension on
|
|
6
|
+
// both reader + writer so already-Draco-compressed meshes round-trip
|
|
7
|
+
// correctly.
|
|
8
|
+
|
|
9
|
+
import { NodeIO } from '@gltf-transform/core';
|
|
10
|
+
import { KHRDracoMeshCompression, ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
|
11
|
+
import draco3dgltf from 'draco3dgltf';
|
|
12
|
+
|
|
13
|
+
let _io = null;
|
|
14
|
+
|
|
15
|
+
async function buildIo() {
|
|
16
|
+
const [encoder, decoder] = await Promise.all([
|
|
17
|
+
draco3dgltf.createEncoderModule(),
|
|
18
|
+
draco3dgltf.createDecoderModule(),
|
|
19
|
+
]);
|
|
20
|
+
return new NodeIO()
|
|
21
|
+
.registerExtensions(ALL_EXTENSIONS)
|
|
22
|
+
.registerDependencies({
|
|
23
|
+
'draco3d.encoder': encoder,
|
|
24
|
+
'draco3d.decoder': decoder,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getIo() {
|
|
29
|
+
if (!_io) _io = await buildIo();
|
|
30
|
+
return _io;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchGlbBytes(url) {
|
|
34
|
+
if (url.startsWith('data:')) {
|
|
35
|
+
const comma = url.indexOf(',');
|
|
36
|
+
if (comma === -1) throw new Error('Invalid data URL');
|
|
37
|
+
const meta = url.slice(5, comma);
|
|
38
|
+
const data = url.slice(comma + 1);
|
|
39
|
+
if (meta.includes(';base64')) {
|
|
40
|
+
return Buffer.from(data, 'base64');
|
|
41
|
+
}
|
|
42
|
+
return Buffer.from(decodeURIComponent(data), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
const r = await fetch(url);
|
|
45
|
+
if (!r.ok) throw new Error(`Failed to fetch ${url}: HTTP ${r.status}`);
|
|
46
|
+
return Buffer.from(await r.arrayBuffer());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readDocument(url) {
|
|
50
|
+
const io = await getIo();
|
|
51
|
+
const bytes = await fetchGlbBytes(url);
|
|
52
|
+
const doc = await io.readBinary(bytes);
|
|
53
|
+
return { doc, bytes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function writeBinary(doc) {
|
|
57
|
+
const io = await getIo();
|
|
58
|
+
return io.writeBinary(doc);
|
|
59
|
+
}
|
package/src/lib/jito.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Jito Block Engine helpers.
|
|
2
|
+
//
|
|
3
|
+
// We submit pre-signed transaction bundles to Jito's mainnet block engine.
|
|
4
|
+
// A bundle of 1-5 transactions either all land in the same block or none
|
|
5
|
+
// land — atomicity is what enables the launch-and-collect tricks in
|
|
6
|
+
// nirholas/atomic (separate funder and creator wallets, leaked-key
|
|
7
|
+
// rescues, sniper-resistant launches).
|
|
8
|
+
//
|
|
9
|
+
// Jito tip accounts rotate occasionally; if you start hitting
|
|
10
|
+
// "Bundles must write lock at least one tip account" errors, fetch fresh
|
|
11
|
+
// ones via the getTipAccounts JSON-RPC method against the block engine.
|
|
12
|
+
|
|
13
|
+
import { PublicKey } from '@solana/web3.js';
|
|
14
|
+
|
|
15
|
+
export const JITO_BUNDLE_URL = 'https://mainnet.block-engine.jito.wtf/api/v1/bundles';
|
|
16
|
+
|
|
17
|
+
export const JITO_TIP_ACCOUNTS = [
|
|
18
|
+
'ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt',
|
|
19
|
+
'HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe',
|
|
20
|
+
'Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY',
|
|
21
|
+
'ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49',
|
|
22
|
+
'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL',
|
|
23
|
+
'96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5',
|
|
24
|
+
'3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT',
|
|
25
|
+
'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function randomTipAccount() {
|
|
29
|
+
return new PublicKey(JITO_TIP_ACCOUNTS[Math.floor(Math.random() * JITO_TIP_ACCOUNTS.length)]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function submitBundle(bs58Txs) {
|
|
33
|
+
const res = await fetch(JITO_BUNDLE_URL, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'sendBundle', params: [bs58Txs] }),
|
|
37
|
+
});
|
|
38
|
+
const body = await res.json().catch(() => ({}));
|
|
39
|
+
if (body.error) {
|
|
40
|
+
const err = new Error(`Jito bundle submit failed: ${JSON.stringify(body.error)}`);
|
|
41
|
+
err.code = 'jito_error';
|
|
42
|
+
err.detail = body.error;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
bundleId: body.result,
|
|
47
|
+
explorer: `https://explorer.jito.wtf/bundle/${body.result}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Poll signature statuses until all are confirmed/failed or the timeout
|
|
52
|
+
// elapses. Used after submitting a Jito bundle so callers see a final
|
|
53
|
+
// state instead of just the bundle id.
|
|
54
|
+
export async function waitForSignatures(connection, signatures, { timeoutMs = 60_000, intervalMs = 2_000 } = {}) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
while (Date.now() < deadline) {
|
|
57
|
+
const sigs = await connection.getSignatureStatuses(signatures);
|
|
58
|
+
const all = sigs?.value || [];
|
|
59
|
+
const allConfirmed = all.length === signatures.length
|
|
60
|
+
&& all.every((s) => s?.confirmationStatus === 'confirmed' || s?.confirmationStatus === 'finalized');
|
|
61
|
+
if (allConfirmed) {
|
|
62
|
+
const anyErr = all.find((s) => s?.err);
|
|
63
|
+
return {
|
|
64
|
+
ok: !anyErr,
|
|
65
|
+
err: anyErr?.err || null,
|
|
66
|
+
statuses: all.map((s) => s?.confirmationStatus || null),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, err: 'timeout', statuses: null };
|
|
72
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Jupiter swap for any Solana SPL or pump.fun token.
|
|
2
|
+
//
|
|
3
|
+
// Two modes:
|
|
4
|
+
// - jupiterBuyDirect: the buyer wallet has SOL — single tx, no Jito.
|
|
5
|
+
// - jupiterBuyBundled: ported from nirholas/atomic's buy-jito.js. The
|
|
6
|
+
// funder transfers SOL to the buyer + Jito tip in Tx1, the buyer signs
|
|
7
|
+
// the Jupiter swap in Tx2, both submitted as a bundle. Use this when
|
|
8
|
+
// the buyer key is shared/leaked and you must beat sweeper bots.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
PublicKey,
|
|
12
|
+
SystemProgram,
|
|
13
|
+
TransactionMessage,
|
|
14
|
+
VersionedTransaction,
|
|
15
|
+
ComputeBudgetProgram,
|
|
16
|
+
LAMPORTS_PER_SOL,
|
|
17
|
+
} from '@solana/web3.js';
|
|
18
|
+
|
|
19
|
+
import { bs58encode, getConnection, keypairFromSecret } from './solana.js';
|
|
20
|
+
import { randomTipAccount, submitBundle, waitForSignatures } from './jito.js';
|
|
21
|
+
|
|
22
|
+
const SOL_MINT = 'So11111111111111111111111111111111111111112';
|
|
23
|
+
|
|
24
|
+
async function fetchJupiterQuote({ inputMint = SOL_MINT, outputMint, amount, slippageBps = 500 }) {
|
|
25
|
+
const url = `https://lite-api.jup.ag/swap/v1/quote?inputMint=${inputMint}&outputMint=${outputMint}&amount=${amount}&slippageBps=${slippageBps}`;
|
|
26
|
+
const r = await fetch(url);
|
|
27
|
+
if (!r.ok) {
|
|
28
|
+
const txt = await r.text().catch(() => '');
|
|
29
|
+
throw new Error(`Jupiter quote failed (${r.status}): ${txt.slice(0, 300)}`);
|
|
30
|
+
}
|
|
31
|
+
return r.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchJupiterSwapTx({ quoteResponse, userPublicKey, priorityMicroLamports = 2_000_000 }) {
|
|
35
|
+
const r = await fetch('https://lite-api.jup.ag/swap/v1/swap', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
quoteResponse,
|
|
40
|
+
userPublicKey,
|
|
41
|
+
wrapAndUnwrapSol: true,
|
|
42
|
+
computeUnitPriceMicroLamports: priorityMicroLamports,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
if (!r.ok) {
|
|
46
|
+
const txt = await r.text().catch(() => '');
|
|
47
|
+
throw new Error(`Jupiter swap build failed (${r.status}): ${txt.slice(0, 300)}`);
|
|
48
|
+
}
|
|
49
|
+
return r.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function jupiterBuyDirect({ buyerSecret, targetMint, buySol = 0.01, slippageBps = 500, priorityMicroLamports = 2_000_000 }) {
|
|
53
|
+
const buyer = keypairFromSecret(buyerSecret);
|
|
54
|
+
const conn = getConnection();
|
|
55
|
+
|
|
56
|
+
const buyLamports = Math.floor(buySol * LAMPORTS_PER_SOL);
|
|
57
|
+
const quote = await fetchJupiterQuote({ outputMint: targetMint, amount: buyLamports, slippageBps });
|
|
58
|
+
const { swapTransaction } = await fetchJupiterSwapTx({
|
|
59
|
+
quoteResponse: quote,
|
|
60
|
+
userPublicKey: buyer.publicKey.toBase58(),
|
|
61
|
+
priorityMicroLamports,
|
|
62
|
+
});
|
|
63
|
+
const swapTx = VersionedTransaction.deserialize(Buffer.from(swapTransaction, 'base64'));
|
|
64
|
+
swapTx.sign([buyer]);
|
|
65
|
+
|
|
66
|
+
const sig = await conn.sendTransaction(swapTx, { maxRetries: 5 });
|
|
67
|
+
const wait = await waitForSignatures(conn, [sig], { timeoutMs: 60_000, intervalMs: 2_000 });
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: wait.ok,
|
|
71
|
+
signature: sig,
|
|
72
|
+
buyer: buyer.publicKey.toBase58(),
|
|
73
|
+
target: targetMint,
|
|
74
|
+
soldSol: buySol,
|
|
75
|
+
expectedOutAmount: quote.outAmount,
|
|
76
|
+
priceImpactPct: quote.priceImpactPct,
|
|
77
|
+
route: Array.isArray(quote.routePlan) ? quote.routePlan.map((r) => r.swapInfo?.label).filter(Boolean) : [],
|
|
78
|
+
statuses: wait.statuses,
|
|
79
|
+
err: wait.err,
|
|
80
|
+
explorer: `https://solscan.io/tx/${sig}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function jupiterBuyBundled({
|
|
85
|
+
funderSecret,
|
|
86
|
+
buyerSecret,
|
|
87
|
+
targetMint,
|
|
88
|
+
buySol = 0.01,
|
|
89
|
+
slippageBps = 500,
|
|
90
|
+
jitoTipSol = 0.005,
|
|
91
|
+
priorityMicroLamports = 2_000_000,
|
|
92
|
+
}) {
|
|
93
|
+
const funder = keypairFromSecret(funderSecret);
|
|
94
|
+
const buyer = keypairFromSecret(buyerSecret);
|
|
95
|
+
const conn = getConnection();
|
|
96
|
+
|
|
97
|
+
const funderBal = await conn.getBalance(funder.publicKey, 'confirmed');
|
|
98
|
+
const needed = (buySol + 0.005 + jitoTipSol + 0.002) * LAMPORTS_PER_SOL;
|
|
99
|
+
if (funderBal < needed) {
|
|
100
|
+
const err = new Error(
|
|
101
|
+
`Funder needs >= ${(needed / LAMPORTS_PER_SOL).toFixed(4)} SOL; has ${(funderBal / LAMPORTS_PER_SOL).toFixed(4)} SOL.`,
|
|
102
|
+
);
|
|
103
|
+
err.code = 'insufficient_funds';
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const buyLamports = Math.floor(buySol * LAMPORTS_PER_SOL);
|
|
108
|
+
const quote = await fetchJupiterQuote({ outputMint: targetMint, amount: buyLamports, slippageBps });
|
|
109
|
+
const { swapTransaction } = await fetchJupiterSwapTx({
|
|
110
|
+
quoteResponse: quote,
|
|
111
|
+
userPublicKey: buyer.publicKey.toBase58(),
|
|
112
|
+
priorityMicroLamports,
|
|
113
|
+
});
|
|
114
|
+
const swapTx = VersionedTransaction.deserialize(Buffer.from(swapTransaction, 'base64'));
|
|
115
|
+
swapTx.sign([buyer]);
|
|
116
|
+
|
|
117
|
+
const transferToBuyer = Math.floor((buySol + 0.005) * LAMPORTS_PER_SOL);
|
|
118
|
+
const tipAccount = randomTipAccount();
|
|
119
|
+
const blockhash = swapTx.message.recentBlockhash;
|
|
120
|
+
|
|
121
|
+
const fundMsg = new TransactionMessage({
|
|
122
|
+
payerKey: funder.publicKey,
|
|
123
|
+
recentBlockhash: blockhash,
|
|
124
|
+
instructions: [
|
|
125
|
+
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityMicroLamports }),
|
|
126
|
+
ComputeBudgetProgram.setComputeUnitLimit({ units: 1000 }),
|
|
127
|
+
SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: buyer.publicKey, lamports: transferToBuyer }),
|
|
128
|
+
SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: tipAccount, lamports: Math.floor(jitoTipSol * LAMPORTS_PER_SOL) }),
|
|
129
|
+
],
|
|
130
|
+
}).compileToV0Message();
|
|
131
|
+
const fundTx = new VersionedTransaction(fundMsg);
|
|
132
|
+
fundTx.sign([funder]);
|
|
133
|
+
|
|
134
|
+
const bundle = [bs58encode(fundTx.serialize()), bs58encode(swapTx.serialize())];
|
|
135
|
+
const { bundleId, explorer } = await submitBundle(bundle);
|
|
136
|
+
const sig1 = bs58encode(fundTx.signatures[0]);
|
|
137
|
+
const sig2 = bs58encode(swapTx.signatures[0]);
|
|
138
|
+
const wait = await waitForSignatures(conn, [sig1, sig2], { timeoutMs: 60_000, intervalMs: 2_000 });
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ok: wait.ok,
|
|
142
|
+
bundleId,
|
|
143
|
+
bundleExplorer: explorer,
|
|
144
|
+
fundTxSignature: sig1,
|
|
145
|
+
swapTxSignature: sig2,
|
|
146
|
+
funder: funder.publicKey.toBase58(),
|
|
147
|
+
buyer: buyer.publicKey.toBase58(),
|
|
148
|
+
target: targetMint,
|
|
149
|
+
soldSol: buySol,
|
|
150
|
+
expectedOutAmount: quote.outAmount,
|
|
151
|
+
priceImpactPct: quote.priceImpactPct,
|
|
152
|
+
route: Array.isArray(quote.routePlan) ? quote.routePlan.map((r) => r.swapInfo?.label).filter(Boolean) : [],
|
|
153
|
+
statuses: wait.statuses,
|
|
154
|
+
err: wait.err,
|
|
155
|
+
fundTxExplorer: `https://solscan.io/tx/${sig1}`,
|
|
156
|
+
swapTxExplorer: `https://solscan.io/tx/${sig2}`,
|
|
157
|
+
};
|
|
158
|
+
}
|