@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,175 @@
|
|
|
1
|
+
// pump.fun + on-chain snapshot data. Pulls live numbers from public APIs
|
|
2
|
+
// and the Solana RPC. No fallbacks, no mocked numbers — if a source is
|
|
3
|
+
// unreachable the field is null so callers see the gap clearly.
|
|
4
|
+
//
|
|
5
|
+
// Sources:
|
|
6
|
+
// - Jupiter Lite price API (lite-api.jup.ag/price/v3)
|
|
7
|
+
// - Dexscreener tokens API (api.dexscreener.com/latest/dex/tokens/<mint>)
|
|
8
|
+
// - pump.fun frontend-api-v3 (frontend-api-v3.pump.fun/coins/<mint>)
|
|
9
|
+
// - Solana getTokenLargestAccounts via the configured RPC
|
|
10
|
+
// - Optional Helius DAS getAsset (HELIUS_API_KEY)
|
|
11
|
+
|
|
12
|
+
import { Connection, PublicKey } from '@solana/web3.js';
|
|
13
|
+
|
|
14
|
+
import { HELIUS_API_KEY, SOLANA_RPC_URL } from '../config.js';
|
|
15
|
+
|
|
16
|
+
async function fetchJson(url, init = {}, timeoutMs = 8000) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
21
|
+
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
|
|
22
|
+
return await res.json();
|
|
23
|
+
} finally {
|
|
24
|
+
clearTimeout(t);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getJupiterPrice(mint) {
|
|
29
|
+
try {
|
|
30
|
+
const data = await fetchJson(`https://lite-api.jup.ag/price/v3?ids=${mint}`);
|
|
31
|
+
const entry = data?.[mint];
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
return {
|
|
34
|
+
usdPrice: entry.usdPrice ?? null,
|
|
35
|
+
priceChange24hPct: entry.priceChange24h ?? null,
|
|
36
|
+
liquidityUsd: entry.liquidity ?? null,
|
|
37
|
+
decimals: entry.decimals ?? null,
|
|
38
|
+
blockId: entry.blockId ?? null,
|
|
39
|
+
};
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { error: err.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getDexscreener(mint) {
|
|
46
|
+
try {
|
|
47
|
+
const data = await fetchJson(`https://api.dexscreener.com/latest/dex/tokens/${mint}`);
|
|
48
|
+
const pairs = Array.isArray(data?.pairs) ? data.pairs : [];
|
|
49
|
+
if (pairs.length === 0) return null;
|
|
50
|
+
const pair = pairs.reduce((best, p) => {
|
|
51
|
+
const v = Number(p?.volume?.h24 || 0);
|
|
52
|
+
return v > (best?.vol || 0) ? { pair: p, vol: v } : best;
|
|
53
|
+
}, null)?.pair;
|
|
54
|
+
if (!pair) return null;
|
|
55
|
+
return {
|
|
56
|
+
volume24hUsd: Number(pair.volume?.h24 || 0),
|
|
57
|
+
priceUsd: pair.priceUsd ? Number(pair.priceUsd) : null,
|
|
58
|
+
priceChange24hPct: pair.priceChange?.h24 ?? null,
|
|
59
|
+
liquidityUsd: pair.liquidity?.usd ?? null,
|
|
60
|
+
fdvUsd: pair.fdv ?? null,
|
|
61
|
+
marketCapUsd: pair.marketCap ?? null,
|
|
62
|
+
pairAddress: pair.pairAddress,
|
|
63
|
+
dex: pair.dexId,
|
|
64
|
+
chain: pair.chainId,
|
|
65
|
+
url: pair.url,
|
|
66
|
+
txns24h: pair.txns?.h24 ?? null,
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { error: err.message };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getPumpFunMeta(mint) {
|
|
74
|
+
try {
|
|
75
|
+
const data = await fetchJson(`https://frontend-api-v3.pump.fun/coins/${mint}`);
|
|
76
|
+
if (!data || data.error) return null;
|
|
77
|
+
return {
|
|
78
|
+
name: data.name || null,
|
|
79
|
+
symbol: data.symbol || null,
|
|
80
|
+
description: data.description || null,
|
|
81
|
+
imageUrl: data.image_uri || null,
|
|
82
|
+
twitter: data.twitter || null,
|
|
83
|
+
telegram: data.telegram || null,
|
|
84
|
+
website: data.website || null,
|
|
85
|
+
creator: data.creator || null,
|
|
86
|
+
createdAtMs: data.created_timestamp || null,
|
|
87
|
+
complete: !!data.complete,
|
|
88
|
+
marketCapUsd: data.usd_market_cap ?? null,
|
|
89
|
+
marketCapQuote: data.market_cap ?? null,
|
|
90
|
+
totalSupply: data.total_supply_str || data.total_supply || null,
|
|
91
|
+
poolAddress: data.pool_address || null,
|
|
92
|
+
lastTradeTimestampMs: data.last_trade_timestamp || null,
|
|
93
|
+
athMarketCapUsd: data.ath_market_cap ?? null,
|
|
94
|
+
athMarketCapTimestampMs: data.ath_market_cap_timestamp || null,
|
|
95
|
+
program: data.program || null,
|
|
96
|
+
};
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { error: err.message };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getTopHolders(mint) {
|
|
103
|
+
try {
|
|
104
|
+
const conn = new Connection(SOLANA_RPC_URL, 'confirmed');
|
|
105
|
+
const res = await conn.getTokenLargestAccounts(new PublicKey(mint));
|
|
106
|
+
const top = (res?.value || []).map((acct) => ({
|
|
107
|
+
address: acct.address.toBase58(),
|
|
108
|
+
uiAmount: acct.uiAmount,
|
|
109
|
+
amount: acct.amount,
|
|
110
|
+
decimals: acct.decimals,
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
topHolderCount: top.length,
|
|
114
|
+
topHolders: top,
|
|
115
|
+
};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { error: err.message };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function getHeliusInfo(mint) {
|
|
122
|
+
if (!HELIUS_API_KEY) return null;
|
|
123
|
+
try {
|
|
124
|
+
const url = `https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}`;
|
|
125
|
+
const body = {
|
|
126
|
+
jsonrpc: '2.0',
|
|
127
|
+
id: 'getAsset',
|
|
128
|
+
method: 'getAsset',
|
|
129
|
+
params: { id: mint, options: { showFungible: true } },
|
|
130
|
+
};
|
|
131
|
+
const data = await fetchJson(url, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'content-type': 'application/json' },
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
});
|
|
136
|
+
const supply = data?.result?.token_info?.supply ?? null;
|
|
137
|
+
const decimals = data?.result?.token_info?.decimals ?? null;
|
|
138
|
+
const priceInfo = data?.result?.token_info?.price_info ?? null;
|
|
139
|
+
return {
|
|
140
|
+
supply: supply !== null ? String(supply) : null,
|
|
141
|
+
decimals,
|
|
142
|
+
heliusPriceUsd: priceInfo?.price_per_token ?? null,
|
|
143
|
+
};
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { error: err.message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function snapshot(mint) {
|
|
150
|
+
const [price, ds, meta, holders, helius] = await Promise.all([
|
|
151
|
+
getJupiterPrice(mint),
|
|
152
|
+
getDexscreener(mint),
|
|
153
|
+
getPumpFunMeta(mint),
|
|
154
|
+
getTopHolders(mint),
|
|
155
|
+
getHeliusInfo(mint),
|
|
156
|
+
]);
|
|
157
|
+
return {
|
|
158
|
+
token: mint,
|
|
159
|
+
fetchedAt: new Date().toISOString(),
|
|
160
|
+
price,
|
|
161
|
+
volume24h: ds,
|
|
162
|
+
meta,
|
|
163
|
+
holders,
|
|
164
|
+
helius,
|
|
165
|
+
image: meta?.imageUrl || null,
|
|
166
|
+
pumpUrl: `https://pump.fun/coin/${mint}`,
|
|
167
|
+
sources: {
|
|
168
|
+
price: 'https://lite-api.jup.ag/price/v3',
|
|
169
|
+
volume24h: 'https://api.dexscreener.com',
|
|
170
|
+
meta: 'https://frontend-api-v3.pump.fun',
|
|
171
|
+
holders: SOLANA_RPC_URL,
|
|
172
|
+
helius: HELIUS_API_KEY ? 'https://mainnet.helius-rpc.com' : null,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Client helpers for the three.ws server-side render endpoints. The
|
|
2
|
+
// MCP doesn't ship a renderer itself — chromium is too heavy for an
|
|
3
|
+
// npm-installed local server. Instead we POST to three.ws's hosted
|
|
4
|
+
// headless-chromium pipeline (the same renderer that powers OG cards),
|
|
5
|
+
// stream the PNG back, and return it as a base64 data URL plus the
|
|
6
|
+
// upstream URL so callers can choose whichever they want.
|
|
7
|
+
|
|
8
|
+
import { THREE_WS_BASE } from '../config.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_INLINE = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
async function postPng(path, body, { maxInlineBytes = DEFAULT_MAX_INLINE } = {}) {
|
|
13
|
+
const url = `${THREE_WS_BASE}${path}`;
|
|
14
|
+
const t0 = Date.now();
|
|
15
|
+
const r = await fetch(url, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'content-type': 'application/json' },
|
|
18
|
+
body: JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
const durationMs = Date.now() - t0;
|
|
21
|
+
if (!r.ok) {
|
|
22
|
+
// Try to surface the JSON error envelope three.ws returns.
|
|
23
|
+
let detail = null;
|
|
24
|
+
try {
|
|
25
|
+
detail = await r.json();
|
|
26
|
+
} catch {
|
|
27
|
+
detail = { error_description: await r.text().catch(() => '') };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
status: r.status,
|
|
32
|
+
error: detail?.error || 'render_failed',
|
|
33
|
+
message: detail?.error_description || `HTTP ${r.status}`,
|
|
34
|
+
endpoint: url,
|
|
35
|
+
durationMs,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
39
|
+
const meta = {
|
|
40
|
+
width: Number(r.headers.get('x-render-width')) || null,
|
|
41
|
+
height: Number(r.headers.get('x-render-height')) || null,
|
|
42
|
+
background: r.headers.get('x-render-background') || null,
|
|
43
|
+
pose: r.headers.get('x-render-pose') || null,
|
|
44
|
+
poseLabel: r.headers.get('x-render-pose-label') || null,
|
|
45
|
+
};
|
|
46
|
+
const inline = buf.length <= maxInlineBytes;
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
status: r.status,
|
|
50
|
+
endpoint: url,
|
|
51
|
+
durationMs,
|
|
52
|
+
sizeBytes: buf.length,
|
|
53
|
+
mime: 'image/png',
|
|
54
|
+
dataUrl: inline ? `data:image/png;base64,${buf.toString('base64')}` : null,
|
|
55
|
+
omittedInline: !inline,
|
|
56
|
+
meta,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function renderGlbThumbnail({ glbUrl, width, height, background, maxInlineBytes }) {
|
|
61
|
+
return postPng('/api/render/glb', { glbUrl, width, height, background }, { maxInlineBytes });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function renderAvatarClipRemote({ glbUrl, width, height, background, posePresetId, cameraOrbit, expression, maxInlineBytes }) {
|
|
65
|
+
return postPng(
|
|
66
|
+
'/api/render/avatar-clip',
|
|
67
|
+
{ glbUrl, width, height, background, posePresetId, cameraOrbit, expression },
|
|
68
|
+
{ maxInlineBytes },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchPoseCatalogRemote() {
|
|
73
|
+
const r = await fetch(`${THREE_WS_BASE}/api/render/avatar-clip`, { method: 'GET' });
|
|
74
|
+
if (!r.ok) throw new Error(`Pose catalog fetch failed: HTTP ${r.status}`);
|
|
75
|
+
return r.json();
|
|
76
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Solana primitives: keypair load, balances, transfers.
|
|
2
|
+
//
|
|
3
|
+
// Every signer-required tool accepts an optional base58 `secret` in the
|
|
4
|
+
// tool arguments. If not provided, it falls back to SOLANA_SECRET_KEY /
|
|
5
|
+
// FUNDER_SECRET from the environment. We never embed default keys.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Connection,
|
|
9
|
+
Keypair,
|
|
10
|
+
PublicKey,
|
|
11
|
+
SystemProgram,
|
|
12
|
+
TransactionMessage,
|
|
13
|
+
VersionedTransaction,
|
|
14
|
+
ComputeBudgetProgram,
|
|
15
|
+
LAMPORTS_PER_SOL,
|
|
16
|
+
} from '@solana/web3.js';
|
|
17
|
+
import bs58 from 'bs58';
|
|
18
|
+
|
|
19
|
+
import { SOLANA_RPC_URL, SOLANA_DEFAULT_SECRET } from '../config.js';
|
|
20
|
+
|
|
21
|
+
const bs58encode = bs58.default ? bs58.default.encode : bs58.encode;
|
|
22
|
+
const bs58decode = bs58.default ? bs58.default.decode : bs58.decode;
|
|
23
|
+
|
|
24
|
+
export { bs58encode, bs58decode, LAMPORTS_PER_SOL };
|
|
25
|
+
|
|
26
|
+
let _conn = null;
|
|
27
|
+
export function getConnection() {
|
|
28
|
+
if (!_conn) _conn = new Connection(SOLANA_RPC_URL, 'confirmed');
|
|
29
|
+
return _conn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isValidPubkey(s) {
|
|
33
|
+
try {
|
|
34
|
+
new PublicKey(s);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function keypairFromSecret(secret) {
|
|
42
|
+
const trimmed = String(secret || '').trim();
|
|
43
|
+
if (!trimmed) {
|
|
44
|
+
const err = new Error(
|
|
45
|
+
'Solana secret required. Pass `secret` (base58) in the tool call, ' +
|
|
46
|
+
'or set SOLANA_SECRET_KEY in the MCP server environment.',
|
|
47
|
+
);
|
|
48
|
+
err.code = 'no_signer';
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
const bytes = bs58decode(trimmed);
|
|
52
|
+
if (bytes.length !== 64) {
|
|
53
|
+
const err = new Error(`Solana secret must decode to 64 bytes (got ${bytes.length})`);
|
|
54
|
+
err.code = 'invalid_secret';
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
return Keypair.fromSecretKey(bytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadSigner(secret) {
|
|
61
|
+
return keypairFromSecret(secret || SOLANA_DEFAULT_SECRET);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getBalanceSol(pubkeyStr) {
|
|
65
|
+
const conn = getConnection();
|
|
66
|
+
const lamports = await conn.getBalance(new PublicKey(pubkeyStr), 'confirmed');
|
|
67
|
+
return { lamports, sol: lamports / LAMPORTS_PER_SOL };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getTokenBalances(pubkeyStr) {
|
|
71
|
+
const conn = getConnection();
|
|
72
|
+
const owner = new PublicKey(pubkeyStr);
|
|
73
|
+
// SPL Token program — fetch parsed accounts so we get mint + amount cleanly.
|
|
74
|
+
const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
|
|
75
|
+
const TOKEN_2022_PROGRAM = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
|
|
76
|
+
const [legacy, t22] = await Promise.all([
|
|
77
|
+
conn.getParsedTokenAccountsByOwner(owner, { programId: TOKEN_PROGRAM }),
|
|
78
|
+
conn.getParsedTokenAccountsByOwner(owner, { programId: TOKEN_2022_PROGRAM }),
|
|
79
|
+
]);
|
|
80
|
+
const all = [...legacy.value, ...t22.value];
|
|
81
|
+
return all
|
|
82
|
+
.map((r) => {
|
|
83
|
+
const info = r.account.data?.parsed?.info;
|
|
84
|
+
if (!info) return null;
|
|
85
|
+
const amount = info.tokenAmount;
|
|
86
|
+
return {
|
|
87
|
+
mint: info.mint,
|
|
88
|
+
owner: info.owner,
|
|
89
|
+
account: r.pubkey.toBase58(),
|
|
90
|
+
amount: amount?.amount,
|
|
91
|
+
uiAmount: amount?.uiAmount,
|
|
92
|
+
uiAmountString: amount?.uiAmountString,
|
|
93
|
+
decimals: amount?.decimals,
|
|
94
|
+
};
|
|
95
|
+
})
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.filter((t) => Number(t.uiAmount) > 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function sendSol({ secret, to, sol, priorityMicroLamports = 100000 }) {
|
|
101
|
+
if (!isValidPubkey(to)) {
|
|
102
|
+
const err = new Error(`Destination is not a valid Solana pubkey: ${to}`);
|
|
103
|
+
err.code = 'invalid_destination';
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const signer = loadSigner(secret);
|
|
107
|
+
const conn = getConnection();
|
|
108
|
+
const lamports = Math.floor(Number(sol) * LAMPORTS_PER_SOL);
|
|
109
|
+
if (!Number.isFinite(lamports) || lamports <= 0) {
|
|
110
|
+
const err = new Error(`sol must be a positive number (got ${sol})`);
|
|
111
|
+
err.code = 'invalid_amount';
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash('confirmed');
|
|
115
|
+
const msg = new TransactionMessage({
|
|
116
|
+
payerKey: signer.publicKey,
|
|
117
|
+
recentBlockhash: blockhash,
|
|
118
|
+
instructions: [
|
|
119
|
+
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityMicroLamports }),
|
|
120
|
+
ComputeBudgetProgram.setComputeUnitLimit({ units: 1000 }),
|
|
121
|
+
SystemProgram.transfer({
|
|
122
|
+
fromPubkey: signer.publicKey,
|
|
123
|
+
toPubkey: new PublicKey(to),
|
|
124
|
+
lamports,
|
|
125
|
+
}),
|
|
126
|
+
],
|
|
127
|
+
}).compileToV0Message();
|
|
128
|
+
const tx = new VersionedTransaction(msg);
|
|
129
|
+
tx.sign([signer]);
|
|
130
|
+
const sig = await conn.sendTransaction(tx, { maxRetries: 5 });
|
|
131
|
+
const conf = await conn.confirmTransaction(
|
|
132
|
+
{ signature: sig, blockhash, lastValidBlockHeight },
|
|
133
|
+
'confirmed',
|
|
134
|
+
);
|
|
135
|
+
if (conf?.value?.err) {
|
|
136
|
+
const err = new Error(`Transaction failed: ${JSON.stringify(conf.value.err)}`);
|
|
137
|
+
err.code = 'tx_failed';
|
|
138
|
+
err.signature = sig;
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
signature: sig,
|
|
143
|
+
from: signer.publicKey.toBase58(),
|
|
144
|
+
to,
|
|
145
|
+
sol: Number(sol),
|
|
146
|
+
lamports,
|
|
147
|
+
explorer: `https://solscan.io/tx/${sig}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Vanity grinder. Returns the first keypair whose base58 pubkey matches the
|
|
152
|
+
// given prefix and/or suffix (case-sensitive by default). The user can cap
|
|
153
|
+
// max attempts to keep the call bounded.
|
|
154
|
+
export function grindVanity({ prefix = '', suffix = '', caseSensitive = true, maxAttempts = 500_000 }) {
|
|
155
|
+
const pre = String(prefix || '');
|
|
156
|
+
const suf = String(suffix || '');
|
|
157
|
+
if (!pre && !suf) {
|
|
158
|
+
const err = new Error('Provide at least a prefix or suffix to grind for.');
|
|
159
|
+
err.code = 'invalid_input';
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
const matches = (b58) => {
|
|
163
|
+
const candidate = caseSensitive ? b58 : b58.toLowerCase();
|
|
164
|
+
const p = caseSensitive ? pre : pre.toLowerCase();
|
|
165
|
+
const s = caseSensitive ? suf : suf.toLowerCase();
|
|
166
|
+
if (p && !candidate.startsWith(p)) return false;
|
|
167
|
+
if (s && !candidate.endsWith(s)) return false;
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
const startedAt = Date.now();
|
|
171
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
172
|
+
const kp = Keypair.generate();
|
|
173
|
+
const b58 = kp.publicKey.toBase58();
|
|
174
|
+
if (matches(b58)) {
|
|
175
|
+
return {
|
|
176
|
+
found: true,
|
|
177
|
+
attempts: i + 1,
|
|
178
|
+
durationMs: Date.now() - startedAt,
|
|
179
|
+
pubkey: b58,
|
|
180
|
+
secret: bs58encode(kp.secretKey),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
found: false,
|
|
186
|
+
attempts: maxAttempts,
|
|
187
|
+
durationMs: Date.now() - startedAt,
|
|
188
|
+
message: `No match for prefix="${pre}" suffix="${suf}" in ${maxAttempts} attempts. Try a shorter pattern or a higher maxAttempts.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// `dress_avatar` — apply or remove accessories on a spawned avatar session
|
|
2
|
+
// and optionally set the pose. Returns the updated session manifest and a
|
|
3
|
+
// refreshed viewer URL.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { ACCESSORIES, POSE_PRESETS, findAccessory, getSession, updateSession, viewerUrlFor } from '../lib/avatars.js';
|
|
8
|
+
|
|
9
|
+
export const def = {
|
|
10
|
+
name: 'dress_avatar',
|
|
11
|
+
title: 'Apply accessories + pose to a spawned avatar',
|
|
12
|
+
description:
|
|
13
|
+
'Apply (or replace) accessories on a spawned avatar session and optionally set a pose. Pass accessoryIds to set the full accessory list (empty array clears them). Pass pose to switch animations. Returns the updated viewer URL.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
sessionId: z.string().describe('Session id returned by spawn_avatar.'),
|
|
16
|
+
accessoryIds: z.array(z.string()).optional()
|
|
17
|
+
.describe(`Full list of accessory ids to wear. Allowed: ${ACCESSORIES.map((a) => a.id).join(', ')}. Empty array clears.`),
|
|
18
|
+
pose: z.string().optional()
|
|
19
|
+
.describe(`Pose preset. Allowed: ${POSE_PRESETS.join(', ')}.`),
|
|
20
|
+
},
|
|
21
|
+
async handler(args) {
|
|
22
|
+
const { sessionId, accessoryIds, pose } = args || {};
|
|
23
|
+
const session = getSession(sessionId);
|
|
24
|
+
if (!session) {
|
|
25
|
+
return { ok: false, error: 'unknown_session', message: `No session ${sessionId}. Call spawn_avatar first.` };
|
|
26
|
+
}
|
|
27
|
+
const patch = {};
|
|
28
|
+
if (Array.isArray(accessoryIds)) {
|
|
29
|
+
const resolved = [];
|
|
30
|
+
const missing = [];
|
|
31
|
+
for (const id of accessoryIds) {
|
|
32
|
+
const acc = findAccessory(id);
|
|
33
|
+
if (!acc) {
|
|
34
|
+
missing.push(id);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
resolved.push(acc);
|
|
38
|
+
}
|
|
39
|
+
if (missing.length) {
|
|
40
|
+
return { ok: false, error: 'unknown_accessory', missing, allowed: ACCESSORIES.map((a) => a.id) };
|
|
41
|
+
}
|
|
42
|
+
patch.accessories = resolved;
|
|
43
|
+
}
|
|
44
|
+
if (pose) {
|
|
45
|
+
if (!POSE_PRESETS.includes(pose)) {
|
|
46
|
+
return { ok: false, error: 'unknown_pose', pose, allowed: POSE_PRESETS };
|
|
47
|
+
}
|
|
48
|
+
patch.pose = pose;
|
|
49
|
+
}
|
|
50
|
+
const updated = updateSession(sessionId, patch);
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
sessionId: updated.id,
|
|
54
|
+
avatar: updated.avatar,
|
|
55
|
+
accessories: updated.accessories,
|
|
56
|
+
pose: updated.pose,
|
|
57
|
+
viewerUrl: viewerUrlFor(updated),
|
|
58
|
+
lastUpdated: updated.lastUpdated,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// `ens_sns_resolve` — resolve human-readable names to on-chain addresses
|
|
2
|
+
// across ENS (Ethereum) and SNS (Solana, Bonfida). Useful for naming
|
|
3
|
+
// avatar wallets ("alice.sol") or sending to ENS addresses without
|
|
4
|
+
// pasting raw hex.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import { resolveName } from '../lib/ens-sns.js';
|
|
9
|
+
|
|
10
|
+
export const def = {
|
|
11
|
+
name: 'ens_sns_resolve',
|
|
12
|
+
title: 'Resolve ENS + SNS names to addresses',
|
|
13
|
+
description:
|
|
14
|
+
'Resolve a human-readable name to addresses across ENS (Ethereum) and SNS (Solana, Bonfida). For .eth: returns Ethereum address + reverse lookup. For .sol: returns Solana owner wallet + the wallet\'s other owned .sol domains + favorite domain. Names without a suffix are tried against both registries.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
name: z.string().min(1).max(253).describe('Name to resolve, e.g. "vitalik.eth", "bonfida.sol", or bare "vitalik" (tried in both registries).'),
|
|
17
|
+
},
|
|
18
|
+
async handler(args) {
|
|
19
|
+
return await resolveName(args.name);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `generate_avatar` — generate a textured GLB from a text prompt or
|
|
2
|
+
// reference image(s) via Replicate. Requires REPLICATE_API_TOKEN and a
|
|
3
|
+
// pinned model version in REPLICATE_TEXT_TO_AVATAR_MODEL (recommended:
|
|
4
|
+
// the latest tencent/hunyuan-3d-3.1 commercial-OK version).
|
|
5
|
+
//
|
|
6
|
+
// On success, the result is a new avatar session preloaded with the
|
|
7
|
+
// generated GLB URL.
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { REPLICATE_API_TOKEN, REPLICATE_TEXT_TO_AVATAR_MODEL } from '../config.js';
|
|
12
|
+
import { createSession, viewerUrlFor } from '../lib/avatars.js';
|
|
13
|
+
|
|
14
|
+
const REPLICATE_BASE = 'https://api.replicate.com/v1';
|
|
15
|
+
|
|
16
|
+
function authHeaders() {
|
|
17
|
+
return { authorization: `Bearer ${REPLICATE_API_TOKEN}`, 'content-type': 'application/json' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractGlbUrl(output) {
|
|
21
|
+
if (!output) return null;
|
|
22
|
+
if (typeof output === 'string') return output;
|
|
23
|
+
if (Array.isArray(output)) {
|
|
24
|
+
for (const v of output) if (typeof v === 'string' && /\.glb(\?|$)/i.test(v)) return v;
|
|
25
|
+
for (const v of output) if (typeof v === 'string' && /^https?:\/\//.test(v)) return v;
|
|
26
|
+
}
|
|
27
|
+
if (typeof output === 'object') {
|
|
28
|
+
for (const key of ['glb', 'mesh', 'mesh_url', 'output_url', 'url', 'model']) {
|
|
29
|
+
if (typeof output[key] === 'string') return output[key];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function submitPrediction({ version, input }) {
|
|
36
|
+
const res = await fetch(`${REPLICATE_BASE}/predictions`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: authHeaders(),
|
|
39
|
+
body: JSON.stringify({ version, input }),
|
|
40
|
+
});
|
|
41
|
+
const data = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const err = new Error(data?.detail || data?.title || `replicate returned ${res.status}`);
|
|
44
|
+
err.code = 'provider_error';
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pollPrediction(predictionId, { timeoutMs, intervalMs }) {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
let last = null;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
const r = await fetch(`${REPLICATE_BASE}/predictions/${encodeURIComponent(predictionId)}`, {
|
|
55
|
+
headers: authHeaders(),
|
|
56
|
+
});
|
|
57
|
+
const data = await r.json().catch(() => ({}));
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
const err = new Error(data?.detail || `replicate poll returned ${r.status}`);
|
|
60
|
+
err.code = 'provider_error';
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
last = data;
|
|
64
|
+
const s = data.status;
|
|
65
|
+
if (s === 'succeeded' || s === 'failed' || s === 'canceled') return data;
|
|
66
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
67
|
+
}
|
|
68
|
+
return { ...last, _timedOut: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const def = {
|
|
72
|
+
name: 'generate_avatar',
|
|
73
|
+
title: 'Generate a 3D avatar (Replicate text-to-3D)',
|
|
74
|
+
description:
|
|
75
|
+
'Generate a textured GLB from a text prompt or reference image URLs via Replicate (Hunyuan-3D 3.1 by default; configurable). Returns the GLB URL and a new avatar session you can dress + animate. Requires REPLICATE_API_TOKEN and REPLICATE_TEXT_TO_AVATAR_MODEL on the MCP server.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
prompt: z.string().max(1000).optional().describe('Text description of the avatar to generate.'),
|
|
78
|
+
images: z.array(z.string().url()).max(4).optional().describe('Reference image URLs for image-to-3D.'),
|
|
79
|
+
seed: z.number().int().min(0).max(2147483647).optional(),
|
|
80
|
+
texture: z.boolean().optional().describe('Request PBR textures when supported (default true).'),
|
|
81
|
+
name: z.string().max(80).optional().describe('Name for the resulting avatar session.'),
|
|
82
|
+
voice: z
|
|
83
|
+
.enum(['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse'])
|
|
84
|
+
.optional()
|
|
85
|
+
.describe('TTS voice the session should use.'),
|
|
86
|
+
},
|
|
87
|
+
async handler(args) {
|
|
88
|
+
if (!REPLICATE_API_TOKEN) {
|
|
89
|
+
return { ok: false, error: 'not_configured', message: 'REPLICATE_API_TOKEN is not set on the MCP server.' };
|
|
90
|
+
}
|
|
91
|
+
if (!REPLICATE_TEXT_TO_AVATAR_MODEL) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: 'not_configured',
|
|
95
|
+
message: 'REPLICATE_TEXT_TO_AVATAR_MODEL is not set. Pin a commercial-OK image/text-to-3D version (e.g. latest tencent/hunyuan-3d-3.1).',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const { prompt, images, seed, texture, name, voice } = args || {};
|
|
99
|
+
if (!prompt && (!images || images.length === 0)) {
|
|
100
|
+
return { ok: false, error: 'invalid_input', message: 'Provide either prompt or images[].' };
|
|
101
|
+
}
|
|
102
|
+
const input = {
|
|
103
|
+
prompt: prompt || undefined,
|
|
104
|
+
image: images && images.length ? images[0] : undefined,
|
|
105
|
+
images: images && images.length ? images : undefined,
|
|
106
|
+
seed: typeof seed === 'number' ? seed : undefined,
|
|
107
|
+
texture: typeof texture === 'boolean' ? texture : true,
|
|
108
|
+
};
|
|
109
|
+
Object.keys(input).forEach((k) => input[k] === undefined && delete input[k]);
|
|
110
|
+
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
let submitted;
|
|
113
|
+
try {
|
|
114
|
+
submitted = await submitPrediction({ version: REPLICATE_TEXT_TO_AVATAR_MODEL, input });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { ok: false, error: err.code || 'provider_error', message: err.message };
|
|
117
|
+
}
|
|
118
|
+
const timeoutMs = Number(process.env.REPLICATE_TIMEOUT_MS || '120000');
|
|
119
|
+
const intervalMs = Number(process.env.REPLICATE_POLL_MS || '2000');
|
|
120
|
+
const finalState = await pollPrediction(submitted.id, { timeoutMs, intervalMs });
|
|
121
|
+
const durationMs = Date.now() - started;
|
|
122
|
+
if (finalState._timedOut) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: 'timeout',
|
|
126
|
+
message: `prediction did not finish within ${timeoutMs}ms`,
|
|
127
|
+
predictionId: submitted.id,
|
|
128
|
+
durationMs,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (finalState.status === 'failed' || finalState.status === 'canceled') {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: 'prediction_failed',
|
|
135
|
+
message: finalState.error || `prediction ended with status ${finalState.status}`,
|
|
136
|
+
predictionId: submitted.id,
|
|
137
|
+
durationMs,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const glbUrl = extractGlbUrl(finalState.output);
|
|
141
|
+
if (!glbUrl) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: 'no_glb_in_output',
|
|
145
|
+
message: 'prediction succeeded but no GLB url was found in output',
|
|
146
|
+
rawOutput: finalState.output,
|
|
147
|
+
predictionId: submitted.id,
|
|
148
|
+
durationMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const session = createSession({ glb: glbUrl, name, voice });
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
sessionId: session.id,
|
|
155
|
+
avatar: session.avatar,
|
|
156
|
+
predictionId: submitted.id,
|
|
157
|
+
model: REPLICATE_TEXT_TO_AVATAR_MODEL,
|
|
158
|
+
prompt: prompt || null,
|
|
159
|
+
images: images || null,
|
|
160
|
+
seed: typeof seed === 'number' ? seed : null,
|
|
161
|
+
durationMs,
|
|
162
|
+
viewerUrl: viewerUrlFor(session),
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|