@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/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 +250 -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/chain.js
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/** Only this module translates Move storage into application read models. */
|
|
4
|
+
import { bcs } from '@mysten/sui/bcs';
|
|
5
|
+
import { ObjectError } from '@mysten/sui/client';
|
|
6
|
+
import { GrpcWebFetchTransport, RpcError, SuiGrpcClient } from '@mysten/sui/grpc';
|
|
7
|
+
import { Transaction } from '@mysten/sui/transactions';
|
|
8
|
+
import { normalizeStructTag, normalizeSuiAddress, parseStructTag } from '@mysten/sui/utils';
|
|
9
|
+
import { UnaryCall } from '@protobuf-ts/runtime-rpc';
|
|
10
|
+
import { AGENT_GAS_NAME, DAY_MS, sameCoin, SUI, } from './domain.js';
|
|
11
|
+
// === BCS layouts of the Move types this module reads ===
|
|
12
|
+
const idBcs = bcs.Address;
|
|
13
|
+
const bagBcs = bcs.struct('Bag', { id: idBcs, size: bcs.u64() });
|
|
14
|
+
const controlBcs = bcs.struct('Control', {
|
|
15
|
+
controller: idBcs,
|
|
16
|
+
name: bcs.string(),
|
|
17
|
+
owner: bcs.Address,
|
|
18
|
+
agent: bcs.Address,
|
|
19
|
+
agent_expires_ms: bcs.u64(),
|
|
20
|
+
policies: bcs.struct('Policies', { bag: bagBcs }),
|
|
21
|
+
positions: bagBcs,
|
|
22
|
+
});
|
|
23
|
+
const passBcs = bcs.struct('Pass', { id: idBcs, control: controlBcs });
|
|
24
|
+
const meterBcs = bcs.struct('Meter', { day: bcs.u64(), used: bcs.u64() });
|
|
25
|
+
const transferPolicyBcs = bcs.struct('TransferPolicy', {
|
|
26
|
+
recipients: bagBcs,
|
|
27
|
+
per_transfer: bcs.u64(),
|
|
28
|
+
daily: bcs.u64(),
|
|
29
|
+
meter: meterBcs,
|
|
30
|
+
});
|
|
31
|
+
const swapPolicyBcs = bcs.struct('SwapPolicy', {
|
|
32
|
+
venues: bcs.vector(idBcs),
|
|
33
|
+
per_trade: bcs.u64(),
|
|
34
|
+
daily: bcs.u64(),
|
|
35
|
+
min_price: bcs.u64(),
|
|
36
|
+
meter: meterBcs,
|
|
37
|
+
});
|
|
38
|
+
const lendingPolicyBcs = bcs.struct('LendingPolicy', {
|
|
39
|
+
pool: idBcs,
|
|
40
|
+
asset: bcs.u8(),
|
|
41
|
+
allow_borrow: bcs.bool(),
|
|
42
|
+
per_action: bcs.u64(),
|
|
43
|
+
daily: bcs.u64(),
|
|
44
|
+
min_health_factor: bcs.u256(),
|
|
45
|
+
meter: meterBcs,
|
|
46
|
+
});
|
|
47
|
+
const stakingPolicyBcs = bcs.struct('StakingPolicy', {
|
|
48
|
+
validators: bcs.vector(bcs.Address),
|
|
49
|
+
per_stake: bcs.u64(),
|
|
50
|
+
daily: bcs.u64(),
|
|
51
|
+
meter: meterBcs,
|
|
52
|
+
});
|
|
53
|
+
const accountCapBcs = bcs.struct('AccountCap', {
|
|
54
|
+
id: idBcs,
|
|
55
|
+
owner: bcs.Address,
|
|
56
|
+
});
|
|
57
|
+
/** The position address a NAVI account cap controls, from the cap object's content. */
|
|
58
|
+
export const naviAccountOwner = (content) => normalizeSuiAddress(accountCapBcs.parse(content).owner);
|
|
59
|
+
const stakedSuiBcs = bcs.struct('StakedSui', {
|
|
60
|
+
id: idBcs,
|
|
61
|
+
pool_id: idBcs,
|
|
62
|
+
stake_activation_epoch: bcs.u64(),
|
|
63
|
+
principal: bcs.u64(),
|
|
64
|
+
});
|
|
65
|
+
const STAKED_SUI_TYPE = normalizeStructTag('0x3::staking_pool::StakedSui');
|
|
66
|
+
const rateLimitBcs = bcs.enum('RateLimit', {
|
|
67
|
+
Windowed: bcs.struct('Windowed', {
|
|
68
|
+
limit: bcs.u256(),
|
|
69
|
+
spent: bcs.u256(),
|
|
70
|
+
anchor_ms: bcs.option(bcs.u64()),
|
|
71
|
+
index: bcs.u64(),
|
|
72
|
+
window: bcs.enum('Window', {
|
|
73
|
+
PeriodicMs: bcs.u64(),
|
|
74
|
+
CalendarMonths: bcs.u8(),
|
|
75
|
+
}),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
const allowanceBcs = bcs.struct('Allowance', {
|
|
79
|
+
id: idBcs,
|
|
80
|
+
settings: bcs.struct('Settings', {
|
|
81
|
+
funder: bcs.Address,
|
|
82
|
+
spender: bcs.option(bcs.Address),
|
|
83
|
+
app: bcs.option(bcs.struct('TypeName', { name: bcs.string() })),
|
|
84
|
+
lifetime_cap: bcs.option(bcs.u256()),
|
|
85
|
+
start_timestamp_ms: bcs.option(bcs.u64()),
|
|
86
|
+
expiration_timestamp_ms: bcs.option(bcs.u64()),
|
|
87
|
+
rate_limit: bcs.option(rateLimitBcs),
|
|
88
|
+
name: bcs.string(),
|
|
89
|
+
}),
|
|
90
|
+
current_spend: bcs.u256(),
|
|
91
|
+
});
|
|
92
|
+
const allowanceCapBcs = bcs.struct('AllowanceCap', { id: idBcs, allowance: idBcs });
|
|
93
|
+
const clockBcs = bcs.struct('Clock', { id: idBcs, timestamp_ms: bcs.u64() });
|
|
94
|
+
const createdBcs = bcs.struct('Created', {
|
|
95
|
+
pass: idBcs,
|
|
96
|
+
receipt: idBcs,
|
|
97
|
+
name: bcs.string(),
|
|
98
|
+
owner: bcs.Address,
|
|
99
|
+
agent: bcs.Address,
|
|
100
|
+
agent_expires_ms: bcs.u64(),
|
|
101
|
+
});
|
|
102
|
+
const issuedBcs = bcs.struct('Issued', {
|
|
103
|
+
pass: idBcs,
|
|
104
|
+
coin_type: bcs.struct('TypeName', { name: bcs.string() }),
|
|
105
|
+
lifetime_cap: bcs.option(bcs.u256()),
|
|
106
|
+
expires_ms: bcs.option(bcs.u64()),
|
|
107
|
+
});
|
|
108
|
+
const receiptBcs = bcs.struct('Receipt', {
|
|
109
|
+
id: idBcs,
|
|
110
|
+
pass: idBcs,
|
|
111
|
+
name: bcs.string(),
|
|
112
|
+
});
|
|
113
|
+
/** The app type that binds allowances to the `pass` module. */
|
|
114
|
+
export const appType = (d) => `${d.packageId}::pass::PASS`;
|
|
115
|
+
export const createdEventType = (d) => `${d.packageId}::pass::Created`;
|
|
116
|
+
export function parseCreated(bytes) {
|
|
117
|
+
const c = createdBcs.parse(bytes);
|
|
118
|
+
return {
|
|
119
|
+
id: normalizeSuiAddress(c.pass),
|
|
120
|
+
receiptId: normalizeSuiAddress(c.receipt),
|
|
121
|
+
name: c.name,
|
|
122
|
+
owner: normalizeSuiAddress(c.owner),
|
|
123
|
+
agent: normalizeSuiAddress(c.agent),
|
|
124
|
+
agentExpiresMs: c.agent_expires_ms,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** The pass receipts in a wallet, for discovery without any local state. */
|
|
128
|
+
export async function listReceipts(client, d, owner) {
|
|
129
|
+
const receipts = [];
|
|
130
|
+
let cursor = null;
|
|
131
|
+
do {
|
|
132
|
+
const page = await client.listOwnedObjects({
|
|
133
|
+
owner,
|
|
134
|
+
type: receiptType(d),
|
|
135
|
+
cursor,
|
|
136
|
+
include: { content: true },
|
|
137
|
+
});
|
|
138
|
+
for (const o of page.objects) {
|
|
139
|
+
const r = receiptBcs.parse(o.content);
|
|
140
|
+
receipts.push({
|
|
141
|
+
id: normalizeSuiAddress(r.id),
|
|
142
|
+
pass: normalizeSuiAddress(r.pass),
|
|
143
|
+
name: r.name,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
cursor = page.hasNextPage ? page.cursor : null;
|
|
147
|
+
} while (cursor);
|
|
148
|
+
return receipts;
|
|
149
|
+
}
|
|
150
|
+
/** The owner's discovery receipt for a pass. */
|
|
151
|
+
export const receiptType = (d) => `${d.packageId}::pass::Receipt`;
|
|
152
|
+
export const allowanceType = (coin) => `0x2::allowance::Allowance<0x2::balance::Balance<${coin}>>`;
|
|
153
|
+
export const allowanceCapType = (coin) => `0x2::allowance::AllowanceCap<0x2::balance::Balance<${coin}>>`;
|
|
154
|
+
export const policyType = (d, module, name, typeArguments) => normalizeStructTag(`${d.packageId}::${module}::${name}${typeArguments.length ? `<${typeArguments.join(',')}>` : ''}`);
|
|
155
|
+
const typeNameTag = (name) => normalizeStructTag(`0x${name}`);
|
|
156
|
+
export async function readAuthorization(client, d, digest) {
|
|
157
|
+
const result = await client.waitForTransaction({
|
|
158
|
+
digest,
|
|
159
|
+
include: {
|
|
160
|
+
events: true,
|
|
161
|
+
transaction: true,
|
|
162
|
+
effects: true,
|
|
163
|
+
objectTypes: true,
|
|
164
|
+
balanceChanges: true,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
if (result.$kind !== 'Transaction')
|
|
168
|
+
throw new Error('Authorization transaction failed on chain');
|
|
169
|
+
const tx = result.Transaction;
|
|
170
|
+
if (!tx.transaction?.sender)
|
|
171
|
+
throw new Error('Authorization has no sender');
|
|
172
|
+
const events = (type) => tx.events.filter((e) => e.eventType === type);
|
|
173
|
+
return {
|
|
174
|
+
sender: normalizeSuiAddress(tx.transaction.sender),
|
|
175
|
+
passes: events(createdEventType(d)).map((e) => parseCreated(e.bcs)),
|
|
176
|
+
issued: events(`${d.packageId}::pass::Issued`).map((e) => {
|
|
177
|
+
const i = issuedBcs.parse(e.bcs);
|
|
178
|
+
return {
|
|
179
|
+
passId: normalizeSuiAddress(i.pass),
|
|
180
|
+
coinType: typeNameTag(i.coin_type.name),
|
|
181
|
+
lifetimeCap: i.lifetime_cap,
|
|
182
|
+
expiresMs: i.expires_ms,
|
|
183
|
+
};
|
|
184
|
+
}),
|
|
185
|
+
balanceChanges: (tx.balanceChanges ?? []).map((c) => ({
|
|
186
|
+
address: normalizeSuiAddress(c.address),
|
|
187
|
+
coinType: normalizeStructTag(c.coinType),
|
|
188
|
+
amount: c.amount,
|
|
189
|
+
})),
|
|
190
|
+
objects: tx
|
|
191
|
+
// Dynamic-field children (policy bag entries) carry no type here and are not needed.
|
|
192
|
+
.effects.changedObjects.filter((o) => o.idOperation === 'Created' && tx.objectTypes[o.objectId])
|
|
193
|
+
.map((o) => ({
|
|
194
|
+
id: normalizeSuiAddress(o.objectId),
|
|
195
|
+
type: normalizeStructTag(tx.objectTypes[o.objectId]),
|
|
196
|
+
owner: o.outputOwner?.$kind === 'AddressOwner'
|
|
197
|
+
? normalizeSuiAddress(o.outputOwner.AddressOwner)
|
|
198
|
+
: null,
|
|
199
|
+
shared: o.outputOwner?.$kind === 'Shared',
|
|
200
|
+
})),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Public fullnodes rate-limit by IP; a 429 is retried with these pauses before it surfaces. */
|
|
204
|
+
const RETRY_PAUSES_MS = [500, 1000, 2000, 4000];
|
|
205
|
+
const rateLimited = (e) => e instanceof RpcError && e.code === 'RESOURCE_EXHAUSTED';
|
|
206
|
+
class RetryingTransport extends GrpcWebFetchTransport {
|
|
207
|
+
unary(method, input, options) {
|
|
208
|
+
const settled = (async () => {
|
|
209
|
+
for (let attempt = 0;; attempt++) {
|
|
210
|
+
const call = super.unary(method, input, options);
|
|
211
|
+
try {
|
|
212
|
+
await call.response;
|
|
213
|
+
return call;
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (!rateLimited(e) || attempt >= RETRY_PAUSES_MS.length)
|
|
217
|
+
throw e;
|
|
218
|
+
for (const p of [call.headers, call.status, call.trailers])
|
|
219
|
+
p.catch(() => { });
|
|
220
|
+
await new Promise((r) => setTimeout(r, RETRY_PAUSES_MS[attempt]));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
return new UnaryCall(method, options.meta ?? {}, input, settled.then((c) => c.headers), settled.then((c) => c.response), settled.then((c) => c.status), settled.then((c) => c.trailers));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function clientFor(d) {
|
|
228
|
+
return new SuiGrpcClient({
|
|
229
|
+
network: d.network,
|
|
230
|
+
transport: new RetryingTransport({ baseUrl: d.grpcUrl }),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
export async function assertChain(client, d) {
|
|
234
|
+
let actual;
|
|
235
|
+
try {
|
|
236
|
+
actual = (await client.getChainIdentifier()).chainIdentifier;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
throw new Error(`Could not reach ${d.network} at ${d.grpcUrl}: ${error.message}. Check the network connection and try again.`);
|
|
240
|
+
}
|
|
241
|
+
if (actual !== d.chainIdentifier)
|
|
242
|
+
throw new Error(`${d.grpcUrl} is not the ${d.network} chain this deployment was made for`);
|
|
243
|
+
}
|
|
244
|
+
async function chainTime(client) {
|
|
245
|
+
const { object } = await client.getObject({
|
|
246
|
+
objectId: '0x6',
|
|
247
|
+
include: { content: true },
|
|
248
|
+
});
|
|
249
|
+
return Number(clockBcs.parse(object.content).timestamp_ms);
|
|
250
|
+
}
|
|
251
|
+
/** A meter as the chain would apply it now: the stored count, or zero after midnight. */
|
|
252
|
+
function usage(m, now) {
|
|
253
|
+
const day = Math.floor(now / DAY_MS);
|
|
254
|
+
return {
|
|
255
|
+
usedToday: day > Number(m.day) ? '0' : m.used,
|
|
256
|
+
resetsAtMs: (day + 1) * DAY_MS,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async function dynamicFields(client, parentId) {
|
|
260
|
+
const fields = [];
|
|
261
|
+
let cursor = null;
|
|
262
|
+
do {
|
|
263
|
+
const page = await client.listDynamicFields({
|
|
264
|
+
parentId,
|
|
265
|
+
cursor,
|
|
266
|
+
include: { value: true },
|
|
267
|
+
});
|
|
268
|
+
fields.push(...page.dynamicFields);
|
|
269
|
+
cursor = page.hasNextPage ? page.cursor : null;
|
|
270
|
+
} while (cursor);
|
|
271
|
+
return fields;
|
|
272
|
+
}
|
|
273
|
+
/** Policies are dynamic fields of the control's bag, one per policy type. */
|
|
274
|
+
async function readPolicies(client, d, bagId, now) {
|
|
275
|
+
const policies = { transfers: [], swaps: [], lending: [], staking: [] };
|
|
276
|
+
for (const field of await dynamicFields(client, bagId)) {
|
|
277
|
+
const tag = parseStructTag(field.valueType);
|
|
278
|
+
if (normalizeSuiAddress(tag.address) !== d.packageId)
|
|
279
|
+
continue;
|
|
280
|
+
const args = tag.typeParams.map((t) => normalizeStructTag(typeof t === 'string' ? t : t));
|
|
281
|
+
if (tag.module === 'transfer_policy') {
|
|
282
|
+
const p = transferPolicyBcs.parse(field.value.bcs);
|
|
283
|
+
const recipients = (await dynamicFields(client, normalizeSuiAddress(p.recipients.id))).map((r) => ({
|
|
284
|
+
name: bcs.string().parse(r.name.bcs),
|
|
285
|
+
address: normalizeSuiAddress(bcs.Address.parse(r.value.bcs)),
|
|
286
|
+
}));
|
|
287
|
+
recipients.sort((a, b) => a.name.localeCompare(b.name));
|
|
288
|
+
policies.transfers.push({
|
|
289
|
+
coinType: args[0],
|
|
290
|
+
recipients,
|
|
291
|
+
perTransfer: p.per_transfer,
|
|
292
|
+
daily: p.daily,
|
|
293
|
+
usage: usage(p.meter, now),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else if (tag.module === 'swap_policy') {
|
|
297
|
+
const p = swapPolicyBcs.parse(field.value.bcs);
|
|
298
|
+
policies.swaps.push({
|
|
299
|
+
coinIn: args[0],
|
|
300
|
+
coinOut: args[1],
|
|
301
|
+
venues: p.venues.map((v) => normalizeSuiAddress(v)),
|
|
302
|
+
perTrade: p.per_trade,
|
|
303
|
+
daily: p.daily,
|
|
304
|
+
minPrice: p.min_price,
|
|
305
|
+
usage: usage(p.meter, now),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
else if (tag.module === 'lending_policy') {
|
|
309
|
+
const p = lendingPolicyBcs.parse(field.value.bcs);
|
|
310
|
+
policies.lending.push({
|
|
311
|
+
coinType: args[0],
|
|
312
|
+
poolId: normalizeSuiAddress(p.pool),
|
|
313
|
+
asset: p.asset,
|
|
314
|
+
allowBorrow: p.allow_borrow,
|
|
315
|
+
perAction: p.per_action,
|
|
316
|
+
daily: p.daily,
|
|
317
|
+
minHealthFactor: p.min_health_factor,
|
|
318
|
+
usage: usage(p.meter, now),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
else if (tag.module === 'staking_policy') {
|
|
322
|
+
const p = stakingPolicyBcs.parse(field.value.bcs);
|
|
323
|
+
policies.staking.push({
|
|
324
|
+
validators: p.validators.map((v) => normalizeSuiAddress(v)),
|
|
325
|
+
perStake: p.per_stake,
|
|
326
|
+
daily: p.daily,
|
|
327
|
+
usage: usage(p.meter, now),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const byType = (a, b) => a.coinType.localeCompare(b.coinType);
|
|
332
|
+
policies.transfers.sort(byType);
|
|
333
|
+
policies.lending.sort(byType);
|
|
334
|
+
policies.swaps.sort((a, b) => (a.coinIn + a.coinOut).localeCompare(b.coinIn + b.coinOut));
|
|
335
|
+
return policies;
|
|
336
|
+
}
|
|
337
|
+
export async function readControl(client, d, id) {
|
|
338
|
+
const [{ object }, now] = await Promise.all([
|
|
339
|
+
client.getObject({ objectId: id, include: { content: true } }),
|
|
340
|
+
chainTime(client),
|
|
341
|
+
]);
|
|
342
|
+
if (normalizeStructTag(object.type) !== normalizeStructTag(`${d.packageId}::pass::AgentPass`) ||
|
|
343
|
+
object.owner.$kind !== 'Shared')
|
|
344
|
+
throw new Error('Unexpected pass type or ownership');
|
|
345
|
+
const { control } = passBcs.parse(object.content);
|
|
346
|
+
const [policies, positions] = await Promise.all([
|
|
347
|
+
readPolicies(client, d, normalizeSuiAddress(control.policies.bag.id), now),
|
|
348
|
+
dynamicFields(client, normalizeSuiAddress(control.positions.id)),
|
|
349
|
+
]);
|
|
350
|
+
const navi = positions.find((p) => normalizeStructTag(p.valueType) ===
|
|
351
|
+
normalizeStructTag(`${d.protocols.navi.packageId}::account::AccountCap`));
|
|
352
|
+
const stakes = positions
|
|
353
|
+
.filter((p) => normalizeStructTag(p.valueType) === STAKED_SUI_TYPE)
|
|
354
|
+
.map((p) => {
|
|
355
|
+
const s = stakedSuiBcs.parse(p.value.bcs);
|
|
356
|
+
return {
|
|
357
|
+
id: normalizeSuiAddress(s.id),
|
|
358
|
+
poolId: normalizeSuiAddress(s.pool_id),
|
|
359
|
+
activationEpoch: s.stake_activation_epoch,
|
|
360
|
+
principal: s.principal,
|
|
361
|
+
};
|
|
362
|
+
})
|
|
363
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
364
|
+
return {
|
|
365
|
+
id: normalizeSuiAddress(control.controller),
|
|
366
|
+
name: control.name,
|
|
367
|
+
owner: normalizeSuiAddress(control.owner),
|
|
368
|
+
agent: normalizeSuiAddress(control.agent),
|
|
369
|
+
agentExpiresMs: control.agent_expires_ms,
|
|
370
|
+
revoked: control.agent_expires_ms === '0',
|
|
371
|
+
policies,
|
|
372
|
+
naviPosition: navi ? normalizeSuiAddress(accountCapBcs.parse(navi.value.bcs).owner) : null,
|
|
373
|
+
stakes,
|
|
374
|
+
chainTimeMs: now,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/** Address balances of any address. */
|
|
378
|
+
/**
|
|
379
|
+
* A DeepBook pool's mid price, in quote base units per 1e9 base units of base: the scale of a
|
|
380
|
+
* swap policy's floor for selling the base. Read by simulating `pool::mid_price` on the latest
|
|
381
|
+
* package, since the original is version-gated.
|
|
382
|
+
*/
|
|
383
|
+
export async function midPrice(client, d, pool) {
|
|
384
|
+
const tx = new Transaction();
|
|
385
|
+
tx.setSender(normalizeSuiAddress('0x1'));
|
|
386
|
+
tx.moveCall({
|
|
387
|
+
target: `${d.protocols.deepbook.publishedAt}::pool::mid_price`,
|
|
388
|
+
typeArguments: [pool.coinA, pool.coinB],
|
|
389
|
+
arguments: [tx.object(pool.poolId), tx.object('0x6')],
|
|
390
|
+
});
|
|
391
|
+
const result = await client.simulateTransaction({
|
|
392
|
+
transaction: tx,
|
|
393
|
+
include: { commandResults: true },
|
|
394
|
+
});
|
|
395
|
+
const value = result.commandResults?.[0]?.returnValues?.[0]?.bcs;
|
|
396
|
+
const executed = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction;
|
|
397
|
+
if (!executed?.status.success || !value)
|
|
398
|
+
throw new Error(executed?.status.error?.message ?? 'No price returned');
|
|
399
|
+
return bcs.u64().parse(value);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* A DeepBook pool's order sizes, in base-coin base units: an order under `minSize` fills
|
|
403
|
+
* nothing, and sizes are rounded down to `lotSize`. Read by simulation on the latest package.
|
|
404
|
+
*/
|
|
405
|
+
export async function deepbookSizes(client, d, pool) {
|
|
406
|
+
const tx = new Transaction();
|
|
407
|
+
tx.setSender(normalizeSuiAddress('0x1'));
|
|
408
|
+
tx.moveCall({
|
|
409
|
+
target: `${d.protocols.deepbook.publishedAt}::pool::pool_book_params`,
|
|
410
|
+
typeArguments: [pool.coinA, pool.coinB],
|
|
411
|
+
arguments: [tx.object(pool.poolId)],
|
|
412
|
+
});
|
|
413
|
+
const result = await client.simulateTransaction({
|
|
414
|
+
transaction: tx,
|
|
415
|
+
include: { commandResults: true },
|
|
416
|
+
});
|
|
417
|
+
const values = result.commandResults?.[0]?.returnValues;
|
|
418
|
+
const executed = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction;
|
|
419
|
+
if (!executed?.status.success || !values || values.length < 3)
|
|
420
|
+
throw new Error(executed?.status.error?.message ?? 'No pool parameters returned');
|
|
421
|
+
return { lotSize: bcs.u64().parse(values[1].bcs), minSize: bcs.u64().parse(values[2].bcs) };
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* What a listed pool would give for `amountIn` of `coinIn` right now, read from the venue's own
|
|
425
|
+
* quote function by simulation, so it touches no allowance. `amountIn` is what the pool would
|
|
426
|
+
* actually take: DeepBook pays the taker fee out of the input and rounds the rest down to the
|
|
427
|
+
* lot size, so a small order can fill nothing; Cetus may stop short when liquidity runs out.
|
|
428
|
+
*/
|
|
429
|
+
export async function quoteSwap(client, d, pool, coinIn, amountIn) {
|
|
430
|
+
const forward = sameCoin(pool.coinA, coinIn);
|
|
431
|
+
const tx = new Transaction();
|
|
432
|
+
tx.setSender(normalizeSuiAddress('0x1'));
|
|
433
|
+
if (pool.venue === 'deepbook')
|
|
434
|
+
tx.moveCall({
|
|
435
|
+
target: `${d.protocols.deepbook.publishedAt}::pool::${forward ? 'get_quote_quantity_out_input_fee' : 'get_base_quantity_out_input_fee'}`,
|
|
436
|
+
typeArguments: [pool.coinA, pool.coinB],
|
|
437
|
+
arguments: [tx.object(pool.poolId), tx.pure.u64(amountIn), tx.object('0x6')],
|
|
438
|
+
});
|
|
439
|
+
else
|
|
440
|
+
tx.moveCall({
|
|
441
|
+
target: `${d.protocols.cetus.publishedAt}::pool::calculate_swap_result`,
|
|
442
|
+
typeArguments: [pool.coinA, pool.coinB],
|
|
443
|
+
arguments: [
|
|
444
|
+
tx.object(pool.poolId),
|
|
445
|
+
tx.pure.bool(forward),
|
|
446
|
+
tx.pure.bool(true),
|
|
447
|
+
tx.pure.u64(amountIn),
|
|
448
|
+
],
|
|
449
|
+
});
|
|
450
|
+
const result = await client.simulateTransaction({
|
|
451
|
+
transaction: tx,
|
|
452
|
+
include: { commandResults: true },
|
|
453
|
+
});
|
|
454
|
+
const executed = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction;
|
|
455
|
+
const values = result.commandResults?.[0]?.returnValues;
|
|
456
|
+
if (!executed?.status.success || !values?.[0]?.bcs)
|
|
457
|
+
throw new Error(executed?.status.error?.message ?? 'No quote returned');
|
|
458
|
+
if (pool.venue === 'deepbook') {
|
|
459
|
+
// (base out, quote out, DEEP required): the input side's value is what stayed unfilled.
|
|
460
|
+
const [base, quote] = [values[0].bcs, values[1].bcs].map((v) => bcs.u64().parse(v));
|
|
461
|
+
const unfilled = forward ? base : quote;
|
|
462
|
+
return {
|
|
463
|
+
amountIn: (BigInt(amountIn) - BigInt(unfilled)).toString(),
|
|
464
|
+
amountOut: forward ? quote : base,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const quote = bcs
|
|
468
|
+
.struct('CalculatedSwapResult', { amountIn: bcs.u64(), amountOut: bcs.u64() })
|
|
469
|
+
.parse(values[0].bcs);
|
|
470
|
+
return { amountIn: quote.amountIn, amountOut: quote.amountOut };
|
|
471
|
+
}
|
|
472
|
+
/** The owner's address balance of every listed coin, by normalized coin type. */
|
|
473
|
+
export async function readBalances(client, d, owner) {
|
|
474
|
+
const held = await readHoldings(client, d, owner);
|
|
475
|
+
return Object.fromEntries(Object.entries(held).map(([c, h]) => [c, h.addressBalance]));
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Every listed coin an address holds, split the way the chain splits it: the address balance,
|
|
479
|
+
* which allowances draw from and gas may come from, and coin objects, which only the owner's
|
|
480
|
+
* own transactions can move.
|
|
481
|
+
*/
|
|
482
|
+
export async function readHoldings(client, d, owner) {
|
|
483
|
+
const entries = await Promise.all(d.coins.map(async (c) => {
|
|
484
|
+
const { balance } = await client.getBalance({ owner, coinType: c.type });
|
|
485
|
+
return [
|
|
486
|
+
normalizeStructTag(c.type),
|
|
487
|
+
{ addressBalance: balance.addressBalance, coinBalance: balance.coinBalance },
|
|
488
|
+
];
|
|
489
|
+
}));
|
|
490
|
+
return Object.fromEntries(entries);
|
|
491
|
+
}
|
|
492
|
+
/** The coin a `0x2::allowance::Allowance<Balance<T>>` type names. */
|
|
493
|
+
export function allowanceCoin(type) {
|
|
494
|
+
const tag = parseStructTag(type);
|
|
495
|
+
if (normalizeSuiAddress(tag.address) !== normalizeSuiAddress('0x2') ||
|
|
496
|
+
tag.module !== 'allowance' ||
|
|
497
|
+
tag.name !== 'Allowance')
|
|
498
|
+
return null;
|
|
499
|
+
const inner = tag.typeParams[0];
|
|
500
|
+
if (!inner || typeof inner === 'string')
|
|
501
|
+
return null;
|
|
502
|
+
const coin = inner.typeParams[0];
|
|
503
|
+
return coin ? normalizeStructTag(typeof coin === 'string' ? coin : coin) : null;
|
|
504
|
+
}
|
|
505
|
+
/** Returns null once the owner has revoked: `sui::allowance::revoke` deletes the object. */
|
|
506
|
+
export async function readAllowance(client, id, coinType) {
|
|
507
|
+
let object;
|
|
508
|
+
try {
|
|
509
|
+
({ object } = await client.getObject({
|
|
510
|
+
objectId: id,
|
|
511
|
+
include: { content: true },
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (error instanceof ObjectError && (error.reason === 'deleted' || error.reason === 'notFound'))
|
|
516
|
+
return null;
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
if (normalizeStructTag(object.type) !== normalizeStructTag(allowanceType(coinType)) ||
|
|
520
|
+
object.owner.$kind !== 'Shared')
|
|
521
|
+
throw new Error('Unexpected allowance type or ownership');
|
|
522
|
+
const now = await chainTime(client);
|
|
523
|
+
const a = allowanceBcs.parse(object.content), s = a.settings, r = s.rate_limit?.Windowed;
|
|
524
|
+
let rateLimit = null;
|
|
525
|
+
if (r) {
|
|
526
|
+
const periodMs = r.window.PeriodicMs ?? null;
|
|
527
|
+
let spentNow = r.spent;
|
|
528
|
+
if (r.anchor_ms !== null && periodMs !== null) {
|
|
529
|
+
const current = Math.floor((now - Number(r.anchor_ms)) / Number(periodMs));
|
|
530
|
+
if (current > Number(r.index))
|
|
531
|
+
spentNow = '0';
|
|
532
|
+
}
|
|
533
|
+
rateLimit = {
|
|
534
|
+
limit: r.limit,
|
|
535
|
+
spent: r.spent,
|
|
536
|
+
spentNow,
|
|
537
|
+
anchorMs: r.anchor_ms,
|
|
538
|
+
index: r.index,
|
|
539
|
+
periodMs,
|
|
540
|
+
months: r.window.CalendarMonths ?? null,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
id: normalizeSuiAddress(a.id),
|
|
545
|
+
coinType,
|
|
546
|
+
funder: normalizeSuiAddress(s.funder),
|
|
547
|
+
spender: s.spender ? normalizeSuiAddress(s.spender) : null,
|
|
548
|
+
app: s.app ? typeNameTag(s.app.name) : null,
|
|
549
|
+
name: s.name,
|
|
550
|
+
lifetimeCap: s.lifetime_cap,
|
|
551
|
+
startMs: s.start_timestamp_ms,
|
|
552
|
+
expirationMs: s.expiration_timestamp_ms,
|
|
553
|
+
rateLimit,
|
|
554
|
+
currentSpend: a.current_spend,
|
|
555
|
+
chainTimeMs: now,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** The allowance an `AllowanceCap` revokes, so a cap can be paired with its allowance. */
|
|
559
|
+
export async function readAllowanceCapTarget(client, id) {
|
|
560
|
+
const { object } = await client.getObject({ objectId: id, include: { content: true } });
|
|
561
|
+
return normalizeSuiAddress(allowanceCapBcs.parse(object.content).allowance);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Every allowance currently bound to a pass, found from the owner's wallet: each `AllowanceCap`
|
|
565
|
+
* there names an allowance, and an allowance belongs to this pass when the pass's app type,
|
|
566
|
+
* owner and agent key are its app, funder and spender. The gas allowance is the plain one for
|
|
567
|
+
* the same agent. Reads the chain as it is now, so a changed pass reads right too.
|
|
568
|
+
*/
|
|
569
|
+
export async function discoverAllowances(client, d, pass) {
|
|
570
|
+
const app = normalizeStructTag(appType(d)), owner = normalizeSuiAddress(pass.owner), agent = normalizeSuiAddress(pass.agent);
|
|
571
|
+
const caps = [];
|
|
572
|
+
let cursor = null;
|
|
573
|
+
do {
|
|
574
|
+
const page = await client.listOwnedObjects({
|
|
575
|
+
owner,
|
|
576
|
+
cursor,
|
|
577
|
+
limit: 50,
|
|
578
|
+
});
|
|
579
|
+
for (const o of page.objects) {
|
|
580
|
+
const tag = parseStructTag(o.type);
|
|
581
|
+
if (normalizeSuiAddress(tag.address) === normalizeSuiAddress('0x2') &&
|
|
582
|
+
tag.module === 'allowance' &&
|
|
583
|
+
tag.name === 'AllowanceCap')
|
|
584
|
+
caps.push(o.objectId);
|
|
585
|
+
}
|
|
586
|
+
cursor = page.hasNextPage ? page.cursor : null;
|
|
587
|
+
} while (cursor && caps.length < 1000);
|
|
588
|
+
const allowances = {};
|
|
589
|
+
let gas = null;
|
|
590
|
+
await Promise.all(caps.map(async (cap) => {
|
|
591
|
+
const target = await readAllowanceCapTarget(client, cap).catch(() => null);
|
|
592
|
+
if (!target)
|
|
593
|
+
return;
|
|
594
|
+
const { object } = await client
|
|
595
|
+
.getObject({ objectId: target })
|
|
596
|
+
.catch(() => ({ object: null }));
|
|
597
|
+
const coin = object ? allowanceCoin(object.type) : null;
|
|
598
|
+
if (!coin)
|
|
599
|
+
return;
|
|
600
|
+
const view = await readAllowance(client, target, coin);
|
|
601
|
+
if (!view || view.funder !== owner || view.spender !== agent)
|
|
602
|
+
return;
|
|
603
|
+
if (view.app === app)
|
|
604
|
+
allowances[coin] = { allowance: target, revokeCap: cap };
|
|
605
|
+
else if (view.app === null && view.name === AGENT_GAS_NAME && coin === SUI)
|
|
606
|
+
gas = { allowance: target, revokeCap: cap };
|
|
607
|
+
}));
|
|
608
|
+
return { allowances, gas };
|
|
609
|
+
}
|
|
610
|
+
/** The pass-bound allowances of an active session, by coin; null where one no longer exists. */
|
|
611
|
+
export async function readAllowances(client, active) {
|
|
612
|
+
const entries = await Promise.all(Object.entries(active.allowances).map(async ([coin, ids]) => [
|
|
613
|
+
coin,
|
|
614
|
+
await readAllowance(client, ids.allowance, coin),
|
|
615
|
+
]));
|
|
616
|
+
return Object.fromEntries(entries);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* The deployment extended with every coin and pool `needs` names that it does not list, read
|
|
620
|
+
* from the chain rather than taken from whoever wrote the proposal: a coin's symbol and
|
|
621
|
+
* decimals from its `CoinMetadata`, a pool's venue and coin order from its object type, which
|
|
622
|
+
* must be a `Pool` of one of the deployment's venue packages. Coins added this way carry
|
|
623
|
+
* `unlisted`, so pages print their type beside the symbol. Lending stays with the reserves the
|
|
624
|
+
* deployment lists.
|
|
625
|
+
*/
|
|
626
|
+
export async function withMarketFacts(client, d, needs) {
|
|
627
|
+
const coins = [...d.coins], pools = [...d.pools];
|
|
628
|
+
const venues = [
|
|
629
|
+
['deepbook', d.protocols.deepbook.packageId],
|
|
630
|
+
['cetus', d.protocols.cetus.packageId],
|
|
631
|
+
];
|
|
632
|
+
for (const id of needs.pools) {
|
|
633
|
+
if (pools.some((p) => normalizeSuiAddress(p.poolId) === normalizeSuiAddress(id)))
|
|
634
|
+
continue;
|
|
635
|
+
const { object } = await client.getObject({ objectId: id });
|
|
636
|
+
const tag = parseStructTag(object.type);
|
|
637
|
+
const venue = venues.find(([, pkg]) => normalizeSuiAddress(tag.address) === normalizeSuiAddress(pkg))?.[0];
|
|
638
|
+
const [coinA, coinB] = tag.typeParams;
|
|
639
|
+
if (!venue || tag.module !== 'pool' || tag.name !== 'Pool' || !coinA || !coinB)
|
|
640
|
+
throw new Error(`${id} is not a DeepBook or Cetus pool on this network`);
|
|
641
|
+
const show = (t) => typeof t === 'string' ? normalizeStructTag(t) : normalizeStructTag(structTagString(t));
|
|
642
|
+
pools.push({ venue, poolId: normalizeSuiAddress(id), coinA: show(coinA), coinB: show(coinB) });
|
|
643
|
+
}
|
|
644
|
+
const wanted = new Set(needs.coins.map((c) => normalizeStructTag(c)));
|
|
645
|
+
for (const p of pools) {
|
|
646
|
+
wanted.add(p.coinA);
|
|
647
|
+
wanted.add(p.coinB);
|
|
648
|
+
}
|
|
649
|
+
for (const type of wanted) {
|
|
650
|
+
if (coins.some((c) => sameCoin(c.type, type)))
|
|
651
|
+
continue;
|
|
652
|
+
const { coinMetadata } = await client.getCoinMetadata({ coinType: type });
|
|
653
|
+
if (!coinMetadata)
|
|
654
|
+
throw new Error(`${type} has no coin metadata on this network`);
|
|
655
|
+
coins.push({
|
|
656
|
+
type,
|
|
657
|
+
symbol: coinMetadata.symbol.slice(0, 16) || 'COIN',
|
|
658
|
+
decimals: coinMetadata.decimals,
|
|
659
|
+
unlisted: true,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
return { ...d, coins, pools };
|
|
663
|
+
}
|
|
664
|
+
function structTagString(t) {
|
|
665
|
+
const params = t.typeParams.map((p) => (typeof p === 'string' ? p : structTagString(p)));
|
|
666
|
+
return `${t.address}::${t.module}::${t.name}${params.length ? `<${params.join(',')}>` : ''}`;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Every coin an address holds, listed or not, with the metadata the chain publishes for it:
|
|
670
|
+
* what an agent needs to notice a coin the deployment does not list yet and talk about it
|
|
671
|
+
* by name. Pages through the owner's balances; a wallet with hundreds of coin types is rare.
|
|
672
|
+
*/
|
|
673
|
+
export async function readAllHoldings(client, d, owner) {
|
|
674
|
+
const balances = [];
|
|
675
|
+
let cursor = null;
|
|
676
|
+
do {
|
|
677
|
+
const page = await client.listBalances({
|
|
678
|
+
owner,
|
|
679
|
+
cursor,
|
|
680
|
+
limit: 50,
|
|
681
|
+
});
|
|
682
|
+
balances.push(...page.balances);
|
|
683
|
+
cursor = page.hasNextPage ? page.cursor : null;
|
|
684
|
+
} while (cursor && balances.length < 500);
|
|
685
|
+
return Promise.all(balances.map(async (b) => {
|
|
686
|
+
const type = normalizeStructTag(b.coinType);
|
|
687
|
+
const listed = d.coins.find((c) => sameCoin(c.type, type));
|
|
688
|
+
const meta = listed
|
|
689
|
+
? null
|
|
690
|
+
: await client
|
|
691
|
+
.getCoinMetadata({ coinType: type })
|
|
692
|
+
.then((r) => r.coinMetadata)
|
|
693
|
+
.catch(() => null);
|
|
694
|
+
return {
|
|
695
|
+
coinType: type,
|
|
696
|
+
symbol: listed?.symbol ?? meta?.symbol ?? null,
|
|
697
|
+
decimals: listed?.decimals ?? meta?.decimals ?? null,
|
|
698
|
+
addressBalance: b.addressBalance,
|
|
699
|
+
coinBalance: b.coinBalance,
|
|
700
|
+
listed: !!listed,
|
|
701
|
+
};
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
/** Live chain state for an authorized session. Nothing here is cached or decided locally. */
|
|
705
|
+
export async function readStatus(client, d, active, sessionAddress) {
|
|
706
|
+
const [control, allowances, ownerBalances, gas] = await Promise.all([
|
|
707
|
+
readControl(client, d, active.passId),
|
|
708
|
+
readAllowances(client, active),
|
|
709
|
+
readHoldings(client, d, active.owner),
|
|
710
|
+
client.getBalance({ owner: sessionAddress }).then((r) => r.balance),
|
|
711
|
+
]);
|
|
712
|
+
return {
|
|
713
|
+
control,
|
|
714
|
+
allowances,
|
|
715
|
+
ownerBalances,
|
|
716
|
+
revoked: Object.values(allowances).some((a) => !a) || control.revoked,
|
|
717
|
+
gas,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Addresses of the active validators. The system state keeps them in its versioned inner
|
|
722
|
+
* object, a dynamic field of `0x5`, read here through its JSON rendering.
|
|
723
|
+
*/
|
|
724
|
+
export async function activeValidators(client) {
|
|
725
|
+
const { dynamicFields } = await client.listDynamicFields({ parentId: '0x5' });
|
|
726
|
+
for (const field of dynamicFields) {
|
|
727
|
+
const { object } = await client.getObject({
|
|
728
|
+
objectId: field.fieldId,
|
|
729
|
+
include: { json: true },
|
|
730
|
+
});
|
|
731
|
+
const found = findActiveValidators(object.json);
|
|
732
|
+
if (found.length)
|
|
733
|
+
return found;
|
|
734
|
+
}
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
function findActiveValidators(node) {
|
|
738
|
+
if (!node || typeof node !== 'object')
|
|
739
|
+
return [];
|
|
740
|
+
const o = node;
|
|
741
|
+
if (Array.isArray(o.active_validators))
|
|
742
|
+
return o.active_validators.flatMap((v) => {
|
|
743
|
+
const address = v?.metadata?.sui_address;
|
|
744
|
+
return typeof address === 'string' ? [normalizeSuiAddress(address)] : [];
|
|
745
|
+
});
|
|
746
|
+
for (const value of Object.values(o)) {
|
|
747
|
+
const found = findActiveValidators(value);
|
|
748
|
+
if (found.length)
|
|
749
|
+
return found;
|
|
750
|
+
}
|
|
751
|
+
return [];
|
|
752
|
+
}
|
|
753
|
+
export function explorer(d, digest) {
|
|
754
|
+
return d.network === 'localnet'
|
|
755
|
+
? null
|
|
756
|
+
: `https://suiscan.xyz/${d.network}/tx/${encodeURIComponent(digest)}`;
|
|
757
|
+
}
|
|
758
|
+
export function explorerObject(d, id) {
|
|
759
|
+
return d.network === 'localnet' ? null : `https://suiscan.xyz/${d.network}/object/${id}`;
|
|
760
|
+
}
|
|
761
|
+
export function explorerAddress(d, address) {
|
|
762
|
+
return d.network === 'localnet' ? null : `https://suiscan.xyz/${d.network}/account/${address}`;
|
|
763
|
+
}
|