@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/domain.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { fromBase64, isValidSuiAddress, normalizeStructTag, normalizeSuiAddress, toBase64, } from '@mysten/sui/utils';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
/** The gas coin, in the normalized spelling every coin-keyed record uses. */
|
|
6
|
+
export const SUI = normalizeStructTag('0x2::sui::SUI');
|
|
7
|
+
export const DAY_MS = 86_400_000;
|
|
8
|
+
/** NAVI's fixed-point scale for health factors: 1e27 is 1.0. */
|
|
9
|
+
export const RAY = 10n ** 27n;
|
|
10
|
+
export const address = z
|
|
11
|
+
.string()
|
|
12
|
+
.refine(isValidSuiAddress, 'Invalid Sui address')
|
|
13
|
+
.transform((v) => normalizeSuiAddress(v));
|
|
14
|
+
/** A coin's type tag, normalized so two spellings of one coin compare equal. */
|
|
15
|
+
export const coinType = z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^0x[0-9a-fA-F]+::[A-Za-z_][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*(<.*>)?$/, 'Not a coin type')
|
|
18
|
+
.transform((v) => normalizeStructTag(v));
|
|
19
|
+
export const amount = z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(/^(0|[1-9][0-9]*)$/)
|
|
22
|
+
.refine((v) => BigInt(v) <= 18446744073709551615n, 'Amount exceeds u64');
|
|
23
|
+
const positive = amount.refine((v) => BigInt(v) > 0n, 'Must be positive');
|
|
24
|
+
/** Name of the separate SUI allowance the agent draws on to top up its own gas float. */
|
|
25
|
+
export const AGENT_GAS_NAME = 'agent gas';
|
|
26
|
+
export const sameCoin = (a, b) => normalizeStructTag(a) === normalizeStructTag(b);
|
|
27
|
+
/** The agent names recipients by nickname; it never supplies an address. */
|
|
28
|
+
export const recipientName = z.string().regex(/^[a-z0-9_-]{1,32}$/);
|
|
29
|
+
const perAndDaily = (r) => BigInt(r.perX) <= BigInt(r.daily);
|
|
30
|
+
/** `TransferPolicy<coin>`: sends of one coin to named recipients. Base units of that coin. */
|
|
31
|
+
export const TransferRulesSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
coin: coinType,
|
|
34
|
+
recipients: z.array(z.object({ name: recipientName, address }).strict()).min(1),
|
|
35
|
+
perTransfer: positive,
|
|
36
|
+
daily: positive,
|
|
37
|
+
})
|
|
38
|
+
.strict()
|
|
39
|
+
.refine((r) => perAndDaily({ perX: r.perTransfer, daily: r.daily }) &&
|
|
40
|
+
new Set(r.recipients.map((x) => x.name)).size === r.recipients.length, 'Per-transfer limit must not exceed the daily limit; recipient names must be unique');
|
|
41
|
+
export const VenueSchema = z.enum(['deepbook', 'cetus']);
|
|
42
|
+
/**
|
|
43
|
+
* `SwapPolicy<coinIn, coinOut>`: selling `coinIn` for `coinOut` on the listed pools. Limits in
|
|
44
|
+
* `coinIn` base units; `minPrice` is `coinOut` base units per 1e9 base units of `coinIn`, the
|
|
45
|
+
* policy's own scale (`priceUnits` converts a human price).
|
|
46
|
+
*/
|
|
47
|
+
export const SwapRulesSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
coinIn: coinType,
|
|
50
|
+
coinOut: coinType,
|
|
51
|
+
/** Pool object IDs, from the deployment's pools for this pair. */
|
|
52
|
+
pools: z.array(address).min(1),
|
|
53
|
+
perTrade: positive,
|
|
54
|
+
daily: positive,
|
|
55
|
+
/** Required on chain too: the agent key builds the whole transaction and can move a pool's
|
|
56
|
+
* price inside it, so without a floor the daily cap could be sold for nothing. */
|
|
57
|
+
minPrice: positive,
|
|
58
|
+
})
|
|
59
|
+
.strict()
|
|
60
|
+
.refine((r) => perAndDaily({ perX: r.perTrade, daily: r.daily }) &&
|
|
61
|
+
!sameCoin(r.coinIn, r.coinOut) &&
|
|
62
|
+
new Set(r.pools).size === r.pools.length, 'Per-trade limit must not exceed the daily limit; the pair must differ; pools must be unique');
|
|
63
|
+
/**
|
|
64
|
+
* `LendingPolicy<coin>` on NAVI: supplying and withdrawing one coin, and when `allowBorrow`,
|
|
65
|
+
* borrowing and repaying it. The health factor floor applies to any withdraw or borrow under
|
|
66
|
+
* this policy; null means no floor.
|
|
67
|
+
*/
|
|
68
|
+
export const LendingRulesSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
coin: coinType,
|
|
71
|
+
allowBorrow: z.boolean(),
|
|
72
|
+
perAction: positive,
|
|
73
|
+
daily: positive,
|
|
74
|
+
minHealthFactor: z.number().min(1).max(100).nullable(),
|
|
75
|
+
})
|
|
76
|
+
.strict()
|
|
77
|
+
.refine((r) => perAndDaily({ perX: r.perAction, daily: r.daily }), 'Per-action limit must not exceed the daily limit');
|
|
78
|
+
/** `StakingPolicy`: staking SUI with validators. SUI base units; the system's 1 SUI minimum applies. */
|
|
79
|
+
export const StakingRulesSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
/** Validator addresses the agent may stake with; empty allows any active validator. */
|
|
82
|
+
validators: z.array(address),
|
|
83
|
+
perStake: positive,
|
|
84
|
+
daily: positive,
|
|
85
|
+
})
|
|
86
|
+
.strict()
|
|
87
|
+
.refine((r) => perAndDaily({ perX: r.perStake, daily: r.daily }) &&
|
|
88
|
+
new Set(r.validators).size === r.validators.length, 'Per-stake limit must not exceed the daily limit; validators must be unique');
|
|
89
|
+
const unique = (keys) => new Set(keys).size === keys.length;
|
|
90
|
+
/**
|
|
91
|
+
* Each rule becomes one on-chain policy object, keyed by its type: one transfer rule per coin,
|
|
92
|
+
* one swap rule per direction, one lending rule per coin, at most one staking rule. An action
|
|
93
|
+
* with no rule for its coin is refused.
|
|
94
|
+
*/
|
|
95
|
+
export const PolicySchema = z
|
|
96
|
+
.object({
|
|
97
|
+
transfers: z.array(TransferRulesSchema).default([]),
|
|
98
|
+
swaps: z.array(SwapRulesSchema).default([]),
|
|
99
|
+
lending: z.array(LendingRulesSchema).default([]),
|
|
100
|
+
staking: StakingRulesSchema.nullable().default(null),
|
|
101
|
+
})
|
|
102
|
+
.strict()
|
|
103
|
+
.refine((p) => p.transfers.length || p.swaps.length || p.lending.length || p.staking, 'Allow at least one kind of action')
|
|
104
|
+
.refine((p) => unique(p.transfers.map((t) => t.coin)) &&
|
|
105
|
+
unique(p.swaps.map((s) => `${s.coinIn}>${s.coinOut}`)) &&
|
|
106
|
+
unique(p.lending.map((l) => l.coin)), 'One transfer rule per coin, one swap rule per direction, one lending rule per coin');
|
|
107
|
+
/** The system's own minimum for one stake. */
|
|
108
|
+
export const MIN_STAKE = '1000000000';
|
|
109
|
+
/** The coins a rule set draws from the owner's address balance: what needs an allowance. */
|
|
110
|
+
export function spentCoins(p) {
|
|
111
|
+
const coins = [
|
|
112
|
+
...p.transfers.map((t) => t.coin),
|
|
113
|
+
...p.swaps.map((s) => s.coinIn),
|
|
114
|
+
// Supplying draws the coin; so does repaying when borrowing is allowed.
|
|
115
|
+
...p.lending.map((l) => l.coin),
|
|
116
|
+
...(p.staking ? [SUI] : []),
|
|
117
|
+
];
|
|
118
|
+
return [...new Set(coins.map((c) => normalizeStructTag(c)))];
|
|
119
|
+
}
|
|
120
|
+
const CoinSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
type: coinType,
|
|
123
|
+
symbol: z.string().min(1).max(16),
|
|
124
|
+
decimals: z.number().int().min(0).max(18),
|
|
125
|
+
/** Added from the chain's own metadata for a proposal, not vetted in the deployment: shown with its type. */
|
|
126
|
+
unlisted: z.boolean().optional(),
|
|
127
|
+
})
|
|
128
|
+
.strict();
|
|
129
|
+
/** A pool the pass can trade on. `coinA`/`coinB` is the pool's own order: DeepBook base and quote, Cetus A and B. */
|
|
130
|
+
const PoolSchema = z
|
|
131
|
+
.object({ venue: VenueSchema, poolId: address, coinA: coinType, coinB: coinType })
|
|
132
|
+
.strict();
|
|
133
|
+
const assetIndex = z.number().int().min(0).max(255);
|
|
134
|
+
export const DeploymentSchema = z
|
|
135
|
+
.object({
|
|
136
|
+
network: z.enum(['localnet', 'testnet', 'devnet', 'mainnet']),
|
|
137
|
+
grpcUrl: z.string().url(),
|
|
138
|
+
chainIdentifier: z.string().min(1),
|
|
139
|
+
/** The original package: every type of the pass is addressed by it. */
|
|
140
|
+
packageId: address,
|
|
141
|
+
/** The latest upgrade of that package: every call goes to it. Equal until an upgrade. */
|
|
142
|
+
publishedAt: address,
|
|
143
|
+
/** The package's shared `Config`: the version gate every spend and install checks. */
|
|
144
|
+
configId: address,
|
|
145
|
+
/**
|
|
146
|
+
* Where the owner reviews and signs: the site we host at a fixed origin, built with this
|
|
147
|
+
* deployment. Absent in development, when the local process serves the same build.
|
|
148
|
+
*/
|
|
149
|
+
siteUrl: z.string().url().optional(),
|
|
150
|
+
/** The coins a pass on this chain can hold rules for. SUI, the gas coin, comes first. */
|
|
151
|
+
coins: z.array(CoinSchema).min(1),
|
|
152
|
+
/** The pools a swap rule can list, in either direction. */
|
|
153
|
+
pools: z.array(PoolSchema),
|
|
154
|
+
/** A demo stablecoin's faucet, which the authorization mints from instead of converting coins. */
|
|
155
|
+
demoFaucet: z
|
|
156
|
+
.object({ packageId: address, faucetId: address, coin: coinType })
|
|
157
|
+
.strict()
|
|
158
|
+
.optional(),
|
|
159
|
+
/** Protocol packages and objects on this chain. Stand-ins off mainnet. */
|
|
160
|
+
protocols: z
|
|
161
|
+
.object({
|
|
162
|
+
deepbook: z
|
|
163
|
+
.object({
|
|
164
|
+
packageId: address,
|
|
165
|
+
/** The latest upgrade, for calls made from a PTB; the original is version-gated. */
|
|
166
|
+
publishedAt: address,
|
|
167
|
+
})
|
|
168
|
+
.strict(),
|
|
169
|
+
/** `packageId` defines the pool type; `publishedAt` is the upgrade that answers calls. */
|
|
170
|
+
cetus: z.object({ packageId: address, publishedAt: address, configId: address }).strict(),
|
|
171
|
+
navi: z
|
|
172
|
+
.object({
|
|
173
|
+
/** The package that defines `account::AccountCap`, which the pass holds. */
|
|
174
|
+
packageId: address,
|
|
175
|
+
/** The latest upgrade of the lending package, which answers calls. */
|
|
176
|
+
publishedAt: address,
|
|
177
|
+
oraclePackageId: address,
|
|
178
|
+
storageId: address,
|
|
179
|
+
oracleId: address,
|
|
180
|
+
incentiveV2Id: address,
|
|
181
|
+
incentiveV3Id: address,
|
|
182
|
+
/** NAVI's pool object and asset index per coin a lending rule can name. */
|
|
183
|
+
reserves: z.record(coinType, z.object({ poolId: address, assetId: assetIndex }).strict()),
|
|
184
|
+
})
|
|
185
|
+
.strict(),
|
|
186
|
+
})
|
|
187
|
+
.strict(),
|
|
188
|
+
})
|
|
189
|
+
.strict()
|
|
190
|
+
.refine((d) => sameCoin(d.coins[0].type, SUI), 'The first coin must be SUI, the gas coin')
|
|
191
|
+
.refine((d) => unique(d.coins.map((c) => c.type)), 'Each coin once')
|
|
192
|
+
.refine((d) => d.pools.every((p) => d.coins.some((c) => sameCoin(c.type, p.coinA)) &&
|
|
193
|
+
d.coins.some((c) => sameCoin(c.type, p.coinB))) &&
|
|
194
|
+
Object.keys(d.protocols.navi.reserves).every((t) => d.coins.some((c) => sameCoin(c.type, t))) &&
|
|
195
|
+
(!d.demoFaucet || d.coins.some((c) => sameCoin(c.type, d.demoFaucet.coin))), 'Pools, reserves and the demo faucet may only name listed coins');
|
|
196
|
+
/** A listed coin's symbol and decimals, by type. */
|
|
197
|
+
export function coinInfo(d, type) {
|
|
198
|
+
const c = d.coins.find((x) => sameCoin(x.type, type));
|
|
199
|
+
if (!c)
|
|
200
|
+
throw new Error(`${type} is not a coin this deployment lists`);
|
|
201
|
+
return c;
|
|
202
|
+
}
|
|
203
|
+
/** A listed coin by symbol, case-insensitive, for inputs written in human terms. */
|
|
204
|
+
export function coinBySymbol(d, symbol) {
|
|
205
|
+
const c = d.coins.find((x) => x.symbol.toLowerCase() === symbol.toLowerCase());
|
|
206
|
+
if (!c)
|
|
207
|
+
throw new Error(`No coin called ${symbol}; this deployment lists ${d.coins.map((x) => x.symbol).join(', ')}`);
|
|
208
|
+
return c;
|
|
209
|
+
}
|
|
210
|
+
/** The pools that trade `coinIn` for `coinOut`, in either pool order. */
|
|
211
|
+
export function poolsFor(d, coinIn, coinOut) {
|
|
212
|
+
return d.pools.filter((p) => (sameCoin(p.coinA, coinIn) && sameCoin(p.coinB, coinOut)) ||
|
|
213
|
+
(sameCoin(p.coinB, coinIn) && sameCoin(p.coinA, coinOut)));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* What a local process hands the site in the URL, and all it ever hands it: the rules the owner
|
|
217
|
+
* is about to sign and the agent key they are for. The site reads the rest from the chain.
|
|
218
|
+
*/
|
|
219
|
+
export const ProposalSchema = z
|
|
220
|
+
.object({
|
|
221
|
+
v: z.literal(1),
|
|
222
|
+
sessionAddress: address,
|
|
223
|
+
setup: z.lazy(() => SetupSchema),
|
|
224
|
+
/** Present when the proposal changes the rules of this existing pass instead of creating one. */
|
|
225
|
+
passId: address.optional(),
|
|
226
|
+
})
|
|
227
|
+
.strict();
|
|
228
|
+
export const encodeProposal = (p) => toBase64(new TextEncoder().encode(JSON.stringify(ProposalSchema.parse(p))))
|
|
229
|
+
.replaceAll('+', '-')
|
|
230
|
+
.replaceAll('/', '_')
|
|
231
|
+
.replace(/=+$/, '');
|
|
232
|
+
export const decodeProposal = (s) => ProposalSchema.parse(JSON.parse(new TextDecoder().decode(fromBase64(s.replaceAll('-', '+').replaceAll('_', '/')))));
|
|
233
|
+
const ttlMs = z
|
|
234
|
+
.number()
|
|
235
|
+
.int()
|
|
236
|
+
.positive()
|
|
237
|
+
.max(30 * DAY_MS);
|
|
238
|
+
const gasAmount = positive;
|
|
239
|
+
/** Absolute end of the agent's term, written on chain. The chain requires it within 30 days. */
|
|
240
|
+
const expiresMs = z.number().int().positive();
|
|
241
|
+
const name = z.string().regex(/^[\x20-\x7e]{1,64}$/);
|
|
242
|
+
/**
|
|
243
|
+
* What the owner reviews and signs: one pass per agent, the owner's coins left in their own
|
|
244
|
+
* address balance under one allowance per coin the rules spend, the policies, and the gas terms.
|
|
245
|
+
*/
|
|
246
|
+
export const SetupSchema = z
|
|
247
|
+
.object({
|
|
248
|
+
name,
|
|
249
|
+
policy: PolicySchema,
|
|
250
|
+
ttlMs,
|
|
251
|
+
expiresMs,
|
|
252
|
+
/** Lifetime caps, by coin type: the hard backstop under the policies. */
|
|
253
|
+
allowances: z.record(coinType, positive),
|
|
254
|
+
gasAmount,
|
|
255
|
+
/** A separate SUI allowance for gas top-ups: lifetime cap and daily window. */
|
|
256
|
+
gasCap: positive,
|
|
257
|
+
gasDaily: positive,
|
|
258
|
+
})
|
|
259
|
+
.strict()
|
|
260
|
+
.refine((s) => BigInt(s.gasDaily) <= BigInt(s.gasCap), 'Daily gas limit must not exceed the gas lifetime cap')
|
|
261
|
+
.refine((s) => {
|
|
262
|
+
const caps = new Set(Object.keys(s.allowances).map((c) => normalizeStructTag(c)));
|
|
263
|
+
const spent = spentCoins(s.policy);
|
|
264
|
+
return spent.every((c) => caps.has(c)) && caps.size === spent.length;
|
|
265
|
+
}, 'Exactly the coins the rules spend need an allowance')
|
|
266
|
+
.refine(({ policy: p, allowances }) => {
|
|
267
|
+
const cap = (coin) => BigInt(allowances[normalizeStructTag(coin)] ?? '0');
|
|
268
|
+
return (p.transfers.every((t) => BigInt(t.daily) <= cap(t.coin)) &&
|
|
269
|
+
p.swaps.every((s) => BigInt(s.daily) <= cap(s.coinIn)) &&
|
|
270
|
+
p.lending.every((l) => BigInt(l.daily) <= cap(l.coin)) &&
|
|
271
|
+
(!p.staking || BigInt(p.staking.daily) <= cap(SUI)));
|
|
272
|
+
}, 'A daily limit must not exceed the lifetime cap of its coin');
|
|
273
|
+
/** What activation confirmed on chain: the pass, the owner's receipt for it, and the allowances. */
|
|
274
|
+
export const ActiveSessionSchema = z
|
|
275
|
+
.object({
|
|
276
|
+
passId: address,
|
|
277
|
+
/** The discovery receipt in the owner's wallet. */
|
|
278
|
+
receiptId: address,
|
|
279
|
+
/** The pass-bound allowance and its revoke cap, by coin type. */
|
|
280
|
+
allowances: z.record(coinType, z.object({ allowance: address, revokeCap: address }).strict()),
|
|
281
|
+
/** The dedicated SUI gas allowance and its revoke cap. */
|
|
282
|
+
gasAllowance: address,
|
|
283
|
+
gasRevokeCap: address,
|
|
284
|
+
owner: address,
|
|
285
|
+
authorizationDigest: z.string().min(1),
|
|
286
|
+
})
|
|
287
|
+
.strict();
|
|
288
|
+
/** Decimal strings enter and leave the UI; all arithmetic stays in integer base units. */
|
|
289
|
+
export function units(value, decimals) {
|
|
290
|
+
if (!new RegExp(`^(0|[1-9][0-9]*)(\\.[0-9]{1,${decimals}})?$`).test(value))
|
|
291
|
+
throw new Error(`Enter a non-negative number with at most ${decimals} decimals`);
|
|
292
|
+
const [whole, fraction = ''] = value.split('.');
|
|
293
|
+
return amount.parse((BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction.padEnd(decimals, '0'))).toString());
|
|
294
|
+
}
|
|
295
|
+
export function display(value, decimals) {
|
|
296
|
+
const n = BigInt(value), scale = 10n ** BigInt(decimals);
|
|
297
|
+
const fraction = (n % scale).toString().padStart(decimals, '0').replace(/0+$/, '');
|
|
298
|
+
return `${n / scale}${fraction ? `.${fraction}` : ''}`;
|
|
299
|
+
}
|
|
300
|
+
/** A coin type as an owner can recognise it: the package's first and last characters and the name. */
|
|
301
|
+
export function shortType(type) {
|
|
302
|
+
const [pkg, , name] = normalizeStructTag(type).split('::');
|
|
303
|
+
return `${pkg.slice(0, 6)}…${pkg.slice(-4)}::${name}`;
|
|
304
|
+
}
|
|
305
|
+
/** A coin's name as the page prints it: the symbol, plus the type when the deployment never vetted it. */
|
|
306
|
+
export function coinLabel(d, type) {
|
|
307
|
+
const c = d.coins.find((x) => sameCoin(x.type, type));
|
|
308
|
+
if (!c)
|
|
309
|
+
return shortType(type);
|
|
310
|
+
return c.unlisted ? `${c.symbol} (${shortType(type)})` : c.symbol;
|
|
311
|
+
}
|
|
312
|
+
/** An amount of a coin in human terms, with its name. */
|
|
313
|
+
export function showCoin(d, type, value) {
|
|
314
|
+
const c = d.coins.find((x) => sameCoin(x.type, type));
|
|
315
|
+
return c
|
|
316
|
+
? `${display(value, c.decimals)} ${coinLabel(d, type)}`
|
|
317
|
+
: `${value} base units of ${type}`;
|
|
318
|
+
}
|
|
319
|
+
/** The coins and pools a policy names, for the facts a page or server must know about them. */
|
|
320
|
+
export function policyNeeds(p) {
|
|
321
|
+
const coins = new Set(), pools = new Set();
|
|
322
|
+
for (const t of p.transfers)
|
|
323
|
+
coins.add(normalizeStructTag(t.coin));
|
|
324
|
+
for (const s of p.swaps) {
|
|
325
|
+
coins.add(normalizeStructTag(s.coinIn));
|
|
326
|
+
coins.add(normalizeStructTag(s.coinOut));
|
|
327
|
+
for (const id of s.pools)
|
|
328
|
+
pools.add(id);
|
|
329
|
+
}
|
|
330
|
+
for (const l of p.lending)
|
|
331
|
+
coins.add(normalizeStructTag(l.coin));
|
|
332
|
+
if (p.staking)
|
|
333
|
+
coins.add(SUI);
|
|
334
|
+
return { coins: [...coins], pools: [...pools] };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A swap policy's price scale is `coinOut` base units per 1e9 base units of `coinIn`. A human
|
|
338
|
+
* price is `coinOut` per one `coinIn`; the two differ by the coins' decimals.
|
|
339
|
+
*/
|
|
340
|
+
export function priceUnits(d, coinIn, coinOut, price) {
|
|
341
|
+
const shift = coinInfo(d, coinOut).decimals + 9 - coinInfo(d, coinIn).decimals;
|
|
342
|
+
if (shift >= 0)
|
|
343
|
+
return units(price, shift);
|
|
344
|
+
return (BigInt(units(price, 0)) / 10n ** BigInt(-shift)).toString();
|
|
345
|
+
}
|
|
346
|
+
export function priceDisplay(d, coinIn, coinOut, minPrice) {
|
|
347
|
+
const shift = coinInfo(d, coinOut).decimals + 9 - coinInfo(d, coinIn).decimals;
|
|
348
|
+
return shift >= 0
|
|
349
|
+
? display(minPrice, shift)
|
|
350
|
+
: (BigInt(minPrice) * 10n ** BigInt(-shift)).toString();
|
|
351
|
+
}
|
|
352
|
+
/** Health factors are entered with two decimals and stored on chain in ray units. */
|
|
353
|
+
export const healthFactorRay = (hf) => hf === null ? '0' : ((BigInt(units(hf.toFixed(2), 2)) * RAY) / 100n).toString();
|
|
354
|
+
/** Gas top-ups per UTC day when the owner names none: a fifth of the lifetime cap, so a runaway
|
|
355
|
+
* agent burns the gas budget over days, not hours. */
|
|
356
|
+
export const defaultGasDaily = (gasCap) => {
|
|
357
|
+
const fifth = BigInt(gasCap) / 5n;
|
|
358
|
+
return (fifth > 0n ? fifth : BigInt(gasCap)).toString();
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* What the rules can draw from the owner's address balance, by coin: each allowance's cap, and
|
|
362
|
+
* the gas cap on top for SUI. Keys are normalized coin types.
|
|
363
|
+
*/
|
|
364
|
+
export function drawable(setup) {
|
|
365
|
+
const need = {};
|
|
366
|
+
for (const [coin, cap] of Object.entries(setup.allowances))
|
|
367
|
+
need[normalizeStructTag(coin)] = (need[normalizeStructTag(coin)] ?? 0n) + BigInt(cap);
|
|
368
|
+
need[SUI] = (need[SUI] ?? 0n) + BigInt(setup.gasCap);
|
|
369
|
+
return Object.fromEntries(Object.entries(need).map(([c, v]) => [c, v.toString()]));
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Allowances draw only from the owner's address balance, never from coin objects. What the
|
|
373
|
+
* authorization converts from the owner's coins into their address balance is, per coin, the
|
|
374
|
+
* shortfall between what the rules can draw (`drawable`) and what that balance already holds.
|
|
375
|
+
* Converted coins stay the owner's to spend.
|
|
376
|
+
*/
|
|
377
|
+
export function conversion(setup, held) {
|
|
378
|
+
const have = (coin) => BigInt(held[normalizeStructTag(coin)] ?? '0');
|
|
379
|
+
return Object.fromEntries(Object.entries(drawable(setup)).map(([coin, want]) => [
|
|
380
|
+
coin,
|
|
381
|
+
(BigInt(want) > have(coin) ? BigInt(want) - have(coin) : 0n).toString(),
|
|
382
|
+
]));
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* SUI the owner must hold in total to sign, address balance and coin objects together: what the
|
|
386
|
+
* rules may draw, the stipend and a fee reserve. Gas comes from either side, so nothing has to
|
|
387
|
+
* be a coin object; the authorization consolidates what it touches into the address balance.
|
|
388
|
+
*/
|
|
389
|
+
export function requiredSui(setup) {
|
|
390
|
+
return (BigInt(drawable(setup)[SUI]) + BigInt(setup.gasAmount) + OWNER_FEE_RESERVE).toString();
|
|
391
|
+
}
|
|
392
|
+
/** Set aside for the owner's own transaction fees when checking what they hold. */
|
|
393
|
+
export const OWNER_FEE_RESERVE = 50000000n;
|
package/dist/explorer.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { normalizeSuiAddress } from '@mysten/sui/utils';
|
|
4
|
+
import { createdEventType, discoverAllowances, parseCreated, readControl, } from './chain.js';
|
|
5
|
+
import { describeAttempt } from './describe.js';
|
|
6
|
+
async function createdEvents(client, d, limit, before = null) {
|
|
7
|
+
// Newest first; a continuation reads the page before the last cursor.
|
|
8
|
+
const page = await client.listEvents({
|
|
9
|
+
filter: { eventType: createdEventType(d) },
|
|
10
|
+
order: 'descending',
|
|
11
|
+
limit,
|
|
12
|
+
...(before ? { before } : {}),
|
|
13
|
+
});
|
|
14
|
+
const summaries = await Promise.all(page.events.map(async (e) => {
|
|
15
|
+
const r = await client.getTransaction({ digest: e.transactionDigest }).catch(() => null);
|
|
16
|
+
const tx = r?.Transaction ?? r?.FailedTransaction;
|
|
17
|
+
return {
|
|
18
|
+
...parseCreated(e.bcs),
|
|
19
|
+
digest: e.transactionDigest,
|
|
20
|
+
timestampMs: tx?.timestampMs ?? null,
|
|
21
|
+
};
|
|
22
|
+
}));
|
|
23
|
+
return { summaries, hasNextPage: page.hasNextPage, endCursor: page.endCursor };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The pass most recently created for an agent key, among the newest `Created` events: how the
|
|
27
|
+
* process that proposed a pass learns that the owner signed, with nothing sent back to it.
|
|
28
|
+
*/
|
|
29
|
+
export async function findPassByAgent(client, d, agent) {
|
|
30
|
+
const wanted = normalizeSuiAddress(agent);
|
|
31
|
+
let cursor = null;
|
|
32
|
+
for (let pages = 0; pages < 10; pages++) {
|
|
33
|
+
const page = await client.listEvents({
|
|
34
|
+
filter: { eventType: createdEventType(d) },
|
|
35
|
+
order: 'descending',
|
|
36
|
+
limit: 50,
|
|
37
|
+
...(cursor ? { before: cursor } : {}),
|
|
38
|
+
});
|
|
39
|
+
for (const e of page.events) {
|
|
40
|
+
const created = parseCreated(e.bcs);
|
|
41
|
+
if (created.agent === wanted)
|
|
42
|
+
return { ...created, digest: e.transactionDigest };
|
|
43
|
+
}
|
|
44
|
+
if (!page.hasNextPage || !page.endCursor)
|
|
45
|
+
return null;
|
|
46
|
+
cursor = page.endCursor;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Every pass ever created for an agent key, newest first, among the package's `Created`
|
|
52
|
+
* events. A key is meant to sign one authorization; more than one here means a proposal was
|
|
53
|
+
* signed again.
|
|
54
|
+
*/
|
|
55
|
+
export async function passesByAgent(client, d, agent) {
|
|
56
|
+
const wanted = normalizeSuiAddress(agent), found = [];
|
|
57
|
+
let cursor = null;
|
|
58
|
+
for (let pages = 0; pages < 10; pages++) {
|
|
59
|
+
const page = await client.listEvents({
|
|
60
|
+
filter: { eventType: createdEventType(d) },
|
|
61
|
+
order: 'descending',
|
|
62
|
+
limit: 50,
|
|
63
|
+
...(cursor ? { before: cursor } : {}),
|
|
64
|
+
});
|
|
65
|
+
for (const e of page.events) {
|
|
66
|
+
const created = parseCreated(e.bcs);
|
|
67
|
+
if (created.agent === wanted)
|
|
68
|
+
found.push({ ...created, digest: e.transactionDigest });
|
|
69
|
+
}
|
|
70
|
+
if (!page.hasNextPage || !page.endCursor)
|
|
71
|
+
break;
|
|
72
|
+
cursor = page.endCursor;
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
/** The most recently created passes on this package. */
|
|
77
|
+
export async function listPasses(client, d, limit = 50) {
|
|
78
|
+
return (await createdEvents(client, d, limit)).summaries;
|
|
79
|
+
}
|
|
80
|
+
/** One pass's creation record, found among the package's `Created` events. */
|
|
81
|
+
export async function findPass(client, d, passId) {
|
|
82
|
+
const wanted = normalizeSuiAddress(passId);
|
|
83
|
+
let cursor = null;
|
|
84
|
+
// A bounded scan: the demo package will not hold thousands of passes.
|
|
85
|
+
for (let pages = 0; pages < 40; pages++) {
|
|
86
|
+
const { summaries, hasNextPage, endCursor } = await createdEvents(client, d, 50, cursor);
|
|
87
|
+
const hit = summaries.find((s) => s.id === wanted);
|
|
88
|
+
if (hit)
|
|
89
|
+
return hit;
|
|
90
|
+
if (!hasNextPage || !endCursor)
|
|
91
|
+
return null;
|
|
92
|
+
cursor = endCursor;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The allowances and caps the authorization created, identified the way activation does: the
|
|
98
|
+
* pass-bound ones carry the `PASS` app and the owner as funder, the gas allowance its fixed
|
|
99
|
+
* name. Null once the owner has revoked, because the allowances no longer exist.
|
|
100
|
+
*/
|
|
101
|
+
export async function discoverActive(client, d, pass) {
|
|
102
|
+
const control = await readControl(client, d, pass.id);
|
|
103
|
+
const { allowances, gas } = await discoverAllowances(client, d, {
|
|
104
|
+
owner: pass.owner,
|
|
105
|
+
agent: control.agent,
|
|
106
|
+
});
|
|
107
|
+
if (!gas || Object.keys(allowances).length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
passId: pass.id,
|
|
111
|
+
receiptId: pass.receiptId,
|
|
112
|
+
allowances,
|
|
113
|
+
gasAllowance: gas.allowance,
|
|
114
|
+
gasRevokeCap: gas.revokeCap,
|
|
115
|
+
owner: pass.owner,
|
|
116
|
+
authorizationDigest: pass.digest,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const include = { transaction: true, balanceChanges: true };
|
|
120
|
+
/** The agent's most recent transactions, newest first, read straight from the chain. */
|
|
121
|
+
export async function agentAttempts(client, d, agent, recipients, limit = 30) {
|
|
122
|
+
const page = await client.listTransactions({
|
|
123
|
+
filter: { sender: agent },
|
|
124
|
+
order: 'descending',
|
|
125
|
+
limit,
|
|
126
|
+
include,
|
|
127
|
+
});
|
|
128
|
+
return page.transactions.map((r) => describeAttempt(d, agent, recipients, (r.Transaction ?? r.FailedTransaction)));
|
|
129
|
+
}
|
|
130
|
+
/** Every recent call into the package's `pass` module, from any agent or owner. */
|
|
131
|
+
export async function packageFeed(client, d, limit = 40) {
|
|
132
|
+
const page = await client.listTransactions({
|
|
133
|
+
filter: { function: `${d.publishedAt}::pass` },
|
|
134
|
+
order: 'descending',
|
|
135
|
+
limit,
|
|
136
|
+
include,
|
|
137
|
+
});
|
|
138
|
+
return page.transactions.map((r) => {
|
|
139
|
+
const tx = (r.Transaction ?? r.FailedTransaction);
|
|
140
|
+
const sender = normalizeSuiAddress(tx.transaction?.sender ?? '0x0');
|
|
141
|
+
return { ...describeAttempt(d, sender, [], tx), sender };
|
|
142
|
+
});
|
|
143
|
+
}
|
package/dist/float.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Copyright (c) Mysten Labs, Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { Transaction } from '@mysten/sui/transactions';
|
|
4
|
+
import { readControl } from './chain.js';
|
|
5
|
+
import { SUI } from './domain.js';
|
|
6
|
+
import { SessionStore } from './storage.js';
|
|
7
|
+
/** The least budget the network accepts for a transaction, at the reference gas price. */
|
|
8
|
+
const MIN_BUDGET = 3000000n;
|
|
9
|
+
/** Below this there is nothing worth a transaction. */
|
|
10
|
+
const DUST = MIN_BUDGET + 1000000n;
|
|
11
|
+
/** A pass is over when its object is gone (closed) or its agent is revoked (stopped). */
|
|
12
|
+
export async function passEnded(client, d, passId) {
|
|
13
|
+
return client.getObject({ objectId: passId }).then(() => readControl(client, d, passId).then((c) => c.revoked), () => true);
|
|
14
|
+
}
|
|
15
|
+
/** A pass is closed when its object is gone. */
|
|
16
|
+
export async function passClosed(client, passId) {
|
|
17
|
+
return client.getObject({ objectId: passId }).then(() => false, () => true);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Sends the session key's whole SUI address balance to `owner`, less the gas this transaction
|
|
21
|
+
* itself costs: a first simulation prices it, the real transaction keeps twice that as budget
|
|
22
|
+
* and sends the rest, so what stays behind is about one transaction's fee. Returns the digest,
|
|
23
|
+
* or null when the float was dust.
|
|
24
|
+
*/
|
|
25
|
+
export async function returnGasFloat(client, directory, sessionAddress, owner) {
|
|
26
|
+
// Another process (the session's own signer, say) may be sweeping at the same moment; a
|
|
27
|
+
// rejection for a balance that moved under us is answered by looking again.
|
|
28
|
+
for (let attempt = 0;; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
return await sweepOnce(client, directory, sessionAddress, owner);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (attempt > 0 || !/[Ii]nsufficient/.test(error.message))
|
|
34
|
+
throw error;
|
|
35
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function sweepOnce(client, directory, sessionAddress, owner) {
|
|
40
|
+
const { balance } = await client.getBalance({ owner: sessionAddress, coinType: SUI });
|
|
41
|
+
const float = BigInt(balance.addressBalance);
|
|
42
|
+
if (float <= DUST)
|
|
43
|
+
return null;
|
|
44
|
+
const build = (amount, budget) => {
|
|
45
|
+
const tx = new Transaction();
|
|
46
|
+
tx.setSender(sessionAddress);
|
|
47
|
+
tx.setGasPayment([]);
|
|
48
|
+
tx.setGasBudget(Number(budget));
|
|
49
|
+
tx.moveCall({
|
|
50
|
+
target: '0x2::balance::send_funds',
|
|
51
|
+
typeArguments: [SUI],
|
|
52
|
+
arguments: [tx.balance({ balance: amount.toString() }), tx.pure.address(owner)],
|
|
53
|
+
});
|
|
54
|
+
return tx;
|
|
55
|
+
};
|
|
56
|
+
const probeBudget = MIN_BUDGET;
|
|
57
|
+
const sim = await client.simulateTransaction({
|
|
58
|
+
transaction: build(float - probeBudget, probeBudget),
|
|
59
|
+
include: { effects: true },
|
|
60
|
+
});
|
|
61
|
+
const t = sim.$kind === 'Transaction' ? sim.Transaction : sim.FailedTransaction;
|
|
62
|
+
if (!t.status.success)
|
|
63
|
+
throw new Error(`Could not price the sweep: ${t.status.error?.message}`);
|
|
64
|
+
const used = t.effects.gasUsed;
|
|
65
|
+
const fee = BigInt(used?.computationCost ?? '0') +
|
|
66
|
+
BigInt(used?.storageCost ?? '0') -
|
|
67
|
+
BigInt(used?.storageRebate ?? '0');
|
|
68
|
+
const budget = fee * 2n > MIN_BUDGET ? fee * 2n : MIN_BUDGET;
|
|
69
|
+
if (float <= budget)
|
|
70
|
+
return null;
|
|
71
|
+
const amount = float - budget;
|
|
72
|
+
const result = await client.signAndExecuteTransaction({
|
|
73
|
+
transaction: build(amount, budget),
|
|
74
|
+
signer: await SessionStore.keyOf(directory),
|
|
75
|
+
});
|
|
76
|
+
if (result.$kind !== 'Transaction')
|
|
77
|
+
throw new Error(`The sweep was refused: ${result.FailedTransaction.status.error?.message}`);
|
|
78
|
+
await client.waitForTransaction({ result });
|
|
79
|
+
return { digest: result.Transaction.digest, amount: amount.toString() };
|
|
80
|
+
}
|