@mysten-incubation/sui-agent-pass 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +423 -4
- package/deployments/README.md +28 -0
- package/deployments/mainnet.json +65 -0
- package/deployments/testnet.json +69 -0
- package/dist/activation.js +287 -0
- package/dist/agent.js +409 -0
- package/dist/chain.js +763 -0
- package/dist/cli.js +245 -0
- package/dist/describe.js +352 -0
- package/dist/domain.js +393 -0
- package/dist/explorer.js +143 -0
- package/dist/float.js +80 -0
- package/dist/mcp.js +180 -0
- package/dist/navi.js +125 -0
- package/dist/owner.js +410 -0
- package/dist/plugin.js +795 -0
- package/dist/server.js +229 -0
- package/dist/signer.js +40 -0
- package/dist/storage.js +180 -0
- package/docs/AGENT.md +207 -0
- package/docs/PLAYBOOK.md +199 -0
- package/package.json +95 -10
- package/web-dist/assets/index-GamQ8vny.js +844 -0
- package/web-dist/assets/index-IgtbvOwM.css +1 -0
- package/web-dist/assets/mount-B6ddx2sS.js +3842 -0
- package/web-dist/deployments/mainnet.json +65 -0
- package/web-dist/deployments/testnet.json +69 -0
- package/web-dist/favicon.svg +10 -0
- package/web-dist/index.html +19 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { select } from '@inquirer/prompts';
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import { clientFor, readStatus } from './chain.js';
|
|
11
|
+
import { cliInvocation, defaultRoot, forgetSession, openBrowser, servePlugin, servePluginHttp, } from './plugin.js';
|
|
12
|
+
import { startServer } from './server.js';
|
|
13
|
+
import { SessionStore } from './storage.js';
|
|
14
|
+
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
15
|
+
/**
|
|
16
|
+
* Picks several things one at a time with arrows and enter, the way a single-choice menu
|
|
17
|
+
* works, instead of a checkbox whose space key many people never find. Each pick leaves the
|
|
18
|
+
* list; the last entry ends the picking. `atLeastOne` refuses to end with nothing picked.
|
|
19
|
+
*/
|
|
20
|
+
async function pickMany(message, choices, done, atLeastOne) {
|
|
21
|
+
const picked = [];
|
|
22
|
+
let left = [...choices];
|
|
23
|
+
while (left.length) {
|
|
24
|
+
const pick = await select({
|
|
25
|
+
message: picked.length ? `${message} (${picked.length} picked so far)` : message,
|
|
26
|
+
choices: [...left, { name: done, value: null }],
|
|
27
|
+
});
|
|
28
|
+
if (pick === null) {
|
|
29
|
+
if (picked.length || !atLeastOne)
|
|
30
|
+
break;
|
|
31
|
+
console.log('Pick at least one.');
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
picked.push(pick);
|
|
35
|
+
left = left.filter((c) => c.value !== pick);
|
|
36
|
+
}
|
|
37
|
+
return picked;
|
|
38
|
+
}
|
|
39
|
+
export async function serve(store, port = 0, open = true) {
|
|
40
|
+
const server = await startServer(store, port);
|
|
41
|
+
console.log(`\nOwner page: ${server.ownerUrl}\nSession address: ${store.state.sessionAddress}\nSession directory: ${store.directory}`);
|
|
42
|
+
console.log(`\nAfter authorization, register the signer with your agent (from the agent's working directory for Claude Code):\n${cliInvocation().map(quote).join(' ')} connect --session ${quote(store.directory)} --client claude-code\nOther clients: --client claude-desktop | cursor | hermes | codex | json\n\nKeep this process running. Ctrl-C stops signing; use the owner page to revoke on chain.`);
|
|
43
|
+
if (open)
|
|
44
|
+
openBrowser(server.ownerUrl);
|
|
45
|
+
const stop = () => {
|
|
46
|
+
void server.close().then(() => process.exit(0));
|
|
47
|
+
};
|
|
48
|
+
process.once('SIGINT', stop);
|
|
49
|
+
process.once('SIGTERM', stop);
|
|
50
|
+
return server;
|
|
51
|
+
}
|
|
52
|
+
const NETWORKS = ['localnet', 'testnet', 'devnet', 'mainnet'];
|
|
53
|
+
/** Load the deployment from an explicit path, or the bundled deployment committed for a network. */
|
|
54
|
+
const program = new Command().name('agent-pass').description('A pass for autonomous agents');
|
|
55
|
+
program
|
|
56
|
+
.command('serve')
|
|
57
|
+
.description("Advanced: run one session's HTTP signer in the foreground, for agents that cannot start a process themselves. The stdio server registered by connect starts on demand and needs no serve.")
|
|
58
|
+
.requiredOption('--session <directory>')
|
|
59
|
+
.option('--port <port>', 'Local port', '0')
|
|
60
|
+
.option('--no-open')
|
|
61
|
+
.action(async (o) => {
|
|
62
|
+
await serve(await SessionStore.open(resolve(o.session)), Number(o.port), o.open);
|
|
63
|
+
});
|
|
64
|
+
program
|
|
65
|
+
.command('status')
|
|
66
|
+
.requiredOption('--session <directory>')
|
|
67
|
+
.action(async (o) => {
|
|
68
|
+
const { state } = await SessionStore.open(resolve(o.session));
|
|
69
|
+
if (!state.active)
|
|
70
|
+
return console.log('Awaiting owner authorization. Run serve to reopen the review page.');
|
|
71
|
+
console.log(JSON.stringify(await readStatus(clientFor(state.deployment), state.deployment, state.active, state.sessionAddress), null, 2));
|
|
72
|
+
});
|
|
73
|
+
program
|
|
74
|
+
.command('forget')
|
|
75
|
+
.description("Retire a session whose pass is over: refuses while the pass is live, returns the key's gas float to the owner, then deletes the directory and its key")
|
|
76
|
+
.requiredOption('--session <directory>')
|
|
77
|
+
.action(async (o) => {
|
|
78
|
+
const { swept } = await forgetSession(resolve(o.session));
|
|
79
|
+
console.log(swept
|
|
80
|
+
? `Returned ${swept.amount} MIST of gas to the owner (${swept.digest}); session deleted.`
|
|
81
|
+
: 'Nothing left to return; session deleted.');
|
|
82
|
+
});
|
|
83
|
+
program
|
|
84
|
+
.command('mcp')
|
|
85
|
+
.description('Stdio MCP for the Claude Code plugin: propose, watch, sign for and revoke passes')
|
|
86
|
+
.option('--root <directory>', 'Sessions and default deployment', defaultRoot())
|
|
87
|
+
.option('--deployment <path>', 'deployment.json used for new passes')
|
|
88
|
+
.option('--no-open', 'Do not open the browser for owner pages')
|
|
89
|
+
.option('--http <port>', 'Serve on a local HTTP endpoint instead of stdio; writes url and bearer token to <root>/mcp-http.json')
|
|
90
|
+
.action(async (o) => {
|
|
91
|
+
const options = {
|
|
92
|
+
root: resolve(o.root),
|
|
93
|
+
deployment: o.deployment,
|
|
94
|
+
openBrowser: o.open,
|
|
95
|
+
};
|
|
96
|
+
if (o.http === undefined)
|
|
97
|
+
return servePlugin(options);
|
|
98
|
+
const { url } = await servePluginHttp(options, Number(o.http));
|
|
99
|
+
console.error(`agent-pass MCP at ${url}; bearer token in ${join(options.root, 'mcp-http.json')}`);
|
|
100
|
+
});
|
|
101
|
+
const CLIENTS = ['claude-code', 'claude-desktop', 'cursor', 'hermes', 'codex', 'json'];
|
|
102
|
+
/** Merges one server entry into a JSON MCP config file, creating it if needed. */
|
|
103
|
+
async function mergeJsonConfig(path, entry) {
|
|
104
|
+
const current = await readFile(path, 'utf8')
|
|
105
|
+
.then((t) => JSON.parse(t))
|
|
106
|
+
.catch(() => ({}));
|
|
107
|
+
current.mcpServers = { ...(current.mcpServers ?? {}), 'agent-pass': entry };
|
|
108
|
+
await mkdir(dirname(path), { recursive: true });
|
|
109
|
+
await writeFile(path, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
|
|
110
|
+
console.log(`Wrote agent-pass into ${path}. Restart the client to load it.`);
|
|
111
|
+
}
|
|
112
|
+
async function claudeMcpAdd(args) {
|
|
113
|
+
const child = spawn('claude', ['mcp', 'add', ...args], { stdio: 'inherit' });
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
child.once('error', reject);
|
|
116
|
+
child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`claude mcp add exited ${code}`)));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Writes the signer into one client's MCP configuration; the stdio form needs no running process. */
|
|
120
|
+
async function registerClient(client, http) {
|
|
121
|
+
const [command, ...args] = cliInvocation();
|
|
122
|
+
const stdio = { command, args: [...args, 'mcp'] };
|
|
123
|
+
const home = homedir();
|
|
124
|
+
switch (client) {
|
|
125
|
+
case 'claude-code':
|
|
126
|
+
if (http)
|
|
127
|
+
await claudeMcpAdd([
|
|
128
|
+
'--transport',
|
|
129
|
+
'http',
|
|
130
|
+
'--scope',
|
|
131
|
+
'local',
|
|
132
|
+
'agent-pass',
|
|
133
|
+
http.url,
|
|
134
|
+
'--header',
|
|
135
|
+
http.headers.Authorization,
|
|
136
|
+
]);
|
|
137
|
+
else
|
|
138
|
+
await claudeMcpAdd([
|
|
139
|
+
'--transport',
|
|
140
|
+
'stdio',
|
|
141
|
+
'--scope',
|
|
142
|
+
'user',
|
|
143
|
+
'agent-pass',
|
|
144
|
+
'--',
|
|
145
|
+
stdio.command,
|
|
146
|
+
...stdio.args,
|
|
147
|
+
]);
|
|
148
|
+
return;
|
|
149
|
+
case 'claude-desktop': {
|
|
150
|
+
if (http)
|
|
151
|
+
throw new Error('Claude Desktop starts MCP servers itself; run connect without --session to register the stdio server');
|
|
152
|
+
const path = process.platform === 'darwin'
|
|
153
|
+
? join(home, 'Library/Application Support/Claude/claude_desktop_config.json')
|
|
154
|
+
: process.platform === 'win32'
|
|
155
|
+
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json')
|
|
156
|
+
: join(home, '.config/Claude/claude_desktop_config.json');
|
|
157
|
+
return mergeJsonConfig(path, stdio);
|
|
158
|
+
}
|
|
159
|
+
case 'cursor':
|
|
160
|
+
return mergeJsonConfig(join(home, '.cursor/mcp.json'), http ?? stdio);
|
|
161
|
+
case 'hermes':
|
|
162
|
+
console.log(`Add to ~/.hermes/config.yaml under mcp_servers:\n\n agent-pass:\n${http
|
|
163
|
+
? ` url: "${http.url}"\n headers:\n Authorization: "${http.headers.Authorization}"`
|
|
164
|
+
: ` command: "${stdio.command}"\n args: ${JSON.stringify(stdio.args)}`}\n\nThen restart Hermes; the tools appear in "hermes tools list".`);
|
|
165
|
+
return;
|
|
166
|
+
case 'codex':
|
|
167
|
+
console.log(`Add to ~/.codex/config.toml:\n\n[mcp_servers.agent-pass]\n${http
|
|
168
|
+
? `url = "${http.url}"\nhttp_headers = { Authorization = "${http.headers.Authorization}" }`
|
|
169
|
+
: `command = "${stdio.command}"\nargs = ${JSON.stringify(stdio.args)}`}\n`);
|
|
170
|
+
return;
|
|
171
|
+
case 'json':
|
|
172
|
+
console.log(JSON.stringify({ mcpServers: { 'agent-pass': http ?? stdio } }, null, 2));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** The agents on this machine, as far as their usual files show; nothing is preselected. */
|
|
176
|
+
async function chooseClients() {
|
|
177
|
+
const home = homedir();
|
|
178
|
+
const found = async (path) => access(path).then(() => ' (found)', () => '');
|
|
179
|
+
const onPath = (bin) => new Promise((done) => {
|
|
180
|
+
const child = spawn('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' });
|
|
181
|
+
child.once('error', () => done(''));
|
|
182
|
+
child.once('exit', (code) => done(code === 0 ? ' (found)' : ''));
|
|
183
|
+
});
|
|
184
|
+
const desktopConfig = process.platform === 'darwin'
|
|
185
|
+
? join(home, 'Library/Application Support/Claude')
|
|
186
|
+
: process.platform === 'win32'
|
|
187
|
+
? join(process.env.APPDATA ?? join(home, 'AppData/Roaming'), 'Claude')
|
|
188
|
+
: join(home, '.config/Claude');
|
|
189
|
+
const [claude, desktop, cursor, hermes, codex] = await Promise.all([
|
|
190
|
+
onPath('claude'),
|
|
191
|
+
found(desktopConfig),
|
|
192
|
+
found(join(home, '.cursor')),
|
|
193
|
+
found(join(home, '.hermes')),
|
|
194
|
+
found(join(home, '.codex')),
|
|
195
|
+
]);
|
|
196
|
+
console.log('Pick one at a time with the arrow keys and enter; add more after.');
|
|
197
|
+
const picked = await pickMany('Which agent may use your passes?', [
|
|
198
|
+
{ name: `Claude Code${claude}`, value: 'claude-code' },
|
|
199
|
+
{ name: `Claude Desktop${desktop}`, value: 'claude-desktop' },
|
|
200
|
+
{ name: `Cursor${cursor}`, value: 'cursor' },
|
|
201
|
+
{ name: `Hermes${hermes} (prints the config to paste)`, value: 'hermes' },
|
|
202
|
+
{ name: `Codex${codex} (prints the config to paste)`, value: 'codex' },
|
|
203
|
+
{ name: 'Something else (prints a generic MCP entry)', value: 'json' },
|
|
204
|
+
], "That's all", false);
|
|
205
|
+
if (picked.length === 0) {
|
|
206
|
+
const finish = await select({
|
|
207
|
+
message: 'No agent picked. Finish without registering any?',
|
|
208
|
+
choices: [
|
|
209
|
+
{ name: 'Go back and pick one', value: false },
|
|
210
|
+
{ name: 'Finish; I will run connect later', value: true },
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
if (!finish)
|
|
214
|
+
return chooseClients();
|
|
215
|
+
}
|
|
216
|
+
return picked;
|
|
217
|
+
}
|
|
218
|
+
program
|
|
219
|
+
.command('connect')
|
|
220
|
+
.description("Register the signer with an agent client. By default the stdio server over every pass on this machine, which the client starts when it needs it. --session registers one session's running HTTP signer (serve) instead.")
|
|
221
|
+
.option('--session <directory>')
|
|
222
|
+
.option('--client <client>', `one of ${CLIENTS.join(', ')}; asked when omitted`)
|
|
223
|
+
.action(async (o) => {
|
|
224
|
+
if (o.client && !CLIENTS.includes(o.client))
|
|
225
|
+
throw new Error(`Unknown client '${o.client}'`);
|
|
226
|
+
let http = null;
|
|
227
|
+
if (o.session) {
|
|
228
|
+
const store = await SessionStore.open(resolve(o.session));
|
|
229
|
+
if (!store.state.active)
|
|
230
|
+
throw new Error('Authorize this session in the wallet first');
|
|
231
|
+
const { mcpUrl } = JSON.parse(await readFile(join(store.directory, 'runtime.json'), 'utf8'));
|
|
232
|
+
http = { url: mcpUrl, headers: { Authorization: `Bearer ${await store.mcpToken()}` } };
|
|
233
|
+
}
|
|
234
|
+
const clients = o.client ? [o.client] : await chooseClients();
|
|
235
|
+
for (const client of clients)
|
|
236
|
+
await registerClient(client, http);
|
|
237
|
+
if (!http)
|
|
238
|
+
console.log('\nDone. Open the agent and tell it what to do with your money; it proposes the pass, you review and sign in your wallet, and it acts within the rules. Restart the agent after upgrading this package so it loads the new server.');
|
|
239
|
+
});
|
|
240
|
+
if (process.argv[1] && /(?:^|\/)cli\.(?:ts|js)$/.test(process.argv[1])) {
|
|
241
|
+
program.parseAsync().catch((error) => {
|
|
242
|
+
console.error(error.message);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
});
|
|
245
|
+
}
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* One rendering of the rules for every surface. The chat, the review page and the status
|
|
5
|
+
* report all print these rows, so the text the owner approves is the text the wallet page
|
|
6
|
+
* shows. Browser-safe: no Node imports.
|
|
7
|
+
*/
|
|
8
|
+
import { bcs } from '@mysten/sui/bcs';
|
|
9
|
+
import { fromBase64, normalizeSuiAddress } from '@mysten/sui/utils';
|
|
10
|
+
import { priceDisplay, sameCoin, showCoin, SUI, } from './domain.js';
|
|
11
|
+
const healthFactor = (ray) => (Number(BigInt(ray) / 10n ** 25n) / 100).toFixed(2);
|
|
12
|
+
/** The reviewed rules, or the installed policies once `live` is read from chain. */
|
|
13
|
+
export function describePolicy(d, p, live = null) {
|
|
14
|
+
const show = (coin, value) => showCoin(d, coin, value);
|
|
15
|
+
const symbol = (coin) => d.coins.find((c) => sameCoin(c.type, coin))?.symbol ?? coin;
|
|
16
|
+
const poolName = (id) => {
|
|
17
|
+
const pool = d.pools.find((x) => x.poolId === id);
|
|
18
|
+
return pool ? (pool.venue === 'deepbook' ? 'DeepBook' : 'Cetus') : id;
|
|
19
|
+
};
|
|
20
|
+
const rows = [
|
|
21
|
+
{
|
|
22
|
+
label: 'Allowed actions',
|
|
23
|
+
value: [
|
|
24
|
+
p.transfers.length && 'Transfers',
|
|
25
|
+
p.swaps.length && 'Swaps',
|
|
26
|
+
p.lending.length &&
|
|
27
|
+
(p.lending.some((l) => l.allowBorrow) ? 'Lending and borrowing' : 'Lending'),
|
|
28
|
+
p.staking && 'Staking',
|
|
29
|
+
]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(' + '),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
for (const t of p.transfers) {
|
|
35
|
+
const l = live?.transfers.find((x) => sameCoin(x.coinType, t.coin));
|
|
36
|
+
rows.push({
|
|
37
|
+
label: `Pay ${symbol(t.coin)} to`,
|
|
38
|
+
value: (l?.recipients ?? t.recipients).map((r) => `${r.name} → ${r.address}`).join('\n'),
|
|
39
|
+
}, {
|
|
40
|
+
label: `${symbol(t.coin)} per transfer · per UTC day`,
|
|
41
|
+
value: `${show(t.coin, l?.perTransfer ?? t.perTransfer)} · ${show(t.coin, l?.daily ?? t.daily)}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
for (const sw of p.swaps) {
|
|
45
|
+
const l = live?.swaps.find((x) => sameCoin(x.coinIn, sw.coinIn) && sameCoin(x.coinOut, sw.coinOut));
|
|
46
|
+
const minPrice = l?.minPrice ?? sw.minPrice;
|
|
47
|
+
rows.push({
|
|
48
|
+
label: `Sell ${symbol(sw.coinIn)} for ${symbol(sw.coinOut)} on`,
|
|
49
|
+
value: (l?.venues ?? sw.pools).map(poolName).join(', '),
|
|
50
|
+
}, {
|
|
51
|
+
label: `${symbol(sw.coinIn)} per trade · per UTC day`,
|
|
52
|
+
value: `${show(sw.coinIn, l?.perTrade ?? sw.perTrade)} · ${show(sw.coinIn, l?.daily ?? sw.daily)}`,
|
|
53
|
+
}, {
|
|
54
|
+
label: 'Lowest price accepted',
|
|
55
|
+
value: `${priceDisplay(d, sw.coinIn, sw.coinOut, minPrice)} ${symbol(sw.coinOut)} per ${symbol(sw.coinIn)}`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
for (const le of p.lending) {
|
|
59
|
+
const l = live?.lending.find((x) => sameCoin(x.coinType, le.coin));
|
|
60
|
+
rows.push({
|
|
61
|
+
label: `NAVI ${le.allowBorrow ? 'borrowing' : 'collateral'} in ${symbol(le.coin)}, per action · per UTC day`,
|
|
62
|
+
value: `${show(le.coin, l?.perAction ?? le.perAction)} · ${show(le.coin, l?.daily ?? le.daily)}`,
|
|
63
|
+
});
|
|
64
|
+
const floor = l ? healthFactor(l.minHealthFactor) : le.minHealthFactor?.toFixed(2);
|
|
65
|
+
if (floor && floor !== '0.00')
|
|
66
|
+
rows.push({ label: `Health factor kept above (${symbol(le.coin)})`, value: floor });
|
|
67
|
+
}
|
|
68
|
+
if (p.staking) {
|
|
69
|
+
const l = live?.staking[0];
|
|
70
|
+
const validators = l?.validators ?? p.staking.validators;
|
|
71
|
+
rows.push({
|
|
72
|
+
label: 'Stake SUI with',
|
|
73
|
+
value: validators.length ? validators.join('\n') : 'Any active validator',
|
|
74
|
+
}, {
|
|
75
|
+
label: 'SUI per stake · per UTC day',
|
|
76
|
+
value: `${show(SUI, l?.perStake ?? p.staking.perStake)} · ${show(SUI, l?.daily ?? p.staking.daily)}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
81
|
+
const utc = new Intl.DateTimeFormat('en-US', {
|
|
82
|
+
dateStyle: 'medium',
|
|
83
|
+
timeStyle: 'short',
|
|
84
|
+
timeZone: 'UTC',
|
|
85
|
+
});
|
|
86
|
+
/** Everything the owner is about to sign, rules first. Coins stay in the owner's address. */
|
|
87
|
+
export function describeSetup(d, setup) {
|
|
88
|
+
return [
|
|
89
|
+
...describePolicy(d, setup.policy),
|
|
90
|
+
{
|
|
91
|
+
label: 'Authorization ends',
|
|
92
|
+
value: `${utc.format(setup.expiresMs)} UTC (${setup.ttlMs / 86_400_000} ${setup.ttlMs === 86_400_000 ? 'day' : 'days'}); you can revoke earlier`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: 'Agent may ever withdraw (lifetime caps)',
|
|
96
|
+
value: Object.entries(setup.allowances)
|
|
97
|
+
.map(([coin, cap]) => showCoin(d, coin, cap))
|
|
98
|
+
.join(' · '),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: 'Gas allowance, lifetime · per UTC day',
|
|
102
|
+
value: `${showCoin(d, SUI, setup.gasCap)} · ${showCoin(d, SUI, setup.gasDaily)}`,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
label: 'Agent gas stipend (outside the rules)',
|
|
106
|
+
value: showCoin(d, SUI, setup.gasAmount),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
export const renderRows = (rows) => rows.map(({ label, value }) => `- ${label}: ${value.replaceAll('\n', '; ')}`).join('\n');
|
|
111
|
+
const OWNER_ONLY = new Set([
|
|
112
|
+
'deposit_object',
|
|
113
|
+
'withdraw_object',
|
|
114
|
+
'navi_open',
|
|
115
|
+
'destroy',
|
|
116
|
+
'issue',
|
|
117
|
+
'rotate_allowance',
|
|
118
|
+
'add_policy',
|
|
119
|
+
'remove_policy',
|
|
120
|
+
'set_agent',
|
|
121
|
+
'revoke_agent',
|
|
122
|
+
]);
|
|
123
|
+
/**
|
|
124
|
+
* Reads the agent's transaction back in the owner's terms: the action it attempted, where
|
|
125
|
+
* value went and, for a refusal, the rule that stopped it.
|
|
126
|
+
*/
|
|
127
|
+
export function describeAttempt(d, sessionAddress, recipients, tx) {
|
|
128
|
+
const { inputs, commands } = tx.transaction;
|
|
129
|
+
const coin = (type, value) => type ? showCoin(d, normalizeType(type), value) : `${value} base units of an unknown coin`;
|
|
130
|
+
const input = (arg) => arg && typeof arg === 'object' && 'Input' in arg
|
|
131
|
+
? inputs[arg.Input]
|
|
132
|
+
: undefined;
|
|
133
|
+
const pure = (arg, kind) => {
|
|
134
|
+
const i = input(arg);
|
|
135
|
+
if (!i || !('Pure' in i) || !i.Pure)
|
|
136
|
+
return null;
|
|
137
|
+
try {
|
|
138
|
+
const bytes = fromBase64(i.Pure.bytes);
|
|
139
|
+
return kind === 'u64'
|
|
140
|
+
? bcs.u64().parse(bytes)
|
|
141
|
+
: kind === 'address'
|
|
142
|
+
? normalizeSuiAddress(bcs.Address.parse(bytes))
|
|
143
|
+
: bcs.string().parse(bytes);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const object = (arg) => {
|
|
150
|
+
const i = input(arg);
|
|
151
|
+
if (!i || !('Object' in i) || !i.Object)
|
|
152
|
+
return null;
|
|
153
|
+
const o = i.Object;
|
|
154
|
+
const id = 'SharedObject' in o
|
|
155
|
+
? o.SharedObject?.objectId
|
|
156
|
+
: 'ImmOrOwnedObject' in o
|
|
157
|
+
? o.ImmOrOwnedObject?.objectId
|
|
158
|
+
: undefined;
|
|
159
|
+
return id ? normalizeSuiAddress(id) : null;
|
|
160
|
+
};
|
|
161
|
+
/** A spend names its amount in a withdrawal input rather than a pure argument. */
|
|
162
|
+
const withdrawal = (arg) => {
|
|
163
|
+
const i = input(arg);
|
|
164
|
+
if (!i || !('FundsWithdrawal' in i) || !i.FundsWithdrawal)
|
|
165
|
+
return null;
|
|
166
|
+
const w = i.FundsWithdrawal;
|
|
167
|
+
return w.reservation?.MaxAmountU64 ?? null;
|
|
168
|
+
};
|
|
169
|
+
const amount0 = (call) => {
|
|
170
|
+
const v = withdrawal(call.arguments[1]);
|
|
171
|
+
return v === null ? 'SUI' : coin(SUI, v);
|
|
172
|
+
};
|
|
173
|
+
const venue = (pool) => {
|
|
174
|
+
const known = d.pools.find((p) => p.poolId === pool);
|
|
175
|
+
return known ? (known.venue === 'deepbook' ? 'DeepBook' : 'Cetus') : 'a pool you did not list';
|
|
176
|
+
};
|
|
177
|
+
// Calls name the package version they ran on: the original or any upgrade of it.
|
|
178
|
+
const ours = (pkg) => [d.packageId, d.publishedAt].some((p) => normalizeSuiAddress(p) === normalizeSuiAddress(pkg));
|
|
179
|
+
const calls = commands.flatMap((c) => 'MoveCall' in c && c.MoveCall ? [c.MoveCall] : []);
|
|
180
|
+
// The entry point is the call into `pass`; helpers such as `staking::stake_key` only build its
|
|
181
|
+
// arguments.
|
|
182
|
+
const main = calls.find((c) => ours(c.package) && c.module === 'pass') ??
|
|
183
|
+
calls.find((c) => ours(c.package)) ??
|
|
184
|
+
calls[0];
|
|
185
|
+
let what = 'Custom transaction';
|
|
186
|
+
let recipient = null;
|
|
187
|
+
if (main && ours(main.package)) {
|
|
188
|
+
const [t0, t1] = main.typeArguments, a = main.arguments;
|
|
189
|
+
// Spending entries take (pass, config, allowance, withdrawal, ...): the amount sits in the
|
|
190
|
+
// withdrawal input at slot 3. navi_withdraw and navi_borrow take no withdrawal.
|
|
191
|
+
const amount = (i, type = t0) => {
|
|
192
|
+
const v = pure(a[i], 'u64') ?? withdrawal(a[i]);
|
|
193
|
+
return v === null ? 'an amount' : coin(type, v);
|
|
194
|
+
};
|
|
195
|
+
switch (main.function) {
|
|
196
|
+
case 'transfer':
|
|
197
|
+
recipient = pure(a[a.length - 2], 'string');
|
|
198
|
+
what = `Pay ${amount(3)} to ${recipient ? `"${recipient}"` : 'a recipient'}`;
|
|
199
|
+
break;
|
|
200
|
+
case 'deepbook_base_to_quote':
|
|
201
|
+
what = `Sell ${amount(3)} on ${venue(object(a[4]))}, accepting at least ${amount(5, t1)}`;
|
|
202
|
+
break;
|
|
203
|
+
case 'deepbook_quote_to_base':
|
|
204
|
+
what = `Sell ${amount(3, t1)} on ${venue(object(a[4]))}, accepting at least ${amount(5)}`;
|
|
205
|
+
break;
|
|
206
|
+
case 'cetus_a_to_b':
|
|
207
|
+
what = `Sell ${amount(3)} on ${venue(object(a[5]))}, accepting at least ${amount(6, t1)}`;
|
|
208
|
+
break;
|
|
209
|
+
case 'cetus_b_to_a':
|
|
210
|
+
what = `Sell ${amount(3, t1)} on ${venue(object(a[5]))}, accepting at least ${amount(6)}`;
|
|
211
|
+
break;
|
|
212
|
+
case 'navi_supply':
|
|
213
|
+
what = `Supply ${amount(3)} to NAVI`;
|
|
214
|
+
break;
|
|
215
|
+
case 'navi_repay':
|
|
216
|
+
what = `Repay ${amount(3)} to NAVI`;
|
|
217
|
+
break;
|
|
218
|
+
case 'navi_withdraw':
|
|
219
|
+
what = `Withdraw ${amount(7)} from NAVI`;
|
|
220
|
+
break;
|
|
221
|
+
case 'navi_borrow':
|
|
222
|
+
what = `Borrow ${amount(7)} from NAVI`;
|
|
223
|
+
break;
|
|
224
|
+
case 'stake': {
|
|
225
|
+
const validator = pure(a[5], 'address');
|
|
226
|
+
// `stake` has no type argument: it is always SUI.
|
|
227
|
+
what = `Stake ${amount(3, SUI)} with ${validator ? `validator ${short(validator)}` : 'a validator'}`;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'unstake': {
|
|
231
|
+
const stake = pure(a[3], 'address');
|
|
232
|
+
what = `Unstake ${stake ? short(stake) : 'a stake'} back to your balance`;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
what = OWNER_ONLY.has(main.function)
|
|
237
|
+
? `Call the owner-only ${main.module}::${main.function}`
|
|
238
|
+
: `Call ${main.module}::${main.function}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else if (main &&
|
|
242
|
+
normalizeSuiAddress(main.package) === normalizeSuiAddress('0x2') &&
|
|
243
|
+
main.module === 'allowance' &&
|
|
244
|
+
main.function === 'balance_spend') {
|
|
245
|
+
what = `Top up the agent's gas with ${amount0(main)}`;
|
|
246
|
+
}
|
|
247
|
+
else if (main) {
|
|
248
|
+
what = `Call ${main.module}::${main.function}`;
|
|
249
|
+
}
|
|
250
|
+
const names = new Map(recipients.map((r) => [normalizeSuiAddress(r.address), r.name]));
|
|
251
|
+
const self = normalizeSuiAddress(sessionAddress);
|
|
252
|
+
const moved = tx.status.success
|
|
253
|
+
? tx.balanceChanges
|
|
254
|
+
.filter((c) => BigInt(c.amount) > 0n && normalizeSuiAddress(c.address) !== self)
|
|
255
|
+
.map((c) => `${coin(c.coinType, c.amount)} → ${names.get(normalizeSuiAddress(c.address)) ?? short(c.address)}`)
|
|
256
|
+
: [];
|
|
257
|
+
return {
|
|
258
|
+
digest: tx.digest,
|
|
259
|
+
timestampMs: tx.timestampMs,
|
|
260
|
+
success: tx.status.success,
|
|
261
|
+
what,
|
|
262
|
+
why: tx.status.success ? null : describeRefusal(d, tx.status.error, recipient),
|
|
263
|
+
moved,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const short = (address) => `${address.slice(0, 8)}…${address.slice(-6)}`;
|
|
267
|
+
const normalizeType = (type) => type.replace(/^0x0*([0-9a-f]+)::/i, (_, a) => `${normalizeSuiAddress(a)}::`);
|
|
268
|
+
/** The owner's rule behind an abort, from the codes the policy modules define. */
|
|
269
|
+
export function describeRefusal(d, error, recipient) {
|
|
270
|
+
const abort = error.MoveAbort;
|
|
271
|
+
if (!abort?.location?.module)
|
|
272
|
+
return error.message;
|
|
273
|
+
const code = Number(abort.cleverError?.errorCode ?? abort.abortCode);
|
|
274
|
+
const where = `${abort.location.module} abort ${code}`;
|
|
275
|
+
const pkg = normalizeSuiAddress(abort.location.package ?? '0x0');
|
|
276
|
+
// The framework and the system package have their own reasons; anything else is a protocol.
|
|
277
|
+
const system = {
|
|
278
|
+
allowance: {
|
|
279
|
+
3: "Over the lifetime cap of the owner's allowance for this coin (checked before the daily limit, so when the two are equal the day is used up as well)",
|
|
280
|
+
4: "Over the allowance's daily window",
|
|
281
|
+
},
|
|
282
|
+
validator_set: { 10: 'Below the 1 SUI minimum stake', 0: 'Not an active validator' },
|
|
283
|
+
};
|
|
284
|
+
if (pkg === normalizeSuiAddress('0x2') || pkg === normalizeSuiAddress('0x3')) {
|
|
285
|
+
const reason = system[abort.location.module]?.[code];
|
|
286
|
+
return reason ? `${reason} (${where})` : `Refused in ${where}`;
|
|
287
|
+
}
|
|
288
|
+
// A protocol's own refusal is not one of the owner's rules; say so, and name the ones an agent
|
|
289
|
+
// meets on NAVI (lending_core/sources/error.move).
|
|
290
|
+
if (pkg !== normalizeSuiAddress(d.packageId)) {
|
|
291
|
+
const navi = {
|
|
292
|
+
1500: 'NAVI is paused',
|
|
293
|
+
1502: 'NAVI has no fresh oracle price for this asset; the price must be refreshed in the same transaction',
|
|
294
|
+
1503: 'NAVI rejected the amount',
|
|
295
|
+
1506: 'NAVI pool has insufficient balance',
|
|
296
|
+
1508: 'NAVI no longer accepts this entry point; the pass must call its current one',
|
|
297
|
+
1600: 'NAVI finds the position unhealthy',
|
|
298
|
+
1601: 'the position holds no collateral',
|
|
299
|
+
1602: 'the position has no debt to repay',
|
|
300
|
+
1603: 'the collateral does not cover that borrow (loan-to-value)',
|
|
301
|
+
1604: "NAVI's deposit cap for this asset is reached",
|
|
302
|
+
1605: "NAVI's borrow cap for this asset is reached",
|
|
303
|
+
};
|
|
304
|
+
const known = navi[code];
|
|
305
|
+
return known
|
|
306
|
+
? `Refused by NAVI, not by your rules: ${known} (${where})`
|
|
307
|
+
: `Refused inside the protocol, not by your rules (${where}): ${error.message}`;
|
|
308
|
+
}
|
|
309
|
+
const reasons = {
|
|
310
|
+
transfer_policy: {
|
|
311
|
+
0: `${recipient ? `"${recipient}"` : 'That recipient'} is not a recipient you named`,
|
|
312
|
+
1: 'Over your per-transfer limit',
|
|
313
|
+
2: 'Zero amount',
|
|
314
|
+
},
|
|
315
|
+
meter: { 0: 'Over your daily limit' },
|
|
316
|
+
swap_policy: {
|
|
317
|
+
0: 'Not a pool you listed',
|
|
318
|
+
1: 'Over your per-trade limit',
|
|
319
|
+
3: "Below the agent's own minimum output",
|
|
320
|
+
4: 'Below your price floor',
|
|
321
|
+
8: 'The venue filled nothing (order under its minimum size)',
|
|
322
|
+
},
|
|
323
|
+
lending_policy: {
|
|
324
|
+
0: 'Not the NAVI pool or asset you chose',
|
|
325
|
+
1: 'Borrowing is not allowed',
|
|
326
|
+
3: 'Over your per-action limit',
|
|
327
|
+
},
|
|
328
|
+
navi: {
|
|
329
|
+
0: 'Your health-factor floor would be breached',
|
|
330
|
+
2: 'The NAVI account is not in the pass; you reclaimed it',
|
|
331
|
+
},
|
|
332
|
+
staking_policy: {
|
|
333
|
+
0: 'Not a validator you listed',
|
|
334
|
+
1: 'Over your per-stake limit',
|
|
335
|
+
2: 'Zero amount',
|
|
336
|
+
},
|
|
337
|
+
staking: { 0: 'No such stake is held by the pass' },
|
|
338
|
+
pass: {
|
|
339
|
+
1: 'Not an allowance issued for this pass',
|
|
340
|
+
2: 'No such object is held by the pass',
|
|
341
|
+
7: 'This entry was retired by a package upgrade; use the current SDK',
|
|
342
|
+
},
|
|
343
|
+
control: {
|
|
344
|
+
0: 'Only your wallet can do this',
|
|
345
|
+
1: 'This key is not your agent',
|
|
346
|
+
2: "Your agent's access was revoked or has expired",
|
|
347
|
+
},
|
|
348
|
+
policies: { 0: 'No rule allows this coin or action' },
|
|
349
|
+
};
|
|
350
|
+
const reason = reasons[abort.location.module]?.[code];
|
|
351
|
+
return reason ? `${reason} (${where})` : `Refused in ${where}`;
|
|
352
|
+
}
|