@oobe-protocol-labs/sap-mcp-server 0.9.37 → 0.9.38
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/.env.example +14 -0
- package/CHANGELOG.md +85 -0
- package/README.md +3 -3
- package/USER_DOCS/01_HOSTED_REMOTE_MCP.md +1 -1
- package/USER_DOCS/04_CLIENT_CONFIGS.md +3 -3
- package/USER_DOCS/06_DESKTOP_GUI_WIZARD.md +5 -5
- package/dist/config/env.d.ts +32 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +24 -1
- package/dist/config/env.js.map +1 -1
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.js +1 -1
- package/dist/core/logger.js +1 -1
- package/dist/payments/pricing.d.ts.map +1 -1
- package/dist/payments/pricing.js +39 -1
- package/dist/payments/pricing.js.map +1 -1
- package/dist/perps/adrena/adrena-builder.d.ts +301 -0
- package/dist/perps/adrena/adrena-builder.d.ts.map +1 -0
- package/dist/perps/adrena/adrena-builder.js +1148 -0
- package/dist/perps/adrena/adrena-builder.js.map +1 -0
- package/dist/perps/adrena/adrena-constants.d.ts +135 -0
- package/dist/perps/adrena/adrena-constants.d.ts.map +1 -0
- package/dist/perps/adrena/adrena-constants.js +100 -0
- package/dist/perps/adrena/adrena-constants.js.map +1 -0
- package/dist/perps/adrena/adrena-data-api.d.ts +180 -0
- package/dist/perps/adrena/adrena-data-api.d.ts.map +1 -0
- package/dist/perps/adrena/adrena-data-api.js +169 -0
- package/dist/perps/adrena/adrena-data-api.js.map +1 -0
- package/dist/perps/adrena/adrena-pda.d.ts +146 -0
- package/dist/perps/adrena/adrena-pda.d.ts.map +1 -0
- package/dist/perps/adrena/adrena-pda.js +215 -0
- package/dist/perps/adrena/adrena-pda.js.map +1 -0
- package/dist/perps/adrena/index.d.ts +11 -0
- package/dist/perps/adrena/index.d.ts.map +1 -0
- package/dist/perps/adrena/index.js +11 -0
- package/dist/perps/adrena/index.js.map +1 -0
- package/dist/server/server-metadata.d.ts +2 -0
- package/dist/server/server-metadata.d.ts.map +1 -1
- package/dist/server/server-metadata.js +3 -1
- package/dist/server/server-metadata.js.map +1 -1
- package/dist/tools/adrena-tools.d.ts +67 -0
- package/dist/tools/adrena-tools.d.ts.map +1 -0
- package/dist/tools/adrena-tools.js +1062 -0
- package/dist/tools/adrena-tools.js.map +1 -0
- package/dist/tools/client-sdk-tools.d.ts +8 -0
- package/dist/tools/client-sdk-tools.d.ts.map +1 -1
- package/dist/tools/client-sdk-tools.js +31 -6
- package/dist/tools/client-sdk-tools.js.map +1 -1
- package/dist/tools/perp-tools.d.ts +150 -7
- package/dist/tools/perp-tools.d.ts.map +1 -1
- package/dist/tools/perp-tools.js +787 -212
- package/dist/tools/perp-tools.js.map +1 -1
- package/dist/tools/quick-context-tool.js +1 -0
- package/dist/tools/quick-context-tool.js.map +1 -1
- package/dist/tools/register-tools.d.ts.map +1 -1
- package/dist/tools/register-tools.js +4 -0
- package/dist/tools/register-tools.js.map +1 -1
- package/docs/07_ENDPOINTS_AND_CLIENTS.md +1 -1
- package/docs/10_OPERATIONS_RELEASE_AND_PM2.md +54 -6
- package/docs/14_DESKTOP_WIZARD_RELEASE.md +3 -3
- package/docs/BRANCH_AND_CI.md +2 -2
- package/package.json +3 -3
- package/server.json +3 -3
- package/skills/sap-defi/SKILL.md +93 -22
- package/skills/sap-mcp/TOOL_REFERENCE.md +39 -4
package/dist/tools/perp-tools.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* @name tools/perp-tools
|
|
3
3
|
* @description MCP tools for perpetual futures trading and chart analysis on Solana.
|
|
4
4
|
*
|
|
5
|
-
* Provides
|
|
5
|
+
* Provides perp analytics plus explicit execution-readiness tooling:
|
|
6
6
|
*
|
|
7
7
|
* Read-only tools (7):
|
|
8
|
-
* - sap_perp_markets — List Adrena
|
|
8
|
+
* - sap_perp_markets — List Adrena markets with funding/accounting, OI, and oracle ids.
|
|
9
9
|
* - sap_perp_position_info — Read on-chain perp positions for a wallet.
|
|
10
10
|
* - sap_perp_funding_history — Compute funding rate from on-chain custody account.
|
|
11
11
|
* - sap_chart_ohlc — OHLC candlestick data for any Solana token.
|
|
@@ -13,24 +13,28 @@
|
|
|
13
13
|
* - sap_chart_volume_profile — Volume profile analysis (POC, VAH, VAL).
|
|
14
14
|
* - sap_perp_liquidation_zones — Compute liquidation zones for open positions.
|
|
15
15
|
*
|
|
16
|
-
* Professional planning tools
|
|
16
|
+
* Professional planning tools:
|
|
17
17
|
* - sap_perp_trade_plan — Build a trader-grade risk, route, and execution checklist.
|
|
18
|
+
* - sap_perp_builder_status — Report whether a hosted unsigned perps builder is configured.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* Adrena reads decode Pool, Custody, and Position accounts directly from the
|
|
21
|
+
* official release/39 ABI layout through Solana RPC. Optional provider hooks
|
|
22
|
+
* can enrich reads or build unsigned execution transactions, but SAP MCP never
|
|
23
|
+
* pretends a perps execution route exists until the builder can return a real
|
|
24
|
+
* locally finalizable Solana transaction.
|
|
22
25
|
*
|
|
23
26
|
* @module tools/perp-tools
|
|
24
27
|
*/
|
|
25
28
|
import { PublicKey } from '@solana/web3.js';
|
|
29
|
+
import bs58 from 'bs58';
|
|
26
30
|
import { createTextResponse } from '../adapters/mcp/tool-response.js';
|
|
27
31
|
import { registerTool } from '../adapters/mcp/sdk-compat.js';
|
|
28
32
|
import { logger } from '../core/logger.js';
|
|
29
33
|
/* ═══════════════════════════════════════════════════════════════════
|
|
30
34
|
* Constants
|
|
31
35
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
32
|
-
/** Adrena program ID on Solana mainnet. */
|
|
33
|
-
const
|
|
36
|
+
/** Adrena program ID on Solana mainnet. Can be overridden by hosted env config. */
|
|
37
|
+
const DEFAULT_ADRENA_PROGRAM_ID = '13gDzEXCdocbj8iAiqrScGo47NiSuYENGsRqi3SEAwet';
|
|
34
38
|
/** DexScreener REST API base URL (free, no API key). */
|
|
35
39
|
const DEXSCREENER_API_URL = 'https://api.dexscreener.com';
|
|
36
40
|
/** DeFiLlama REST API base URL (free, no API key). */
|
|
@@ -50,6 +54,103 @@ const DISC_POOL = Buffer.from([241, 154, 109, 4, 17, 177, 109, 188]);
|
|
|
50
54
|
const DISC_CUSTODY = Buffer.from([1, 184, 48, 81, 93, 131, 63, 145]);
|
|
51
55
|
/** Position account discriminator — sha256("account:Position")[0..8]. */
|
|
52
56
|
const DISC_POSITION = Buffer.from([170, 188, 143, 228, 122, 64, 247, 208]);
|
|
57
|
+
/** Adrena release/39 constants from the official ABI. */
|
|
58
|
+
const ADRENA_USD_DECIMALS = 6;
|
|
59
|
+
const ADRENA_PRICE_DECIMALS = 10;
|
|
60
|
+
const ADRENA_BPS_DECIMALS = 4;
|
|
61
|
+
/** Absolute offsets include the 8-byte Anchor account discriminator. */
|
|
62
|
+
const ADRENA_CUSTODY_OFFSETS = {
|
|
63
|
+
allowTrade: 10,
|
|
64
|
+
allowSwap: 11,
|
|
65
|
+
decimals: 12,
|
|
66
|
+
pool: 16,
|
|
67
|
+
mint: 48,
|
|
68
|
+
tokenAccount: 80,
|
|
69
|
+
oracle: 112,
|
|
70
|
+
tradeOracle: 144,
|
|
71
|
+
maxInitialLeverage: 176,
|
|
72
|
+
maxLeverage: 180,
|
|
73
|
+
maxPositionLockedUsd: 184,
|
|
74
|
+
volumeOpenPositionUsd: 320,
|
|
75
|
+
tradeOiLongUsd: 360,
|
|
76
|
+
tradeOiShortUsd: 368,
|
|
77
|
+
assetsCollateral: 376,
|
|
78
|
+
assetsOwned: 384,
|
|
79
|
+
assetsLocked: 392,
|
|
80
|
+
longPositionsOpenCount: 400,
|
|
81
|
+
longPositionsSizeUsd: 408,
|
|
82
|
+
longPositionsBorrowSizeUsd: 416,
|
|
83
|
+
longPositionsLockedAmount: 424,
|
|
84
|
+
longPositionsCollateralUsd: 472,
|
|
85
|
+
shortPositionsOpenCount: 600,
|
|
86
|
+
shortPositionsSizeUsd: 608,
|
|
87
|
+
shortPositionsBorrowSizeUsd: 616,
|
|
88
|
+
shortPositionsLockedAmount: 624,
|
|
89
|
+
shortPositionsCollateralUsd: 672,
|
|
90
|
+
borrowRateCurrent: 800,
|
|
91
|
+
borrowRateLastUpdate: 808,
|
|
92
|
+
optimalUtilizationBps: 832,
|
|
93
|
+
virtualFundingMaxHourlyRate: 840,
|
|
94
|
+
virtualFundingMinTotalOiUsd: 848,
|
|
95
|
+
virtualFundingImbalanceSensitivityBps: 856,
|
|
96
|
+
virtualFundingCurrentLongToShort: 864,
|
|
97
|
+
virtualFundingLastUpdate: 872,
|
|
98
|
+
virtualFundingCumulativeLongToShort: 880,
|
|
99
|
+
virtualFundingCumulativeShortToLong: 896,
|
|
100
|
+
isSynthetic: 912,
|
|
101
|
+
version: 913,
|
|
102
|
+
oracleFeedId: 914,
|
|
103
|
+
tradeOracleFeedId: 915,
|
|
104
|
+
};
|
|
105
|
+
const ADRENA_POOL_OFFSETS = {
|
|
106
|
+
allowTrade: 12,
|
|
107
|
+
allowSwap: 13,
|
|
108
|
+
registeredCustodyCount: 15,
|
|
109
|
+
name: 16,
|
|
110
|
+
custodies: 48,
|
|
111
|
+
lpTokenPriceUsd: 328,
|
|
112
|
+
aumUsd: 448,
|
|
113
|
+
};
|
|
114
|
+
export const ADRENA_POSITION_OWNER_MEMCMP_OFFSET = 16;
|
|
115
|
+
const ADRENA_POSITION_OFFSETS = {
|
|
116
|
+
side: 9,
|
|
117
|
+
owner: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
|
|
118
|
+
pool: 48,
|
|
119
|
+
custody: 80,
|
|
120
|
+
collateralCustody: 112,
|
|
121
|
+
openTime: 144,
|
|
122
|
+
updateTime: 152,
|
|
123
|
+
price: 160,
|
|
124
|
+
sizeUsd: 168,
|
|
125
|
+
borrowSizeUsd: 176,
|
|
126
|
+
collateralUsd: 184,
|
|
127
|
+
unrealizedInterestUsd: 192,
|
|
128
|
+
lockedAmount: 216,
|
|
129
|
+
collateralAmount: 224,
|
|
130
|
+
exitFeeUsd: 232,
|
|
131
|
+
liquidationFeeUsd: 240,
|
|
132
|
+
id: 248,
|
|
133
|
+
takeProfitLimitPrice: 256,
|
|
134
|
+
paidInterestUsd: 264,
|
|
135
|
+
stopLossLimitPrice: 272,
|
|
136
|
+
stopLossClosePositionPrice: 280,
|
|
137
|
+
unrealizedFundingPaidUsd: 328,
|
|
138
|
+
unrealizedFundingReceivedUsd: 336,
|
|
139
|
+
};
|
|
140
|
+
const ADRENA_MAINNET_POOLS = {
|
|
141
|
+
'4bQRutgDJs6vuh6ZcWaPVXiQaBzbHketjbCDjL4oRN34': { name: 'main-pool', type: 'gmx' },
|
|
142
|
+
GN2hyBVHcUitWETeDfAoeXDMqow1x8StqdRFnGaUB2vb: { name: 'commodities-pool', type: 'autonom' },
|
|
143
|
+
};
|
|
144
|
+
const ADRENA_MAINNET_MARKETS_BY_CUSTODY = {
|
|
145
|
+
Dk523LZeDQbZtUwPEBjFXCd2Au1tD7mWZBJJmcgHktNk: { symbol: 'USDC', market: 'USDC', poolName: 'main-pool', kind: 'collateral' },
|
|
146
|
+
'8aJuzsgjxBnvRhDcfQBD7z4CUj7QoPEpaNwVd7KqsSk5': { symbol: 'BONK', market: 'BONK-PERP', poolName: 'main-pool', kind: 'perp' },
|
|
147
|
+
GZ9XfWwgTRhkma2Y91Q9r1XKotNXYjBnKKabj19rhT71: { symbol: 'JITOSOL', market: 'JITOSOL-PERP', poolName: 'main-pool', kind: 'perp' },
|
|
148
|
+
GFu3qS22mo6bAjg4Lr5R7L8pPgHq6GvbjJPKEHkbbs2c: { symbol: 'WBTC', market: 'WBTC-PERP', poolName: 'main-pool', kind: 'perp' },
|
|
149
|
+
woVG8fmrUzFJhWa6mRjiYC2qFCY73oAnQeioYK1m1JX: { symbol: 'USDC', market: 'USDC', poolName: 'commodities-pool', kind: 'collateral' },
|
|
150
|
+
JB86ouHXGYgF4UbPs8yxYdaHudrdsintf5EbBfMydzYt: { symbol: 'XAU', market: 'XAU-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
|
|
151
|
+
De21TFyUPHkvFsWAt6xJLBBXGp636VuL5cKk2DvfbHiR: { symbol: 'WTI', market: 'WTI-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
|
|
152
|
+
PexsCkkxpVmY4HNxUjT3U9PEg69kYScc8GukUwn6Q3Q: { symbol: 'XAG', market: 'XAG-PERP', poolName: 'commodities-pool', kind: 'synthetic-perp' },
|
|
153
|
+
};
|
|
53
154
|
/* ═══════════════════════════════════════════════════════════════════
|
|
54
155
|
* Helper: timed fetch
|
|
55
156
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
@@ -62,13 +163,14 @@ const DISC_POSITION = Buffer.from([170, 188, 143, 228, 122, 64, 247, 208]);
|
|
|
62
163
|
*
|
|
63
164
|
* @internal
|
|
64
165
|
*/
|
|
65
|
-
async function timedFetch(url) {
|
|
166
|
+
async function timedFetch(url, options = {}) {
|
|
66
167
|
try {
|
|
67
168
|
const controller = new AbortController();
|
|
68
|
-
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
169
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
69
170
|
const response = await fetch(url, {
|
|
70
|
-
method: 'GET',
|
|
71
|
-
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
method: options.method ?? 'GET',
|
|
172
|
+
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
|
173
|
+
body: options.body,
|
|
72
174
|
signal: controller.signal,
|
|
73
175
|
});
|
|
74
176
|
clearTimeout(timeout);
|
|
@@ -80,6 +182,75 @@ async function timedFetch(url) {
|
|
|
80
182
|
return null;
|
|
81
183
|
}
|
|
82
184
|
}
|
|
185
|
+
function getPerpsConfig(context) {
|
|
186
|
+
return context.config.perps ?? {
|
|
187
|
+
adrenaProgramId: DEFAULT_ADRENA_PROGRAM_ID,
|
|
188
|
+
apiKeyConfigured: Boolean(process.env.SAP_MCP_PERPS_API_KEY?.trim()),
|
|
189
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function getAdrenaProgramId(context) {
|
|
193
|
+
return new PublicKey(getPerpsConfig(context).adrenaProgramId || DEFAULT_ADRENA_PROGRAM_ID);
|
|
194
|
+
}
|
|
195
|
+
function perpsProviderHeaders() {
|
|
196
|
+
const apiKey = process.env.SAP_MCP_PERPS_API_KEY?.trim();
|
|
197
|
+
if (!apiKey) {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
Authorization: `Bearer ${apiKey}`,
|
|
202
|
+
'X-API-Key': apiKey,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function appendQuery(baseUrl, params) {
|
|
206
|
+
const url = new URL(baseUrl);
|
|
207
|
+
for (const [key, value] of Object.entries(params)) {
|
|
208
|
+
if (value) {
|
|
209
|
+
url.searchParams.set(key, value);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return url.toString();
|
|
213
|
+
}
|
|
214
|
+
export function normalizePerpProviderMarkets(payload, marketFilter = '') {
|
|
215
|
+
const records = Array.isArray(payload)
|
|
216
|
+
? payload
|
|
217
|
+
: payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
218
|
+
? (payload.markets ?? payload.data ?? [])
|
|
219
|
+
: [];
|
|
220
|
+
const normalized = records
|
|
221
|
+
.filter((record) => Boolean(record) && typeof record === 'object' && !Array.isArray(record))
|
|
222
|
+
.map((record) => {
|
|
223
|
+
const symbol = String(record.symbol ?? record.market ?? '').toUpperCase();
|
|
224
|
+
return {
|
|
225
|
+
...record,
|
|
226
|
+
symbol,
|
|
227
|
+
market: String(record.market ?? symbol),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
if (!marketFilter) {
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
233
|
+
return normalized.filter((record) => record.symbol === marketFilter || String(record.market).toUpperCase() === marketFilter);
|
|
234
|
+
}
|
|
235
|
+
async function fetchConfiguredPerpMarkets(context, marketFilter) {
|
|
236
|
+
const perps = getPerpsConfig(context);
|
|
237
|
+
if (!perps.marketsUrl) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const url = appendQuery(perps.marketsUrl, { market: marketFilter || undefined });
|
|
241
|
+
const payload = await timedFetch(url, {
|
|
242
|
+
headers: perpsProviderHeaders(),
|
|
243
|
+
timeoutMs: perps.timeoutMs,
|
|
244
|
+
});
|
|
245
|
+
if (!payload) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
markets: normalizePerpProviderMarkets(payload, marketFilter),
|
|
250
|
+
source: Array.isArray(payload) ? 'configured-perps-provider' : payload.source ?? payload.venue ?? 'configured-perps-provider',
|
|
251
|
+
timestamp: Array.isArray(payload) ? Date.now() : payload.timestamp,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
83
254
|
/**
|
|
84
255
|
* @name discToBase58
|
|
85
256
|
* @description Convert an 8-byte discriminator Buffer to a base58 string for
|
|
@@ -90,8 +261,8 @@ async function timedFetch(url) {
|
|
|
90
261
|
*
|
|
91
262
|
* @internal
|
|
92
263
|
*/
|
|
93
|
-
function discToBase58(disc) {
|
|
94
|
-
return
|
|
264
|
+
export function discToBase58(disc) {
|
|
265
|
+
return bs58.encode(disc);
|
|
95
266
|
}
|
|
96
267
|
/**
|
|
97
268
|
* @name readU64LE
|
|
@@ -107,28 +278,262 @@ function readU64LE(buf, offset) {
|
|
|
107
278
|
return Number(buf.readBigUInt64LE(offset));
|
|
108
279
|
}
|
|
109
280
|
/**
|
|
110
|
-
* @name
|
|
111
|
-
* @description Read
|
|
281
|
+
* @name readI64LE
|
|
282
|
+
* @description Read an i64 little-endian value from a Buffer and return as a JS number.
|
|
283
|
+
*
|
|
284
|
+
* @param buf — Source buffer.
|
|
285
|
+
* @param offset — Read offset.
|
|
286
|
+
* @returns The signed value as a JavaScript number.
|
|
287
|
+
*
|
|
288
|
+
* @internal
|
|
289
|
+
*/
|
|
290
|
+
function readI64LE(buf, offset) {
|
|
291
|
+
return Number(buf.readBigInt64LE(offset));
|
|
292
|
+
}
|
|
293
|
+
function readU128Split(buf, offset) {
|
|
294
|
+
const high = buf.readBigUInt64LE(offset);
|
|
295
|
+
const low = buf.readBigUInt64LE(offset + 8);
|
|
296
|
+
return (high << 64n) + low;
|
|
297
|
+
}
|
|
298
|
+
function readPublicKey(buf, offset) {
|
|
299
|
+
return new PublicKey(buf.subarray(offset, offset + 32)).toBase58();
|
|
300
|
+
}
|
|
301
|
+
function scaleNumber(raw, decimals) {
|
|
302
|
+
return Number(raw) / (10 ** decimals);
|
|
303
|
+
}
|
|
304
|
+
function readUsd(buf, offset) {
|
|
305
|
+
return scaleNumber(buf.readBigUInt64LE(offset), ADRENA_USD_DECIMALS);
|
|
306
|
+
}
|
|
307
|
+
function readPrice(buf, offset) {
|
|
308
|
+
return scaleNumber(buf.readBigUInt64LE(offset), ADRENA_PRICE_DECIMALS);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* @name readAdrenaLimitedString
|
|
312
|
+
* @description Read Adrena `LimitedString` (31-byte value + 1-byte length)
|
|
112
313
|
* from an account data buffer.
|
|
113
314
|
*
|
|
114
315
|
* @param buf — Source buffer.
|
|
115
|
-
* @param offset — Offset to the
|
|
116
|
-
* @param maxLen — Maximum bytes to read (default 16).
|
|
316
|
+
* @param offset — Offset to the LimitedString field.
|
|
117
317
|
* @returns Decoded symbol string, or empty string on failure.
|
|
118
318
|
*
|
|
119
319
|
* @internal
|
|
120
320
|
*/
|
|
121
|
-
function
|
|
321
|
+
export function readAdrenaLimitedString(buf, offset) {
|
|
122
322
|
try {
|
|
123
|
-
const len = buf
|
|
124
|
-
if (len === 0 || len >
|
|
323
|
+
const len = buf[offset + 31] ?? 0;
|
|
324
|
+
if (len === 0 || len > 31)
|
|
125
325
|
return '';
|
|
126
|
-
return buf.subarray(offset
|
|
326
|
+
return buf.subarray(offset, offset + len).toString('utf8').replace(/\0/g, '').trim();
|
|
127
327
|
}
|
|
128
328
|
catch {
|
|
129
329
|
return '';
|
|
130
330
|
}
|
|
131
331
|
}
|
|
332
|
+
function inferAdrenaSymbolFromOracle(oracle, fallback) {
|
|
333
|
+
const normalized = oracle.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
334
|
+
for (const candidate of ['JITOSOL', 'WBTC', 'BONK', 'USDC', 'XAU', 'WTI', 'XAG', 'SOL', 'BTC']) {
|
|
335
|
+
if (normalized.includes(candidate)) {
|
|
336
|
+
return candidate;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return fallback;
|
|
340
|
+
}
|
|
341
|
+
function decodeAdrenaPoolAccount(pubkey, data) {
|
|
342
|
+
if (data.length < ADRENA_POOL_OFFSETS.custodies + 32) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (!data.subarray(0, 8).equals(DISC_POOL)) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const poolAddress = pubkey.toBase58();
|
|
349
|
+
const registeredCustodyCount = data[ADRENA_POOL_OFFSETS.registeredCustodyCount] ?? 0;
|
|
350
|
+
const custodies = [];
|
|
351
|
+
const custodyCount = Math.min(registeredCustodyCount, 8);
|
|
352
|
+
for (let i = 0; i < custodyCount; i++) {
|
|
353
|
+
custodies.push(readPublicKey(data, ADRENA_POOL_OFFSETS.custodies + i * 32));
|
|
354
|
+
}
|
|
355
|
+
const manifestPool = ADRENA_MAINNET_POOLS[poolAddress];
|
|
356
|
+
return {
|
|
357
|
+
poolAddress,
|
|
358
|
+
name: readAdrenaLimitedString(data, ADRENA_POOL_OFFSETS.name) || manifestPool?.name || 'unknown-pool',
|
|
359
|
+
type: manifestPool?.type,
|
|
360
|
+
allowTrade: data[ADRENA_POOL_OFFSETS.allowTrade] !== 0,
|
|
361
|
+
allowSwap: data[ADRENA_POOL_OFFSETS.allowSwap] !== 0,
|
|
362
|
+
registeredCustodyCount,
|
|
363
|
+
custodies,
|
|
364
|
+
lpTokenPriceUsd: readUsd(data, ADRENA_POOL_OFFSETS.lpTokenPriceUsd),
|
|
365
|
+
aumUsd: scaleNumber(readU128Split(data, ADRENA_POOL_OFFSETS.aumUsd), ADRENA_USD_DECIMALS),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export function decodeAdrenaCustodyAccount(pubkey, data, pool) {
|
|
369
|
+
if (data.length < ADRENA_CUSTODY_OFFSETS.version + 1) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
if (!data.subarray(0, 8).equals(DISC_CUSTODY)) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const custodyAddress = pubkey.toBase58();
|
|
376
|
+
const manifest = ADRENA_MAINNET_MARKETS_BY_CUSTODY[custodyAddress];
|
|
377
|
+
const poolAddress = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.pool);
|
|
378
|
+
const custodyMint = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.mint);
|
|
379
|
+
const oracle = readAdrenaLimitedString(data, ADRENA_CUSTODY_OFFSETS.oracle);
|
|
380
|
+
const tradeOracle = readAdrenaLimitedString(data, ADRENA_CUSTODY_OFFSETS.tradeOracle);
|
|
381
|
+
const inferredSymbol = inferAdrenaSymbolFromOracle(tradeOracle || oracle, custodyMint.slice(0, 6).toUpperCase());
|
|
382
|
+
const symbol = manifest?.symbol ?? inferredSymbol;
|
|
383
|
+
const isSynthetic = data[ADRENA_CUSTODY_OFFSETS.isSynthetic] !== 0;
|
|
384
|
+
const kind = manifest?.kind ?? (isSynthetic ? 'synthetic-perp' : 'unknown');
|
|
385
|
+
return {
|
|
386
|
+
symbol,
|
|
387
|
+
market: manifest?.market ?? (kind === 'collateral' ? symbol : `${symbol}-PERP`),
|
|
388
|
+
custodyAddress,
|
|
389
|
+
poolAddress,
|
|
390
|
+
poolName: pool?.name ?? manifest?.poolName ?? ADRENA_MAINNET_POOLS[poolAddress]?.name,
|
|
391
|
+
poolType: pool?.type ?? ADRENA_MAINNET_POOLS[poolAddress]?.type,
|
|
392
|
+
custodyMint,
|
|
393
|
+
tokenAccount: readPublicKey(data, ADRENA_CUSTODY_OFFSETS.tokenAccount),
|
|
394
|
+
oracle,
|
|
395
|
+
tradeOracle,
|
|
396
|
+
oracleFeedId: data[ADRENA_CUSTODY_OFFSETS.oracleFeedId] ?? 0,
|
|
397
|
+
tradeOracleFeedId: data[ADRENA_CUSTODY_OFFSETS.tradeOracleFeedId] ?? 0,
|
|
398
|
+
decimals: data[ADRENA_CUSTODY_OFFSETS.decimals] ?? 0,
|
|
399
|
+
allowTrade: data[ADRENA_CUSTODY_OFFSETS.allowTrade] !== 0,
|
|
400
|
+
allowSwap: data[ADRENA_CUSTODY_OFFSETS.allowSwap] !== 0,
|
|
401
|
+
isStable: data[13] !== 0,
|
|
402
|
+
isSynthetic,
|
|
403
|
+
version: data[ADRENA_CUSTODY_OFFSETS.version] ?? 0,
|
|
404
|
+
kind,
|
|
405
|
+
markPrice: null,
|
|
406
|
+
markPriceSource: 'not_in_custody_account',
|
|
407
|
+
maxInitialLeverage: data.readUInt32LE(ADRENA_CUSTODY_OFFSETS.maxInitialLeverage) / (10 ** ADRENA_BPS_DECIMALS),
|
|
408
|
+
maxLeverage: data.readUInt32LE(ADRENA_CUSTODY_OFFSETS.maxLeverage) / (10 ** ADRENA_BPS_DECIMALS),
|
|
409
|
+
maxPositionLockedUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.maxPositionLockedUsd),
|
|
410
|
+
openInterestLong: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsSizeUsd),
|
|
411
|
+
openInterestShort: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsSizeUsd),
|
|
412
|
+
openPositionsLong: readU64LE(data, ADRENA_CUSTODY_OFFSETS.longPositionsOpenCount),
|
|
413
|
+
openPositionsShort: readU64LE(data, ADRENA_CUSTODY_OFFSETS.shortPositionsOpenCount),
|
|
414
|
+
borrowSizeLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsBorrowSizeUsd),
|
|
415
|
+
borrowSizeShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsBorrowSizeUsd),
|
|
416
|
+
collateralLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.longPositionsCollateralUsd),
|
|
417
|
+
collateralShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.shortPositionsCollateralUsd),
|
|
418
|
+
lockedAmountLongRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.longPositionsLockedAmount).toString(),
|
|
419
|
+
lockedAmountShortRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.shortPositionsLockedAmount).toString(),
|
|
420
|
+
tradeOiLongUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.tradeOiLongUsd),
|
|
421
|
+
tradeOiShortUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.tradeOiShortUsd),
|
|
422
|
+
cumulativeOpenPositionUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.volumeOpenPositionUsd),
|
|
423
|
+
assetsRaw: {
|
|
424
|
+
collateral: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsCollateral).toString(),
|
|
425
|
+
owned: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsOwned).toString(),
|
|
426
|
+
locked: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.assetsLocked).toString(),
|
|
427
|
+
},
|
|
428
|
+
borrowRate: {
|
|
429
|
+
currentRateRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.borrowRateCurrent).toString(),
|
|
430
|
+
lastUpdate: readI64LE(data, ADRENA_CUSTODY_OFFSETS.borrowRateLastUpdate),
|
|
431
|
+
},
|
|
432
|
+
funding: {
|
|
433
|
+
currentRateLongToShortRaw: data.readBigInt64LE(ADRENA_CUSTODY_OFFSETS.virtualFundingCurrentLongToShort).toString(),
|
|
434
|
+
lastUpdate: readI64LE(data, ADRENA_CUSTODY_OFFSETS.virtualFundingLastUpdate),
|
|
435
|
+
cumulativeLongToShortRaw: readU128Split(data, ADRENA_CUSTODY_OFFSETS.virtualFundingCumulativeLongToShort).toString(),
|
|
436
|
+
cumulativeShortToLongRaw: readU128Split(data, ADRENA_CUSTODY_OFFSETS.virtualFundingCumulativeShortToLong).toString(),
|
|
437
|
+
maxHourlyFundingRateRaw: data.readBigUInt64LE(ADRENA_CUSTODY_OFFSETS.virtualFundingMaxHourlyRate).toString(),
|
|
438
|
+
minTotalOiUsd: readUsd(data, ADRENA_CUSTODY_OFFSETS.virtualFundingMinTotalOiUsd),
|
|
439
|
+
imbalanceSensitivityBps: data.readUInt16LE(ADRENA_CUSTODY_OFFSETS.virtualFundingImbalanceSensitivityBps),
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function sideFromAdrenaByte(sideByte) {
|
|
444
|
+
if (sideByte === 1)
|
|
445
|
+
return 'long';
|
|
446
|
+
if (sideByte === 2)
|
|
447
|
+
return 'short';
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
export function decodeAdrenaPositionAccount(pubkey, data, marketsByCustody = new Map()) {
|
|
451
|
+
if (data.length < ADRENA_POSITION_OFFSETS.unrealizedFundingReceivedUsd + 8) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (!data.subarray(0, 8).equals(DISC_POSITION)) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const sideRaw = data[ADRENA_POSITION_OFFSETS.side] ?? 0;
|
|
458
|
+
const side = sideFromAdrenaByte(sideRaw);
|
|
459
|
+
if (!side) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
const custodyAddress = readPublicKey(data, ADRENA_POSITION_OFFSETS.custody);
|
|
463
|
+
const market = marketsByCustody.get(custodyAddress)?.market ?? 'unknown';
|
|
464
|
+
const entryPrice = readPrice(data, ADRENA_POSITION_OFFSETS.price);
|
|
465
|
+
const sizeUsd = readUsd(data, ADRENA_POSITION_OFFSETS.sizeUsd);
|
|
466
|
+
const collateralUsd = readUsd(data, ADRENA_POSITION_OFFSETS.collateralUsd);
|
|
467
|
+
const leverage = collateralUsd > 0 ? sizeUsd / collateralUsd : 0;
|
|
468
|
+
const liquidationPrice = leverage > 0
|
|
469
|
+
? side === 'long'
|
|
470
|
+
? entryPrice * (1 - 1 / leverage)
|
|
471
|
+
: entryPrice * (1 + 1 / leverage)
|
|
472
|
+
: 0;
|
|
473
|
+
const takeProfitLimitPrice = readPrice(data, ADRENA_POSITION_OFFSETS.takeProfitLimitPrice);
|
|
474
|
+
const stopLossLimitPrice = readPrice(data, ADRENA_POSITION_OFFSETS.stopLossLimitPrice);
|
|
475
|
+
const stopLossClosePositionPrice = readPrice(data, ADRENA_POSITION_OFFSETS.stopLossClosePositionPrice);
|
|
476
|
+
return {
|
|
477
|
+
positionKey: pubkey.toBase58(),
|
|
478
|
+
market,
|
|
479
|
+
side,
|
|
480
|
+
sideRaw,
|
|
481
|
+
owner: readPublicKey(data, ADRENA_POSITION_OFFSETS.owner),
|
|
482
|
+
poolAddress: readPublicKey(data, ADRENA_POSITION_OFFSETS.pool),
|
|
483
|
+
custodyAddress,
|
|
484
|
+
collateralCustodyAddress: readPublicKey(data, ADRENA_POSITION_OFFSETS.collateralCustody),
|
|
485
|
+
openTime: readI64LE(data, ADRENA_POSITION_OFFSETS.openTime),
|
|
486
|
+
updateTime: readI64LE(data, ADRENA_POSITION_OFFSETS.updateTime),
|
|
487
|
+
entryPrice,
|
|
488
|
+
entryPriceRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.price).toString(),
|
|
489
|
+
size: sizeUsd,
|
|
490
|
+
collateral: collateralUsd,
|
|
491
|
+
borrowSizeUsd: readUsd(data, ADRENA_POSITION_OFFSETS.borrowSizeUsd),
|
|
492
|
+
leverage,
|
|
493
|
+
unrealizedPnl: 0,
|
|
494
|
+
unrealizedInterestUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedInterestUsd),
|
|
495
|
+
unrealizedFundingPaidUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedFundingPaidUsd),
|
|
496
|
+
unrealizedFundingReceivedUsd: readUsd(data, ADRENA_POSITION_OFFSETS.unrealizedFundingReceivedUsd),
|
|
497
|
+
lockedAmountRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.lockedAmount).toString(),
|
|
498
|
+
collateralAmountRaw: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.collateralAmount).toString(),
|
|
499
|
+
liquidationPrice,
|
|
500
|
+
takeProfitLimitPrice: takeProfitLimitPrice > 0 ? takeProfitLimitPrice : null,
|
|
501
|
+
stopLossLimitPrice: stopLossLimitPrice > 0 ? stopLossLimitPrice : null,
|
|
502
|
+
stopLossClosePositionPrice: stopLossClosePositionPrice > 0 ? stopLossClosePositionPrice : null,
|
|
503
|
+
id: data.readBigUInt64LE(ADRENA_POSITION_OFFSETS.id).toString(),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
async function readAdrenaMarketsByCustody(context) {
|
|
507
|
+
const adrenaProgramId = getAdrenaProgramId(context);
|
|
508
|
+
const [poolAccounts, custodyAccounts] = await Promise.all([
|
|
509
|
+
context.connection.getProgramAccounts(adrenaProgramId, {
|
|
510
|
+
filters: [{ memcmp: { offset: 0, bytes: discToBase58(DISC_POOL) } }],
|
|
511
|
+
commitment: 'confirmed',
|
|
512
|
+
}),
|
|
513
|
+
context.connection.getProgramAccounts(adrenaProgramId, {
|
|
514
|
+
filters: [{ memcmp: { offset: 0, bytes: discToBase58(DISC_CUSTODY) } }],
|
|
515
|
+
commitment: 'confirmed',
|
|
516
|
+
}),
|
|
517
|
+
]);
|
|
518
|
+
const pools = new Map();
|
|
519
|
+
for (const { pubkey, account } of poolAccounts) {
|
|
520
|
+
const decoded = decodeAdrenaPoolAccount(pubkey, account.data);
|
|
521
|
+
if (decoded) {
|
|
522
|
+
pools.set(decoded.poolAddress, decoded);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const markets = new Map();
|
|
526
|
+
for (const { pubkey, account } of custodyAccounts) {
|
|
527
|
+
const poolAddress = account.data.length >= ADRENA_CUSTODY_OFFSETS.pool + 32
|
|
528
|
+
? readPublicKey(account.data, ADRENA_CUSTODY_OFFSETS.pool)
|
|
529
|
+
: '';
|
|
530
|
+
const decoded = decodeAdrenaCustodyAccount(pubkey, account.data, pools.get(poolAddress));
|
|
531
|
+
if (decoded) {
|
|
532
|
+
markets.set(decoded.custodyAddress, decoded);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return markets;
|
|
536
|
+
}
|
|
132
537
|
/* ═══════════════════════════════════════════════════════════════════
|
|
133
538
|
* Tool 1: sap_perp_markets
|
|
134
539
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
@@ -157,132 +562,115 @@ function registerPerpMarketsTool(server, context) {
|
|
|
157
562
|
additionalProperties: false,
|
|
158
563
|
};
|
|
159
564
|
registerTool(server, 'sap_perp_markets', {
|
|
160
|
-
description: 'List available perpetual futures markets from
|
|
565
|
+
description: 'List available Adrena perpetual futures markets from Pool/Custody accounts with funding/accounting fields, open interest, leverage caps, and oracle identifiers. Read-only analysis tool. Custody accounts do not expose a ready mark price, so markPrice is null until an oracle/feed decoder or configured Adrena data runtime is available. If markets are empty, treat it as data unavailable, not proof that markets do not exist. SAP MCP exposes execution only when sap_perp_builder_status reports a verified hosted unsigned builder.',
|
|
161
566
|
inputSchema: schema,
|
|
162
567
|
}, async (args) => {
|
|
163
568
|
const marketFilter = typeof args['market'] === 'string' ? args['market'].toUpperCase() : '';
|
|
164
569
|
try {
|
|
570
|
+
const providerMarkets = await fetchConfiguredPerpMarkets(context, marketFilter);
|
|
571
|
+
if (providerMarkets) {
|
|
572
|
+
return createTextResponse(JSON.stringify({
|
|
573
|
+
source: providerMarkets.source,
|
|
574
|
+
markets: providerMarkets.markets,
|
|
575
|
+
count: providerMarkets.markets.length,
|
|
576
|
+
timestamp: providerMarkets.timestamp ?? Date.now(),
|
|
577
|
+
dataAvailability: {
|
|
578
|
+
status: providerMarkets.markets.length > 0 ? 'available' : 'empty_from_provider',
|
|
579
|
+
reason: providerMarkets.markets.length > 0
|
|
580
|
+
? 'Configured perps market provider returned market data.'
|
|
581
|
+
: 'Configured perps market provider responded but returned no matching markets for the requested filter.',
|
|
582
|
+
agentAction: providerMarkets.markets.length > 0
|
|
583
|
+
? 'Use these records for professional planning. Before execution, call sap_perp_builder_status to verify whether a hosted unsigned builder is configured.'
|
|
584
|
+
: 'Do not infer that the venue has no markets. Try a broader market filter or check provider status before planning execution.',
|
|
585
|
+
},
|
|
586
|
+
recommendedNextTools: [
|
|
587
|
+
'sap_perp_builder_status',
|
|
588
|
+
'sap_chart_ohlc',
|
|
589
|
+
'sap_chart_volume_profile',
|
|
590
|
+
'sap_perp_trade_plan',
|
|
591
|
+
],
|
|
592
|
+
executionStatus: getPerpsConfig(context).builderUrl
|
|
593
|
+
? 'hosted_unsigned_builder_configured'
|
|
594
|
+
: 'analysis_only_no_hosted_builder',
|
|
595
|
+
note: 'Market data came from the configured hosted perps provider, not heuristic account decoding. Execution still requires a typed unsigned builder and local finalization.',
|
|
596
|
+
}, null, 2));
|
|
597
|
+
}
|
|
598
|
+
const adrenaProgramId = getAdrenaProgramId(context);
|
|
165
599
|
// Fetch Custody accounts — these contain per-token price, funding rate, and OI.
|
|
166
600
|
// We use memcmp on the first 8 bytes (Anchor account discriminator).
|
|
167
|
-
const custodyAccounts = await context.connection.getProgramAccounts(
|
|
601
|
+
const custodyAccounts = await context.connection.getProgramAccounts(adrenaProgramId, {
|
|
168
602
|
filters: [
|
|
169
603
|
{ memcmp: { offset: 0, bytes: discToBase58(DISC_CUSTODY) } },
|
|
170
604
|
],
|
|
171
605
|
commitment: 'confirmed',
|
|
172
606
|
});
|
|
173
607
|
// Fetch Pool accounts — these contain pool configuration and token info.
|
|
174
|
-
const poolAccounts = await context.connection.getProgramAccounts(
|
|
608
|
+
const poolAccounts = await context.connection.getProgramAccounts(adrenaProgramId, {
|
|
175
609
|
filters: [
|
|
176
610
|
{ memcmp: { offset: 0, bytes: discToBase58(DISC_POOL) } },
|
|
177
611
|
],
|
|
178
612
|
commitment: 'confirmed',
|
|
179
613
|
});
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// ... + pricing data (price u64, fundingRate u64, openInterestLong u64, openInterestShort u64, ...)
|
|
186
|
-
// The exact offsets may vary by IDL version; we attempt common layouts.
|
|
187
|
-
const markets = [];
|
|
188
|
-
for (const { pubkey, account } of custodyAccounts) {
|
|
189
|
-
const data = account.data;
|
|
190
|
-
if (data.length < 8 + 32 + 32 + 4)
|
|
191
|
-
continue;
|
|
192
|
-
// After discriminator(8): pool(32) + custodyMint(32) = offset 72
|
|
193
|
-
const poolPubkey = new PublicKey(data.subarray(8, 8 + 32));
|
|
194
|
-
const custodyMint = new PublicKey(data.subarray(8 + 32, 8 + 64));
|
|
195
|
-
const poolAddress = poolPubkey.toBase58();
|
|
196
|
-
// Try to read symbol at offset 72 (after pool + custodyMint).
|
|
197
|
-
// Anchor String = 4-byte LE length + UTF-8 bytes.
|
|
198
|
-
let symbol = readSymbol(data, 8 + 64, 16);
|
|
199
|
-
// Try alternative offsets if symbol is empty (layout may differ).
|
|
200
|
-
if (!symbol) {
|
|
201
|
-
// Try after pool(32) only — some IDL versions place symbol before custodyMint.
|
|
202
|
-
symbol = readSymbol(data, 8 + 32, 16);
|
|
614
|
+
const poolInfoByAddress = new Map();
|
|
615
|
+
for (const { pubkey, account } of poolAccounts) {
|
|
616
|
+
const decoded = decodeAdrenaPoolAccount(pubkey, account.data);
|
|
617
|
+
if (decoded) {
|
|
618
|
+
poolInfoByAddress.set(decoded.poolAddress, decoded);
|
|
203
619
|
}
|
|
204
|
-
// Read decimals (u8) — typically right after the symbol string.
|
|
205
|
-
// We try offset after symbol, but since symbol length varies, we also
|
|
206
|
-
// try fixed offsets as fallback.
|
|
207
|
-
let decimals = 6;
|
|
208
|
-
const symbolEndOffset = 8 + 64 + 4 + (symbol ? symbol.length : 0);
|
|
209
|
-
if (symbolEndOffset < data.length) {
|
|
210
|
-
const dec = data[symbolEndOffset];
|
|
211
|
-
if (dec > 0 && dec <= 18)
|
|
212
|
-
decimals = dec;
|
|
213
|
-
}
|
|
214
|
-
// Price and funding data: attempt to read from deeper in the account.
|
|
215
|
-
// Common Adrena Custody layout has pricing fields after config section.
|
|
216
|
-
// We try multiple offsets; if reads fail, default to 0.
|
|
217
|
-
// Pricing section typically starts around offset 120-200 depending on symbol length.
|
|
218
|
-
let markPrice = 0;
|
|
219
|
-
let fundingRate = 0;
|
|
220
|
-
let openInterestLong = 0;
|
|
221
|
-
let openInterestShort = 0;
|
|
222
|
-
// Try reading price/funding at various known offsets.
|
|
223
|
-
// Adrena Custody has: ... config fields ... then pricing struct with:
|
|
224
|
-
// price(u64) + fundingRate(u64) + openInterestLong(u64) + openInterestShort(u64)
|
|
225
|
-
// We scan for plausible price data.
|
|
226
|
-
for (const baseOffset of [120, 144, 160, 176, 192, 200, 208, 224, 240]) {
|
|
227
|
-
if (baseOffset + 32 > data.length)
|
|
228
|
-
continue;
|
|
229
|
-
const price = readU64LE(data, baseOffset);
|
|
230
|
-
const funding = readU64LE(data, baseOffset + 8);
|
|
231
|
-
const oiLong = readU64LE(data, baseOffset + 16);
|
|
232
|
-
const oiShort = readU64LE(data, baseOffset + 24);
|
|
233
|
-
// Heuristic: price should be a reasonable value (> 0, not absurdly large)
|
|
234
|
-
// and funding should be small relative to price.
|
|
235
|
-
if (price > 0 && price < 1e15 && Math.abs(funding) < price) {
|
|
236
|
-
markPrice = price;
|
|
237
|
-
fundingRate = funding;
|
|
238
|
-
openInterestLong = oiLong;
|
|
239
|
-
openInterestShort = oiShort;
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
const marketEntry = {
|
|
244
|
-
symbol: symbol || 'UNKNOWN',
|
|
245
|
-
custodyAddress: pubkey.toBase58(),
|
|
246
|
-
poolAddress,
|
|
247
|
-
custodyMint: custodyMint.toBase58(),
|
|
248
|
-
markPrice,
|
|
249
|
-
fundingRate,
|
|
250
|
-
openInterestLong,
|
|
251
|
-
openInterestShort,
|
|
252
|
-
decimals,
|
|
253
|
-
inPool: poolAddresses.has(poolAddress),
|
|
254
|
-
};
|
|
255
|
-
markets.push(marketEntry);
|
|
256
620
|
}
|
|
621
|
+
const markets = custodyAccounts
|
|
622
|
+
.map(({ pubkey, account }) => {
|
|
623
|
+
const poolAddress = account.data.length >= ADRENA_CUSTODY_OFFSETS.pool + 32
|
|
624
|
+
? readPublicKey(account.data, ADRENA_CUSTODY_OFFSETS.pool)
|
|
625
|
+
: '';
|
|
626
|
+
return decodeAdrenaCustodyAccount(pubkey, account.data, poolInfoByAddress.get(poolAddress));
|
|
627
|
+
})
|
|
628
|
+
.filter((market) => Boolean(market));
|
|
257
629
|
// Apply market filter if provided.
|
|
258
630
|
const filtered = marketFilter
|
|
259
|
-
? markets.filter(m => m.symbol.toUpperCase() === marketFilter)
|
|
631
|
+
? markets.filter(m => m.symbol.toUpperCase() === marketFilter || m.market.toUpperCase() === marketFilter)
|
|
260
632
|
: markets;
|
|
261
|
-
const
|
|
633
|
+
const scannedAccounts = custodyAccounts.length + poolAccounts.length;
|
|
634
|
+
const dataAvailability = scannedAccounts === 0
|
|
262
635
|
? {
|
|
263
|
-
status: '
|
|
264
|
-
reason: 'No Adrena Pool/Custody accounts were returned by
|
|
265
|
-
agentAction: 'Do not infer that no perp markets exist and do not retry in a loop. Use sap_chart_ohlc/sap_chart_volume_profile for market context, then stop before execution until
|
|
636
|
+
status: 'rpc_scan_empty',
|
|
637
|
+
reason: 'No configured perps provider is set and no Adrena Pool/Custody accounts were returned by getProgramAccounts using Anchor account discriminators. On an indexed/full-history RPC this usually means the program ID or account type discriminators are stale, not that perps do not exist.',
|
|
638
|
+
agentAction: 'Do not infer that no perp markets exist and do not retry in a loop. Operator should verify ADRENA_PROGRAM_ID and account discriminators against the current deployed program/IDL, or configure SAP_MCP_PERPS_MARKETS_URL with an IDL-backed provider. Use sap_chart_ohlc/sap_chart_volume_profile for market context, then stop before execution until sap_perp_builder_status says a builder is available.',
|
|
266
639
|
}
|
|
267
|
-
:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
640
|
+
: filtered.length === 0
|
|
641
|
+
? {
|
|
642
|
+
status: 'decoded_no_matching_markets',
|
|
643
|
+
reason: 'The configured RPC returned Adrena Pool/Custody accounts, but no decoded market matched the requested filter. This is usually a market-filter issue.',
|
|
644
|
+
agentAction: 'Retry once with an empty market filter to inspect all decoded symbols. If markets decode but markPrice is null, use oracle/chart tools for live price and do not pretend custody contains a spot mark price.',
|
|
645
|
+
}
|
|
646
|
+
: {
|
|
647
|
+
status: 'available',
|
|
648
|
+
reason: 'Adrena account scan returned on-chain accounts from the configured RPC.',
|
|
649
|
+
agentAction: 'Use custodyAddress and poolAddress only for analysis. Call sap_perp_builder_status before any execution attempt.',
|
|
650
|
+
};
|
|
272
651
|
return createTextResponse(JSON.stringify({
|
|
273
652
|
source: 'on-chain-rpc',
|
|
274
653
|
markets: filtered,
|
|
275
654
|
count: filtered.length,
|
|
276
655
|
totalCustodies: custodyAccounts.length,
|
|
277
656
|
totalPools: poolAccounts.length,
|
|
657
|
+
scan: {
|
|
658
|
+
programId: adrenaProgramId.toBase58(),
|
|
659
|
+
custodyDiscriminator: discToBase58(DISC_CUSTODY),
|
|
660
|
+
poolDiscriminator: discToBase58(DISC_POOL),
|
|
661
|
+
},
|
|
278
662
|
dataAvailability,
|
|
279
663
|
recommendedNextTools: [
|
|
664
|
+
'sap_perp_builder_status',
|
|
280
665
|
'sap_chart_ohlc',
|
|
281
666
|
'sap_chart_volume_profile',
|
|
282
667
|
'sap_perp_trade_plan',
|
|
283
668
|
],
|
|
284
|
-
executionStatus:
|
|
285
|
-
|
|
669
|
+
executionStatus: getPerpsConfig(context).builderUrl
|
|
670
|
+
? 'hosted_unsigned_builder_configured'
|
|
671
|
+
: 'analysis_only_no_hosted_builder',
|
|
672
|
+
priceNote: 'Adrena Custody accounts expose market configuration, OI, funding/accounting, and oracle identifiers. They do not expose a ready-to-use mark price. markPrice is null until SAP MCP adds a typed oracle/feed decoder or configured Adrena data runtime.',
|
|
673
|
+
note: 'Data read directly from Solana on-chain Pool/Custody accounts using the official Adrena release/39 ABI layout. Use custodyAddress and poolAddress for analysis only. Do not execute perps unless SAP MCP exposes a typed unsigned builder or a local signer tool for that exact action.',
|
|
286
674
|
}));
|
|
287
675
|
}
|
|
288
676
|
catch (err) {
|
|
@@ -321,7 +709,7 @@ function registerPerpPositionInfoTool(server, context) {
|
|
|
321
709
|
additionalProperties: false,
|
|
322
710
|
};
|
|
323
711
|
registerTool(server, 'sap_perp_position_info', {
|
|
324
|
-
description: 'Read all open perpetual positions on Adrena for a given wallet address. Returns position key, market, side, size, collateral, entry price,
|
|
712
|
+
description: 'Read all open perpetual positions on Adrena for a given wallet address. Returns position key, market, side, size, collateral, entry price, leverage, unrealized PnL, and liquidation price from the on-chain Position account. markPrice mirrors entryPrice until SAP MCP has a live oracle/feed price decoder. Read-only — uses Solana RPC.',
|
|
325
713
|
inputSchema: schema,
|
|
326
714
|
}, async (args) => {
|
|
327
715
|
const walletStr = typeof args['wallet'] === 'string' ? args['wallet'] : '';
|
|
@@ -336,44 +724,39 @@ function registerPerpPositionInfoTool(server, context) {
|
|
|
336
724
|
return createTextResponse(JSON.stringify({ error: 'Invalid wallet address' }), { isError: true });
|
|
337
725
|
}
|
|
338
726
|
try {
|
|
339
|
-
|
|
340
|
-
// Position layout (from IDL, after 8-byte discriminator):
|
|
341
|
-
// owner(32) + pool(32) + custody(32) + side(1) + price(u64) + sizeUsd(u64) + collateralUsd(u64) + ...
|
|
342
|
-
const accounts = await context.connection.getProgramAccounts(ADRENA_PROGRAM_ID, {
|
|
727
|
+
const accounts = await context.connection.getProgramAccounts(getAdrenaProgramId(context), {
|
|
343
728
|
filters: [
|
|
344
729
|
{ memcmp: { offset: 0, bytes: discToBase58(DISC_POSITION) } },
|
|
345
|
-
{ memcmp: { offset:
|
|
730
|
+
{ memcmp: { offset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET, bytes: walletPubkey.toBase58() } },
|
|
346
731
|
],
|
|
347
732
|
commitment: 'confirmed',
|
|
348
733
|
});
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
collateral: collateralUsd,
|
|
366
|
-
entryPrice: price,
|
|
367
|
-
markPrice: price,
|
|
368
|
-
leverage,
|
|
369
|
-
unrealizedPnl,
|
|
370
|
-
liquidationPrice,
|
|
371
|
-
};
|
|
372
|
-
});
|
|
734
|
+
const marketsByCustody = await readAdrenaMarketsByCustody(context);
|
|
735
|
+
const positions = accounts
|
|
736
|
+
.map(({ pubkey, account }) => decodeAdrenaPositionAccount(pubkey, account.data, marketsByCustody))
|
|
737
|
+
.filter((position) => Boolean(position))
|
|
738
|
+
.map((position) => ({
|
|
739
|
+
positionKey: position.positionKey,
|
|
740
|
+
market: position.market,
|
|
741
|
+
side: position.side,
|
|
742
|
+
size: position.size,
|
|
743
|
+
collateral: position.collateral,
|
|
744
|
+
entryPrice: position.entryPrice,
|
|
745
|
+
markPrice: position.entryPrice,
|
|
746
|
+
leverage: position.leverage,
|
|
747
|
+
unrealizedPnl: position.unrealizedPnl,
|
|
748
|
+
liquidationPrice: position.liquidationPrice,
|
|
749
|
+
}));
|
|
373
750
|
return createTextResponse(JSON.stringify({
|
|
374
751
|
wallet: walletStr,
|
|
375
752
|
positions,
|
|
376
753
|
count: positions.length,
|
|
754
|
+
scan: {
|
|
755
|
+
programId: getAdrenaProgramId(context).toBase58(),
|
|
756
|
+
positionDiscriminator: discToBase58(DISC_POSITION),
|
|
757
|
+
ownerMemcmpOffset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
|
|
758
|
+
},
|
|
759
|
+
note: 'Positions are decoded from the official Adrena release/39 Position account layout. markPrice mirrors entryPrice until a live oracle/feed price decoder is available.',
|
|
377
760
|
}));
|
|
378
761
|
}
|
|
379
762
|
catch (err) {
|
|
@@ -474,61 +857,51 @@ function registerPerpFundingHistoryTool(server, context) {
|
|
|
474
857
|
message: 'The provided address is not a Custody account. Use sap_perp_markets to get the correct custodyAddress.',
|
|
475
858
|
}), { isError: true });
|
|
476
859
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
symbol = readSymbol(data, 8 + 32, 16);
|
|
860
|
+
const poolAddress = readPublicKey(data, ADRENA_CUSTODY_OFFSETS.pool);
|
|
861
|
+
const poolsByAddress = new Map();
|
|
862
|
+
const poolInfo = await context.connection.getAccountInfo(new PublicKey(poolAddress), 'confirmed');
|
|
863
|
+
if (poolInfo) {
|
|
864
|
+
const decodedPool = decodeAdrenaPoolAccount(new PublicKey(poolAddress), poolInfo.data);
|
|
865
|
+
if (decodedPool)
|
|
866
|
+
poolsByAddress.set(poolAddress, decodedPool);
|
|
485
867
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
for (const baseOffset of [120, 144, 160, 176, 192, 200, 208, 224, 240, 256]) {
|
|
493
|
-
if (baseOffset + 48 > data.length)
|
|
494
|
-
continue;
|
|
495
|
-
const price = readU64LE(data, baseOffset);
|
|
496
|
-
const funding = readU64LE(data, baseOffset + 8);
|
|
497
|
-
const oiLong = readU64LE(data, baseOffset + 16);
|
|
498
|
-
const oiShort = readU64LE(data, baseOffset + 24);
|
|
499
|
-
const cumFunding = readU64LE(data, baseOffset + 32);
|
|
500
|
-
if (price > 0 && price < 1e15 && Math.abs(funding) < price) {
|
|
501
|
-
markPrice = price;
|
|
502
|
-
fundingRate = funding;
|
|
503
|
-
openInterestLong = oiLong;
|
|
504
|
-
openInterestShort = oiShort;
|
|
505
|
-
cumulativeFunding = cumFunding;
|
|
506
|
-
break;
|
|
507
|
-
}
|
|
868
|
+
const decoded = decodeAdrenaCustodyAccount(custodyPubkey, data, poolsByAddress.get(poolAddress));
|
|
869
|
+
if (!decoded) {
|
|
870
|
+
return createTextResponse(JSON.stringify({
|
|
871
|
+
error: 'Unable to decode Adrena Custody account with release/39 ABI layout',
|
|
872
|
+
custodyAddress: custodyAddressStr,
|
|
873
|
+
}), { isError: true });
|
|
508
874
|
}
|
|
509
875
|
const timestamp = Date.now();
|
|
510
876
|
return createTextResponse(JSON.stringify({
|
|
511
877
|
source: 'on-chain-rpc',
|
|
512
878
|
custodyAddress: custodyAddressStr,
|
|
513
|
-
market: market ||
|
|
514
|
-
symbol: symbol
|
|
515
|
-
poolAddress:
|
|
516
|
-
custodyMint: custodyMint
|
|
879
|
+
market: market || decoded.market,
|
|
880
|
+
symbol: decoded.symbol,
|
|
881
|
+
poolAddress: decoded.poolAddress,
|
|
882
|
+
custodyMint: decoded.custodyMint,
|
|
883
|
+
oracle: decoded.oracle,
|
|
884
|
+
tradeOracle: decoded.tradeOracle,
|
|
517
885
|
currentFunding: {
|
|
518
886
|
timestamp,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
887
|
+
fundingRateRaw: decoded.funding.currentRateLongToShortRaw,
|
|
888
|
+
cumulativeLongToShortRaw: decoded.funding.cumulativeLongToShortRaw,
|
|
889
|
+
cumulativeShortToLongRaw: decoded.funding.cumulativeShortToLongRaw,
|
|
890
|
+
maxHourlyFundingRateRaw: decoded.funding.maxHourlyFundingRateRaw,
|
|
891
|
+
fundingLastUpdate: decoded.funding.lastUpdate,
|
|
892
|
+
markPrice: decoded.markPrice,
|
|
893
|
+
markPriceSource: decoded.markPriceSource,
|
|
894
|
+
openInterestLong: decoded.openInterestLong,
|
|
895
|
+
openInterestShort: decoded.openInterestShort,
|
|
524
896
|
},
|
|
525
897
|
records: [{
|
|
526
898
|
timestamp,
|
|
527
|
-
|
|
528
|
-
|
|
899
|
+
fundingRateRaw: decoded.funding.currentRateLongToShortRaw,
|
|
900
|
+
cumulativeLongToShortRaw: decoded.funding.cumulativeLongToShortRaw,
|
|
901
|
+
cumulativeShortToLongRaw: decoded.funding.cumulativeShortToLongRaw,
|
|
529
902
|
}],
|
|
530
903
|
count: 1,
|
|
531
|
-
note: 'On-chain data provides the current funding snapshot only. Historical time-series funding data is not available via Solana RPC
|
|
904
|
+
note: 'On-chain Adrena Custody data provides the current funding/accounting snapshot only. Historical time-series funding data is not available via Solana RPC; use repeated snapshots or an indexed Adrena data runtime for history.',
|
|
532
905
|
}));
|
|
533
906
|
}
|
|
534
907
|
catch (err) {
|
|
@@ -879,7 +1252,8 @@ function registerChartVolumeProfileTool(server, _context) {
|
|
|
879
1252
|
* @description Register the sap_perp_liquidation_zones read-only tool.
|
|
880
1253
|
*
|
|
881
1254
|
* Reads on-chain Adrena positions for a wallet and computes liquidation
|
|
882
|
-
* zones
|
|
1255
|
+
* zones from account geometry. Live current price requires a separate
|
|
1256
|
+
* oracle/feed decoder.
|
|
883
1257
|
*
|
|
884
1258
|
* @param server — MCP server instance.
|
|
885
1259
|
* @param context — Runtime context with Solana RPC connection.
|
|
@@ -899,7 +1273,7 @@ function registerPerpLiquidationZonesTool(server, context) {
|
|
|
899
1273
|
additionalProperties: false,
|
|
900
1274
|
};
|
|
901
1275
|
registerTool(server, 'sap_perp_liquidation_zones', {
|
|
902
|
-
description: 'Compute liquidation zones for all open Adrena perp positions of a wallet. Reads on-chain positions via Solana RPC and calculates liquidation
|
|
1276
|
+
description: 'Compute liquidation zones for all open Adrena perp positions of a wallet. Reads on-chain positions via Solana RPC and calculates liquidation geometry from entry price, leverage, and liquidation price. currentPrice mirrors entryPrice until SAP MCP has a live oracle/feed price decoder. Read-only.',
|
|
903
1277
|
inputSchema: schema,
|
|
904
1278
|
}, async (args) => {
|
|
905
1279
|
const walletStr = typeof args['wallet'] === 'string' ? args['wallet'] : '';
|
|
@@ -914,45 +1288,42 @@ function registerPerpLiquidationZonesTool(server, context) {
|
|
|
914
1288
|
return createTextResponse(JSON.stringify({ error: 'Invalid wallet address' }), { isError: true });
|
|
915
1289
|
}
|
|
916
1290
|
try {
|
|
917
|
-
const accounts = await context.connection.getProgramAccounts(
|
|
1291
|
+
const accounts = await context.connection.getProgramAccounts(getAdrenaProgramId(context), {
|
|
918
1292
|
filters: [
|
|
919
1293
|
{ memcmp: { offset: 0, bytes: discToBase58(DISC_POSITION) } },
|
|
920
|
-
{ memcmp: { offset:
|
|
1294
|
+
{ memcmp: { offset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET, bytes: walletPubkey.toBase58() } },
|
|
921
1295
|
],
|
|
922
1296
|
commitment: 'confirmed',
|
|
923
1297
|
});
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
// Liquidation price: for long, price drops by 1/leverage; for short, price rises.
|
|
933
|
-
const liquidationPrice = leverage > 0
|
|
934
|
-
? side === 'long'
|
|
935
|
-
? entryPrice * (1 - 1 / leverage)
|
|
936
|
-
: entryPrice * (1 + 1 / leverage)
|
|
937
|
-
: 0;
|
|
938
|
-
const currentPrice = entryPrice;
|
|
939
|
-
const distanceToLiquidationPct = liquidationPrice > 0
|
|
940
|
-
? Math.abs((currentPrice - liquidationPrice) / currentPrice) * 100
|
|
1298
|
+
const marketsByCustody = await readAdrenaMarketsByCustody(context);
|
|
1299
|
+
const zones = accounts
|
|
1300
|
+
.map(({ pubkey, account }) => decodeAdrenaPositionAccount(pubkey, account.data, marketsByCustody))
|
|
1301
|
+
.filter((position) => Boolean(position))
|
|
1302
|
+
.map((position) => {
|
|
1303
|
+
const currentPrice = position.entryPrice;
|
|
1304
|
+
const distanceToLiquidationPct = position.liquidationPrice > 0 && currentPrice > 0
|
|
1305
|
+
? Math.abs((currentPrice - position.liquidationPrice) / currentPrice) * 100
|
|
941
1306
|
: 0;
|
|
942
1307
|
return {
|
|
943
|
-
positionKey:
|
|
944
|
-
market:
|
|
945
|
-
side,
|
|
946
|
-
liquidationPrice,
|
|
1308
|
+
positionKey: position.positionKey,
|
|
1309
|
+
market: position.market,
|
|
1310
|
+
side: position.side,
|
|
1311
|
+
liquidationPrice: position.liquidationPrice,
|
|
947
1312
|
currentPrice,
|
|
948
1313
|
distanceToLiquidationPct,
|
|
949
|
-
leverage,
|
|
1314
|
+
leverage: position.leverage,
|
|
950
1315
|
};
|
|
951
1316
|
});
|
|
952
1317
|
return createTextResponse(JSON.stringify({
|
|
953
1318
|
wallet: walletStr,
|
|
954
1319
|
zones,
|
|
955
1320
|
count: zones.length,
|
|
1321
|
+
scan: {
|
|
1322
|
+
programId: getAdrenaProgramId(context).toBase58(),
|
|
1323
|
+
positionDiscriminator: discToBase58(DISC_POSITION),
|
|
1324
|
+
ownerMemcmpOffset: ADRENA_POSITION_OWNER_MEMCMP_OFFSET,
|
|
1325
|
+
},
|
|
1326
|
+
note: 'Liquidation zones use entryPrice as currentPrice until SAP MCP has a live Adrena oracle/feed price decoder. Treat the output as risk geometry, not a liquidation alert feed.',
|
|
956
1327
|
}));
|
|
957
1328
|
}
|
|
958
1329
|
catch (err) {
|
|
@@ -1120,12 +1491,214 @@ function registerPerpTradePlanTool(server) {
|
|
|
1120
1491
|
}, null, 2));
|
|
1121
1492
|
});
|
|
1122
1493
|
}
|
|
1494
|
+
/* ═══════════════════════════════════════════════════════════════════
|
|
1495
|
+
* Tool 9: sap_perp_builder_status
|
|
1496
|
+
* ═══════════════════════════════════════════════════════════════════ */
|
|
1497
|
+
function registerPerpBuilderStatusTool(server, context) {
|
|
1498
|
+
const schema = {
|
|
1499
|
+
type: 'object',
|
|
1500
|
+
properties: {
|
|
1501
|
+
venue: {
|
|
1502
|
+
type: 'string',
|
|
1503
|
+
description: 'Optional perp venue to check, for example "adrena" or "sap-perps-provider".',
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
additionalProperties: false,
|
|
1507
|
+
};
|
|
1508
|
+
registerTool(server, 'sap_perp_builder_status', {
|
|
1509
|
+
description: 'Free readiness check for perps execution. Returns whether SAP MCP has a configured hosted unsigned transaction builder for perp orders. If builderAvailable is false, agents must stop before execution and must not route direct signer-only perps tools through x402 paid-call replay.',
|
|
1510
|
+
inputSchema: schema,
|
|
1511
|
+
}, async (args) => {
|
|
1512
|
+
const perps = getPerpsConfig(context);
|
|
1513
|
+
const venue = typeof args['venue'] === 'string' ? args['venue'] : 'configured-perps-provider';
|
|
1514
|
+
return createTextResponse(JSON.stringify({
|
|
1515
|
+
venue,
|
|
1516
|
+
marketsProviderConfigured: Boolean(perps.marketsUrl),
|
|
1517
|
+
positionsProviderConfigured: Boolean(perps.positionsUrl),
|
|
1518
|
+
builderAvailable: Boolean(perps.builderUrl),
|
|
1519
|
+
adrenaProgramId: perps.adrenaProgramId,
|
|
1520
|
+
rpcScanMode: 'anchor-discriminator-getProgramAccounts',
|
|
1521
|
+
scanDiscriminators: {
|
|
1522
|
+
pool: discToBase58(DISC_POOL),
|
|
1523
|
+
custody: discToBase58(DISC_CUSTODY),
|
|
1524
|
+
position: discToBase58(DISC_POSITION),
|
|
1525
|
+
},
|
|
1526
|
+
builderMode: perps.builderUrl ? 'hosted_unsigned_transaction_builder' : 'not_configured',
|
|
1527
|
+
nativeAdrenaDecoder: {
|
|
1528
|
+
available: true,
|
|
1529
|
+
source: 'official Adrena release/39 ABI layout',
|
|
1530
|
+
sdkPackage: 'adrena-sdk@beta (types only, no runtime JS — using vendored IDL with @coral-xyz/anchor instead)',
|
|
1531
|
+
},
|
|
1532
|
+
nativeAdrenaBuilder: {
|
|
1533
|
+
available: true,
|
|
1534
|
+
source: 'vendored Adrena IDL (release/39) + @coral-xyz/anchor',
|
|
1535
|
+
operations: [
|
|
1536
|
+
'sap_adrena_build_open_long',
|
|
1537
|
+
'sap_adrena_build_open_short',
|
|
1538
|
+
'sap_adrena_build_close_long',
|
|
1539
|
+
'sap_adrena_build_close_short',
|
|
1540
|
+
'sap_adrena_build_set_stop_loss',
|
|
1541
|
+
'sap_adrena_build_set_take_profit',
|
|
1542
|
+
'sap_adrena_build_cancel_stop_loss',
|
|
1543
|
+
'sap_adrena_build_cancel_take_profit',
|
|
1544
|
+
'sap_adrena_build_add_limit_order',
|
|
1545
|
+
'sap_adrena_build_cancel_limit_order',
|
|
1546
|
+
'sap_adrena_build_open_commodity_long',
|
|
1547
|
+
'sap_adrena_build_open_commodity_short',
|
|
1548
|
+
'sap_adrena_build_close_commodity_long',
|
|
1549
|
+
'sap_adrena_build_close_commodity_short',
|
|
1550
|
+
'sap_adrena_build_add_liquidity',
|
|
1551
|
+
'sap_adrena_build_remove_liquidity',
|
|
1552
|
+
'sap_adrena_build_swap',
|
|
1553
|
+
'sap_adrena_build_init_user_staking',
|
|
1554
|
+
'sap_adrena_build_add_liquid_stake',
|
|
1555
|
+
'sap_adrena_build_remove_liquid_stake',
|
|
1556
|
+
'sap_adrena_build_add_locked_stake',
|
|
1557
|
+
'sap_adrena_build_claim_stakes',
|
|
1558
|
+
],
|
|
1559
|
+
dataApi: [
|
|
1560
|
+
'sap_adrena_get_positions',
|
|
1561
|
+
'sap_adrena_get_pool_info',
|
|
1562
|
+
'sap_adrena_get_custody_info',
|
|
1563
|
+
'sap_adrena_get_trader_info',
|
|
1564
|
+
'sap_adrena_get_trader_leaderboard',
|
|
1565
|
+
'sap_adrena_get_mutagen',
|
|
1566
|
+
'sap_adrena_get_mutagen_leaderboard',
|
|
1567
|
+
'sap_adrena_get_prices',
|
|
1568
|
+
'sap_adrena_get_trading_prices',
|
|
1569
|
+
'sap_adrena_get_position_status',
|
|
1570
|
+
],
|
|
1571
|
+
signerPolicy: 'All builder tools return unsigned base64 transactions. Sign locally via sap_payments_finalize_transaction. SAP MCP never signs user-owned Adrena transactions.',
|
|
1572
|
+
},
|
|
1573
|
+
signerPolicy: 'Use sap_adrena_build_* tools to construct unsigned transactions, then sign locally with sap_payments_finalize_transaction.',
|
|
1574
|
+
paymentPolicy: perps.builderUrl
|
|
1575
|
+
? 'Calling sap_perp_build_order_transaction is a paid builder call; finalization happens locally without exposing keypair bytes.'
|
|
1576
|
+
: 'No x402 payment is required to learn that perps execution is unavailable.',
|
|
1577
|
+
nextAction: perps.builderUrl
|
|
1578
|
+
? 'Use sap_perp_trade_plan, then sap_perp_build_order_transaction, then sap_payments_finalize_transaction with submit:true after user confirmation.'
|
|
1579
|
+
: 'Use sap_perp_trade_plan and chart tools for analysis only. Configure SAP_MCP_PERPS_BUILDER_URL only when the provider returns complete unsigned Solana transactions for local finalization.',
|
|
1580
|
+
}, null, 2));
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
/* ═══════════════════════════════════════════════════════════════════
|
|
1584
|
+
* Optional Tool 10: sap_perp_build_order_transaction
|
|
1585
|
+
* ═══════════════════════════════════════════════════════════════════ */
|
|
1586
|
+
function registerPerpOrderBuilderTool(server, context) {
|
|
1587
|
+
const perps = getPerpsConfig(context);
|
|
1588
|
+
if (!perps.builderUrl) {
|
|
1589
|
+
return false;
|
|
1590
|
+
}
|
|
1591
|
+
const schema = {
|
|
1592
|
+
type: 'object',
|
|
1593
|
+
properties: {
|
|
1594
|
+
venue: {
|
|
1595
|
+
type: 'string',
|
|
1596
|
+
description: 'Perps venue supported by the configured builder, for example "adrena".',
|
|
1597
|
+
},
|
|
1598
|
+
owner: {
|
|
1599
|
+
type: 'string',
|
|
1600
|
+
description: 'User wallet public key that will sign and own the perp order/position.',
|
|
1601
|
+
},
|
|
1602
|
+
market: {
|
|
1603
|
+
type: 'string',
|
|
1604
|
+
description: 'Market symbol or provider market id, for example SOL-PERP.',
|
|
1605
|
+
},
|
|
1606
|
+
side: {
|
|
1607
|
+
type: 'string',
|
|
1608
|
+
description: 'Trade side for the order.',
|
|
1609
|
+
enum: ['long', 'short'],
|
|
1610
|
+
},
|
|
1611
|
+
orderType: {
|
|
1612
|
+
type: 'string',
|
|
1613
|
+
description: 'Order type supported by the provider. Use "market" unless the provider explicitly supports limit/trigger orders.',
|
|
1614
|
+
enum: ['market', 'limit', 'trigger'],
|
|
1615
|
+
},
|
|
1616
|
+
collateralMint: {
|
|
1617
|
+
type: 'string',
|
|
1618
|
+
description: 'Collateral token mint. Use USDC mint for USDC-margined perps unless the provider says otherwise.',
|
|
1619
|
+
},
|
|
1620
|
+
collateralAmount: {
|
|
1621
|
+
type: 'string',
|
|
1622
|
+
description: 'Collateral amount in smallest token units, for example micro-USDC for USDC.',
|
|
1623
|
+
},
|
|
1624
|
+
leverage: {
|
|
1625
|
+
type: 'number',
|
|
1626
|
+
description: 'Requested leverage multiplier after local policy checks.',
|
|
1627
|
+
minimum: 1,
|
|
1628
|
+
maximum: 100,
|
|
1629
|
+
},
|
|
1630
|
+
slippageBps: {
|
|
1631
|
+
type: 'number',
|
|
1632
|
+
description: 'Maximum slippage in basis points. Keep <= 100 unless user explicitly confirms higher risk.',
|
|
1633
|
+
minimum: 0,
|
|
1634
|
+
maximum: 10_000,
|
|
1635
|
+
},
|
|
1636
|
+
reduceOnly: {
|
|
1637
|
+
type: 'boolean',
|
|
1638
|
+
description: 'Set true only for reduce-only close/reduce flows.',
|
|
1639
|
+
},
|
|
1640
|
+
limitPrice: {
|
|
1641
|
+
type: 'number',
|
|
1642
|
+
description: 'Optional limit or trigger price in USD when orderType is limit or trigger.',
|
|
1643
|
+
minimum: 0,
|
|
1644
|
+
},
|
|
1645
|
+
clientOrderId: {
|
|
1646
|
+
type: 'string',
|
|
1647
|
+
description: 'Optional client id for idempotency/audit. Generate once and reuse on retries.',
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
required: ['owner', 'market', 'side', 'orderType', 'collateralMint', 'collateralAmount', 'leverage'],
|
|
1651
|
+
additionalProperties: false,
|
|
1652
|
+
};
|
|
1653
|
+
registerTool(server, 'sap_perp_build_order_transaction', {
|
|
1654
|
+
description: 'Build an unsigned perps order transaction using the configured hosted perps builder. This tool is registered only when SAP_MCP_PERPS_BUILDER_URL is configured. It must return an unsigned Solana transaction for local finalization; SAP MCP never signs user-owned perps transactions on hosted.',
|
|
1655
|
+
inputSchema: schema,
|
|
1656
|
+
}, async (args) => {
|
|
1657
|
+
const payload = await timedFetch(perps.builderUrl, {
|
|
1658
|
+
method: 'POST',
|
|
1659
|
+
headers: perpsProviderHeaders(),
|
|
1660
|
+
body: JSON.stringify({
|
|
1661
|
+
...args,
|
|
1662
|
+
requestedBy: 'sap-mcp-hosted',
|
|
1663
|
+
signerPolicy: 'local-finalization-required',
|
|
1664
|
+
}),
|
|
1665
|
+
timeoutMs: perps.timeoutMs,
|
|
1666
|
+
});
|
|
1667
|
+
const transactionBase64 = payload?.transactionBase64
|
|
1668
|
+
?? payload?.unsignedTransactionBase64
|
|
1669
|
+
?? payload?.transaction;
|
|
1670
|
+
if (!payload || typeof transactionBase64 !== 'string' || transactionBase64.length === 0) {
|
|
1671
|
+
return createTextResponse(JSON.stringify({
|
|
1672
|
+
error: 'perps_builder_invalid_response',
|
|
1673
|
+
message: 'Configured perps builder did not return transactionBase64/unsignedTransactionBase64. Execution stopped before local signing.',
|
|
1674
|
+
expectedNextStep: 'Fix SAP_MCP_PERPS_BUILDER_URL provider response. Do not create temporary signing scripts or hand-roll perps transactions.',
|
|
1675
|
+
}, null, 2), { isError: true });
|
|
1676
|
+
}
|
|
1677
|
+
return createTextResponse(JSON.stringify({
|
|
1678
|
+
success: true,
|
|
1679
|
+
venue: args['venue'] ?? 'configured-perps-provider',
|
|
1680
|
+
transactionBase64,
|
|
1681
|
+
lastValidBlockHeight: payload.lastValidBlockHeight,
|
|
1682
|
+
blockhash: payload.blockhash,
|
|
1683
|
+
feePayer: payload.feePayer,
|
|
1684
|
+
provider: payload.provider ?? 'configured-perps-builder',
|
|
1685
|
+
signerPolicy: 'local-finalization-required',
|
|
1686
|
+
nextTool: 'sap_payments_finalize_transaction',
|
|
1687
|
+
finalizeArgs: {
|
|
1688
|
+
transactionBase64,
|
|
1689
|
+
submit: false,
|
|
1690
|
+
},
|
|
1691
|
+
warnings: payload.warnings,
|
|
1692
|
+
}, null, 2));
|
|
1693
|
+
});
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1123
1696
|
/* ═══════════════════════════════════════════════════════════════════
|
|
1124
1697
|
* Main registration function
|
|
1125
1698
|
* ═══════════════════════════════════════════════════════════════════ */
|
|
1126
1699
|
/**
|
|
1127
1700
|
* @name registerPerpTools
|
|
1128
|
-
* @description Register
|
|
1701
|
+
* @description Register perp trading analytics, planning, and optional execution-builder tools.
|
|
1129
1702
|
*
|
|
1130
1703
|
* @param server — MCP server instance.
|
|
1131
1704
|
* @param context — Shared runtime context with SAP client, connection, and config.
|
|
@@ -1142,6 +1715,8 @@ export function registerPerpTools(server, context) {
|
|
|
1142
1715
|
registerChartVolumeProfileTool(server, context);
|
|
1143
1716
|
registerPerpLiquidationZonesTool(server, context);
|
|
1144
1717
|
registerPerpTradePlanTool(server);
|
|
1145
|
-
|
|
1718
|
+
registerPerpBuilderStatusTool(server, context);
|
|
1719
|
+
const builderRegistered = registerPerpOrderBuilderTool(server, context);
|
|
1720
|
+
logger.debug('Perp tools registered', { count: builderRegistered ? 10 : 9, builderRegistered });
|
|
1146
1721
|
}
|
|
1147
1722
|
//# sourceMappingURL=perp-tools.js.map
|