helius-mcp 1.2.0 → 2.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +42 -30
  3. package/dist/http.d.ts +1 -1
  4. package/dist/index.js +2 -56
  5. package/dist/results/store.d.ts +8 -0
  6. package/dist/results/store.js +72 -0
  7. package/dist/results/types.d.ts +47 -0
  8. package/dist/results/types.js +1 -0
  9. package/dist/router/action-groups.d.ts +6 -0
  10. package/dist/router/action-groups.js +32 -0
  11. package/dist/router/action-handlers.d.ts +20 -0
  12. package/dist/router/action-handlers.js +125 -0
  13. package/dist/router/actions.d.ts +12 -0
  14. package/dist/router/actions.js +123 -0
  15. package/dist/router/catalog.d.ts +6 -0
  16. package/dist/router/catalog.js +388 -0
  17. package/dist/router/context.d.ts +5 -0
  18. package/dist/router/context.js +10 -0
  19. package/dist/router/dispatch.d.ts +4 -0
  20. package/dist/router/dispatch.js +276 -0
  21. package/dist/router/instructions.d.ts +1 -0
  22. package/dist/router/instructions.js +25 -0
  23. package/dist/router/register.d.ts +2 -0
  24. package/dist/router/register.js +15 -0
  25. package/dist/router/required-params.d.ts +9 -0
  26. package/dist/router/required-params.js +66 -0
  27. package/dist/router/responses.d.ts +29 -0
  28. package/dist/router/responses.js +186 -0
  29. package/dist/router/schemas.d.ts +216 -0
  30. package/dist/router/schemas.js +195 -0
  31. package/dist/router/telemetry.d.ts +27 -0
  32. package/dist/router/telemetry.js +52 -0
  33. package/dist/router/types.d.ts +46 -0
  34. package/dist/router/types.js +1 -0
  35. package/dist/scripts/validate-catalog.d.ts +2 -2
  36. package/dist/scripts/validate-catalog.js +10 -10
  37. package/dist/tools/accounts.js +5 -5
  38. package/dist/tools/assets.js +5 -5
  39. package/dist/tools/auth.js +392 -288
  40. package/dist/tools/config.js +3 -3
  41. package/dist/tools/das-extras.js +6 -6
  42. package/dist/tools/docs.js +55 -41
  43. package/dist/tools/enhanced-websockets.js +13 -13
  44. package/dist/tools/fees.js +3 -3
  45. package/dist/tools/index.d.ts +1 -1
  46. package/dist/tools/index.js +2 -80
  47. package/dist/tools/laserstream.js +20 -23
  48. package/dist/tools/network.js +41 -2
  49. package/dist/tools/plans.d.ts +0 -5
  50. package/dist/tools/plans.js +167 -12
  51. package/dist/tools/product-catalog.d.ts +1 -0
  52. package/dist/tools/product-catalog.js +51 -16
  53. package/dist/tools/recommend.d.ts +0 -1
  54. package/dist/tools/recommend.js +9 -28
  55. package/dist/tools/shared.d.ts +1 -0
  56. package/dist/tools/shared.js +10 -2
  57. package/dist/tools/solana-knowledge.js +23 -7
  58. package/dist/tools/staking.d.ts +2 -0
  59. package/dist/tools/staking.js +268 -0
  60. package/dist/tools/transactions.js +167 -3
  61. package/dist/tools/transfers.js +38 -43
  62. package/dist/tools/wallet.js +27 -16
  63. package/dist/tools/webhooks.js +3 -3
  64. package/dist/tools/zk-compression.d.ts +2 -0
  65. package/dist/tools/zk-compression.js +781 -0
  66. package/dist/utils/config.d.ts +2 -2
  67. package/dist/utils/config.js +68 -6
  68. package/dist/utils/errors.d.ts +10 -1
  69. package/dist/utils/errors.js +46 -12
  70. package/dist/utils/feedback.js +1 -4
  71. package/dist/utils/helius.js +2 -1
  72. package/dist/utils/ows.d.ts +74 -0
  73. package/dist/utils/ows.js +155 -0
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/package.json +2 -2
  77. package/system-prompts/helius/claude.system.md +56 -25
  78. package/system-prompts/helius/full.md +474 -130
  79. package/system-prompts/helius/openai.developer.md +56 -25
  80. package/system-prompts/helius-dflow/claude.system.md +41 -6
  81. package/system-prompts/helius-dflow/full.md +581 -92
  82. package/system-prompts/helius-dflow/openai.developer.md +41 -6
  83. package/system-prompts/helius-jupiter/claude.system.md +333 -0
  84. package/system-prompts/helius-jupiter/full.md +5109 -0
  85. package/system-prompts/helius-jupiter/openai.developer.md +333 -0
  86. package/system-prompts/helius-okx/claude.system.md +182 -0
  87. package/system-prompts/helius-okx/full.md +584 -0
  88. package/system-prompts/helius-okx/openai.developer.md +182 -0
  89. package/system-prompts/helius-phantom/claude.system.md +15 -2
  90. package/system-prompts/helius-phantom/full.md +254 -101
  91. package/system-prompts/helius-phantom/openai.developer.md +15 -2
  92. package/system-prompts/svm/claude.system.md +1 -0
  93. package/system-prompts/svm/full.md +1 -0
  94. package/system-prompts/svm/openai.developer.md +1 -0
@@ -0,0 +1,268 @@
1
+ import { z } from 'zod';
2
+ import { address } from '@solana/kit';
3
+ import { getHeliusClient, hasApiKey, getSessionWalletAddress } from '../utils/helius.js';
4
+ import { mcpText, mcpError, handleToolError, isValidAddressFormat, missingParamError } from '../utils/errors.js';
5
+ import { noApiKeyResponse } from './shared.js';
6
+ import { resolveOwsOrKeypairSigner } from '../utils/ows.js';
7
+ // ── Tool Registration ──
8
+ export function registerStakingTools(server) {
9
+ // ── Stake SOL ──
10
+ server.tool('stakeSOL', 'BEST FOR: staking native SOL to the Helius validator to earn yield. ' +
11
+ 'PREFER getStakeAccounts to view existing stake accounts, getWithdrawableAmount to check withdrawal eligibility. ' +
12
+ 'Creates a new stake account, delegates to the Helius validator, and sends the transaction on-chain. ' +
13
+ 'Requires a configured keypair (call generateKeypair if needed). ' +
14
+ 'This is an irreversible on-chain transaction. ' +
15
+ 'The Solana runtime requires a rent-exempt reserve (~0.00228 SOL) on top of the staked amount. ' +
16
+ 'Credit cost: ~3 credits (CU simulation + priority fee estimate + send).', {
17
+ amount: z.number().positive().describe('Amount of SOL to stake (e.g., 1.0 for one SOL). This is the delegation amount; a small rent-exempt reserve (~0.00228 SOL) is added automatically.'),
18
+ owsWallet: z.string().optional().describe('OWS wallet name for policy-gated signing (requires `ows` CLI installed).'),
19
+ }, async ({ amount, owsWallet }) => {
20
+ if (!hasApiKey())
21
+ return noApiKeyResponse();
22
+ try {
23
+ const resolved = await resolveOwsOrKeypairSigner(owsWallet);
24
+ if (!resolved.ok)
25
+ return resolved.error;
26
+ const { signer, walletAddress } = resolved;
27
+ const helius = getHeliusClient();
28
+ // Pre-flight balance check — need amount + ~0.01 SOL for rent + fees
29
+ const balanceResult = await helius.getBalance(walletAddress);
30
+ const balanceLamports = BigInt(balanceResult.value);
31
+ const stakeLamports = BigInt(Math.round(amount * 1_000_000_000));
32
+ const reserveLamports = 10000000n; // ~0.01 SOL for rent-exempt reserve + fees
33
+ if (balanceLamports < stakeLamports + reserveLamports) {
34
+ const available = Number(balanceLamports) / 1_000_000_000;
35
+ return mcpError(`Insufficient SOL balance. You have ${available} SOL but need ${amount} SOL plus ~0.01 SOL for rent-exempt reserve and transaction fees.\n\n` +
36
+ `Wallet: \`${walletAddress}\``, { type: 'INSUFFICIENT_FUNDS', code: 'LOW_SOL', retryable: false, recovery: 'Fund the wallet with more SOL.' });
37
+ }
38
+ // Get stake instructions (async — fetches rent-exempt minimum from RPC)
39
+ const { instructions, stakeAccount } = await helius.stake.getStakeInstructions(signer, amount);
40
+ // Send transaction — stakeAccount is a generated keypair that must co-sign
41
+ const signature = await helius.tx.sendTransactionWithSender({
42
+ signers: [signer, stakeAccount],
43
+ instructions: [...instructions],
44
+ region: 'Default',
45
+ });
46
+ const signerLabel = owsWallet ? `\`${walletAddress}\` (OWS: ${owsWallet})` : `\`${walletAddress}\``;
47
+ return mcpText(`**SOL Staked to Helius Validator**\n\n` +
48
+ `- **From:** ${signerLabel}\n` +
49
+ `- **Stake Account:** \`${stakeAccount.address}\`\n` +
50
+ `- **Amount:** ${amount} SOL\n` +
51
+ `- **Signature:** \`${signature}\`\n` +
52
+ `- **Explorer:** https://orbmarkets.io/tx/${signature}\n\n` +
53
+ `The stake account is now delegated to the Helius validator. ` +
54
+ `Staking rewards begin accruing after the activation epoch completes (~1 epoch / 2-3 days).`);
55
+ }
56
+ catch (err) {
57
+ return handleToolError(err, 'Error staking SOL');
58
+ }
59
+ });
60
+ // ── Unstake SOL ──
61
+ server.tool('unstakeSOL', 'BEST FOR: deactivating (unstaking) a stake account to begin the cooldown period before withdrawal. ' +
62
+ 'PREFER getStakeAccounts to find stake account addresses first. ' +
63
+ 'Sends a deactivation transaction for the given stake account. ' +
64
+ 'After deactivation, wait ~1 full epoch (2-3 days) before funds become withdrawable. ' +
65
+ 'Use withdrawStake after cooldown completes. ' +
66
+ 'Requires a configured keypair (the stake authority). ' +
67
+ 'This is an irreversible on-chain transaction. ' +
68
+ 'Credit cost: ~3 credits.', {
69
+ stakeAccount: z.string().describe('Address of the stake account to deactivate (base58 encoded). Use getStakeAccounts to find your Helius stake accounts.'),
70
+ owsWallet: z.string().optional().describe('OWS wallet name for policy-gated signing (requires `ows` CLI installed).'),
71
+ }, async ({ stakeAccount: stakeAccountAddress, owsWallet }) => {
72
+ if (!hasApiKey())
73
+ return noApiKeyResponse();
74
+ try {
75
+ const resolved = await resolveOwsOrKeypairSigner(owsWallet);
76
+ if (!resolved.ok)
77
+ return resolved.error;
78
+ const { signer, walletAddress } = resolved;
79
+ // Validate address
80
+ if (!isValidAddressFormat(stakeAccountAddress)) {
81
+ return mcpError(`Invalid stake account address "${stakeAccountAddress}". Expected a valid Solana address (32-44 base58 characters).`, { type: 'VALIDATION', code: 'INVALID_ADDRESS', retryable: false, recovery: 'Provide a valid base58-encoded Solana address.' });
82
+ }
83
+ const helius = getHeliusClient();
84
+ // Get deactivation instruction (synchronous)
85
+ const ix = helius.stake.getUnstakeInstruction(signer, address(stakeAccountAddress));
86
+ // Send transaction
87
+ const signature = await helius.tx.sendTransactionWithSender({
88
+ signers: [signer],
89
+ instructions: [ix],
90
+ region: 'Default',
91
+ });
92
+ const signerLabel = owsWallet ? `\`${walletAddress}\` (OWS: ${owsWallet})` : `\`${walletAddress}\``;
93
+ return mcpText(`**Stake Account Deactivated**\n\n` +
94
+ `- **Stake Account:** \`${stakeAccountAddress}\`\n` +
95
+ `- **Authority:** ${signerLabel}\n` +
96
+ `- **Signature:** \`${signature}\`\n` +
97
+ `- **Explorer:** https://orbmarkets.io/tx/${signature}\n\n` +
98
+ `The stake account is now deactivating. Funds will become withdrawable after the cooldown period (~1 full epoch / 2-3 days). ` +
99
+ `Use \`getWithdrawableAmount\` to check when funds are ready, then \`withdrawStake\` to withdraw.`);
100
+ }
101
+ catch (err) {
102
+ return handleToolError(err, 'Error deactivating stake account');
103
+ }
104
+ });
105
+ // ── Withdraw Stake ──
106
+ server.tool('withdrawStake', 'BEST FOR: withdrawing SOL from a deactivated stake account back to your wallet. ' +
107
+ 'PREFER getWithdrawableAmount to check if funds are available before withdrawing. ' +
108
+ 'By default withdraws the full balance (including rent-exempt reserve) and closes the stake account. ' +
109
+ 'The stake account must be fully deactivated (cooldown complete, ~1 epoch after unstakeSOL) before withdrawal. ' +
110
+ 'Requires a configured keypair (the withdraw authority). ' +
111
+ 'This is an irreversible on-chain transaction. ' +
112
+ 'Credit cost: ~3 credits.', {
113
+ stakeAccount: z.string().describe('Address of the deactivated stake account to withdraw from (base58 encoded).'),
114
+ destination: z.string().optional().describe('Destination wallet address to receive the withdrawn SOL (base58 encoded). Defaults to the MCP wallet address if omitted.'),
115
+ amount: z.number().positive().optional().describe('Amount of SOL to withdraw. If omitted, withdraws the entire withdrawable balance (including rent-exempt reserve, which closes the account).'),
116
+ owsWallet: z.string().optional().describe('OWS wallet name for policy-gated signing (requires `ows` CLI installed).'),
117
+ }, async ({ stakeAccount: stakeAccountAddress, destination, amount, owsWallet }) => {
118
+ if (!hasApiKey())
119
+ return noApiKeyResponse();
120
+ try {
121
+ const resolved = await resolveOwsOrKeypairSigner(owsWallet);
122
+ if (!resolved.ok)
123
+ return resolved.error;
124
+ const { signer, walletAddress } = resolved;
125
+ // Validate addresses
126
+ if (!isValidAddressFormat(stakeAccountAddress)) {
127
+ return mcpError(`Invalid stake account address "${stakeAccountAddress}". Expected a valid Solana address (32-44 base58 characters).`, { type: 'VALIDATION', code: 'INVALID_ADDRESS', retryable: false, recovery: 'Provide a valid base58-encoded Solana address.' });
128
+ }
129
+ if (destination && !isValidAddressFormat(destination)) {
130
+ return mcpError(`Invalid destination address "${destination}". Expected a valid Solana address (32-44 base58 characters).`, { type: 'VALIDATION', code: 'INVALID_ADDRESS', retryable: false, recovery: 'Provide a valid base58-encoded Solana address.' });
131
+ }
132
+ const helius = getHeliusClient();
133
+ const dest = destination || walletAddress;
134
+ // Determine lamports to withdraw
135
+ let lamports;
136
+ if (amount === undefined) {
137
+ // Withdraw full balance including rent-exempt reserve (closes account)
138
+ lamports = await helius.stake.getWithdrawableAmount(stakeAccountAddress, true);
139
+ }
140
+ else {
141
+ lamports = Math.round(amount * 1_000_000_000);
142
+ }
143
+ if (lamports === 0) {
144
+ return mcpError(`No funds available to withdraw from stake account \`${stakeAccountAddress}\`. ` +
145
+ `The stake may still be active or in cooldown (not yet fully deactivated). ` +
146
+ `Use \`getStakeAccounts\` to check the status.`, { type: 'INSUFFICIENT_FUNDS', code: 'ZERO_BALANCE', retryable: false, recovery: 'Stake may still be active or in cooldown. Use getStakeAccounts to check status.' });
147
+ }
148
+ // Get withdraw instruction (synchronous)
149
+ const ix = helius.stake.getWithdrawInstruction(signer, address(stakeAccountAddress), address(dest), lamports);
150
+ // Send transaction
151
+ const signature = await helius.tx.sendTransactionWithSender({
152
+ signers: [signer],
153
+ instructions: [ix],
154
+ region: 'Default',
155
+ });
156
+ const solAmount = lamports / 1_000_000_000;
157
+ return mcpText(`**Stake Withdrawn**\n\n` +
158
+ `- **Stake Account:** \`${stakeAccountAddress}\`\n` +
159
+ `- **Destination:** \`${dest}\`\n` +
160
+ `- **Amount:** ${solAmount} SOL\n` +
161
+ `- **Signature:** \`${signature}\`\n` +
162
+ `- **Explorer:** https://orbmarkets.io/tx/${signature}` +
163
+ (amount === undefined ? `\n\nThe full balance was withdrawn and the stake account has been closed.` : ''));
164
+ }
165
+ catch (err) {
166
+ return handleToolError(err, 'Error withdrawing stake');
167
+ }
168
+ });
169
+ // ── Get Stake Accounts ──
170
+ server.tool('getStakeAccounts', 'BEST FOR: listing all stake accounts delegated to the Helius validator for a wallet. ' +
171
+ 'PREFER getWithdrawableAmount to check withdrawal eligibility for a specific stake account. ' +
172
+ 'Returns stake account addresses, delegated amounts, and activation status (active, deactivating, or inactive). ' +
173
+ 'Does not require a keypair — any wallet address can be queried. ' +
174
+ 'Credit cost: ~10 credits (getProgramAccounts).', {
175
+ wallet: z.string().optional().describe('Wallet address to query Helius stake accounts for (base58 encoded). Defaults to the MCP wallet if omitted and a keypair is configured.'),
176
+ }, async ({ wallet }) => {
177
+ if (!hasApiKey())
178
+ return noApiKeyResponse();
179
+ try {
180
+ // Resolve wallet address
181
+ let walletAddress = wallet;
182
+ if (!walletAddress) {
183
+ walletAddress = getSessionWalletAddress() ?? undefined;
184
+ if (!walletAddress) {
185
+ return missingParamError('getStakeAccounts', 'Pass a wallet address or call generateKeypair first.');
186
+ }
187
+ }
188
+ if (!isValidAddressFormat(walletAddress)) {
189
+ return mcpError(`Invalid wallet address "${walletAddress}". Expected a valid Solana address (32-44 base58 characters).`, { type: 'VALIDATION', code: 'INVALID_ADDRESS', retryable: false, recovery: 'Provide a valid base58-encoded Solana address.' });
190
+ }
191
+ const helius = getHeliusClient();
192
+ const accounts = await helius.stake.getHeliusStakeAccounts(walletAddress);
193
+ if (!accounts || accounts.length === 0) {
194
+ return mcpText(`**No Helius Stake Accounts Found**\n\n` +
195
+ `Wallet \`${walletAddress}\` has no stake accounts delegated to the Helius validator.\n\n` +
196
+ `Use \`stakeSOL\` to stake SOL to the Helius validator.`);
197
+ }
198
+ const U64_MAX = '18446744073709551615';
199
+ const lines = [`**Helius Stake Accounts for \`${walletAddress}\`**\n`];
200
+ for (const account of accounts) {
201
+ const pubkey = account.pubkey;
202
+ const lamports = account.account.lamports;
203
+ const solAmount = Number(lamports) / 1_000_000_000;
204
+ const delegation = account.account.data?.parsed?.info?.stake?.delegation;
205
+ let status = 'Unknown';
206
+ let details = '';
207
+ if (delegation) {
208
+ const deactivationEpoch = delegation.deactivationEpoch;
209
+ if (deactivationEpoch === U64_MAX) {
210
+ status = 'Active';
211
+ details = `Activation epoch: ${delegation.activationEpoch}`;
212
+ }
213
+ else {
214
+ status = 'Deactivating';
215
+ details = `Deactivation epoch: ${deactivationEpoch}`;
216
+ }
217
+ }
218
+ else {
219
+ status = 'Inactive';
220
+ }
221
+ lines.push(`### \`${pubkey}\`\n` +
222
+ `- **Balance:** ${solAmount} SOL\n` +
223
+ `- **Status:** ${status}\n` +
224
+ (details ? `- **${details}**\n` : '') +
225
+ (delegation ? `- **Delegated stake:** ${Number(delegation.stake) / 1_000_000_000} SOL\n` : ''));
226
+ }
227
+ lines.push(`---\n_${accounts.length} stake account(s) found._`);
228
+ return mcpText(lines.join('\n'));
229
+ }
230
+ catch (err) {
231
+ return handleToolError(err, 'Error fetching stake accounts');
232
+ }
233
+ });
234
+ // ── Get Withdrawable Amount ──
235
+ server.tool('getWithdrawableAmount', 'BEST FOR: checking how much SOL can be withdrawn from a stake account. ' +
236
+ 'PREFER getStakeAccounts to find stake account addresses, withdrawStake to actually withdraw. ' +
237
+ 'Returns 0 if the stake account is still active or in cooldown (not yet withdrawable). ' +
238
+ 'Does not require a keypair. ' +
239
+ 'Credit cost: ~3 credits (getAccountInfo + getEpochInfo + getMinimumBalanceForRentExemption).', {
240
+ stakeAccount: z.string().describe('Stake account address to check (base58 encoded). Use getStakeAccounts to find stake account addresses.'),
241
+ includeRentExempt: z.boolean().optional().default(false).describe('If true, includes the rent-exempt reserve (~0.00228 SOL) in the withdrawable amount. Withdrawing the full amount (with rent) closes the stake account.'),
242
+ }, async ({ stakeAccount: stakeAccountAddress, includeRentExempt }) => {
243
+ if (!hasApiKey())
244
+ return noApiKeyResponse();
245
+ try {
246
+ if (!isValidAddressFormat(stakeAccountAddress)) {
247
+ return mcpError(`Invalid stake account address "${stakeAccountAddress}". Expected a valid Solana address (32-44 base58 characters).`, { type: 'VALIDATION', code: 'INVALID_ADDRESS', retryable: false, recovery: 'Provide a valid base58-encoded Solana address.' });
248
+ }
249
+ const helius = getHeliusClient();
250
+ const lamports = await helius.stake.getWithdrawableAmount(stakeAccountAddress, includeRentExempt);
251
+ const solAmount = lamports / 1_000_000_000;
252
+ if (lamports === 0) {
253
+ return mcpText(`**Withdrawable Amount: 0 SOL**\n\n` +
254
+ `Stake account \`${stakeAccountAddress}\` has no withdrawable funds.\n\n` +
255
+ `This usually means the stake is still active or in the cooldown period after deactivation. ` +
256
+ `Use \`getStakeAccounts\` to check the current status.`);
257
+ }
258
+ return mcpText(`**Withdrawable Amount**\n\n` +
259
+ `- **Stake Account:** \`${stakeAccountAddress}\`\n` +
260
+ `- **Withdrawable:** ${solAmount} SOL (${lamports.toLocaleString()} lamports)\n` +
261
+ `- **Includes rent-exempt reserve:** ${includeRentExempt ? 'Yes (withdrawing closes the account)' : 'No'}\n\n` +
262
+ `Use \`withdrawStake\` to withdraw these funds.`);
263
+ }
264
+ catch (err) {
265
+ return handleToolError(err, 'Error checking withdrawable amount');
266
+ }
267
+ });
268
+ }
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { getHeliusClient, hasApiKey } from '../utils/helius.js';
3
3
  import { formatSol, formatAddress, formatTimestamp } from '../utils/formatters.js';
4
4
  import { noApiKeyResponse } from './shared.js';
5
- import { mcpText, validateEnum, handleToolError, http400Error } from '../utils/errors.js';
5
+ import { mcpText, mcpError, validateEnum, handleToolError, http400Error } from '../utils/errors.js';
6
6
  import bs58 from 'bs58';
7
7
  // ─── Helpers ───
8
8
  const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
@@ -125,7 +125,7 @@ function formatSwapSummary(swap, tokenMetadata) {
125
125
  const asset = tokenMetadata.get(input.mint);
126
126
  const symbol = asset?.token_info?.symbol || asset?.content?.metadata?.symbol || asset?.content?.metadata?.name || formatAddress(input.mint);
127
127
  const amount = Number(input.rawTokenAmount.tokenAmount) / Math.pow(10, input.rawTokenAmount.decimals);
128
- parts.push(`${amount.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol}`);
128
+ parts.push(`${amount.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol} (${input.mint})`);
129
129
  }
130
130
  }
131
131
  if (parts.length === 0)
@@ -141,7 +141,7 @@ function formatSwapSummary(swap, tokenMetadata) {
141
141
  const asset = tokenMetadata.get(output.mint);
142
142
  const symbol = asset?.token_info?.symbol || asset?.content?.metadata?.symbol || asset?.content?.metadata?.name || formatAddress(output.mint);
143
143
  const amount = Number(output.rawTokenAmount.tokenAmount) / Math.pow(10, output.rawTokenAmount.decimals);
144
- parts.push(`${amount.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol}`);
144
+ parts.push(`${amount.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${symbol} (${output.mint})`);
145
145
  }
146
146
  }
147
147
  if (parts.length === 0)
@@ -616,4 +616,168 @@ export function registerTransactionTools(server) {
616
616
  return handleToolError(err, 'Error fetching transaction history');
617
617
  }
618
618
  });
619
+ // Get Transfers By Address — parsed token + native SOL transfers (one row per transfer)
620
+ server.tool('getTransfersByAddress', 'BEST FOR: parsed token + native SOL transfers for an address — one row per transfer (not per transaction). Filters: mint, direction (in/out/any), counterparty (with), time/slot/amount ranges, solMode. Use getTransactionHistory when you need whole-transaction context. Paginated via paginationToken.', {
621
+ address: z.string().describe('Solana address (base58 encoded)'),
622
+ with: z.string().optional().describe('Filter by counterparty address — returns only transfers to/from this address'),
623
+ direction: z.string().optional().default('any').describe('"in" | "out" | "any" — relative to the queried address'),
624
+ mint: z.string().optional().describe('Filter by token mint. Use the WSOL mint to filter native SOL when solMode="merged".'),
625
+ solMode: z.string().optional().describe('"merged" treats SOL and WSOL as one asset; "separate" keeps them distinct'),
626
+ limit: z.number().int().min(1).max(100).optional().default(25).describe('Max transfers per page (1-100)'),
627
+ sortOrder: z.string().optional().default('desc').describe('"desc" = newest first (default), "asc" = oldest first'),
628
+ paginationToken: z.string().optional().describe('Cursor from a previous response — pass to fetch the next page'),
629
+ commitment: z.string().optional().describe('"finalized" | "confirmed"'),
630
+ amountGte: z.string().optional().describe('Raw u64 amount (as string) lower bound — not UI amount'),
631
+ amountLte: z.string().optional().describe('Raw u64 amount (as string) upper bound — not UI amount'),
632
+ blockTimeGte: z.number().optional().describe('Filter: block time >= this Unix timestamp (seconds)'),
633
+ blockTimeLte: z.number().optional().describe('Filter: block time <= this Unix timestamp (seconds)'),
634
+ slotGte: z.number().optional().describe('Filter: slot >= this value'),
635
+ slotLte: z.number().optional().describe('Filter: slot <= this value'),
636
+ }, async ({ address, with: counterparty, direction, mint, solMode, limit, sortOrder, paginationToken, commitment, amountGte, amountLte, blockTimeGte, blockTimeLte, slotGte, slotLte, }) => {
637
+ if (!hasApiKey())
638
+ return noApiKeyResponse();
639
+ const helius = getHeliusClient();
640
+ let err;
641
+ err = validateEnum(direction, ['in', 'out', 'any'], 'Transfers By Address Error', 'direction');
642
+ if (err)
643
+ return err;
644
+ err = validateEnum(sortOrder, ['asc', 'desc'], 'Transfers By Address Error', 'sortOrder');
645
+ if (err)
646
+ return err;
647
+ if (solMode) {
648
+ err = validateEnum(solMode, ['merged', 'separate'], 'Transfers By Address Error', 'solMode');
649
+ if (err)
650
+ return err;
651
+ }
652
+ if (commitment) {
653
+ err = validateEnum(commitment, ['finalized', 'confirmed'], 'Transfers By Address Error', 'commitment');
654
+ if (err)
655
+ return err;
656
+ }
657
+ // SDK's TransferComparisonFilter types amount as `number`, so values outside JS safe-integer
658
+ // range (>2^53) can't be passed without precision loss. u64 raw amounts on high-decimal tokens
659
+ // can exceed this. Surface a clear error rather than silently dropping the filter.
660
+ const parseAmountBound = (raw, label) => {
661
+ if (raw === undefined)
662
+ return undefined;
663
+ const n = Number(raw);
664
+ if (!Number.isFinite(n)) {
665
+ return { error: `${label} must be a numeric raw u64 amount (got "${raw}")` };
666
+ }
667
+ if (!Number.isSafeInteger(n) || n < 0) {
668
+ return {
669
+ error: `${label}="${raw}" is outside JS safe integer range (>2^53) or negative. The SDK's amount filter is typed as number — pass a smaller raw amount or filter client-side. (u64 raw amounts can exceed this; high-decimal SPL tokens are the common case.)`,
670
+ };
671
+ }
672
+ return n;
673
+ };
674
+ const filters = {};
675
+ const aGte = parseAmountBound(amountGte, 'amountGte');
676
+ const aLte = parseAmountBound(amountLte, 'amountLte');
677
+ const amountErrMeta = {
678
+ type: 'VALIDATION',
679
+ code: 'INVALID_AMOUNT_BOUND',
680
+ retryable: false,
681
+ recovery: 'Pass a raw u64 amount within JS safe integer range (<= 2^53), or filter client-side after fetching.',
682
+ };
683
+ if (aGte && typeof aGte === 'object')
684
+ return mcpError(aGte.error, amountErrMeta);
685
+ if (aLte && typeof aLte === 'object')
686
+ return mcpError(aLte.error, amountErrMeta);
687
+ if (aGte !== undefined || aLte !== undefined) {
688
+ filters.amount = {};
689
+ if (typeof aGte === 'number')
690
+ filters.amount.gte = aGte;
691
+ if (typeof aLte === 'number')
692
+ filters.amount.lte = aLte;
693
+ }
694
+ if (blockTimeGte !== undefined || blockTimeLte !== undefined) {
695
+ filters.blockTime = {};
696
+ if (blockTimeGte !== undefined)
697
+ filters.blockTime.gte = blockTimeGte;
698
+ if (blockTimeLte !== undefined)
699
+ filters.blockTime.lte = blockTimeLte;
700
+ }
701
+ if (slotGte !== undefined || slotLte !== undefined) {
702
+ filters.slot = {};
703
+ if (slotGte !== undefined)
704
+ filters.slot.gte = slotGte;
705
+ if (slotLte !== undefined)
706
+ filters.slot.lte = slotLte;
707
+ }
708
+ const config = {
709
+ direction,
710
+ limit: Math.min(Math.max(limit, 1), 100),
711
+ sortOrder,
712
+ };
713
+ if (counterparty)
714
+ config.with = counterparty;
715
+ if (mint)
716
+ config.mint = mint;
717
+ if (solMode)
718
+ config.solMode = solMode;
719
+ if (paginationToken)
720
+ config.paginationToken = paginationToken;
721
+ if (commitment)
722
+ config.commitment = commitment;
723
+ if (Object.keys(filters).length > 0)
724
+ config.filters = filters;
725
+ let result;
726
+ try {
727
+ result = await helius.getTransfersByAddress(address, config);
728
+ }
729
+ catch (e) {
730
+ return handleToolError(e, 'Error fetching transfers');
731
+ }
732
+ if (!result || !result.data || result.data.length === 0) {
733
+ return mcpText(`**Transfers for ${formatAddress(address)}**\n\nNo transfers found.`);
734
+ }
735
+ const mints = new Set();
736
+ for (const t of result.data) {
737
+ if (t.mint)
738
+ mints.add(t.mint);
739
+ }
740
+ const tokenMetadata = await fetchTokenMetadata(helius, mints);
741
+ const symbolFor = (m) => {
742
+ const asset = tokenMetadata.get(m);
743
+ return asset?.token_info?.symbol || asset?.content?.metadata?.symbol || asset?.content?.metadata?.name || formatAddress(m);
744
+ };
745
+ const WSOL = 'So11111111111111111111111111111111111111112';
746
+ // SDK contract: native SOL transfers omit fromTokenAccount/toTokenAccount. JSON deserialization
747
+ // can surface either `undefined` (key absent) or `null` (key present with null value) — accept both.
748
+ const isNativeSol = (t) => t.mint === WSOL && t.fromTokenAccount == null && t.toTokenAccount == null;
749
+ const formatAmount = (t) => {
750
+ if (isNativeSol(t)) {
751
+ // amount is raw lamports as string
752
+ const n = Number(t.amount);
753
+ if (Number.isFinite(n))
754
+ return formatSol(n);
755
+ }
756
+ // Use uiAmount (string, precision-preserving) directly and trim trailing zeros for readability
757
+ const ui = t.uiAmount ?? '';
758
+ if (!ui.includes('.'))
759
+ return ui;
760
+ return ui.replace(/0+$/, '').replace(/\.$/, '');
761
+ };
762
+ const orderLabel = sortOrder === 'asc' ? 'oldest first' : 'newest first';
763
+ const lines = [`**Transfers for ${formatAddress(address)}** (${result.data.length} transfers, ${orderLabel})`, ''];
764
+ for (const t of result.data) {
765
+ const sym = isNativeSol(t) ? 'SOL' : symbolFor(t.mint);
766
+ const from = t.fromUserAccount ? formatAddress(t.fromUserAccount) : (t.type === 'mint' ? 'mint' : '?');
767
+ const to = t.toUserAccount ? formatAddress(t.toUserAccount) : (t.type === 'burn' ? 'burn' : '?');
768
+ const time = t.blockTime ? formatTimestamp(Number(t.blockTime)) : 'N/A';
769
+ const dirArrow = '→';
770
+ lines.push(`${t.type.toUpperCase()} ${formatAmount(t)} ${sym} (${t.mint})`);
771
+ lines.push(` ${from} ${dirArrow} ${to}`);
772
+ lines.push(` Sig: \`${t.signature}\` | Slot: ${t.slot.toLocaleString()} | ${time}`);
773
+ if (t.feeAmount && t.feeAmount !== '0') {
774
+ lines.push(` Fee: ${t.feeUiAmount ?? t.feeAmount} ${sym}`);
775
+ }
776
+ lines.push('');
777
+ }
778
+ if (result.paginationToken) {
779
+ lines.push(`**Next Page Token:** \`${result.paginationToken}\``);
780
+ }
781
+ return mcpText(truncateResponse(lines.join('\n')));
782
+ });
619
783
  }