lyra-plugin-onchain 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/README.md +26 -0
- package/package.json +55 -0
- package/src/approval.ts +50 -0
- package/src/client.ts +32 -0
- package/src/derive.test.ts +45 -0
- package/src/derive.ts +39 -0
- package/src/guidance.ts +47 -0
- package/src/index.ts +94 -0
- package/src/policy.test.ts +187 -0
- package/src/policy.ts +267 -0
- package/src/protocols.ts +109 -0
- package/src/simulate.ts +60 -0
- package/src/tools/balance.ts +81 -0
- package/src/tools/cetus.ts +93 -0
- package/src/tools/deepbook.ts +60 -0
- package/src/tools/defillama.ts +89 -0
- package/src/tools/navi.ts +189 -0
- package/src/tools/policy.ts +180 -0
- package/src/tools/protocols.ts +38 -0
- package/src/tools/scallop.ts +217 -0
- package/src/tools/send.ts +121 -0
- package/src/tools/swap.ts +171 -0
- package/src/tools/walrus.ts +66 -0
- package/src/types.ts +32 -0
- package/src/vault.ts +76 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defi.yields` — read-only yield discovery across Sui DeFi via DefiLlama.
|
|
3
|
+
*
|
|
4
|
+
* Discovery only: it never moves funds. It gives the agent market context — the
|
|
5
|
+
* best APYs on Sui, with TVL, stablecoin, and impermanent-loss-risk signals — so
|
|
6
|
+
* a supply/swap proposal is grounded in real data before it hits the policy gate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ToolDef } from 'lyra-core'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { adapterForProject } from '../protocols'
|
|
12
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
13
|
+
|
|
14
|
+
const YIELDS_URL = 'https://yields.llama.fi/pools'
|
|
15
|
+
|
|
16
|
+
interface LlamaPool {
|
|
17
|
+
chain: string
|
|
18
|
+
project: string
|
|
19
|
+
symbol: string
|
|
20
|
+
apy: number | null
|
|
21
|
+
tvlUsd: number | null
|
|
22
|
+
stablecoin: boolean
|
|
23
|
+
ilRisk: string
|
|
24
|
+
exposure: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Schema = z.object({
|
|
28
|
+
asset: z.string().optional().describe('Filter by symbol substring, e.g. "USDC", "SUI".'),
|
|
29
|
+
project: z.string().optional().describe('Filter by protocol, e.g. "scallop", "navi-lending".'),
|
|
30
|
+
limit: z.number().int().min(1).max(25).optional().describe('Max pools to return. Default 8.'),
|
|
31
|
+
})
|
|
32
|
+
type Args = z.infer<typeof Schema>
|
|
33
|
+
|
|
34
|
+
export function makeDefiYields(_ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
35
|
+
return {
|
|
36
|
+
name: 'defi.yields',
|
|
37
|
+
description:
|
|
38
|
+
'Discover the best yields on Sui (DefiLlama, read-only): pools ranked by APY with TVL, stablecoin, and IL-risk signals. Use for "where can I earn yield", "best stablecoin yield on Sui". Never moves funds.',
|
|
39
|
+
searchHint: 'yield apy best earn discover defillama pools tvl stablecoin opportunities sui',
|
|
40
|
+
schema: Schema,
|
|
41
|
+
handler: async args => {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(YIELDS_URL)
|
|
44
|
+
if (!res.ok) return { ok: false, error: `DefiLlama returned ${res.status}` }
|
|
45
|
+
const body = (await res.json()) as { data: LlamaPool[] }
|
|
46
|
+
const asset = args.asset?.trim().toUpperCase()
|
|
47
|
+
const project = args.project?.trim().toLowerCase()
|
|
48
|
+
const pools = body.data
|
|
49
|
+
.filter(p => p.chain === 'Sui')
|
|
50
|
+
.filter(p => (asset ? p.symbol?.toUpperCase().includes(asset) : true))
|
|
51
|
+
.filter(p => (project ? p.project?.toLowerCase().includes(project) : true))
|
|
52
|
+
.sort((a, b) => (b.apy ?? 0) - (a.apy ?? 0))
|
|
53
|
+
.slice(0, args.limit ?? 8)
|
|
54
|
+
.map(p => {
|
|
55
|
+
// Tag each opportunity with whether Lyra can actually ACT on it.
|
|
56
|
+
const adapter = adapterForProject(p.project)
|
|
57
|
+
return {
|
|
58
|
+
project: p.project,
|
|
59
|
+
symbol: p.symbol,
|
|
60
|
+
apy: p.apy != null ? `${p.apy.toFixed(2)}%` : null,
|
|
61
|
+
tvlUsd: p.tvlUsd,
|
|
62
|
+
stablecoin: p.stablecoin,
|
|
63
|
+
ilRisk: p.ilRisk,
|
|
64
|
+
exposure: p.exposure,
|
|
65
|
+
executable: adapter?.execute ?? false,
|
|
66
|
+
executeWith: adapter?.execute
|
|
67
|
+
? (adapter.tools.find(t => /supply|stake|deposit/.test(t)) ?? adapter.tools[0])
|
|
68
|
+
: null,
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
const executable = pools.filter(p => p.executable).length
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
data: {
|
|
75
|
+
chain: 'Sui',
|
|
76
|
+
count: pools.length,
|
|
77
|
+
pools,
|
|
78
|
+
note:
|
|
79
|
+
executable < pools.length
|
|
80
|
+
? `${executable}/${pools.length} are directly executable by Lyra (Scallop/NAVI). For others Lyra can discover + explain, but cannot execute — propose the best executable alternative or give manual steps.`
|
|
81
|
+
: undefined,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NAVI lending tools (the largest Sui lending market by TVL): markets + position
|
|
3
|
+
* (read) and SUI supply/withdraw (policy-gated writes).
|
|
4
|
+
*
|
|
5
|
+
* NAVI's SDK is v1-compatible and exposes PTB builders (`depositCoin`,
|
|
6
|
+
* `withdrawCoin`) that take a @mysten/sui Transaction, so writes flow through the
|
|
7
|
+
* SAME policy → simulate → execute pipeline as sui.send. The dry-run simulate is
|
|
8
|
+
* the safety net for a malformed or out-of-funds action.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
12
|
+
import type { ToolDef } from 'lyra-core'
|
|
13
|
+
import { NAVISDKClient, depositCoin, pool, withdrawCoin } from 'navi-sdk'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
16
|
+
import { simulate } from '../simulate'
|
|
17
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
18
|
+
|
|
19
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
20
|
+
|
|
21
|
+
function ensureMainnet(ctx: OnchainRuntimeContext): string | null {
|
|
22
|
+
return ctx.network === 'mainnet' ? null : 'NAVI SDK supports mainnet only'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- navi.markets ----------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export function makeNaviMarkets(ctx: OnchainRuntimeContext): ToolDef<Record<string, never>> {
|
|
28
|
+
return {
|
|
29
|
+
name: 'navi.markets',
|
|
30
|
+
description:
|
|
31
|
+
'Read NAVI lending markets on Sui (the largest Sui money market by TVL): supply/borrow APY and reserves per asset. Read-only discovery.',
|
|
32
|
+
searchHint: 'navi lending market supply borrow apy yield reserves rates earn largest tvl',
|
|
33
|
+
schema: z.object({}),
|
|
34
|
+
handler: async () => {
|
|
35
|
+
const err = ensureMainnet(ctx)
|
|
36
|
+
if (err) return { ok: false, error: err }
|
|
37
|
+
try {
|
|
38
|
+
const c = new NAVISDKClient({ networkType: 'mainnet' })
|
|
39
|
+
const info = (await c.getPoolInfo()) as Record<string, unknown>
|
|
40
|
+
const pools = Object.values(info)
|
|
41
|
+
.map(p => {
|
|
42
|
+
const r = p as {
|
|
43
|
+
coinType?: string
|
|
44
|
+
symbol?: string
|
|
45
|
+
supplyIncentiveApyInfo?: { apy?: number }
|
|
46
|
+
borrowIncentiveApyInfo?: { apy?: number }
|
|
47
|
+
base_supply_rate?: number
|
|
48
|
+
base_borrow_rate?: number
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
symbol: r.symbol ?? r.coinType,
|
|
52
|
+
supplyApy: r.base_supply_rate ?? r.supplyIncentiveApyInfo?.apy ?? null,
|
|
53
|
+
borrowApy: r.base_borrow_rate ?? r.borrowIncentiveApyInfo?.apy ?? null,
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.filter(p => p.symbol)
|
|
57
|
+
.slice(0, 12)
|
|
58
|
+
return { ok: true, data: { protocol: 'navi', network: 'mainnet', pools } }
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- navi.position ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export function makeNaviPosition(ctx: OnchainRuntimeContext): ToolDef<Record<string, never>> {
|
|
69
|
+
return {
|
|
70
|
+
name: 'navi.position',
|
|
71
|
+
description:
|
|
72
|
+
"The agent's NAVI position: supplied/borrowed balances and health factor (lower = closer to liquidation). Read-only.",
|
|
73
|
+
searchHint: 'navi position portfolio supplied borrowed debt health factor liquidation',
|
|
74
|
+
schema: z.object({}),
|
|
75
|
+
handler: async () => {
|
|
76
|
+
const err = ensureMainnet(ctx)
|
|
77
|
+
if (err) return { ok: false, error: err }
|
|
78
|
+
try {
|
|
79
|
+
const c = new NAVISDKClient({ networkType: 'mainnet' })
|
|
80
|
+
const [health, portfolios] = await Promise.all([
|
|
81
|
+
c.getHealthFactor(ctx.agentAddress).catch(() => null),
|
|
82
|
+
// .d.ts declares 0 args but it accepts an address at runtime.
|
|
83
|
+
(c as { getAllNaviPortfolios(a: string): Promise<unknown> })
|
|
84
|
+
.getAllNaviPortfolios(ctx.agentAddress)
|
|
85
|
+
.catch(() => null),
|
|
86
|
+
])
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
data: {
|
|
90
|
+
protocol: 'navi',
|
|
91
|
+
healthFactor: health,
|
|
92
|
+
portfolios: portfolios ? Object.fromEntries(portfolios as Map<string, unknown>) : null,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- navi.supply / navi.withdraw (SUI) -------------------------------------
|
|
103
|
+
|
|
104
|
+
const AmountSchema = z.object({ amount: z.string().min(1).describe('Amount of SUI, e.g. "1.5".') })
|
|
105
|
+
type AmountArgs = z.infer<typeof AmountSchema>
|
|
106
|
+
|
|
107
|
+
async function runNaviWrite(
|
|
108
|
+
ctx: OnchainRuntimeContext,
|
|
109
|
+
amount: string,
|
|
110
|
+
kind: 'supply' | 'withdraw',
|
|
111
|
+
): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
|
|
112
|
+
const err = ensureMainnet(ctx)
|
|
113
|
+
if (err) return { ok: false, error: err }
|
|
114
|
+
const amountMist = suiToMist(amount)
|
|
115
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
116
|
+
return { ok: false, error: `invalid amount "${amount}"` }
|
|
117
|
+
|
|
118
|
+
if (ctx.policy && kind === 'supply') {
|
|
119
|
+
const verdict = evaluatePolicy(
|
|
120
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'navi' },
|
|
121
|
+
ctx.policy,
|
|
122
|
+
)
|
|
123
|
+
if (!verdict.allowed)
|
|
124
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const tx = new Transaction()
|
|
129
|
+
const suiPool = (pool as Record<string, unknown>).Sui
|
|
130
|
+
if (kind === 'supply') {
|
|
131
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
132
|
+
await depositCoin(tx as never, suiPool as never, coin as never, Number(amountMist))
|
|
133
|
+
} else {
|
|
134
|
+
// navi-sdk's withdrawCoin already wraps the withdrawn Balance into a Coin
|
|
135
|
+
// (via coin::from_balance) and returns [coin]; destructure and transfer it.
|
|
136
|
+
const [coin] = await withdrawCoin(tx as never, suiPool as never, Number(amountMist))
|
|
137
|
+
tx.transferObjects([coin as never], ctx.agentAddress)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
141
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
142
|
+
|
|
143
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
144
|
+
signer: ctx.keypair,
|
|
145
|
+
transaction: tx,
|
|
146
|
+
options: { showEffects: true },
|
|
147
|
+
})
|
|
148
|
+
if (res.effects?.status?.status !== 'success') {
|
|
149
|
+
return { ok: false, error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}` }
|
|
150
|
+
}
|
|
151
|
+
// Wait for the tx to settle/index so a follow-up action (e.g. an immediate
|
|
152
|
+
// withdraw after a supply) doesn't race NAVI's not-yet-settled accounting.
|
|
153
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
data: {
|
|
157
|
+
protocol: 'navi',
|
|
158
|
+
action: kind,
|
|
159
|
+
amountSui: amount,
|
|
160
|
+
digest: res.digest,
|
|
161
|
+
simGasUsed: sim.gasUsed,
|
|
162
|
+
policyEnforced: ctx.policy != null,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function makeNaviSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
171
|
+
return {
|
|
172
|
+
name: 'navi.supply',
|
|
173
|
+
description:
|
|
174
|
+
'Supply (deposit) idle SUI into NAVI to earn lending yield. Policy-checked, simulated, then executed.',
|
|
175
|
+
searchHint: 'navi supply deposit lend sui earn yield idle',
|
|
176
|
+
schema: AmountSchema,
|
|
177
|
+
handler: async args => runNaviWrite(ctx, args.amount, 'supply'),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function makeNaviWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
182
|
+
return {
|
|
183
|
+
name: 'navi.withdraw',
|
|
184
|
+
description: 'Withdraw supplied SUI from NAVI back to the agent. Simulated, then executed.',
|
|
185
|
+
searchHint: 'navi withdraw redeem unlend remove sui',
|
|
186
|
+
schema: AmountSchema,
|
|
187
|
+
handler: async args => runNaviWrite(ctx, args.amount, 'withdraw'),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy tools: `policy.show` (legible control layer) and `policy.create`
|
|
3
|
+
* (mint an on-chain `lyra::policy::AgentPolicy`).
|
|
4
|
+
*
|
|
5
|
+
* The control layer is only trustworthy if it is legible and enforceable.
|
|
6
|
+
* `policy.show` reports the active off-chain caps; `policy.create` publishes a
|
|
7
|
+
* shared AgentPolicy on Sui so the same budget/per-tx/expiry are enforced in
|
|
8
|
+
* Move and every action mints an auditable receipt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { bcs } from '@mysten/sui/bcs'
|
|
12
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
13
|
+
import type { ToolDef } from 'lyra-core'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import { suiToMist } from '../policy'
|
|
16
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
17
|
+
import { fmtSui } from './balance'
|
|
18
|
+
|
|
19
|
+
// --- policy.show -----------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const ShowSchema = z.object({})
|
|
22
|
+
type ShowArgs = z.infer<typeof ShowSchema>
|
|
23
|
+
|
|
24
|
+
export function makePolicyShow(ctx: OnchainRuntimeContext): ToolDef<ShowArgs> {
|
|
25
|
+
return {
|
|
26
|
+
name: 'policy.show',
|
|
27
|
+
description:
|
|
28
|
+
'Show the active deterministic fund-control policy: per-tx cap, auto-approve ceiling, autonomy tier, coin/protocol allowlists, slippage cap, and expiry. Read-only. Call for "what are my limits", "what can you spend", or before explaining why an action was blocked.',
|
|
29
|
+
searchHint:
|
|
30
|
+
'policy limits caps allowlist autonomy approval guardrails rules what can you spend',
|
|
31
|
+
schema: ShowSchema,
|
|
32
|
+
handler: async () => {
|
|
33
|
+
const p = ctx.policy
|
|
34
|
+
if (!p) {
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
data: {
|
|
38
|
+
enforced: false,
|
|
39
|
+
policyPackage: ctx.packageId ?? null,
|
|
40
|
+
note: 'No LYRA_POLICY_* configured — no in-code caps this session. Value-moving actions still go through simulation and the session permission mode.',
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const readOnly = p.readOnly === true || p.autonomy === 'readonly'
|
|
45
|
+
const maxPerTx = p.maxMistPerTx === undefined ? null : `${fmtSui(p.maxMistPerTx)} SUI`
|
|
46
|
+
const autoUpTo = p.autoMaxMistPerTx === undefined ? null : `${fmtSui(p.autoMaxMistPerTx)} SUI`
|
|
47
|
+
const lines: string[] = []
|
|
48
|
+
if (readOnly) lines.push('READ-ONLY: all writes are blocked.')
|
|
49
|
+
if (maxPerTx) lines.push(`Hard cap: sends over ${maxPerTx} are blocked.`)
|
|
50
|
+
if (autoUpTo) lines.push(`Auto-execute up to ${autoUpTo}; above that requires approval.`)
|
|
51
|
+
if (p.maxSlippageBps !== undefined)
|
|
52
|
+
lines.push(`Swaps over ${p.maxSlippageBps} bps slippage are blocked.`)
|
|
53
|
+
if (p.coinAllowlist?.length)
|
|
54
|
+
lines.push(`Only ${p.coinAllowlist.length} allowlisted coin type(s) may be moved.`)
|
|
55
|
+
if (p.protocolAllowlist?.length)
|
|
56
|
+
lines.push(`Only protocols: ${p.protocolAllowlist.join(', ')}.`)
|
|
57
|
+
if (p.recipientAllowlist?.length)
|
|
58
|
+
lines.push(`Transfers only to ${p.recipientAllowlist.length} allowlisted recipient(s).`)
|
|
59
|
+
if (p.expiryMs !== undefined)
|
|
60
|
+
lines.push(`Policy expires ${new Date(p.expiryMs).toISOString()}.`)
|
|
61
|
+
if (p.autonomy === 'confirm') lines.push('Autonomy=confirm: every write needs approval.')
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
data: {
|
|
66
|
+
enforced: true,
|
|
67
|
+
readOnly,
|
|
68
|
+
autonomy: p.autonomy ?? 'auto',
|
|
69
|
+
maxPerTx,
|
|
70
|
+
autoApproveUpTo: autoUpTo,
|
|
71
|
+
maxSlippageBps: p.maxSlippageBps ?? null,
|
|
72
|
+
coinAllowlist: p.coinAllowlist ?? null,
|
|
73
|
+
protocolAllowlist: p.protocolAllowlist ?? null,
|
|
74
|
+
recipientAllowlist: p.recipientAllowlist ?? null,
|
|
75
|
+
expiry: p.expiryMs ? new Date(p.expiryMs).toISOString() : null,
|
|
76
|
+
policyPackage: ctx.packageId ?? null,
|
|
77
|
+
policyObject: ctx.policyObjectId ?? null,
|
|
78
|
+
summary:
|
|
79
|
+
lines.length > 0 ? lines.join(' ') : 'Policy armed but with no specific caps set.',
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- policy.create ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const CreateSchema = z.object({
|
|
89
|
+
budgetSui: z.string().min(1).describe('Lifetime spend ceiling in SUI, e.g. "10".'),
|
|
90
|
+
maxPerTxSui: z.string().min(1).describe('Hard per-action cap in SUI, e.g. "1".'),
|
|
91
|
+
maxSlippageBps: z
|
|
92
|
+
.number()
|
|
93
|
+
.int()
|
|
94
|
+
.min(0)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('Reference slippage cap (bps). Default 100.'),
|
|
97
|
+
expiryMinutes: z
|
|
98
|
+
.number()
|
|
99
|
+
.int()
|
|
100
|
+
.min(0)
|
|
101
|
+
.optional()
|
|
102
|
+
.describe('Minutes until the policy expires. 0 / omit = never.'),
|
|
103
|
+
})
|
|
104
|
+
type CreateArgs = z.infer<typeof CreateSchema>
|
|
105
|
+
|
|
106
|
+
export function makePolicyCreate(ctx: OnchainRuntimeContext): ToolDef<CreateArgs> {
|
|
107
|
+
return {
|
|
108
|
+
name: 'policy.create',
|
|
109
|
+
description:
|
|
110
|
+
'Publish a shared on-chain lyra::policy::AgentPolicy bounding this agent: a lifetime budget, a per-tx cap, slippage, and an expiry. Enforced in Move on every subsequent action. Returns the policy object id + owner cap.',
|
|
111
|
+
searchHint: 'create policy on-chain agentpolicy budget cap expiry mint publish guardrail',
|
|
112
|
+
schema: CreateSchema,
|
|
113
|
+
handler: async args => {
|
|
114
|
+
try {
|
|
115
|
+
if (!ctx.packageId)
|
|
116
|
+
return { ok: false, error: 'no lyra::policy package configured (LYRA_PACKAGE_ID)' }
|
|
117
|
+
const budget = suiToMist(args.budgetSui)
|
|
118
|
+
const maxPerTx = suiToMist(args.maxPerTxSui)
|
|
119
|
+
if (budget === undefined || maxPerTx === undefined) {
|
|
120
|
+
return { ok: false, error: 'invalid budget or per-tx amount' }
|
|
121
|
+
}
|
|
122
|
+
const expiryMs = args.expiryMinutes ? Date.now() + args.expiryMinutes * 60_000 : 0
|
|
123
|
+
const tx = new Transaction()
|
|
124
|
+
tx.moveCall({
|
|
125
|
+
target: `${ctx.packageId}::policy::create_policy`,
|
|
126
|
+
arguments: [
|
|
127
|
+
tx.pure.address(ctx.agentAddress),
|
|
128
|
+
tx.pure.u64(budget),
|
|
129
|
+
tx.pure.u64(maxPerTx),
|
|
130
|
+
tx.pure.u64(BigInt(args.maxSlippageBps ?? 100)),
|
|
131
|
+
// allowed_coins: vector<vector<u8>> — empty = any coin (off-chain policy still applies).
|
|
132
|
+
tx.pure(bcs.vector(bcs.vector(bcs.u8())).serialize([])),
|
|
133
|
+
// allowed_protocols: vector<address> — empty = any protocol.
|
|
134
|
+
tx.pure(bcs.vector(bcs.Address).serialize([])),
|
|
135
|
+
tx.pure.u64(BigInt(expiryMs)),
|
|
136
|
+
tx.object.clock(),
|
|
137
|
+
],
|
|
138
|
+
})
|
|
139
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
140
|
+
signer: ctx.keypair,
|
|
141
|
+
transaction: tx,
|
|
142
|
+
options: { showEffects: true, showObjectChanges: true },
|
|
143
|
+
})
|
|
144
|
+
if (res.effects?.status?.status !== 'success') {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Wait for indexing so the new shared policy object is queryable before
|
|
151
|
+
// the next action references it (avoids a notExists race on sui.send).
|
|
152
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
153
|
+
const created = (res.objectChanges ?? []).filter(c => c.type === 'created') as {
|
|
154
|
+
objectId: string
|
|
155
|
+
objectType?: string
|
|
156
|
+
}[]
|
|
157
|
+
const policyObj = created.find(c => String(c.objectType).endsWith('::policy::AgentPolicy'))
|
|
158
|
+
const ownerCap = created.find(c =>
|
|
159
|
+
String(c.objectType).endsWith('::policy::PolicyOwnerCap'),
|
|
160
|
+
)
|
|
161
|
+
// Wire subsequent writes to record against this policy on-chain.
|
|
162
|
+
if (policyObj) ctx.policyObjectId = policyObj.objectId
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
data: {
|
|
167
|
+
digest: res.digest,
|
|
168
|
+
policyObject: policyObj?.objectId ?? null,
|
|
169
|
+
ownerCap: ownerCap?.objectId ?? null,
|
|
170
|
+
budgetSui: args.budgetSui,
|
|
171
|
+
maxPerTxSui: args.maxPerTxSui,
|
|
172
|
+
expiry: expiryMs ? new Date(expiryMs).toISOString() : 'never',
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `protocols.list` — Lyra's honest capability map: which Sui protocols it can
|
|
3
|
+
* READ vs EXECUTE on. The agent calls this before claiming it can act somewhere,
|
|
4
|
+
* so it never promises a transaction on a protocol it has no adapter for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolDef } from 'lyra-core'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { PROTOCOLS } from '../protocols'
|
|
10
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
11
|
+
|
|
12
|
+
export function makeProtocolsList(_ctx: OnchainRuntimeContext): ToolDef<Record<string, never>> {
|
|
13
|
+
return {
|
|
14
|
+
name: 'protocols.list',
|
|
15
|
+
description:
|
|
16
|
+
"Lyra's integrated protocols and what it can do with each: read-only vs executable, by category (lending, DEX, staking, storage, CLOB). Call this to answer 'what can you do', 'which protocols do you support', or before telling a user whether an action is possible. Discovery (defi.yields) spans ALL Sui protocols; execution is bounded to this list.",
|
|
17
|
+
searchHint:
|
|
18
|
+
'protocols supported integrations capabilities what can you do execute adapters list',
|
|
19
|
+
schema: z.object({}),
|
|
20
|
+
handler: async () => ({
|
|
21
|
+
ok: true,
|
|
22
|
+
data: {
|
|
23
|
+
protocols: PROTOCOLS.map(p => ({
|
|
24
|
+
id: p.id,
|
|
25
|
+
name: p.name,
|
|
26
|
+
category: p.category,
|
|
27
|
+
canRead: p.read,
|
|
28
|
+
canExecute: p.execute,
|
|
29
|
+
tools: p.tools,
|
|
30
|
+
note: p.note,
|
|
31
|
+
})),
|
|
32
|
+
executable: PROTOCOLS.filter(p => p.execute).map(p => p.id),
|
|
33
|
+
boundary:
|
|
34
|
+
'Lyra can DISCOVER yields on any Sui protocol (defi.yields) but only EXECUTE on the protocols marked canExecute. For others, it surfaces the opportunity and proposes the best executable alternative or manual steps — it does not fabricate transactions.',
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
}
|