opindex-agent 0.1.0 → 0.2.1
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/README.md +9 -4
- package/mcp/server.mjs +132 -8
- package/package.json +1 -1
- package/skills/opindex-trading/SKILL.md +150 -19
- package/test/invariants.mjs +23 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ An agent's own Solana wallet, token risk answers it pays for over x402, and
|
|
|
4
4
|
swaps it signs itself.
|
|
5
5
|
|
|
6
6
|
Two ways in. `src/` is a library for an agent that does not speak MCP; `mcp/`
|
|
7
|
-
is the same library behind
|
|
7
|
+
is the same library behind ten tools for a host that does.
|
|
8
8
|
|
|
9
9
|
## Install (as an MCP server)
|
|
10
10
|
|
|
@@ -14,14 +14,19 @@ No configuration is required. Add it to your agent host's MCP config:
|
|
|
14
14
|
{
|
|
15
15
|
"mcpServers": {
|
|
16
16
|
"opindex": {
|
|
17
|
-
"command": "
|
|
18
|
-
"args": ["
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "opindex-agent"]
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
That is the whole installation — `npx` fetches the package on first run. To work
|
|
25
|
+
from a clone instead, point `command` at `node` and `args` at
|
|
26
|
+
`/absolute/path/to/opindex-agent/mcp/server.mjs`, and run `npm install` here first.
|
|
27
|
+
|
|
28
|
+
Then ask your agent to call `agent_wallet`. The key is created on first use, and
|
|
29
|
+
the answer carries the address to fund.
|
|
25
30
|
|
|
26
31
|
The skill in `skills/opindex-trading/SKILL.md` teaches an agent the order to
|
|
27
32
|
use the tools in. Load it the way your host loads skills; without it the tool
|
package/mcp/server.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
33
33
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
34
34
|
import { z } from 'zod';
|
|
35
35
|
import { createHash } from 'node:crypto';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
36
37
|
import { PublicKey, VersionedTransaction } from '@solana/web3.js';
|
|
37
38
|
import {
|
|
38
39
|
createAssociatedTokenAccountIdempotentInstruction,
|
|
@@ -47,9 +48,20 @@ import { wallet } from '../src/wallet.mjs';
|
|
|
47
48
|
import { connection, returnAddress, rpcSource } from '../src/returns.mjs';
|
|
48
49
|
|
|
49
50
|
const USDC = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The version that is actually running, read from the package that shipped it.
|
|
54
|
+
*
|
|
55
|
+
* Hosts install this with `npx` or with `npm install -g`, and the second never
|
|
56
|
+
* updates itself. A stale client looks exactly like a current one from inside
|
|
57
|
+
* a conversation -- fewer tools, older descriptions -- so `agent_wallet` says
|
|
58
|
+
* which one this is, and the person can compare it with npm's latest.
|
|
59
|
+
*/
|
|
60
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
50
61
|
const PUBLIC_RPC = 'https://api.mainnet-beta.solana.com';
|
|
51
62
|
|
|
52
63
|
const mint = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/, 'a base58 Solana mint address');
|
|
64
|
+
const amountUsd = z.number().int().min(1).max(1_000_000);
|
|
53
65
|
const amountBase = z
|
|
54
66
|
.string()
|
|
55
67
|
.regex(/^[1-9][0-9]*$/, 'base units as a decimal string, no leading zero, non-zero');
|
|
@@ -67,11 +79,23 @@ const built = new Map();
|
|
|
67
79
|
const ok = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
|
|
68
80
|
const refuse = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], isError: true });
|
|
69
81
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
82
|
+
/**
|
|
83
|
+
* A paid call to one of the service's HTTP routes, through its MCP pointer.
|
|
84
|
+
*
|
|
85
|
+
* The pointer names the route and nothing else: the service writes the mint
|
|
86
|
+
* into the path and leaves every query parameter out. So `query` is appended
|
|
87
|
+
* here, after the pointer arrives. Without it an `amount_usd` given to
|
|
88
|
+
* `assess_token` was dropped on the way, and the answer silently came back
|
|
89
|
+
* without the exit cost at that size.
|
|
90
|
+
*/
|
|
91
|
+
async function paid(tool, args, { method, body, query } = {}) {
|
|
72
92
|
const pointer = await resolvePointer(tool, args);
|
|
93
|
+
const url = new URL(pointer.url);
|
|
94
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
95
|
+
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
96
|
+
}
|
|
73
97
|
const payingCall = await payingFetch();
|
|
74
|
-
const response = await payingCall(
|
|
98
|
+
const response = await payingCall(url.toString(), {
|
|
75
99
|
method: method ?? pointer.method,
|
|
76
100
|
...(body ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {}),
|
|
77
101
|
});
|
|
@@ -85,7 +109,7 @@ async function paid(tool, args, { method, body } = {}) {
|
|
|
85
109
|
return { status: response.status, body: parsed, paid: response.headers.has('payment-response') };
|
|
86
110
|
}
|
|
87
111
|
|
|
88
|
-
const server = new McpServer({ name: 'opindex', version:
|
|
112
|
+
const server = new McpServer({ name: 'opindex', version: VERSION });
|
|
89
113
|
|
|
90
114
|
// ---------------------------------------------------------------- free tools
|
|
91
115
|
|
|
@@ -98,7 +122,12 @@ server.registerTool(
|
|
|
98
122
|
'Call this first: it tells you the address to ask your human to fund, and whether the agent is able to pay at all. ' +
|
|
99
123
|
'Fund with USDC before SOL — a USDC transfer creates the token account the payment scheme needs, and the scheme ' +
|
|
100
124
|
'cannot create it. Read `return_address` and check it is your human\'s own wallet: if they funded from an exchange, ' +
|
|
101
|
-
'it is the exchange\'s hot wallet and returning funds there can lose them.'
|
|
125
|
+
'it is the exchange\'s hot wallet and returning funds there can lose them. ' +
|
|
126
|
+
'Before the first trade, ask your human for the limits and the risk: money at risk, size of one trade, the loss ' +
|
|
127
|
+
'at which you stop, which levels are acceptable (no_flags < caution < unknown < risky) and whether you may ' +
|
|
128
|
+
'acknowledge them yourself, the most an exit may cost, when to exit, where candidates come from, and whether to ' +
|
|
129
|
+
'confirm each trade. Say them back and keep to them: this client enforces none of them, and the amount funded ' +
|
|
130
|
+
'is the only limit anything enforces.',
|
|
102
131
|
inputSchema: {},
|
|
103
132
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
104
133
|
},
|
|
@@ -131,6 +160,10 @@ server.registerTool(
|
|
|
131
160
|
? `derived from the chain: the first USDC transfer in, ${ret.signature}`
|
|
132
161
|
: ret.why,
|
|
133
162
|
rpc: rpcSource(),
|
|
163
|
+
client_version: VERSION,
|
|
164
|
+
client_update:
|
|
165
|
+
'Compare client_version with `npm view opindex-agent version`. Installed globally (command `opindex-mcp`), ' +
|
|
166
|
+
'it never updates itself: `npm install -g opindex-agent@latest`, then restart the host.',
|
|
134
167
|
note:
|
|
135
168
|
'The agent needs no SOL to pay for answers — the service\'s facilitator pays that network fee. ' +
|
|
136
169
|
'SOL is for the swaps the agent signs itself.',
|
|
@@ -178,11 +211,11 @@ for (const [name, tool, price, freshness] of [
|
|
|
178
211
|
'exit cost and liquidity are what a trade depends on and those are exactly the fields that go stale. ' +
|
|
179
212
|
'The answer carries provenance on every field and `meta.partial` when something could not be measured; ' +
|
|
180
213
|
'an unmeasurable number is null, never a small number.',
|
|
181
|
-
inputSchema: { mint, amount_usd:
|
|
214
|
+
inputSchema: { mint, amount_usd: amountUsd.optional() },
|
|
182
215
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
183
216
|
},
|
|
184
217
|
async ({ mint: m, amount_usd }) => {
|
|
185
|
-
const { status, body, paid: wasPaid } = await paid(tool, { mint: m,
|
|
218
|
+
const { status, body, paid: wasPaid } = await paid(tool, { mint: m }, { query: { amount_usd } });
|
|
186
219
|
if (status !== 200) return refuse({ status, charged: wasPaid, ...body });
|
|
187
220
|
return ok({ ...body, _charged: wasPaid ? price : 'not charged' });
|
|
188
221
|
},
|
|
@@ -204,7 +237,9 @@ server.registerTool(
|
|
|
204
237
|
' not_built — a named reason; no transaction exists.\n' +
|
|
205
238
|
' unassessable — the risk could not be established, so nothing was built and nothing was charged.\n' +
|
|
206
239
|
'The raw transaction is deliberately not returned: `submit_swap` takes the `swap_id` so that no transaction from ' +
|
|
207
|
-
'anywhere else can be submitted through this agent
|
|
240
|
+
'anywhere else can be submitted through this agent.\n' +
|
|
241
|
+
'Build only within the limits your human set (see `agent_wallet`). If none were set, ask for them before ' +
|
|
242
|
+
'building: this costs money on every call, refusals included.',
|
|
208
243
|
inputSchema: {
|
|
209
244
|
input_mint: mint,
|
|
210
245
|
output_mint: mint,
|
|
@@ -236,6 +271,95 @@ server.registerTool(
|
|
|
236
271
|
},
|
|
237
272
|
);
|
|
238
273
|
|
|
274
|
+
// ---------------------------------------------------------- trading data
|
|
275
|
+
//
|
|
276
|
+
// Three routes served from the service's own samples of the tokens it watches.
|
|
277
|
+
// Each answer says how it was sampled and how much of the market it covers;
|
|
278
|
+
// none of them says what to buy.
|
|
279
|
+
const samplesNote =
|
|
280
|
+
'Served from the service\'s own samples of the chain, about once a minute, not from a transaction feed. ' +
|
|
281
|
+
'`404 NO_HISTORY` means not sampled yet: free, ask again in a minute. `404 NOT_WATCHED` is free too; do not loop on ' +
|
|
282
|
+
'it. Data only, not investment advice.';
|
|
283
|
+
|
|
284
|
+
/** One of the three data routes: paid, and a free 404 is said to be free. */
|
|
285
|
+
async function sampled(tool, args, query, price) {
|
|
286
|
+
const { status, body, paid: wasPaid } = await paid(tool, args, { query });
|
|
287
|
+
if (status !== 200) return refuse({ status, charged: wasPaid, ...body });
|
|
288
|
+
return ok({ ...body, _charged: wasPaid ? price : 'not charged' });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
server.registerTool(
|
|
292
|
+
'screen_tokens',
|
|
293
|
+
{
|
|
294
|
+
title: 'Watched tokens that meet your conditions ($0.02, paid from the wallet)',
|
|
295
|
+
description:
|
|
296
|
+
'PAID: $0.02 in USDC from this agent\'s wallet, an empty list included. The tokens this service watches that pass ' +
|
|
297
|
+
'the conditions YOU give, each with why it is included. `meta.coverage` says how many are watched: the list is ' +
|
|
298
|
+
'not the whole market, and there is no ranking by anyone\'s opinion; `sort` is one measurable field. Use your ' +
|
|
299
|
+
'human\'s limits as the conditions: `max_level`, `max_exit_cost_bps` at `amount_usd`, `min_liquidity_usd`. ' +
|
|
300
|
+
'A condition whose value could not be measured is not met. ' +
|
|
301
|
+
samplesNote,
|
|
302
|
+
inputSchema: {
|
|
303
|
+
max_level: z.enum(['no_flags', 'caution']).optional(),
|
|
304
|
+
min_liquidity_usd: z.number().positive().optional(),
|
|
305
|
+
max_exit_cost_bps: z.number().int().nonnegative().optional().describe('needs amount_usd'),
|
|
306
|
+
amount_usd: amountUsd.optional(),
|
|
307
|
+
min_age_hours: z.number().int().nonnegative().optional(),
|
|
308
|
+
quote: z.enum(['sol', 'usdc', 'any']).optional(),
|
|
309
|
+
sort: z
|
|
310
|
+
.enum(['liquidity', 'price_change_1h', 'price_change_24h', 'flow_1h', 'liquidity_change_24h', 'curve_progress'])
|
|
311
|
+
.optional(),
|
|
312
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
313
|
+
cursor: z.string().optional().describe('next_cursor of the previous page, with the same conditions'),
|
|
314
|
+
},
|
|
315
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
316
|
+
},
|
|
317
|
+
async (query) => sampled('screen_tokens', {}, query, '$0.02'),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
server.registerTool(
|
|
321
|
+
'token_history',
|
|
322
|
+
{
|
|
323
|
+
title: 'Sampled price, liquidity and holders of a token ($0.01, paid from the wallet)',
|
|
324
|
+
description:
|
|
325
|
+
'PAID: $0.01 in USDC from this agent\'s wallet. Candles, liquidity, reserve flow and holder concentration of one ' +
|
|
326
|
+
'token over a window, made of samples (`sampling.method: reserve_snapshot`): a minute with no sample has no candle. ' +
|
|
327
|
+
'`reserve_flow` is classed swap_like, deposit_like, withdrawal_like or quiet: what the reserves looked like, ' +
|
|
328
|
+
'never trade volume. A paid call on a token nobody watches puts it on watch. ' +
|
|
329
|
+
samplesNote,
|
|
330
|
+
inputSchema: {
|
|
331
|
+
mint,
|
|
332
|
+
window: z.enum(['1h', '6h', '24h', '7d']).optional(),
|
|
333
|
+
resolution: z.enum(['1m', '5m', '15m', '1h']).optional(),
|
|
334
|
+
pool: mint.optional().describe('one pool of this mint, to read that pool rather than the watched one'),
|
|
335
|
+
},
|
|
336
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
337
|
+
},
|
|
338
|
+
async ({ mint: m, ...query }) => sampled('token_history', { mint: m }, query, '$0.01'),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
server.registerTool(
|
|
342
|
+
'token_indicators',
|
|
343
|
+
{
|
|
344
|
+
title: 'Indicators for one token, and your position in it ($0.01, paid from the wallet)',
|
|
345
|
+
description:
|
|
346
|
+
'PAID: $0.01 in USDC from this agent\'s wallet. Numbers with formulas for one watched token: price and liquidity ' +
|
|
347
|
+
'change, drawdown, volatility, reserve flow, holder and safety changes, and exit cost at `amount_usd`. Give ' +
|
|
348
|
+
'`entry_price_usd` and `position_base_units` together to get your position; it reads nothing of your wallet. ' +
|
|
349
|
+
'Read `safety_changes_24h` first when you hold the token. A number that cannot be computed is null, never zero. ' +
|
|
350
|
+
'This route never puts a token on watch: on `NO_HISTORY` for a token nobody watches, call `token_history` first. ' +
|
|
351
|
+
samplesNote,
|
|
352
|
+
inputSchema: {
|
|
353
|
+
mint,
|
|
354
|
+
amount_usd: amountUsd.optional(),
|
|
355
|
+
entry_price_usd: z.number().positive().optional(),
|
|
356
|
+
position_base_units: amountBase.optional(),
|
|
357
|
+
},
|
|
358
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
359
|
+
},
|
|
360
|
+
async ({ mint: m, ...query }) => sampled('token_indicators', { mint: m }, query, '$0.01'),
|
|
361
|
+
);
|
|
362
|
+
|
|
239
363
|
// ------------------------------------------------------------ signing tools
|
|
240
364
|
|
|
241
365
|
server.registerTool(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opindex-agent",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "An agent's Solana wallet, token risk answers paid for over x402, and swaps it signs itself. Library plus an MCP server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "MONOLITH LABS LTD <info@monolithlabs.uk>",
|
|
@@ -5,29 +5,42 @@ description: Use when trading Solana SPL tokens, or when asked whether a token i
|
|
|
5
5
|
|
|
6
6
|
# Trading Solana tokens through opindex
|
|
7
7
|
|
|
8
|
-
You have a wallet of your own and
|
|
8
|
+
You have a wallet of your own and ten tools. Two are free reads, six cost USDC
|
|
9
9
|
from that wallet, and two move money. This explains the order to use them in
|
|
10
10
|
and the things that will cost your human money if you get them wrong.
|
|
11
11
|
|
|
12
12
|
## What this service does not answer
|
|
13
13
|
|
|
14
14
|
Before anything else, so you do not build a plan on it: **this service does not
|
|
15
|
-
tell you what to
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
15
|
+
tell you what to hold or when to act.** It gives you numbers and says how they
|
|
16
|
+
were measured; the decision is yours.
|
|
17
|
+
|
|
18
|
+
- **Discovery exists only among tokens the service already watches.**
|
|
19
|
+
`screen` lists the watched tokens that pass the conditions *you* give it, and
|
|
20
|
+
`meta.coverage` says how many tokens are watched, how many passed and where
|
|
21
|
+
the set came from. It is not the market: a token missing from a screen may
|
|
22
|
+
simply not be watched. There is no ranking by our opinion; `sort` is one
|
|
23
|
+
measurable field.
|
|
24
|
+
- **History is by samples.** `history` and `indicators` come from readings the
|
|
25
|
+
service takes of a pool about once a minute and of its holders about once an
|
|
26
|
+
hour. `sampling.method` says so: `reserve_snapshot`. A candle is a candle over
|
|
27
|
+
samples. A minute with no sample has no candle. There is no transaction feed
|
|
28
|
+
behind any of it.
|
|
29
|
+
- **Position is by your data.** It does not know what you hold or what you paid.
|
|
30
|
+
Give `indicators` your `entry_price_usd` and `position_base_units` and it will
|
|
31
|
+
compute from those two numbers and nothing else; it reads nothing of your
|
|
32
|
+
wallet.
|
|
33
|
+
|
|
34
|
+
What it is: a filter, a cost model and a record of how a pool's reserves moved.
|
|
35
|
+
It tells you which tokens not to touch, what leaving one costs at the size you
|
|
36
|
+
actually hold, and what has changed since you looked. That is an input to a
|
|
37
|
+
decision and not the decision, and not a source of candidates beyond the watched
|
|
38
|
+
set or of timing beyond the samples.
|
|
28
39
|
|
|
29
40
|
If your human expects you to trade profitably using only this, say so plainly
|
|
30
|
-
before you start rather than after.
|
|
41
|
+
before you start rather than after. Then do not stop there: if they still want
|
|
42
|
+
you to trade, the decision is theirs to delegate, and the next step is agreeing
|
|
43
|
+
the limits with them (below), not declining.
|
|
31
44
|
|
|
32
45
|
## Start with the wallet, before anything else
|
|
33
46
|
|
|
@@ -48,6 +61,38 @@ Two things in its answer matter more than the balances:
|
|
|
48
61
|
You need **no SOL to pay for answers** — the service's facilitator pays that
|
|
49
62
|
network fee. You need SOL only for swaps you sign yourself.
|
|
50
63
|
|
|
64
|
+
## Before the first trade, agree the limits and the risk
|
|
65
|
+
|
|
66
|
+
Funding you is not a decision about what to do with the money. Before your first
|
|
67
|
+
trade, ask your human for the following, and do not trade until they have
|
|
68
|
+
answered. Ask only for what they have not already said. If they say "you
|
|
69
|
+
decide", propose numbers yourself and have them confirm: a limit they confirmed
|
|
70
|
+
is theirs, one you assumed is not.
|
|
71
|
+
|
|
72
|
+
1. **Money at risk.** All of the wallet, or how much of it.
|
|
73
|
+
2. **Size of one trade.** In USD, or as a share of the money at risk.
|
|
74
|
+
3. **When to stop.** The loss on the money at risk after which you stop trading
|
|
75
|
+
and report, instead of trading to win it back.
|
|
76
|
+
4. **Which risk levels are acceptable**, in this service's words: only
|
|
77
|
+
`no_flags`; or `caution` too, and then whether any factor is still off limits
|
|
78
|
+
(`freeze_authority_active`, say); `unknown` and `risky` only if they name
|
|
79
|
+
them. And whether you may acknowledge an allowed level yourself, or must ask
|
|
80
|
+
each time.
|
|
81
|
+
5. **Exit cost.** The most leaving may cost at the trade's size
|
|
82
|
+
(`exit_cost_for_amount`) before you will not enter. `null` is unknown and
|
|
83
|
+
fails this check.
|
|
84
|
+
6. **When to exit a position.** A gain to take, a loss to cut, or a time after
|
|
85
|
+
which you leave anyway.
|
|
86
|
+
7. **Where candidates come from.** Only mints they give you, or also ones you
|
|
87
|
+
find, and from where.
|
|
88
|
+
8. **Confirmation.** Ask before each trade, or trade within these limits and
|
|
89
|
+
report after.
|
|
90
|
+
|
|
91
|
+
Say the answers back as one short list and keep to it. **These limits are yours
|
|
92
|
+
to keep: the client enforces none of them.** The amount funded is the only limit
|
|
93
|
+
anything enforces, so tell your human that too. If a trade would break a limit,
|
|
94
|
+
do not make it; say which limit and ask. Changing a limit is their call.
|
|
95
|
+
|
|
51
96
|
## Never trade on a free answer
|
|
52
97
|
|
|
53
98
|
`token_identity` is free and may be up to a day stale. That is fine for "what is
|
|
@@ -62,15 +107,22 @@ half-cent.
|
|
|
62
107
|
|
|
63
108
|
## Read the verdict properly, not just the level
|
|
64
109
|
|
|
65
|
-
`heuristic.level` is `no_flags`, `caution`, `unknown` or `risky`. **Read
|
|
110
|
+
`heuristic.level` is `no_flags`, `caution`, `unknown` or `risky`; a factor's `severity` is `risky`, `caution` or `info`. **Read
|
|
66
111
|
`heuristic.factors` even when the level is calm.** A trusted issuer gets a quiet
|
|
67
112
|
level while the facts still say what they say: USDC comes back `no_flags` with
|
|
68
113
|
`mint_authority_active` and `freeze_authority_active` both marked risky, because
|
|
69
114
|
its issuer really can mint and really can freeze your account. The level is not
|
|
70
115
|
a promise; the factors are the evidence.
|
|
71
116
|
|
|
72
|
-
|
|
117
|
+
Three more habits worth having:
|
|
73
118
|
|
|
119
|
+
- **`info` is a fact that, for a mature token, does not raise the level.** Read
|
|
120
|
+
it if you hold a position: `lp_not_burned`, `metadata_mutable` and
|
|
121
|
+
`holder_concentration` still say what they say. `heuristic.maturity` shows
|
|
122
|
+
why the token counted as mature (liquidity, age since this service first saw
|
|
123
|
+
it, and who holds its LP). A token not yet mature, including every token in
|
|
124
|
+
the first 30 days of the service seeing it, keeps these as `caution`. The
|
|
125
|
+
default `screen` filter `max_level=no_flags` now lists mature tokens.
|
|
74
126
|
- **`unknown` outranks `caution`.** An unnamed gap can hide anything, so treat
|
|
75
127
|
it as worse than a named mild finding, never better.
|
|
76
128
|
- **A null is not a small number.** `null` means the service could not measure
|
|
@@ -98,10 +150,77 @@ price of the assessment behind it, not a bug. So read the factors before you
|
|
|
98
150
|
re-send: if you are going to decline the trade anyway, decline it now rather
|
|
99
151
|
than paying twice to arrive there.
|
|
100
152
|
|
|
101
|
-
**Before you acknowledge, tell your human what you are accepting
|
|
102
|
-
|
|
153
|
+
**Before you acknowledge, tell your human what you are accepting**, unless they
|
|
154
|
+
already allowed that level in the limits you agreed; then acknowledge and name
|
|
155
|
+
the factors in your report. Either way, name the factors, not the level. "This token's LP is not burned and a transfer hook can
|
|
103
156
|
still be set on it" is a sentence they can judge. "Level: caution" is not.
|
|
104
157
|
|
|
158
|
+
## How to read `*_like`
|
|
159
|
+
|
|
160
|
+
`history.reserve_flow` and `indicators.flow` classify each interval between two
|
|
161
|
+
samples by the signs of how both vaults of the pool moved:
|
|
162
|
+
|
|
163
|
+
- **`swap_like`** — the two moved in opposite directions. At least one swap
|
|
164
|
+
happened in that minute, and its size is a **net**, not a turnover.
|
|
165
|
+
- **`deposit_like`** — neither shrank (both deltas ≥ 0, not both zero). Looks
|
|
166
|
+
like liquidity added.
|
|
167
|
+
- **`withdrawal_like`** — neither grew (both deltas ≤ 0, not both zero). Looks
|
|
168
|
+
like liquidity removed, and may hide swaps.
|
|
169
|
+
- **`quiet`** — neither moved.
|
|
170
|
+
|
|
171
|
+
The suffix is the point. A minute can hold a swap and a deposit together, so the
|
|
172
|
+
class says what the reserves *looked like*, never what happened. The note on the
|
|
173
|
+
field says the same: deltas of the quote vault, classified by the signs of both
|
|
174
|
+
vault deltas, **not trade volume**. Never turn `quote_net` into a count of
|
|
175
|
+
transactions or a size of activity, and never report it as one.
|
|
176
|
+
|
|
177
|
+
A hole in the samples is not a `quiet` pool: across a gap no interval exists.
|
|
178
|
+
`sampling.gaps` counts them, and a number that could not be measured is `null`
|
|
179
|
+
with `meta.partial: true`, never zero.
|
|
180
|
+
|
|
181
|
+
## `404 NO_HISTORY` is free: wait a minute
|
|
182
|
+
|
|
183
|
+
`history` and `indicators` answer `404 NO_HISTORY` when the service has fewer than
|
|
184
|
+
two samples of that token. It is a client error, **it is not charged**, and it
|
|
185
|
+
is not a failure: it is the start of an answer.
|
|
186
|
+
|
|
187
|
+
- `history` on a token nobody watches asks the service to start watching it,
|
|
188
|
+
when the request was a paying one. Ask again in a minute.
|
|
189
|
+
- `indicators` never starts a watch. If it says `NO_HISTORY` for a token that
|
|
190
|
+
nobody watches, call `history` first, then `indicators`.
|
|
191
|
+
- `404 NOT_WATCHED` means the pool you named is not that token's watched pool, or
|
|
192
|
+
its watch has ended. Asking again does not change that; do not loop on it.
|
|
193
|
+
- `404 NOT_WATCHED` from all three routes, `screen` included, with "the watcher
|
|
194
|
+
is not running on this deployment" means that deployment collects nothing.
|
|
195
|
+
It is free; do not retry it there.
|
|
196
|
+
|
|
197
|
+
Do not retry faster than once a minute, and do not read a `NO_HISTORY` as "this
|
|
198
|
+
token has no history": it means "not yet sampled".
|
|
199
|
+
|
|
200
|
+
## Indicators are numbers, not advice
|
|
201
|
+
|
|
202
|
+
Every answer ends `"disclaimer": "Data only, not investment advice."`, and it
|
|
203
|
+
means it. There is no rating, no action field and no ranking by our opinion.
|
|
204
|
+
What there is, is a number with a formula (`docs/API.md`, "The trading-data
|
|
205
|
+
routes") that you can recompute.
|
|
206
|
+
|
|
207
|
+
- Say what the number is and how it was measured when you report it to your
|
|
208
|
+
human: "liquidity fell 18% in one sample interval, which looks like a
|
|
209
|
+
withdrawal", not "the pool is being rugged".
|
|
210
|
+
- `price.change` is over *sampled closes* and is `null` when there is no sample
|
|
211
|
+
of the right age. A `null` is not "no change".
|
|
212
|
+
- `exit.cost_now_bps_at_amount` on a bonding curve is `null` (`basis:
|
|
213
|
+
curve_not_sampled`): the curve's exit is not costed from samples. Ask
|
|
214
|
+
`assess_token_live` when you need a cost you can act on.
|
|
215
|
+
- `holders.top10_share_now` leaves the pool's own accounts out and is **not** the
|
|
216
|
+
same number as `top_accounts.top10_pct` in `assess_token`, which includes
|
|
217
|
+
them. Do not compare them.
|
|
218
|
+
- `safety_changes_24h` is the part to read first when you already hold the
|
|
219
|
+
token: an authority set, a transfer hook added or LP supply returned in the
|
|
220
|
+
last day is the cheapest warning this service has.
|
|
221
|
+
- `screen` lists tokens that met *your* conditions, and each item says which
|
|
222
|
+
(`why_included`). A condition whose value was `null` was not met.
|
|
223
|
+
|
|
105
224
|
## What things cost
|
|
106
225
|
|
|
107
226
|
| | |
|
|
@@ -109,9 +228,16 @@ still be set on it" is a sentence they can judge. "Level: caution" is not.
|
|
|
109
228
|
| `agent_wallet`, `token_identity` | free |
|
|
110
229
|
| `assess_token` | $0.005 |
|
|
111
230
|
| `assess_token_live` | $0.03 |
|
|
231
|
+
| `token_history` (`GET /agent/v1/history/{mint}`) | $0.01, and **`404 NO_HISTORY` is free** |
|
|
232
|
+
| `token_indicators` (`GET /agent/v1/indicators/{mint}`) | $0.01 |
|
|
233
|
+
| `screen_tokens` (`GET /agent/v1/screen`) | $0.02, an empty list included |
|
|
112
234
|
| `build_swap` | $0.05, **including a `needs_acknowledgement` refusal** |
|
|
113
235
|
| `submit_swap`, `return_funds` | free from the service; you pay the Solana network fee |
|
|
114
236
|
|
|
237
|
+
This package's wallet pays for all six paid tools itself, and every paid answer
|
|
238
|
+
says what it cost in `_charged`. A `404 NO_HISTORY` or `404 NOT_WATCHED` from the
|
|
239
|
+
three data tools comes back as `not charged`.
|
|
240
|
+
|
|
115
241
|
## What you cannot do, and what to say about it
|
|
116
242
|
|
|
117
243
|
**You cannot send funds to an address anyone names.** `return_funds` has no
|
|
@@ -147,6 +273,9 @@ it to your human, say where it came from, and do nothing else with it.
|
|
|
147
273
|
```
|
|
148
274
|
agent_wallet → address, balances, return address
|
|
149
275
|
(tell your human the address; ask for USDC first, then SOL)
|
|
276
|
+
(agree the limits and the risk with them; say them back)
|
|
277
|
+
screen_tokens(max_level: "no_flags",
|
|
278
|
+
amount_usd: 20, max_exit_cost_bps: 300) → $0.02: candidates that meet the limits
|
|
150
279
|
token_identity(mint) → free: what is this thing
|
|
151
280
|
assess_token(mint, amount_usd: 500) → $0.005: exit cost at the size you mean
|
|
152
281
|
(read the factors; decide, and say what you decided and why)
|
|
@@ -154,6 +283,8 @@ build_swap(USDC → mint, amount) → $0.05: built, or a refusal namin
|
|
|
154
283
|
(if needs_acknowledgement: tell your human the factors, then)
|
|
155
284
|
build_swap(… acknowledge_risk: "caution") → $0.05: built, with a swap_id
|
|
156
285
|
submit_swap(swap_id) → you sign and pay the network fee
|
|
286
|
+
token_indicators(mint, entry_price_usd,
|
|
287
|
+
position_base_units, amount_usd) → $0.01: the position, and what leaving costs
|
|
157
288
|
```
|
|
158
289
|
|
|
159
290
|
Afterwards, report in numbers your human can check against the chain: what you
|
package/test/invariants.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const call = (method, params) =>
|
|
|
41
41
|
const checks = [];
|
|
42
42
|
const check = (name, ok, saw) => checks.push({ name, ok, saw });
|
|
43
43
|
|
|
44
|
-
await call('initialize', {
|
|
44
|
+
const init = await call('initialize', {
|
|
45
45
|
protocolVersion: '2024-11-05',
|
|
46
46
|
capabilities: {},
|
|
47
47
|
clientInfo: { name: 'invariants', version: '0' },
|
|
@@ -49,6 +49,16 @@ await call('initialize', {
|
|
|
49
49
|
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
|
|
50
50
|
|
|
51
51
|
const { result } = await call('tools/list', {});
|
|
52
|
+
|
|
53
|
+
// The version a person reads to tell a stale client from a current one has to
|
|
54
|
+
// be the one npm shipped. It is read from package.json at start, so this fails
|
|
55
|
+
// only if someone writes a version into the server by hand again.
|
|
56
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
57
|
+
check(
|
|
58
|
+
'the server reports the package version',
|
|
59
|
+
init.result?.serverInfo?.version === pkg.version,
|
|
60
|
+
`${init.result?.serverInfo?.version} vs ${pkg.version}`,
|
|
61
|
+
);
|
|
52
62
|
const tools = result?.tools ?? [];
|
|
53
63
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
54
64
|
const params = (name) => Object.keys(byName.get(name)?.inputSchema?.properties ?? {});
|
|
@@ -61,6 +71,9 @@ const EXPECTED = [
|
|
|
61
71
|
'build_swap',
|
|
62
72
|
'submit_swap',
|
|
63
73
|
'return_funds',
|
|
74
|
+
'screen_tokens',
|
|
75
|
+
'token_history',
|
|
76
|
+
'token_indicators',
|
|
64
77
|
];
|
|
65
78
|
check('every tool is present', EXPECTED.every((n) => byName.has(n)), tools.map((t) => t.name).join(', '));
|
|
66
79
|
|
|
@@ -101,7 +114,7 @@ for (const name of ['submit_swap', 'return_funds']) {
|
|
|
101
114
|
|
|
102
115
|
// The paid tools have to say so in words a model will read, because being
|
|
103
116
|
// charged without warning is the complaint that ends an integration.
|
|
104
|
-
for (const name of ['assess_token', 'assess_token_live', 'build_swap']) {
|
|
117
|
+
for (const name of ['assess_token', 'assess_token_live', 'build_swap', 'screen_tokens', 'token_history', 'token_indicators']) {
|
|
105
118
|
check(`${name} says it is paid`, (byName.get(name)?.description ?? '').includes('PAID'), 'description');
|
|
106
119
|
}
|
|
107
120
|
|
|
@@ -124,7 +137,14 @@ check('the skill mentions every tool', missingFromSkill.length === 0, missingFro
|
|
|
124
137
|
// the skill proves nothing about a document that mentions prices in several
|
|
125
138
|
// places: a wrong price in the table passes while the right one still stands
|
|
126
139
|
// in a worked example below.
|
|
127
|
-
const PRICES = {
|
|
140
|
+
const PRICES = {
|
|
141
|
+
assess_token: '$0.005',
|
|
142
|
+
assess_token_live: '$0.03',
|
|
143
|
+
build_swap: '$0.05',
|
|
144
|
+
screen_tokens: '$0.02',
|
|
145
|
+
token_history: '$0.01',
|
|
146
|
+
token_indicators: '$0.01',
|
|
147
|
+
};
|
|
128
148
|
const expectedPrices = new Set(Object.values(PRICES));
|
|
129
149
|
|
|
130
150
|
for (const [tool, price] of Object.entries(PRICES)) {
|
|
@@ -155,7 +175,6 @@ check(
|
|
|
155
175
|
// starts with `./` — and says so in a warning that prints the normalised path,
|
|
156
176
|
// which is the valid form, so the message points at a string that is fine.
|
|
157
177
|
// That cost one failed publish; it costs nothing to pin.
|
|
158
|
-
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
159
178
|
const bins = Object.entries(pkg.bin ?? {});
|
|
160
179
|
check('the package declares an executable', bins.length > 0, JSON.stringify(pkg.bin));
|
|
161
180
|
for (const [name, target] of bins) {
|