@mysten-incubation/sui-agent-pass 0.0.0 → 0.1.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/dist/agent.js ADDED
@@ -0,0 +1,409 @@
1
+ // Copyright (c) Mysten Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * The agent SDK: construction against the pass, signing through the session signer, submission
5
+ * and the persisted outcome. Importable as `@mysten-incubation/sui-agent-pass/agent`; never imported by the
6
+ * signing service.
7
+ */
8
+ import { readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
12
+ import { Inputs, Transaction } from '@mysten/sui/transactions';
13
+ import { fromBase64, normalizeStructTag, normalizeSuiAddress, toBase64 } from '@mysten/sui/utils';
14
+ import { activeValidators, clientFor, quoteSwap, readAllowance, readControl } from './chain.js';
15
+ import { describeRefusal } from './describe.js';
16
+ import { poolsFor, sameCoin, SUI, } from './domain.js';
17
+ import { refreshNaviPrices } from './navi.js';
18
+ const CLOCK = '0x6';
19
+ const SUI_SYSTEM = '0x5';
20
+ /**
21
+ * The session's own gas float only has to cover the next transaction, whose budget the build
22
+ * estimates. The float is refilled from the gas allowance when it falls under the floor, by as
23
+ * much as the allowance can still give up to one top-up, so a small gas allowance still works;
24
+ * a transaction costs well under 0.01 SUI on mainnet.
25
+ */
26
+ const GAS_FLOOR = 10000000n;
27
+ const GAS_TOPUP = 50000000n;
28
+ /** Below this the next transaction may not even pay for itself. */
29
+ const GAS_RESERVE = 2000000n;
30
+ /** A build without simulation has nothing to estimate its budget; this covers any pass action. */
31
+ const PROVE_BUDGET = 20_000_000;
32
+ export class Agent {
33
+ mcp;
34
+ identity;
35
+ client;
36
+ constructor(mcp, identity) {
37
+ this.mcp = mcp;
38
+ this.identity = identity;
39
+ this.client = clientFor(identity.deployment);
40
+ }
41
+ /** Connects through the session directory `session_info` names: its signer URL and token. */
42
+ static async open(directory) {
43
+ const [runtime, token] = await Promise.all([
44
+ readFile(join(directory, 'runtime.json'), 'utf8').then(JSON.parse),
45
+ readFile(join(directory, 'mcp.token'), 'utf8'),
46
+ ]);
47
+ return Agent.connect(runtime.mcpUrl, token.trim());
48
+ }
49
+ static async connect(url, bearer) {
50
+ const client = new Client({ name: 'independent-agent', version: '0.1.0' });
51
+ await client.connect(new StreamableHTTPClientTransport(new URL(url), {
52
+ requestInit: { headers: { Authorization: `Bearer ${bearer}` } },
53
+ }));
54
+ const info = await client.callTool({ name: 'session_info', arguments: {} });
55
+ if (info.isError)
56
+ throw new Error(JSON.stringify(info));
57
+ return new Agent(client, JSON.parse(info.content[0].text));
58
+ }
59
+ async submit(tx, options = {}) {
60
+ await this.ensureGas();
61
+ return this.execute(tx, options);
62
+ }
63
+ async close() {
64
+ await this.mcp.close();
65
+ }
66
+ /** Tops up the session's own gas float from the gas allowance before it runs dry. A refused
67
+ * refill (cap or window hit, owner balance empty) is not fatal while the float still covers one
68
+ * budget: it is reported and the action proceeds on what is left. */
69
+ async float() {
70
+ const { sessionAddress, deployment } = this.identity;
71
+ const { balance } = await this.client.getBalance({ owner: sessionAddress, coinType: SUI });
72
+ return BigInt(balance.addressBalance);
73
+ }
74
+ async ensureGas() {
75
+ const float = await this.float();
76
+ if (float >= GAS_FLOOR)
77
+ return;
78
+ const { deployment, active } = this.identity;
79
+ // What the gas allowance can still give: under its lifetime cap and its current window.
80
+ const view = await readAllowance(this.client, active.gasAllowance, SUI);
81
+ const room = [
82
+ view?.lifetimeCap === null || view?.lifetimeCap === undefined
83
+ ? null
84
+ : BigInt(view.lifetimeCap) - BigInt(view.currentSpend),
85
+ view?.rateLimit ? BigInt(view.rateLimit.limit) - BigInt(view.rateLimit.spentNow) : null,
86
+ ].filter((r) => r !== null);
87
+ const available = view ? room.reduce((m, r) => (r < m ? r : m), GAS_TOPUP) : 0n;
88
+ const stranded = () => new Error(`The session holds ${float} MIST for gas and the gas allowance has nothing left to give`);
89
+ if (available <= 0n) {
90
+ if (float < GAS_RESERVE)
91
+ throw stranded();
92
+ return;
93
+ }
94
+ try {
95
+ await this.execute(refillGas(this.identity, available.toString()));
96
+ }
97
+ catch (error) {
98
+ if (float < GAS_RESERVE)
99
+ throw error;
100
+ console.warn(`Gas refill refused, continuing on the remaining float: ${error.message}`);
101
+ }
102
+ }
103
+ async execute(tx, options = {}) {
104
+ const client = this.client;
105
+ tx.setSender(this.identity.sessionAddress);
106
+ tx.setGasOwner(this.identity.sessionAddress);
107
+ tx.setGasPayment([]);
108
+ // The simulated build estimates the budget; the other path gets a fixed one the float can pay.
109
+ const bytes = options.proveChainRefusal
110
+ ? await buildWithoutSimulation(tx, this.identity, Math.min(PROVE_BUDGET, Number(await this.float())))
111
+ : await tx.build({ client });
112
+ const reply = await this.mcp.callTool({
113
+ name: 'sign_transaction',
114
+ arguments: { transactionBytes: toBase64(bytes) },
115
+ });
116
+ if (reply.isError)
117
+ throw new Error(`Signer refused: ${JSON.stringify(reply.content)}`);
118
+ const signed = JSON.parse(reply.content[0].text);
119
+ const result = await client.executeTransaction({
120
+ transaction: fromBase64(signed.bytes),
121
+ signatures: [signed.signature],
122
+ include: { effects: true, events: true, balanceChanges: true },
123
+ });
124
+ await client.waitForTransaction({ result });
125
+ const record = result.Transaction ?? result.FailedTransaction;
126
+ return {
127
+ digest: signed.digest,
128
+ success: record.status.success,
129
+ error: record.status.error ?? null,
130
+ reason: record.status.success
131
+ ? null
132
+ : describeRefusal(this.identity.deployment, record.status.error, null),
133
+ balanceChanges: (record.balanceChanges ?? []).map((c) => ({
134
+ address: c.address,
135
+ coinType: c.coinType,
136
+ amount: c.amount,
137
+ })),
138
+ signed,
139
+ result,
140
+ };
141
+ }
142
+ }
143
+ /** The first validator the owner listed, or the first active one when the list is empty. */
144
+ export async function pickValidator(i) {
145
+ const client = clientFor(i.deployment);
146
+ const listed = (await readControl(client, i.deployment, i.active.passId)).policies.staking[0]
147
+ ?.validators;
148
+ const validator = listed?.[0] ?? (await activeValidators(client))[0];
149
+ if (!validator)
150
+ throw new Error('No validator to stake with');
151
+ return validator;
152
+ }
153
+ /** Pull SUI from the dedicated gas allowance into the session's own address to pay gas. */
154
+ export function refillGas(i, amount) {
155
+ const d = i.deployment, tx = new Transaction(), active = i.active;
156
+ const balance = tx.moveCall({
157
+ target: '0x2::allowance::balance_spend',
158
+ typeArguments: [SUI],
159
+ arguments: [
160
+ tx.object(active.gasAllowance),
161
+ tx.withdrawal({
162
+ amount,
163
+ type: SUI,
164
+ from: 'allowance',
165
+ allowance: active.gasAllowance,
166
+ funder: active.owner,
167
+ }),
168
+ tx.object(CLOCK),
169
+ ],
170
+ });
171
+ tx.moveCall({
172
+ target: '0x2::balance::send_funds',
173
+ typeArguments: [SUI],
174
+ arguments: [balance, tx.pure.address(i.sessionAddress)],
175
+ });
176
+ return tx;
177
+ }
178
+ /** Explicit proof path: resolve references and gas metadata without executing a dry run.
179
+ * The normal gRPC SDK builder simulates and refuses failed transactions before signing.
180
+ * These examples use only owned/shared object arguments (no Receiving arguments).
181
+ */
182
+ export async function buildWithoutSimulation(tx, i, budget = PROVE_BUDGET) {
183
+ const client = clientFor(i.deployment), { systemState } = await client.getCurrentSystemState();
184
+ tx.setSender(i.sessionAddress);
185
+ tx.setGasOwner(i.sessionAddress);
186
+ tx.setGasPayment([]);
187
+ tx.setGasPrice(systemState.referenceGasPrice);
188
+ tx.setGasBudget(budget);
189
+ tx.setExpiration({
190
+ ValidDuring: {
191
+ minEpoch: systemState.epoch,
192
+ maxEpoch: String(BigInt(systemState.epoch) + 1n),
193
+ minTimestamp: null,
194
+ maxTimestamp: null,
195
+ chain: i.deployment.chainIdentifier,
196
+ nonce: crypto.getRandomValues(new Uint32Array(1))[0],
197
+ },
198
+ });
199
+ tx.addBuildPlugin(async (data, _options, next) => {
200
+ await Promise.all(data.inputs.map(async (input, index) => {
201
+ if (!input.UnresolvedObject)
202
+ return;
203
+ const { object } = await client.getObject({
204
+ objectId: input.UnresolvedObject.objectId,
205
+ });
206
+ data.inputs[index] =
207
+ object.owner.$kind === 'Shared'
208
+ ? Inputs.SharedObjectRef({
209
+ objectId: object.objectId,
210
+ initialSharedVersion: object.owner.Shared.initialSharedVersion,
211
+ mutable: object.objectId !== normalizeSuiAddress('0x6'),
212
+ })
213
+ : Inputs.ObjectRef(object);
214
+ }));
215
+ await next();
216
+ });
217
+ return tx.build();
218
+ }
219
+ /**
220
+ * Every spend draws from the owner's allowance for that coin: the allowance object as a mutable
221
+ * shared input plus a withdrawal input for the amount.
222
+ */
223
+ function funds(tx, i, coin, amount) {
224
+ const ids = i.active.allowances[normalizeStructTag(coin)];
225
+ if (!ids)
226
+ throw new Error(`This pass has no allowance for ${coin}`);
227
+ return [
228
+ tx.object(ids.allowance),
229
+ tx.withdrawal({
230
+ amount,
231
+ type: coin,
232
+ from: 'allowance',
233
+ allowance: ids.allowance,
234
+ funder: i.active.owner,
235
+ }),
236
+ ];
237
+ }
238
+ const pass = (tx, i) => [
239
+ tx.object(i.active.passId),
240
+ tx.object(i.deployment.configId),
241
+ ];
242
+ /** Direct transfer of a coin to a recipient the owner named. Amount in the coin's base units. */
243
+ export function send(i, coin, amount, recipient) {
244
+ const d = i.deployment, tx = new Transaction();
245
+ tx.moveCall({
246
+ target: `${d.publishedAt}::pass::transfer`,
247
+ typeArguments: [coin],
248
+ arguments: [
249
+ ...pass(tx, i),
250
+ ...funds(tx, i, coin, amount),
251
+ tx.pure.string(recipient),
252
+ tx.object(CLOCK),
253
+ ],
254
+ });
255
+ return tx;
256
+ }
257
+ /** The listed pool for a pair, narrowed by id or venue; the first listed one by default. */
258
+ function poolFor(i, pair) {
259
+ const pool = poolsFor(i.deployment, pair.coinIn, pair.coinOut).find((p) => (!pair.poolId || p.poolId === pair.poolId) && (!pair.venue || p.venue === pair.venue));
260
+ if (!pool)
261
+ throw new Error(`No listed pool sells ${pair.coinIn} for ${pair.coinOut}`);
262
+ return pool;
263
+ }
264
+ /**
265
+ * What a sale would return right now, from the venue's own quote and touching no allowance:
266
+ * `amountIn` is what the pool would actually take (DeepBook pays its fee from the input and
267
+ * rounds down to the lot size, so a small order fills nothing) and `amountOut` what comes back.
268
+ * Size `minOut` from this, a little under `amountOut`, rather than from a mid price.
269
+ */
270
+ export async function quote(i, pair, amountIn) {
271
+ const pool = poolFor(i, pair);
272
+ const result = await quoteSwap(clientFor(i.deployment), i.deployment, pool, pair.coinIn, amountIn);
273
+ return { pool, ...result };
274
+ }
275
+ /**
276
+ * Sells `coinIn` for `coinOut` on a listed pool. Proceeds return to the owner, never to the
277
+ * agent. The pool decides the entry and its direction: DeepBook base to quote or quote to base,
278
+ * Cetus a to b or b to a. Without `poolId`, the first listed pool for the pair.
279
+ */
280
+ export function swap(i, pair, amountIn, minOut) {
281
+ const d = i.deployment, tx = new Transaction();
282
+ const pool = poolFor(i, pair);
283
+ const forward = sameCoin(pool.coinA, pair.coinIn);
284
+ const entry = pool.venue === 'deepbook'
285
+ ? forward
286
+ ? 'deepbook_base_to_quote'
287
+ : 'deepbook_quote_to_base'
288
+ : forward
289
+ ? 'cetus_a_to_b'
290
+ : 'cetus_b_to_a';
291
+ const market = pool.venue === 'deepbook'
292
+ ? [tx.object(pool.poolId)]
293
+ : [tx.object(d.protocols.cetus.configId), tx.object(pool.poolId)];
294
+ tx.moveCall({
295
+ target: `${d.publishedAt}::pass::${entry}`,
296
+ typeArguments: [pool.coinA, pool.coinB],
297
+ arguments: [
298
+ ...pass(tx, i),
299
+ ...funds(tx, i, pair.coinIn, amountIn),
300
+ ...market,
301
+ tx.pure.u64(minOut),
302
+ tx.object(CLOCK),
303
+ ],
304
+ });
305
+ return tx;
306
+ }
307
+ const reserve = (i, coin) => {
308
+ const r = i.deployment.protocols.navi.reserves[normalizeStructTag(coin)];
309
+ if (!r)
310
+ throw new Error(`NAVI has no reserve for ${coin} in this deployment`);
311
+ return r;
312
+ };
313
+ /** Supply a coin to NAVI as collateral for the pass's own position. */
314
+ export function supply(i, coin, amount) {
315
+ const d = i.deployment, tx = new Transaction(), n = d.protocols.navi, r = reserve(i, coin);
316
+ tx.moveCall({
317
+ target: `${d.publishedAt}::pass::navi_supply`,
318
+ typeArguments: [coin],
319
+ arguments: [
320
+ ...pass(tx, i),
321
+ ...funds(tx, i, coin, amount),
322
+ tx.object(n.storageId),
323
+ tx.object(r.poolId),
324
+ tx.pure.u8(r.assetId),
325
+ tx.object(n.incentiveV2Id),
326
+ tx.object(n.incentiveV3Id),
327
+ tx.object(CLOCK),
328
+ ],
329
+ });
330
+ return tx;
331
+ }
332
+ /** Withdraw supplied collateral from NAVI into the owner's balance. No allowance is drawn. */
333
+ export function withdraw(i, coin, amount) {
334
+ return naviAmount(i, 'navi_withdraw', coin, amount);
335
+ }
336
+ /** Borrow a coin against the position, into the owner's balance. No allowance is drawn. */
337
+ export function borrow(i, coin, amount) {
338
+ return naviAmount(i, 'navi_borrow', coin, amount);
339
+ }
340
+ async function naviAmount(i, entry, coin, amount) {
341
+ const d = i.deployment, tx = new Transaction(), n = d.protocols.navi, r = reserve(i, coin);
342
+ await refreshNaviPrices(tx, d);
343
+ // NAVI's SUI pool stakes idle SUI and may unstake to pay out, hence the system state.
344
+ tx.moveCall({
345
+ target: `${d.publishedAt}::pass::${entry}_v2`,
346
+ typeArguments: [coin],
347
+ arguments: [
348
+ ...pass(tx, i),
349
+ tx.object(n.oracleId),
350
+ tx.object(n.storageId),
351
+ tx.object(r.poolId),
352
+ tx.pure.u8(r.assetId),
353
+ tx.object(n.incentiveV2Id),
354
+ tx.object(n.incentiveV3Id),
355
+ tx.object(SUI_SYSTEM),
356
+ tx.pure.u64(amount),
357
+ tx.object(CLOCK),
358
+ ],
359
+ });
360
+ return tx;
361
+ }
362
+ /**
363
+ * Repay NAVI debt from the owner's allowance. The allowance is charged in full before NAVI
364
+ * runs: size it from the live debt, since an over-payment returns to the owner but still counts.
365
+ */
366
+ export async function repay(i, coin, amount) {
367
+ const d = i.deployment, tx = new Transaction(), n = d.protocols.navi, r = reserve(i, coin);
368
+ await refreshNaviPrices(tx, d);
369
+ tx.moveCall({
370
+ target: `${d.publishedAt}::pass::navi_repay`,
371
+ typeArguments: [coin],
372
+ arguments: [
373
+ ...pass(tx, i),
374
+ ...funds(tx, i, coin, amount),
375
+ tx.object(n.oracleId),
376
+ tx.object(n.storageId),
377
+ tx.object(r.poolId),
378
+ tx.pure.u8(r.assetId),
379
+ tx.object(n.incentiveV2Id),
380
+ tx.object(n.incentiveV3Id),
381
+ tx.object(CLOCK),
382
+ ],
383
+ });
384
+ return tx;
385
+ }
386
+ /** Stake SUI with a validator; the `StakedSui` stays in the pass. */
387
+ export function stake(i, amount, validator) {
388
+ const d = i.deployment, tx = new Transaction();
389
+ tx.moveCall({
390
+ target: `${d.publishedAt}::pass::stake`,
391
+ arguments: [
392
+ ...pass(tx, i),
393
+ ...funds(tx, i, SUI, amount),
394
+ tx.object(SUI_SYSTEM),
395
+ tx.pure.address(validator),
396
+ tx.object(CLOCK),
397
+ ],
398
+ });
399
+ return tx;
400
+ }
401
+ /** Withdraw one held stake, principal and rewards, into the owner's balance. No allowance is drawn. */
402
+ export function unstake(i, stakeId) {
403
+ const d = i.deployment, tx = new Transaction();
404
+ tx.moveCall({
405
+ target: `${d.publishedAt}::pass::unstake`,
406
+ arguments: [...pass(tx, i), tx.object(SUI_SYSTEM), tx.pure.id(stakeId), tx.object(CLOCK)],
407
+ });
408
+ return tx;
409
+ }