@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/dist/owner.js ADDED
@@ -0,0 +1,410 @@
1
+ // Copyright (c) Mysten Labs, Inc.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /** Owner transaction construction belongs to the consent flow, never to MCP. */
4
+ import { Transaction, } from '@mysten/sui/transactions';
5
+ import { normalizeStructTag } from '@mysten/sui/utils';
6
+ import { appType } from './chain.js';
7
+ import { AGENT_GAS_NAME, DAY_MS, healthFactorRay, sameCoin, SUI, } from './domain.js';
8
+ import { canSweep, refreshNaviPrices, withdrawAllInto } from './navi.js';
9
+ const RATE_LIMIT = '0x2::allowance::RateLimit';
10
+ const CLOCK = '0x6';
11
+ export const STAKED_SUI = '0x3::staking_pool::StakedSui';
12
+ const balanceOf = (coin) => `0x2::balance::Balance<${coin}>`;
13
+ /**
14
+ * Each rule becomes one policy object, built by its own module and installed with the
15
+ * pass's generic `add_policy`. Every policy names the coin type it governs.
16
+ */
17
+ function installPolicies(tx, d, pass, p, openNavi) {
18
+ // Calls go to the latest upgrade; the policy types belong to the original package.
19
+ const pkg = d.publishedAt, types = d.packageId, clock = tx.object(CLOCK), config = tx.object(d.configId);
20
+ const install = (type, policy) => tx.moveCall({
21
+ target: `${pkg}::pass::add_policy`,
22
+ typeArguments: [type],
23
+ arguments: [pass, config, policy],
24
+ });
25
+ for (const t of p.transfers) {
26
+ const policy = tx.moveCall({
27
+ target: `${pkg}::transfer_policy::new`,
28
+ typeArguments: [t.coin],
29
+ arguments: [tx.pure.u64(t.perTransfer), tx.pure.u64(t.daily), clock],
30
+ });
31
+ for (const r of t.recipients)
32
+ tx.moveCall({
33
+ target: `${pkg}::transfer_policy::add_recipient`,
34
+ typeArguments: [t.coin],
35
+ arguments: [policy, tx.pure.string(r.name), tx.pure.address(r.address)],
36
+ });
37
+ install(`${types}::transfer_policy::TransferPolicy<${t.coin}>`, policy);
38
+ }
39
+ for (const sw of p.swaps)
40
+ install(`${types}::swap_policy::SwapPolicy<${sw.coinIn},${sw.coinOut}>`, tx.moveCall({
41
+ target: `${pkg}::swap_policy::new`,
42
+ typeArguments: [sw.coinIn, sw.coinOut],
43
+ arguments: [
44
+ tx.pure.vector('address', sw.pools),
45
+ tx.pure.u64(sw.perTrade),
46
+ tx.pure.u64(sw.daily),
47
+ tx.pure.u64(sw.minPrice),
48
+ clock,
49
+ ],
50
+ }));
51
+ for (const l of p.lending) {
52
+ const reserve = d.protocols.navi.reserves[l.coin];
53
+ if (!reserve)
54
+ throw new Error(`NAVI has no reserve for ${l.coin} in this deployment`);
55
+ install(`${types}::lending_policy::LendingPolicy<${l.coin}>`, tx.moveCall({
56
+ target: `${pkg}::lending_policy::new`,
57
+ typeArguments: [l.coin],
58
+ arguments: [
59
+ tx.pure.id(reserve.poolId),
60
+ tx.pure.u8(reserve.assetId),
61
+ tx.pure.bool(l.allowBorrow),
62
+ tx.pure.u64(l.perAction),
63
+ tx.pure.u64(l.daily),
64
+ tx.pure.u256(healthFactorRay(l.minHealthFactor)),
65
+ clock,
66
+ ],
67
+ }));
68
+ }
69
+ // The agent's lending runs on the pass's NAVI account, which only the owner opens, once.
70
+ if (p.lending.length && openNavi)
71
+ tx.moveCall({ target: `${pkg}::pass::navi_open`, arguments: [pass, config] });
72
+ if (p.staking)
73
+ install(`${types}::staking_policy::StakingPolicy`, tx.moveCall({
74
+ target: `${pkg}::staking_policy::new`,
75
+ arguments: [
76
+ tx.pure.vector('address', p.staking.validators),
77
+ tx.pure.u64(p.staking.perStake),
78
+ tx.pure.u64(p.staking.daily),
79
+ clock,
80
+ ],
81
+ }));
82
+ }
83
+ function gasStipend(tx, amount, session) {
84
+ tx.moveCall({
85
+ target: '0x2::balance::send_funds',
86
+ typeArguments: [SUI],
87
+ arguments: [tx.balance({ balance: amount }), tx.pure.address(session)],
88
+ });
89
+ }
90
+ /**
91
+ * Allowances draw only from the owner's address balance, so the authorization converts the
92
+ * owner's coin objects into it as far as the caps need (`conversion`), coin by coin. SUI comes
93
+ * off the gas coin, which the wallet backs with the address balance and coin objects together,
94
+ * so whatever it touches ends in the address balance. Any other coin is taken from the listed
95
+ * coin objects explicitly: a plain balance request would be served from the address balance
96
+ * when it is large enough, converting nothing. A demo stablecoin is minted from its faucet.
97
+ */
98
+ function convertCoins(tx, d, convert, coins, owner) {
99
+ for (const [coin, amount] of Object.entries(convert)) {
100
+ if (BigInt(amount) <= 0n)
101
+ continue;
102
+ const faucet = d.demoFaucet && sameCoin(d.demoFaucet.coin, coin) ? d.demoFaucet : null;
103
+ let source;
104
+ if (faucet)
105
+ source = tx.moveCall({
106
+ target: `${faucet.packageId}::usdc::mint`,
107
+ arguments: [tx.object(faucet.faucetId), tx.pure.u64(amount)],
108
+ });
109
+ else if (sameCoin(coin, SUI))
110
+ source = tx.splitCoins(tx.gas, [amount])[0];
111
+ else {
112
+ const refs = coins[normalizeStructTag(coin)] ?? [];
113
+ if (!refs.length)
114
+ throw new Error(`No ${coin} coin objects to convert`);
115
+ const [first, ...rest] = refs.map((r) => tx.objectRef(r));
116
+ if (rest.length)
117
+ tx.mergeCoins(first, rest);
118
+ source = tx.splitCoins(first, [amount])[0];
119
+ }
120
+ tx.moveCall({
121
+ target: '0x2::coin::send_funds',
122
+ typeArguments: [coin],
123
+ arguments: [source, tx.pure.address(owner)],
124
+ });
125
+ }
126
+ }
127
+ /** A daily-window rate limit as an `Option<RateLimit>`. */
128
+ function rateLimitOption(tx, dailyLimit) {
129
+ const limit = tx.moveCall({
130
+ target: '0x2::allowance::periodic_rate_limit',
131
+ arguments: [tx.pure.u64(DAY_MS), tx.pure.u256(dailyLimit)],
132
+ });
133
+ return tx.moveCall({
134
+ target: '0x1::option::some',
135
+ typeArguments: [RATE_LIMIT],
136
+ arguments: [limit],
137
+ });
138
+ }
139
+ /**
140
+ * A separate, non-app-bound SUI allowance the agent spends to top up its own gas float. Its own
141
+ * lifetime cap and daily window bound gas spend independently of the asset-management allowances.
142
+ */
143
+ function issueGasAllowance(tx, setup, session) {
144
+ tx.moveCall({
145
+ target: '0x2::allowance::new',
146
+ typeArguments: [balanceOf(SUI)],
147
+ arguments: [
148
+ tx.pure.string(AGENT_GAS_NAME),
149
+ tx.pure.address(session),
150
+ tx.pure.option('u256', setup.gasCap),
151
+ tx.pure.option('u64', null),
152
+ tx.pure.option('u64', setup.expiresMs),
153
+ rateLimitOption(tx, setup.gasDaily),
154
+ ],
155
+ });
156
+ }
157
+ /**
158
+ * One PTB: the pass (its receipt goes to the owner), one allowance bound to the pass module per
159
+ * coin the rules spend, the gas allowance, the policies, the NAVI account when lending is allowed, then the
160
+ * owner's coins converted into their address balance as far as the caps need, and the session's
161
+ * gas stipend sent. Coins never enter the pass.
162
+ */
163
+ export function buildAuthorization(d, setup, owner, session, convert, coins = {}) {
164
+ const tx = new Transaction();
165
+ tx.setSender(owner);
166
+ const pass = tx.moveCall({
167
+ target: `${d.publishedAt}::pass::create`,
168
+ arguments: [
169
+ tx.object(d.configId),
170
+ tx.pure.string(setup.name),
171
+ tx.pure.address(session),
172
+ tx.pure.u64(setup.expiresMs),
173
+ tx.object(CLOCK),
174
+ ],
175
+ });
176
+ const issue = (coin, cap) => issueAllowance(tx, d, pass, setup, session, coin, cap);
177
+ for (const [coin, cap] of Object.entries(setup.allowances))
178
+ issue(coin, cap);
179
+ issueGasAllowance(tx, setup, session);
180
+ installPolicies(tx, d, pass, setup.policy, true);
181
+ tx.moveCall({ target: `${d.publishedAt}::pass::share`, arguments: [pass] });
182
+ convertCoins(tx, d, convert, coins, owner);
183
+ gasStipend(tx, setup.gasAmount, session);
184
+ return tx;
185
+ }
186
+ /**
187
+ * Revocation ends the agent's term on the pass and deletes every allowance, so later spends
188
+ * are refused before execution.
189
+ */
190
+ /** One pass-bound allowance over the owner's balance: proposed with the pass's terms, issued by the pass. */
191
+ function issueAllowance(tx, d, pass, setup, session, coin, cap) {
192
+ tx.moveCall({
193
+ target: `${d.publishedAt}::pass::issue`,
194
+ typeArguments: [coin],
195
+ arguments: [
196
+ pass,
197
+ tx.object(d.configId),
198
+ tx.moveCall({
199
+ target: '0x2::allowance::propose_for_app',
200
+ typeArguments: [balanceOf(coin), appType(d)],
201
+ arguments: [
202
+ tx.pure.string(setup.name),
203
+ tx.pure.address(session),
204
+ tx.pure.option('u256', cap),
205
+ tx.pure.option('u64', null),
206
+ tx.pure.option('u64', setup.expiresMs),
207
+ tx.moveCall({ target: '0x1::option::none', typeArguments: [RATE_LIMIT] }),
208
+ ],
209
+ }),
210
+ ],
211
+ });
212
+ }
213
+ function revokeAllowance(tx, coin, cap, allowance) {
214
+ tx.moveCall({
215
+ target: '0x2::allowance::revoke',
216
+ typeArguments: [balanceOf(coin)],
217
+ arguments: [tx.object(cap), tx.object(allowance)],
218
+ });
219
+ }
220
+ /** Removes every installed policy; transfer policies hold a table and must be destroyed, the others drop. */
221
+ function removePolicies(tx, d, pass, policies) {
222
+ const call = d.publishedAt, types = d.packageId;
223
+ const remove = (type) => tx.moveCall({
224
+ target: `${call}::pass::remove_policy`,
225
+ typeArguments: [type],
226
+ arguments: [pass],
227
+ });
228
+ for (const t of policies.transfers)
229
+ tx.moveCall({
230
+ target: `${call}::transfer_policy::destroy`,
231
+ typeArguments: [t.coinType],
232
+ arguments: [remove(`${types}::transfer_policy::TransferPolicy<${t.coinType}>`)],
233
+ });
234
+ for (const s of policies.swaps)
235
+ remove(`${types}::swap_policy::SwapPolicy<${s.coinIn},${s.coinOut}>`);
236
+ for (const l of policies.lending)
237
+ remove(`${types}::lending_policy::LendingPolicy<${l.coinType}>`);
238
+ if (policies.staking.length)
239
+ remove(`${types}::staking_policy::StakingPolicy`);
240
+ }
241
+ /**
242
+ * Replaces a pass's rules with `next` in one owner transaction: the term first (new allowances
243
+ * may not outlive it), then every policy removed and the new set installed, then each coin's
244
+ * allowance kept when its cap and expiry are unchanged and otherwise revoked and reissued, the
245
+ * gas allowance likewise. The agent key stays, nothing is sent to it, and a NAVI position the
246
+ * pass already holds stays whether or not lending remains allowed. Replaced policies start with
247
+ * empty daily meters; kept allowances keep their lifetime spend.
248
+ */
249
+ export function buildAmendment(d, current, next, owner) {
250
+ const tx = new Transaction();
251
+ tx.setSender(owner);
252
+ const { active, control } = current, pass = tx.object(active.passId), session = control.agent;
253
+ if (String(next.expiresMs) !== control.agentExpiresMs)
254
+ tx.moveCall({
255
+ target: `${d.publishedAt}::pass::set_agent`,
256
+ arguments: [
257
+ pass,
258
+ tx.object(d.configId),
259
+ tx.pure.address(session),
260
+ tx.pure.u64(next.expiresMs),
261
+ tx.object(CLOCK),
262
+ ],
263
+ });
264
+ removePolicies(tx, d, pass, control.policies);
265
+ installPolicies(tx, d, pass, next.policy, next.policy.lending.length > 0 && !control.naviPosition);
266
+ const same = (v, cap, daily) => !!v &&
267
+ v.lifetimeCap === cap &&
268
+ v.expirationMs === String(next.expiresMs) &&
269
+ (daily === null ? v.rateLimit === null : v.rateLimit?.limit === daily);
270
+ for (const [coin, cap] of Object.entries(next.allowances)) {
271
+ const key = normalizeStructTag(coin), have = active.allowances[key];
272
+ if (have && same(current.allowances[key] ?? null, cap, null))
273
+ continue;
274
+ if (have)
275
+ revokeAllowance(tx, key, have.revokeCap, have.allowance);
276
+ issueAllowance(tx, d, pass, next, session, key, cap);
277
+ }
278
+ for (const [coin, have] of Object.entries(active.allowances))
279
+ if (!(normalizeStructTag(coin) in next.allowances) &&
280
+ !Object.keys(next.allowances).some((c) => sameCoin(c, coin)))
281
+ revokeAllowance(tx, coin, have.revokeCap, have.allowance);
282
+ if (!same(current.gas, next.gasCap, next.gasDaily)) {
283
+ revokeAllowance(tx, SUI, active.gasRevokeCap, active.gasAllowance);
284
+ issueGasAllowance(tx, next, session);
285
+ }
286
+ return tx;
287
+ }
288
+ export function buildRevocation(d, active, owner) {
289
+ const tx = new Transaction();
290
+ tx.setSender(owner);
291
+ tx.moveCall({
292
+ target: `${d.publishedAt}::pass::revoke_agent`,
293
+ arguments: [tx.object(active.passId)],
294
+ });
295
+ for (const [coin, cap, allowance] of grants(active))
296
+ tx.moveCall({
297
+ target: '0x2::allowance::revoke',
298
+ typeArguments: [balanceOf(coin)],
299
+ arguments: [tx.object(cap), tx.object(allowance)],
300
+ });
301
+ return tx;
302
+ }
303
+ /** Every (coin, revoke cap, allowance) triple of a session: the pass-bound ones and gas. */
304
+ export function grants(active) {
305
+ return [
306
+ ...Object.entries(active.allowances).map(([coin, ids]) => [
307
+ coin,
308
+ ids.revokeCap,
309
+ ids.allowance,
310
+ ]),
311
+ [SUI, active.gasRevokeCap, active.gasAllowance],
312
+ ];
313
+ }
314
+ /**
315
+ * The owner takes the NAVI account cap out of the pass into their own wallet. NAVI files the
316
+ * position under the cap, so holding it is holding the position: the owner acts on NAVI directly
317
+ * and the agent has nothing to lend on until the cap is deposited again.
318
+ */
319
+ /** The pass's NAVI account cap, out of the pass and into the transaction's hands. */
320
+ function takeNaviCap(tx, d, pass) {
321
+ const key = tx.moveCall({ target: `${d.publishedAt}::navi::account_key` });
322
+ return tx.moveCall({
323
+ target: `${d.publishedAt}::pass::withdraw_object`,
324
+ typeArguments: [
325
+ `${d.packageId}::navi::AccountKey`,
326
+ `${d.protocols.navi.packageId}::account::AccountCap`,
327
+ ],
328
+ arguments: [pass, key],
329
+ });
330
+ }
331
+ /**
332
+ * Takes the pass's NAVI account into the owner's wallet, emptied of its collateral first when it
333
+ * owes nothing, since NAVI's app cannot operate a cap-based position.
334
+ */
335
+ export async function buildReclaimPosition(d, active, owner, position = null) {
336
+ const tx = new Transaction();
337
+ tx.setSender(owner);
338
+ if (canSweep(position))
339
+ await refreshNaviPrices(tx, d);
340
+ const cap = takeNaviCap(tx, d, tx.object(active.passId));
341
+ if (canSweep(position))
342
+ withdrawAllInto(tx, d, cap, position, owner);
343
+ tx.transferObjects([cap], owner);
344
+ return tx;
345
+ }
346
+ export function buildClose(d, pass, active, control, owner, position = null) {
347
+ return buildCloseAll(d, [{ pass, active, control, position }], owner);
348
+ }
349
+ /**
350
+ * Closes several passes of one owner in one transaction and one signature. NAVI's app does not
351
+ * see account-cap positions, so a position with collateral and no debt is withdrawn into the
352
+ * owner's wallet here, before its cap is handed over; one with debt comes over as it is.
353
+ */
354
+ export async function buildCloseAll(d, closings, owner) {
355
+ const tx = new Transaction();
356
+ tx.setSender(owner);
357
+ if (closings.some((c) => c.control.naviPosition && canSweep(c.position)))
358
+ await refreshNaviPrices(tx, d);
359
+ for (const c of closings)
360
+ closeInto(tx, d, c, owner);
361
+ return tx;
362
+ }
363
+ function closeInto(tx, d, { pass, active, control, position }, owner) {
364
+ const call = d.publishedAt, types = d.packageId;
365
+ const passArg = tx.object(pass.passId);
366
+ if (!control.revoked)
367
+ tx.moveCall({ target: `${call}::pass::revoke_agent`, arguments: [passArg] });
368
+ if (active)
369
+ for (const [coin, cap, allowance] of grants(active))
370
+ revokeAllowance(tx, coin, cap, allowance);
371
+ removePolicies(tx, d, passArg, control.policies);
372
+ const held = [];
373
+ if (control.naviPosition) {
374
+ const cap = takeNaviCap(tx, d, passArg);
375
+ if (canSweep(position))
376
+ withdrawAllInto(tx, d, cap, position, owner);
377
+ held.push(cap);
378
+ }
379
+ for (const s of control.stakes) {
380
+ const key = tx.moveCall({
381
+ target: `${call}::staking::stake_key`,
382
+ arguments: [tx.pure.id(s.id)],
383
+ });
384
+ held.push(tx.moveCall({
385
+ target: `${call}::pass::withdraw_object`,
386
+ typeArguments: [`${types}::staking::StakeKey`, STAKED_SUI],
387
+ arguments: [passArg, key],
388
+ }));
389
+ }
390
+ if (held.length)
391
+ tx.transferObjects(held, owner);
392
+ tx.moveCall({ target: `${call}::pass::destroy`, arguments: [passArg] });
393
+ tx.moveCall({ target: `${call}::pass::burn_receipt`, arguments: [tx.object(pass.receiptId)] });
394
+ }
395
+ /** The owner takes one stake out of the pass; it is then an ordinary `StakedSui` in their wallet. */
396
+ export function buildReclaimStake(d, active, owner, stakeId) {
397
+ const tx = new Transaction();
398
+ tx.setSender(owner);
399
+ const key = tx.moveCall({
400
+ target: `${d.packageId}::staking::stake_key`,
401
+ arguments: [tx.pure.id(stakeId)],
402
+ });
403
+ const stake = tx.moveCall({
404
+ target: `${d.publishedAt}::pass::withdraw_object`,
405
+ typeArguments: [`${d.packageId}::staking::StakeKey`, STAKED_SUI],
406
+ arguments: [tx.object(active.passId), key],
407
+ });
408
+ tx.transferObjects([stake], owner);
409
+ return tx;
410
+ }