lyra-plugin-onchain 0.1.8 → 0.1.10
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/package.json +2 -2
- package/src/client.ts +12 -3
- package/src/index.ts +13 -5
- package/src/protocol-ids.ts +48 -0
- package/src/protocols.ts +2 -9
- package/src/tools/liquid-stake.ts +8 -1
- package/src/tools/navi.ts +22 -7
- package/src/tools/scallop.ts +59 -42
- package/src/tools/send.ts +35 -9
- package/src/tools/stake.ts +9 -1
- package/src/tools/suilend.ts +101 -39
- package/src/tools/swap.ts +9 -1
- package/src/types.ts +21 -0
- package/src/vault-fund.ts +107 -0
- package/src/vault.ts +64 -0
- package/src/derive.test.ts +0 -45
- package/src/minimums.test.ts +0 -36
- package/src/policy.test.ts +0 -187
- package/src/tools/onchain.integration.test.ts +0 -111
- package/src/tools/tools.test.ts +0 -142
package/src/tools/suilend.ts
CHANGED
|
@@ -20,18 +20,22 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { Transaction } from '@mysten/sui/transactions'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
// Import from @suilend/sdk SUBPATHS, not the package index. The index re-exports
|
|
24
|
+
// strategies.js → @suilend/springsui-sdk, whose ESM does a directory import
|
|
25
|
+
// (`import './lib'`) that Node's ESM resolver rejects — which would break
|
|
26
|
+
// importing this plugin under Node (the Next.js web server; Bun tolerates it).
|
|
27
|
+
// The lending client + initialize subpaths don't pull springsui and load cleanly
|
|
28
|
+
// on both runtimes, so Suilend works on the web AND the CLI.
|
|
29
|
+
import { LENDING_MARKET_ID, LENDING_MARKET_TYPE, SuilendClient } from '@suilend/sdk/client'
|
|
30
|
+
import { initializeSuilend } from '@suilend/sdk/lib/initialize'
|
|
29
31
|
import type { ToolDef } from 'lyra-core'
|
|
30
32
|
import { z } from 'zod'
|
|
31
33
|
import { checkMinimum } from '../minimums'
|
|
32
34
|
import { evaluatePolicy, suiToMist } from '../policy'
|
|
35
|
+
import { PROTOCOL_IDS } from '../protocol-ids'
|
|
33
36
|
import { simulate } from '../simulate'
|
|
34
37
|
import type { OnchainRuntimeContext } from '../types'
|
|
38
|
+
import { fundSui } from '../vault-fund'
|
|
35
39
|
|
|
36
40
|
const SUI_TYPE = '0x2::sui::SUI'
|
|
37
41
|
// Canonical + long-form SUI coin types (reserve maps may use either).
|
|
@@ -57,19 +61,31 @@ function isSuiType(t: string): boolean {
|
|
|
57
61
|
return t === SUI_TYPE || t === SUI_LONG
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
/** The agent's obligation cap + id, or null if it has no Suilend position yet.
|
|
64
|
+
/** The agent's obligation cap + id, or null if it has no Suilend position yet.
|
|
65
|
+
* Retries: getObligationOwnerCaps reads owned objects + their BCS, which some
|
|
66
|
+
* fullnode replicas serve inconsistently right after a write ("invalid data
|
|
67
|
+
* type" / stale owned-object index) — a short retry rides out that lag. */
|
|
61
68
|
async function findObligation(
|
|
62
69
|
ctx: OnchainRuntimeContext,
|
|
63
70
|
): Promise<{ capId: string; obligationId: string } | null> {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
let lastErr: unknown
|
|
72
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
const caps = await SuilendClient.getObligationOwnerCaps(
|
|
75
|
+
ctx.agentAddress,
|
|
76
|
+
[LENDING_MARKET_TYPE],
|
|
77
|
+
ctx.client as never,
|
|
78
|
+
)
|
|
79
|
+
if (!caps.length) return null
|
|
80
|
+
const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
|
|
81
|
+
const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
|
|
82
|
+
return { capId: capObjectId(c), obligationId }
|
|
83
|
+
} catch (e) {
|
|
84
|
+
lastErr = e
|
|
85
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw lastErr
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
// --- suilend.supply --------------------------------------------------------
|
|
@@ -79,6 +95,43 @@ const AmountSchema = z.object({
|
|
|
79
95
|
})
|
|
80
96
|
type AmountArgs = z.infer<typeof AmountSchema>
|
|
81
97
|
|
|
98
|
+
// Borrowable/repayable assets. Suilend (like most money markets) forbids
|
|
99
|
+
// borrowing the SAME asset you post as collateral (obligation::borrow abort 8),
|
|
100
|
+
// so the canonical flow is "supply SUI → borrow a stablecoin". USDC is the
|
|
101
|
+
// default; SUI is available for the reverse (supply a stable, borrow SUI).
|
|
102
|
+
const USDC_TYPE = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
|
|
103
|
+
type CoinInfo = { type: string; decimals: number; minBase: bigint; label: string }
|
|
104
|
+
const BORROW_COINS: Record<'usdc' | 'sui', CoinInfo> = {
|
|
105
|
+
usdc: { type: USDC_TYPE, decimals: 6, minBase: 100_000n, label: 'USDC' }, // 0.1 USDC
|
|
106
|
+
sui: { type: SUI_TYPE, decimals: 9, minBase: 10_000_000n, label: 'SUI' }, // 0.01 SUI
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse a decimal amount into base units for a given decimals count. */
|
|
110
|
+
function toBaseUnits(amount: string, decimals: number): bigint | undefined {
|
|
111
|
+
const a = amount.trim()
|
|
112
|
+
if (!/^\d+(\.\d+)?$/.test(a)) return undefined
|
|
113
|
+
const dot = a.indexOf('.')
|
|
114
|
+
const whole = dot === -1 ? a : a.slice(0, dot)
|
|
115
|
+
const frac = dot === -1 ? '' : a.slice(dot + 1)
|
|
116
|
+
const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals)
|
|
117
|
+
try {
|
|
118
|
+
return BigInt(whole || '0') * 10n ** BigInt(decimals) + BigInt(fracPadded || '0')
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const BorrowSchema = z.object({
|
|
125
|
+
amount: z.string().min(1).describe('Amount to borrow/repay, e.g. "5".'),
|
|
126
|
+
coin: z
|
|
127
|
+
.enum(['usdc', 'sui'])
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
'Asset to borrow/repay (default "usdc"). Suilend disallows borrowing the same asset you supply as collateral, so borrowing USDC against SUI collateral is the canonical flow.',
|
|
131
|
+
),
|
|
132
|
+
})
|
|
133
|
+
type BorrowArgs = z.infer<typeof BorrowSchema>
|
|
134
|
+
|
|
82
135
|
export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
83
136
|
return {
|
|
84
137
|
name: 'suilend.supply',
|
|
@@ -107,7 +160,12 @@ export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArg
|
|
|
107
160
|
const existing = await findObligation(ctx)
|
|
108
161
|
const tx = new Transaction()
|
|
109
162
|
tx.setSender(ctx.agentAddress)
|
|
110
|
-
|
|
163
|
+
// Source the supply from the treasury vault (policy-enforced) when wired.
|
|
164
|
+
const coin = fundSui(tx, ctx, amountMist, {
|
|
165
|
+
protocol: PROTOCOL_IDS.suilend,
|
|
166
|
+
kind: 'supply',
|
|
167
|
+
memo: 'suilend supply',
|
|
168
|
+
})
|
|
111
169
|
// The @suilend/sdk@1.1.x SDK carries its own nested @mysten/sui copy, so
|
|
112
170
|
// TS sees a distinct Transaction class — cast at the boundary. The two
|
|
113
171
|
// copies interop at runtime (verified with a live mainnet dry-run).
|
|
@@ -217,24 +275,25 @@ export function makeSuilendWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
|
|
|
217
275
|
|
|
218
276
|
// --- suilend.borrow --------------------------------------------------------
|
|
219
277
|
|
|
220
|
-
export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<
|
|
278
|
+
export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
|
|
221
279
|
return {
|
|
222
280
|
name: 'suilend.borrow',
|
|
223
281
|
description:
|
|
224
|
-
'Borrow
|
|
225
|
-
searchHint: 'suilend borrow loan leverage debt against collateral
|
|
226
|
-
schema:
|
|
282
|
+
'Borrow an asset from Suilend against supplied collateral. Default borrows USDC against SUI collateral (Suilend disallows same-asset borrows). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
|
|
283
|
+
searchHint: 'suilend borrow loan leverage debt against collateral usdc stablecoin money market',
|
|
284
|
+
schema: BorrowSchema,
|
|
227
285
|
handler: async args => {
|
|
228
286
|
const err = ensureMainnet(ctx)
|
|
229
287
|
if (err) return { ok: false, error: err }
|
|
230
|
-
const
|
|
231
|
-
|
|
288
|
+
const coin = BORROW_COINS[args.coin ?? 'usdc']
|
|
289
|
+
const amountBase = toBaseUnits(args.amount, coin.decimals)
|
|
290
|
+
if (amountBase === undefined || amountBase <= 0n)
|
|
232
291
|
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
233
|
-
|
|
234
|
-
|
|
292
|
+
if (amountBase < coin.minBase)
|
|
293
|
+
return { ok: false, error: `amount too small: below the minimum ${coin.label} borrow` }
|
|
235
294
|
if (ctx.policy) {
|
|
236
295
|
const verdict = evaluatePolicy(
|
|
237
|
-
{ kind: 'transfer', coinType:
|
|
296
|
+
{ kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'borrow' },
|
|
238
297
|
ctx.policy,
|
|
239
298
|
)
|
|
240
299
|
if (!verdict.allowed)
|
|
@@ -252,8 +311,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
|
|
|
252
311
|
ctx.agentAddress,
|
|
253
312
|
obligation.capId,
|
|
254
313
|
obligation.obligationId,
|
|
255
|
-
|
|
256
|
-
|
|
314
|
+
coin.type,
|
|
315
|
+
amountBase.toString(),
|
|
257
316
|
tx as never,
|
|
258
317
|
)
|
|
259
318
|
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
@@ -274,7 +333,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
|
|
|
274
333
|
data: {
|
|
275
334
|
protocol: 'suilend',
|
|
276
335
|
action: 'borrow',
|
|
277
|
-
|
|
336
|
+
amount: args.amount,
|
|
337
|
+
coin: coin.label,
|
|
278
338
|
digest: res.digest,
|
|
279
339
|
policyEnforced: ctx.policy != null,
|
|
280
340
|
},
|
|
@@ -288,22 +348,23 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
|
|
|
288
348
|
|
|
289
349
|
// --- suilend.repay ---------------------------------------------------------
|
|
290
350
|
|
|
291
|
-
export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<
|
|
351
|
+
export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
|
|
292
352
|
return {
|
|
293
353
|
name: 'suilend.repay',
|
|
294
354
|
description:
|
|
295
|
-
'Repay borrowed
|
|
296
|
-
searchHint: 'suilend repay pay back debt loan close sui money market',
|
|
297
|
-
schema:
|
|
355
|
+
'Repay borrowed debt on Suilend (default USDC; pass coin "sui" to repay a SUI loan). Simulated, then executed.',
|
|
356
|
+
searchHint: 'suilend repay pay back debt loan close usdc stablecoin sui money market',
|
|
357
|
+
schema: BorrowSchema,
|
|
298
358
|
handler: async args => {
|
|
299
359
|
const err = ensureMainnet(ctx)
|
|
300
360
|
if (err) return { ok: false, error: err }
|
|
301
|
-
const
|
|
302
|
-
|
|
361
|
+
const coin = BORROW_COINS[args.coin ?? 'usdc']
|
|
362
|
+
const amountBase = toBaseUnits(args.amount, coin.decimals)
|
|
363
|
+
if (amountBase === undefined || amountBase <= 0n)
|
|
303
364
|
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
304
365
|
if (ctx.policy) {
|
|
305
366
|
const verdict = evaluatePolicy(
|
|
306
|
-
{ kind: 'transfer', coinType:
|
|
367
|
+
{ kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'suilend' },
|
|
307
368
|
ctx.policy,
|
|
308
369
|
)
|
|
309
370
|
if (!verdict.allowed)
|
|
@@ -318,8 +379,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
|
|
|
318
379
|
await suilend.repayIntoObligation(
|
|
319
380
|
ctx.agentAddress,
|
|
320
381
|
obligation.obligationId,
|
|
321
|
-
|
|
322
|
-
|
|
382
|
+
coin.type,
|
|
383
|
+
amountBase.toString(),
|
|
323
384
|
tx as never,
|
|
324
385
|
)
|
|
325
386
|
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
@@ -340,7 +401,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
|
|
|
340
401
|
data: {
|
|
341
402
|
protocol: 'suilend',
|
|
342
403
|
action: 'repay',
|
|
343
|
-
|
|
404
|
+
amount: args.amount,
|
|
405
|
+
coin: coin.label,
|
|
344
406
|
digest: res.digest,
|
|
345
407
|
},
|
|
346
408
|
}
|
package/src/tools/swap.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { checkMinimum } from '../minimums'
|
|
|
17
17
|
import { evaluatePolicy } from '../policy'
|
|
18
18
|
import { simulate } from '../simulate'
|
|
19
19
|
import type { OnchainRuntimeContext } from '../types'
|
|
20
|
+
import { fundSui } from '../vault-fund'
|
|
20
21
|
|
|
21
22
|
const SUI_TYPE = '0x2::sui::SUI'
|
|
22
23
|
|
|
@@ -119,9 +120,16 @@ export function makeSwap(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
|
119
120
|
tx.setSender(me)
|
|
120
121
|
tx.setGasBudget(150_000_000)
|
|
121
122
|
try {
|
|
123
|
+
// SUI input is sourced from the treasury vault (policy-enforced) when
|
|
124
|
+
// wired; non-SUI input comes from the agent's own balance (the vault
|
|
125
|
+
// is SUI-typed). The swapped output goes to the agent.
|
|
122
126
|
const coinIn =
|
|
123
127
|
from.type === SUI_TYPE
|
|
124
|
-
?
|
|
128
|
+
? fundSui(tx, ctx, amountIn, {
|
|
129
|
+
protocol: '0x0',
|
|
130
|
+
kind: 'swap',
|
|
131
|
+
memo: `swap to ${args.to}`,
|
|
132
|
+
})
|
|
125
133
|
: coinWithBalance({ type: from.type, balance: amountIn })
|
|
126
134
|
const coinOut = await ag.swap({ quote: q, signer: me, tx, coinIn: coinIn as never })
|
|
127
135
|
tx.transferObjects([coinOut as never], me)
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,27 @@ export interface OnchainRuntimeContext {
|
|
|
23
23
|
packageId?: string
|
|
24
24
|
/** Shared `AgentPolicy` object id. When set, writes compose an on-chain receipt + enforcement. */
|
|
25
25
|
policyObjectId?: string
|
|
26
|
+
/**
|
|
27
|
+
* Treasury `Vault<SUI>` object id bound to `policyObjectId`. When set (together
|
|
28
|
+
* with `policyObjectId` + `packageId`), write tools source their SUI from the
|
|
29
|
+
* vault via the policy-gated `vault_spend` instead of the agent's gas coin — so
|
|
30
|
+
* the vault is the single fund source and every deployment is enforced on-chain.
|
|
31
|
+
* When unset, tools fall back to the agent's own SUI (single-key mode).
|
|
32
|
+
*/
|
|
33
|
+
vaultId?: string
|
|
34
|
+
/**
|
|
35
|
+
* The vault/policy owner address. Audit `ActionReceipt`s from `vault_spend` are
|
|
36
|
+
* sent here (falls back to the agent when unset). The owner also receives any
|
|
37
|
+
* funds swept back to the treasury.
|
|
38
|
+
*/
|
|
39
|
+
ownerAddress?: string
|
|
40
|
+
/**
|
|
41
|
+
* Snapshot of the vault's SUI balance (MIST, as a string) at ctx-build time.
|
|
42
|
+
* A funding heuristic: when the vault can't cover an action, tools fall back to
|
|
43
|
+
* the agent's own SUI so the action still works (the on-chain `vault_spend` is
|
|
44
|
+
* the authoritative gate). Absent/`"0"` ⇒ always fall back.
|
|
45
|
+
*/
|
|
46
|
+
vaultMist?: string
|
|
26
47
|
/** Walrus network for receipt/memory storage (defaults to `network`). */
|
|
27
48
|
walrusNetwork?: SuiNetwork
|
|
28
49
|
agentDir: string
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-funded actions. Lyra's treasury `lyra::vault::Vault<SUI>` is the SINGLE
|
|
3
|
+
* fund source for agent actions: instead of splitting the agent's own gas coin,
|
|
4
|
+
* a write tool draws SUI from the owner's vault via the policy-gated `vault_spend`
|
|
5
|
+
* (the agent signs; `enforce_spend` re-checks budget, per-tx cap, coin/protocol
|
|
6
|
+
* allowlists, expiry, and revoke ON-CHAIN). Idle funds stay in the vault, and the
|
|
7
|
+
* treasury is topped back up on withdrawal (Option 1: funds cycle vault→protocol→
|
|
8
|
+
* vault, so the treasury doesn't drain as the agent works).
|
|
9
|
+
*
|
|
10
|
+
* All helpers degrade gracefully: with no vault configured in the runtime ctx,
|
|
11
|
+
* `fundSui` falls back to the agent's gas coin — so single-key mode (e.g. the CLI
|
|
12
|
+
* with no provisioned vault) keeps working unchanged.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Transaction, TransactionObjectArgument } from '@mysten/sui/transactions'
|
|
16
|
+
import type { OnchainRuntimeContext } from './types'
|
|
17
|
+
|
|
18
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
19
|
+
const CLOCK = '0x6'
|
|
20
|
+
|
|
21
|
+
/** True when the ctx carries a fully-wired treasury vault (vault + policy + package). */
|
|
22
|
+
export function hasVault(ctx: OnchainRuntimeContext): boolean {
|
|
23
|
+
return Boolean(ctx.vaultId && ctx.policyObjectId && ctx.packageId)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when the wired vault can cover `amountMist` (per its balance snapshot).
|
|
28
|
+
* When false, callers fund from the agent's own SUI so the action still works —
|
|
29
|
+
* e.g. the user provisioned a vault but funded the agent directly, or the vault
|
|
30
|
+
* is short for this action. The on-chain `vault_spend` remains the hard gate.
|
|
31
|
+
*/
|
|
32
|
+
export function canFundFromVault(ctx: OnchainRuntimeContext, amountMist: bigint): boolean {
|
|
33
|
+
if (!hasVault(ctx)) return false
|
|
34
|
+
return BigInt(ctx.vaultMist ?? '0') >= amountMist
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function enc(tx: Transaction, s: string) {
|
|
38
|
+
return tx.pure.vector('u8', Array.from(new TextEncoder().encode(s)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FundOpts {
|
|
42
|
+
/**
|
|
43
|
+
* Protocol package/registry id checked against the policy's protocol allowlist.
|
|
44
|
+
* Pass the protocol's package id (e.g. NAVI, Scallop) so an owner-set allowlist
|
|
45
|
+
* gates it meaningfully; `0x0` when the action isn't protocol-scoped (a plain
|
|
46
|
+
* transfer). Defaults to `0x0`.
|
|
47
|
+
*/
|
|
48
|
+
protocol?: string
|
|
49
|
+
/** Short action kind for the on-chain receipt (e.g. `stake`, `supply`, `swap`). */
|
|
50
|
+
kind: string
|
|
51
|
+
/** Human memo for the on-chain receipt. */
|
|
52
|
+
memo: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Source `amountMist` of SUI for an in-PTB action. When a vault is wired, draws
|
|
57
|
+
* from it via `vault_spend` (policy-enforced) and routes the audit `ActionReceipt`
|
|
58
|
+
* to the owner; otherwise splits the agent's gas coin. Returns the `Coin<SUI>` to
|
|
59
|
+
* feed into the protocol call in the SAME transaction.
|
|
60
|
+
*/
|
|
61
|
+
export function fundSui(
|
|
62
|
+
tx: Transaction,
|
|
63
|
+
ctx: OnchainRuntimeContext,
|
|
64
|
+
amountMist: bigint,
|
|
65
|
+
opts: FundOpts,
|
|
66
|
+
): TransactionObjectArgument {
|
|
67
|
+
if (canFundFromVault(ctx, amountMist)) {
|
|
68
|
+
const [coin, receipt] = tx.moveCall({
|
|
69
|
+
target: `${ctx.packageId}::vault::vault_spend`,
|
|
70
|
+
typeArguments: [SUI_TYPE],
|
|
71
|
+
arguments: [
|
|
72
|
+
tx.object(ctx.vaultId as string),
|
|
73
|
+
tx.object(ctx.policyObjectId as string),
|
|
74
|
+
tx.pure.u64(amountMist),
|
|
75
|
+
tx.pure.address(opts.protocol ?? '0x0'),
|
|
76
|
+
enc(tx, opts.kind),
|
|
77
|
+
enc(tx, opts.memo),
|
|
78
|
+
tx.object(CLOCK),
|
|
79
|
+
],
|
|
80
|
+
})
|
|
81
|
+
// The receipt is an on-chain audit object (key+store); park it with the owner
|
|
82
|
+
// (or the agent when the owner is unknown) so the PTB has no dangling value.
|
|
83
|
+
tx.transferObjects([receipt as TransactionObjectArgument], ctx.ownerAddress ?? ctx.agentAddress)
|
|
84
|
+
return coin as TransactionObjectArgument
|
|
85
|
+
}
|
|
86
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
87
|
+
return coin as TransactionObjectArgument
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return a `Coin<SUI>` to the treasury vault (Option 1: withdrawals/unstakes cycle
|
|
92
|
+
* back into the vault instead of accumulating on the agent). No-op-returns `false`
|
|
93
|
+
* when no vault is wired, so the caller can transfer the coin to the agent instead.
|
|
94
|
+
*/
|
|
95
|
+
export function returnSuiToVault(
|
|
96
|
+
tx: Transaction,
|
|
97
|
+
ctx: OnchainRuntimeContext,
|
|
98
|
+
coin: TransactionObjectArgument,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (!ctx.vaultId || !ctx.packageId) return false
|
|
101
|
+
tx.moveCall({
|
|
102
|
+
target: `${ctx.packageId}::vault::deposit`,
|
|
103
|
+
typeArguments: [SUI_TYPE],
|
|
104
|
+
arguments: [tx.object(ctx.vaultId), coin],
|
|
105
|
+
})
|
|
106
|
+
return true
|
|
107
|
+
}
|
package/src/vault.ts
CHANGED
|
@@ -22,6 +22,70 @@ export interface OwnerVault {
|
|
|
22
22
|
vaultMist: string
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface AgentVault {
|
|
26
|
+
policyId: string
|
|
27
|
+
vaultId: string
|
|
28
|
+
owner: string
|
|
29
|
+
vaultMist: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the treasury vault for a delegated `agentAddress` WITHOUT knowing the
|
|
34
|
+
* owner — the CLI/gateway single-tenant path (the running agent knows only its own
|
|
35
|
+
* address). Finds the `AgentPolicy` delegating to this agent via `PolicyCreated`
|
|
36
|
+
* events, then its `Vault` via `VaultOpened`. Returns null when the agent has no
|
|
37
|
+
* (unrevoked) policy+vault, so the tools fall back to the agent's own SUI.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveVaultForAgent(
|
|
40
|
+
agentAddress: string,
|
|
41
|
+
network: SuiNetwork = 'mainnet',
|
|
42
|
+
): Promise<AgentVault | null> {
|
|
43
|
+
const client = makeSuiClient(network)
|
|
44
|
+
const created = await client
|
|
45
|
+
.queryEvents({
|
|
46
|
+
query: { MoveEventType: `${ORIGINAL_PKG}::policy::PolicyCreated` },
|
|
47
|
+
limit: 200,
|
|
48
|
+
order: 'descending',
|
|
49
|
+
})
|
|
50
|
+
.catch(() => null)
|
|
51
|
+
if (!created) return null
|
|
52
|
+
|
|
53
|
+
for (const e of created.data) {
|
|
54
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
55
|
+
const f = e.parsedJson as any
|
|
56
|
+
if (f?.agent !== agentAddress) continue
|
|
57
|
+
const policyId = f.policy_id as string | undefined
|
|
58
|
+
const owner = f.owner as string | undefined
|
|
59
|
+
if (!policyId || !owner) continue
|
|
60
|
+
const pol = await client
|
|
61
|
+
.getObject({ id: policyId, options: { showContent: true } })
|
|
62
|
+
.catch(() => null)
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
64
|
+
const pf = (pol?.data?.content as any)?.fields
|
|
65
|
+
if (!pf || pf.revoked) continue
|
|
66
|
+
|
|
67
|
+
const ev = await client
|
|
68
|
+
.queryEvents({
|
|
69
|
+
query: { MoveEventType: `${VAULT_PKG}::vault::VaultOpened` },
|
|
70
|
+
limit: 200,
|
|
71
|
+
order: 'descending',
|
|
72
|
+
})
|
|
73
|
+
.catch(() => null)
|
|
74
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
75
|
+
const match = ev?.data.find(x => (x.parsedJson as any)?.policy_id === policyId)
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
77
|
+
const vaultId = (match?.parsedJson as any)?.vault_id as string | undefined
|
|
78
|
+
if (!vaultId) continue
|
|
79
|
+
const v = await client
|
|
80
|
+
.getObject({ id: vaultId, options: { showContent: true } })
|
|
81
|
+
.catch(() => null)
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
83
|
+
const vaultMist = String((v?.data?.content as any)?.fields?.balance ?? '0')
|
|
84
|
+
return { policyId, vaultId, owner, vaultMist }
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
25
89
|
/** Find the owner's policy (delegating to their derived agent) + its SUI vault. */
|
|
26
90
|
export async function resolveOwnerVault(
|
|
27
91
|
owner: string,
|
package/src/derive.test.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { deriveAgentAddress, deriveAgentKeypair } from './derive'
|
|
3
|
-
|
|
4
|
-
const MASTER = 'a'.repeat(64)
|
|
5
|
-
const A = '0x46e37419c58981bb69100fa559baa52fd7e219486d75e9dc3f0896fab01d06f5'
|
|
6
|
-
const B = '0x250880a4c1a268da8011b164f599d4e100cefce84f862d36396cd1a943ee8a35'
|
|
7
|
-
|
|
8
|
-
describe('agent derivation', () => {
|
|
9
|
-
it('is deterministic for the same owner + master', () => {
|
|
10
|
-
expect(deriveAgentAddress(A, MASTER)).toBe(deriveAgentAddress(A, MASTER))
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('gives distinct agents for distinct owners', () => {
|
|
14
|
-
expect(deriveAgentAddress(A, MASTER)).not.toBe(deriveAgentAddress(B, MASTER))
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('is case-insensitive on the owner address', () => {
|
|
18
|
-
expect(deriveAgentAddress(A.toUpperCase().replace('0X', '0x'), MASTER)).toBe(
|
|
19
|
-
deriveAgentAddress(A, MASTER),
|
|
20
|
-
)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('changes with the master secret (no master reuse leaks)', () => {
|
|
24
|
-
expect(deriveAgentAddress(A, MASTER)).not.toBe(deriveAgentAddress(A, `${'b'.repeat(64)}`))
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('returns a valid 32-byte Sui address', () => {
|
|
28
|
-
expect(deriveAgentAddress(A, MASTER)).toMatch(/^0x[0-9a-f]{64}$/)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('the keypair signs (a real Ed25519 key)', async () => {
|
|
32
|
-
const kp = deriveAgentKeypair(A, MASTER)
|
|
33
|
-
const { signature } = await kp.signPersonalMessage(new TextEncoder().encode('hi'))
|
|
34
|
-
expect(typeof signature).toBe('string')
|
|
35
|
-
expect(signature.length).toBeGreaterThan(0)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('rejects a too-short master secret', () => {
|
|
39
|
-
expect(() => deriveAgentAddress(A, 'short')).toThrow()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('rejects a malformed owner address', () => {
|
|
43
|
-
expect(() => deriveAgentAddress('not-an-address', MASTER)).toThrow()
|
|
44
|
-
})
|
|
45
|
-
})
|
package/src/minimums.test.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { MIN_MIST, checkMinimum } from './minimums'
|
|
3
|
-
|
|
4
|
-
describe('checkMinimum', () => {
|
|
5
|
-
test('rejects a too-small stake (0.1 SUI < 1 SUI minimum)', () => {
|
|
6
|
-
const err = checkMinimum('stake', 100_000_000n) // 0.1 SUI
|
|
7
|
-
expect(err).not.toBeNull()
|
|
8
|
-
expect(err).toContain('amount too small')
|
|
9
|
-
expect(err).toContain('1 SUI minimum for stake')
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
test('accepts a stake at the 1 SUI minimum', () => {
|
|
13
|
-
expect(checkMinimum('stake', MIN_MIST.stake)).toBeNull()
|
|
14
|
-
expect(checkMinimum('stake', 2_000_000_000n)).toBeNull()
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test('rejects dust transfers/swaps/supplies below their minimums', () => {
|
|
18
|
-
expect(checkMinimum('transfer', 1n)).not.toBeNull()
|
|
19
|
-
expect(checkMinimum('swap', 1_000_000n)).not.toBeNull() // 0.001 < 0.01
|
|
20
|
-
expect(checkMinimum('supply', 5_000_000n)).not.toBeNull() // 0.005 < 0.01
|
|
21
|
-
expect(checkMinimum('borrow', 9_999_999n)).not.toBeNull()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('accepts amounts at/above the minimum', () => {
|
|
25
|
-
expect(checkMinimum('transfer', MIN_MIST.transfer)).toBeNull()
|
|
26
|
-
expect(checkMinimum('swap', MIN_MIST.swap)).toBeNull()
|
|
27
|
-
expect(checkMinimum('supply', MIN_MIST.supply)).toBeNull()
|
|
28
|
-
expect(checkMinimum('borrow', MIN_MIST.borrow)).toBeNull()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
test('the message names the amount, the minimum, and the action', () => {
|
|
32
|
-
const err = checkMinimum('swap', 5_000_000n) // 0.005 SUI
|
|
33
|
-
expect(err).toContain('0.005 SUI')
|
|
34
|
-
expect(err).toContain('0.01 SUI minimum for swap')
|
|
35
|
-
})
|
|
36
|
-
})
|