lyra-plugin-onchain 0.1.5 → 0.1.8
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 +6 -17
- package/src/index.ts +34 -1
- package/src/minimums.test.ts +36 -0
- package/src/minimums.ts +35 -0
- package/src/protocols.ts +60 -6
- package/src/tools/liquid-stake.ts +152 -0
- package/src/tools/navi.ts +48 -5
- package/src/tools/onchain.integration.test.ts +111 -0
- package/src/tools/scallop.ts +47 -13
- package/src/tools/send.ts +3 -0
- package/src/tools/stake.ts +209 -0
- package/src/tools/suilend.ts +419 -0
- package/src/tools/swap.ts +6 -0
- package/src/tools/tools.test.ts +142 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suilend lending tools (supporting integration): SUI supply / withdraw /
|
|
3
|
+
* borrow / repay + a read-only position, on Suilend's MAIN_POOL lending market.
|
|
4
|
+
*
|
|
5
|
+
* Suilend is one of the three largest money markets on Sui. Its SDK's 3.x line
|
|
6
|
+
* pins @mysten/sui v2 (a Transaction-class ABI break vs. our v1 stack), so we
|
|
7
|
+
* use @suilend/sdk@1.1.x, whose @mysten/sui is a *peerDependency* — it binds to
|
|
8
|
+
* our hosted v1 copy, so the v1 Transaction we build is accepted directly by the
|
|
9
|
+
* SDK (verified end-to-end with a live mainnet dry-run of a deposit).
|
|
10
|
+
*
|
|
11
|
+
* Value semantics (checked against the SDK source):
|
|
12
|
+
* deposit / borrow / repay → underlying MIST (clean).
|
|
13
|
+
* withdraw → cTokens; we convert the requested underlying via
|
|
14
|
+
* the reserve's cToken exchange rate.
|
|
15
|
+
*
|
|
16
|
+
* Every write runs the same guarded pipeline as sui.send: minimum guard →
|
|
17
|
+
* policy gate → dry-run simulate → execute → on-chain effects check. The
|
|
18
|
+
* simulate is the safety net — an under-collateralized borrow or an over-sized
|
|
19
|
+
* withdraw fails before broadcast, never a false success.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
23
|
+
import {
|
|
24
|
+
LENDING_MARKET_ID,
|
|
25
|
+
LENDING_MARKET_TYPE,
|
|
26
|
+
SuilendClient,
|
|
27
|
+
initializeSuilend,
|
|
28
|
+
} from '@suilend/sdk'
|
|
29
|
+
import type { ToolDef } from 'lyra-core'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
import { checkMinimum } from '../minimums'
|
|
32
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
33
|
+
import { simulate } from '../simulate'
|
|
34
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
35
|
+
|
|
36
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
37
|
+
// Canonical + long-form SUI coin types (reserve maps may use either).
|
|
38
|
+
const SUI_LONG = `0x${'0'.repeat(63)}2::sui::SUI`
|
|
39
|
+
|
|
40
|
+
function ensureMainnet(ctx: OnchainRuntimeContext): string | null {
|
|
41
|
+
return ctx.network === 'mainnet' ? null : 'Suilend SDK supports mainnet only'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function newSuilend(ctx: OnchainRuntimeContext): Promise<SuilendClient> {
|
|
45
|
+
// ctx.client is @mysten/sui v1.4x; @suilend/sdk@1.1.x binds @mysten/sui as a
|
|
46
|
+
// peer, so the client interops directly across the copy boundary.
|
|
47
|
+
return SuilendClient.initialize(LENDING_MARKET_ID, LENDING_MARKET_TYPE, ctx.client as never)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract the object id from a parsed ObligationOwnerCap (`id` is a UID). */
|
|
51
|
+
function capObjectId(cap: { id: unknown }): string {
|
|
52
|
+
const id = cap.id as string | { id: string }
|
|
53
|
+
return typeof id === 'string' ? id : id.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSuiType(t: string): boolean {
|
|
57
|
+
return t === SUI_TYPE || t === SUI_LONG
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The agent's obligation cap + id, or null if it has no Suilend position yet. */
|
|
61
|
+
async function findObligation(
|
|
62
|
+
ctx: OnchainRuntimeContext,
|
|
63
|
+
): Promise<{ capId: string; obligationId: string } | null> {
|
|
64
|
+
const caps = await SuilendClient.getObligationOwnerCaps(
|
|
65
|
+
ctx.agentAddress,
|
|
66
|
+
[LENDING_MARKET_TYPE],
|
|
67
|
+
ctx.client as never,
|
|
68
|
+
)
|
|
69
|
+
if (!caps.length) return null
|
|
70
|
+
const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
|
|
71
|
+
const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
|
|
72
|
+
return { capId: capObjectId(c), obligationId }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- suilend.supply --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const AmountSchema = z.object({
|
|
78
|
+
amount: z.string().min(1).describe('Amount of SUI, e.g. "1.5".'),
|
|
79
|
+
})
|
|
80
|
+
type AmountArgs = z.infer<typeof AmountSchema>
|
|
81
|
+
|
|
82
|
+
export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
83
|
+
return {
|
|
84
|
+
name: 'suilend.supply',
|
|
85
|
+
description:
|
|
86
|
+
'Supply (deposit) idle SUI into Suilend to earn lending yield. Creates a Suilend obligation on first use, then deposits into it. Policy-checked, simulated, then executed.',
|
|
87
|
+
searchHint: 'suilend supply deposit lend sui earn yield idle money market',
|
|
88
|
+
schema: AmountSchema,
|
|
89
|
+
handler: async args => {
|
|
90
|
+
const err = ensureMainnet(ctx)
|
|
91
|
+
if (err) return { ok: false, error: err }
|
|
92
|
+
const amountMist = suiToMist(args.amount)
|
|
93
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
94
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
95
|
+
const tooSmall = checkMinimum('supply', amountMist)
|
|
96
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
97
|
+
if (ctx.policy) {
|
|
98
|
+
const verdict = evaluatePolicy(
|
|
99
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'suilend' },
|
|
100
|
+
ctx.policy,
|
|
101
|
+
)
|
|
102
|
+
if (!verdict.allowed)
|
|
103
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const suilend = await newSuilend(ctx)
|
|
107
|
+
const existing = await findObligation(ctx)
|
|
108
|
+
const tx = new Transaction()
|
|
109
|
+
tx.setSender(ctx.agentAddress)
|
|
110
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
111
|
+
// The @suilend/sdk@1.1.x SDK carries its own nested @mysten/sui copy, so
|
|
112
|
+
// TS sees a distinct Transaction class — cast at the boundary. The two
|
|
113
|
+
// copies interop at runtime (verified with a live mainnet dry-run).
|
|
114
|
+
if (existing) {
|
|
115
|
+
suilend.deposit(coin as never, SUI_TYPE, existing.capId, tx as never)
|
|
116
|
+
} else {
|
|
117
|
+
const cap = suilend.createObligation(tx as never)
|
|
118
|
+
suilend.deposit(coin as never, SUI_TYPE, cap, tx as never)
|
|
119
|
+
tx.transferObjects([cap as never], ctx.agentAddress)
|
|
120
|
+
}
|
|
121
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
122
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
123
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
124
|
+
signer: ctx.keypair,
|
|
125
|
+
transaction: tx,
|
|
126
|
+
options: { showEffects: true },
|
|
127
|
+
})
|
|
128
|
+
if (res.effects?.status?.status !== 'success')
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
132
|
+
}
|
|
133
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
134
|
+
return {
|
|
135
|
+
ok: true,
|
|
136
|
+
data: {
|
|
137
|
+
protocol: 'suilend',
|
|
138
|
+
action: 'supply',
|
|
139
|
+
amountSui: args.amount,
|
|
140
|
+
newObligation: !existing,
|
|
141
|
+
digest: res.digest,
|
|
142
|
+
policyEnforced: ctx.policy != null,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- suilend.withdraw ------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export function makeSuilendWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
155
|
+
return {
|
|
156
|
+
name: 'suilend.withdraw',
|
|
157
|
+
description:
|
|
158
|
+
'Withdraw supplied SUI from Suilend back to the agent (amount in underlying SUI). Refreshes prices, converts to cTokens at the current exchange rate, simulated, then executed.',
|
|
159
|
+
searchHint: 'suilend withdraw redeem unlend remove supplied sui money market',
|
|
160
|
+
schema: AmountSchema,
|
|
161
|
+
handler: async args => {
|
|
162
|
+
const err = ensureMainnet(ctx)
|
|
163
|
+
if (err) return { ok: false, error: err }
|
|
164
|
+
const amountMist = suiToMist(args.amount)
|
|
165
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
166
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
167
|
+
try {
|
|
168
|
+
const suilend = await newSuilend(ctx)
|
|
169
|
+
const obligation = await findObligation(ctx)
|
|
170
|
+
if (!obligation)
|
|
171
|
+
return { ok: false, error: 'no Suilend position — supply SUI before withdrawing' }
|
|
172
|
+
// Convert underlying → cTokens using the SUI reserve exchange rate.
|
|
173
|
+
const data = await initializeSuilend(ctx.client as never, suilend)
|
|
174
|
+
const rate = suiReserveExchangeRate(data)
|
|
175
|
+
if (rate === null) return { ok: false, error: 'could not read SUI reserve exchange rate' }
|
|
176
|
+
// cTokens = floor(underlying / rate); floor keeps us at/under the request.
|
|
177
|
+
const ctokens = BigInt(Math.floor(Number(amountMist) / rate))
|
|
178
|
+
if (ctokens <= 0n) return { ok: false, error: 'amount too small to withdraw' }
|
|
179
|
+
const tx = new Transaction()
|
|
180
|
+
tx.setSender(ctx.agentAddress)
|
|
181
|
+
await suilend.withdrawAndSendToUser(
|
|
182
|
+
ctx.agentAddress,
|
|
183
|
+
obligation.capId,
|
|
184
|
+
obligation.obligationId,
|
|
185
|
+
SUI_TYPE,
|
|
186
|
+
ctokens.toString(),
|
|
187
|
+
tx as never,
|
|
188
|
+
)
|
|
189
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
190
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
191
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
192
|
+
signer: ctx.keypair,
|
|
193
|
+
transaction: tx,
|
|
194
|
+
options: { showEffects: true },
|
|
195
|
+
})
|
|
196
|
+
if (res.effects?.status?.status !== 'success')
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
200
|
+
}
|
|
201
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
data: {
|
|
205
|
+
protocol: 'suilend',
|
|
206
|
+
action: 'withdraw',
|
|
207
|
+
amountSui: args.amount,
|
|
208
|
+
digest: res.digest,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- suilend.borrow --------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
221
|
+
return {
|
|
222
|
+
name: 'suilend.borrow',
|
|
223
|
+
description:
|
|
224
|
+
'Borrow SUI from Suilend against supplied collateral (amount in underlying SUI). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
|
|
225
|
+
searchHint: 'suilend borrow loan leverage debt against collateral sui money market',
|
|
226
|
+
schema: AmountSchema,
|
|
227
|
+
handler: async args => {
|
|
228
|
+
const err = ensureMainnet(ctx)
|
|
229
|
+
if (err) return { ok: false, error: err }
|
|
230
|
+
const amountMist = suiToMist(args.amount)
|
|
231
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
232
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
233
|
+
const tooSmall = checkMinimum('borrow', amountMist)
|
|
234
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
235
|
+
if (ctx.policy) {
|
|
236
|
+
const verdict = evaluatePolicy(
|
|
237
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'borrow' },
|
|
238
|
+
ctx.policy,
|
|
239
|
+
)
|
|
240
|
+
if (!verdict.allowed)
|
|
241
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const suilend = await newSuilend(ctx)
|
|
245
|
+
const obligation = await findObligation(ctx)
|
|
246
|
+
if (!obligation)
|
|
247
|
+
return { ok: false, error: 'no Suilend position — supply collateral before borrowing' }
|
|
248
|
+
const tx = new Transaction()
|
|
249
|
+
tx.setSender(ctx.agentAddress)
|
|
250
|
+
// borrow() self-refreshes prices (addRefreshCalls default true).
|
|
251
|
+
await suilend.borrowAndSendToUser(
|
|
252
|
+
ctx.agentAddress,
|
|
253
|
+
obligation.capId,
|
|
254
|
+
obligation.obligationId,
|
|
255
|
+
SUI_TYPE,
|
|
256
|
+
amountMist.toString(),
|
|
257
|
+
tx as never,
|
|
258
|
+
)
|
|
259
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
260
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
261
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
262
|
+
signer: ctx.keypair,
|
|
263
|
+
transaction: tx,
|
|
264
|
+
options: { showEffects: true },
|
|
265
|
+
})
|
|
266
|
+
if (res.effects?.status?.status !== 'success')
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
270
|
+
}
|
|
271
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
data: {
|
|
275
|
+
protocol: 'suilend',
|
|
276
|
+
action: 'borrow',
|
|
277
|
+
amountSui: args.amount,
|
|
278
|
+
digest: res.digest,
|
|
279
|
+
policyEnforced: ctx.policy != null,
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- suilend.repay ---------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
292
|
+
return {
|
|
293
|
+
name: 'suilend.repay',
|
|
294
|
+
description:
|
|
295
|
+
'Repay borrowed SUI debt on Suilend (amount in underlying SUI). Simulated, then executed.',
|
|
296
|
+
searchHint: 'suilend repay pay back debt loan close sui money market',
|
|
297
|
+
schema: AmountSchema,
|
|
298
|
+
handler: async args => {
|
|
299
|
+
const err = ensureMainnet(ctx)
|
|
300
|
+
if (err) return { ok: false, error: err }
|
|
301
|
+
const amountMist = suiToMist(args.amount)
|
|
302
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
303
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
304
|
+
if (ctx.policy) {
|
|
305
|
+
const verdict = evaluatePolicy(
|
|
306
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'suilend' },
|
|
307
|
+
ctx.policy,
|
|
308
|
+
)
|
|
309
|
+
if (!verdict.allowed)
|
|
310
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const suilend = await newSuilend(ctx)
|
|
314
|
+
const obligation = await findObligation(ctx)
|
|
315
|
+
if (!obligation) return { ok: false, error: 'no Suilend position — nothing to repay' }
|
|
316
|
+
const tx = new Transaction()
|
|
317
|
+
tx.setSender(ctx.agentAddress)
|
|
318
|
+
await suilend.repayIntoObligation(
|
|
319
|
+
ctx.agentAddress,
|
|
320
|
+
obligation.obligationId,
|
|
321
|
+
SUI_TYPE,
|
|
322
|
+
amountMist.toString(),
|
|
323
|
+
tx as never,
|
|
324
|
+
)
|
|
325
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
326
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
327
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
328
|
+
signer: ctx.keypair,
|
|
329
|
+
transaction: tx,
|
|
330
|
+
options: { showEffects: true },
|
|
331
|
+
})
|
|
332
|
+
if (res.effects?.status?.status !== 'success')
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
336
|
+
}
|
|
337
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
data: {
|
|
341
|
+
protocol: 'suilend',
|
|
342
|
+
action: 'repay',
|
|
343
|
+
amountSui: args.amount,
|
|
344
|
+
digest: res.digest,
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- suilend.position (read) -----------------------------------------------
|
|
355
|
+
|
|
356
|
+
const PositionSchema = z.object({})
|
|
357
|
+
type PositionArgs = z.infer<typeof PositionSchema>
|
|
358
|
+
|
|
359
|
+
export function makeSuilendPosition(ctx: OnchainRuntimeContext): ToolDef<PositionArgs> {
|
|
360
|
+
return {
|
|
361
|
+
name: 'suilend.position',
|
|
362
|
+
description:
|
|
363
|
+
"The agent's Suilend position: deposits, borrows, and per-asset balances. Read-only.",
|
|
364
|
+
searchHint: 'suilend position portfolio deposits borrows collateral debt health',
|
|
365
|
+
schema: PositionSchema,
|
|
366
|
+
handler: async () => {
|
|
367
|
+
const err = ensureMainnet(ctx)
|
|
368
|
+
if (err) return { ok: false, error: err }
|
|
369
|
+
try {
|
|
370
|
+
const suilend = await newSuilend(ctx)
|
|
371
|
+
const obligation = await findObligation(ctx)
|
|
372
|
+
if (!obligation)
|
|
373
|
+
return {
|
|
374
|
+
ok: true,
|
|
375
|
+
data: { protocol: 'suilend', hasPosition: false, deposits: [], borrows: [] },
|
|
376
|
+
}
|
|
377
|
+
const parsed = (await suilend.getObligation(obligation.obligationId)) as {
|
|
378
|
+
deposits?: Array<{ coinType?: { name?: string }; depositedCtokenAmount?: bigint }>
|
|
379
|
+
borrows?: Array<{ coinType?: { name?: string }; borrowedAmount?: { value?: bigint } }>
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
data: {
|
|
384
|
+
protocol: 'suilend',
|
|
385
|
+
hasPosition: true,
|
|
386
|
+
obligationId: obligation.obligationId,
|
|
387
|
+
deposits: (parsed.deposits ?? []).map(d => ({
|
|
388
|
+
coin: d.coinType?.name,
|
|
389
|
+
ctokens: String(d.depositedCtokenAmount ?? 0n),
|
|
390
|
+
})),
|
|
391
|
+
borrows: (parsed.borrows ?? []).map(b => ({
|
|
392
|
+
coin: b.coinType?.name,
|
|
393
|
+
borrowed: String(b.borrowedAmount?.value ?? 0n),
|
|
394
|
+
})),
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Find the SUI reserve's cToken exchange rate (underlying per cToken) from an
|
|
405
|
+
* initializeSuilend() result, or null if not present. */
|
|
406
|
+
function suiReserveExchangeRate(data: unknown): number | null {
|
|
407
|
+
const d = data as {
|
|
408
|
+
reserveMap?: Record<string, { coinType?: string; cTokenExchangeRate?: unknown }>
|
|
409
|
+
}
|
|
410
|
+
const map = d.reserveMap
|
|
411
|
+
if (!map) return null
|
|
412
|
+
const reserve =
|
|
413
|
+
map[SUI_TYPE] ??
|
|
414
|
+
map[SUI_LONG] ??
|
|
415
|
+
Object.values(map).find(r => typeof r.coinType === 'string' && isSuiType(r.coinType))
|
|
416
|
+
if (!reserve) return null
|
|
417
|
+
const rate = Number(String(reserve.cTokenExchangeRate))
|
|
418
|
+
return Number.isFinite(rate) && rate > 0 ? rate : null
|
|
419
|
+
}
|
package/src/tools/swap.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { MetaAg } from '@7kprotocol/sdk-ts'
|
|
|
13
13
|
import { Transaction, coinWithBalance } from '@mysten/sui/transactions'
|
|
14
14
|
import type { ToolDef } from 'lyra-core'
|
|
15
15
|
import { z } from 'zod'
|
|
16
|
+
import { checkMinimum } from '../minimums'
|
|
16
17
|
import { evaluatePolicy } from '../policy'
|
|
17
18
|
import { simulate } from '../simulate'
|
|
18
19
|
import type { OnchainRuntimeContext } from '../types'
|
|
@@ -63,6 +64,11 @@ export function makeSwap(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
|
63
64
|
if (from.type === to.type) return { ok: false, error: 'from and to are the same coin' }
|
|
64
65
|
const amountIn = BigInt(Math.round(Number(args.amount) * 10 ** from.decimals))
|
|
65
66
|
if (amountIn <= 0n) return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
67
|
+
// Minimum only bounds SUI-denominated input (amountIn is then in MIST).
|
|
68
|
+
if (from.type === SUI_TYPE) {
|
|
69
|
+
const tooSmall = checkMinimum('swap', amountIn)
|
|
70
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
71
|
+
}
|
|
66
72
|
|
|
67
73
|
// Policy gate. The MIST per-tx cap is SUI-denominated, so it only bounds
|
|
68
74
|
// SUI-input swaps; the slippage/protocol/expiry checks always apply.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the DeFi tool surface: (1) the plugin registers every
|
|
3
|
+
* value-moving adapter, and (2) each new write tool runs its guard checks
|
|
4
|
+
* (mainnet-only, valid amount, minimum size) BEFORE it ever touches the
|
|
5
|
+
* network — so a dust/invalid/testnet request fails fast and deterministically,
|
|
6
|
+
* never a false success.
|
|
7
|
+
*
|
|
8
|
+
* These tests deliberately use a bare stub context: the guard branches return
|
|
9
|
+
* before any `client`/`keypair` access, so no RPC is needed. Live end-to-end
|
|
10
|
+
* PTB simulation lives in `onchain.integration.test.ts` (gated on a real key).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test'
|
|
14
|
+
import type { PluginContext, ToolDef } from 'lyra-core'
|
|
15
|
+
import plugin from '../index'
|
|
16
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
17
|
+
import { makeVoloStake } from './liquid-stake'
|
|
18
|
+
import { makeScallopBorrow, makeScallopRepay } from './scallop'
|
|
19
|
+
import { makeSuilendBorrow, makeSuilendSupply } from './suilend'
|
|
20
|
+
|
|
21
|
+
// A context whose network/amount guards resolve before any client access.
|
|
22
|
+
function stubCtx(network: 'mainnet' | 'testnet' = 'mainnet'): OnchainRuntimeContext {
|
|
23
|
+
return {
|
|
24
|
+
network,
|
|
25
|
+
agentAddress: '0x0000000000000000000000000000000000000000000000000000000000000abc',
|
|
26
|
+
agentDir: '/tmp/lyra-test',
|
|
27
|
+
// Accessing either of these would throw — guards must return first.
|
|
28
|
+
client: new Proxy(
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
get() {
|
|
32
|
+
throw new Error('network access before guard')
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
) as never,
|
|
36
|
+
keypair: {} as never,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('onchain plugin registration', () => {
|
|
41
|
+
test('registers the full DeFi write surface (lending + staking + swap)', () => {
|
|
42
|
+
const names: string[] = []
|
|
43
|
+
const ctx = {
|
|
44
|
+
registerTool: (def: ToolDef) => names.push(def.name),
|
|
45
|
+
registerListener: () => {},
|
|
46
|
+
addHook: () => {},
|
|
47
|
+
network: 'mainnet',
|
|
48
|
+
agentDir: '/tmp',
|
|
49
|
+
agentId: 'test',
|
|
50
|
+
// side-banded onchain runtime the plugin reads off PluginContext
|
|
51
|
+
onchain: stubCtx(),
|
|
52
|
+
} as unknown as PluginContext
|
|
53
|
+
plugin.register(ctx)
|
|
54
|
+
|
|
55
|
+
for (const expected of [
|
|
56
|
+
// lending: the three biggest Sui money markets, full CRUD
|
|
57
|
+
'scallop.supply',
|
|
58
|
+
'scallop.withdraw',
|
|
59
|
+
'scallop.borrow',
|
|
60
|
+
'scallop.repay',
|
|
61
|
+
'navi.supply',
|
|
62
|
+
'navi.borrow',
|
|
63
|
+
'navi.repay',
|
|
64
|
+
'suilend.supply',
|
|
65
|
+
'suilend.withdraw',
|
|
66
|
+
'suilend.borrow',
|
|
67
|
+
'suilend.repay',
|
|
68
|
+
'suilend.position',
|
|
69
|
+
// staking: native + Volo liquid staking
|
|
70
|
+
'sui.stake',
|
|
71
|
+
'sui.unstake',
|
|
72
|
+
'volo.stake',
|
|
73
|
+
'volo.unstake',
|
|
74
|
+
// swap aggregator
|
|
75
|
+
'swap',
|
|
76
|
+
]) {
|
|
77
|
+
expect(names).toContain(expected)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('registers nothing without an onchain runtime (safe no-op for loaders)', () => {
|
|
82
|
+
const names: string[] = []
|
|
83
|
+
const ctx = {
|
|
84
|
+
registerTool: (def: ToolDef) => names.push(def.name),
|
|
85
|
+
registerListener: () => {},
|
|
86
|
+
addHook: () => {},
|
|
87
|
+
network: 'mainnet',
|
|
88
|
+
agentDir: '/tmp',
|
|
89
|
+
agentId: 'test',
|
|
90
|
+
} as unknown as PluginContext
|
|
91
|
+
plugin.register(ctx)
|
|
92
|
+
expect(names).toHaveLength(0)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('write-tool guards (fail before network)', () => {
|
|
97
|
+
test('suilend.supply rejects a dust amount below the supply minimum', async () => {
|
|
98
|
+
const res = await makeSuilendSupply(stubCtx()).handler({ amount: '0.001' })
|
|
99
|
+
expect(res.ok).toBe(false)
|
|
100
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('suilend.borrow rejects a dust amount below the borrow minimum', async () => {
|
|
104
|
+
const res = await makeSuilendBorrow(stubCtx()).handler({ amount: '0.0001' })
|
|
105
|
+
expect(res.ok).toBe(false)
|
|
106
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('volo.stake rejects below the 1 SUI staking minimum', async () => {
|
|
110
|
+
const res = await makeVoloStake(stubCtx()).handler({ amount: '0.1' })
|
|
111
|
+
expect(res.ok).toBe(false)
|
|
112
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('scallop.borrow rejects a dust amount', async () => {
|
|
116
|
+
const res = await makeScallopBorrow(stubCtx()).handler({ amount: '0.001' })
|
|
117
|
+
expect(res.ok).toBe(false)
|
|
118
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('invalid amount strings are rejected before any network call', async () => {
|
|
122
|
+
const res = await makeVoloStake(stubCtx()).handler({ amount: 'not-a-number' })
|
|
123
|
+
expect(res.ok).toBe(false)
|
|
124
|
+
if (!res.ok) expect(res.error).toContain('invalid amount')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('mainnet-only tools refuse to run on testnet', async () => {
|
|
128
|
+
const supply = await makeSuilendSupply(stubCtx('testnet')).handler({ amount: '5' })
|
|
129
|
+
expect(supply.ok).toBe(false)
|
|
130
|
+
if (!supply.ok) expect(supply.error).toContain('mainnet only')
|
|
131
|
+
|
|
132
|
+
const stake = await makeVoloStake(stubCtx('testnet')).handler({ amount: '5' })
|
|
133
|
+
expect(stake.ok).toBe(false)
|
|
134
|
+
if (!stake.ok) expect(stake.error).toContain('mainnet only')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('scallop.repay is a registered, well-formed tool', () => {
|
|
138
|
+
const tool = makeScallopRepay(stubCtx())
|
|
139
|
+
expect(tool.name).toBe('scallop.repay')
|
|
140
|
+
expect(typeof tool.handler).toBe('function')
|
|
141
|
+
})
|
|
142
|
+
})
|